Spaces:
Running
Running
| /** | |
| * 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 ( | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" | |
| style={{ opacity: 0.5, flexShrink: 0 }}> | |
| <path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 8.75 4.25V1.5Zm6.75.56v2.19c0 .138.112.25.25.25h2.19Z"/> | |
| </svg> | |
| ); | |
| } | |
| // ── 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 ( | |
| <div | |
| className={`ec-card${dim ? " ec-dimmed" : ""}`} | |
| style={{ | |
| position: "absolute", | |
| left: pos.x, | |
| top: pos.y, | |
| width: CARD_W, | |
| cursor: "grab", | |
| borderColor: highlight ? s.dot : undefined, | |
| boxShadow: highlight | |
| ? `0 0 0 2px ${s.dot}, 0 0 20px ${s.glow.replace(/[\d.]+\)$/, '0.60)')}, 0 20px 60px ${s.glow.replace(/[\d.]+\)$/, '0.45)')}` | |
| : undefined, | |
| }} | |
| onMouseDown={(e) => onDragStart?.(e, node)} | |
| onClick={() => { if (!wasDragged?.current) onSelect(node); }} | |
| onMouseEnter={() => onHover(node.id)} | |
| onMouseLeave={() => onHover(null)} | |
| > | |
| {/* Top row: type tag */} | |
| <div className="ec-card-top" style={{ marginBottom: 6 }}> | |
| <span className="ec-type-tag" style={{ color: s.dot, borderColor: `${s.dot}44` }}> | |
| {node.data.type} | |
| </span> | |
| </div> | |
| {/* Name */} | |
| <div className="ec-name">{node.data.label}</div> | |
| {/* File path */} | |
| {node.data.file && ( | |
| <div className="ec-file" style={{ marginTop: 3 }}> | |
| <FileIcon /> | |
| {node.data.file} | |
| </div> | |
| )} | |
| {/* Description — truncated; full text shown in NodeDetailPanel */} | |
| {node.data.description && ( | |
| <div className="ec-subtitle" style={{ marginTop: 4 }}> | |
| {node.data.description.length > 90 | |
| ? node.data.description.slice(0, 88) + "…" | |
| : node.data.description} | |
| </div> | |
| )} | |
| {/* Method pills */} | |
| {node.data.items?.length > 0 && ( | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 3, marginTop: 6 }}> | |
| {node.data.items.slice(0, 4).map((item, i) => ( | |
| <code key={i} className="ec-item">{item}</code> | |
| ))} | |
| </div> | |
| )} | |
| {/* Ask button */} | |
| {onAsk && ( | |
| <button className="ec-ask" onClick={e => { e.stopPropagation(); onAsk(node.data); }}> | |
| Ask about this → | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ── 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 ( | |
| <div className="ec-container"> | |
| {/* ── Canvas ── */} | |
| <div | |
| ref={wrapRef} | |
| className="ec-canvas-wrapper" | |
| onMouseDown={onMouseDown} | |
| > | |
| <div | |
| className="ec-canvas" | |
| style={{ | |
| width: canvasW, | |
| height: canvasH, | |
| transform: `translate(${xform.x}px, ${xform.y}px) scale(${xform.scale})`, | |
| transformOrigin: "0 0", | |
| }} | |
| > | |
| {/* ── SVG arrow layer ── */} | |
| <svg | |
| width={canvasW} | |
| height={canvasH} | |
| style={{ position: "absolute", inset: 0, pointerEvents: "none", overflow: "visible" }} | |
| aria-hidden="true" | |
| > | |
| <defs> | |
| <marker id="gd-arrow" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"> | |
| <polygon points="0 0, 7 2.5, 0 5" fill="rgba(91,143,249,0.35)" /> | |
| </marker> | |
| <marker id="gd-arrow-hi" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"> | |
| <polygon points="0 0, 7 2.5, 0 5" fill="#7DABFF" /> | |
| </marker> | |
| {/* Per-edge directional gradients — bright at source, fades toward target. | |
| gradientUnits="userSpaceOnUse" maps x1/y1/x2/y2 to canvas coordinates | |
| so the gradient always aligns with the actual arrow path. */} | |
| {layoutEdges.map((edge, i) => { | |
| const from = getPosFor(edge.source); | |
| const to = getPosFor(edge.target); | |
| if (!from || !to) return null; | |
| return ( | |
| <linearGradient key={`grad-${i}`} id={`edge-grad-${i}`} | |
| gradientUnits="userSpaceOnUse" | |
| x1={from.x + CARD_W} y1={from.y + CARD_H / 2} | |
| x2={to.x} y2={to.y + CARD_H / 2}> | |
| <stop offset="0%" stopColor="#7DABFF" stopOpacity="0.55" /> | |
| <stop offset="100%" stopColor="#5B8FF9" stopOpacity="0.08" /> | |
| </linearGradient> | |
| ); | |
| })} | |
| </defs> | |
| {layoutEdges.map((edge, i) => { | |
| const from = getPosFor(edge.source); | |
| const to = getPosFor(edge.target); | |
| if (!from || !to) return null; | |
| // Highlight an edge when either endpoint is the hovered node OR a direct | |
| // neighbour. connectedIds only contains neighbours, so we also check | |
| // hoveredId directly — otherwise edges FROM the hovered node aren't lit. | |
| const touchesHovered = edge.source === hoveredId || edge.target === hoveredId; | |
| const isHi = hoveredId && (touchesHovered || (connectedIds.has(edge.source) && connectedIds.has(edge.target))); | |
| const isDim = hoveredId && !isHi; | |
| const d = bezierPath(from, to); | |
| // Midpoint for optional edge label | |
| const mx = (from.x + CARD_W + to.x) / 2; | |
| const my = (from.y + to.y + CARD_H) / 2; | |
| return ( | |
| <g key={i}> | |
| {/* Wide transparent stroke = clickable hit area. | |
| pointerEvents: "stroke" means only the stroke path area fires events, | |
| not the bounding box — so cards behind don't get shadowed. */} | |
| <path | |
| d={d} | |
| fill="none" | |
| stroke="transparent" | |
| strokeWidth={14} | |
| style={{ cursor: "pointer", pointerEvents: "stroke" }} | |
| onClick={() => handleEdgeClick(edge)} | |
| /> | |
| {/* Visible arrow. | |
| "inherits" = solid line (UML: solid with hollow arrow). | |
| "uses" = dashed line (UML: dependency/composition). */} | |
| <path | |
| d={d} | |
| fill="none" | |
| stroke={isHi ? "#7DABFF" : `url(#edge-grad-${i})`} | |
| strokeWidth={isHi ? 2 : 1.5} | |
| strokeDasharray={edge.label === "uses" ? "5 3" : undefined} | |
| markerEnd={isHi ? "url(#gd-arrow-hi)" : "url(#gd-arrow)"} | |
| style={{ | |
| opacity: isDim ? 0.1 : 1, | |
| transition: "opacity 0.15s, stroke 0.15s, stroke-width 0.15s", | |
| pointerEvents: "none", | |
| }} | |
| /> | |
| {/* Edge label */} | |
| {edge.label && ( | |
| <text | |
| x={mx} y={my} | |
| textAnchor="middle" | |
| fontSize="10" | |
| fontFamily="JetBrains Mono, monospace" | |
| fill="#A8C5FF" | |
| style={{ | |
| opacity: isDim ? 0.08 : 0.75, | |
| transition: "opacity 0.15s", | |
| pointerEvents: "none", | |
| paintOrder: "stroke", | |
| stroke: "#09090E", | |
| strokeWidth: 3, | |
| }} | |
| > | |
| {edge.label} | |
| </text> | |
| )} | |
| </g> | |
| ); | |
| })} | |
| </svg> | |
| {/* ── Diagram node cards ── */} | |
| {layoutNodes.map(node => ( | |
| <DiagramCard | |
| key={node.id} | |
| node={node} | |
| pos={getPosFor(node.id) ?? { x: node.x, y: node.y }} | |
| hoveredId={hoveredId} | |
| connectedIds={connectedIds} | |
| onSelect={handleNodeSelect} | |
| onHover={setHoveredId} | |
| onAsk={onAskAbout ? handleNodeAsk : null} | |
| onDragStart={onNodeDragStart} | |
| wasDragged={wasDragged} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| {/* ── Legend bar — same structure as ExploreView's ec-legend ── */} | |
| {!panelOpen && ( | |
| <div className="ec-legend"> | |
| {presentTypes.map(type => { | |
| const s = styleFor(type); | |
| return ( | |
| <span key={type} className="ec-legend-item"> | |
| <span className="ec-legend-dot" style={{ background: s.dot }} /> | |
| {type} | |
| </span> | |
| ); | |
| })} | |
| <span className="ec-legend-hint"> | |
| {layoutNodes.length} nodes · {layoutEdges.length} edges · scroll to zoom · drag to pan · click to explore | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |