feat: persist presentation figure focus

- Add focusPath to MainLocation and FigureBeatDefinition to SceneBeatDefinition
- Canonical hash format: #scene/<scene>/<beat>/focus/<segment>/<segment>
- Add set_focus_path reducer action
- Next/previous restores destination beat's canonical Focus Path
- Discussion return preserves exact originating Focus Path
- Fix catalog cycle detection to cover disconnected subgraphs
- Add FigureCatalogIssue typed issue model
- Fix test fixtures using nonexistent edge endpoints
- Remove unsafe non-null assertions in catalog and focus modules
This commit is contained in:
lda
2026-07-05 17:58:57 +07:00 Verified
parent b44ef12ca4
commit 91748e1277
14 changed files with 212 additions and 60 deletions
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { defineFigureCatalog } from "./catalog.js";
import {
cyclicCatalog,
disconnectedCyclicCatalog,
duplicateFigureCatalog,
duplicateNodeCatalog,
unknownChildCatalog,
@@ -22,6 +23,7 @@ describe("defineFigureCatalog", () => {
["unknown edge endpoint", unknownEdgeCatalog, "unknown_edge_endpoint"],
["unknown child figure", unknownChildCatalog, "unknown_child_figure"],
["recursive child cycle", cyclicCatalog, "child_cycle"],
["disconnected child cycle", disconnectedCyclicCatalog, "child_cycle"],
])("rejects %s", (_label, catalog, code) => {
expect(() => defineFigureCatalog(catalog)).toThrow(code);
});
@@ -1,9 +1,33 @@
import type {
FigureCatalogDefinition,
FigureDefinition,
FigureNodeDefinition,
} from "./model.js";
export type FigureCatalogIssue =
| { readonly code: "duplicate_figure"; readonly figureId: string }
| { readonly code: "duplicate_node"; readonly figureId: string; readonly nodeId: string }
| { readonly code: "unknown_root_figure"; readonly figureId: string }
| { readonly code: "unknown_edge_endpoint"; readonly figureId: string; readonly endpointId: string }
| { readonly code: "unknown_child_figure"; readonly figureId: string; readonly childFigureId: string }
| { readonly code: "child_cycle"; readonly fromFigureId: string; readonly toFigureId: string };
const issueToCode = (issue: FigureCatalogIssue): string => {
switch (issue.code) {
case "duplicate_figure":
return `duplicate_figure:${issue.figureId}`;
case "duplicate_node":
return `duplicate_node:${issue.figureId}:${issue.nodeId}`;
case "unknown_root_figure":
return `unknown_root_figure:${issue.figureId}`;
case "unknown_edge_endpoint":
return `unknown_edge_endpoint:${issue.figureId}:${issue.endpointId}`;
case "unknown_child_figure":
return `unknown_child_figure:${issue.figureId}:${issue.childFigureId}`;
case "child_cycle":
return `child_cycle:${issue.fromFigureId}:${issue.toFigureId}`;
}
};
/**
* Validates and returns a figure catalog. Static authored data is validated once
* at module load; user or server payloads are not accepted through this interface.
@@ -14,18 +38,18 @@ import type {
export const defineFigureCatalog = (
catalog: FigureCatalogDefinition,
): FigureCatalogDefinition => {
const issues: string[] = [];
const issues: FigureCatalogIssue[] = [];
const figureById = new Map<string, FigureDefinition>();
for (const figure of catalog.figures) {
if (figureById.has(figure.id)) {
issues.push(`duplicate_figure:${figure.id}`);
issues.push({ code: "duplicate_figure", figureId: figure.id });
}
figureById.set(figure.id, figure);
}
if (!figureById.has(catalog.rootFigureId)) {
issues.push(`unknown_root_figure:${catalog.rootFigureId}`);
issues.push({ code: "unknown_root_figure", figureId: catalog.rootFigureId });
}
const nodeIdsByFigure = new Map<string, Set<string>>();
@@ -33,7 +57,7 @@ export const defineFigureCatalog = (
const nodeIds = new Set<string>();
for (const node of figure.nodes) {
if (nodeIds.has(node.id)) {
issues.push(`duplicate_node:${figure.id}:${node.id}`);
issues.push({ code: "duplicate_node", figureId: figure.id, nodeId: node.id });
}
nodeIds.add(node.id);
}
@@ -41,13 +65,14 @@ export const defineFigureCatalog = (
}
for (const figure of catalog.figures) {
const nodeIds = nodeIdsByFigure.get(figure.id)!;
const nodeIds = nodeIdsByFigure.get(figure.id);
if (!nodeIds) continue;
for (const edge of figure.edges) {
if (!nodeIds.has(edge.from)) {
issues.push(`unknown_edge_endpoint:${figure.id}:${edge.from}`);
issues.push({ code: "unknown_edge_endpoint", figureId: figure.id, endpointId: edge.from });
}
if (!nodeIds.has(edge.to)) {
issues.push(`unknown_edge_endpoint:${figure.id}:${edge.to}`);
issues.push({ code: "unknown_edge_endpoint", figureId: figure.id, endpointId: edge.to });
}
}
}
@@ -56,35 +81,39 @@ export const defineFigureCatalog = (
for (const node of figure.nodes) {
if (node.childFigureId !== undefined) {
if (!figureById.has(node.childFigureId)) {
issues.push(`unknown_child_figure:${figure.id}:${node.childFigureId}`);
issues.push({ code: "unknown_child_figure", figureId: figure.id, childFigureId: node.childFigureId });
}
}
}
}
const visited = new Set<string>();
const inStack = new Set<string>();
const visitChildChains = (figureId: string, path: string[]) => {
// Detect child-figure cycles from every figure, not just the root, so
// disconnected subgraphs with cycles are also caught.
const edgeVisited = new Set<string>();
const edgeInStack = new Set<string>();
const visitChildChains = (figureId: string) => {
const figure = figureById.get(figureId);
if (!figure) return;
for (const node of figure.nodes) {
if (node.childFigureId === undefined) continue;
const key = `${figureId}:${node.childFigureId}`;
if (inStack.has(key)) {
issues.push(`child_cycle:${figureId}:${node.childFigureId}`);
const edgeKey = `${figureId}:${node.childFigureId}`;
if (edgeInStack.has(edgeKey)) {
issues.push({ code: "child_cycle", fromFigureId: figureId, toFigureId: node.childFigureId });
continue;
}
if (visited.has(key)) continue;
visited.add(key);
inStack.add(key);
visitChildChains(node.childFigureId, [...path, node.childFigureId]);
inStack.delete(key);
if (edgeVisited.has(edgeKey)) continue;
edgeVisited.add(edgeKey);
edgeInStack.add(edgeKey);
visitChildChains(node.childFigureId);
edgeInStack.delete(edgeKey);
}
};
visitChildChains(catalog.rootFigureId, [catalog.rootFigureId]);
for (const figure of catalog.figures) {
visitChildChains(figure.id);
}
if (issues.length > 0) {
throw new Error(issues.join(";"));
throw new Error(issues.map(issueToCode).join(";"));
}
return catalog;
@@ -42,7 +42,8 @@ const buildBreadcrumbs = (
if (!node?.childFigureId) break;
const childFigure = findFigure(catalog, node.childFigureId);
if (!childFigure) break;
crumbs.push({ label: node.label, path: [...crumbs[crumbs.length - 1]!.path, segment] });
const prevCrumb = crumbs[crumbs.length - 1];
crumbs.push({ label: node.label, path: [...(prevCrumb?.path ?? []), segment] });
currentFigure = childFigure;
}
@@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest";
import type { FigureDefinition, FigureNodeDefinition } from "./model.js";
import { layoutFigure, type PositionedFigure } from "./layout.js";
const position = (layout: PositionedFigure, nodeId: string) =>
layout.nodes.find((n) => n.id === nodeId)!.position;
const position = (layout: PositionedFigure, nodeId: string) => {
const node = layout.nodes.find((n) => n.id === nodeId);
if (!node) throw new Error(`node ${nodeId} not found in layout`);
return node.position;
};
describe("layoutFigure", () => {
it("places layered edges from top to bottom", () => {
@@ -98,7 +98,7 @@ export const duplicateNodeCatalog: FigureCatalogDefinition = {
...overviewFigure,
nodes: [
...overviewFigure.nodes,
{ id: "client", label: "Dup", summary: "Dup", kind: "actor" as const },
{ id: "client", label: "Dup", summary: "Dup", kind: "actor" },
],
},
runtimeDetailFigure,
@@ -158,8 +158,33 @@ export const cyclicCatalog: FigureCatalogDefinition = {
],
};
const edgeA: FigureEdgeDefinition = { id: "e1", from: "a", to: "b" };
const edgeB: FigureEdgeDefinition = { id: "e2", from: "b", to: "c" };
export const disconnectedCyclicCatalog: FigureCatalogDefinition = {
rootFigureId: "architecture-overview",
figures: [
overviewFigure,
{
id: "disconnected",
title: "Disconnected",
layout: { kind: "layered" as const },
nodes: [
{ id: "x", label: "X", summary: "x", kind: "actor" as const, childFigureId: "disconnected-2" },
],
edges: [],
},
{
id: "disconnected-2",
title: "Disconnected 2",
layout: { kind: "layered" as const },
nodes: [
{ id: "y", label: "Y", summary: "y", kind: "actor" as const, childFigureId: "disconnected" },
],
edges: [],
},
],
};
const edgeA: FigureEdgeDefinition = { id: "e1", from: "client", to: "runtime" };
const edgeB: FigureEdgeDefinition = { id: "e2", from: "discover", to: "repair" };
export const layeredFigure: FigureDefinition = {
id: "layered",