// Force-directed graph — the centerpiece. // Uses d3-force (loaded from CDN in index.html) for the simulation. SVG render // for the visual layer so state transitions are CSS-driven. const { useEffect, useRef, useState, useMemo, useCallback } = React; window.ForgeGraph = function ForgeGraph({ nodes, edges, resolved, selected, hoveredCanon, onToggleSelect, onHoverNode, onHoverEdge, }) { const svgRef = useRef(null); const containerRef = useRef(null); const simRef = useRef(null); const [size, setSize] = useState({ w: 1000, h: 700 }); const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 }); const transformRef = useRef(transform); transformRef.current = transform; // resize observer useEffect(() => { if (!containerRef.current) return; const ro = new ResizeObserver(([e]) => { const r = e.contentRect; setSize({ w: Math.max(400, r.width), h: Math.max(400, r.height) }); }); ro.observe(containerRef.current); return () => ro.disconnect(); }, []); // Build simulation. Pin nodes by canonical so they keep identity across re-renders. const simNodesRef = useRef(new Map()); useEffect(() => { // (re)initialise simulation when node list size changes const w = size.w, h = size.h; const simNodes = nodes.map(n => { const existing = simNodesRef.current.get(n.canonical); if (existing) { existing.node = n; return existing; } const fresh = { canonical: n.canonical, node: n, x: w / 2 + (Math.random() - 0.5) * 200, y: h / 2 + (Math.random() - 0.5) * 200, vx: 0, vy: 0, }; simNodesRef.current.set(n.canonical, fresh); return fresh; }); const simEdges = edges.map(e => ({ source: e.from, target: e.to, relation: e.relation, })); const sim = d3.forceSimulation(simNodes) .force("link", d3.forceLink(simEdges) .id(d => d.canonical) .distance(l => { // Tight cluster for compatible/requires, push apart for breaks if (l.relation === "BREAKS") return 230; if (l.relation === "REQUIRES") return 140; if (l.relation === "CONDITIONAL") return 160; if (l.relation === "DEGRADES") return 200; return 170; }) .strength(l => { if (l.relation === "REQUIRES") return 0.6; if (l.relation === "BREAKS") return 0.15; return 0.25; }) ) .force("charge", d3.forceManyBody().strength(-580).distanceMax(450)) .force("center", d3.forceCenter(w / 2, h / 2).strength(0.04)) .force("collide", d3.forceCollide().radius(54).strength(0.9)) // Group similar types together vertically .force("typeY", d3.forceY(d => typeYTarget(d.node.type, h)).strength(0.06)) .alpha(1) .alphaDecay(0.025); simRef.current = sim; const renderTick = () => { // direct DOM update for perf const svg = svgRef.current; if (!svg) return; for (const n of simNodes) { const el = svg.querySelector(`g.node[data-canon="${cssEscape(n.canonical)}"]`); if (el) el.setAttribute("transform", `translate(${n.x.toFixed(2)},${n.y.toFixed(2)})`); } for (let i = 0; i < simEdges.length; i++) { const e = simEdges[i]; const path = svg.querySelector(`g.edge[data-id="${i}"] path.edge-path`); if (path && e.source.x != null) { const x1 = e.source.x, y1 = e.source.y; const x2 = e.target.x, y2 = e.target.y; const dx = x2 - x1, dy = y2 - y1; const len = Math.sqrt(dx*dx + dy*dy) || 1; // perpendicular unit vector × offset amount (slight molten droop) const nx = -dy / len, ny = dx / len; const sag = Math.min(40, len * 0.18) * ((i % 2) ? 1 : -1) * (e.relation === "BREAKS" ? 0.6 : 1); const cx = (x1 + x2) / 2 + nx * sag; const cy = (y1 + y2) / 2 + ny * sag; const d = `M ${x1.toFixed(1)} ${y1.toFixed(1)} Q ${cx.toFixed(1)} ${cy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; path.setAttribute("d", d); const glow = svg.querySelector(`g.edge[data-id="${i}"] path.edge-glow`); if (glow) glow.setAttribute("d", d); } } }; sim.on("tick", renderTick); // Pre-tick a chunk so the layout settles before the first paint, // and so we have a fallback layout even if d3.timer doesn't fire // in this preview iframe. for (let i = 0; i < 80; i++) sim.tick(); renderTick(); // Belt-and-braces: drive ticks via a manual rAF loop in case d3.timer // is paused (some preview iframes throttle d3-timer). let rafHandle; const loop = () => { if (sim.alpha() > sim.alphaMin()) { sim.tick(); renderTick(); } rafHandle = requestAnimationFrame(loop); }; rafHandle = requestAnimationFrame(loop); return () => { sim.stop(); cancelAnimationFrame(rafHandle); }; }, [nodes.length, edges.length, size.w, size.h]); // Kick the simulation when selection changes so layout adapts to highlighted edges. useEffect(() => { if (simRef.current) { simRef.current.alpha(0.35).restart(); } }, [selected.length]); // Pan & zoom (basic) useEffect(() => { const svg = svgRef.current; if (!svg) return; let dragging = false; let last = null; const onDown = (ev) => { // Only pan when target is not a node card if (ev.target.closest("g.node")) return; dragging = true; last = { x: ev.clientX, y: ev.clientY }; svg.style.cursor = "grabbing"; }; const onMove = (ev) => { if (!dragging) return; const dx = ev.clientX - last.x; const dy = ev.clientY - last.y; last = { x: ev.clientX, y: ev.clientY }; setTransform(t => ({ ...t, x: t.x + dx, y: t.y + dy })); }; const onUp = () => { dragging = false; svg.style.cursor = "grab"; }; const onWheel = (ev) => { ev.preventDefault(); const dir = ev.deltaY < 0 ? 1.1 : 1 / 1.1; setTransform(t => { const k = Math.max(0.4, Math.min(2.2, t.k * dir)); // zoom centered on cursor const rect = svg.getBoundingClientRect(); const cx = ev.clientX - rect.left; const cy = ev.clientY - rect.top; const dx = (cx - t.x) * (1 - dir); const dy = (cy - t.y) * (1 - dir); return { x: t.x + dx, y: t.y + dy, k }; }); }; svg.addEventListener("mousedown", onDown); window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); svg.addEventListener("wheel", onWheel, { passive: false }); return () => { svg.removeEventListener("mousedown", onDown); window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); svg.removeEventListener("wheel", onWheel); }; }, []); const resetView = () => setTransform({ x: 0, y: 0, k: 1 }); const zoom = (dir) => setTransform(t => ({ ...t, k: Math.max(0.4, Math.min(2.2, t.k * dir)) })); const selectedSet = useMemo(() => new Set(selected), [selected]); // map edges to their indices so the simulation can find them const edgeData = useMemo(() => edges.map((e, i) => ({ ...e, _i: i })), [edges]); // Which edges are "hot" — incident to a selected node const hotEdge = (e) => selectedSet.has(e.from) || selectedSet.has(e.to); // For "needs-by" rendering: which nodes are amber because some selected node requires them? const neededBy = useMemo(() => { const m = {}; for (const e of edges) { if (e.relation === "REQUIRES" && selectedSet.has(e.from) && !selectedSet.has(e.to)) { (m[e.to] = m[e.to] || []).push(e.from); } } return m; }, [edges, selectedSet]); // Helpers function classFor(canon) { const r = resolved[canon]; if (!r) return "s-available"; return "s-" + r.status; } return (
{/* Molten plate gradient — the "just-forged" red-hot interior */} {/* Forge ember glow filter (used on selected plates) */} {/* Painterly noise overlay for plate backgrounds */} {/* Arrow for REQUIRES edges */} {/* Tier-rarity coin gradients — T1 legendary, T2 rare, T3 common */} {/* Edges */} {edgeData.map((e, i) => { const hot = hotEdge(e); const anySelected = selectedSet.size > 0; const faded = anySelected && !hot && e.relation !== "BREAKS"; const relCls = e.relation.toLowerCase(); return ( onHoverEdge && onHoverEdge(e, ev)} onMouseLeave={() => onHoverEdge && onHoverEdge(null)} > {/* glow overlay drawn first (under) for hot edges */} ); })} {/* Nodes — ornate brass-framed runestone plaques */} {nodes.map(n => { const meta = window.TYPE_META[n.type] || window.TYPE_META.technique; const status = (resolved[n.canonical] || {}).status || "available"; const needs = neededBy[n.canonical] || []; const isSel = selectedSet.has(n.canonical); const cls = isSel ? "s-selected" : ("s-" + status); // Octagon vertices (flat-top, 64×64). Computed once below. const OCT_OUTER = "-13.3,-32 13.3,-32 32,-13.3 32,13.3 13.3,32 -13.3,32 -32,13.3 -32,-13.3"; const OCT_BRASS = "-11.2,-27 11.2,-27 27,-11.2 27,11.2 11.2,27 -11.2,27 -27,11.2 -27,-11.2"; const OCT_HILT = "-9.5,-23 9.5,-23 23,-9.5 23,9.5 9.5,23 -9.5,23 -23,9.5 -23,-9.5"; // Four corner stud positions (on the diagonal short edges) const STUDS = [[-22.6,-22.6], [22.6,-22.6], [22.6,22.6], [-22.6,22.6]]; const tierGrad = `url(#brass-t${n.tier})`; const needsLabel = needs.length > 0 ? `needs ${truncate(displayName(needs[0]), 14)}` : ""; const chipW = Math.max(70, needsLabel.length * 5 + 16); return ( onToggleSelect(n.canonical)} onMouseEnter={ev => onHoverNode(n, ev)} onMouseLeave={() => onHoverNode(null)} > {/* outer pulse halo (only visible per state) */} {/* outer black/iron bezel */} {/* the stone plate (interior) */} {/* type-colored gem glow behind icon */} {/* brass frame ring */} {/* brass corner studs */} {STUDS.map(([sx,sy], si) => ( ))} {/* icon — type glyph */} {/* name ribbon below the plate */} {truncate(n.name, 14)} {/* tier wax-seal medallion top-right */} T{n.tier} {/* lock graphic for blocked state — over the icon, ominous */} {/* "needs X" pinned scroll-tag for conditional */} {(status === "conditional" && needs.length > 0) && ( {needsLabel} )} ); })} {/* corner controls */}
The Forge api · localhost:8010
{selectedSet.size === 0 && (

Lay an ingredient on the anvil

Pick a component from the inventory.
Incompatible nodes will grey out with citations.

)}
forge · 2026
); }; // helpers function typeYTarget(type, h) { const order = ["architecture", "technique", "optimizer", "scheduler", "quantization", "inference"]; const i = order.indexOf(type); if (i === -1) return h / 2; // distribute around vertical center with mild attraction const span = h * 0.55; return (h / 2) + (i - (order.length - 1) / 2) * (span / order.length); } function cssEscape(s) { if (window.CSS && window.CSS.escape) return window.CSS.escape(s); return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); } function displayName(canon) { const n = (window.FORGE_NODES || []).find(x => x.canonical === canon); return n ? n.name : canon; } function truncate(s, n) { return s && s.length > n ? s.slice(0, n - 1) + "…" : s; }