Spaces:
Sleeping
Sleeping
| // 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 ( | |
| <div className="stage" ref={containerRef}> | |
| <svg | |
| ref={svgRef} | |
| className="graph" | |
| width={size.w} height={size.h} | |
| viewBox={`0 0 ${size.w} ${size.h}`} | |
| preserveAspectRatio="xMidYMid meet" | |
| > | |
| <defs> | |
| {/* Molten plate gradient — the "just-forged" red-hot interior */} | |
| <radialGradient id="grad-molten-plate" cx="50%" cy="55%" r="65%"> | |
| <stop offset="0%" stopColor="#5a1f0a" /> | |
| <stop offset="35%" stopColor="#3a1408" /> | |
| <stop offset="70%" stopColor="#1a0807" /> | |
| <stop offset="100%" stopColor="#0a0303" /> | |
| </radialGradient> | |
| {/* Forge ember glow filter (used on selected plates) */} | |
| <filter id="node-ember-glow" x="-50%" y="-50%" width="200%" height="200%"> | |
| <feGaussianBlur stdDeviation="2.6" result="blur" /> | |
| <feFlood floodColor="#ff6a1f" floodOpacity="0.55" /> | |
| <feComposite in2="blur" operator="in" /> | |
| <feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge> | |
| </filter> | |
| {/* Painterly noise overlay for plate backgrounds */} | |
| <filter id="plate-grain" x="0" y="0" width="100%" height="100%"> | |
| <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="3" /> | |
| <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0" /> | |
| <feComposite in2="SourceGraphic" operator="in" /> | |
| </filter> | |
| {/* Arrow for REQUIRES edges */} | |
| <marker id="arrow-requires" viewBox="0 0 10 10" refX="9" refY="5" | |
| markerWidth="6" markerHeight="6" orient="auto-start-reverse"> | |
| <path d="M0 0 L10 5 L0 10 z" fill="rgba(212,164,68,0.7)" /> | |
| </marker> | |
| {/* Tier-rarity coin gradients — T1 legendary, T2 rare, T3 common */} | |
| <radialGradient id="brass-t1" cx="40%" cy="35%" r="75%"> | |
| <stop offset="0%" stopColor="#ffd09a" /> | |
| <stop offset="45%" stopColor="#ff8a3d" /> | |
| <stop offset="100%" stopColor="#6a2a08" /> | |
| </radialGradient> | |
| <radialGradient id="brass-t2" cx="40%" cy="35%" r="75%"> | |
| <stop offset="0%" stopColor="#b8d4f0" /> | |
| <stop offset="45%" stopColor="#5b9bd8" /> | |
| <stop offset="100%" stopColor="#1c3a5a" /> | |
| </radialGradient> | |
| <radialGradient id="brass-t3" cx="40%" cy="35%" r="75%"> | |
| <stop offset="0%" stopColor="#cac4b6" /> | |
| <stop offset="45%" stopColor="#9a9285" /> | |
| <stop offset="100%" stopColor="#3a3528" /> | |
| </radialGradient> | |
| </defs> | |
| <g transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}> | |
| {/* Edges */} | |
| <g className="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 ( | |
| <g key={i} | |
| className={`edge ${relCls} ${hot ? "hot" : ""} ${faded ? "faded" : ""}`} | |
| data-id={i} | |
| onMouseEnter={ev => onHoverEdge && onHoverEdge(e, ev)} | |
| onMouseLeave={() => onHoverEdge && onHoverEdge(null)} | |
| > | |
| {/* glow overlay drawn first (under) for hot edges */} | |
| <path className="edge-glow" /> | |
| <path className="edge-path" | |
| markerEnd={e.relation === "REQUIRES" ? "url(#arrow-requires)" : undefined} /> | |
| </g> | |
| ); | |
| })} | |
| </g> | |
| {/* Nodes — ornate brass-framed runestone plaques */} | |
| <g className="nodes"> | |
| {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 ( | |
| <g | |
| key={n.canonical} | |
| className={`node ${cls}`} | |
| data-canon={n.canonical} | |
| style={{ ["--type-color"]: meta.color }} | |
| onClick={() => onToggleSelect(n.canonical)} | |
| onMouseEnter={ev => onHoverNode(n, ev)} | |
| onMouseLeave={() => onHoverNode(null)} | |
| > | |
| {/* outer pulse halo (only visible per state) */} | |
| <polygon className="node-aura" points={OCT_OUTER} | |
| transform="scale(1.25)" /> | |
| {/* outer black/iron bezel */} | |
| <polygon className="node-frame-outer" points={OCT_OUTER} /> | |
| {/* the stone plate (interior) */} | |
| <polygon className="node-plate" points={OCT_BRASS} /> | |
| {/* type-colored gem glow behind icon */} | |
| <ellipse className="node-gem" cx="0" cy="-2" rx="14" ry="11" /> | |
| {/* brass frame ring */} | |
| <polygon className="node-frame" points={OCT_BRASS} /> | |
| <polygon className="node-frame-inner" points={OCT_HILT} /> | |
| {/* brass corner studs */} | |
| {STUDS.map(([sx,sy], si) => ( | |
| <circle key={si} className="node-stud" cx={sx} cy={sy} r="1.8" /> | |
| ))} | |
| {/* icon — type glyph */} | |
| <g className="node-icon" transform="translate(-12,-14) scale(1.0)" | |
| dangerouslySetInnerHTML={{ __html: meta.icon }} /> | |
| {/* name ribbon below the plate */} | |
| <path className="node-ribbon" | |
| d="M -33 33 L -38 47 L -28 44 L -22 48 L -16 44 L -10 48 L -4 44 L 2 48 L 8 44 L 14 48 L 20 44 L 26 48 L 34 47 L 33 33 Z" /> | |
| <text className="node-name" y="44.5">{truncate(n.name, 14)}</text> | |
| {/* tier wax-seal medallion top-right */} | |
| <g className="node-seal" transform="translate(22,-22)"> | |
| <circle className="node-seal-disc" r="8.5" fill={tierGrad} /> | |
| <circle r="6.5" fill="none" stroke="rgba(0,0,0,0.4)" strokeWidth="0.5" /> | |
| <text y="2.5">T{n.tier}</text> | |
| </g> | |
| {/* lock graphic for blocked state — over the icon, ominous */} | |
| <g className="node-lock" transform="translate(0,-2)"> | |
| <path className="lock-shackle" d="M -5 -2 v -4 a 5 5 0 0 1 10 0 v 4" /> | |
| <rect className="lock-body" x="-7" y="-2" width="14" height="11" rx="1.5" /> | |
| <circle className="lock-keyhole" cx="0" cy="2" r="1.4" /> | |
| <rect className="lock-keyhole" x="-0.5" y="2" width="1" height="3.5" /> | |
| </g> | |
| {/* "needs X" pinned scroll-tag for conditional */} | |
| {(status === "conditional" && needs.length > 0) && ( | |
| <g className="needs-chip" transform="translate(0,60)"> | |
| <rect x={-chipW/2} y="-7" width={chipW} height="14" rx="2" /> | |
| <circle className="chip-pin" cx={-chipW/2} cy="0" r="1.5" /> | |
| <circle className="chip-pin" cx={chipW/2} cy="0" r="1.5" /> | |
| <text y="2.5">{needsLabel}</text> | |
| </g> | |
| )} | |
| </g> | |
| ); | |
| })} | |
| </g> | |
| </g> | |
| </svg> | |
| {/* corner controls */} | |
| <div className="stage-controls"> | |
| <button className="ctrl" onClick={() => zoom(1.15)} title="Zoom in"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3M8 11h6M11 8v6"/></svg> | |
| </button> | |
| <button className="ctrl" onClick={() => zoom(1/1.15)} title="Zoom out"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3M8 11h6"/></svg> | |
| </button> | |
| <button className="ctrl" onClick={resetView} title="Reset view"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/></svg> | |
| </button> | |
| </div> | |
| <div className="stage-toolbar"> | |
| <span className="pill"><span className="dot" style={{ background: "var(--ember-1)", boxShadow: "0 0 6px var(--ember-1)"}}/>The Forge</span> | |
| <span className="pill" title="API base URL"> | |
| api · localhost:8010 | |
| </span> | |
| </div> | |
| {selectedSet.size === 0 && ( | |
| <div className="stage-hint"> | |
| <div className="ring" /> | |
| <h3>Lay an ingredient on the anvil</h3> | |
| <p>Pick a component from the inventory.<br/>Incompatible nodes will grey out with citations.</p> | |
| </div> | |
| )} | |
| <div className="anvil-watermark">forge · 2026</div> | |
| </div> | |
| ); | |
| }; | |
| // 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; } | |