From b30752bcddf4d3a739b84f2b37316c24851fe506 Mon Sep 17 00:00:00 2001 From: Rassl Date: Thu, 21 May 2026 02:06:55 +0400 Subject: [PATCH] feat: prevent nodes fetch on graph click --- src/components/universe/graph-canvas.tsx | 7 +- src/components/universe/graph-pane.tsx | 7 +- src/graph-viz-kit/GraphView.tsx | 96 ++++++++++++------------ 3 files changed, 59 insertions(+), 51 deletions(-) diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index f196b4c..9f1643f 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -182,8 +182,13 @@ function apiToGraph( for (const [key, arr] of bundles) { if (arr.length < CLUSTER_THRESHOLD) continue const [source, edge_type, target_type] = key.split("::") + // Skip clusters whose source isn't in the loaded payload — buildGraph drops + // edges with unknown endpoints, so the cluster's parent edge would vanish + // and the proxy would end up as an orphan synthetic root with no visible + // parent. Let the targets fall back to __group_ grouping instead. + if (!nodeTypeById.has(source)) continue const clusterId = `__cluster_${source}_${edge_type}_${target_type}` - extraNodes.push({ id: clusterId, label: `${target_type} × ${arr.length}` }) + extraNodes.push({ id: clusterId, label: `${target_type} × ${arr.length} · ${edge_type}` }) extraEdges.push({ source, target: clusterId, label: edge_type }) for (const e of arr) { extraEdges.push({ source: clusterId, target: e.target, label: edge_type }) diff --git a/src/components/universe/graph-pane.tsx b/src/components/universe/graph-pane.tsx index 9706766..87949d7 100644 --- a/src/components/universe/graph-pane.tsx +++ b/src/components/universe/graph-pane.tsx @@ -15,7 +15,6 @@ export function GraphPane() { const edges = useGraphStore((s) => s.edges) const selectedNode = useGraphStore((s) => s.selectedNode) const setSelectedNode = useGraphStore((s) => s.setSelectedNode) - const setSidebarSelectedNode = useGraphStore((s) => s.setSidebarSelectedNode) const clearSelection = useGraphStore((s) => s.clearSelection) const schemas = useSchemaStore((s) => s.schemas) @@ -33,9 +32,13 @@ export function GraphPane() { const workflowsOpen = useAppStore((s) => s.workflowsOpen) const toggleWorkflows = useAppStore((s) => s.toggleWorkflows) + // Canvas clicks only update `selectedNode` (drives the preview panel). + // `sidebarSelectedNode` stays for sidebar-list selections, which gate the + // 1-hop neighbor fetch (use-sidebar-neighbor-fetch). Re-firing the fetch + // on every canvas click re-parented absorbed members across rebuilds and + // sent the camera to stale positions. function onSelect(node: GraphNode) { setSelectedNode(node) - setSidebarSelectedNode(node) } function openPanel(toggle: () => void) { diff --git a/src/graph-viz-kit/GraphView.tsx b/src/graph-viz-kit/GraphView.tsx index f7c0622..1191bfd 100644 --- a/src/graph-viz-kit/GraphView.tsx +++ b/src/graph-viz-kit/GraphView.tsx @@ -756,7 +756,6 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima const highlightedEdges = useMemo(() => { - if (hovered === null && selectedId === null) return []; // Source from graph.edges (not targetEdges) so cross-edges touching the // hovered/selected node surface here even when the subgraph-mode filter // strips them out of the base render. Skip extraEdges (cluster-absorbed @@ -766,44 +765,47 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima const visibleSet = viewState.mode === "subgraph" ? new Set(viewState.visibleNodeIds) : null; - const real = graph.edges.filter((e) => { - const touches = - e.src === hovered || e.dst === hovered || - e.src === selectedId || e.dst === selectedId; - if (!touches) return false; - const srcType = graph.nodes[e.src]?.nodeType; - if (srcType === "_group" || srcType === "_cluster") { - // Spokes are dropped by default — for large clusters the ring chord - // polygon below handles them. For a small cluster ( !visibleSet || visibleSet.has(m), - ); - if (visibleMembers.length >= RING_MIN_MEMBERS) return false; - } - if (visibleSet && (!visibleSet.has(e.src) || !visibleSet.has(e.dst))) return false; - return true; - }); - - // Ring chord polygon (Feature 5): when the focused node is a proxy with - // ≥ RING_MIN_MEMBERS, connect members in angular order around the proxy - // so the cluster reads as a perimeter rather than N independent spokes. - // outAdj (children only) — using graph.adj would pull the proxy's source - // (e.g. the parent Episode) into the perimeter. + const hasFocus = hovered !== null || selectedId !== null; + const real = !hasFocus + ? [] + : graph.edges.filter((e) => { + const touches = + e.src === hovered || e.dst === hovered || + e.src === selectedId || e.dst === selectedId; + if (!touches) return false; + const srcType = graph.nodes[e.src]?.nodeType; + if (srcType === "_group" || srcType === "_cluster") { + // Spokes are dropped by default — for large clusters the ring chord + // polygon below handles them. For a small cluster ( !visibleSet || visibleSet.has(m), + ); + if (visibleMembers.length >= RING_MIN_MEMBERS) return false; + } + if (visibleSet && (!visibleSet.has(e.src) || !visibleSet.has(e.dst))) return false; + return true; + }); + + // Ring chord polygon (Feature 5): connect members in angular order around + // every cluster/group proxy with ≥ RING_MIN_MEMBERS members so the cluster + // reads as a perimeter. Always rendered for `_group` proxies (members are + // real laid-out roots/orphans). For `_cluster` proxies, members stay + // collapsed/invisible at a tiny offset until the proxy is expanded, so we + // only draw the ring while expanded — otherwise the perimeter degenerates + // into a tight stack of dots at the proxy center. const ring: GraphEdge[] = []; - const seen = new Set(); - const addRingFor = (proxyId: number | null) => { - if (proxyId === null || seen.has(proxyId)) return; - seen.add(proxyId); + for (let proxyId = 0; proxyId < graph.nodes.length; proxyId++) { const nodeType = graph.nodes[proxyId]?.nodeType; - if (nodeType !== "_group" && nodeType !== "_cluster") return; + if (nodeType !== "_group" && nodeType !== "_cluster") continue; + if (nodeType === "_cluster" && proxyId !== expandedClusterId) continue; const members = (graph.outAdj?.[proxyId] ?? []).filter( (m) => !visibleSet || visibleSet.has(m), ); - if (members.length < RING_MIN_MEMBERS) return; + if (members.length < RING_MIN_MEMBERS) continue; const p = graph.nodes[proxyId].position; const sorted = members .slice() @@ -819,12 +821,10 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima type: RING_EDGE_TYPE, }); } - }; - addRingFor(hovered); - addRingFor(selectedId); + } return ring.length > 0 ? [...real, ...ring] : real; - }, [hovered, selectedId, graph.edges, graph.nodes, graph.outAdj, viewState]); + }, [hovered, selectedId, graph.edges, graph.nodes, graph.outAdj, viewState, expandedClusterId]); // For each neighbor of the focused node, the set of edge types connecting // it to the focused node. Lets the label render show "via MENTIONS" etc. @@ -1840,11 +1840,11 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima : isHoverNeighbor ? "rgba(200,200,200,0.85)" : isRecentNode ? `rgba(100,255,180,${(0.5 + 0.45 * recentOpacity).toFixed(2)})` : "rgba(190,200,210,0.75)"; - const labelSize = isHovered || isSelected ? 14 - : isTopHit ? (topRank === 0 ? 16 : 14) - : isRecentNode ? 13 - : isHoverNeighbor ? 12 - : 11; + const labelSize = isHovered || isSelected ? 15 + : isTopHit ? (topRank === 0 ? 17 : 15) + : isRecentNode ? 14 + : isHoverNeighbor ? 13 + : 12; const labelWeight = isHovered || isSelected || isExpandedProxy || isTopHit ? 700 : 500; const iconColor = node.icon @@ -1895,7 +1895,7 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima >
- + {node.nodeType!.toLowerCase()}
@@ -1967,7 +1967,7 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima return (