Skip to content

Commit f6ddb9f

Browse files
feat: WebGL shreds chart
1 parent ff72369 commit f6ddb9f

9 files changed

Lines changed: 955 additions & 105 deletions

File tree

src/api/entities.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,16 @@ export enum ShredEvent {
945945
shred_published = 6,
946946
}
947947

948+
export const MAX_SHRED_EVENT_IDX = Object.values(ShredEvent).reduce(
949+
(acc, v) => {
950+
if (typeof v === "number" && v > acc) {
951+
return v;
952+
}
953+
return acc;
954+
},
955+
-Infinity,
956+
);
957+
948958
export const liveShredsSchema = z.object({
949959
reference_slot: z.number(),
950960
reference_ts: z.coerce.bigint(),
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useLayoutEffect, useRef } from "react";
2+
3+
import { useMeasure, useRafLoop } from "react-use";
4+
import { Box, Flex } from "@radix-ui/themes";
5+
import type { FlexProps } from "@radix-ui/themes";
6+
import { useShredsChartScale } from "../useShredsChartScale";
7+
import { useSetAtom } from "jotai";
8+
import { minDirtySlotByChartAtom } from "../atoms";
9+
import type { RendererObj, TsRange } from "./chartUtils";
10+
import { setUpRenderer, draw } from "./chartUtils";
11+
import ShredsSlotLabels from "../ShredsSlotLabels";
12+
import { MChartAxes, xAxisHeight } from "./ChartAxes";
13+
import { createLabelsState, type LabelsState } from "../utils";
14+
15+
const REDRAW_INTERVAL_MS = 15;
16+
17+
type FlexPropsSubset = Pick<FlexProps, "height" | "minHeight" | "flexGrow">;
18+
19+
interface ShredsChartProps {
20+
chartId: string;
21+
}
22+
export default function ShredsChart({
23+
chartId,
24+
...flexProps
25+
}: ShredsChartProps & FlexPropsSubset) {
26+
const setMinDirtySlotByChart = useSetAtom(minDirtySlotByChartAtom);
27+
28+
const prevTimeDiffsRef = useRef<number[]>([]);
29+
const lastRedrawRef = useRef(0);
30+
const [measureRef, { width, height: fullHeight }] =
31+
useMeasure<HTMLDivElement>();
32+
const height = fullHeight - xAxisHeight;
33+
34+
const containerRef = useRef<HTMLDivElement>(null);
35+
const rendererRef = useRef<RendererObj | undefined>();
36+
const visibleTsRangeRef = useRef<TsRange | undefined>();
37+
const labelsRef = useRef<{
38+
prevLabels: LabelsState;
39+
tempNewLabels: LabelsState;
40+
}>({
41+
prevLabels: createLabelsState(),
42+
tempNewLabels: createLabelsState(),
43+
});
44+
45+
const scale = useShredsChartScale();
46+
47+
useLayoutEffect(() => {
48+
// setup dirty slot tracking
49+
setMinDirtySlotByChart((prev) => {
50+
// trigger draw of every slot
51+
prev.set(chartId, -Infinity);
52+
return prev;
53+
});
54+
55+
return () => {
56+
setMinDirtySlotByChart((prev) => {
57+
prev.delete(chartId);
58+
return prev;
59+
});
60+
61+
// dispose of WebGL resources
62+
const obj = rendererRef.current;
63+
if (!obj) return;
64+
65+
obj.renderer.dispose();
66+
for (const slotMesh of obj.meshes.values()) {
67+
slotMesh.mesh.geometry.dispose();
68+
}
69+
for (const slotMesh of obj.availableMeshes) {
70+
slotMesh.mesh.geometry.dispose();
71+
}
72+
rendererRef.current = undefined;
73+
};
74+
}, [chartId, setMinDirtySlotByChart]);
75+
76+
// handle chart resize
77+
useLayoutEffect(() => {
78+
if (!rendererRef.current) return;
79+
const { renderer } = rendererRef.current;
80+
renderer.setSize(width, height);
81+
draw(
82+
chartId,
83+
prevTimeDiffsRef,
84+
rendererRef.current,
85+
visibleTsRangeRef,
86+
labelsRef,
87+
scale,
88+
true /* force redraw */,
89+
[0, width],
90+
);
91+
}, [scale, width, height, chartId]);
92+
93+
useRafLoop(function drawShredsLoop(time: number) {
94+
if (
95+
lastRedrawRef.current == null ||
96+
time - lastRedrawRef.current >= REDRAW_INTERVAL_MS
97+
) {
98+
lastRedrawRef.current = time;
99+
if (!rendererRef.current) {
100+
const result = setUpRenderer(width, height);
101+
if (!result) return;
102+
rendererRef.current = result;
103+
containerRef.current?.replaceChildren(result.renderer.domElement);
104+
} else {
105+
draw(
106+
chartId,
107+
prevTimeDiffsRef,
108+
rendererRef.current,
109+
visibleTsRangeRef,
110+
labelsRef,
111+
scale,
112+
false,
113+
[0, width],
114+
);
115+
}
116+
}
117+
});
118+
119+
return (
120+
<Flex direction="column" gap="2px" {...flexProps}>
121+
<ShredsSlotLabels />
122+
<Box flexGrow="1" minHeight="0" position="relative" ref={measureRef}>
123+
<MChartAxes
124+
chartId={`${chartId}-axes`}
125+
scale={scale}
126+
containerWidth={width}
127+
containerHeight={fullHeight + 1}
128+
/>
129+
<Box ref={containerRef} position="relative" style={{ zIndex: 1 }} />
130+
</Box>
131+
</Flex>
132+
);
133+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Box } from "@radix-ui/themes";
2+
import { xRangeMs } from "../../../../api/worker/cache/shreds/shredsCalc";
3+
import { useRef, useCallback, useMemo, useEffect, memo } from "react";
4+
import type { AlignedData } from "uplot";
5+
import {
6+
chartAxisColor,
7+
gridLineColor,
8+
gridTicksColor,
9+
} from "../../../../colors";
10+
import UplotReact from "../../../../uplotReact/UplotReact";
11+
import { shredsXScaleKey } from "../shredsProgressionPlugin";
12+
import { getXIncrs, chartXPadding } from "../utils";
13+
14+
export const xAxisHeight = 30;
15+
16+
interface ChartAxesProps {
17+
chartId: string;
18+
scale: number;
19+
containerWidth: number;
20+
containerHeight: number;
21+
}
22+
23+
export const MChartAxes = memo(function ChartAxes({
24+
chartId,
25+
scale,
26+
containerWidth,
27+
containerHeight,
28+
}: ChartAxesProps) {
29+
const width = containerWidth + 2 * chartXPadding;
30+
const uplotRef = useRef<uPlot>();
31+
32+
const handleCreate = useCallback((u: uPlot) => {
33+
uplotRef.current = u;
34+
}, []);
35+
36+
const [chartData, xIncrs] = useMemo(() => {
37+
return [
38+
[[Math.trunc(scale * -xRangeMs), 0], new Array(2)] satisfies AlignedData,
39+
getXIncrs(scale),
40+
];
41+
}, [scale]);
42+
43+
useEffect(() => {
44+
if (!uplotRef.current) return;
45+
uplotRef.current.axes[0].incrs = () => xIncrs;
46+
uplotRef.current.setData(chartData, true);
47+
}, [chartData, xIncrs]);
48+
49+
const options = useMemo<uPlot.Options>(() => {
50+
return {
51+
padding: [0, chartXPadding, 0, chartXPadding],
52+
width: 0,
53+
height: 0,
54+
scales: {
55+
[shredsXScaleKey]: { time: false },
56+
y: {
57+
time: false,
58+
range: [0, 1],
59+
},
60+
},
61+
series: [{ scale: shredsXScaleKey }, {}],
62+
cursor: {
63+
show: false,
64+
drag: {
65+
// disable zoom
66+
[shredsXScaleKey]: false,
67+
y: false,
68+
},
69+
},
70+
legend: { show: false },
71+
axes: [
72+
{
73+
scale: shredsXScaleKey,
74+
incrs: xIncrs,
75+
size: xAxisHeight,
76+
ticks: {
77+
opacity: 0.2,
78+
stroke: chartAxisColor,
79+
size: 5,
80+
width: 1 / devicePixelRatio,
81+
},
82+
values: (_, ticks) =>
83+
// special label for right-most tick
84+
ticks.map((val) =>
85+
val === 0 ? "now" : `${(val / 1_000).toFixed(1)}s`,
86+
),
87+
grid: {
88+
stroke: gridLineColor,
89+
width: 1 / devicePixelRatio,
90+
},
91+
stroke: gridTicksColor,
92+
},
93+
{
94+
size: 0,
95+
grid: {
96+
filter: () => [0],
97+
stroke: gridTicksColor,
98+
width: 1,
99+
},
100+
},
101+
],
102+
};
103+
}, [xIncrs]);
104+
105+
options.width = width;
106+
options.height = containerHeight;
107+
108+
return (
109+
<Box
110+
position="absolute"
111+
left={`-${chartXPadding}px`}
112+
right={`-${chartXPadding}px`}
113+
top="0"
114+
bottom="0"
115+
style={{ zIndex: 0 }}
116+
>
117+
<UplotReact
118+
id={chartId}
119+
options={options}
120+
data={chartData}
121+
onCreate={handleCreate}
122+
setSizeDebounceMs={0}
123+
/>
124+
</Box>
125+
);
126+
});

0 commit comments

Comments
 (0)