/** * useTimeMachine - snapshot playback state for the map. * * The UI uses this as a media-style transport: a straight timeline, explicit * snapshot mode, immediate live restore, and interpolated frames between * recorded snapshots for moving entities. */ import { useSyncExternalStore } from 'react'; import { API_BASE } from '@/lib/api'; import { mergeData } from './useDataStore'; import { forceRefreshLiveData, pausePolling, resumePolling } from './useDataPolling'; export interface HourlyIndexEntry { count: number; latest_id: string; latest_ts: string; snapshot_ids: string[]; } export interface SnapshotMeta { id: string; timestamp: string; unix_ts: number; format?: string; layers: string[]; layer_counts: Record; profile?: string | null; } interface PlaybackSnapshot extends SnapshotMeta { snapshot_id: string; data: SnapshotData; } type SnapshotData = Record; type Entity = Record; type Listener = () => void; export interface TimeMachineState { mode: 'live' | 'snapshot'; snapshotId: string | null; snapshotTimestamp: string | null; currentUnixTs: number | null; timelineStart: number | null; timelineEnd: number | null; snapshots: SnapshotMeta[]; playing: boolean; playbackSpeed: number; hourlyIndex: Record; loading: boolean; error: string | null; } const MOVING_LAYER_KEYS = new Set([ 'commercial_flights', 'private_flights', 'private_jets', 'military_flights', 'tracked_flights', 'uavs', 'ships', 'satellites', 'tinygs_satellites', 'sigint', ]); const listeners = new Set(); const playbackCache = new Map(); const playbackFetches = new Map>(); let state: TimeMachineState = { mode: 'live', snapshotId: null, snapshotTimestamp: null, currentUnixTs: null, timelineStart: null, timelineEnd: null, snapshots: [], playing: false, playbackSpeed: 6, hourlyIndex: {}, loading: false, error: null, }; let _playbackTimer: ReturnType | null = null; let _playbackLastTick = 0; let _seekSerial = 0; let _playbackSeeking = false; function setState(patch: Partial) { state = { ...state, ...patch }; for (const fn of listeners) fn(); } function getSnapshot() { return state; } function subscribe(onStoreChange: Listener) { listeners.add(onStoreChange); return () => { listeners.delete(onStoreChange); }; } function numericTs(meta: { unix_ts?: number | null; timestamp?: string | null }): number { if (typeof meta.unix_ts === 'number' && Number.isFinite(meta.unix_ts)) return meta.unix_ts; if (meta.timestamp) { const ms = Date.parse(meta.timestamp); if (Number.isFinite(ms)) return ms / 1000; } return 0; } function sortSnapshots(snapshots: SnapshotMeta[]): SnapshotMeta[] { return [...snapshots] .map((snap) => ({ ...snap, unix_ts: numericTs(snap) })) .filter((snap) => snap.id && snap.unix_ts > 0) .sort((a, b) => a.unix_ts - b.unix_ts); } function updateTimelineFromSnapshots(snapshots: SnapshotMeta[]) { setState({ snapshots, timelineStart: snapshots[0]?.unix_ts ?? null, timelineEnd: snapshots[snapshots.length - 1]?.unix_ts ?? null, }); } function snapshotIndex(snapshotId: string): number { return state.snapshots.findIndex((snap) => snap.id === snapshotId); } function prefetchPlaybackSnapshots(snapshotIds: Array) { for (const snapshotId of snapshotIds) { if (!snapshotId || playbackCache.has(snapshotId) || playbackFetches.has(snapshotId)) continue; void fetchPlaybackSnapshot(snapshotId); } } function snapshotPatch(data: SnapshotData): SnapshotData { return { ...data }; } function stopPlaybackWithError(message: string) { setState({ loading: false, error: message, playing: false }); _stopPlaybackTimer(); } function asEntityArray(value: unknown): Entity[] | null { if (!Array.isArray(value)) return null; return value.filter((item): item is Entity => typeof item === 'object' && item !== null); } function stringField(entity: Entity, key: string): string { const value = entity[key]; if (typeof value === 'string') return value.trim(); if (typeof value === 'number' && Number.isFinite(value)) return String(value); return ''; } function numberField(entity: Entity, key: string): number | null { const value = entity[key]; if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim()) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function entityKey(layer: string, entity: Entity): string { const candidates = layer === 'ships' ? ['mmsi', 'imo', 'callsign', 'name'] : layer.includes('satellite') ? ['norad_id', 'norad', 'id', 'name'] : layer === 'sigint' ? ['id', 'callsign', 'long_name'] : ['icao24', 'registration', 'callsign', 'id', 'name']; for (const candidate of candidates) { const value = stringField(entity, candidate).toLowerCase(); if (value) return `${layer}:${candidate}:${value}`; } return ''; } function interpolateScalar(a: unknown, b: unknown, ratio: number): unknown { if (typeof a !== 'number' || typeof b !== 'number') return a; if (!Number.isFinite(a) || !Number.isFinite(b)) return a; return a + (b - a) * ratio; } function interpolateAngle(a: unknown, b: unknown, ratio: number): unknown { if (typeof a !== 'number' || typeof b !== 'number') return a; if (!Number.isFinite(a) || !Number.isFinite(b)) return a; const delta = ((((b - a) % 360) + 540) % 360) - 180; return (a + delta * ratio + 360) % 360; } function interpolateEntity(layer: string, prev: Entity, next: Entity, ratio: number): Entity | null { const prevLat = numberField(prev, 'lat'); const prevLng = numberField(prev, 'lng'); const nextLat = numberField(next, 'lat'); const nextLng = numberField(next, 'lng'); if (prevLat == null || prevLng == null || nextLat == null || nextLng == null) return null; const frame: Entity = { ...prev }; frame.lat = prevLat + (nextLat - prevLat) * ratio; frame.lng = prevLng + (nextLng - prevLng) * ratio; frame.alt = interpolateScalar(prev.alt, next.alt, ratio); frame.altitude = interpolateScalar(prev.altitude, next.altitude, ratio); frame.heading = interpolateAngle(prev.heading, next.heading, ratio); frame.true_track = interpolateAngle(prev.true_track, next.true_track, ratio); frame.cog = interpolateAngle(prev.cog, next.cog, ratio); frame.sog = interpolateScalar(prev.sog, next.sog, ratio); frame.speed_knots = interpolateScalar(prev.speed_knots, next.speed_knots, ratio); frame._snapshot_interpolated = true; frame._snapshot_interpolation_layer = layer; return frame; } function interpolateSnapshotData(prev: SnapshotData, next: SnapshotData, ratio: number): SnapshotData { if (ratio <= 0) return snapshotPatch(prev); if (ratio >= 1) return snapshotPatch(next); const out = snapshotPatch(prev); for (const layer of MOVING_LAYER_KEYS) { const prevItems = asEntityArray(prev[layer]); const nextItems = asEntityArray(next[layer]); if (!prevItems?.length) continue; if (!nextItems?.length) { out[layer] = []; continue; } const nextByKey = new Map(); for (const item of nextItems) { const key = entityKey(layer, item); if (key) nextByKey.set(key, item); } const interpolated: Entity[] = []; for (const item of prevItems) { const key = entityKey(layer, item); if (!key) continue; const nextItem = nextByKey.get(key); if (!nextItem) continue; const frame = interpolateEntity(layer, item, nextItem, ratio); if (frame) interpolated.push(frame); } out[layer] = interpolated; } return out; } function findFramePair(unixTs: number): { prev: SnapshotMeta; next: SnapshotMeta | null } | null { const snapshots = state.snapshots; if (snapshots.length === 0) return null; if (unixTs <= snapshots[0].unix_ts) return { prev: snapshots[0], next: null }; const last = snapshots[snapshots.length - 1]; if (unixTs >= last.unix_ts) return { prev: last, next: null }; for (let i = 0; i < snapshots.length - 1; i += 1) { const prev = snapshots[i]; const next = snapshots[i + 1]; if (unixTs >= prev.unix_ts && unixTs <= next.unix_ts) { return { prev, next }; } } return { prev: last, next: null }; } async function fetchPlaybackSnapshot(snapshotId: string): Promise { const cached = playbackCache.get(snapshotId); if (cached) return cached; const inflight = playbackFetches.get(snapshotId); if (inflight) return inflight; const request = (async () => { try { const res = await fetch(`${API_BASE}/api/ai/timemachine/playback/${snapshotId}`); if (!res.ok) return null; const json = (await res.json()) as PlaybackSnapshot; const snap: PlaybackSnapshot = { ...json, id: json.snapshot_id || json.id, unix_ts: numericTs(json), layer_counts: json.layer_counts || {}, layers: json.layers || Object.keys(json.data || {}), data: json.data || {}, }; playbackCache.set(snapshotId, snap); return snap; } finally { playbackFetches.delete(snapshotId); } })(); playbackFetches.set(snapshotId, request); return request; } async function loadExactSnapshot(snapshotId: string, pausePlayback: boolean): Promise { setState({ loading: true, error: null }); const serial = ++_seekSerial; try { const snap = await fetchPlaybackSnapshot(snapshotId); if (serial !== _seekSerial) return; if (!snap) { stopPlaybackWithError('Failed to load snapshot frame.'); return; } pausePolling(); mergeData(snapshotPatch(snap.data)); setState({ mode: 'snapshot', snapshotId: snap.snapshot_id || snap.id, snapshotTimestamp: snap.timestamp, currentUnixTs: snap.unix_ts, loading: false, error: null, playing: pausePlayback ? false : state.playing, }); const idx = snapshotIndex(snap.id); prefetchPlaybackSnapshots([ state.snapshots[idx + 1]?.id, state.snapshots[idx + 2]?.id, ]); if (pausePlayback) _stopPlaybackTimer(); } catch (e) { stopPlaybackWithError(`Network error: ${e}`); } } function _stopPlaybackTimer() { if (_playbackTimer) { clearInterval(_playbackTimer); _playbackTimer = null; } _playbackSeeking = false; } function _startPlaybackTimer() { _stopPlaybackTimer(); _playbackLastTick = performance.now(); _playbackTimer = setInterval(() => { if (state.mode !== 'snapshot' || !state.playing || state.snapshots.length === 0) { _stopPlaybackTimer(); return; } if (_playbackSeeking) return; const now = performance.now(); const elapsedMs = Math.max(1, now - _playbackLastTick); _playbackLastTick = now; const currentTs = state.currentUnixTs ?? state.snapshots[0].unix_ts; const pair = findFramePair(currentTs + 0.001); if (!pair?.next) { setState({ playing: false }); _stopPlaybackTimer(); return; } const segmentSeconds = Math.max(1, state.playbackSpeed); const segmentGap = Math.max(1, pair.next.unix_ts - pair.prev.unix_ts); const advance = segmentGap * (elapsedMs / (segmentSeconds * 1000)); const nextTs = Math.min(pair.next.unix_ts, currentTs + advance); _playbackSeeking = true; void seekToTime(nextTs, { keepPlaying: true }).finally(() => { _playbackSeeking = false; }); }, 250); } export async function refreshSnapshotList(): Promise { try { const res = await fetch(`${API_BASE}/api/ai/timemachine/snapshots?limit=100`); if (!res.ok) return; const json = await res.json(); updateTimelineFromSnapshots(sortSnapshots(json.snapshots || [])); } catch (e) { console.error('Time Machine: failed to fetch snapshots', e); } } export async function refreshHourlyIndex(): Promise { try { const [indexRes] = await Promise.all([ fetch(`${API_BASE}/api/ai/timemachine/hourly-index`), refreshSnapshotList(), ]); if (indexRes.ok) { const json = await indexRes.json(); setState({ hourlyIndex: json.hours || {} }); } } catch (e) { console.error('Time Machine: failed to fetch hourly index', e); } } export async function enterSnapshotMode(snapshotId: string): Promise { await loadExactSnapshot(snapshotId, true); } export function exitSnapshotMode(): void { _stopPlaybackTimer(); resumePolling(); setState({ mode: 'live', snapshotId: null, snapshotTimestamp: null, currentUnixTs: null, playing: false, loading: false, error: null, }); void forceRefreshLiveData(); } export async function seekToTime( unixTs: number, options: { keepPlaying?: boolean } = {}, ): Promise { if (state.snapshots.length === 0) return; const pair = findFramePair(unixTs); if (!pair) return; const serial = ++_seekSerial; const waitingOnUncachedFrame = !playbackCache.has(pair.prev.id) || Boolean(pair.next && !playbackCache.has(pair.next.id)); setState({ loading: !options.keepPlaying || waitingOnUncachedFrame, error: null }); try { const prev = await fetchPlaybackSnapshot(pair.prev.id); const next = pair.next ? await fetchPlaybackSnapshot(pair.next.id) : null; if (serial !== _seekSerial) return; if (!prev) { stopPlaybackWithError('Failed to fetch playback frame.'); return; } const hasNext = Boolean(next && pair.next && pair.next.unix_ts > pair.prev.unix_ts); const ratio = hasNext && pair.next ? Math.max(0, Math.min(1, (unixTs - pair.prev.unix_ts) / (pair.next.unix_ts - pair.prev.unix_ts))) : 0; const data = hasNext && next ? interpolateSnapshotData(prev.data, next.data, ratio) : snapshotPatch(prev.data); const timestamp = new Date(unixTs * 1000).toISOString(); pausePolling(); mergeData(data); setState({ mode: 'snapshot', snapshotId: prev.snapshot_id || prev.id, snapshotTimestamp: timestamp, currentUnixTs: unixTs, loading: false, error: null, playing: options.keepPlaying ? state.playing : false, }); const prevIdx = snapshotIndex(prev.id); prefetchPlaybackSnapshots([ pair.next?.id, state.snapshots[prevIdx + 2]?.id, state.snapshots[prevIdx + 3]?.id, ]); if (!options.keepPlaying) _stopPlaybackTimer(); } catch (e) { stopPlaybackWithError(`Network error: ${e}`); } } export async function stepForward(): Promise { const snapshots = state.snapshots; if (snapshots.length === 0) return; const currentTs = state.currentUnixTs ?? snapshots[0].unix_ts; const next = snapshots.find((snap) => snap.unix_ts > currentTs + 0.001); if (!next) { setState({ playing: false }); _stopPlaybackTimer(); return; } await loadExactSnapshot(next.id, true); } export async function stepBackward(): Promise { const snapshots = state.snapshots; if (snapshots.length === 0) return; const currentTs = state.currentUnixTs ?? snapshots[snapshots.length - 1].unix_ts; const previous = [...snapshots].reverse().find((snap) => snap.unix_ts < currentTs - 0.001); if (!previous) return; await loadExactSnapshot(previous.id, true); } export async function startPlayback(): Promise { if (state.snapshots.length === 0) return; if (state.mode !== 'snapshot') { await seekToTime(state.currentUnixTs ?? state.snapshots[0].unix_ts, { keepPlaying: true }); } setState({ playing: true }); _startPlaybackTimer(); } export function togglePlayback(): void { if (state.playing) { setState({ playing: false }); _stopPlaybackTimer(); return; } void startPlayback(); } export function setPlaybackSpeed(secondsPerSegment: number): void { setState({ playbackSpeed: Math.max(1, secondsPerSegment) }); if (state.playing) _startPlaybackTimer(); } export function useTimeMachine(): TimeMachineState { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } let _indexRefreshTimer: ReturnType | null = null; if (typeof window !== 'undefined' && !_indexRefreshTimer) { setTimeout(refreshHourlyIndex, 1500); _indexRefreshTimer = setInterval(refreshHourlyIndex, 5 * 60 * 1000); }