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.orange | oklch(63% 0.13 50) |
EPISODE_LINE_CHART_PALETTE.green | oklch(58% 0.12 150) |
EPISODE_LINE_CHART_PALETTE.purple | oklch(58% 0.13 295) |
EPISODE_LINE_CHART_PALETTE.red | oklch(58% 0.17 25) |
EPISODE_LINE_CHART_PALETTE.teal | oklch(60% 0.10 200) |
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 | — | Top-left title (UI weight). |
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. |
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. |
unitHint | string | — | Sub-label under the tooltip's time header — explains units. |
showMidline | boolean | true | Render the y-axis midline reference. |
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) + '°'). |