1import { createEffect, createSignal, Index, onCleanup, onMount, Show } from "solid-js";
2import uPlot from "uplot";
3import type { Series } from "../types/model.ts";
4import { bytes, clock, rate } from "../format.ts";
5
6/** Card chart height; a legend takes its bottom 26px. */
7const HEIGHT = 196;
8const minute = (t: number) => new Date(t * 1000).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
9const day = (t: number) => new Date(t * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" });
10/** Byte axes step by powers of two (4 GiB, 8 GiB), matching the binary units they're labelled in. */
11const BINARY = Array.from({ length: 60 }, (_, i) => 2 ** i);
12/** Synced charts share one y-axis width so their times line up. */
13const GUTTER = 56;
14/** Line height of an inline value; closer values are pushed apart. */
15const MARK = 16;
16const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
17/** Canvas can't resolve CSS variables, so `var(--x)` is read from the current theme. */
18const resolve = (color: string) => (color.startsWith("var(") ? token(color.slice(4, -1)) : color);
19
20export interface ChartProps {
21 series: Series[];
22 format: (value: number) => string;
23 /** Colors per series, hex or `var(--…)`; defaults to categorical slots in order. */
24 colors?: string[];
25 stacked?: boolean;
26 max?: number;
27 /** Total height in px, legend included; defaults to the card chart's. */
28 height?: number;
29 /** Defaults to showing when there is more than one series. */
30 legend?: boolean;
31 /** The hovered sample's index, null once the pointer leaves. */
32 onHover?: (index: number | null) => void;
33 /** Charts sharing a key share one crosshair. */
34 sync?: string;
35 /** Values float in the plot, beside the crosshair or at the right edge, instead of a legend below. */
36 inline?: boolean;
37 /** False keeps the time grid but drops its labels, for a chart stacked above one that shows them. */
38 timeAxis?: boolean;
39 /** False fits the y range to the data instead of starting at zero. */
40 zero?: boolean;
41}
42
43interface Mark {
44 name: string;
45 value: string;
46 color: string;
47 y: number;
48}
49
50export function TimeChart(props: ChartProps) {
51 let root!: HTMLDivElement;
52 let plot: uPlot | undefined;
53 let shape = "";
54 let raw: (number | null)[][] = [];
55 let keys: string[] = [];
56 const [view, setView] = createSignal<{ marks: Mark[]; x: number | null; flip: boolean; t: number | null }>(
57 { marks: [], x: null, flip: false, t: null });
58 const scheme = matchMedia("(prefers-color-scheme: dark)");
59 const [dark, setDark] = createSignal(scheme.matches);
60
61 /** Floats each series' value at its line, or its band's middle, for the hovered sample or else the latest. */
62 function place(u: uPlot) {
63 if (!props.inline) return;
64 const index = u.cursor.idx ?? null;
65 const height = u.over.clientHeight;
66 // At rest the frame shows the latest values, so the plot only names the lines, and a lone line needs no naming.
67 const found = index === null && raw.length === 1 ? [] : raw.flatMap((values, i) => {
68 const j = index ?? values.findLastIndex((v) => v != null);
69 const value = values[j];
70 if (value == null) return [];
71 const top = u.data[i + 1]![j]!;
72 const middle = props.stacked ? (top + (i ? u.data[i]![j] ?? 0 : 0)) / 2 : top;
73 const label = index === null ? "" : props.format(value);
74 return [{ value, mark: { name: String(u.series[i + 1]!.label), value: label, color: keys[i]!, y: u.valToPos(middle, "y") } }];
75 });
76 // The biggest values win the room there is, then overlapping ones are pushed apart and back inside.
77 const marks = found.sort((a, b) => b.value - a.value).slice(0, Math.floor(height / MARK))
78 .map((item) => item.mark).sort((a, b) => a.y - b.y);
79 marks.forEach((mark, i) => (mark.y = Math.max(mark.y, i ? marks[i - 1]!.y + MARK : MARK / 2)));
80 for (let i = marks.length - 1; i >= 0; i--) marks[i]!.y = Math.min(marks[i]!.y, (marks[i + 1]?.y ?? height + MARK / 2) - MARK);
81 const t = index === null ? null : u.data[0][index]!;
82 const x = t === null ? null : u.valToPos(t, "x");
83 setView({ marks, x, flip: x !== null && x > u.over.clientWidth - 170, t });
84 }
85
86 function build(series: Series[], colors: string[], data: uPlot.AlignedData) {
87 const legend = !props.inline && (props.legend ?? series.length > 1);
88 const axis = {
89 stroke: token("--muted"),
90 grid: { stroke: token("--grid"), width: 1 },
91 ticks: { show: false },
92 font: `11px ${token("--sans")}`,
93 };
94 const hex = (color: string, alpha: string) => (color.startsWith("#") ? color + alpha : color);
95 // uPlot also calls fill for the legend marker, before the plot box exists.
96 const fade = (color: string) => (u: uPlot) => {
97 if (!u.bbox.height) return color;
98 const gradient = u.ctx.createLinearGradient(0, u.bbox.top, 0, u.bbox.top + u.bbox.height);
99 gradient.addColorStop(0, hex(color, props.inline ? "66" : "38"));
100 gradient.addColorStop(1, hex(color, props.inline ? "0c" : "00"));
101 return gradient;
102 };
103 const smooth = uPlot.paths.spline!();
104 // Read now, since uPlot calls back outside Solid's tracking; a change to either rebuilds the chart.
105 const { max: top, zero } = props;
106 return new uPlot({
107 width: root.clientWidth,
108 height: (props.height ?? HEIGHT) - (legend ? 26 : 0),
109 cursor: { points: { size: 8 }, drag: { x: false, y: false }, sync: props.sync ? { key: props.sync } : undefined },
110 // Fixed edges: synced charts must line up, and uPlot otherwise pads each for its own last time label.
111 padding: props.sync ? [7, 24, props.timeAxis === false ? 7 : null, null] : undefined,
112 legend: { show: legend, live: true },
113 hooks: {
114 setCursor: [(u) => props.onHover?.(u.cursor.idx ?? null), place],
115 draw: [place],
116 },
117 scales: {
118 y: {
119 range: (_u, min, max) => zero === false
120 ? [min - (max - min) * 0.1 - 1, max + (max - min) * 0.1 + 1]
121 : [0, top ?? (max > 0 ? max * 1.1 : 1)],
122 },
123 },
124 axes: [
125 {
126 ...axis,
127 size: props.timeAxis === false ? 0 : 24,
128 values: (u, splits) => splits.map((t) => props.timeAxis === false ? ""
129 : u.scales.x!.max! - u.scales.x!.min! > 2 * 86400 ? day(t) : minute(t)),
130 },
131 {
132 ...axis,
133 incrs: props.format === bytes || props.format === rate ? BINARY : undefined,
134 // A round tick reads "4 GiB", not "4.0 GiB".
135 values: (_u, values) => values.map((v) => props.format(v).replace(/\.0(?=\D|$)/, "")),
136 space: props.inline ? 28 : undefined,
137 size: props.sync ? GUTTER : (u, values) => {
138 // uPlot caches the canvas font it last set, so a changed one must go back or later redraws inherit it.
139 const font = u.ctx.font;
140 u.ctx.font = axis.font;
141 const width = Math.max(0, ...(values ?? []).map((v) => u.ctx.measureText(v).width));
142 u.ctx.font = font;
143 return Math.ceil(width) + 10;
144 },
145 },
146 ],
147 series: [
148 { value: (_u, t) => (t == null ? "" : clock(t)) },
149 ...series.map((item, i) => ({
150 label: item.name,
151 stroke: colors[i],
152 width: props.stacked ? 1 : 2,
153 fill: props.stacked ? hex(colors[i]!, "55") : series.length === 1 ? fade(colors[i]!) : undefined,
154 paths: smooth,
155 points: { show: false },
156 value: (_u: uPlot, _v: number | null, _si: number, index: number | null) => {
157 const value = index == null ? raw[i]!.at(-1) : raw[i]![index];
158 return value == null ? "–" : props.format(value);
159 },
160 })),
161 ],
162 bands: props.stacked ? series.slice(1).map((_, i) => ({ series: [i + 2, i + 1] as [number, number] })) : [],
163 }, data, root);
164 }
165
166 const values = (
167 <div class="values" classList={{ hover: view().x !== null, flip: view().flip }}
168 style={{ left: view().x === null ? undefined : `${view().x}px` }}>
169 <Index each={view().marks}>
170 {(mark) => (
171 <span style={{ top: `${mark().y}px`, "--key": mark().color }}>
172 <Show when={mark().value}><b>{mark().value}</b></Show>{mark().name}
173 </span>
174 )}
175 </Index>
176 <Show when={props.timeAxis !== false && view().t}>
177 {(t) => <time>{props.series[0]!.t.at(-1)! - props.series[0]!.t[0]! > 86400 ? `${day(t())}, ${minute(t())}` : minute(t())}</time>}
178 </Show>
179 </div>
180 ) as HTMLDivElement;
181
182 // New data only calls setData, which keeps the hover; a new set of series, colors, or theme rebuilds.
183 createEffect(() => {
184 dark();
185 const series = props.series;
186 const colors = (props.colors ?? series.map((_, i) => `var(--series-${i + 1})`)).map(resolve);
187 raw = series.map((item) => item.v);
188 keys = colors;
189 const shown = props.stacked
190 ? raw.map((_, i) => raw[0]!.map((_, j) => raw.slice(0, i + 1).reduce((sum, values) => sum + (values[j] ?? 0), 0)))
191 : raw;
192 const data = (series.length ? [series[0]!.t, ...shown] : []) as uPlot.AlignedData;
193 const next = JSON.stringify([series.map((item) => item.name), colors, props.stacked, props.height, props.legend, props.max,
194 props.inline, props.timeAxis, props.zero, props.sync, dark()]);
195 if (plot && next === shape) return plot.setData(data);
196 // Holds the old chart's height until uPlot sizes the new one, a microtask later, so a layout in between can't
197 // shrink the page and clamp its scroll (WebKit does, Chrome waits).
198 root.style.minHeight = `${root.offsetHeight}px`;
199 requestAnimationFrame(() => (root.style.minHeight = ""));
200 plot?.destroy();
201 plot = series.length ? build(series, colors, data) : undefined;
202 if (plot && props.inline) plot.over.append(values);
203 shape = next;
204 });
205
206 onMount(() => {
207 const observer = new ResizeObserver(() => plot?.setSize({ width: root.clientWidth, height: plot.height }));
208 observer.observe(root);
209 const change = () => setDark(scheme.matches);
210 scheme.addEventListener("change", change);
211 onCleanup(() => {
212 observer.disconnect();
213 scheme.removeEventListener("change", change);
214 plot?.destroy();
215 });
216 });
217
218 return <div class="chart" ref={root} />;
219}