diff --git a/pecan/src/components/Constellation.tsx b/pecan/src/components/Constellation.tsx index 7fbd2bb..aa68d9c 100644 --- a/pecan/src/components/Constellation.tsx +++ b/pecan/src/components/Constellation.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import type { SensorStar } from '../hooks/useConstellationSignals'; import { ConstellationSidebar } from './ConstellationSidebar'; -import { dataStore } from '../lib/DataStore'; +import { dataStore, type TelemetrySource } from '../lib/DataStore'; +import type { TimelineMode } from '../context/TimelineContext'; import { calculateCorrelation, getCorrelationMeta, findStrongCorrelations } from '../utils/statistics'; import { Zap, Share2, RefreshCw, Info } from 'lucide-react'; @@ -10,9 +11,27 @@ interface ConstellationCanvasProps { sensorValuesRef: React.RefObject>; telemetryHistoryRef: React.RefObject>; onExport: (constellationIds: string[]) => void; + /** Timeline cursor (epoch ms). When in replay or paused, "isLive" is evaluated at this time. */ + cursorTimeMs?: number; + /** Active telemetry source — replay buffer is queried separately from live. */ + source?: TelemetrySource; + /** Timeline mode — paused mode also pins the canvas to cursorTimeMs. */ + mode?: TimelineMode; } -export default function ConstellationCanvas({ sensors, sensorValuesRef, telemetryHistoryRef, onExport }: ConstellationCanvasProps) { +export default function ConstellationCanvas({ sensors, sensorValuesRef, telemetryHistoryRef, onExport, cursorTimeMs, source = "live", mode = "live" }: ConstellationCanvasProps) { + // Mirror timeline state into refs so the rAF render loop can read fresh + // values without re-subscribing or restarting. + const cursorTimeMsRef = useRef(cursorTimeMs ?? Date.now()); + const sourceRef = useRef(source); + const modeRef = useRef(mode); + useEffect(() => { + cursorTimeMsRef.current = cursorTimeMs ?? Date.now(); + // Pinned-mode cursor moves rewrite history wholesale; drop cached correlations. + correlationCacheRef.current.clear(); + }, [cursorTimeMs]); + useEffect(() => { sourceRef.current = source; correlationCacheRef.current.clear(); }, [source]); + useEffect(() => { modeRef.current = mode; }, [mode]); const canvasRef = useRef(null); const offscreenCanvasRef = useRef(null); @@ -59,6 +78,10 @@ export default function ConstellationCanvas({ sensors, sensorValuesRef, telemetr // --- RENDER ENGINE --- const timeRef = useRef(0); const bgStarsRef = useRef<{x: number, y: number, size: number, alpha: number}[]>([]); + // Correlation cache: pair-key -> { r, expiresAt }. History updates at most + // ~10Hz; recomputing Pearson per link per rAF (60Hz) is wasted work. + const correlationCacheRef = useRef>(new Map()); + const CORRELATION_TTL_MS = 150; useEffect(() => { const canvas = canvasRef.current; @@ -230,7 +253,11 @@ export default function ConstellationCanvas({ sensors, sensorValuesRef, telemetr const sx = cx + x1 * scale; const sy = cy + y2 * scale; - const latest = dataStore.getLatest(s.msgID); + const activeSource = sourceRef.current; + const pinned = activeSource === "replay" || modeRef.current === "paused"; + const latest = pinned + ? dataStore.getLatestAt(s.msgID, cursorTimeMsRef.current, activeSource) + : dataStore.getLatest(s.msgID, activeSource); const isLive = latest && !!latest.data[s.sigName]; return { ...s, sx, sy, scale, zDepth: z2, isLive, behindCamera: scale <= 0 }; @@ -248,10 +275,17 @@ export default function ConstellationCanvas({ sensors, sensorValuesRef, telemetr if (sNode && tNode && !sNode.behindCamera && !tNode.behindCamera) { const isSelected = selectedIds.includes(sNode.id) && selectedIds.includes(tNode.id); - // Calculate Correlation - const h1 = telemetryHistoryRef.current?.[sNode.id] || []; - const h2 = telemetryHistoryRef.current?.[tNode.id] || []; - const r = calculateCorrelation(h1, h2); + // Calculate Correlation (cached — TTL avoids per-frame Pearson recompute) + const cacheKey = sNode.id < tNode.id ? `${sNode.id}|${tNode.id}` : `${tNode.id}|${sNode.id}`; + const nowMs = performance.now(); + let cached = correlationCacheRef.current.get(cacheKey); + if (!cached || cached.expiresAt <= nowMs) { + const h1 = telemetryHistoryRef.current?.[sNode.id] || []; + const h2 = telemetryHistoryRef.current?.[tNode.id] || []; + cached = { r: calculateCorrelation(h1, h2), expiresAt: nowMs + CORRELATION_TTL_MS }; + correlationCacheRef.current.set(cacheKey, cached); + } + const r = cached.r; const meta = getCorrelationMeta(r); const alpha = (isSelected ? 0.9 : 0.25) * Math.min(sNode.scale, tNode.scale); @@ -455,7 +489,7 @@ export default function ConstellationCanvas({ sensors, sensorValuesRef, telemetr }; return ( -
+
{ + // refreshKey is referenced solely to invalidate the memo when the active + // DBC changes (e.g. after a .pecan replay import embeds a new DBC). + void refreshKey; return getLoadedDbcMessages(); - }, []); + }, [refreshKey]); const sensors = useMemo(() => { // 1. Identify all unique categories and count their signal density diff --git a/pecan/src/pages/Constellation.tsx b/pecan/src/pages/Constellation.tsx index a598079..8d6a196 100644 --- a/pecan/src/pages/Constellation.tsx +++ b/pecan/src/pages/Constellation.tsx @@ -2,19 +2,40 @@ import { useRef, useState, useCallback, useEffect } from 'react'; import ConstellationCanvas from '../components/Constellation'; import { ConstellationExportModal } from '../components/ConstellationExportModal'; import { useConstellationSignals } from '../hooks/useConstellationSignals'; -import { dataStore } from '../lib/DataStore'; +import { dataStore, type TelemetrySample } from '../lib/DataStore'; +import { useTimeline } from '../context/TimelineContext'; +import TimelineBar from '../components/TimelineBar'; const HISTORY_LEN = 50; export default function ConstellationPage() { - const sensors = useConstellationSignals(); + const { source, mode, selectedTimeMs, windowMs, replaySession } = useTimeline(); + + // Re-enumerate sensors when a new replay session loads (its embedded DBC may + // differ from the live DBC) so signals reflect the imported file. + const sensorsRefreshKey = source === "replay" + ? `replay:${replaySession?.loadedAtMs ?? 0}` + : "live"; + const sensors = useConstellationSignals(sensorsRefreshKey); + const sensorValuesRef = useRef>({}); const telemetryHistoryRef = useRef>({}); const [showExport, setShowExport] = useState(false); const [selectedForExport, setSelectedForExport] = useState([]); - // Keep sensorValuesRef.current up-to-date with live data + // When pinned to a cursor (replay, or live in paused mode) we rebuild the + // sensor value/history refs from the cursor on every change so correlations + // and node colors reflect the historical moment, not the latest live frame. + const isPinnedToCursor = source === "replay" || mode === "paused"; + + // Live + live: subscribe to incoming frames and accumulate a rolling history. + // Wipe refs on entry so residual replay data doesn't bleed into correlations. useEffect(() => { + if (isPinnedToCursor) return; + + sensorValuesRef.current = {}; + telemetryHistoryRef.current = {}; + const unsub = dataStore.subscribe(() => { const allLatest = dataStore.getAllLatest(); const vals: Record = {}; @@ -22,7 +43,6 @@ export default function ConstellationPage() { for (const sigName in sample.data) { const key = `${sample.msgID}:${sigName}`; vals[key] = sample.data[sigName].sensorReading; - // update rolling history if (!telemetryHistoryRef.current[key]) { telemetryHistoryRef.current[key] = []; } @@ -35,7 +55,44 @@ export default function ConstellationPage() { sensorValuesRef.current = vals; }); return unsub; - }, []); + }, [isPinnedToCursor]); + + // Pinned to cursor: rebuild values and rolling histories from the data + // store every time the cursor moves. This overwrites the refs entirely so + // there's no need to reset separately on source transitions. + useEffect(() => { + if (!isPinnedToCursor) return; + + const allLatest = dataStore.getAllLatestAt(selectedTimeMs, source); + + const vals: Record = {}; + const histories: Record = {}; + const windowCache = new Map(); + + for (const s of sensors) { + const latest = allLatest.get(s.msgID); + const reading = latest?.data?.[s.sigName]?.sensorReading; + if (typeof reading === "number") { + vals[s.id] = reading; + } + + let history = windowCache.get(s.msgID); + if (!history) { + history = dataStore.getHistoryAt(s.msgID, windowMs, selectedTimeMs, source); + windowCache.set(s.msgID, history); + } + + const hist: number[] = []; + for (const sample of history) { + const v = sample.data?.[s.sigName]?.sensorReading; + if (typeof v === "number") hist.push(v); + } + histories[s.id] = hist.length > HISTORY_LEN ? hist.slice(-HISTORY_LEN) : hist; + } + + sensorValuesRef.current = vals; + telemetryHistoryRef.current = histories; + }, [isPinnedToCursor, selectedTimeMs, source, windowMs, sensors]); const handleExport = useCallback((constellationIds: string[]) => { setSelectedForExport(constellationIds); @@ -43,19 +100,27 @@ export default function ConstellationPage() { }, []); return ( -
- - {showExport && ( - setShowExport(false)} +
+
+ +
+
+ - )} + {showExport && ( + setShowExport(false)} + /> + )} +
); }