EpisodeTimeline
A zoomable episode-detail timeline: time ruler on top, a frame thumbnail strip
beneath it, and N rows of labelled track blocks under that. The cursor is
hover-driven — the user's pointer position is the single source of truth
for the highlighted time. An optional time prop provides a fallback position
to display when the pointer leaves (e.g., for syncing with a running video
clock).
Pair with EpisodeVideoStack when you have multi-camera footage that should scrub against the same cursor.
Basic usage
Pure hover-driven: the cursor follows the pointer; when it leaves, the cursor disappears.
Frames
frames is an ascending-time-sorted list of { time, image }. The
component does not re-sort — sorting on every render would be wasteful and
the contract is documented in the type. Frames at the same time deduplicate
naturally because the greedy culler keeps the first one and drops any
followers within frameWidth + 4px.
When frames is empty, the strip and its footer label collapse entirely —
there's no empty band and no 0 OF 0 FRAMES readout; the track rows sit
directly beneath the ruler.
Each frame's left edge sits at its time on the axis
(fx = timeToX(f.time)) — the frame visually represents the chunk starting
at time. This keeps the t = 0 cell fully visible at the timeline's left
edge instead of half-clipped.
Greedy culling
When the visible window is wide (low zoom), many frames would overlap in
screen space. The component sweeps left-to-right and renders a frame only
when its time is at least (frameWidth + 4px) / pxPerSec later than the
previous rendered frame. Off-screen frames are skipped.
As you zoom in, the visible time window shrinks and pxPerSec grows, so the
stride between renderable frames shrinks too — more of the supplied frames
qualify until eventually every one is shown. The footer readout
{visible} OF {total} FRAMES updates live as you scrub.
Time positions are exact — each frame's left edge sits at the X
corresponding to its actual time, no quantization to evenly-spaced slots.
Tracks and blocks
Each track is a row; each block is a labelled span { start, end, label }.
Blocks outside the visible window are skipped entirely; blocks that straddle
the edge render with a dashed border on the clipped side to signal
"continues off-screen."
Provide stable id on each block to get reliable hover highlight + callback
identity.
Interaction model
| Gesture | Effect |
|---|---|
| Hover | Bright accent cursor pinned to the pointer X; highlights every track block whose [start, end] contains the cursor time AND the frame whose visual extent contains it. Fires onHover(time) on every move, onHoverEnd() on leave. |
| Click on empty area | onSeek(time) — separate "commit" event distinct from continuous hover. |
| Click on a block | onBlockClick(block, trackId) — does NOT fire onSeek. |
| Drag (≥ 4px) | Pans the viewport (when zoomed in). |
| Shift + drag vertical | Drag-zoom: drag up to zoom in, down to zoom out. Anchors the time under the cursor. |
| Wheel + alt/⌘/ctrl | Zoom at cursor (also fires on trackpad pinch — macOS dispatches ctrlKey + wheel). |
| Wheel (horizontal-dominant) | Pan when zoomed in. Two-finger horizontal swipes are always swallowed, so a trackpad gesture on the timeline never triggers the browser's back/forward navigation. |
ZoomBar ‹ / › | Step zoom by ×1.4 / ÷1.4. |
| ZoomBar drag readout | Continuous zoom via exp(dx × 0.008). |
Controlled vs uncontrolled viewport
zoom and panPct are optional. Omit them → the component owns viewport
state internally. Provide either one → the component flips into
controlled mode for both (the omitted value falls back to its default,
but the component no longer writes to its internal state).
onViewportChange always fires when the user gestures, so the parent can
persist the new values.
Design rules
User-visible behaviour the component enforces:
- 0s strict left edge — the ruler picks tick steps from a fixed ladder
(
0.05, 0.1, 0.25, 0.5, 1, 5, 10, …seconds) and starts ticking at0; the chart never shows negative time. The0sand duration labels stay visible at the very edges instead of getting clipped. - Anchor zoom —
⌘(oralt/ctrl) + wheel zooms about the cursor: the time directly under the pointer stays fixed across the zoom step. - Three-tier ruler —
major / minor / micro. As zoom changes, ticks cross-fade smoothly between tiers instead of popping.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | — | Episode duration in seconds. Must be > 0. |
frames | FrameSample[] | — | Frame thumbnails, sorted ascending by time. |
tracks | TimelineTrack[] | — | Track rows, drawn top-to-bottom in array order. |
time | number | null | null | Fallback cursor position when the user is NOT hovering. Hover always overrides. null → pure hover-driven cursor that disappears on leave. |
onHover | (t: number) => void | — | Called continuously on every hover move with the time at the cursor. |
onHoverEnd | () => void | — | Called when the cursor leaves the timeline. |
onSeek | (t: number) => void | — | Called on click in an empty area (single commit). Clicks on a track block fire onBlockClick instead and do NOT fire onSeek. |
zoom | number | 1 | Controlled zoom level. Clamped to [minZoom, maxZoom]. Pairing with panPct flips the component into controlled mode for both. |
panPct | number | 0 | Controlled pan as fraction of duration. Clamped to [0, 1 - 1/zoom]. |
onViewportChange | (v: ViewportState) => void | — | Fires when the user changes zoom or pan via wheel/drag. |
onBlockClick | (block, trackId) => void | — | Click on a track block (suppresses onSeek). |
onBlockHover | (block | null, trackId | null) => void | — | Hover enter/leave on a track block. Receives null on leave. |
framesLabel | string | 'FRAMES' | Left-side uppercase caption under the frame strip. The right side is auto-generated as {visibleCount} OF {totalCount} FRAMES. |
frameHeight | number | auto | Frame thumbnail height in pixels (width auto from 16:9). When omitted, sized to fit 12 cells with 4px gaps. |
trackRowHeight | number | 28 | Track row height. |
minZoom | number | 1 | Minimum zoom factor. |
maxZoom | number | 20 | Maximum zoom factor. |
className | string | — | Extra classes on the root wrapper. |