| 1 | import { createEffect, createMemo, on } from "solid-js"; |
| 2 | import { LIVE_INTERVAL } from "../types/model.ts"; |
| 3 | |
| 4 | const W = 44; |
| 5 | const H = 14; |
| 6 | /** Samples averaged into each point, aligned to the stream's sample count so a finished point never moves. */ |
| 7 | const PER_POINT = 7; |
| 8 | const GAP = 2; |
| 9 | const 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. */ |
| 12 | function 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 | */ |
| 21 | export 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 | } |