/** * layout — dependency-free, deterministic layout math for the sd diagram system. * No d3, no randomness (so SSR + reduced-motion stay stable). See sd DATAVIZ.md. */ import type { Point } from "@/lib/chart-utils"; // ---------- Tree (org chart, decision tree) ---------- export type TreeNode = { id: string; label?: string; children?: TreeNode[]; /** Optional edge label from parent (e.g. "sí" / "no" on a decision tree). */ edgeLabel?: string; [k: string]: unknown; }; export type PlacedNode = { id: string; label?: string; edgeLabel?: string; x: number; y: number; depth: number; parentId?: string; data: TreeNode; }; export type Edge = { from: Point; to: Point; label?: string }; /** Tidy top-down tree layout into [0,width] × [0,height]. */ export function treeLayout( root: TreeNode, width: number, height: number, pad = 0.08, ): { nodes: PlacedNode[]; edges: Edge[] } { const leaves: TreeNode[] = []; let maxDepth = 0; const xOf = new Map(); // 1. collect leaf order + depth (function walk(n: TreeNode, depth: number) { maxDepth = Math.max(maxDepth, depth); const kids = n.children ?? []; if (!kids.length) leaves.push(n); else kids.forEach((k) => walk(k, depth + 1)); })(root, 0); leaves.forEach((leaf, i) => { xOf.set(leaf.id, leaves.length === 1 ? 0.5 : i / (leaves.length - 1)); }); // 2. internal node x = mean of children (function setX(n: TreeNode): number { const kids = n.children ?? []; if (!kids.length) return xOf.get(n.id)!; const x = kids.map(setX).reduce((a, b) => a + b, 0) / kids.length; xOf.set(n.id, x); return x; })(root); const innerW = width * (1 - pad * 2); const innerH = height * (1 - pad * 2); const px = (t: number) => width * pad + t * innerW; const py = (depth: number) => height * pad + (maxDepth === 0 ? 0 : (depth / maxDepth) * innerH); const nodes: PlacedNode[] = []; const edges: Edge[] = []; (function place(n: TreeNode, depth: number, parentId?: string) { const node: PlacedNode = { id: n.id, label: n.label, edgeLabel: n.edgeLabel, x: px(xOf.get(n.id)!), y: py(depth), depth, parentId, data: n, }; nodes.push(node); (n.children ?? []).forEach((k) => { place(k, depth + 1, n.id); }); })(root, 0); for (const node of nodes) { if (node.parentId) { const parent = nodes.find((p) => p.id === node.parentId)!; edges.push({ from: { x: parent.x, y: parent.y }, to: { x: node.x, y: node.y }, label: node.edgeLabel }); } } return { nodes, edges }; } // ---------- Radial (mind map) ---------- export function radialLayout( root: TreeNode, cx: number, cy: number, radius: number, ): { nodes: PlacedNode[]; edges: Edge[] } { const nodes: PlacedNode[] = [ { id: root.id, label: root.label, x: cx, y: cy, depth: 0, data: root }, ]; const edges: Edge[] = []; const branches = root.children ?? []; branches.forEach((b, i) => { const angle = (i / branches.length) * Math.PI * 2 - Math.PI / 2; const bx = cx + Math.cos(angle) * radius; const by = cy + Math.sin(angle) * radius; nodes.push({ id: b.id, label: b.label, x: bx, y: by, depth: 1, parentId: root.id, data: b }); edges.push({ from: { x: cx, y: cy }, to: { x: bx, y: by } }); const subs = b.children ?? []; subs.forEach((s, j) => { const spread = 0.7; const sa = angle + (subs.length === 1 ? 0 : (j / (subs.length - 1) - 0.5) * spread); const sx = bx + Math.cos(sa) * radius * 0.62; const sy = by + Math.sin(sa) * radius * 0.62; nodes.push({ id: s.id, label: s.label, x: sx, y: sy, depth: 2, parentId: b.id, data: s }); edges.push({ from: { x: bx, y: by }, to: { x: sx, y: sy } }); }); }); return { nodes, edges }; } // ---------- Sankey ---------- export type SankeyNodeIn = { id: string; label?: string }; export type SankeyLinkIn = { source: string; target: string; value: number }; export type SankeyNode = SankeyNodeIn & { x: number; y: number; w: number; h: number; col: number; value: number }; export type SankeyLink = SankeyLinkIn & { path: string; width: number }; export function sankeyLayout( columns: string[][], nodes: SankeyNodeIn[], links: SankeyLinkIn[], width: number, height: number, nodeWidth = 18, nodePad = 14, ): { nodes: SankeyNode[]; links: SankeyLink[] } { const colOf = new Map(); columns.forEach((col, ci) => col.forEach((id) => colOf.set(id, ci))); const nCols = columns.length; const colX = (ci: number) => nCols === 1 ? 0 : (ci / (nCols - 1)) * (width - nodeWidth); const valueOf = (id: string) => { const inV = links.filter((l) => l.target === id).reduce((a, l) => a + l.value, 0); const outV = links.filter((l) => l.source === id).reduce((a, l) => a + l.value, 0); return Math.max(inV, outV, 1); }; const placed: SankeyNode[] = []; columns.forEach((col, ci) => { const totalVal = col.reduce((a, id) => a + valueOf(id), 0); const totalPad = nodePad * (col.length - 1); const scale = (height - totalPad) / (totalVal || 1); let y = 0; col.forEach((id) => { const v = valueOf(id); const h = Math.max(v * scale, 2); const meta = nodes.find((n) => n.id === id); placed.push({ id, label: meta?.label ?? id, x: colX(ci), y, w: nodeWidth, h, col: ci, value: v }); y += h + nodePad; }); }); const byId = new Map(placed.map((n) => [n.id, n])); const scaleOfNode = (n: SankeyNode) => n.h / n.value; const outCursor = new Map(); const inCursor = new Map(); const outLinks: SankeyLink[] = links.map((l) => { const s = byId.get(l.source)!; const t = byId.get(l.target)!; const w = l.value * Math.min(scaleOfNode(s), scaleOfNode(t)); const so = outCursor.get(s.id) ?? 0; const to = inCursor.get(t.id) ?? 0; outCursor.set(s.id, so + w); inCursor.set(t.id, to + w); const x0 = s.x + s.w; const y0 = s.y + so + w / 2; const x1 = t.x; const y1 = t.y + to + w / 2; const mx = (x0 + x1) / 2; const path = `M${x0.toFixed(1)},${y0.toFixed(1)} C${mx.toFixed(1)},${y0.toFixed(1)} ${mx.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`; return { ...l, path, width: Math.max(w, 1) }; }); return { nodes: placed, links: outLinks }; } // ---------- Treemap (binary split, deterministic) ---------- export type TreemapItemIn = { id: string; label?: string; value: number }; export type TreemapRect = TreemapItemIn & { x: number; y: number; w: number; h: number }; export function treemapLayout( items: TreemapItemIn[], width: number, height: number, ): TreemapRect[] { const sorted = [...items].sort((a, b) => b.value - a.value); const out: TreemapRect[] = []; function split(list: TreemapItemIn[], x: number, y: number, w: number, h: number) { if (!list.length) return; if (list.length === 1) { out.push({ ...list[0], x, y, w, h }); return; } const total = list.reduce((a, it) => a + it.value, 0); let acc = 0; let i = 0; while (i < list.length - 1 && acc + list[i].value < total / 2) { acc += list[i].value; i++; } const a = list.slice(0, i + 1); const b = list.slice(i + 1); const aVal = a.reduce((s, it) => s + it.value, 0); const frac = aVal / total; if (w >= h) { split(a, x, y, w * frac, h); split(b, x + w * frac, y, w * (1 - frac), h); } else { split(a, x, y, w, h * frac); split(b, x, y + h * frac, w, h * (1 - frac)); } } split(sorted, 0, 0, width, height); return out; } // ---------- Node positioning (network graph) ---------- export function circleLayout(n: number, cx: number, cy: number, r: number): Point[] { return Array.from({ length: n }, (_, i) => { const a = (i / n) * Math.PI * 2 - Math.PI / 2; return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }; }); } export function gridLayout(n: number, cols: number, width: number, height: number, pad = 0.1): Point[] { const rows = Math.ceil(n / cols); const innerW = width * (1 - pad * 2); const innerH = height * (1 - pad * 2); return Array.from({ length: n }, (_, i) => { const c = i % cols; const r = Math.floor(i / cols); return { x: width * pad + (cols === 1 ? innerW / 2 : (c / (cols - 1)) * innerW), y: height * pad + (rows === 1 ? innerH / 2 : (r / (rows - 1)) * innerH), }; }); }