feat(workbench): group live presentation steps into expandable categories
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s

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.
This commit is contained in:
2026-11-12 12:54:09 +08:00
parent 7a304a91a8
commit 03827a2fc0
18 changed files with 1029 additions and 84 deletions
@@ -93,8 +93,11 @@ export function InvestigationPresentationPanel({
const immediate = Boolean(reducedMotion) || keyboardAction;
const contentId = useId();
const reviewing = history?.selectedId != null;
const selected = history?.entries.find((entry) => entry.id === history.selectedId);
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
const selected = history?.mergedEntry?.id === history?.selectedId
? history?.mergedEntry
: history?.entries.find((entry) => entry.id === history.selectedId);
const selectedLastId = selected?.mergedStepIds?.at(-1) ?? selected?.id;
const pending = selectedLastId !== undefined ? history!.entries.filter((entry) => entry.id > selectedLastId).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
const references = (ids: string[]) =>
ids.length ? (
@@ -191,6 +194,9 @@ export function InvestigationPresentationPanel({
: "跟随所选调查,展示地图操作与结果"}
</p>
{history?.entries.length ? <PresentationHistorySwitcher {...history} playback={collapsed ? undefined : history.playback} /> : null}
{selected?.mergedSummary ? <p aria-label="合并展示类别" className="sr-only">
合并内容:{selected.mergedSummary}
</p> : null}
</motion.header>
<AnimatePresence initial={false} mode="popLayout">
{!collapsed ? (
@@ -0,0 +1,132 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal,
DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { groupAdjacentPresentationHistory, presentationHistoryStepLabel, type PresentationHistoryGroup } from "../utils/presentation-history-categories";
import type { PresentationHistoryControls, PresentationHistoryEntry } from "../utils/presentation-view-history";
const compactQuery = "(max-width: 639px)";
const compactSnapshot = () => window.matchMedia(compactQuery).matches;
function subscribeCompact(listener: () => void) {
const media = window.matchMedia(compactQuery);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}
const rowClass = "min-h-8 gap-2 text-xs active:bg-accent";
export function PresentationHistoryMenu({ entries, selectedId, mergedEntry, mergedEntries, onSelect }: PresentationHistoryControls) {
const [open, setOpen] = useState(false);
const [expandedGroupId, setExpandedGroupId] = useState<number | null>(null);
const [activeGroupId, setActiveGroupId] = useState<number | null>(null);
const compact = useSyncExternalStore(subscribeCompact, compactSnapshot, () => false);
const contentRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const choices = mergedEntries ?? (mergedEntry ? [mergedEntry] : []);
const groups = groupAdjacentPresentationHistory(entries);
const activeGroup = groups.find((group) => group.id === activeGroupId);
const selected = mergedEntry?.id === selectedId ? mergedEntry
: choices.find((entry) => entry.id === selectedId) ?? entries.find((entry) => entry.id === selectedId);
const triggerLabel = selected?.mergedStepIds ? selected.label
: selected ? `${entries.indexOf(selected) + 1}. ${presentationHistoryStepLabel(selected)}` : "当前(最新)";
useEffect(() => {
if (!compact || !open) return;
const frame = requestAnimationFrame(() => contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus());
return () => cancelAnimationFrame(frame);
}, [activeGroupId, compact, open]);
const close = () => { setOpen(false); setActiveGroupId(null); setExpandedGroupId(null); };
const select = (id: number | null) => { onSelect(id); close(); };
const items = (group: PresentationHistoryGroup) => <GroupHistoryItems
group={group} entries={entries} selectedId={selectedId} onSelect={select} />;
return <DropdownMenu modal={false} open={open} onOpenChange={(next) => { if (next) setOpen(true); else close(); }}>
<DropdownMenuTrigger asChild>
<Button ref={triggerRef} type="button" variant="outline" aria-label="切换展示动作" title={selected?.label ?? triggerLabel}
className="form-field h-8 min-w-0 flex-1 justify-between gap-2 px-2 text-xs font-normal">
<span className="min-w-0 truncate">{triggerLabel}</span>
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent ref={contentRef} align="start" sideOffset={4} collisionPadding={12}
onPointerDownOutside={(event) => {
// The trigger owns toggling. An exiting menu must not dismiss a fresh
// open from the same pointer event while its closing animation finishes.
if (event.target instanceof Node && triggerRef.current?.contains(event.target)) event.preventDefault();
}}
aria-label={compact && activeGroup ? `${activeGroup.label}展示步骤` : "展示类别"} aria-labelledby={undefined}
className={`workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)] p-1 ${compact ? "w-72" : "w-40"}`}>
{compact && activeGroup ? <>
<DropdownMenuItem className={rowClass} onSelect={(event) => { event.preventDefault(); setActiveGroupId(null); }}>
<ChevronLeft aria-hidden />返回类别
</DropdownMenuItem>
<DropdownMenuSeparator />
{items(activeGroup)}
</> : <>
<DropdownMenuItem className={rowClass} aria-current={selectedId === null ? "true" : undefined} onSelect={() => select(null)}>
<Radio aria-hidden />当前(最新)
{selectedId === null ? <Check className="ml-auto" aria-hidden /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
{groups.slice().reverse().map((group) => {
const view = choices.find((entry) => entry.id === group.id) ?? group.entries.slice().reverse().find((entry) => entry.status !== "running");
const selectedGroup = selectedId === group.id || group.entries.some((entry) => entry.id === selectedId);
const row = <div className="flex items-center" data-history-group-row data-history-group-id={group.id}>
<DropdownMenuItem className={`${rowClass} min-w-0 flex-1 justify-between`} aria-label={group.label}
aria-current={selectedGroup ? "true" : undefined} disabled={!view}
title={`步骤 ${entries.indexOf(group.entries[0]) + 1}–${entries.indexOf(group.entries.at(-1)!) + 1}`}
onKeyDown={(event) => {
if (!compact && event.key === "ArrowRight") { event.preventDefault(); setExpandedGroupId(group.id); }
}} onSelect={() => { if (view) select(view.id); }}>
{group.label}{selectedGroup ? <Check aria-hidden /> : null}
</DropdownMenuItem>
{compact ? <DropdownMenuItem aria-label={`展开${group.label}步骤`} className={`${rowClass} size-8 justify-center px-2`}
onSelect={(event) => { event.preventDefault(); setActiveGroupId(group.id); }}>
<ChevronRight aria-hidden />
</DropdownMenuItem> : <DropdownMenuSubTrigger aria-label={`展开${group.label}步骤`}
className={`${rowClass} size-8 justify-center px-2 [&>svg]:ml-0`} />}
</div>;
return compact ? <div key={group.id}>{row}</div> : <DropdownMenuSub key={group.id}
open={expandedGroupId === group.id} onOpenChange={(next) => setExpandedGroupId((previous) => next ? group.id : previous === group.id ? null : previous)}>
{row}
<DropdownMenuPortal>
<DropdownMenuSubContent aria-label={`${group.label}展示步骤`} aria-labelledby={undefined} collisionPadding={12} sideOffset={4}
onFocusOutside={(event) => {
const target = event.target;
const parentMenu = contentRef.current;
// Radix focuses the parent menu between pointer targets. Both actions
// in this split row still belong to the same expanded category.
if (target instanceof Node && (target === parentMenu ||
parentMenu?.querySelector(`[data-history-group-id="${group.id}"]`)?.contains(target)))
event.preventDefault();
}}
className="workbench-shadcn-surface z-[91] max-h-(--radix-dropdown-menu-content-available-height) w-80 max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain">
{items(group)}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>;
})}
</>}
</DropdownMenuContent>
</DropdownMenu>;
}
function GroupHistoryItems({ group, entries, selectedId, onSelect }: {
group: PresentationHistoryGroup;
entries: PresentationHistoryEntry[];
selectedId: number | null;
onSelect: (id: number) => void;
}) {
return group.entries.slice().reverse().map((entry) => <DropdownMenuItem key={entry.id}
className={`${rowClass} items-start`} disabled={entry.status === "running"}
aria-label={`${entries.indexOf(entry) + 1}. ${presentationHistoryStepLabel(entry)}`}
aria-current={entry.id === selectedId ? "true" : undefined} onSelect={() => onSelect(entry.id)}>
<span className="min-w-0 flex-1 break-words leading-5">{entries.indexOf(entry) + 1}. {presentationHistoryStepLabel(entry)}</span>
{entry.status === "running" ? <LoaderCircle className="mt-0.5 animate-spin" aria-label="进行中" />
: entry.status !== "completed" ? <CircleAlert className="mt-0.5 text-muted-foreground" aria-label={entry.status === "error" ? "未完成" : "已取消"} />
: entry.id === selectedId ? <Check className="mt-0.5" aria-hidden /> : null}
</DropdownMenuItem>);
}
@@ -1,11 +1,60 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import "@/test/radix-select-test-setup";
import { PresentationViewHistory } from "../utils/presentation-view-history";
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { mergeAdjacentPresentationHistory } from "../utils/merge-presentation-history";
afterEach(cleanup);
beforeEach(() => {
vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
});
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it.each(["error", "cancelled"] as const)("keeps navigation ordered when a %s step is selected", async (status) => {
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts" });
const failed = history.begin("地图定位", "live", "调查", { ...metadata, category: "map" });
history.finish(failed, status);
history.record("打开图表 · B", "live", "调查", { ...metadata, category: "charts" });
const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
const onSelect = vi.fn();
const user = userEvent.setup();
render(<PresentationHistorySwitcher entries={history.entries} currentEntryId={history.currentEntryId}
selectedId={failed} mergedEntries={groups} onSelect={onSelect} />);
expect(screen.getByRole("button", { name: "上一个动作" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "上一个动作" }));
expect(onSelect).toHaveBeenLastCalledWith(groups[0].id);
await user.click(screen.getByRole("button", { name: "下一个动作" }));
expect(onSelect).toHaveBeenLastCalledWith(groups[1].id);
});
it("selects merged adjacent steps from their concise first-level title and lists only original steps below", async () => {
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
history.record("打开图表 · 压力曲线", "live", "SCADA 异常调查", { ...metadata, category: "charts" });
history.record("打开图表 · 流量曲线", "live", "SCADA 异常调查", { ...metadata, category: "charts" });
history.record("SCADA 分析", "live", "SCADA 异常调查", { ...metadata, category: "scada" });
const mergedEntries = mergeAdjacentPresentationHistory(history.entries, "task", "SCADA 异常调查");
const onSelect = vi.fn();
const user = userEvent.setup();
render(<PresentationHistorySwitcher entries={history.entries} currentEntryId={history.entries[2].id}
selectedId={null} mergedEntry={mergedEntries[0]} mergedEntries={mergedEntries} onSelect={onSelect} />);
await user.click(screen.getByRole("button", { name: "切换展示动作" }));
const menu = screen.getByRole("menu", { name: "展示类别" });
expect([...menu.querySelectorAll("[data-history-group-row]")].map((item) => item.textContent)).toEqual(["SCADA 分析", "图表"]);
expect(screen.queryByText("合并查看")).not.toBeInTheDocument();
await user.click(within(menu).getByRole("menuitem", { name: "图表" }));
expect(onSelect).toHaveBeenLastCalledWith(mergedEntries[0].id);
await user.click(screen.getByRole("button", { name: "切换展示动作" }));
await user.click(screen.getByRole("menuitem", { name: "展开图表步骤" }));
const charts = screen.getByRole("menu", { name: "图表展示步骤" });
expect(within(charts).getAllByRole("menuitem").map((item) => item.textContent)).toEqual(["2. 流量曲线", "1. 压力曲线"]);
within(charts).getByRole("menuitem", { name: "1. 压力曲线" }).focus();
await user.keyboard("{Enter}");
expect(onSelect).toHaveBeenLastCalledWith(history.entries[0].id);
});
function entries() {
const history = new PresentationViewHistory();
@@ -79,3 +128,39 @@ it("offers playback, speed, progress and keeps the latest snapshot pinned until
await user.click(screen.getByRole("button", { name: "回到当前" }));
expect(onSelect).toHaveBeenCalledWith(null);
});
it("counts and navigates first-level groups, mapping an original step to its parent position", async () => {
const user = userEvent.setup();
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
history.record("chart one", "live", "调查", { ...metadata, category: "charts" });
const child = history.record("chart two", "live", "调查", { ...metadata, category: "charts" });
history.record("map", "live", "调查", { ...metadata, category: "map" });
history.record("chart three", "live", "调查", { ...metadata, category: "charts" });
const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
const onSelect = vi.fn();
const playback = { entries: groups, playing: false, speed: 1, onToggle: vi.fn(), onSpeedChange: vi.fn(), onSeek: vi.fn() };
const props = { entries: history.entries, currentEntryId: history.currentEntryId,
mergedEntries: groups, onSelect, playback };
const { rerender } = render(<PresentationHistorySwitcher {...props} selectedId={groups[0].id} mergedEntry={groups[0]} />);
const slider = screen.getByRole("slider", { name: "历史播放进度" });
expect(slider).toHaveAttribute("max", "2");
expect(slider).toHaveValue("0");
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("1/3");
expect(screen.getByRole("button", { name: "上一个动作" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "下一个动作" }));
expect(onSelect).toHaveBeenLastCalledWith(groups[1].id);
fireEvent.change(slider, { target: { value: "1" } });
expect(playback.onSeek).toHaveBeenLastCalledWith(groups[1].id);
rerender(<PresentationHistorySwitcher {...props} selectedId={child} />);
expect(slider).toHaveValue("0");
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("1/3");
await user.click(screen.getByRole("button", { name: "下一个动作" }));
expect(onSelect).toHaveBeenLastCalledWith(groups[1].id);
rerender(<PresentationHistorySwitcher {...props} selectedId={groups[1].id} mergedEntry={groups[1]} />);
expect(slider).toHaveValue("1");
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("2/3");
rerender(<PresentationHistorySwitcher {...props} selectedId={null} />);
expect(slider).toHaveValue("2");
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("3/3");
});
@@ -2,6 +2,9 @@ import { ChevronLeft, ChevronRight, Pause, Play, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
import { groupAdjacentPresentationHistory } from "../utils/presentation-history-categories";
import { PresentationHistoryMenu } from "./presentation-history-menu";
const statusLabels = { running: "进行中", completed: "完成", error: "未完成", cancelled: "已取消" };
@@ -9,19 +12,28 @@ export function PresentationHistorySwitcher({
entries,
currentEntryId,
selectedId,
mergedEntry,
mergedEntries,
onSelect,
playback
}: PresentationHistoryControls) {
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
const available = entries.filter(
const playable = playback?.entries ?? (mergedEntries?.length ? mergedEntries : entries).filter((entry) => entry.status === "completed");
const grouped = entries.some((entry) => entry.taskId);
const available = grouped ? groupAdjacentPresentationHistory(entries).flatMap((group) => {
const view = mergedEntries?.find((entry) => entry.id === group.id)
?? group.entries.slice().reverse().find((entry) => entry.status !== "running");
return view ? [{ id: view.id, stepIds: group.entries.map((entry) => entry.id) }] : [];
}) : entries.filter(
(entry) => entry.status !== "running" && (entry.id !== currentEntryId || entry.id === selectedId)
);
).map((entry) => ({ id: entry.id, stepIds: [entry.id] }));
const index =
selectedId === null
? available.length
: available.findIndex((entry) => entry.id === selectedId);
const playable = entries.filter((entry) => entry.status === "completed");
const progress = Math.max(0, playable.findIndex((entry) => entry.id === selectedId));
: available.findIndex((entry) => entry.id === selectedId ||
entry.stepIds.includes(selectedId));
const progress = selectedId === null ? Math.max(0, playable.length - 1)
: Math.max(0, playable.findIndex((entry) => entry.id === selectedId || entry.mergedStepIds?.includes(selectedId)));
return (
<div className="mt-2 min-w-0 space-y-1.5" role="group" aria-label="展示动作历史">
<div className="flex min-w-0 items-center gap-1">
@@ -36,7 +48,10 @@ export function PresentationHistorySwitcher({
>
<ChevronLeft size={14} aria-hidden />
</Button>
<Select
{entries.some((entry) => entry.taskId) ? (
<PresentationHistoryMenu entries={entries} currentEntryId={currentEntryId}
selectedId={selectedId} mergedEntry={mergedEntry} mergedEntries={mergedEntries} onSelect={onSelect} />
) : <Select
value={selectedId === null ? "current" : String(selectedId)}
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
>
@@ -72,7 +87,7 @@ export function PresentationHistorySwitcher({
)
)}
</SelectContent>
</Select>
</Select>}
<Button
type="button"
variant="ghost"
@@ -106,12 +121,12 @@ export function PresentationHistorySwitcher({
{playback.playing ? <Pause size={14} aria-hidden /> : <Play size={14} aria-hidden />}
</Button>
<input type="range" aria-label="历史播放进度" className="h-8 w-0 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-default"
min={0} max={Math.max(0, playable.length - 1)} step={1} value={selectedId === null ? Math.max(0, playable.length - 1) : progress}
min={0} max={Math.max(0, playable.length - 1)} step={1} value={progress}
disabled={playable.length < 2}
aria-valuetext={selectedId === null ? "当前最新结果" : `第 ${progress + 1} 个,共 ${playable.length} 个结果`}
onChange={(event) => { const entry = playable[Number(event.target.value)]; if (entry) playback.onSeek(entry.id); }} />
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground" aria-label="历史播放位置">
{selectedId === null ? playable.length : playable.length ? progress + 1 : 0}/{playable.length}
{playable.length ? progress + 1 : 0}/{playable.length}
</span>
<Select value={String(playback.speed)} onValueChange={(value) => playback.onSpeedChange(Number(value))}>
<SelectTrigger aria-label="历史播放速度"
@@ -15,6 +15,18 @@ const options = { scope: "preview" as const, enabled: false, task: null, project
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("pins an explicitly selected latest step until returning to current", async () => {
const { result } = renderHook(() => useInvestigationPresentation(options));
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "selected" }], commands: [] }));
const latestId = result.current.history.entries.at(-1)!.id;
act(() => result.current.history.onSelect(latestId));
expect(result.current.history.selectedId).toBe(latestId);
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "new current" }], commands: [] }));
expect(result.current.records).toEqual([{ value: "selected" }]);
act(() => result.current.history.onSelect(null));
expect(result.current.records).toEqual([{ value: "new current" }]);
});
it("never commits a previous task's display under the newly selected task scope", async () => {
const commits: Array<{ scope: string; records: unknown[] }> = [];
const { result, rerender } = renderHook(({ scopeId }) => {
@@ -16,6 +16,8 @@ import {
} from "../api/investigation-presentation";
import { hydrateInvestigationPresentationArchive } from "../utils/investigation-presentation-archive";
import { presentationCommandLabel } from "../utils/presentation-command-label";
import { mergeAdjacentPresentationHistory } from "../utils/merge-presentation-history";
import { presentationCommandCategory } from "../utils/presentation-history-categories";
import type {
InvestigationPresentationCommand
} from "../api/investigation-presentation";
@@ -187,12 +189,28 @@ export function useInvestigationPresentation(options: Options) {
const record = useCallback(
(label: string, origin = source()) => {
storeRef.current.record(label, origin, valuesRef.current.task?.title ?? null, {
taskId: valuesRef.current.task?.task_id
taskId: valuesRef.current.task?.task_id,
executionGeneration: valuesRef.current.task?.execution_generation
});
publish();
},
[publish, source]
);
const historyEntries = storeRef.current.entries;
const taskEntries =
!options.scope || options.scope === "investigation"
? historyEntries.filter((entry) => entry.taskId === taskId)
: historyEntries;
const mergedEntries = useMemo(() => {
if ((options.scope && options.scope !== "investigation") || !options.task) return [];
return mergeAdjacentPresentationHistory(historyEntries, options.task.task_id, options.task.title);
}, [options.scope, options.task, historyEntries]);
const selectedMerged = storeRef.current.selected?.mergedStepIds ? storeRef.current.selected : null;
const mergedHistory = mergedEntries.map((entry) => selectedMerged && entry.id === selectedMerged.id ? selectedMerged : entry);
// Keep both group order and composed snapshots fixed until the user leaves playback.
const playbackEntries = storeRef.current.playbackEntries.length
? storeRef.current.playbackEntries
: (mergedHistory.length ? mergedHistory : taskEntries).filter((entry) => entry.status === "completed");
const selectHistory = useCallback(
(id: number | null, pin = false) => {
const store = storeRef.current;
@@ -202,12 +220,18 @@ export function useInvestigationPresentation(options: Options) {
camera:
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
});
if (!store.select(!pin && id === store.currentEntryId ? null : id)) return;
if (pin && id !== null) {
if (!store.seekPlayback(id, playbackEntries)) return;
} else if (id !== null && id < 0) {
const merged = mergedHistory.find((entry) => entry.id === id);
if (!merged) return;
store.selectMerged(merged);
} else if (!store.select(id)) return;
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
},
[publish, scene]
[publish, scene, mergedHistory, playbackEntries]
);
const clearMapOverlays = useCallback(
@@ -274,7 +298,10 @@ export function useInvestigationPresentation(options: Options) {
presentationCommandLabel(command, store.current),
origin,
valuesRef.current.task?.title ?? null,
{ key: eventKey, taskId: valuesRef.current.task?.task_id }
{ key: eventKey, taskId: valuesRef.current.task?.task_id,
executionGeneration: event?.execution_generation ?? valuesRef.current.task?.execution_generation,
category: presentationCommandCategory(command.action),
chartId: command.action === "open_chart" || command.action === "close_chart" ? command.chart_id : undefined }
);
const pendingEntry = store.entries.find((entry) => entry.id === historyId);
const id = ++activityIdRef.current;
@@ -561,10 +588,6 @@ export function useInvestigationPresentation(options: Options) {
return createInvestigationDevCase(caseId);
}, []);
const taskEntries =
!options.scope || options.scope === "investigation"
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
: storeRef.current.entries;
const playing = storeRef.current.playing;
const selectedHistoryId = storeRef.current.selectedId;
useEffect(() => {
@@ -598,9 +621,12 @@ export function useInvestigationPresentation(options: Options) {
history: {
currentEntryId: storeRef.current.currentEntryId,
entries: taskEntries,
mergedEntry: selectedMerged ?? mergedEntries.at(-1) ?? null,
mergedEntries: mergedHistory,
selectedId: storeRef.current.selectedId,
onSelect: selectHistory,
playback: {
entries: playbackEntries,
playing,
speed: playbackSpeed,
onToggle: () => {
@@ -609,7 +635,7 @@ export function useInvestigationPresentation(options: Options) {
else {
if (store.selectedId === null)
store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera });
store.startPlayback(taskEntries);
store.startPlayback(playbackEntries);
}
publish();
const map = valuesRef.current.mapRef.current;
@@ -10,6 +10,7 @@ import {
type PresentationPorts
} from "../map/presentation-command-executor";
import { presentationCommandLabel } from "./presentation-command-label";
import { presentationCommandCategory } from "./presentation-history-categories";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
/** Hydrate saved display data offscreen. No business actions, SSE publication or map mutations. */
@@ -50,6 +51,9 @@ export async function hydrateInvestigationPresentationArchive({
execution_generation: event.execution_generation,
command: event.command
});
const label = parsed.success
? presentationCommandLabel(parsed.data.command, staging.current)
: "历史展示参数无效";
let message: string | undefined;
try {
if (!parsed.success) throw new Error("已保存的展示指令不符合当前格式,未执行。");
@@ -73,12 +77,13 @@ export async function hydrateInvestigationPresentationArchive({
signal.throwIfAborted();
if (parsed.success && parsed.data.command.action.startsWith("clear_")) continue;
const id = history.begin(
parsed.success
? presentationCommandLabel(parsed.data.command, staging.current)
: "历史展示参数无效",
label,
"live",
`${task.title} · 第 ${generation} 轮`,
{ key, taskId: task.task_id, createdAt: Date.parse(event.occurred_at) }
{ key, taskId: task.task_id, executionGeneration: event.execution_generation, createdAt: Date.parse(event.occurred_at),
category: parsed.success ? presentationCommandCategory(parsed.data.command.action) : "results",
chartId: parsed.success && (parsed.data.command.action === "open_chart" || parsed.data.command.action === "close_chart")
? parsed.data.command.chart_id : undefined }
);
history.finish(id, message ? "error" : "completed", message, staging.current);
}
@@ -99,6 +104,8 @@ export async function hydrateInvestigationPresentationArchive({
const id = history.begin(`报告第 ${report.revision} 版 · 数据与分析`, "live", task.title, {
key,
taskId: task.task_id,
executionGeneration: report.execution_generation,
category: "results",
createdAt: Date.parse(report.published_at)
});
history.finish(id, "completed", undefined, current);
@@ -0,0 +1,139 @@
import { expect, it } from "vitest";
import { mergePresentationHistory, mergeAdjacentPresentationHistory } from "./merge-presentation-history";
import { groupAdjacentPresentationHistory } from "./presentation-history-categories";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
const metadata = { taskId: "task-a", executionGeneration: 2 };
const chart = (id: string, value = 20) => ({ action: "open_chart" as const, chart_id: id, evidence_refs: ["reading"],
chart: { title: id, chart_type: "line" as const, x_data: ["08:00"], series: [{ name: "压力", data: [value] }] } });
const point = (device_id: string) => ({ type: "Feature" as const, properties: { device_id },
geometry: { type: "Point" as const, coordinates: [121.4, 31.1] } });
it("merges consecutive categories in order and prevents earlier charts leaking into a later group", () => {
const history = new PresentationViewHistory();
history.update({ ...history.current, charts: [chart("A")] });
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
history.update({ ...history.current, charts: [chart("A"), chart("B")] });
history.record("打开图表 · B", "live", "调查", { ...metadata, category: "charts", chartId: "B" });
history.update({ ...history.current, scada: { data: { type: "FeatureCollection", features: [point("P1")] }, items: [{ device_id: "P1", level: "high" }] } });
history.record("SCADA 分析", "live", "调查", { ...metadata, category: "scada" });
history.update({ ...history.current, charts: [chart("A"), chart("B"), chart("C")] });
history.record("打开图表 · C", "live", "调查", { ...metadata, category: "charts", chartId: "C" });
const groups = groupAdjacentPresentationHistory(history.entries);
expect(groups.map((group) => [group.label, group.entries.length])).toEqual([["图表", 2], ["SCADA 分析", 1], ["图表", 1]]);
const merged = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查");
expect(merged.map((entry) => entry.id)).toEqual([-1, -3, -4]);
expect(merged.map((entry) => entry.snapshot.charts.map((item) => item.chart_id))).toEqual([["A", "B"], [], ["C"]]);
expect(merged[0].snapshot.scada).toBeNull();
expect(merged[1].snapshot.scada?.items).toHaveLength(1);
expect(merged[2].snapshot.scada).toBeNull();
});
it("groups the selected task's sequence while keeping preview and live results separate", () => {
const history = new PresentationViewHistory();
const record = (extra: Partial<typeof metadata> = {}, source: "live" | "preview" = "live") =>
history.record("打开图表 · A", source, "调查", { ...metadata, category: "charts", chartId: "A", ...extra });
history.update({ ...history.current, charts: [chart("A")] });
record();
record({ taskId: "task-b" });
record();
record({}, "preview");
record({ executionGeneration: 3 });
const merged = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查");
expect(groupAdjacentPresentationHistory(history.entries)).toHaveLength(5);
expect(merged.map((entry) => entry.mergedStepIds)).toEqual([[1, 3], [5]]);
});
it("merges adjacent categories across historical rounds while preserving each original step", () => {
const history = new PresentationViewHistory();
for (const generation of [1, 7]) {
history.update({ ...emptyPresentationView(), charts: [chart(`G${generation}`)] });
history.record(`打开图表 · G${generation}`, "live", "调查", { ...metadata,
executionGeneration: generation, category: "charts", chartId: `G${generation}` });
}
const groups = groupAdjacentPresentationHistory(history.entries);
expect(groups).toHaveLength(1);
expect(groups[0].entries.map((entry) => entry.executionGeneration)).toEqual([1, 7]);
expect(mergeAdjacentPresentationHistory(history.entries, "task-a", "调查")[0].snapshot.charts.map((item) => item.chart_id)).toEqual(["G1", "G7"]);
});
it("does not discard pending or failed intervening categories before grouping", () => {
const history = new PresentationViewHistory();
history.update({ ...history.current, charts: [chart("A")] });
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
const failed = history.begin("SCADA 分析", "live", "调查", { ...metadata, category: "scada" });
history.finish(failed, "error", "not found");
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
history.begin("地图定位", "live", "调查", { ...metadata, category: "map" });
expect(groupAdjacentPresentationHistory(history.entries).map((group) => group.label)).toEqual(["图表", "SCADA 分析", "图表", "地图操作"]);
expect(mergeAdjacentPresentationHistory(history.entries, "task-a", "调查").map((entry) => entry.mergedStepIds)).toEqual([[1], [3]]);
});
it("keeps a selected first-level group frozen while its adjacent live run grows", () => {
const history = new PresentationViewHistory();
history.update({ ...history.current, charts: [chart("A")] });
history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
const selected = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查")[0];
history.selectMerged(selected);
history.update({ ...history.current, charts: [chart("A"), chart("B")] });
history.record("打开图表 · B", "live", "调查", { ...metadata, category: "charts", chartId: "B" });
const next = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查")[0];
expect(next.id).toBe(selected.id);
expect(next.mergedStepIds).toEqual([1, 2]);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A"]);
history.select(null);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A", "B"]);
});
it("combines saved chart and SCADA results with the latest value per object and isolates the task and generation", () => {
const history = new PresentationViewHistory();
history.update({ ...emptyPresentationView(), charts: [chart("old-task")] });
history.record("other task", "live", "其他调查", { ...metadata, taskId: "task-b" });
history.update({ ...emptyPresentationView(), charts: [chart("old-round")] });
history.record("old round", "live", "调查", { ...metadata, executionGeneration: 1 });
history.update({ ...emptyPresentationView(), charts: [chart("A")],
highlights: { type: "FeatureCollection", features: [point("P1")] }, highlightLayers: ["scada"],
scada: { data: { type: "FeatureCollection", features: [point("P1")] }, items: [{ device_id: "P1", level: "high" }] } });
const first = history.record("first", "live", "调查", metadata);
history.update({ ...emptyPresentationView(), charts: [chart("A", 25), chart("B")],
highlights: { type: "FeatureCollection", features: [point("P2")] }, highlightLayers: ["pipes"],
scada: { data: { type: "FeatureCollection", features: [point("P2")] },
items: [{ device_id: "P1", level: "low" }, { device_id: "P2", level: "medium" }] } });
const second = history.record("second", "live", "调查", metadata);
// Closing a chart removes it from current but leaves it available in the combined result.
history.update(emptyPresentationView());
history.record("close", "live", "调查", metadata);
history.update({ ...emptyPresentationView(), charts: [chart("failed")] });
history.finish(history.begin("failed", "live", "调查", metadata), "error");
history.record("preview", "preview", "调查", metadata);
const before = structuredClone(history.entries);
const merged = mergePresentationHistory(history.entries, "task-a", 2, "调查")!;
expect(merged.mergedStepIds).toEqual([first, second, second + 1]);
expect(merged.snapshot.charts.map((item) => [item.chart_id, item.chart.series[0].data[0]])).toEqual([["A", 25], ["B", 20]]);
expect(merged.snapshot.scada?.items).toEqual([{ device_id: "P1", level: "low" }, { device_id: "P2", level: "medium" }]);
expect(merged.snapshot.highlights.features).toHaveLength(2);
expect(merged.snapshot.scada?.data.features).toHaveLength(2);
expect(merged.snapshot.highlightLayers).toEqual(["scada", "pipes"]);
expect(merged.mergedSummary).toBe("图表 2 张 · SCADA 标记 2 个 · 地图高亮 2 个");
expect(history.entries).toEqual(before);
});
it("pins the combined snapshot while live results arrive and preserves single-step playback", () => {
const history = new PresentationViewHistory();
history.update({ ...emptyPresentationView(), charts: [chart("A")] });
history.record("first", "live", "调查", metadata);
expect(mergePresentationHistory(history.entries, "task-a", 2, "调查")).toBeNull();
history.update({ ...emptyPresentationView(), charts: [chart("B")] });
history.record("second", "live", "调查", metadata);
const merged = mergePresentationHistory(history.entries, "task-a", 2, "调查")!;
history.selectMerged(merged);
history.update({ ...emptyPresentationView(), charts: [chart("C")] });
history.record("third", "live", "调查", metadata);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A", "B"]);
expect(history.selected?.mergedStepIds).toHaveLength(2);
history.startPlayback(history.entries);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A"]);
history.select(null);
expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["C"]);
expect(history.entries).toHaveLength(3);
});
@@ -0,0 +1,109 @@
import type { FeatureCollection } from "geojson";
import { emptyPresentationView, type PresentationHistoryEntry, type PresentationHistoryCategory } from "./presentation-view-history";
import { groupAdjacentPresentationHistory, presentationHistoryCategories, presentationHistoryCategory, presentationHistoryStepLabel } from "./presentation-history-categories";
function mergeFeatures(collections: FeatureCollection[]): FeatureCollection {
const features = new Map<string, FeatureCollection["features"][number]>();
for (const collection of collections) {
for (const feature of collection.features) {
// Geometry distinguishes equal numeric IDs from different model layers.
const key = JSON.stringify([feature.geometry, feature.id ?? null, feature.properties?.device_id ?? null]);
features.set(key, feature);
}
}
return { type: "FeatureCollection", features: [...features.values()] };
}
/** Compose captured results only. Never replay commands or mutate their saved snapshots. */
export function mergePresentationHistory(
entries: PresentationHistoryEntry[],
taskId: string,
executionGeneration: number,
taskTitle: string,
category?: PresentationHistoryCategory
): PresentationHistoryEntry | null {
const completed = entries.filter((entry) => entry.taskId === taskId &&
entry.executionGeneration === executionGeneration && entry.source === "live" && entry.status === "completed" &&
(!category || presentationHistoryCategory(entry) === category));
if (completed.length < 2) return null;
return mergeCompletedPresentations(completed, taskId, executionGeneration, taskTitle, category);
}
function mergeCompletedPresentations(
completed: PresentationHistoryEntry[], taskId: string, executionGeneration: number,
taskTitle: string, category?: PresentationHistoryCategory
): PresentationHistoryEntry {
const snapshot = emptyPresentationView();
const charts = new Map<string, typeof snapshot.charts[number]>();
const evidence = new Map<string, typeof snapshot.evidence[number]>();
const scada = new Map<string, NonNullable<typeof snapshot.scada>["items"][number]>();
for (const { snapshot: view } of completed) {
for (const chart of view.charts) charts.set(chart.chart_id, chart);
for (const item of view.evidence) evidence.set(item.evidence_id, item);
for (const item of view.scada?.items ?? []) scada.set(item.device_id, item);
if (view.blocks.length) snapshot.blocks = view.blocks;
if (view.records.length) snapshot.records = view.records;
if (view.overlay) snapshot.overlay = view.overlay;
if (view.camera) snapshot.camera = view.camera;
}
snapshot.charts = [...charts.values()];
snapshot.evidence = [...evidence.values()];
snapshot.highlights = mergeFeatures(completed.map((entry) => entry.snapshot.highlights));
snapshot.highlightLayers = [...new Set(completed.flatMap((entry) => entry.snapshot.highlightLayers))];
if (scada.size) snapshot.scada = {
data: mergeFeatures(completed.flatMap((entry) => entry.snapshot.scada ? [entry.snapshot.scada.data] : [])),
items: [...scada.values()]
};
if (category) {
if (category !== "charts") snapshot.charts = [];
if (category !== "scada") snapshot.scada = null;
if (category !== "map") { snapshot.highlights = { type: "FeatureCollection", features: [] }; snapshot.highlightLayers = []; }
if (category !== "overlay") snapshot.overlay = null;
if (category !== "results") { snapshot.blocks = []; snapshot.records = []; }
if (category === "charts" || category === "results") snapshot.camera = null;
}
const categoryIndex = presentationHistoryCategories.findIndex((item) => item.id === category);
const categoryLabel = presentationHistoryCategories[categoryIndex]?.label;
return {
id: category ? -(executionGeneration * 10 + categoryIndex + 1) : -executionGeneration,
taskId,
executionGeneration,
taskTitle,
category,
source: "live",
label: `${categoryLabel ? `${categoryLabel}合并` : "合并展示"} · 第 ${executionGeneration} 轮 · ${completed.length} 个步骤`,
status: "completed",
createdAt: completed.at(-1)!.createdAt,
mergedStepIds: completed.map((entry) => entry.id),
mergedSummary: [
snapshot.charts.length ? `图表 ${snapshot.charts.length} 张` : null,
snapshot.scada?.items.length ? `SCADA 标记 ${snapshot.scada.items.length} 个` : null,
snapshot.highlights.features.length ? `地图高亮 ${snapshot.highlights.features.length} 个` : null,
snapshot.overlay ? "覆盖层 1 个" : null,
snapshot.records.length ? `数据记录 ${snapshot.records.length} 条` : null,
snapshot.blocks.length ? `报告内容 ${snapshot.blocks.length} 项` : null,
snapshot.camera ? "地图视角" : null
].filter(Boolean).join(" · ") || `展示步骤 ${completed.length} 项`,
snapshot
};
}
export function mergeAdjacentPresentationHistory(
entries: PresentationHistoryEntry[], taskId: string, taskTitle: string
): PresentationHistoryEntry[] {
return groupAdjacentPresentationHistory(entries.filter((entry) => entry.taskId === taskId)).flatMap((group) => {
const first = group.entries[0];
if (first.taskId !== taskId || first.executionGeneration === undefined) return [];
const scoped = group.category === "charts" ? group.entries.map((entry) => {
const named = entry.snapshot.charts.filter((chart) => entry.chartId !== undefined
? chart.chart_id === entry.chartId : chart.chart.title === presentationHistoryStepLabel(entry));
return { ...entry, snapshot: { ...entry.snapshot,
charts: entry.chartId !== undefined || named.length ? named : entry.snapshot.charts } };
}) : group.entries;
const completed = scoped.filter((entry) => entry.source === "live" && entry.status === "completed");
if (!completed.length) return [];
const generation = completed.at(-1)!.executionGeneration ?? first.executionGeneration;
const merged = mergeCompletedPresentations(completed, taskId, generation, taskTitle, group.category);
return [{ ...merged, id: group.id, label: group.label }];
});
}
@@ -0,0 +1,67 @@
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
import type { PresentationHistoryCategory, PresentationHistoryEntry } from "./presentation-view-history";
export const presentationHistoryCategories: Array<{ id: PresentationHistoryCategory; label: string }> = [
{ id: "charts", label: "图表" },
{ id: "scada", label: "SCADA 分析" },
{ id: "map", label: "地图操作" },
{ id: "overlay", label: "覆盖层" },
{ id: "results", label: "报告与数据" }
];
export type PresentationHistoryGroup = {
id: number;
category: PresentationHistoryCategory;
label: string;
entries: PresentationHistoryEntry[];
};
/** Group before excluding failed or pending results so intervening categories retain their position. */
export function groupAdjacentPresentationHistory(entries: PresentationHistoryEntry[]): PresentationHistoryGroup[] {
const groups: PresentationHistoryGroup[] = [];
for (const entry of entries) {
const category = presentationHistoryCategory(entry);
const previousGroup = groups.at(-1);
const previous = previousGroup?.entries.at(-1);
if (previous && previousGroup?.category === category && previous.taskId === entry.taskId &&
previous.source === entry.source) {
previousGroup.entries.push(entry);
} else {
groups.push({ id: -entry.id, category,
label: presentationHistoryCategories.find((item) => item.id === category)!.label, entries: [entry] });
}
}
return groups;
}
export function presentationCommandCategory(action: InvestigationPresentationCommand["action"]): PresentationHistoryCategory {
switch (action) {
case "open_chart": case "close_chart": return "charts";
case "render_scada_analysis": case "clear_scada_analysis": return "scada";
case "set_overlay": case "clear_overlay": return "overlay";
default: return "map";
}
}
/** Label fallback also classifies page-local snapshots created before this update. */
export function presentationHistoryCategory(entry: PresentationHistoryEntry): PresentationHistoryCategory {
if (entry.category) return entry.category;
if (/^(打开|关闭)图表/.test(entry.label)) return "charts";
if (/^(清除 )?SCADA\s*分析/.test(entry.label)) return "scada";
if (/^(显示|隐藏|清除|定位)覆盖层/.test(entry.label)) return "overlay";
if (/^地图|^调整视角|^清除高亮|^定位关联要素/.test(entry.label)) return "map";
return "results";
}
export function presentationHistoryStepLabel(entry: PresentationHistoryEntry): string {
const separator = entry.label.indexOf(" · ");
if (separator >= 0) {
const action = entry.label.slice(0, separator);
const detail = entry.label.slice(separator + 3);
if (action === "打开图表" || action === "显示覆盖层" || action === "展示结果") return detail;
if (action === "关闭图表") return `关闭 · ${detail}`;
if (action === "地图定位") return `定位 · ${detail}`;
if (action === "地图高亮") return `高亮 · ${detail}`;
}
return entry.label === "SCADA 分析" ? "分析结果" : entry.label;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
import { mergeAdjacentPresentationHistory } from "./merge-presentation-history";
describe("presentation history", () => {
it("updates a retried event in place and ignores completion from its superseded attempt", () => {
@@ -121,3 +122,54 @@ it("pauses playback on manual seeking and scope reset", () => {
expect(history.playing).toBe(false);
expect(history.playbackIds).toEqual([]);
});
it("plays and seeks frozen adjacent groups, retaining their merged snapshots across live arrivals and pause", () => {
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
history.record("first", "live", "调查", { ...metadata, category: "charts" });
history.record("second", "live", "调查", { ...metadata, category: "charts" });
history.record("map", "live", "调查", { ...metadata, category: "map" });
const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
expect(history.startPlayback(groups)).toBe(true);
expect(history.selectedId).toBe(-1);
expect(history.selected).toBe(groups[0]);
expect(history.visible).toBe(groups[0].snapshot);
history.record("later map", "live", "调查", { ...metadata, category: "map" });
history.record("later chart", "live", "调查", { ...metadata, category: "charts" });
expect(history.playbackIds).toEqual(groups.map((entry) => entry.id));
expect(history.advancePlayback()).toBe(true);
expect(history.visible).toBe(groups[1].snapshot);
expect(history.selected?.mergedStepIds).toEqual([3]);
expect(history.advancePlayback()).toBe(false);
expect(history.seekPlayback(groups[0].id)).toBe(true);
expect(history.playing).toBe(false);
expect(history.visible).toBe(groups[0].snapshot);
expect(history.startPlayback(history.playbackEntries)).toBe(true);
expect(history.selectedId).toBe(-1);
expect(history.advancePlayback()).toBe(true);
expect(history.visible).toBe(groups[1].snapshot);
history.select(null);
expect(history.playbackEntries).toEqual([]);
expect(history.visible).toBe(history.current);
});
it("starts grouped playback at the parent of a selected original step and restarts after the final group", () => {
const history = new PresentationViewHistory();
const metadata = { taskId: "task", executionGeneration: 1 };
const first = history.record("chart", "live", "调查", { ...metadata, category: "charts" });
const child = history.record("chart", "live", "调查", { ...metadata, category: "charts" });
history.record("map", "live", "调查", { ...metadata, category: "map" });
const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
history.select(child);
history.startPlayback(groups);
expect(history.selectedId).toBe(-first);
expect(history.selected?.mergedStepIds).toEqual([first, child]);
history.advancePlayback();
history.advancePlayback();
history.startPlayback(history.playbackEntries);
expect(history.selectedId).toBe(-first);
history.select(child);
expect(history.playbackEntries).toEqual([]);
expect(history.selected?.id).toBe(child);
});
@@ -28,6 +28,11 @@ 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;
@@ -36,12 +41,16 @@ export type PresentationHistoryEntry = {
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;
@@ -66,8 +75,13 @@ export class PresentationViewHistory {
entries: PresentationHistoryEntry[] = [];
selectedId: number | null = null;
playing = false;
playbackIds: number[] = [];
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;
@@ -86,7 +100,9 @@ export class PresentationViewHistory {
);
}
get selected() {
return this.entries.find((entry) => entry.id === this.selectedId) ?? null;
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;
@@ -98,7 +114,7 @@ export class PresentationViewHistory {
label: string,
source: PresentationHistoryEntry["source"],
taskTitle: string | null,
metadata: { key?: string; taskId?: string; createdAt?: number } = {}
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;
@@ -133,7 +149,7 @@ export class PresentationViewHistory {
label: string,
source: PresentationHistoryEntry["source"],
taskTitle: string | null,
metadata: { key?: string; taskId?: string; createdAt?: number } = {}
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");
@@ -144,32 +160,54 @@ export class PresentationViewHistory {
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[]) {
this.playbackIds = entries.filter((entry) => entry.status === "completed").map((entry) => entry.id);
if (!this.playbackIds.length) return false;
const index = this.playbackIds.indexOf(this.selectedId ?? -1);
if (index < 0 || index === this.playbackIds.length - 1) this.selectedId = this.playbackIds[0];
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 next = this.playbackIds[this.playbackIds.indexOf(this.selectedId ?? -1) + 1];
const index = this.playbackEntries.findIndex((entry) => entry.id === this.selectedId);
const next = this.playbackEntries[index + 1];
if (next === undefined) {
this.pausePlayback();
return false;
}
this.selectedId = next;
this.selectedMerged = next.mergedStepIds ? next : null;
this.selectedId = next.id;
return true;
}
pausePlayback() { this.playing = false; }
reset() {
this.pausePlayback();
this.playbackIds = [];
this.playbackEntries = [];
this.current = emptyPresentationView();
this.entries = [];
this.selectedId = null;
this.selectedMerged = null;
}
}