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.

signal · jointsLeft arm

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.

signal · jointsLeft arm
signal · temperatureLeft arm motors
signal · jointsRight arm
NO SIGNAL
signal · tactileTouch

Synced with EpisodeTimeline + EpisodeVideoStack

The full Episode-view layout — video stack + timeline + four time-series plots, all driven by one shared cursor.

REC · 00:00.000
front/camera/front/image_compressed
REC · 00:00.000
wrist/camera/wrist/image_compressed
REC · 00:00.000
top/camera/top/image_compressed
1.00×
signal · jointsLeft arm
signal · temperatureLeft arm motors
signal · jointsRight arm
NO SIGNAL
signal · tactileTouch

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:

tsx
const [hover, setHover] = useState<number | null>(null)
const [ownerId, setOwnerId] = useState<string | null>(null)

const bind = (id: string) => ({
  time: hover,
  isScrubOwner: ownerId === id,
  onHover: (t: number) => { setHover(t); setOwnerId(id) },
  onHoverEnd: () => { setHover(null); setOwnerId(null) },
})

<EpisodeLineChart {...bind('joints')} series={joints} duration={d} ... />
<EpisodeLineChart {...bind('temp')}   series={temp}   duration={d} ... />

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:

ts
interface EpisodeLineChartSeries {
  id: string
  label?: string                            // shown in the tooltip readout row
  data: { x: number[]; y: number[] }        // x = seconds, sorted ascending; y = value
  color: string                             // any CSS color — EPISODE_LINE_CHART_PALETTE.* recommended
  width?: number                            // viewBox units; default 1.4
  dash?: string                             // e.g. '3 2.4'
  opacity?: number                          // 0..1
  linecap?: 'butt' | 'round' | 'square'
  readout?: boolean                         // include in tooltip; default true
  ghost?: boolean                           // target / reference trace — dimmed row, "tgt" label suffix
  formatValue?: (v: number) => string       // tooltip cell text; default v.toFixed(2)
}

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).

TokenValue
EPISODE_LINE_CHART_PALETTE.blueoklch(60% 0.13 235)
EPISODE_LINE_CHART_PALETTE.orangeoklch(63% 0.13 50)
EPISODE_LINE_CHART_PALETTE.greenoklch(58% 0.12 150)
EPISODE_LINE_CHART_PALETTE.purpleoklch(58% 0.13 295)
EPISODE_LINE_CHART_PALETTE.redoklch(58% 0.17 25)
EPISODE_LINE_CHART_PALETTE.tealoklch(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

PropTypeDefaultDescription
seriesEpisodeLineChartSeries[]Channel definitions, drawn in array order.
durationnumberTotal time span in seconds. Must be > 0.
titlestringTop-left title (UI weight).
captionstringUppercase mono eyebrow above the title.
yRange[number, number]auto-fitY-axis domain. Computed from sampled series + 5% padding when omitted.
timenumber | nullnullShared cursor time in seconds. null hides the cursor.
onHover(t: number) => voidFires continuously on hover with the time at the cursor.
onHoverEnd() => voidFires when the cursor leaves.
isScrubOwnerbooleantrueWhen 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.
unitHintstringSub-label under the tooltip's time header — explains units.
showMidlinebooleantrueRender the y-axis midline reference.
disabledbooleanfalseRender hatched canvas + "NO SIGNAL"; no interaction.
classNamestringExtra classes on the wrapper.

EpisodeLineChartSeries

FieldTypeDefaultDescription
idstringStable identifier; React key + tooltip readout row key.
labelstringLegend 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.
colorstringStroke color. Any CSS color string; prefer EPISODE_LINE_CHART_PALETTE.* for parity across charts.
widthnumber1.4Stroke width in viewBox units (the viewBox is 100×100, so 1.4 ≈ 1.4px when the chart fills 100px wide).
dashstringSVG stroke-dasharray pattern, e.g. '3 2.4'. Solid when omitted.
opacitynumber1Stroke opacity 0..1.
linecap'butt' | 'round' | 'square''butt'SVG stroke-linecap.
readoutbooleantrueInclude 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).
ghostbooleanfalseMarks 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) => stringv => v.toFixed(2)Tooltip cell text. Use this to map a normalized signal back to physical units (e.g. v => (v * 90).toFixed(1) + '°').