1import { createEffect, createMemo, on } from "solid-js";
2import { LIVE_INTERVAL } from "../types/model.ts";
3
4const W = 44;
5const H = 14;
6/** Samples averaged into each point, aligned to the stream's sample count so a finished point never moves. */
7const PER_POINT = 7;
8const GAP = 2;
9const reduced = matchMedia("(prefers-reduced-motion: reduce)");
10
11/** Rounds up to 1, 2, or 5 times a power of ten, so the scale only changes on real swings. */
12function nice(value: number) {
13 const power = 10 ** Math.floor(Math.log10(value));
14 return [1, 2, 5, 10].map((m) => m * power).find((step) => step >= value)!;
15}
16
17/**
18 * A live trail that scrolls continuously between ticks. `values` are the latest samples and `count` is how many the
19 * stream has produced; the scale is at least `max`.
20 */
21export function Spark(props: { values: number[]; count: number; max: number; color: string; label: string }) {
22 let track!: SVGGElement;
23 const shape = createMemo(() => {
24 const { values, count } = props;
25 const first = count - values.length;
26 const base = Math.floor(first / PER_POINT);
27 const means: number[] = [];
28 for (let bucket = base; values.length && bucket * PER_POINT < count; bucket++) {
29 const slice = values.slice(Math.max(0, bucket * PER_POINT - first), (bucket + 1) * PER_POINT - first);
30 means.push(slice.reduce((sum, v) => sum + v, 0) / slice.length);
31 }
32 const peak = Math.max(0, ...means);
33 const top = peak > props.max ? nice(peak) : props.max;
34 const points = means.map((v, i) => [(i + 0.5) * GAP, H - 1 - Math.min(1, v / top) * (H - 2)] as const);
35 let line = points.length ? `M${points[0]}` : "";
36 for (let i = 1; i < points.length - 1; i++) {
37 const [x, y] = points[i]!;
38 const [nx, ny] = points[i + 1]!;
39 line += `Q${x},${y} ${(x + nx) / 2},${(y + ny) / 2}`;
40 }
41 if (points.length > 1) line += `L${points.at(-1)}`;
42 const area = points.length > 1 ? `${line}L${points.at(-1)![0]},${H}L${points[0]![0]},${H}Z` : "";
43 // Where the view's left edge sits for a given sample count, in this path's coordinates.
44 const offset = (samples: number) => `translateX(${W - (samples / PER_POINT - base) * GAP}px)`;
45 return { line, area, from: offset(count - 1), to: offset(count) };
46 });
47
48 createEffect(on(shape, ({ from, to }) => {
49 if (reduced.matches) track.style.transform = to;
50 else track.animate([{ transform: from }, { transform: to }], { duration: LIVE_INTERVAL * 1000, fill: "forwards" });
51 }));
52
53 return (
54 <svg class="spark" width={W} height={H} viewBox={`0 0 ${W} ${H}`} role="img" aria-label={props.label} data-tip={props.label}>
55 <g ref={track}>
56 <path d={shape().area} fill={props.color} opacity="0.25" />
57 <path d={shape().line} fill="none" stroke={props.color} stroke-width="1.25" stroke-linejoin="round" />
58 </g>
59 </svg>
60 );
61}