| 1 | import { createEffect, createSignal, Index, onCleanup, onMount, Show } from "solid-js"; |
| 2 | import uPlot from "uplot"; |
| 3 | import type { Series } from "../types/model.ts"; |
| 4 | import { bytes, clock, rate } from "../format.ts"; |
| 5 | |
| 6 | /** Card chart height; a legend takes its bottom 26px. */ |
| 7 | const HEIGHT = 196; |
| 8 | const minute = (t: number) => new Date(t * 1000).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); |
| 9 | const 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. */ |
| 11 | const BINARY = Array.from({ length: 60 }, (_, i) => 2 ** i); |
| 12 | /** Synced charts share one y-axis width so their times line up. */ |
| 13 | const GUTTER = 56; |
| 14 | /** Line height of an inline value; closer values are pushed apart. */ |
| 15 | const MARK = 16; |
| 16 | const 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. */ |
| 18 | const resolve = (color: string) => (color.startsWith("var(") ? token(color.slice(4, -1)) : color); |
| 19 | |
| 20 | export 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 | |
| 43 | interface Mark { |
| 44 | name: string; |
| 45 | value: string; |
| 46 | color: string; |
| 47 | y: number; |
| 48 | } |
| 49 | |
| 50 | export 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 | } |