/** * Reads the semantic parts of a rendered Archify SVG: nodes, routed * relationships, their labels and structural frames. Archify marks them * with the same data attributes in all five diagram types, so the 3D view * needs no per-type code. */ export interface Box { x: number; y: number; w: number; h: number; } export interface NodeShape extends Box { id: string; kind: string; rx: number; el: SVGGElement; } export interface EdgeShape { key: string; from: string; to: string; path: SVGPathElement; /** Points along the route, every SAMPLE_STEP user units. */ points: [number, number][]; length: number; animated: boolean; } export interface LabelShape extends Box { key: string; el: SVGGElement; } export interface FrameShape extends Box { kind: string; rx: number; el: SVGRectElement; } export interface DiagramModel { svg: SVGSVGElement; viewBox: Box; nodes: NodeShape[]; edges: EdgeShape[]; labels: LabelShape[]; frames: FrameShape[]; } const SAMPLE_STEP = 3; /** Role of an element for the 3D view; descendants share their ancestor's role. */ export type Role = 'node' | 'edge' | 'label' | 'floor' | 'overlay'; /** * Archify's temporary viewer layers (focus pulse, intent trace, route probe, * journey, lens, story) are marked data-…-overlay. They are animated in 3D * from the live SVG and never baked into the textures. */ function isOverlay(el: Element): boolean { for (const name of el.getAttributeNames()) if (name.startsWith('data-') && name.endsWith('-overlay')) return true; return false; } /** Flow paths inside those overlays: clones of a relationship path with pathLength="1". */ export const FLOW_SELECTOR = [ 'relationship-pulse', 'intent-trace', 'route-probe', 'route-journey', 'semantic-lens', 'story', 'story-carrier', 'chapter-handoff', ].map((name) => `[data-${name}-overlay] path[pathLength]`).join(', '); export function roleOf(el: Element): Role | undefined { if (isOverlay(el)) return 'overlay'; if (el.hasAttribute('data-node-id')) return 'node'; if (el.getAttribute('data-graph-role') === 'automatic-crossover') return 'edge'; if (el.tagName === 'path' && el.hasAttribute('data-edge-from')) return 'edge'; if (el.getAttribute('data-graph-role') === 'automatic-crossover-underlay') return 'edge'; if (el.tagName === 'g' && el.hasAttribute('data-edge-key') && el.querySelector('text')) return 'label'; return undefined; } function num(el: Element, name: string): number { return Number(el.getAttribute(name) ?? 0) || 0; } function rectBox(rect: SVGRectElement): Box & { rx: number } { return { x: num(rect, 'x'), y: num(rect, 'y'), w: num(rect, 'width'), h: num(rect, 'height'), rx: num(rect, 'rx') }; } function nodeShape(el: SVGGElement): NodeShape | undefined { const id = el.getAttribute('data-node-id'); if (!id) return undefined; const kind = el.getAttribute('data-node-kind') ?? 'backend'; // The node body is the semantic rect (c-); c-mask sits under it. const rect = el.querySelector(`:scope > rect.c-${CSS.escape(kind)}`) ?? el.querySelector(':scope > rect:not(.c-mask)') ?? el.querySelector(':scope > rect'); if (rect) return { id, kind, el, ...rectBox(rect) }; const bbox = el.getBBox(); return { id, kind, el, x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height, rx: 0 }; } function samplePath(path: SVGPathElement): { points: [number, number][]; length: number } { const length = path.getTotalLength(); const steps = Math.max(2, Math.ceil(length / SAMPLE_STEP)); const points: [number, number][] = []; for (let i = 0; i <= steps; i++) { const p = path.getPointAtLength((length * i) / steps); points.push([p.x, p.y]); } return { points, length }; } export function readModel(svg: SVGSVGElement): DiagramModel { const vb = svg.viewBox.baseVal; const viewBox = vb && vb.width > 0 ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : { x: 0, y: 0, w: svg.clientWidth, h: svg.clientHeight }; const nodes = [...svg.querySelectorAll('g[data-node-id]')] .map(nodeShape) .filter((n): n is NodeShape => !!n && n.w > 0 && n.h > 0); const edges: EdgeShape[] = []; svg.querySelectorAll('path[data-edge-from]:not([data-graph-role])').forEach((path, index) => { const from = path.getAttribute('data-edge-from') ?? ''; const to = path.getAttribute('data-edge-to') ?? ''; const group = path.parentElement; const { points, length } = samplePath(path); if (length <= 0) return; edges.push({ key: path.getAttribute('data-edge-key') ?? `e${index}`, from, to, path, points, length, animated: path.hasAttribute('data-animate') || !!group?.hasAttribute('data-animate'), }); }); const labels: LabelShape[] = []; svg.querySelectorAll('g[data-edge-key]').forEach((el) => { if (roleOf(el) !== 'label') return; const rect = el.querySelector(':scope > rect'); const box = rect ? rectBox(rect) : (() => { const b = el.getBBox(); return { x: b.x, y: b.y, w: b.width, h: b.height, rx: 0 }; })(); if (box.w > 0 && box.h > 0) labels.push({ key: el.getAttribute('data-edge-key') ?? '', el, ...box }); }); const frames = [...svg.querySelectorAll('rect[data-graph-role="structural-frame"]')].map((el) => ({ kind: el.getAttribute('data-composition-frame-kind') ?? 'region', el, ...rectBox(el), })); return { svg, viewBox, nodes, edges, labels, frames }; } /** * Opacity of an element including its ancestors below the SVG root. The * root is left out: the 3D view hides the 2D SVG by fading the root. */ export function effectiveOpacity(el: Element, root: Element): number { let value = 1; for (let cur: Element | null = el; cur && cur !== root; cur = cur.parentElement) { const style = getComputedStyle(cur); if (style.display === 'none') return 0; value *= Number(style.opacity || 1); } return value; }