Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions pecan/src/components/Constellation.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,9 +11,27 @@ interface ConstellationCanvasProps {
sensorValuesRef: React.RefObject<Record<string, number>>;
telemetryHistoryRef: React.RefObject<Record<string, number[]>>;
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<number>(cursorTimeMs ?? Date.now());
const sourceRef = useRef<TelemetrySource>(source);
const modeRef = useRef<TimelineMode>(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<HTMLCanvasElement>(null);
const offscreenCanvasRef = useRef<HTMLCanvasElement | null>(null);

Expand Down Expand Up @@ -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<Map<string, { r: number, expiresAt: number }>>(new Map());
const CORRELATION_TTL_MS = 150;

useEffect(() => {
const canvas = canvasRef.current;
Expand Down Expand Up @@ -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 };
Expand All @@ -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);
Expand Down Expand Up @@ -455,7 +489,7 @@ export default function ConstellationCanvas({ sensors, sensorValuesRef, telemetr
};

return (
<div className="relative w-full h-screen overflow-hidden select-none" style={{ background: '#020617' }}>
<div className="relative w-full h-full overflow-hidden select-none" style={{ background: '#020617' }}>
<canvas
ref={canvasRef}
className="absolute inset-0 z-0"
Expand Down
7 changes: 5 additions & 2 deletions pecan/src/hooks/useConstellationSignals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ function categoryHex(categoryName: string): string {
return TAILWIND_HEX_MAP[cat.color] ?? DEFAULT_HEX;
}

export function useConstellationSignals(): SensorStar[] {
export function useConstellationSignals(refreshKey?: string | number): SensorStar[] {
const messages = useMemo(() => {
// 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
Expand Down
99 changes: 82 additions & 17 deletions pecan/src/pages/Constellation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,47 @@ 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<Record<string, number>>({});
const telemetryHistoryRef = useRef<Record<string, number[]>>({});
const [showExport, setShowExport] = useState(false);
const [selectedForExport, setSelectedForExport] = useState<string[]>([]);

// 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<string, number> = {};
allLatest.forEach((sample) => {
Comment on lines +40 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Append history only for the message that changed

This subscription rebuilds from getAllLatest() on every single datastore update and appends every signal into telemetryHistoryRef, even when that signal did not receive a new frame. Since subscribe is triggered per ingested message, unrelated high-rate traffic will duplicate stale values for other IDs and skew correlation/insight calculations. Use the updatedMsgID callback argument (or per-signal timestamp checks) so only truly updated signals are appended.

Useful? React with 👍 / 👎.

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] = [];
}
Expand All @@ -35,27 +55,72 @@ 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<string, number> = {};
const histories: Record<string, number[]> = {};
const windowCache = new Map<string, TelemetrySample[]>();

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);
setShowExport(true);
}, []);

return (
<div className="w-full h-screen overflow-hidden">
<ConstellationCanvas
sensors={sensors}
sensorValuesRef={sensorValuesRef}
telemetryHistoryRef={telemetryHistoryRef}
onExport={handleExport}
/>
{showExport && (
<ConstellationExportModal
signalIds={selectedForExport}
onClose={() => setShowExport(false)}
<div className="w-full h-screen overflow-hidden flex flex-col">
<div className="shrink-0 px-4 pt-2 z-30">
<TimelineBar />
</div>
<div className="flex-1 relative min-h-0">
<ConstellationCanvas
sensors={sensors}
sensorValuesRef={sensorValuesRef}
telemetryHistoryRef={telemetryHistoryRef}
onExport={handleExport}
cursorTimeMs={selectedTimeMs}
source={source}
mode={mode}
/>
)}
{showExport && (
<ConstellationExportModal
signalIds={selectedForExport}
onClose={() => setShowExport(false)}
/>
)}
</div>
</div>
);
}
Loading