Files
next-tjwater-frontend/src/features/workbench/utils/presentation-view-history.ts
T
jiang 03827a2fc0
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
feat(workbench): group live presentation steps into expandable categories
Group adjacent live display steps into category menus with merged snapshots, original-step submenus and scoped playback.

The submenu exposes the latest raw step, so explicit selections must remain pinned. Locate failed and cancelled steps using the full group sequence, and prevent an exiting menu from dismissing a trigger-owned reopen. Regression tests reproduce these paths before the fixes.

Validation: lint, typecheck, 1178 unit tests and 10 focused browser cases passed; update the presentation history guide.
2026-11-12 12:54:09 +08:00

214 lines
7.3 KiB
TypeScript

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<Extract<InvestigationPresentationCommand, { action: "open_chart" }>>;
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
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<keyof PresentationView>;
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<PresentationHistoryEntry["status"], "running">,
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;
}
}