import React from 'react'; import { Pause, Play, SkipBack } from 'lucide-react'; import { cn } from '../../shared/utils'; import { Button } from '../button'; import { Slider } from '../slider'; export interface MapTimelineProps extends Omit, 'onChange'> { /** Inclusive `[start, end]` of the timeline, in whatever unit you use. */ range: [number, number]; value: number; onChange: (value: number) => void; /** Playback multipliers offered to the user. @default [1, 2, 4] */ speeds?: number[]; speed?: number; onSpeedChange?: (speed: number) => void; playing?: boolean; onPlayingChange?: (playing: boolean) => void; /** Units advanced per playback tick at 1×. @default 1 */ step?: number; /** Milliseconds between ticks at 1×. @default 250 */ tickMs?: number; /** Renders the value as a label, e.g. `v => '17:32'`. */ formatValue?: (value: number) => string; label?: string; /** @default "Play" / "Pause" / "Restart" */ playLabel?: string; pauseLabel?: string; restartLabel?: string; } /** * Time ruler bound to the map, with playback. * * @description * A sliding window over the data is what turns plotted points into a * space-time reconstruction; the same mechanism serves an inspection-date * filter and a day's schedule. * * Playback respects `prefers-reduced-motion`: when the user has asked for * reduced motion, auto-advance does not start on its own and the control * presents itself as a scrubber. The user can still press play — the setting * suppresses unrequested movement, not the feature. * * @ai-rules * 1. Feed `value` into `` and give the markers a `timeField`. * 2. Keep `playing` in the parent — the component is fully controlled. * 3. Always pass `formatValue`; a raw minute count means nothing to the reader. */ export const MapTimeline = React.forwardRef( ( { range, value, onChange, speeds = [1, 2, 4], speed, onSpeedChange, playing = false, onPlayingChange, step = 1, tickMs = 250, formatValue, label, playLabel = 'Play', pauseLabel = 'Pause', restartLabel = 'Restart', className, ...props }, ref ) => { const [internalSpeed, setInternalSpeed] = React.useState(speeds[0] ?? 1); const activeSpeed = speed ?? internalSpeed; const [start, end] = range; const prefersReducedMotion = React.useMemo(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }, []); const onChangeRef = React.useRef(onChange); const onPlayingChangeRef = React.useRef(onPlayingChange); onChangeRef.current = onChange; onPlayingChangeRef.current = onPlayingChange; const valueRef = React.useRef(value); valueRef.current = value; React.useEffect(() => { if (!playing) return; const interval = window.setInterval(() => { const next = valueRef.current + step * activeSpeed; if (next >= end) { onChangeRef.current(end); onPlayingChangeRef.current?.(false); return; } onChangeRef.current(next); }, tickMs); return () => window.clearInterval(interval); }, [playing, activeSpeed, step, tickMs, end]); const handleSpeed = (next: number) => { if (onSpeedChange) onSpeedChange(next); else setInternalSpeed(next); }; const atEnd = value >= end; return (
onChange(next[0])} aria-label={label ?? 'Timeline position'} className="flex-1" /> {formatValue ? formatValue(value) : value}
{speeds.map(option => ( ))} {prefersReducedMotion ? ( Auto-advance off (reduced motion) ) : null}
); } ); MapTimeline.displayName = 'MapTimeline';