import type { FeatureCollection } from "geojson"; import type { AnswerUiBlock } from "@/features/agent"; import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation"; import type { ScadaAnalysisItem } from "../map/scada-analysis"; import type { InvestigationPresentationActivity } from "./investigation-dev-preview"; export type PresentationCamera = { center: [number, number]; zoom: number; bearing: number; pitch: number; }; export type PresentationView = { charts: Array>; blocks: AnswerUiBlock[]; records: Array>; evidence: Array<{ evidence_id: string; summary: string }>; highlights: FeatureCollection; highlightLayers: string[]; overlay: InvestigationOverlay | null; scada: { data: FeatureCollection; items: ScadaAnalysisItem[] } | null; camera: PresentationCamera | null; }; export type PresentationHistoryEntry = { id: number; key?: string; taskId?: string; executionGeneration?: number; mergedStepIds?: number[]; mergedSummary?: string; category?: PresentationHistoryCategory; chartId?: string; label: string; source: InvestigationPresentationActivity["source"]; taskTitle: string | null; createdAt: number; status: "running" | "completed" | "error" | "cancelled"; message?: string; snapshot: PresentationView; }; export type PresentationHistoryCategory = "charts" | "scada" | "map" | "overlay" | "results"; export type PresentationHistoryControls = { currentEntryId: number | null; entries: PresentationHistoryEntry[]; selectedId: number | null; mergedEntry?: PresentationHistoryEntry | null; mergedEntries?: PresentationHistoryEntry[]; onSelect: (id: number | null) => void; playback?: { entries?: PresentationHistoryEntry[]; playing: boolean; speed: number; onToggle: () => void; onSpeedChange: (speed: number) => void; onSeek: (id: number) => void; }; }; export const emptyPresentationView = (): PresentationView => ({ charts: [], blocks: [], records: [], evidence: [], highlights: { type: "FeatureCollection", features: [] }, highlightLayers: [], overlay: null, scada: null, camera: null }); /** Page-local immutable snapshots. Updating current never changes a pinned historical view. */ export class PresentationViewHistory { current = emptyPresentationView(); entries: PresentationHistoryEntry[] = []; selectedId: number | null = null; playing = false; playbackEntries: PresentationHistoryEntry[] = []; private sequence = 0; private selectedMerged: PresentationHistoryEntry | null = null; get playbackIds() { return this.playbackEntries.map((entry) => entry.id); } get currentEntryId() { if (this.entries.at(-1)?.status === "running") return null; // Results share immutable values with their snapshot. Capturing a manually moved // camera for navigation does not turn the latest result into an older result. const keys = Object.keys(this.current) as Array; return ( this.entries .slice() .reverse() .find( (entry) => entry.status === "completed" && keys.every((key) => key === "camera" || entry.snapshot[key] === this.current[key]) )?.id ?? null ); } get selected() { if (this.selectedId === this.selectedMerged?.id) return this.selectedMerged; return this.playbackEntries.find((entry) => entry.id === this.selectedId) ?? this.entries.find((entry) => entry.id === this.selectedId) ?? null; } get visible() { return this.selected?.snapshot ?? this.current; } update(next: PresentationView) { this.current = next; } begin( label: string, source: PresentationHistoryEntry["source"], taskTitle: string | null, metadata: { key?: string; taskId?: string; executionGeneration?: number; createdAt?: number; category?: PresentationHistoryCategory; chartId?: string } = {} ) { const existing = metadata.key ? this.entries.find((entry) => entry.key === metadata.key) : null; const id = existing?.id ?? ++this.sequence; const entry: PresentationHistoryEntry = { id, label, source, taskTitle, createdAt: existing?.createdAt ?? Date.now(), ...metadata, status: "running", snapshot: this.current }; this.entries = existing ? this.entries.map((previous) => previous.id === id ? entry : previous) : [...this.entries, entry]; return id; } finish( id: number, status: Exclude, message?: string, snapshot = this.current, expected?: PresentationHistoryEntry ) { // Hydration can replace this row before an older cancelled request settles. this.entries = this.entries.map((entry) => entry.id === id && (!expected || entry === expected) ? { ...entry, status, message, snapshot } : entry ); } record( label: string, source: PresentationHistoryEntry["source"], taskTitle: string | null, metadata: { key?: string; taskId?: string; executionGeneration?: number; createdAt?: number; category?: PresentationHistoryCategory; chartId?: string } = {} ) { const id = this.begin(label, source, taskTitle, metadata); this.finish(id, "completed"); return id; } select(id: number | null) { this.pausePlayback(); if (id !== null && !this.entries.some((entry) => entry.id === id && entry.status !== "running")) return false; this.selectedId = id; this.selectedMerged = null; this.playbackEntries = []; return true; } selectMerged(entry: PresentationHistoryEntry) { this.pausePlayback(); this.selectedMerged = entry; this.selectedId = entry.id; this.playbackEntries = []; } startPlayback(entries: PresentationHistoryEntry[]) { const completed = entries.filter((entry) => entry.status === "completed"); if (!completed.length) return false; const index = completed.findIndex((entry) => entry.id === this.selectedId || (this.selectedId !== null && entry.mergedStepIds?.includes(this.selectedId))); this.seekPlayback(completed[index < 0 || index === completed.length - 1 ? 0 : index].id, completed); this.playing = true; return true; } seekPlayback(id: number, entries = this.playbackEntries) { const completed = entries.filter((entry) => entry.status === "completed"); const entry = completed.find((entry) => entry.id === id); if (!entry) return false; this.pausePlayback(); this.playbackEntries = completed; this.selectedMerged = entry.mergedStepIds ? entry : null; this.selectedId = entry.id; return true; } advancePlayback() { if (!this.playing) return false; const index = this.playbackEntries.findIndex((entry) => entry.id === this.selectedId); const next = this.playbackEntries[index + 1]; if (next === undefined) { this.pausePlayback(); return false; } this.selectedMerged = next.mergedStepIds ? next : null; this.selectedId = next.id; return true; } pausePlayback() { this.playing = false; } reset() { this.pausePlayback(); this.playbackEntries = []; this.current = emptyPresentationView(); this.entries = []; this.selectedId = null; this.selectedMerged = null; } }