TimeController
The TimeController class is the animation clock: a wall-clock × speed rAF loop that layers subscribe to for synchronized animation. It deliberately knows nothing about data loading — in any networked app, drive it through a PlaybackGovernor, which gates play/resume/seek on the buffered runway so the clock never advances into unloaded time.
New integrations:
SttPlayeris the recommended single entry point — it wires aTimeControllerandPlaybackGovernortogether for you. Both remain the underlying pieces and are fully usable standalone as documented here.
Installation#
import { TimeController } from '@poopdeck.gl/playback';
Usage#
import { TimeController, PlaybackGovernor } from '@poopdeck.gl/playback';import { AnimatedPointLayer } from '@poopdeck.gl/layers';const timeController = new TimeController({initialTime: Date.parse('2020-01-01'),speed: 86400000 / 1000, // 1 day per secondloop: true,timeRange: {start: Date.parse('2020-01-01'),end: Date.parse('2020-12-31'),},tickThrottleMs: 16,});// Recommended: gate playback on data readiness.const governor = new PlaybackGovernor(timeController);const layer = new AnimatedPointLayer({id: 'earthquakes',data: 'https://example.com/earthquakes/manifest.json',timeController, // layer subscribes automaticallytimeWindow: 86400000,onTilesetReady: (tileset) => governor.setSource(tileset),onBufferChange: (runway) => governor.notifyBufferChange(runway),});// Drive playback through the governor…governor.requestPlay();governor.requestPause();governor.seekTo(Date.parse('2020-06-15'));// …or, for offline/test scenarios, the controller directly:timeController.play();timeController.pause();timeController.seek(Date.parse('2020-06-15'));
Constructor Options#
| Option | Type | Default | Description |
|---|---|---|---|
initialTime | number | Date.now() | Starting time in Unix milliseconds (an explicit 0 is honored). |
speed | number | 1.0 | Playback speed: simulation ms per real ms. Negative plays backward. Internally the sign is decomposed into a travel direction kept separate from the rate magnitude (see getDirection/setDirection). |
loop | boolean | false | Wrap to the other end of timeRange when a boundary is hit. |
bounce | boolean | false | Ping-pong at the range boundaries instead of jumping: the overshoot is reflected back into the range and playback direction reverses, so time stays CONTINUOUS across the boundary. Takes precedence over loop. Exists because a hard loop-wrap teleports currentTime by the full range span in one frame — for trail/window layers that one-frame jump causes a mass tile evict+reload blink and a visible layer flash. Fine for ambient slow drifts; not what you want for directional replay. |
timeRange | { start: number; end: number } | undefined | Time range boundaries. Without it, time advances unbounded. |
tickThrottleMs | number | 0 | Minimum wall-clock interval (ms) between 'tick' notifications during playback. Internal time still advances every animation frame; this only throttles listener notification. 0 = notify every frame. |
Playback boundary behavior#
With a timeRange, hitting a boundary does one of three things:
bounce: true— reflect and reverse direction; fires aplayStatenotification (the speed's sign flipped) so tile loaders re-aim their prefetch immediately.loop: true— teleport to the other end; fires thewrapevent. The wrap is a teleport-seek the clock performed on its own; thePlaybackGovernorsubscribes towrapand routes it through seek semantics (flush stale prefetch, startup-sized gate) instead of letting playback run ungated into possibly-unloaded time.- Neither — clamp to the boundary,
pause(), and fire theendedevent with the clamped time. Distinct from a user pause: the clock ran out of range (media-element'ended'semantics) — UIs show a replay affordance, and thePlaybackGovernorrestarts from the range start on the next play.
Frame-delta clamp and tab refocus#
Browsers suspend requestAnimationFrame in background tabs, so without protection the first frame after a refocus would advance time by the ENTIRE background duration — a playhead teleport. Two defenses are built in:
- every frame's wall-clock delta is clamped to 250 ms (any real frame longer than that is dropped-frames jank where advancing by the full gap would only compound the stutter), and
- a
visibilitychangehandler re-anchors the frame clock when the tab becomes visible while playing, removing even the clamped jump. The handler is registered inplay()and removed inpause()/destroy(), so idle controllers add no document listeners.
Methods#
Playback Control#
| Method | Description |
|---|---|
play() | Start playback. No-op when already playing. |
pause() | Pause playback. No-op (and no playState notification) when already paused. |
toggle() | Toggle play/pause state. |
seek(time: number) | Jump to a specific time (alias of setTime). |
seekBy(delta: number) | Seek by a relative offset. |
External clock (host-driven frames)#
| Method | Description |
|---|---|
attachExternalClock() | Hand the per-frame advance to a host render loop (deck.gl's onBeforeRender). Suppresses the internal rAF — one frame clock, no skew between "time advanced" and "scene drawn" — and re-anchors the frame clock. Idempotent. |
detachExternalClock() | Restore the self-owned rAF loop, resuming it if mid-playback. Idempotent. |
advanceFrame() | Advance the playhead by one host frame. No-op unless an external clock is attached, and the underlying step no-ops while paused — safe to call every frame regardless of play state. |
The advance is coalesced per animation frame on document.timeline.currentTime, which the browser holds constant for every task between two rendering opportunities, so every draw in one frame reads the same token regardless of callback ORDER (an rAF-callback counter cannot promise that — whether the host schedules its next frame before or after drawing would decide it). The coalesce is load-bearing: deck.gl's React wrapper redraws SYNCHRONOUSLY from a dependency-less layout effect, so onBeforeRender fires once per React commit, not once per frame; without it the clock ticks per render and any playback state a tick produces (a stall freezing the clock, a gate opening it) re-enters React from inside its own commit → render → draw → tick chain — which React reports as "Maximum update depth exceeded". No sim-time is lost by skipping a draw: the step integrates performance.now() - lastUpdateTime, so the next accepted advance covers the whole elapsed span. Where no document timeline exists (workers, Node, headless hosts) every call advances.
The deck.gl wiring is packaged as useDeckClock.
State Access#
| Method | Returns | Description |
|---|---|---|
getTime() | number | Get current time in Unix milliseconds. |
setTime(time: number) | void | Set current time (notifies tick listeners synchronously). |
isPlaying() | boolean | Check if currently playing. |
getSpeed() | number | Get the signed effective rate: direction × magnitude (sim-ms per wall-ms) — the contract governors and loaders read. |
setSpeed(speed: number) | void | Set playback speed. The magnitude is always adopted; a negative value explicitly selects reverse; a positive value restores forward — EXCEPT in bounce mode, where direction belongs to the boundary reflection: a UI pushing a positive magnitude mid-reverse (speed slider during the return leg) changes only the rate, never the travel direction. Use setDirection to steer explicitly. Fires playState while playing (a re-plan event for governors/loaders). |
getDirection() | 1 | -1 | Travel direction, kept separate from the rate (bounce reversals flip this, not the rate). |
setDirection(direction) | void | Set travel direction explicitly. Fires playState while playing (a re-plan event for loaders). |
setTimeRange(range) | void | Set time range boundaries. |
getTimeRange() | { start, end } | undefined | The configured time range, if any (a defensive copy). |
setLoop(loop: boolean) | void | Toggle wrapping at the range end (the media-element loop attribute), live. Only changes what happens at the NEXT boundary crossing — it never moves the playhead. bounce still takes precedence when set. Backs the transport bar's loop toggle via usePlayback's onLoopToggle. |
getLoop() | boolean | Whether the clock wraps at the range end rather than ending there. |
getState() | TimeControllerState | Get full state object (includes direction). |
Event Handling#
on() returns an unsubscribe function, so cleanup doesn't have to retain the callback:
const unsubscribe = timeController.on('tick', (time) => render(time));// later (e.g. effect cleanup):unsubscribe(); // equivalent to timeController.off('tick', callback)
| Method | Description |
|---|---|
on('tick', cb) | Time updates. Called every animation frame (or per tickThrottleMs) with the current time. |
on('playState', cb) | Play/pause/speed changes: (playing: boolean, speed: number). Also fired on a bounce reversal. |
on('wrap', cb) | Loop wraps (both directions): (time: number) after the teleport. |
on('ended', cb) | Non-looping, non-bouncing clamp at a range boundary: (time: number) with the clamped time. Distinct from a user pause (media-element 'ended' semantics). |
off(event, cb) | Unsubscribe (same four event names). The function returned by on() does the same. |
destroy() | Pause (also removes the visibilitychange handler) and clear all listeners. |
Types#
interface TimeControllerOptions {initialTime?: number;speed?: number;loop?: boolean;bounce?: boolean;timeRange?: { start: number; end: number };tickThrottleMs?: number;}interface TimeControllerState {currentTime: number;playing: boolean;speed: number; // signed effective rate: direction × magnitudedirection: 1 | -1; // travel direction, kept separate from the rate (bounce flips only this)loop: boolean;}
Both are exported from @poopdeck.gl/playback. The tick/wrap/ended callbacks receive (time: number); playState receives (playing: boolean, speed: number) — see the events table.
Integration with Layers#
When you pass a TimeController to a layer via the timeController prop, the layer automatically:
- Subscribes to
tickevents for time updates (read via agetTimegetter in the shader extension'sdraw()— no React re-render per frame) - Subscribes to
playStateevents to drive prefetch sizing and direction - Unsubscribes when the layer is finalized