release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:55:04 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187060 additions and 14006 deletions
+78
View File
@@ -0,0 +1,78 @@
/**
* useAgentActions — polls for display actions pushed by the OpenClaw agent.
*
* When the agent sends a `show_satellite` or `show_sentinel` command,
* the backend queues a display action. This hook picks it up and
* triggers the same full-screen image viewer as a right-click dossier.
*
* Actions are consumed on read (destructive poll) so they don't pile up.
*/
import { useEffect, useRef, useCallback } from 'react';
import { API_BASE } from '@/lib/api';
interface AgentAction {
action: string;
source?: string;
lat?: number;
lng?: number;
sentinel2?: Record<string, unknown>;
preset?: string;
caption?: string | null;
ts?: number;
// fly_to extras
zoom?: number;
aoi_id?: string;
}
/**
* @param onShowImage — called when the agent wants to display satellite imagery.
* Receives {lat, lng} — the caller should trigger handleMapRightClick or
* equivalent to open the RegionDossierPanel.
* @param onFlyTo — called when the agent wants to center the map on a point
* without opening imagery (e.g. sar_focus_aoi).
*/
export function useAgentActions(
onShowImage: (coords: { lat: number; lng: number }) => void,
onFlyTo?: (coords: { lat: number; lng: number; zoom?: number }) => void,
) {
const onShowImageRef = useRef(onShowImage);
onShowImageRef.current = onShowImage;
const onFlyToRef = useRef(onFlyTo);
onFlyToRef.current = onFlyTo;
const poll = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/ai/agent-actions`);
if (!res.ok) return;
const data = await res.json();
const actions: AgentAction[] = data.actions || [];
for (const action of actions) {
if (action.action === 'show_image' && action.lat != null && action.lng != null) {
onShowImageRef.current({ lat: action.lat, lng: action.lng });
} else if (
action.action === 'fly_to' &&
action.lat != null &&
action.lng != null
) {
onFlyToRef.current?.({
lat: action.lat,
lng: action.lng,
zoom: action.zoom,
});
}
}
} catch {
// Silent fail — agent actions are best-effort
}
}, []);
useEffect(() => {
// Poll every 3 seconds — lightweight endpoint, ~50 bytes when empty
const interval = setInterval(poll, 3000);
// Initial poll on mount
poll();
return () => clearInterval(interval);
}, [poll]);
}
+97
View File
@@ -0,0 +1,97 @@
/**
* useAlertToasts — watches for new high-severity news items and surfaces toast notifications.
*
* Monitors the `news` data key for articles with risk_score >= 8.
* Maintains a seen-set to avoid duplicate toasts. Auto-dismisses after 5 seconds.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useDataKey } from './useDataStore';
import type { NewsArticle } from '@/types/dashboard';
export interface ToastItem {
id: string;
title: string;
source: string;
risk_score: number;
lat: number;
lng: number;
timestamp: number; // when the toast was created
}
const TOAST_THRESHOLD = 8; // minimum risk_score to trigger a toast
const MAX_VISIBLE = 3;
const AUTO_DISMISS_MS = 5_000;
export function useAlertToasts() {
const news = useDataKey('news') as NewsArticle[] | undefined;
const seenKeys = useRef(new Set<string>());
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// Auto-dismiss scheduled toasts
const scheduleDismiss = useCallback((id: string) => {
const timer = setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
timersRef.current.delete(id);
}, AUTO_DISMISS_MS);
timersRef.current.set(id, timer);
}, []);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
}, []);
// Watch for new high-severity articles
useEffect(() => {
if (!news || !Array.isArray(news)) return;
const newToasts: ToastItem[] = [];
for (const article of news) {
if ((article.risk_score || 0) < TOAST_THRESHOLD) continue;
const key = `${article.title}|${article.source}`;
if (seenKeys.current.has(key)) continue;
seenKeys.current.add(key);
newToasts.push({
id: key,
title: article.title,
source: article.source,
risk_score: article.risk_score,
lat: article.lat || article.coords?.[0] || 0,
lng: article.lng || article.coords?.[1] || 0,
timestamp: Date.now(),
});
}
if (newToasts.length > 0) {
setToasts((prev) => {
// Merge new toasts, keep only MAX_VISIBLE most recent
const merged = [...newToasts, ...prev].slice(0, MAX_VISIBLE);
return merged;
});
// Schedule auto-dismiss for each new toast
for (const t of newToasts) {
scheduleDismiss(t.id);
}
}
}, [news, scheduleDismiss]);
// Cleanup timers on unmount
useEffect(() => {
return () => {
for (const timer of timersRef.current.values()) {
clearTimeout(timer);
}
};
}, []);
return { toasts, dismiss };
}
+56
View File
@@ -3,6 +3,55 @@ import { API_BASE } from "@/lib/api";
import { mergeData, setBackendStatus as setStoreBackendStatus } from "./useDataStore";
export type BackendStatus = 'connecting' | 'connected' | 'disconnected';
// ---------------------------------------------------------------------------
// Polling pause/resume — used by Time Machine snapshot playback
// ---------------------------------------------------------------------------
let _pollingPaused = false;
let _fastEtagRef: { current: string | null } | null = null;
let _slowEtagRef: { current: string | null } | null = null;
/** Pause live data polling (snapshot mode). */
export function pausePolling() {
_pollingPaused = true;
}
/** Resume live data polling and invalidate ETags for a full refresh. */
export function resumePolling() {
_pollingPaused = false;
// Invalidate ETags so the next poll gets fresh data (not 304)
if (_fastEtagRef) _fastEtagRef.current = null;
if (_slowEtagRef) _slowEtagRef.current = null;
}
/** Resume live mode and fetch both live tiers immediately instead of waiting for the next poll tick. */
export async function forceRefreshLiveData(): Promise<void> {
_pollingPaused = false;
if (_fastEtagRef) _fastEtagRef.current = null;
if (_slowEtagRef) _slowEtagRef.current = null;
try {
const [fastRes, slowRes] = await Promise.all([
fetch(`${API_BASE}/api/live-data/fast`),
fetch(`${API_BASE}/api/live-data/slow`),
]);
if (fastRes.ok) {
if (_fastEtagRef) _fastEtagRef.current = fastRes.headers.get('etag') || null;
mergeData(await fastRes.json());
}
if (slowRes.ok) {
if (_slowEtagRef) _slowEtagRef.current = slowRes.headers.get('etag') || null;
mergeData(await slowRes.json());
}
if (fastRes.ok || slowRes.ok) {
setStoreBackendStatus('connected');
}
} catch (e) {
console.error("Failed forcing live data refresh", e);
setStoreBackendStatus('disconnected');
}
}
type FastDataProbe = {
commercial_flights?: unknown[];
military_flights?: unknown[];
@@ -46,6 +95,10 @@ export function useDataPolling() {
const slowEtag = useRef<string | null>(null);
useEffect(() => {
// Expose refs so pausePolling/resumePolling can invalidate ETags
_fastEtagRef = fastEtag;
_slowEtagRef = slowEtag;
let hasData = false;
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
@@ -57,6 +110,8 @@ export function useDataPolling() {
clearTimeout(fastTimerId);
fastTimerId = null;
}
// Skip fetch when Time Machine snapshot mode is active
if (_pollingPaused) { scheduleNext('fast'); return; }
if (fastAbortRef.current) return;
const controller = new AbortController();
fastAbortRef.current = controller;
@@ -98,6 +153,7 @@ export function useDataPolling() {
};
const fetchSlowData = async () => {
if (_pollingPaused) { scheduleNext('slow'); return; }
if (slowAbortRef.current) return;
const controller = new AbortController();
slowAbortRef.current = controller;
+87
View File
@@ -0,0 +1,87 @@
/**
* useFeedHealth — derives live feed health from the data store.
*
* Tracks how many entities are in each data category and how fresh the data is.
* Returns compact stats for the bottom status bar.
*/
import { useRef, useMemo } from 'react';
import { useDataKeys } from './useDataStore';
import type { DashboardData, NewsArticle } from '@/types/dashboard';
type FeedStatus = 'healthy' | 'stale' | 'offline';
interface FeedInfo {
label: string;
count: string; // formatted count e.g. "12.4K"
status: FeedStatus;
}
function formatCount(n: number): string {
if (n >= 10000) return `${(n / 1000).toFixed(1)}K`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}
function arrayLen(v: unknown): number {
return Array.isArray(v) ? v.length : 0;
}
export function useFeedHealth(): FeedInfo[] {
const data = useDataKeys([
'commercial_flights',
'private_flights',
'military_flights',
'private_jets',
'tracked_flights',
'ships',
'news',
'satellites',
] as const satisfies readonly (keyof DashboardData)[]);
// Track last-seen timestamps per feed
const timestamps = useRef<Record<string, number>>({});
const now = Date.now();
// Update timestamps when data changes
const feeds = useMemo(() => {
const adsb =
arrayLen(data.commercial_flights) +
arrayLen(data.private_flights) +
arrayLen(data.military_flights) +
arrayLen(data.private_jets) +
arrayLen(data.tracked_flights);
const ais = arrayLen(data.ships);
// Count unique news sources
const newsArr = Array.isArray(data.news) ? data.news : [];
const newsSources = new Set(newsArr.map((n: NewsArticle) => n.source).filter(Boolean));
const sats = arrayLen(data.satellites);
// Update timestamps
if (adsb > 0) timestamps.current.adsb = now;
if (ais > 0) timestamps.current.ais = now;
if (newsArr.length > 0) timestamps.current.news = now;
if (sats > 0) timestamps.current.sats = now;
function getStatus(key: string, count: number): FeedStatus {
if (count === 0) return 'offline';
const lastSeen = timestamps.current[key] || 0;
const age = now - lastSeen;
if (age > 120_000) return 'offline';
if (age > 30_000) return 'stale';
return 'healthy';
}
return [
{ label: 'ADS-B', count: formatCount(adsb), status: getStatus('adsb', adsb) },
{ label: 'AIS', count: formatCount(ais), status: getStatus('ais', ais) },
{ label: 'NEWS', count: String(newsSources.size), status: getStatus('news', newsArr.length) },
{ label: 'SAT', count: formatCount(sats), status: getStatus('sats', sats) },
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
return feeds;
}
+5 -30
View File
@@ -1,33 +1,8 @@
import { useEffect, useRef } from 'react';
import { API_BASE } from '@/lib/api';
/**
* Subscribe to the backend SSE gate-event stream.
* Delivers ALL gate events (encrypted blobs) — the client filters by gate_id locally.
* The server never learns which gates a client cares about (privacy-preserving broadcast).
*
* Falls back gracefully: if the stream fails the browser's EventSource auto-reconnects.
* DEPRECATED — Gate SSE stream removed in S3A.
* The frontend now relies on the authenticated poll loop for gate refresh.
* This stub is kept so stale imports compile without error.
*/
export function useGateSSE(onEvent: (gateId: string) => void) {
const callbackRef = useRef(onEvent);
callbackRef.current = onEvent;
useEffect(() => {
const es = new EventSource(`${API_BASE}/api/mesh/gate/stream`);
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.gate_id && typeof data.gate_id === 'string') {
callbackRef.current(data.gate_id);
}
} catch {
/* ignore parse errors */
}
};
// Browser auto-reconnects EventSource on error — no manual retry needed.
return () => es.close();
}, []);
export function useGateSSE(_onEvent: (gateId: string) => void) {
// no-op
}
@@ -0,0 +1,82 @@
/**
* useKeyboardShortcuts — global keyboard shortcut handler for ShadowBroker.
*
* Registers document-level keydown listener with guards for inputs/textareas.
* Returns nothing — side-effect only hook.
*/
import { useEffect, useCallback } from 'react';
interface ShortcutActions {
toggleLeft: () => void;
toggleRight: () => void;
toggleMarkets: () => void;
openSettings: () => void;
openLegend: () => void;
openShortcuts: () => void;
deselectEntity: () => void;
focusSearch: () => void;
}
export function useKeyboardShortcuts(actions: ShortcutActions) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Don't fire shortcuts when typing in inputs
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
// Don't fire when contentEditable is active
if ((e.target as HTMLElement)?.isContentEditable) return;
// Don't fire on modifier key combos (Ctrl+S, etc.)
if (e.ctrlKey || e.metaKey || e.altKey) return;
switch (e.key.toLowerCase()) {
case 'l':
e.preventDefault();
actions.toggleLeft();
break;
case 'r':
e.preventDefault();
actions.toggleRight();
break;
case 'm':
e.preventDefault();
actions.toggleMarkets();
break;
case 's':
e.preventDefault();
actions.openSettings();
break;
case 'k':
e.preventDefault();
actions.openLegend();
break;
case ' ': // Space bar
e.preventDefault();
actions.openShortcuts();
break;
case 'escape':
e.preventDefault();
actions.deselectEntity();
break;
case 'f':
e.preventDefault();
actions.focusSearch();
break;
}
},
[actions],
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
+16 -14
View File
@@ -192,21 +192,23 @@ async function fetchSentinel2Direct(lat: number, lng: number) {
const features = data.features || [];
if (!features.length) return null; // No scenes — caller uses Esri fallback
const item = features[0];
const assets = item.assets || {};
const rendered = assets.rendered_preview || {};
const thumbnail = assets.thumbnail || {};
const scenes = features.map((item: any) => {
const assets = item.assets || {};
const rendered = assets.rendered_preview || {};
const thumbnail = assets.thumbnail || {};
return {
found: true,
scene_id: item.id,
datetime: item.properties?.datetime,
cloud_cover: item.properties?.['eo:cloud_cover'],
thumbnail_url: thumbnail.href || rendered.href,
fullres_url: rendered.href || thumbnail.href,
bbox: item.bbox ? [...item.bbox] : null,
platform: item.properties?.platform || 'Sentinel-2',
};
});
return {
found: true,
scene_id: item.id,
datetime: item.properties?.datetime,
cloud_cover: item.properties?.['eo:cloud_cover'],
thumbnail_url: thumbnail.href || rendered.href,
fullres_url: rendered.href || thumbnail.href,
bbox: item.bbox ? [...item.bbox] : null,
platform: item.properties?.platform || 'Sentinel-2',
};
return { ...scenes[0], scenes };
}
// ─── MAIN HOOK ─────────────────────────────────────────────────────────────
+62
View File
@@ -0,0 +1,62 @@
/**
* useSignAndAppend — shared submission state for any view that needs
* to sign + post an Infonet economy event.
*
* Wraps ``signAndAppend`` from ``@/mesh/infonetEconomyClient`` with
* React loading / result state. Each view tracks its own per-action
* status independently — the hook returns ``submit(event_type,
* payload)`` plus the latest ``result`` and ``state`` flags.
*
* Cross-cutting non-hostile UX rule: ``result.reason`` on failure
* carries the verbatim diagnostic from the backend so the view
* surfaces it directly. Never display "denied" with no detail.
*/
import { useCallback, useState } from 'react';
import {
signAndAppend,
type AppendResult,
} from '@/mesh/infonetEconomyClient';
export type SubmitState = 'idle' | 'submitting' | 'success' | 'error';
export interface UseSignAndAppendReturn {
state: SubmitState;
result: AppendResult | null;
submit: (
event_type: string,
payload: Record<string, unknown>,
) => Promise<AppendResult>;
reset: () => void;
}
export function useSignAndAppend(): UseSignAndAppendReturn {
const [state, setState] = useState<SubmitState>('idle');
const [result, setResult] = useState<AppendResult | null>(null);
const submit = useCallback(
async (event_type: string, payload: Record<string, unknown>) => {
setState('submitting');
let res: AppendResult;
try {
res = await signAndAppend({ event_type, payload });
} catch (err) {
res = {
ok: false,
reason: err instanceof Error ? err.message : 'unknown_error',
};
}
setResult(res);
setState(res.ok ? 'success' : 'error');
return res;
},
[],
);
const reset = useCallback(() => {
setState('idle');
setResult(null);
}, []);
return { state, result, submit, reset };
}
+534
View File
@@ -0,0 +1,534 @@
/**
* 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<string, number>;
profile?: string | null;
}
interface PlaybackSnapshot extends SnapshotMeta {
snapshot_id: string;
data: SnapshotData;
}
type SnapshotData = Record<string, unknown>;
type Entity = Record<string, unknown>;
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<number, HourlyIndexEntry>;
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<Listener>();
const playbackCache = new Map<string, PlaybackSnapshot>();
const playbackFetches = new Map<string, Promise<PlaybackSnapshot | null>>();
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<typeof setInterval> | null = null;
let _playbackLastTick = 0;
let _seekSerial = 0;
let _playbackSeeking = false;
function setState(patch: Partial<TimeMachineState>) {
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<string | null | undefined>) {
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<string, Entity>();
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<PlaybackSnapshot | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<typeof setInterval> | null = null;
if (typeof window !== 'undefined' && !_indexRefreshTimer) {
setTimeout(refreshHourlyIndex, 1500);
_indexRefreshTimer = setInterval(refreshHourlyIndex, 5 * 60 * 1000);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* useWatchlist — persistent entity watchlist with live data updates.
*
* Allows users to pin entities (flights, ships, news) for persistent tracking.
* Persisted to localStorage. Max 10 items.
*/
import { useState, useEffect, useCallback } from 'react';
export interface WatchlistEntry {
id: string;
type: 'flight' | 'ship' | 'news' | 'satellite' | string;
name: string;
lat: number;
lng: number;
addedAt: number;
// Live stats (updated externally)
altitude?: number;
speed?: number;
heading?: number;
risk_score?: number;
}
const STORAGE_KEY = 'sb_watchlist';
const MAX_ITEMS = 10;
function loadWatchlist(): WatchlistEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw) as WatchlistEntry[];
} catch {
return [];
}
}
function saveWatchlist(items: WatchlistEntry[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}
export function useWatchlist() {
const [items, setItems] = useState<WatchlistEntry[]>(() => loadWatchlist());
// Persist on change
useEffect(() => {
saveWatchlist(items);
}, [items]);
const addToWatchlist = useCallback((entry: WatchlistEntry) => {
setItems((prev) => {
// Don't add duplicates
if (prev.some((e) => e.id === entry.id)) return prev;
// FIFO overflow
const next = [entry, ...prev];
if (next.length > MAX_ITEMS) next.pop();
return next;
});
}, []);
const removeFromWatchlist = useCallback((id: string) => {
setItems((prev) => prev.filter((e) => e.id !== id));
}, []);
const isWatched = useCallback(
(id: string) => items.some((e) => e.id === id),
[items],
);
const clearWatchlist = useCallback(() => {
setItems([]);
}, []);
return { items, addToWatchlist, removeFromWatchlist, isWatched, clearWatchlist };
}