import type { DiagramIRV2 } from "../../model/diagram-ir.js"; import { validateDiagramIR } from "../../model/diagram-ir.js"; export interface ArchitectureSnapshot { id: string; label: string; ir: DiagramIRV2; provenance?: Record } export interface ChangeSet { added: string[]; removed: string[]; modified: string[]; unchanged: string[] } export interface TimelapseFrame { id: string; label: string; index: number; ir: DiagramIRV2; changes: { nodes: ChangeSet; edges: ChangeSet }; provenance?: Record } export interface ArchitectureTimelapse { frames: TimelapseFrame[] } function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object") return `{${Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`; return JSON.stringify(value); } function inventory(ir: DiagramIRV2, kind: "nodes" | "edges"): Map { return new Map(ir.pages.flatMap((page) => page[kind].map((item) => [`${page.id}/${item.id}`, item] as const))); } function classify(previous: Map, current: Map): ChangeSet { const added: string[] = [], removed: string[] = [], modified: string[] = [], unchanged: string[] = []; for (const id of [...current.keys()].sort()) { if (!previous.has(id)) added.push(id); else if (canonical(previous.get(id)) === canonical(current.get(id))) unchanged.push(id); else modified.push(id); } for (const id of [...previous.keys()].sort()) if (!current.has(id)) removed.push(id); return { added, removed, modified, unchanged }; } export function createArchitectureTimelapse(snapshots: readonly ArchitectureSnapshot[]): ArchitectureTimelapse { if (!Array.isArray(snapshots) || snapshots.length === 0) throw new Error("Timelapse requires at least one snapshot"); const ids = new Set(snapshots.map((snapshot) => snapshot.id)); if (ids.size !== snapshots.length) throw new Error("Snapshot IDs must be unique"); let previousNodes = new Map(), previousEdges = new Map(); const frames = snapshots.map((snapshot, index) => { const ir = structuredClone(snapshot.ir); validateDiagramIR(ir); const currentNodes = inventory(ir, "nodes"), currentEdges = inventory(ir, "edges"); const frame: TimelapseFrame = { id: snapshot.id, label: snapshot.label, index, ir, changes: { nodes: classify(previousNodes, currentNodes), edges: classify(previousEdges, currentEdges) }, provenance: snapshot.provenance === undefined ? undefined : structuredClone(snapshot.provenance) }; previousNodes = currentNodes; previousEdges = currentEdges; return frame; }); return { frames }; }