forge / frontend /graph.jsx
juiceb0xc0de's picture
Forge: initial deploy — API + static UI over prebuilt forge.db (seeded at build)
00cabba
Raw
History Blame Contribute Delete
18.8 kB
// 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; }