EpisodeLineChart
A single time-series plot rendered into a 100×100 SVG viewBox. Each series
is two parallel arrays — x (time in seconds, sorted ascending) and y
(value at that time) — plus stroke styling. Adjacent samples are connected
with a straight line. The tooltip header reports the cursor's exact
time — an interpolated x position between samples, freely landing
anywhere along the chart. Per-channel y values are the nearest x
sample to that cursor (no interpolation), so each value the user reads is
always a real recorded number rather than a synthesized one.
Like EpisodeTimeline and
EpisodeVideoStack, the cursor is
hover-driven and shared across surfaces via three controlled props:
time, onHover/onHoverEnd, and isScrubOwner. Hovering any chart in
a group moves the cursor on every peer, and every chart in the group
shows its own tooltip at the shared time — the readout pattern is
"compare values at one moment across N synced charts." Only the owner
draws the bright accent hairline; peers drop to a muted 22%-ink hairline
so the active chart still stands out.
Basic
A single chart driving its own cursor. isScrubOwner defaults to true,
so the bright accent hairline + tooltip appear whenever the pointer is over
the plot.
Multi-chart sync
The canonical Episode-view bottom row: four plots sharing one
time + ownerId state, with the right-arm chart in disabled / NO SIGNAL
mode. Hovering any chart updates the cursor on every other, and every
non-disabled chart renders its own tooltip at the same t so you can
compare per-channel readings across plots at a glance.
Synced with EpisodeTimeline + EpisodeVideoStack
The full Episode-view layout — video stack + timeline + four time-series plots, all driven by one shared cursor.
Cursor sync protocol
All three surfaces — EpisodeLineChart, EpisodeTimeline, EpisodeVideoStack —
accept the same time + onHover + onHoverEnd triple, so a single
useState<number | null> drives them all. isScrubOwner is the
EpisodeLineChart-specific knob that decides which chart in a group renders the
bright hairline + tooltip; peer charts render a muted hairline at the same
time with no tooltip:
Every chart with non-null time renders its tooltip, regardless of
isScrubOwner. Owner-only effects: the bright accent hairline, and the
tooltip's vertical anchor following the pointer Y (peers anchor at
chart-centre).
EpisodeTimeline and EpisodeVideoStack plug into the same hover state
without isScrubOwner — the timeline auto-derives its tone from its own
internal hover, and the stack uses activeId for the focused tile. The
bind('timeline') flow simply marks the timeline as the cursor owner so
every EpisodeLineChart in the group drops to the muted hairline while the user
scrubs on the timeline.
Series shape
Each EpisodeLineChartSeries carries two parallel arrays plus stroke styling:
The sample density is the caller's choice — the chart draws a polyline
through every point, so a 60-sample series is rougher than a 600-sample
one but both work. Non-finite y values (NaN / Infinity) break the line:
the polyline stops at the last good sample and restarts at the next one,
so a gap in the recording reads as a gap on screen.
The tooltip looks up the nearest x sample to the cursor and displays
that sample's actual y — no interpolation. x must be sorted ascending
for the lookup to work.
Palette
A six-hue oklch palette is exported as EPISODE_LINE_CHART_PALETTE. The hues are
hardcoded — the library doesn't ship CSS tokens for them, and the values
don't theme-flip (chart series colours stay legible against both light and
dark backgrounds at these lightness levels).
| Token | Value |
|---|---|
EPISODE_LINE_CHART_PALETTE.blue | oklch(60% 0.13 235) |
EPISODE_LINE_CHART_PALETTE.yellow | oklch(80% 0.15 95) |
EPISODE_LINE_CHART_PALETTE.green | oklch(66% 0.12 150) |
EPISODE_LINE_CHART_PALETTE.purple | oklch(58% 0.13 295) |
EPISODE_LINE_CHART_FAILURE | oklch(58% 0.17 25) |
These are the design system's values, all written in oklch so the five can be
compared by eye. green reaches us from the design as the hex #57A76A; the
conversion costs one 1/255 step on the red channel (rgb(86,167,106) against
rgb(87,167,106)), which is imperceptible and worth less than a table where one
row speaks a different language than the rest. The source hex is recorded beside
the value in utils.ts.
The palette holds no orange, and no red. A signal trace carries no severity, and
a warm line beside a live cursor reads as an alarm state the data never claimed.
Red exists as a separate export, EPISODE_LINE_CHART_FAILURE, for the one case
that genuinely is a severity.
EPISODE_LINE_CHART_FAILURE sits outside EPISODE_LINE_CHART_PALETTE on
purpose. Charts that cycle Object.values(EPISODE_LINE_CHART_PALETTE) assign
by index for an arbitrary series count, and a status colour a cycling caller
can reach stops being a status colour — an ordinary fifth channel would turn
red and read as a fault. Reach for it when something failed, and ship it with a
label or an icon, never as colour alone.
Measured, for the record: adjacent pairs clear colour-blind separation
comfortably — the worst is yellow↔green at ΔE 12.5 under protanopia and 12.8
under tritanopia, against a target of 8. Contrast is the softer spot. yellow
sits above the light-mode lightness band (L 0.80) and reaches only 1.8:1 on the
light surface; green and purple land at 2.8–3.0:1. All are under the 3:1
target for a mark, so they hold up here only because every surface that draws
them also labels them — the band track's value→colour legend, the chart's
cursor readout. A new surface using these hues owes the reader that label; it
can't lean on colour alone.
For "ghost" / target traces, pass color: 'currentColor' and opacity: 0.5
so the dashed line inherits the chart's ink-tinted foreground and matches
in both themes.
Tooltip behaviour
The tooltip flips against the chart's own midline:
- Cursor in the left half → tooltip on the right of the hairline.
- Cursor in the right half → tooltip on the left of the hairline.
Vertically, the owner chart's tooltip follows the pointer Y and flips above the cursor when it crosses 55% down the chart. Peer charts have no live pointer Y, so their tooltip anchors at the chart's vertical centre.
The tooltip floats above any clipping ancestor and follows the chart on scroll, so a user hovering a chart while scrolling the page sees the tooltip glide with the hairline. The tooltip is not clamped to the viewport — when scrolling pushes part of it off-screen, that's accepted rather than letting the tooltip jump unpredictably to stay visible.
Disabled state
disabled renders the chart with a hatched canvas and a centred "NO
SIGNAL" label. No pointer events, no tooltip. The chart still occupies
its grid cell so the surrounding layout doesn't reflow when a sensor
drops out, and the hatch tone follows the chart's text color in both
themes.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
series | EpisodeLineChartSeries[] | — | Channel definitions, drawn in array order. |
duration | number | — | Total time span in seconds. Must be > 0. |
title | string | — | Title in the card's own band above the plot (UI weight). Dims to muted ink when disabled. |
caption | string | — | Uppercase mono eyebrow above the title. |
yRange | [number, number] | auto-fit | Y-axis domain. Computed from sampled series + 5% padding when omitted. |
time | number | null | null | Shared cursor time in seconds. null hides the cursor. |
onHover | (t: number) => void | — | Fires continuously on hover with the time at the cursor. |
onHoverEnd | () => void | — | Fires when the cursor leaves. |
onSeek | (t: number) => void | — | Fires on click with the time at the cursor — the commit gesture (hover previews, click sets the shared playhead). |
isScrubOwner | boolean | true | When true, the chart draws the bright accent hairline and its tooltip follows the pointer Y. When false, the hairline is a muted 22%-ink stroke and the tooltip anchors at chart-centre. Both states still render their tooltip whenever time is non-null. |
unit | string | derived | Physical unit shown as a (°) parenthetical in the y-axis gutter. Derived from the first readout series' formatValue when omitted — a formatter emitting "45.0 °" yields °. Pass '' to suppress it. |
formatTick | (v: number) => string | derived | Y-axis tick label. Defaults to the first readout series' formatValue with the trailing unit stripped (the unit is hoisted into the header rather than repeated on all five ticks), falling back to v.toFixed(2). |
unitHint | string | — | Sub-label under the tooltip's time header — explains units. |
showMidline | boolean | true | Draw the centre y gridline. The v=0 (or range-centre) reference is now the middle line of the y-axis grid rather than a separate stroke on top of it, so false omits that one gridline; its tick label stays. |
disabled | boolean | false | Render hatched canvas + "NO SIGNAL"; no interaction. |
className | string | — | Extra classes on the wrapper. |
EpisodeLineChartSeries
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable identifier; React key + tooltip readout row key. |
label | string | — | Legend label shown in the tooltip readout row. Series with no label are drawn on the chart but omitted from the tooltip. |
data | { x: number[]; y: number[] } | — | Sample points. x is time in seconds, sorted ascending; y is the value at each x. Arrays must be the same length. Adjacent samples are connected with a straight line; NaN y values break the line. |
color | string | — | Stroke color. Any CSS color string; prefer EPISODE_LINE_CHART_PALETTE.* for parity across charts. |
width | number | 1.4 | Stroke width in viewBox units (the viewBox is 100×100, so 1.4 ≈ 1.4px when the chart fills 100px wide). |
dash | string | — | SVG stroke-dasharray pattern, e.g. '3 2.4'. Solid when omitted. |
opacity | number | 1 | Stroke opacity 0..1. |
linecap | 'butt' | 'round' | 'square' | 'butt' | SVG stroke-linecap. |
readout | boolean | true | Include this series in the tooltip readout. Set false for decorative / per-axis traces that would crowd the legend (e.g. y / z axes when only fx is the headline reading). |
ghost | boolean | false | Marks the series as a target / reference trace — dims the readout row, suffixes the label with "tgt", and renders the swatch at width 1 instead of 1.6. |
formatValue | (v: number) => string | v => v.toFixed(2) | Tooltip cell text. Use this to map a normalized signal back to physical units (e.g. v => (v * 90).toFixed(1) + '°'). |