/** * Self-contained wiki/graph.html — #200. * * Emits a single HTML file with no network requests at view time. We compute * node positions server-side with a tiny seeded force simulation, bake the * positions into an inline SVG, and ship vanilla JS only for the interactions * (search, type filter, click-to-open). Deterministic for the same input. */ import type { GraphNode, KnowledgeGraph } from "./graph"; import type { WikiArticle } from "./types"; // ─── Options + types ─────────────────────────────────────────────────── export interface RenderGraphHtmlOptions { /** RNG seed for the layout. Defaults to 42. */ seed?: number; /** Force simulation iterations. Defaults to 200 — enough for <500 nodes. */ iterations?: number; /** Canvas dimensions (pixels). */ width?: number; height?: number; } interface Point { x: number; y: number; } // ─── Seeded PRNG (mulberry32) ────────────────────────────────────────── function makeRng(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ─── Layout ──────────────────────────────────────────────────────────── /** * Deterministic force-directed layout. * O(N² + E) per iteration — plenty fast for <500 nodes. */ function layout( graph: KnowledgeGraph, opts: Required, ): Map { const rng = makeRng(opts.seed); const nodes = [...graph.nodes.values()].sort((a, b) => a.id.localeCompare(b.id), ); const positions = new Map(); // Initialize positions inside a square centered at the middle of the canvas. const cx = opts.width / 2; const cy = opts.height / 2; const radius = Math.min(opts.width, opts.height) / 3; for (const n of nodes) { positions.set(n.id, { x: cx + (rng() - 0.5) * 2 * radius, y: cy + (rng() - 0.5) * 2 * radius, }); } const area = opts.width * opts.height; const k = Math.sqrt(area / Math.max(nodes.length, 1)); // Build adjacency for attractive forces along edges. const edgeList = graph.edges.map((e) => [e.source, e.target] as const); for (let iter = 0; iter < opts.iterations; iter++) { const temperature = 0.1 * opts.width * (1 - iter / opts.iterations); const disp = new Map(); for (const n of nodes) disp.set(n.id, { x: 0, y: 0 }); // Repulsive forces: O(N²) for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const a = nodes[i]; const b = nodes[j]; if (!a || !b) continue; const pa = positions.get(a.id); const pb = positions.get(b.id); if (!pa || !pb) continue; let dx = pa.x - pb.x; let dy = pa.y - pb.y; const dist = Math.sqrt(dx * dx + dy * dy) || 0.01; if (dist > 5 * k) continue; // far-field skip const force = (k * k) / dist; dx = (dx / dist) * force; dy = (dy / dist) * force; const da = disp.get(a.id); const db = disp.get(b.id); if (da) { da.x += dx; da.y += dy; } if (db) { db.x -= dx; db.y -= dy; } } } // Attractive forces along edges. for (const [src, tgt] of edgeList) { const pa = positions.get(src); const pb = positions.get(tgt); if (!pa || !pb) continue; const dx = pa.x - pb.x; const dy = pa.y - pb.y; const dist = Math.sqrt(dx * dx + dy * dy) || 0.01; const force = (dist * dist) / k; const fx = (dx / dist) * force; const fy = (dy / dist) * force; const da = disp.get(src); const db = disp.get(tgt); if (da) { da.x -= fx; da.y -= fy; } if (db) { db.x += fx; db.y += fy; } } // Apply displacement, capped by temperature, and clamp to canvas. for (const n of nodes) { const d = disp.get(n.id); const p = positions.get(n.id); if (!d || !p) continue; const dd = Math.sqrt(d.x * d.x + d.y * d.y) || 0.01; const lim = Math.min(dd, temperature); p.x += (d.x / dd) * lim; p.y += (d.y / dd) * lim; p.x = Math.max(20, Math.min(opts.width - 20, p.x)); p.y = Math.max(20, Math.min(opts.height - 20, p.y)); } } return positions; } // ─── Rendering helpers ───────────────────────────────────────────────── function escapeAttr(s: string): string { return s.replace(/[&<>"']/g, (c) => { switch (c) { case "&": return "&"; case "<": return "<"; case ">": return ">"; case '"': return """; default: return "'"; } }); } function colorForType(type: GraphNode["type"]): string { switch (type) { case "entity": return "#2563eb"; // blue case "module": return "#16a34a"; // green case "feature": return "#ea580c"; // orange case "decision": return "#9333ea"; // purple case "workflow": return "#dc2626"; // red default: return "#6b7280"; // grey } } /** Linearly scale PageRank to a node radius between 4 and 18 px. */ function radiusForPageRank(pageRank: number, min: number, max: number): number { if (max === min) return 8; const t = (pageRank - min) / (max - min); return 4 + t * 14; } /** * Map a graph node to its wiki article. The compiler's article-path * conventions are: * entity: → wiki/entities/.md * feature: → wiki/features/.md * decision: → wiki/decisions/.md * module: → wiki/modules/.md (or best-effort) * * We try the convention first and fall back to a title match only when no * convention candidate exists. Title-only matching was previously the * primary strategy and broke whenever two articles shared a title. */ function articlePathForNode( node: GraphNode, articles: WikiArticle[], ): string | null { const idParts = node.id.split(":"); const kind = idParts[0]; const rest = idParts.slice(1).join(":"); const safe = rest.replace(/[^a-zA-Z0-9_-]/g, "-"); const candidates: string[] = []; if (kind === "entity") candidates.push(`wiki/entities/${safe}.md`); else if (kind === "feature") candidates.push(`wiki/features/${safe}.md`); else if (kind === "decision") candidates.push(`wiki/decisions/${safe}.md`); else if (kind === "module") candidates.push(`wiki/modules/${safe}.md`); for (const path of candidates) { if (articles.some((a) => a.path === path)) return path; } // Disambiguate when multiple articles share a title: only return a hit // when there's exactly one match for this node's label. const titleMatches = articles.filter((a) => a.title === node.label); return titleMatches.length === 1 ? (titleMatches[0]?.path ?? null) : null; } // ─── Public API ──────────────────────────────────────────────────────── export function renderGraphHtml( graph: KnowledgeGraph, articles: WikiArticle[], options: RenderGraphHtmlOptions = {}, ): string { const opts: Required = { seed: options.seed ?? 42, iterations: options.iterations ?? 200, width: options.width ?? 1400, height: options.height ?? 900, }; const nodes = [...graph.nodes.values()].sort((a, b) => a.id.localeCompare(b.id), ); // Empty-state. if (nodes.length === 0) { return emptyStateHtml(); } const positions = layout(graph, opts); const minPr = nodes.reduce((m, n) => Math.min(m, n.pageRank), Infinity); const maxPr = nodes.reduce((m, n) => Math.max(m, n.pageRank), -Infinity); const edgeSvg: string[] = []; for (const e of graph.edges) { const p1 = positions.get(e.source); const p2 = positions.get(e.target); if (!p1 || !p2) continue; edgeSvg.push( ``, ); } const nodeSvg: string[] = []; for (const n of nodes) { const p = positions.get(n.id); if (!p) continue; const r = radiusForPageRank(n.pageRank, minPr, maxPr); const path = articlePathForNode(n, articles); // tabindex makes the node focusable so keyboard users can Tab between // nodes; the inline JS turns Enter/Space into a navigation when a // `data-path` is set. nodeSvg.push( ``, ); nodeSvg.push( ``, ); nodeSvg.push( `${escapeAttr(n.label)}`, ); nodeSvg.push(``); } return pageHtml({ width: opts.width, height: opts.height, edgeSvg: edgeSvg.join(""), nodeSvg: nodeSvg.join(""), summary: `${nodes.length} nodes · ${graph.edges.length} edges`, }); } // ─── HTML templates ──────────────────────────────────────────────────── function pageHtml(ctx: { width: number; height: number; edgeSvg: string; nodeSvg: string; summary: string; }): string { return ` maina wiki · graph

maina wiki · graph

${ctx.summary}
${(["entity", "module", "feature", "decision", "workflow"] as const) .map( (t) => ``, ) .join("")}
${ctx.edgeSvg} ${ctx.nodeSvg} `; } function emptyStateHtml(): string { return ` maina wiki · graph (empty)

No graph yet

Run maina wiki compile to build the knowledge graph. This page will show a force-directed view of its nodes, edges, and communities once compilation produces at least one node.

`; }