/** * 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
)}
); }