/**
* GraphDiagram.jsx — Custom SVG canvas renderer for Architecture and Class diagrams.
*
* Replaced React Flow with the same hand-crafted SVG approach as ExploreView:
* - No port handles, no selection rings, no third-party CSS artifacts
* - Full visual control — same warm sketchbook palette throughout
* - Pan/zoom via CSS transform (scroll = zoom, drag = pan)
* - Bezier arrows with clickable hit areas for edge detail
*
* Layout: BFS topological columns — nodes with no incoming edges go left,
* their dependents go right. Columns that exceed MAX_PER_COL are split
* into sub-columns to prevent tall stacks.
*/
import { useEffect, useMemo, useCallback, useState, useRef } from "react";
// ── Type colours — blueprint palette, consistent with ExploreView ────────────
const TYPE_STYLE = {
// OOP hierarchy — blue family (semantically related, slight variation is intentional)
class: { border: "#5B8FF9", glow: "rgba(91,143,249,0.38)", dot: "#7DABFF" }, // blue
abstract: { border: "#818CF8", glow: "rgba(129,140,248,0.32)", dot: "#A5B4FC" }, // violet-blue
mixin: { border: "#A78BFA", glow: "rgba(167,139,250,0.32)", dot: "#C4B5FD" }, // violet
// Modules / packages — amber (clearly warm vs cool)
module: { border: "#FBBF24", glow: "rgba(251,191,36,0.32)", dot: "#FCD34D" }, // amber
// Services / transforms — emerald
service: { border: "#34D399", glow: "rgba(52,211,153,0.32)", dot: "#6EE7B7" }, // emerald
transform: { border: "#2DD4BF", glow: "rgba(45,212,191,0.32)", dot: "#5EEAD4" }, // teal
// Data / I/O — rose/orange
database: { border: "#FB923C", glow: "rgba(251,146,60,0.32)", dot: "#FDBA74" }, // orange
input: { border: "#F472B6", glow: "rgba(244,114,182,0.32)", dot: "#F9A8D4" }, // rose
output: { border: "#34D399", glow: "rgba(52,211,153,0.32)", dot: "#6EE7B7" }, // emerald
// External deps — neutral steel
external: { border: "#4E5E80", glow: "rgba(78,94,128,0.28)", dot: "#8896B8" }, // steel
};
const FALLBACK_STYLE = { border: "#4E5E80", glow: "rgba(78,94,128,0.28)", dot: "#8896B8" };
function styleFor(type) { return TYPE_STYLE[type] || FALLBACK_STYLE; }
// ── Card geometry ─────────────────────────────────────────────────────────────
const CARD_W = 220; // canvas pixels — matches ExploreView width
const CARD_H = 240; // layout height per card (type+name+file+desc+items+ask ≈ 230px + buffer)
const COL_GAP = 100; // horizontal space between columns
const ROW_GAP = 48; // vertical gap between cards in the same column
// ── Bezier arrow: right-edge of source → left-edge of target ─────────────────
// Identical formula to ExploreView — consistent arrow shape across all views.
function bezierPath(from, to) {
const x1 = from.x + CARD_W;
const y1 = from.y + CARD_H / 2;
const x2 = to.x;
const y2 = to.y + CARD_H / 2;
// Tension scales with horizontal distance so short and long arrows both look
// natural — short: tight S-curve, long: gentle arc.
const tension = Math.max((x2 - x1) * 0.55, 60);
return `M ${x1} ${y1} C ${x1 + tension} ${y1}, ${x2 - tension} ${y2}, ${x2} ${y2}`;
}
// ── Layout: BFS topological columns ──────────────────────────────────────────
// Returns { nodes: [{id, x, y, data}], edges: [{source, target, label}] }
function computeLayout(rawNodes, rawEdges) {
if (!rawNodes?.length) return { nodes: [], edges: [], posMap: {} };
// 6 nodes per column: a fan-out graph (many nodes → same root) with 8-11 children
// was splitting into two sub-columns, forcing the root to draw long arrows that
// crossed the first sub-column. 6 keeps most fan-outs in one column.
const MAX_PER_COL = 6;
// BFS to assign column depth to each node
const inDegree = {};
const outEdges = {};
rawNodes.forEach(n => { inDegree[n.id] = 0; outEdges[n.id] = []; });
rawEdges.forEach(e => {
if (inDegree[e.target] !== undefined) inDegree[e.target]++;
if (outEdges[e.source]) outEdges[e.source].push(e.target);
});
const depth = {};
rawNodes.forEach(n => { depth[n.id] = 0; });
const queue = rawNodes.filter(n => inDegree[n.id] === 0).map(n => n.id);
const tmpIn = { ...inDegree };
const visited = new Set(queue);
let head = 0;
while (head < queue.length) {
const id = queue[head++];
for (const nb of (outEdges[id] || [])) {
depth[nb] = Math.max(depth[nb], depth[id] + 1);
tmpIn[nb]--;
if (tmpIn[nb] === 0 && !visited.has(nb)) { visited.add(nb); queue.push(nb); }
}
}
// Group nodes by column
const cols = {};
rawNodes.forEach(n => {
const c = depth[n.id];
if (!cols[c]) cols[c] = [];
cols[c].push(n);
});
// Split over-tall columns into sub-columns
const colKeys = Object.keys(cols).map(Number).sort((a, b) => a - b);
const expandedCols = {};
let ci = 0;
colKeys.forEach(col => {
const nodes = cols[col];
for (let s = 0; s < nodes.length; s += MAX_PER_COL) {
expandedCols[ci++] = nodes.slice(s, s + MAX_PER_COL);
}
});
// Assign pixel positions — center each column vertically around the tallest column.
// We must compute maxColH first so every column is centered against the same baseline,
// which guarantees all y values are non-negative (no card above the canvas top edge).
// This mirrors ExploreView's layout algorithm exactly.
const posMap = {};
const finalColKeys = Object.keys(expandedCols).map(Number).sort((a, b) => a - b);
const maxColH = Math.max(
...finalColKeys.map(col => {
const n = expandedCols[col].length;
return n * CARD_H + (n - 1) * ROW_GAP;
})
);
finalColKeys.forEach((col) => {
const colNodes = expandedCols[col];
const colH = colNodes.length * CARD_H + (colNodes.length - 1) * ROW_GAP;
const startY = (maxColH - colH) / 2 + 48; // always ≥ 48px — no negative coords
colNodes.forEach((n, ri) => {
posMap[n.id] = {
x: col * (CARD_W + COL_GAP) + 48,
y: startY + ri * (CARD_H + ROW_GAP),
};
});
});
const nodes = rawNodes.map(n => ({
id: n.id,
x: posMap[n.id]?.x ?? 0,
y: posMap[n.id]?.y ?? 0,
data: {
label: n.label,
type: n.type,
file: n.file,
description: n.description,
items: n.items || n.methods || [],
},
}));
const edges = rawEdges.map(e => ({
source: e.source,
target: e.target,
label: e.label || "",
}));
return { nodes, edges, posMap };
}
// ── File icon — same SVG used in ExploreView's ec-file pill ──────────────────
function FileIcon() {
return (
);
}
// ── DiagramCard — ec-card positioned absolutely on the SVG canvas ─────────────
function DiagramCard({ node, pos, hoveredId, connectedIds, onSelect, onHover, onAsk, onDragStart, wasDragged }) {
const dim = hoveredId && hoveredId !== node.id && !connectedIds.has(node.id);
const highlight = hoveredId === node.id || (hoveredId && connectedIds.has(node.id));
const s = styleFor(node.data.type);
return (
onDragStart?.(e, node)}
onClick={() => { if (!wasDragged?.current) onSelect(node); }}
onMouseEnter={() => onHover(node.id)}
onMouseLeave={() => onHover(null)}
>
{/* Top row: type tag */}
{node.data.type}
{/* Name */}
{node.data.label}
{/* File path */}
{node.data.file && (
{node.data.file}
)}
{/* Description — truncated; full text shown in NodeDetailPanel */}
{node.data.description && (
{node.data.description.length > 90
? node.data.description.slice(0, 88) + "…"
: node.data.description}
)}
{/* Method pills */}
{node.data.items?.length > 0 && (
{node.data.items.slice(0, 4).map((item, i) => (
{item}
))}
)}
{/* Ask button */}
{onAsk && (
)}
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function GraphDiagram({ data, onNodeSelect, onEdgeSelect, onAskAbout, repo, panelOpen }) {
const { nodes: layoutNodes, edges: layoutEdges, posMap } = useMemo(
() => computeLayout(data?.nodes || [], data?.edges || []),
[data]
);
const [hoveredId, setHoveredId] = useState(null);
const [xform, setXform] = useState({ x: 0, y: 0, scale: 0.85 });
// nodePos overrides the static layout position for dragged nodes.
// Key = node id, value = { x, y } in canvas coordinates.
const [nodePos, setNodePos] = useState({});
// canvas pan state
const dragging = useRef(false);
const drag0 = useRef({});
// Mirror of xform.scale in a ref so document-level handlers (which have
// empty deps and can't close over xform) can always read the current scale.
const scaleRef = useRef(xform.scale);
useEffect(() => { scaleRef.current = xform.scale; }, [xform.scale]);
// per-node drag state — set on card mousedown, cleared on mouseup
// { id, startPos: {x,y}, startMouse: {x,y} }
const dragNode = useRef(null);
// Set to true when the node actually moves during a drag gesture.
// Read by DiagramCard's onClick to suppress the click-to-select that
// browsers fire automatically after every mousedown+mouseup pair.
const wasDragged = useRef(false);
const wrapRef = useRef(null);
// Center the diagram in the viewport whenever layout changes.
// We read the wrapper's actual rendered dimensions (not CSS values) via
// getBoundingClientRect so the offset is always pixel-perfect.
useEffect(() => {
setNodePos({});
if (!wrapRef.current || !layoutNodes.length) {
setXform({ x: 0, y: 0, scale: 0.85 });
return;
}
const { width, height } = wrapRef.current.getBoundingClientRect();
// Smaller initial scale on mobile so the diagram fits without needing to zoom out
const scale = width < 768 ? 0.5 : 0.85;
// Offset so the scaled canvas sits in the centre of the wrapper
const cx = (width - canvasW * scale) / 2;
const cy = (height - canvasH * scale) / 2;
setXform({ x: cx, y: Math.max(cy, 24), scale });
}, [layoutNodes]); // layoutNodes recomputes whenever data changes
// Non-passive wheel zoom — passive: false required to call preventDefault()
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
function onWheel(e) {
e.preventDefault();
const f = Math.exp(-e.deltaY * 0.001);
const rect = el.getBoundingClientRect();
// Mouse position relative to the wrapper element
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
setXform(t => {
const newScale = Math.min(Math.max(t.scale * f, 0.2), 2.5);
// Pin the canvas point under the cursor: the canvas coord under the mouse is
// (mx - tx) / scale. After scaling, translate so that same coord maps back
// to mx: newTx = mx - canvasX * newScale = mx - (mx - tx) * (newScale / scale)
const ratio = newScale / t.scale;
return { x: mx - (mx - t.x) * ratio, y: my - (my - t.y) * ratio, scale: newScale };
});
}
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
// Touch pan (1 finger) + pinch-to-zoom (2 fingers)
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
let lastTouch = null; // { x, y } — single-finger pan anchor
let lastPinch = null; // { dist, mid } — two-finger pinch state
function pinchDist(t) { return Math.hypot(t[1].clientX - t[0].clientX, t[1].clientY - t[0].clientY); }
function pinchMid(t, rect) {
return { x: (t[0].clientX + t[1].clientX) / 2 - rect.left,
y: (t[0].clientY + t[1].clientY) / 2 - rect.top };
}
function onTouchStart(e) {
if (e.touches.length === 1) {
lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY };
lastPinch = null;
} else if (e.touches.length === 2) {
const t = [...e.touches];
lastPinch = { dist: pinchDist(t), mid: pinchMid(t, el.getBoundingClientRect()) };
lastTouch = null;
}
}
function onTouchMove(e) {
e.preventDefault();
if (e.touches.length === 1 && lastTouch) {
const dx = e.touches[0].clientX - lastTouch.x;
const dy = e.touches[0].clientY - lastTouch.y;
lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY };
setXform(t => ({ ...t, x: t.x + dx, y: t.y + dy }));
} else if (e.touches.length === 2 && lastPinch) {
const t = [...e.touches];
const rect = el.getBoundingClientRect();
const d = pinchDist(t);
const mid = pinchMid(t, rect);
const f = d / lastPinch.dist;
lastPinch = { dist: d, mid };
setXform(s => {
const newScale = Math.min(Math.max(s.scale * f, 0.2), 2.5);
const ratio = newScale / s.scale;
return { x: mid.x - (mid.x - s.x) * ratio, y: mid.y - (mid.y - s.y) * ratio, scale: newScale };
});
}
}
function onTouchEnd() { lastTouch = null; lastPinch = null; }
el.addEventListener("touchstart", onTouchStart, { passive: true });
el.addEventListener("touchmove", onTouchMove, { passive: false });
el.addEventListener("touchend", onTouchEnd);
return () => {
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("touchmove", onTouchMove);
el.removeEventListener("touchend", onTouchEnd);
};
}, []);
// Document-level mousemove/mouseup so drag works even when the cursor
// glides over child cards or outside the wrapper boundary mid-gesture.
useEffect(() => {
function onDocMove(e) {
if (dragNode.current) {
const dx = (e.clientX - dragNode.current.startMouse.x) / scaleRef.current;
const dy = (e.clientY - dragNode.current.startMouse.y) / scaleRef.current;
// Only count as a drag if the node moved more than 4px — prevents
// suppressing clicks caused by tiny hand tremor on mousedown.
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) wasDragged.current = true;
// Capture id and new position NOW — before passing the callback to
// setNodePos. React may call the callback asynchronously, by which
// point onDocUp could have already set dragNode.current = null,
// causing a "Cannot read property of null" crash.
const id = dragNode.current.id;
const newX = dragNode.current.startPos.x + dx;
const newY = dragNode.current.startPos.y + dy;
setNodePos(prev => ({ ...prev, [id]: { x: newX, y: newY } }));
return;
}
if (!dragging.current) return;
setXform(t => ({
...t,
x: drag0.current.tx + (e.clientX - drag0.current.mx),
y: drag0.current.ty + (e.clientY - drag0.current.my),
}));
}
function onDocUp() {
dragNode.current = null;
dragging.current = false;
if (wrapRef.current) wrapRef.current.style.cursor = "grab";
// wasDragged is reset AFTER the click event fires (click fires synchronously
// after mouseup in the same task). setTimeout(0) defers the reset past it.
setTimeout(() => { wasDragged.current = false; }, 0);
}
document.addEventListener("mousemove", onDocMove);
document.addEventListener("mouseup", onDocUp);
return () => {
document.removeEventListener("mousemove", onDocMove);
document.removeEventListener("mouseup", onDocUp);
};
}, []); // empty deps — only refs + stable setters used inside
// Returns the live (possibly dragged) position for a node id.
// Falls back to posMap (static layout) when the node hasn't been moved.
const getPosFor = useCallback((id) => nodePos[id] ?? posMap[id], [nodePos, posMap]);
// Canvas pan — fires only when no node drag is in progress
function onMouseDown(e) {
if (e.button !== 0) return;
dragging.current = true;
drag0.current = { mx: e.clientX, my: e.clientY, tx: xform.x, ty: xform.y };
e.currentTarget.style.cursor = "grabbing";
}
// Called from DiagramCard — stops propagation so the wrapper's onMouseDown
// (canvas pan) does NOT fire when dragging a node.
function onNodeDragStart(e, node) {
if (e.button !== 0) return;
e.stopPropagation();
const current = nodePos[node.id] ?? { x: node.x, y: node.y };
dragNode.current = {
id: node.id,
startPos: current,
startMouse: { x: e.clientX, y: e.clientY },
};
}
// Connected IDs for hover dimming
const connectedIds = useMemo(() => {
if (!hoveredId) return new Set();
const ids = new Set();
layoutEdges.forEach(e => {
if (e.source === hoveredId) ids.add(e.target);
if (e.target === hoveredId) ids.add(e.source);
});
return ids;
}, [hoveredId, layoutEdges]);
const handleNodeSelect = useCallback((node) => {
onNodeSelect?.({
kind: "node",
id: node.id,
label: node.data.label,
type: node.data.type,
file: node.data.file,
description: node.data.description,
items: node.data.items || [],
});
}, [onNodeSelect]);
const handleEdgeClick = useCallback((edge) => {
const srcNode = layoutNodes.find(n => n.id === edge.source);
const tgtNode = layoutNodes.find(n => n.id === edge.target);
const srcLabel = srcNode?.data?.label || edge.source;
const tgtLabel = tgtNode?.data?.label || edge.target;
onEdgeSelect?.({
kind: "edge",
label: `${srcLabel} → ${tgtLabel}`,
type: "edge",
file: null,
description: edge.label ? `Relationship: "${edge.label}"` : "",
items: [],
autoQuestion: `In ${repo}, why does "${srcLabel}" ${edge.label || "depend on"} "${tgtLabel}"? What specifically does it use from it, and what would break if this dependency were removed?`,
});
}, [onEdgeSelect, layoutNodes, repo]);
const handleNodeAsk = useCallback((nodeData) => {
onAskAbout?.(`Explain "${nodeData.label}" in ${repo} in detail — what does it do, what are its key responsibilities, and what other parts of the codebase depend on it?`);
}, [onAskAbout, repo]);
// Canvas bounding box
const allX = layoutNodes.map(n => n.x + CARD_W + 60);
const allY = layoutNodes.map(n => n.y + CARD_H + 80);
const canvasW = Math.max(...allX, 700);
const canvasH = Math.max(...allY, 500);
// Legend: unique types actually present in this diagram
const presentTypes = useMemo(() => {
const seen = new Set();
layoutNodes.forEach(n => { if (n.data?.type) seen.add(n.data.type); });
return [...seen].sort();
}, [layoutNodes]);
return (
{/* ── Canvas ── */}
{/* ── SVG arrow layer ── */}
{/* ── Diagram node cards ── */}
{layoutNodes.map(node => (
))}
{/* ── Legend bar — same structure as ExploreView's ec-legend ── */}
{!panelOpen && (
{presentTypes.map(type => {
const s = styleFor(type);
return (
{type}
);
})}
{layoutNodes.length} nodes · {layoutEdges.length} edges · scroll to zoom · drag to pan · click to explore
)}
);
}