From 76ef614abe84743a29d11a683adf529d0a95deb9 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 20:24:56 +0530 Subject: [PATCH 01/11] feat(extension): radial neighbourhood layout on node select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a node now repositions its neighbourhood as concentric rings — the selected node at the origin, direct neighbours on an inner ring, 2-hop neighbours on an outer ring — so the selection's edges fan out with minimal overlap (the GitNexus-style 'clean edges on select'), instead of freezing positions and only dimming. The inner ring is a star, so it is crossing-free; the outer ring anchors each node near its inner-ring parent's angle to keep ring-to-ring edges radial. assignRadialPositions reads the selection's existing BFS hopLayers (no extra traversal). The prior layout is snapshotted once when a selection begins and restored exactly on deselect; a graph-data change discards the snapshot. Applies on canvas click and search/lens-row selection (which now flies the camera to the origin, where the selected node lands). Webview-only, additive — no core/schema/contract change. extension 648 (+5 radial layout tests: centre at origin, ring radii, distinct angles, no-op, restore). Refs: #168 --- .../src/webview/components/GraphView.tsx | 78 +++++++++---- .../components/graphLayoutPresets.test.ts | 77 +++++++++++++ .../webview/components/graphLayoutPresets.ts | 103 ++++++++++++++++++ 3 files changed, 238 insertions(+), 20 deletions(-) diff --git a/packages/extension/src/webview/components/GraphView.tsx b/packages/extension/src/webview/components/GraphView.tsx index 24aea61..cd1ba53 100644 --- a/packages/extension/src/webview/components/GraphView.tsx +++ b/packages/extension/src/webview/components/GraphView.tsx @@ -9,6 +9,7 @@ import type Sigma from "sigma"; import { applyLayoutPreset, + assignRadialPositions, LAYOUT_PRESET_OPTIONS, restoreNodePositions, snapshotNodePositions, @@ -373,6 +374,9 @@ export function GraphView({ const [retryCount, setRetryCount] = useState(0); const [fallbackGraph, setFallbackGraph] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); + // Pre-radial node positions, captured when a selection begins so deselect can + // restore the exact prior layout. Null when no selection is repositioning. + const radialSnapshotRef = useRef | null>(null); const [overlaySegments, setOverlaySegments] = useState([]); const [showMinimap, setShowMinimap] = useState(false); // Seed from the restored preference (VS Code state); default on when unset. @@ -548,30 +552,59 @@ export function GraphView({ setSearchFocusedIndex(0); }, []); + // Reposition the selected node's neighbourhood as concentric rings (center + + // 2 hops) so its edges fan out radially with minimal crossing. Snapshots the + // prior layout once per selection so deselect can restore it exactly. + const applyRadialOnSelect = useCallback((selection: { hopLayers: string[][] } | null): void => { + const graph = graphRef.current; + if (graph === null || selection === null || selection.hopLayers.length === 0) return; + if (radialSnapshotRef.current === null) { + radialSnapshotRef.current = snapshotNodePositions(graph); + } + assignRadialPositions(graph, selection.hopLayers); + sigmaRef.current?.refresh(); + }, []); + + // Restore the pre-radial layout (deselect / data change). No-op if nothing was + // repositioned. + const restoreFromRadial = useCallback((): void => { + const graph = graphRef.current; + const snapshot = radialSnapshotRef.current; + if (graph === null || snapshot === null) return; + restoreNodePositions(graph, snapshot); + radialSnapshotRef.current = null; + sigmaRef.current?.refresh(); + }, []); + // Select a node (highlight + neighbourhood + Inspector) the same way a canvas // single-click does, then fly the camera to it with a zoom so the move is // obvious. `selectNode` lives in the Sigma effect closure, so the state writes // are replicated here. Shared by search-result and lens-row selection. - const selectAndFlyToNode = useCallback((nodeId: string): void => { - const sigma = sigmaRef.current; - const graph = graphRef.current; - if (graph === null || !graph.hasNode(nodeId)) return; - setSelectedNodeId(nodeId); - // Historically this path set the selection + refreshed but did not touch the - // overlay (unlike a canvas click); preserve that by opting out. - controllerRef.current?.setSelection(nodeId, { updateOverlay: false }); - - // Fly + zoom in (keeping the prior ratio made the pan imperceptible on a - // zoomed-out graph). Clamp so we only ever zoom in, never out. - const camera = (sigma as SigmaWithExtras | null)?.getCamera?.(); - const x = graph.getNodeAttribute(nodeId, "x") as number | undefined; - const y = graph.getNodeAttribute(nodeId, "y") as number | undefined; - if (camera?.animate !== undefined && typeof x === "number" && typeof y === "number") { - const currentRatio = camera.getState?.().ratio ?? 1; - const ratio = Math.min(currentRatio, SEARCH_FLY_TO_RATIO); - camera.animate({ x, y, ratio }, { duration: CAMERA_CENTER_DURATION_MS }); - } - }, []); + const selectAndFlyToNode = useCallback( + (nodeId: string): void => { + const sigma = sigmaRef.current; + const graph = graphRef.current; + if (graph === null || !graph.hasNode(nodeId)) return; + setSelectedNodeId(nodeId); + // Historically this path set the selection + refreshed but did not touch the + // overlay (unlike a canvas click); preserve that by opting out. + controllerRef.current?.setSelection(nodeId, { updateOverlay: false }); + // Radial repositions the selected node to the origin, so fly there. + applyRadialOnSelect(controllerRef.current?.currentSelection ?? null); + + // Fly + zoom in (keeping the prior ratio made the pan imperceptible on a + // zoomed-out graph). Clamp so we only ever zoom in, never out. + const camera = (sigma as SigmaWithExtras | null)?.getCamera?.(); + const x = graph.getNodeAttribute(nodeId, "x") as number | undefined; + const y = graph.getNodeAttribute(nodeId, "y") as number | undefined; + if (camera?.animate !== undefined && typeof x === "number" && typeof y === "number") { + const currentRatio = camera.getState?.().ratio ?? 1; + const ratio = Math.min(currentRatio, SEARCH_FLY_TO_RATIO); + camera.animate({ x, y, ratio }, { duration: CAMERA_CENTER_DURATION_MS }); + } + }, + [applyRadialOnSelect], + ); const handleSearchSelectResult = useCallback( (nodeId: string, index: number): void => { @@ -804,6 +837,9 @@ export function GraphView({ useEffect(() => { setSelectedNodeId(null); + // Graph data changed — drop any pending radial snapshot rather than restoring + // it: its node ids belong to the previous graph and the new layout is fresh. + radialSnapshotRef.current = null; // Clear the controller's selection/hover render mirror when the graph data // changes. setSelection(null) also clears the overlay; setHover(null) is a // no-op before mount (no graph yet) and clears hover after. @@ -906,10 +942,12 @@ export function GraphView({ // selection traversal + overlay. Selecting does NOT recenter the camera. setSelectedNodeId(nodeId); controller.setSelection(nodeId); + applyRadialOnSelect(controller.currentSelection); }, onClear: () => { setSelectedNodeId(null); controller.setSelection(null); + restoreFromRadial(); }, onTracePick: (nodeId) => handleTraceNodeClick(nodeId), // mount() sets the controller's instance before invoking this, so read diff --git a/packages/extension/src/webview/components/graphLayoutPresets.test.ts b/packages/extension/src/webview/components/graphLayoutPresets.test.ts index d3200ae..9e6dfc0 100644 --- a/packages/extension/src/webview/components/graphLayoutPresets.test.ts +++ b/packages/extension/src/webview/components/graphLayoutPresets.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; import { applyLayoutPreset, + assignRadialPositions, LAYOUT_PRESET_OPTIONS, restoreNodePositions, snapshotNodePositions, @@ -316,3 +317,79 @@ describe("applyLayoutPreset — Hierarchical (slice 025 US3)", () => { expect(graph.getNodeAttribute("a", "y")).toBe(9); }); }); + +describe("assignRadialPositions", () => { + /** center → 3 direct neighbours; one neighbour has a 2-hop child. */ + function buildStar(): MultiDirectedGraph { + const graph = new MultiDirectedGraph(); + for (const id of ["c", "n1", "n2", "n3", "g1"]) { + graph.addNode(id, { x: 999, y: 999 }); + } + graph.addDirectedEdge("c", "n1"); + graph.addDirectedEdge("c", "n2"); + graph.addDirectedEdge("c", "n3"); + graph.addDirectedEdge("n1", "g1"); // 2-hop + return graph; + } + + const radius = (graph: MultiDirectedGraph, id: string): number => { + const x = graph.getNodeAttribute(id, "x") as number; + const y = graph.getNodeAttribute(id, "y") as number; + return Math.hypot(x, y); + }; + + it("places the selected node at the origin", () => { + const graph = buildStar(); + assignRadialPositions(graph, [["c"], ["n1", "n2", "n3"], ["g1"]]); + expect(graph.getNodeAttribute("c", "x")).toBe(0); + expect(graph.getNodeAttribute("c", "y")).toBe(0); + }); + + it("places direct neighbours on the inner ring and 2-hop nodes further out", () => { + const graph = buildStar(); + const moved = assignRadialPositions(graph, [["c"], ["n1", "n2", "n3"], ["g1"]]); + + const r1 = radius(graph, "n1"); + expect(radius(graph, "n2")).toBeCloseTo(r1, 5); + expect(radius(graph, "n3")).toBeCloseTo(r1, 5); + // 2-hop child sits on a strictly larger ring. + expect(radius(graph, "g1")).toBeGreaterThan(r1); + + expect(moved).toContain("c"); + expect(moved).toContain("g1"); + }); + + it("spreads inner-ring neighbours to distinct angles (no stacking)", () => { + const graph = buildStar(); + assignRadialPositions(graph, [["c"], ["n1", "n2", "n3"], ["g1"]]); + const angle = (id: string) => + Math.atan2( + graph.getNodeAttribute(id, "y") as number, + graph.getNodeAttribute(id, "x") as number, + ); + const angles = new Set([angle("n1"), angle("n2"), angle("n3")]); + expect(angles.size).toBe(3); + }); + + it("is a no-op for an empty / centerless traversal", () => { + const graph = buildStar(); + const before = snapshotNodePositions(graph); + expect(assignRadialPositions(graph, []).size).toBe(0); + expect(snapshotNodePositions(graph)).toEqual(before); + }); + + it("round-trips with snapshot/restore (deselect restores prior layout)", () => { + const graph = buildStar(); + // Give nodes a meaningful prior layout to restore to. + graph.setNodeAttribute("c", "x", 10); + graph.setNodeAttribute("c", "y", 20); + const snapshot = snapshotNodePositions(graph); + + assignRadialPositions(graph, [["c"], ["n1", "n2", "n3"], ["g1"]]); + expect(graph.getNodeAttribute("c", "x")).toBe(0); // moved by radial + + restoreNodePositions(graph, snapshot); + expect(graph.getNodeAttribute("c", "x")).toBe(10); // restored + expect(graph.getNodeAttribute("c", "y")).toBe(20); + }); +}); diff --git a/packages/extension/src/webview/components/graphLayoutPresets.ts b/packages/extension/src/webview/components/graphLayoutPresets.ts index 215de8b..28c7f00 100644 --- a/packages/extension/src/webview/components/graphLayoutPresets.ts +++ b/packages/extension/src/webview/components/graphLayoutPresets.ts @@ -165,6 +165,109 @@ function assignCircularPositions( } } +/** Radius of the first concentric ring; outer rings step out by this much. */ +const RADIAL_RING_SPACING = 160; +/** Concentric rings to place on select (center = ring 0). 2 hops → rings 1 and 2. */ +const RADIAL_MAX_RINGS = 2; + +/** + * Place a selected node's neighbourhood as concentric rings: the selected node + * at the origin, its direct neighbours evenly spaced on the inner ring, their + * neighbours on the next ring out. Built from the selection's pre-computed BFS + * `hopLayers` (layer 0 = the selected node), so no traversal is recomputed here. + * + * The inner ring (direct neighbours of a single centre) is a star — provably + * crossing-free. Outer rings can cross; we accept that for the added 2-hop + * context. Each ring's nodes are angularly anchored under their nearest + * inner-ring parent so an edge from ring N to ring N+1 stays roughly radial + * instead of sweeping across the circle. + * + * Only nodes within {@link RADIAL_MAX_RINGS} hops are repositioned; everything + * else keeps its prior coordinates (it is dimmed by the selection reducers). + * Returns the set of node ids that were moved. + */ +export function assignRadialPositions( + graph: MultiDirectedGraph, + hopLayers: readonly (readonly string[])[], +): ReadonlySet { + const moved = new Set(); + if (hopLayers.length === 0) return moved; + + const center = hopLayers[0]?.[0]; + if (center === undefined || !graph.hasNode(center)) return moved; + graph.setNodeAttribute(center, "x", 0); + graph.setNodeAttribute(center, "y", 0); + moved.add(center); + + // Angle assigned to each placed node, so the next ring can anchor children + // near their parent's angle (keeps ring→ring edges radial, not chordal). + const angleOf = new Map([[center, 0]]); + + const lastRing = Math.min(RADIAL_MAX_RINGS, hopLayers.length - 1); + for (let ring = 1; ring <= lastRing; ring++) { + const layer = (hopLayers[ring] ?? []).filter((id) => graph.hasNode(id)); + if (layer.length === 0) continue; + const radius = ring * RADIAL_RING_SPACING; + + if (ring === 1) { + // Direct neighbours: spread evenly around the full circle. + for (let i = 0; i < layer.length; i++) { + const angle = (2 * Math.PI * i) / layer.length; + place(graph, layer[i]!, radius, angle, moved, angleOf); + } + } else { + // Outer ring: anchor each node near an inner-ring parent's angle so the + // connecting edge points outward. Falls back to even spread for orphans. + const byParentAngle = layer + .map((id) => ({ id, angle: nearestParentAngle(graph, id, angleOf) })) + .sort((a, b) => a.angle - b.angle); + // Nudge duplicates apart so co-anchored nodes don't stack. + for (let i = 0; i < byParentAngle.length; i++) { + const spread = (i - (byParentAngle.length - 1) / 2) * 0.18; + const base = byParentAngle[i]!.angle; + place(graph, byParentAngle[i]!.id, radius, base + spread, moved, angleOf); + } + } + } + + return moved; +} + +function place( + graph: MultiDirectedGraph, + id: string, + radius: number, + angle: number, + moved: Set, + angleOf: Map, +): void { + graph.setNodeAttribute(id, "x", Math.cos(angle) * radius); + graph.setNodeAttribute(id, "y", Math.sin(angle) * radius); + moved.add(id); + angleOf.set(id, angle); +} + +/** Average angle of an outer node's already-placed neighbours, or 0 if none. */ +function nearestParentAngle( + graph: MultiDirectedGraph, + id: string, + angleOf: Map, +): number { + let sumX = 0; + let sumY = 0; + let count = 0; + graph.forEachNeighbor(id, (neighbor) => { + const a = angleOf.get(neighbor); + if (a === undefined) return; + // Average on the unit circle to avoid wrap-around bias near ±π. + sumX += Math.cos(a); + sumY += Math.sin(a); + count += 1; + }); + if (count === 0) return 0; + return Math.atan2(sumY, sumX); +} + /** * Anti-collision readability post-pass. * From 240dcb3490c510f9356539401977bd88f6c970b1 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 20:33:49 +0530 Subject: [PATCH 02/11] =?UTF-8?q?fix(extension):=20radial=20layout=20was?= =?UTF-8?q?=20fed=20edge=20ids,=20not=20node=20ids=20=E2=80=94=20nothing?= =?UTF-8?q?=20moved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-select radial layout read SelectionTraversal.hopLayers as node layers, but hopLayers holds EDGE ids grouped by BFS depth (it feeds the trace overlay). So assignRadialPositions saw an edge id at hopLayers[0][0], graph.hasNode(edgeId) was false, and it returned immediately — no node ever moved. The type was string[][] for both, so the compiler couldn't catch the mismatch, and the unit test had encoded the same wrong assumption (hand-built node layers). Fix: computeSelection now also returns nodeLayers — NODE ids grouped by BFS depth, layer 0 = the selected node (built from the BFS nodesByDepth it already had). The radial layout consumes nodeLayers; hopLayers stays edge-based for the trace overlay. Added a graphTraversal test pinning the real contract (nodeLayers[0]= [selected], every entry is a node, never an edge). extension 649. --- .../src/webview/components/GraphView.tsx | 6 +++--- .../webview/components/graphLayoutPresets.ts | 12 ++++++------ .../webview/components/graphTraversal.test.ts | 18 +++++++++++++++++- .../src/webview/components/graphTraversal.ts | 11 +++++++++++ .../src/webview/components/graphViewTypes.ts | 3 +++ 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/webview/components/GraphView.tsx b/packages/extension/src/webview/components/GraphView.tsx index cd1ba53..abcfb71 100644 --- a/packages/extension/src/webview/components/GraphView.tsx +++ b/packages/extension/src/webview/components/GraphView.tsx @@ -555,13 +555,13 @@ export function GraphView({ // Reposition the selected node's neighbourhood as concentric rings (center + // 2 hops) so its edges fan out radially with minimal crossing. Snapshots the // prior layout once per selection so deselect can restore it exactly. - const applyRadialOnSelect = useCallback((selection: { hopLayers: string[][] } | null): void => { + const applyRadialOnSelect = useCallback((selection: { nodeLayers: string[][] } | null): void => { const graph = graphRef.current; - if (graph === null || selection === null || selection.hopLayers.length === 0) return; + if (graph === null || selection === null || selection.nodeLayers.length === 0) return; if (radialSnapshotRef.current === null) { radialSnapshotRef.current = snapshotNodePositions(graph); } - assignRadialPositions(graph, selection.hopLayers); + assignRadialPositions(graph, selection.nodeLayers); sigmaRef.current?.refresh(); }, []); diff --git a/packages/extension/src/webview/components/graphLayoutPresets.ts b/packages/extension/src/webview/components/graphLayoutPresets.ts index 28c7f00..00cb31d 100644 --- a/packages/extension/src/webview/components/graphLayoutPresets.ts +++ b/packages/extension/src/webview/components/graphLayoutPresets.ts @@ -174,7 +174,7 @@ const RADIAL_MAX_RINGS = 2; * Place a selected node's neighbourhood as concentric rings: the selected node * at the origin, its direct neighbours evenly spaced on the inner ring, their * neighbours on the next ring out. Built from the selection's pre-computed BFS - * `hopLayers` (layer 0 = the selected node), so no traversal is recomputed here. + * `nodeLayers` (layer 0 = the selected node), so no traversal is recomputed here. * * The inner ring (direct neighbours of a single centre) is a star — provably * crossing-free. Outer rings can cross; we accept that for the added 2-hop @@ -188,12 +188,12 @@ const RADIAL_MAX_RINGS = 2; */ export function assignRadialPositions( graph: MultiDirectedGraph, - hopLayers: readonly (readonly string[])[], + nodeLayers: readonly (readonly string[])[], ): ReadonlySet { const moved = new Set(); - if (hopLayers.length === 0) return moved; + if (nodeLayers.length === 0) return moved; - const center = hopLayers[0]?.[0]; + const center = nodeLayers[0]?.[0]; if (center === undefined || !graph.hasNode(center)) return moved; graph.setNodeAttribute(center, "x", 0); graph.setNodeAttribute(center, "y", 0); @@ -203,9 +203,9 @@ export function assignRadialPositions( // near their parent's angle (keeps ring→ring edges radial, not chordal). const angleOf = new Map([[center, 0]]); - const lastRing = Math.min(RADIAL_MAX_RINGS, hopLayers.length - 1); + const lastRing = Math.min(RADIAL_MAX_RINGS, nodeLayers.length - 1); for (let ring = 1; ring <= lastRing; ring++) { - const layer = (hopLayers[ring] ?? []).filter((id) => graph.hasNode(id)); + const layer = (nodeLayers[ring] ?? []).filter((id) => graph.hasNode(id)); if (layer.length === 0) continue; const radius = ring * RADIAL_RING_SPACING; diff --git a/packages/extension/src/webview/components/graphTraversal.test.ts b/packages/extension/src/webview/components/graphTraversal.test.ts index a380fa1..3e31789 100644 --- a/packages/extension/src/webview/components/graphTraversal.test.ts +++ b/packages/extension/src/webview/components/graphTraversal.test.ts @@ -48,12 +48,28 @@ describe("computeSelection", () => { expect(result!.edgeIds.has("b-c")).toBe(true); }); - it("records per-hop layers", () => { + it("records per-hop EDGE layers in hopLayers", () => { const result = computeSelection(buildGraph(), "a", 2); expect(result!.hopLayers.length).toBeGreaterThanOrEqual(1); expect(result!.hopLayers[0]).toEqual(expect.arrayContaining(["a-b", "a-d"])); }); + + it("records per-hop NODE layers in nodeLayers (layer 0 = selected node)", () => { + const result = computeSelection(buildGraph(), "a", 2); + + // This is the contract the radial-on-select layout depends on: nodeLayers + // holds NODE ids by depth, not edges. Layer 0 is the selected node alone. + expect(result!.nodeLayers[0]).toEqual(["a"]); + expect(result!.nodeLayers[1]).toEqual(expect.arrayContaining(["b", "d"])); + expect(result!.nodeLayers[2]).toEqual(["c"]); + // Every entry must be a real node, never an edge id. + for (const layer of result!.nodeLayers) { + for (const id of layer) { + expect(buildGraph().hasNode(id)).toBe(true); + } + } + }); }); describe("computeTracePath", () => { diff --git a/packages/extension/src/webview/components/graphTraversal.ts b/packages/extension/src/webview/components/graphTraversal.ts index f4c0a2e..a7df395 100644 --- a/packages/extension/src/webview/components/graphTraversal.ts +++ b/packages/extension/src/webview/components/graphTraversal.ts @@ -93,12 +93,23 @@ export function computeSelection( } } + // Node ids grouped by BFS depth (layer 0 = the selected node). Distinct from + // `hopLayers`, which holds EDGE ids per depth. Used by the radial-on-select + // layout to place each ring. + const nodeLayers: string[][] = []; + for (let depth = 0; depth <= maxDepth; depth++) { + const layer = nodesByDepth.get(depth); + if (layer === undefined) break; + nodeLayers.push([...layer]); + } + return { selectedNodeId, nodeIds, edgeIds, orderedEdgeIds, hopLayers, + nodeLayers, maxDepth, }; } diff --git a/packages/extension/src/webview/components/graphViewTypes.ts b/packages/extension/src/webview/components/graphViewTypes.ts index f06a863..84f0dc2 100644 --- a/packages/extension/src/webview/components/graphViewTypes.ts +++ b/packages/extension/src/webview/components/graphViewTypes.ts @@ -252,7 +252,10 @@ export interface SelectionTraversal { nodeIds: Set; edgeIds: Set; orderedEdgeIds: string[]; + /** EDGE ids grouped by BFS depth (used by the trace overlay). */ hopLayers: string[][]; + /** NODE ids grouped by BFS depth, layer 0 = the selected node (radial layout). */ + nodeLayers: string[][]; /** Maximum BFS hop depth applied when this traversal was computed. */ maxDepth: number; } From bbf67df9790c7588d92914e3c0bf97a377275bd2 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 20:53:14 +0530 Subject: [PATCH 03/11] chore(extension): temp layout-apply diagnostic (remove after Circular confirmed) Logs which branch applyLayoutPreset took (applied / noop:already-active / noop:trivial-graph / rejected) + node count, to the Webview Developer Tools console. Helps diagnose 'Circular not working' from the running UI rather than speculatively. Remove once confirmed. --- packages/extension/src/webview/components/GraphView.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/extension/src/webview/components/GraphView.tsx b/packages/extension/src/webview/components/GraphView.tsx index abcfb71..1edd6a5 100644 --- a/packages/extension/src/webview/components/GraphView.tsx +++ b/packages/extension/src/webview/components/GraphView.tsx @@ -420,6 +420,15 @@ export function GraphView({ visibleEdgeIds, }); + // TEMP diagnostic (remove after Circular is confirmed): shows which branch + // the layout-apply took. View in Webview Developer Tools console. + console.log( + `[layout] preset=${preset} active=${layoutSelection.activePreset} ` + + `nodes=${visibleNodeIds.size} → status=${result.status}` + + (result.status === "noop" ? ` reason=${result.reason}` : "") + + (result.status === "rejected" ? ` reason=${result.reason}` : ""), + ); + if (result.status === "applied") { setLayoutSelection({ activePreset: result.preset, notice: null }); const sigma = sigmaRef.current; From 1dd01db8d672d3ea51ae2e95365fdc332e149270 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 21:00:13 +0530 Subject: [PATCH 04/11] fix(extension): run anti-collision pass after ForceAtlas2 (default view de-overlaps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ForceAtlas2 — the default layout every graph opens with — was the only preset that skipped runReadabilityPass (noverlap), so the default view rendered nodes overlapping each other while circular/hierarchical did not. Run the same anti-collision pass after the force pass. This is the cheapest, highest-impact readability win toward the 'organized like a competitor' look; no engine change. extension 649 (updated the back-to-FA2 test: ranReadabilityPass is now true). --- .../src/webview/components/graphLayoutPresets.test.ts | 4 +++- .../extension/src/webview/components/graphLayoutPresets.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/webview/components/graphLayoutPresets.test.ts b/packages/extension/src/webview/components/graphLayoutPresets.test.ts index 9e6dfc0..204d2ed 100644 --- a/packages/extension/src/webview/components/graphLayoutPresets.test.ts +++ b/packages/extension/src/webview/components/graphLayoutPresets.test.ts @@ -190,7 +190,9 @@ describe("applyLayoutPreset — ForceAtlas2 re-application", () => { expect(result.status).toBe("applied"); if (result.status === "applied") { expect(result.preset).toBe("forceAtlas2"); - expect(result.ranReadabilityPass).toBe(false); + // ForceAtlas2 now runs the anti-collision pass too, so the default view + // de-overlaps its nodes (previously only circular/hierarchical did). + expect(result.ranReadabilityPass).toBe(true); } }); }); diff --git a/packages/extension/src/webview/components/graphLayoutPresets.ts b/packages/extension/src/webview/components/graphLayoutPresets.ts index 00cb31d..9f77ed9 100644 --- a/packages/extension/src/webview/components/graphLayoutPresets.ts +++ b/packages/extension/src/webview/components/graphLayoutPresets.ts @@ -328,10 +328,14 @@ export function applyLayoutPreset( iterations: 200, settings: FORCE_ATLAS2_SETTINGS, }); + // Anti-collision after the force pass so overlapping nodes are nudged + // apart — matches circular/hierarchical and keeps the default view (which + // is ForceAtlas2) from rendering nodes on top of each other. + runReadabilityPass(graph); return { status: "applied", preset: "forceAtlas2", - ranReadabilityPass: false, + ranReadabilityPass: true, notice: null, }; From 2676723f56b0177f91321d049d61b3548809a98b Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 21:42:12 +0530 Subject: [PATCH 05/11] =?UTF-8?q?fix(extension):=20Circular/Hierarchical?= =?UTF-8?q?=20graph=20vanished=20=E2=80=94=20camera=20framed=20in=20wrong?= =?UTF-8?q?=20coordinate=20space?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of 'only ForceAtlas2 comes into view': fitCameraToNodes computed a node bounding box in GRAPH coordinates and animated the camera to that center + ratio — but Sigma's camera operates in NORMALIZED space, not graph space. For the default FA2 cloud (near where the camera already sat) it looked fine; but Circular (ring at radius ~160), Hierarchical (origin-centred layers), and radial-on-select all relocate nodes to a different coordinate range, so the mis-aimed camera flew to empty space and the graph disappeared. Fix: delegate framing to Sigma's own camera.animatedReset() (after refresh() so bounds are recomputed), which fits the whole graph correctly for any layout. This replaces the hand-rolled graph-space math in cameraFit.ts; both zoomFit (Fit-to- view button) and the layout-preset handler now frame correctly. Widened the camera structural types to expose animatedReset. Updated tests that had encoded the bug as the spec (one was literally named 'reproduces the hierarchical-empty-page bug' and asserted raw-coordinate animation). extension 649. --- .../src/webview/components/GraphView.tsx | 2 + .../components/SigmaController.test.tsx | 12 +-- .../src/webview/components/SigmaController.ts | 1 + .../src/webview/components/cameraFit.ts | 59 ++++++------- .../components/fitCameraToNodes.test.tsx | 87 +++++++------------ 5 files changed, 68 insertions(+), 93 deletions(-) diff --git a/packages/extension/src/webview/components/GraphView.tsx b/packages/extension/src/webview/components/GraphView.tsx index 1edd6a5..9b5a760 100644 --- a/packages/extension/src/webview/components/GraphView.tsx +++ b/packages/extension/src/webview/components/GraphView.tsx @@ -103,7 +103,9 @@ type SigmaWithExtras = Sigma & { options?: { duration?: number }, ) => void; getState?: () => { x: number; y: number; ratio: number }; + animatedReset?: (options?: { duration?: number }) => unknown; }; + refresh?: () => void; }; function useReducedMotionPreference(): boolean { diff --git a/packages/extension/src/webview/components/SigmaController.test.tsx b/packages/extension/src/webview/components/SigmaController.test.tsx index d825102..95ff059 100644 --- a/packages/extension/src/webview/components/SigmaController.test.tsx +++ b/packages/extension/src/webview/components/SigmaController.test.tsx @@ -64,10 +64,12 @@ function triadGraph(): MultiDirectedGraph { */ function stubSigma(initial = { x: 0.4, y: 0.6, ratio: 2 }) { const animate = vi.fn(); + const animatedReset = vi.fn(); const kill = vi.fn(); const refresh = vi.fn(); const camera = { animate, + animatedReset, getState: () => initial, }; const sigma = { @@ -75,7 +77,7 @@ function stubSigma(initial = { x: 0.4, y: 0.6, ratio: 2 }) { refresh, getCamera: () => camera, } as unknown as Sigma; - return { sigma, animate, kill, refresh }; + return { sigma, animate, animatedReset, kill, refresh }; } function stubContainer(): HTMLDivElement { @@ -328,21 +330,21 @@ describe("SigmaController — camera operations", () => { it("zoomFit frames the populated graph via the camera", () => { const controller = new SigmaController(freshStore(), options()); - const { sigma, animate } = stubSigma(); + const { sigma, animatedReset } = stubSigma(); const graph = new MultiDirectedGraph(); graph.addNode("a", { x: 0, y: 0 }); graph.addNode("b", { x: 10, y: 10 }); controller.adopt(sigma, graph, stubContainer()); controller.zoomFit(); - expect(animate).toHaveBeenCalledTimes(1); + expect(animatedReset).toHaveBeenCalledTimes(1); }); it("zoomFit is a no-op on an empty graph", () => { const controller = new SigmaController(freshStore(), options()); - const { sigma, animate } = stubSigma(); + const { sigma, animatedReset } = stubSigma(); controller.adopt(sigma, new MultiDirectedGraph(), stubContainer()); controller.zoomFit(); - expect(animate).not.toHaveBeenCalled(); + expect(animatedReset).not.toHaveBeenCalled(); }); it("camera ops are no-ops before mount", () => { diff --git a/packages/extension/src/webview/components/SigmaController.ts b/packages/extension/src/webview/components/SigmaController.ts index ff2e6fc..c31ff7e 100644 --- a/packages/extension/src/webview/components/SigmaController.ts +++ b/packages/extension/src/webview/components/SigmaController.ts @@ -69,6 +69,7 @@ interface CameraSurface { options?: { duration?: number }, ) => void; getState?: () => { x: number; y: number; ratio: number }; + animatedReset?: (options?: { duration?: number }) => unknown; } type SigmaWithCamera = Sigma & { getCamera?: () => CameraSurface }; diff --git a/packages/extension/src/webview/components/cameraFit.ts b/packages/extension/src/webview/components/cameraFit.ts index 2b9bd15..592ba77 100644 --- a/packages/extension/src/webview/components/cameraFit.ts +++ b/packages/extension/src/webview/components/cameraFit.ts @@ -1,57 +1,48 @@ /** * Camera-framing helper, extracted from GraphView so it can be unit-tested - * without importing the WebGL-coupled Sigma renderer. Pure: depends only on - * its arguments. + * without importing the WebGL-coupled Sigma renderer. + * + * It delegates to Sigma's own `camera.animatedReset()`, which frames the whole + * graph in Sigma's *normalized* coordinate space. An earlier version did the + * bounding-box math by hand and animated the camera to raw graph coordinates — + * but the Sigma camera operates in normalized space, so any layout that moved + * nodes into a different coordinate range (Circular's ring, Hierarchical's + * layers, the radial-on-select rings) sent the camera to empty space and the + * graph vanished. `animatedReset` is correct by construction for any layout. */ /** The camera surface this helper drives (a structural subset of Sigma's). */ export interface FitCameraTarget { getCamera?: () => { - animate?: ( - state: { x: number; y: number; ratio: number }, - options?: { duration?: number }, - ) => void; + animatedReset?: (options?: { duration?: number }) => unknown; }; + /** Recompute node bounds before reset so the fit reflects the latest layout. */ + refresh?: () => void; } -/** Minimal structural view of the graph needed to compute node bounds. */ +/** Minimal structural view of the graph needed to detect the empty-graph no-op. */ export interface NodeBoundsGraph { order: number; - forEachNode(cb: (node: string, attrs: { x: number; y: number }) => void): void; } /** - * Frame every node in the viewport by animating the camera to the node - * bounding box's centre at a ratio that fits it with padding. Shared by the - * Fit-to-view button and the layout-preset handler so a preset that - * repositions nodes into a new coordinate range (e.g. Hierarchical's - * origin-centred layers) does not leave the camera looking at empty space. - * No-ops on an empty graph or a sigma without a camera. + * Frame every node in the viewport by resetting Sigma's camera to fit the graph. + * Shared by the Fit-to-view button and the layout-preset handler so a preset + * that repositions nodes into a new coordinate range does not leave the camera + * looking at empty space. No-ops on an empty graph or a sigma without a camera. + * + * `_container` is retained for call-site compatibility; Sigma computes the fit + * from its own viewport, so the helper no longer needs the container size. */ export function fitCameraToNodes( sigma: FitCameraTarget, - container: { clientWidth: number; clientHeight: number }, + _container: { clientWidth: number; clientHeight: number }, graph: NodeBoundsGraph, durationMs = 300, ): void { if (graph.order === 0) return; - let minX = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - graph.forEachNode((_node, attrs) => { - if (attrs.x < minX) minX = attrs.x; - if (attrs.x > maxX) maxX = attrs.x; - if (attrs.y < minY) minY = attrs.y; - if (attrs.y > maxY) maxY = attrs.y; - }); - const padding = 40; - const width = container.clientWidth - padding * 2; - const height = container.clientHeight - padding * 2; - const graphWidth = maxX - minX + 1; - const graphHeight = maxY - minY + 1; - const ratio = Math.max(graphWidth / width, graphHeight / height, 0.1); - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - sigma.getCamera?.()?.animate?.({ x: centerX, y: centerY, ratio }, { duration: durationMs }); + // Refresh first so Sigma recomputes its graph→viewport ratio from the new node + // positions; animatedReset then frames that updated bounding box. + sigma.refresh?.(); + sigma.getCamera?.()?.animatedReset?.({ duration: durationMs }); } diff --git a/packages/extension/src/webview/components/fitCameraToNodes.test.tsx b/packages/extension/src/webview/components/fitCameraToNodes.test.tsx index 41cbbf7..175662c 100644 --- a/packages/extension/src/webview/components/fitCameraToNodes.test.tsx +++ b/packages/extension/src/webview/components/fitCameraToNodes.test.tsx @@ -2,69 +2,48 @@ import { describe, expect, it, vi } from "vitest"; import { fitCameraToNodes } from "./cameraFit.js"; -interface FakeCameraState { - x: number; - y: number; - ratio: number; -} - -/** A sigma stub exposing only the camera surface fitCameraToNodes uses. */ -function makeSigma(animate: (state: FakeCameraState, opts?: { duration?: number }) => void) { - return { - getCamera: () => ({ animate }), +/** + * A sigma stub exposing the surface fitCameraToNodes uses: a camera with + * `animatedReset` and a top-level `refresh`. The helper delegates framing to + * Sigma (normalized space) rather than animating to raw graph coordinates — + * that hand-rolled math sent the camera to empty space for any layout that + * relocated nodes (the Circular/Hierarchical "graph vanished" bug). + */ +function makeSigma() { + const animatedReset = vi.fn(); + const refresh = vi.fn(); + const sigma = { + refresh, + getCamera: () => ({ animatedReset }), } as unknown as Parameters[0]; + return { sigma, animatedReset, refresh }; } -/** A graph stub with the minimal node-bounds surface. */ -function makeGraph(nodes: ReadonlyArray<{ x: number; y: number }>) { - return { - order: nodes.length, - forEachNode(cb: (node: string, attrs: { x: number; y: number }) => void) { - nodes.forEach((n, i) => cb(`n${i}`, n)); - }, - }; +function makeGraph(order: number) { + return { order }; } -describe("fitCameraToNodes", () => { - it("centres the camera on the node bounding box", () => { - const animate = vi.fn(); - const sigma = makeSigma(animate); - const container = { clientWidth: 800, clientHeight: 600 }; - // Nodes spanning x:[-100, 300], y:[-50, 150] → centre (100, 50). - const graph = makeGraph([ - { x: -100, y: -50 }, - { x: 300, y: 150 }, - { x: 0, y: 0 }, - ]); - - fitCameraToNodes(sigma, container, graph); +const CONTAINER = { clientWidth: 800, clientHeight: 600 }; - expect(animate).toHaveBeenCalledTimes(1); - const target = animate.mock.calls[0][0] as FakeCameraState; - expect(target.x).toBe(100); - expect(target.y).toBe(50); - // Ratio derives from the larger of width/height fit; must be positive and - // not the floor (the graph is wider than the 0.1 minimum implies). - expect(target.ratio).toBeGreaterThan(0.1); +describe("fitCameraToNodes", () => { + it("frames a populated graph via the camera's animatedReset", () => { + const { sigma, animatedReset, refresh } = makeSigma(); + fitCameraToNodes(sigma, CONTAINER, makeGraph(3)); + // Refresh first (recompute bounds for the new layout), then reset-to-fit. + expect(refresh).toHaveBeenCalledTimes(1); + expect(animatedReset).toHaveBeenCalledTimes(1); }); - it("does not move the camera for an empty graph", () => { - const animate = vi.fn(); - fitCameraToNodes(makeSigma(animate), { clientWidth: 800, clientHeight: 600 }, makeGraph([])); - expect(animate).not.toHaveBeenCalled(); + it("passes through the animation duration", () => { + const { sigma, animatedReset } = makeSigma(); + fitCameraToNodes(sigma, CONTAINER, makeGraph(2), 500); + expect(animatedReset).toHaveBeenCalledWith({ duration: 500 }); }); - it("frames an off-origin cluster so the camera does not stay at the origin", () => { - // Reproduces the hierarchical-empty-page bug: nodes far from origin must - // pull the camera centre away from (0,0). - const animate = vi.fn(); - const graph = makeGraph([ - { x: 4000, y: 4000 }, - { x: 4200, y: 4200 }, - ]); - fitCameraToNodes(makeSigma(animate), { clientWidth: 800, clientHeight: 600 }, graph); - const target = animate.mock.calls[0][0] as FakeCameraState; - expect(target.x).toBe(4100); - expect(target.y).toBe(4100); + it("does not move the camera for an empty graph", () => { + const { sigma, animatedReset, refresh } = makeSigma(); + fitCameraToNodes(sigma, CONTAINER, makeGraph(0)); + expect(refresh).not.toHaveBeenCalled(); + expect(animatedReset).not.toHaveBeenCalled(); }); }); From df5201a43ef3016eb4d24378e8ea463465cdf30a Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 22:25:30 +0530 Subject: [PATCH 06/11] build(extension): add @xyflow/react + enforce the two-engine boundary (focused-graph-view phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @xyflow/react@^12.11.0 to the extension (rules-approved engine for focused subgraphs). Add the ESLint no-restricted-imports boundary the rules doc claimed existed but didn't: bans the legacy 'reactflow' package and confines '@xyflow/react' to webview/blast-radius/ — the main graph stays on Sigma. No code uses React Flow yet; this is the dependency + guardrail groundwork. --- eslint.config.js | 27 +++++++++++ packages/extension/package.json | 1 + pnpm-lock.yaml | 85 ++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 2cede7d..23f19c4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -109,5 +109,32 @@ export default [ ], }, }, + { + // Two-engine boundary (RULE-ARCH-002): Sigma.js is the main graph view; + // React Flow (@xyflow/react) is allowed ONLY for focused subgraphs under + // webview/blast-radius/. The legacy `reactflow` package is banned outright. + // Mirrors the driver-behind-adapter confinement used in core. + files: ["packages/extension/src/**/*.{ts,tsx}"], + ignores: ["**/*.test.ts", "**/*.test.tsx", "packages/extension/src/webview/blast-radius/**"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "reactflow", + message: + "Use @xyflow/react (v12+), not the legacy reactflow package (RULE-ARCH-002).", + }, + { + name: "@xyflow/react", + message: + "React Flow is for focused subgraphs only — import it under webview/blast-radius/. The main graph uses Sigma.", + }, + ], + }, + ], + }, + }, prettier, ]; diff --git a/packages/extension/package.json b/packages/extension/package.json index f6d3042..9e1cda6 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -43,6 +43,7 @@ "dependencies": { "@sigma/node-border": "^3.0.0", "@sigma/node-square": "^3.0.0", + "@xyflow/react": "^12.11.0", "dompurify": "^3.4.7", "graphology": "^0.26.0", "graphology-communities-louvain": "^2.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ce0d9..3394d5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: '@sigma/node-square': specifier: ^3.0.0 version: 3.0.0(sigma@3.0.3(graphology-types@0.24.8)) + '@xyflow/react': + specifier: ^12.11.0 + version: 12.11.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) dompurify: specifier: ^3.4.7 version: 3.4.7 @@ -236,7 +239,7 @@ importers: version: 3.0.3(graphology-types@0.24.8) zustand: specifier: ^5.0.2 - version: 5.0.14(@types/react@19.2.16)(react@19.2.7) + version: 5.0.14(@types/react@19.2.16)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: '@dextree/core': specifier: workspace:* @@ -2168,6 +2171,22 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@xyflow/react@12.11.0': + resolution: {integrity: sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.77': + resolution: {integrity: sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==} + '@yomguithereal/helpers@1.1.1': resolution: {integrity: sha512-UYvAq/XCA7xoh1juWDYsq3W0WywOB+pz8cgVnE1b45ZfdMhBvHDrgmSFG3jXeZSr2tMTYLGHFHON+ekG05Jebg==} @@ -2391,6 +2410,9 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -4801,6 +4823,11 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5108,6 +5135,21 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -6977,6 +7019,31 @@ snapshots: transitivePeerDependencies: - typescript + '@xyflow/react@12.11.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@xyflow/system': 0.0.77 + classcat: 5.0.5 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + zustand: 4.5.7(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.77': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + '@yomguithereal/helpers@1.1.1': {} acorn-jsx@5.3.2(acorn@8.16.0): @@ -7210,6 +7277,8 @@ snapshots: chownr@3.0.0: {} + classcat@5.0.5: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -9874,6 +9943,10 @@ snapshots: url-join@4.0.1: {} + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + util-deprecate@1.0.2: optional: true @@ -10158,9 +10231,17 @@ snapshots: yoctocolors@2.1.2: {} - zustand@5.0.14(@types/react@19.2.16)(react@19.2.7): + zustand@4.5.7(@types/react@19.2.16)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + react: 19.2.7 + + zustand@5.0.14(@types/react@19.2.16)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.16 react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) zwitch@2.0.4: {} From e2ada8b7f21ec771d1ac32d24a48964572f48c07 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 22:30:45 +0530 Subject: [PATCH 07/11] feat(extension): focused-view pure model/layout/edge-styles (focused-graph-view phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework-agnostic core of the React Flow focused view, with zero @xyflow/react import so the logic is unit-testable without the RF runtime: - focusedGraphModel.ts: builds FocusedNode/FocusedEdge from the in-memory graph + a node's SelectionTraversal; rings from nodeLayers; caps the neighbourhood to the top-N most-important nodes (focus always kept) and reports truncatedCount; drops edges with a truncated endpoint; classifies edge direction (caller/callee) - focusedLayout.ts: deterministic centre + concentric rings (mirrors the Sigma radial-on-select math) - edgeStyles.ts: per-relation routed (smoothstep) edge style, reusing edgeColor (shared with Sigma) + direction-aware CALLS emphasis All built from data already in the webview — no host round-trip, no core change. extension 658 (+9 tests). --- .../src/webview/blast-radius/edgeStyles.ts | 47 +++++ .../webview/blast-radius/focusedGraph.test.ts | 181 ++++++++++++++++++ .../webview/blast-radius/focusedGraphModel.ts | 124 ++++++++++++ .../src/webview/blast-radius/focusedLayout.ts | 53 +++++ 4 files changed, 405 insertions(+) create mode 100644 packages/extension/src/webview/blast-radius/edgeStyles.ts create mode 100644 packages/extension/src/webview/blast-radius/focusedGraph.test.ts create mode 100644 packages/extension/src/webview/blast-radius/focusedGraphModel.ts create mode 100644 packages/extension/src/webview/blast-radius/focusedLayout.ts diff --git a/packages/extension/src/webview/blast-radius/edgeStyles.ts b/packages/extension/src/webview/blast-radius/edgeStyles.ts new file mode 100644 index 0000000..e41698b --- /dev/null +++ b/packages/extension/src/webview/blast-radius/edgeStyles.ts @@ -0,0 +1,47 @@ +import { edgeColor } from "../components/graphBuild.js"; +import type { ThemeColors } from "../components/graphViewTypes.js"; + +import type { FocusedEdge } from "./focusedGraphModel.js"; + +/** + * Per-relation edge styling for the focused view. Reuses `edgeColor` (the single + * source of truth shared with the Sigma view) for the base colour, and adds the + * React-Flow-specific bits: a routed edge type and direction-aware emphasis for + * the focus node's callers (inbound) vs callees (outbound). Pure — returns a + * plain style descriptor, no `@xyflow/react` import; `FocusedGraphView` maps it + * onto a React Flow edge. + */ + +export interface FocusedEdgeStyle { + /** React Flow edge type — routed so edges bend around cards, not through them. */ + type: "smoothstep"; + color: string; + strokeWidth: number; + /** Show an arrowhead so direction reads (CALLS source→target, etc.). */ + markerEnd: boolean; +} + +const EMPHASISED_WIDTH = 2.5; +const BASE_WIDTH = 1.5; + +/** + * Style a focused-view edge. Edges touching the focus node are emphasised and, + * for CALLS, coloured by direction (caller vs callee) — mirroring the Sigma + * reducer's direction-aware emphasis. All other edges use their relation colour. + */ +export function focusedEdgeStyle(edge: FocusedEdge, colors: ThemeColors): FocusedEdgeStyle { + const emphasised = edge.direction !== "other"; + + let color = edgeColor(edge.kind, colors); + if (edge.kind === "CALLS") { + if (edge.direction === "inbound") color = colors.callerEdgeColor; + else if (edge.direction === "outbound") color = colors.calleeEdgeColor; + } + + return { + type: "smoothstep", + color, + strokeWidth: emphasised ? EMPHASISED_WIDTH : BASE_WIDTH, + markerEnd: true, + }; +} diff --git a/packages/extension/src/webview/blast-radius/focusedGraph.test.ts b/packages/extension/src/webview/blast-radius/focusedGraph.test.ts new file mode 100644 index 0000000..9c55058 --- /dev/null +++ b/packages/extension/src/webview/blast-radius/focusedGraph.test.ts @@ -0,0 +1,181 @@ +import type { GraphEdge, GraphNode } from "@dextree/core"; +import { describe, expect, it } from "vitest"; + +import type { SelectionTraversal, ThemeColors } from "../components/graphViewTypes.js"; +import { buildFocusedGraphModel, FOCUSED_VIEW_MAX_NODES } from "./focusedGraphModel.js"; +import { computeFocusedLayout, FOCUSED_RING_SPACING } from "./focusedLayout.js"; +import { focusedEdgeStyle } from "./edgeStyles.js"; + +const colors: ThemeColors = { + backgroundColor: "#000000", + labelColor: "#ffffff", + disabledColor: "#888888", + fileNodeColor: "#1188ff", + symbolKindColors: { + default: "#cccccc", + function: "#dcaa00", + class: "#33cc88", + interface: "#8888ff", + enum: "#cc8800", + variable: "#aaaaaa", + type: "#8888ff", + method: "#dcaa00", + }, + definesEdgeColor: "#1166cc", + importsEdgeColor: "#22aa44", + callsEdgeColor: "#ee8822", + inheritsEdgeColor: "#aa44cc", + instantiatesEdgeColor: "#cc4422", + tracePathEdgeColor: "#dcdcaa", + callerEdgeColor: "#22aa44", + calleeEdgeColor: "#ee8822", + entryBorderColor: "#d4af37", +}; + +function sym(id: string, importance?: number, isCore?: boolean): GraphNode { + return { + id, + type: "symbol", + label: id, + filePath: `src/${id}.ts`, + startLine: 1, + symbolKind: "function", + ...(importance === undefined ? {} : { importance }), + ...(isCore === undefined ? {} : { isCore }), + }; +} + +function edge(id: string, source: string, target: string, kind: GraphEdge["kind"]): GraphEdge { + return { id, source, target, kind }; +} + +/** focus → a,b (ring1); a → c (ring2). */ +function traversal(over: Partial = {}): SelectionTraversal { + return { + selectedNodeId: "focus", + nodeIds: new Set(["focus", "a", "b", "c"]), + edgeIds: new Set(["e1", "e2", "e3"]), + orderedEdgeIds: ["e1", "e2", "e3"], + hopLayers: [["e1", "e2"], ["e3"]], + nodeLayers: [["focus"], ["a", "b"], ["c"]], + maxDepth: 2, + ...over, + }; +} + +describe("buildFocusedGraphModel", () => { + const nodes = [sym("focus", 5), sym("a", 3), sym("b", 2), sym("c", 1)]; + const edges = [ + edge("e1", "focus", "a", "CALLS"), // outbound (callee) + edge("e2", "b", "focus", "CALLS"), // inbound (caller) + edge("e3", "a", "c", "IMPORTS"), + ]; + + it("includes the focus node and its neighbourhood with ring indices", () => { + const model = buildFocusedGraphModel(nodes, edges, traversal()); + const byId = new Map(model.nodes.map((n) => [n.id, n])); + expect(byId.get("focus")?.isFocus).toBe(true); + expect(byId.get("focus")?.ring).toBe(0); + expect(byId.get("a")?.ring).toBe(1); + expect(byId.get("c")?.ring).toBe(2); + expect(model.truncatedCount).toBe(0); + }); + + it("classifies edge direction relative to the focus node", () => { + const model = buildFocusedGraphModel(nodes, edges, traversal()); + const byId = new Map(model.edges.map((e) => [e.id, e])); + expect(byId.get("e1")?.direction).toBe("outbound"); // focus → a + expect(byId.get("e2")?.direction).toBe("inbound"); // b → focus + expect(byId.get("e3")?.direction).toBe("other"); // a → c + }); + + it("caps the neighbourhood to the most important nodes and reports truncation", () => { + const many = [sym("focus", 100), ...Array.from({ length: 10 }, (_, i) => sym(`n${i}`, i))]; + const t = traversal({ + selectedNodeId: "focus", + nodeIds: new Set(many.map((n) => n.id)), + edgeIds: new Set(), + orderedEdgeIds: [], + hopLayers: [], + nodeLayers: [["focus"], many.slice(1).map((n) => n.id)], + }); + const model = buildFocusedGraphModel(many, [], t, 4); // focus + top 3 + expect(model.nodes).toHaveLength(4); + expect(model.nodes.some((n) => n.isFocus)).toBe(true); + // Highest-importance others kept (n9, n8, n7), lowest dropped. + expect(model.nodes.map((n) => n.id).sort()).toEqual(["focus", "n7", "n8", "n9"]); + expect(model.truncatedCount).toBe(7); // 10 others − 3 kept + }); + + it("drops edges whose endpoint was truncated", () => { + const many = [sym("focus", 100), ...Array.from({ length: 10 }, (_, i) => sym(`n${i}`, i))]; + const droppedEdge = edge("ed", "focus", "n0", "CALLS"); // n0 is lowest importance → dropped + const t = traversal({ + nodeIds: new Set(many.map((n) => n.id)), + edgeIds: new Set(["ed"]), + nodeLayers: [["focus"], many.slice(1).map((n) => n.id)], + }); + const model = buildFocusedGraphModel(many, [droppedEdge], t, 4); + expect(model.edges).toHaveLength(0); + }); + + it("default cap is the documented constant", () => { + expect(FOCUSED_VIEW_MAX_NODES).toBeGreaterThan(0); + }); +}); + +describe("computeFocusedLayout", () => { + it("places the focus node at the origin and rings at increasing radius", () => { + const model = buildFocusedGraphModel( + [sym("focus", 5), sym("a", 3), sym("b", 2), sym("c", 1)], + [], + traversal({ edgeIds: new Set() }), + ); + const pos = computeFocusedLayout(model.nodes); + expect(pos.get("focus")).toEqual({ x: 0, y: 0 }); + + const radius = (id: string) => Math.hypot(pos.get(id)!.x, pos.get(id)!.y); + expect(radius("a")).toBeCloseTo(FOCUSED_RING_SPACING, 5); + expect(radius("c")).toBeCloseTo(2 * FOCUSED_RING_SPACING, 5); + }); + + it("spreads same-ring nodes to distinct positions", () => { + const model = buildFocusedGraphModel( + [sym("focus", 5), sym("a", 3), sym("b", 2)], + [], + traversal({ nodeIds: new Set(["focus", "a", "b"]), nodeLayers: [["focus"], ["a", "b"]] }), + ); + const pos = computeFocusedLayout(model.nodes); + expect(pos.get("a")).not.toEqual(pos.get("b")); + }); +}); + +describe("focusedEdgeStyle", () => { + it("routes edges (smoothstep) with an arrowhead", () => { + const style = focusedEdgeStyle( + { id: "e", source: "x", target: "y", kind: "IMPORTS", direction: "other" }, + colors, + ); + expect(style.type).toBe("smoothstep"); + expect(style.markerEnd).toBe(true); + expect(style.color).toBe(colors.importsEdgeColor); + }); + + it("colours CALLS by direction and emphasises focus-adjacent edges", () => { + const inbound = focusedEdgeStyle( + { id: "i", source: "b", target: "focus", kind: "CALLS", direction: "inbound" }, + colors, + ); + const outbound = focusedEdgeStyle( + { id: "o", source: "focus", target: "a", kind: "CALLS", direction: "outbound" }, + colors, + ); + const other = focusedEdgeStyle( + { id: "x", source: "a", target: "c", kind: "CALLS", direction: "other" }, + colors, + ); + expect(inbound.color).toBe(colors.callerEdgeColor); + expect(outbound.color).toBe(colors.calleeEdgeColor); + expect(inbound.strokeWidth).toBeGreaterThan(other.strokeWidth); + }); +}); diff --git a/packages/extension/src/webview/blast-radius/focusedGraphModel.ts b/packages/extension/src/webview/blast-radius/focusedGraphModel.ts new file mode 100644 index 0000000..465901e --- /dev/null +++ b/packages/extension/src/webview/blast-radius/focusedGraphModel.ts @@ -0,0 +1,124 @@ +import type { GraphEdge, GraphNode } from "@dextree/core"; + +import type { SelectionTraversal } from "../components/graphViewTypes.js"; + +/** + * Framework-agnostic node/edge model for the focused (React Flow) view. + * + * This module is intentionally free of any `@xyflow/react` import so the "which + * nodes, which edges, how bounded" logic is pure and unit-testable without the + * React Flow runtime. `FocusedGraphView` maps these plain shapes onto React + * Flow's `Node`/`Edge` types. Built entirely from data already in the webview — + * the rendered graph plus a node's selection traversal — so no host round-trip. + */ + +/** A boxed-card node in the focused view. `ring` is the BFS depth (0 = focus). */ +export interface FocusedNode { + id: string; + label: string; + nodeType: GraphNode["type"]; + symbolKind?: GraphNode["symbolKind"]; + importance: number; + isCore: boolean; + ring: number; + /** True for the node the view is focused on (ring 0). */ + isFocus: boolean; +} + +/** A routed edge in the focused view. Direction relative to the focus node. */ +export interface FocusedEdge { + id: string; + source: string; + target: string; + kind: GraphEdge["kind"]; + /** Relative to the focus node: an inbound edge is a caller, outbound a callee. */ + direction: "inbound" | "outbound" | "other"; +} + +export interface FocusedGraphModel { + focusId: string; + nodes: FocusedNode[]; + edges: FocusedEdge[]; + /** How many neighbourhood nodes were dropped by the top-N cap (0 if none). */ + truncatedCount: number; +} + +/** + * Max nodes the focused view renders before it stops being legible. The focus + * node is always kept; the rest of the neighbourhood is ranked by importance and + * the lowest-ranked overflow is dropped (and reported via `truncatedCount`). + */ +export const FOCUSED_VIEW_MAX_NODES = 60; + +const importanceOf = (n: GraphNode): number => + typeof n.importance === "number" && Number.isFinite(n.importance) ? n.importance : 0; + +/** + * Build the focused model from the in-memory graph + a node's traversal. + * + * `traversal.nodeLayers` gives node ids by BFS depth (layer 0 = the focus node), + * so a node's ring is its layer index. Nodes beyond the cap are dropped lowest- + * importance-first (never the focus node); edges with a dropped endpoint are + * dropped too. + */ +export function buildFocusedGraphModel( + graphNodes: readonly GraphNode[], + graphEdges: readonly GraphEdge[], + traversal: SelectionTraversal, + maxNodes: number = FOCUSED_VIEW_MAX_NODES, +): FocusedGraphModel { + const focusId = traversal.selectedNodeId; + const byId = new Map(graphNodes.map((n) => [n.id, n])); + + // Ring index per node id, from the BFS layers (layer 0 = focus). + const ringOf = new Map(); + traversal.nodeLayers.forEach((layer, ring) => { + for (const id of layer) if (!ringOf.has(id)) ringOf.set(id, ring); + }); + + // Candidate focused nodes (those that exist in the live graph), focus first. + const candidates: GraphNode[] = []; + for (const id of traversal.nodeIds) { + const node = byId.get(id); + if (node !== undefined) candidates.push(node); + } + + // Keep the focus node, then the most-important others up to the cap. + const others = candidates + .filter((n) => n.id !== focusId) + .sort((a, b) => importanceOf(b) - importanceOf(a)); + const keptOthers = others.slice(0, Math.max(0, maxNodes - 1)); + const truncatedCount = others.length - keptOthers.length; + + const focusNode = byId.get(focusId); + const kept: GraphNode[] = focusNode === undefined ? keptOthers : [focusNode, ...keptOthers]; + const keptIds = new Set(kept.map((n) => n.id)); + + const nodes: FocusedNode[] = kept.map((n) => ({ + id: n.id, + label: n.label, + nodeType: n.type, + ...(n.symbolKind === undefined ? {} : { symbolKind: n.symbolKind }), + importance: importanceOf(n), + isCore: n.isCore === true, + ring: ringOf.get(n.id) ?? 0, + isFocus: n.id === focusId, + })); + + const edges: FocusedEdge[] = []; + for (const edge of graphEdges) { + if (!traversal.edgeIds.has(edge.id)) continue; + if (!keptIds.has(edge.source) || !keptIds.has(edge.target)) continue; // dropped endpoint + const direction: FocusedEdge["direction"] = + edge.target === focusId ? "inbound" : edge.source === focusId ? "outbound" : "other"; + edges.push({ + id: edge.id, + source: edge.source, + target: edge.target, + kind: edge.kind, + direction, + }); + } + + return { focusId, nodes, edges, truncatedCount }; +} diff --git a/packages/extension/src/webview/blast-radius/focusedLayout.ts b/packages/extension/src/webview/blast-radius/focusedLayout.ts new file mode 100644 index 0000000..f9a8ff5 --- /dev/null +++ b/packages/extension/src/webview/blast-radius/focusedLayout.ts @@ -0,0 +1,53 @@ +import type { FocusedNode } from "./focusedGraphModel.js"; + +/** + * Deterministic positions for the focused view: the focus node at the origin, + * each BFS ring on a concentric circle of increasing radius. Pure (no React Flow + * runtime, no DOM) so it is unit-testable; `FocusedGraphView` feeds the result + * into React Flow node positions. + * + * Deterministic-not-force because focused graphs are small and read better with a + * stable centre+rings than a re-simulated blob — mirrors the Sigma radial-on- + * select layout. + */ + +export interface XY { + x: number; + y: number; +} + +/** Pixel radius of the first ring; each further ring steps out by this much. */ +export const FOCUSED_RING_SPACING = 220; + +/** + * Assign an `{x,y}` to every focused node. Ring 0 (the focus node) goes to the + * origin; nodes sharing a ring are spread evenly around that ring's circle. + * Order within a ring follows the input order (already importance-ranked by the + * model), so the most important neighbours get stable, predictable angles. + */ +export function computeFocusedLayout(nodes: readonly FocusedNode[]): Map { + const positions = new Map(); + + // Bucket node ids by ring, preserving input order. + const ringBuckets = new Map(); + for (const node of nodes) { + const bucket = ringBuckets.get(node.ring); + if (bucket === undefined) ringBuckets.set(node.ring, [node.id]); + else bucket.push(node.id); + } + + for (const [ring, ids] of ringBuckets) { + if (ring === 0) { + // Focus node(s) at the origin (there is normally exactly one). + for (const id of ids) positions.set(id, { x: 0, y: 0 }); + continue; + } + const radius = ring * FOCUSED_RING_SPACING; + for (let i = 0; i < ids.length; i++) { + const angle = (2 * Math.PI * i) / ids.length; + positions.set(ids[i]!, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }); + } + } + + return positions; +} From c45001d7a056eb0c1eccd44b8bd8b60f12fb22c3 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 22:37:45 +0530 Subject: [PATCH 08/11] feat(extension): React Flow focused-view components (focused-graph-view phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boxed-card render surface (the first + only @xyflow/react importers, confined to blast-radius/ by ESLint): - SymbolNode.tsx + .module.css: boxed-card custom node — name + kind badge + kind codicon INSIDE the box, source/target handles so edges route to the card edge. Focus/core emphasis via VS Code CSS vars. (This is the GitNexus-style card look.) - FocusedGraphView.tsx + .module.css: canvas; maps the pure model/ layout/edge-styles (phase 1) onto RF Node/Edge; fitView; routed (smoothstep) edges with arrowheads; truncation notice. Imports xyflow CSS. - LazyFocusedGraphView.tsx: React.lazy code-split so xyflow is not in the initial graph bundle (loads only when a node is focused). SymbolNode component tests (name, badge, file/class icons). Full-canvas RF render deferred — jsdom gives the canvas zero dimensions, making it flaky; the pure model/layout that decides what/where is already covered in phase 1. extension 661. --- .../blast-radius/FocusedGraphView.module.css | 22 ++++ .../webview/blast-radius/FocusedGraphView.tsx | 100 ++++++++++++++++++ .../blast-radius/LazyFocusedGraphView.tsx | 22 ++++ .../blast-radius/SymbolNode.module.css | 57 ++++++++++ .../webview/blast-radius/SymbolNode.test.tsx | 57 ++++++++++ .../src/webview/blast-radius/SymbolNode.tsx | 64 +++++++++++ 6 files changed, 322 insertions(+) create mode 100644 packages/extension/src/webview/blast-radius/FocusedGraphView.module.css create mode 100644 packages/extension/src/webview/blast-radius/FocusedGraphView.tsx create mode 100644 packages/extension/src/webview/blast-radius/LazyFocusedGraphView.tsx create mode 100644 packages/extension/src/webview/blast-radius/SymbolNode.module.css create mode 100644 packages/extension/src/webview/blast-radius/SymbolNode.test.tsx create mode 100644 packages/extension/src/webview/blast-radius/SymbolNode.tsx diff --git a/packages/extension/src/webview/blast-radius/FocusedGraphView.module.css b/packages/extension/src/webview/blast-radius/FocusedGraphView.module.css new file mode 100644 index 0000000..b21bc60 --- /dev/null +++ b/packages/extension/src/webview/blast-radius/FocusedGraphView.module.css @@ -0,0 +1,22 @@ +/* Focused (React Flow) view canvas. VS Code variables only. */ + +.canvas { + position: relative; + width: 100%; + height: 100%; + background: var(--vscode-editor-background); +} + +.truncationNotice { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + padding: 4px 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + color: var(--vscode-descriptionForeground); + font-size: 11px; + pointer-events: none; +} diff --git a/packages/extension/src/webview/blast-radius/FocusedGraphView.tsx b/packages/extension/src/webview/blast-radius/FocusedGraphView.tsx new file mode 100644 index 0000000..c8bc5eb --- /dev/null +++ b/packages/extension/src/webview/blast-radius/FocusedGraphView.tsx @@ -0,0 +1,100 @@ +import { + Background, + Controls, + MarkerType, + ReactFlow, + ReactFlowProvider, + type Edge, + type Node, +} from "@xyflow/react"; +import type React from "react"; +import { useMemo } from "react"; + +import "@xyflow/react/dist/style.css"; + +import type { GraphEdge, GraphNode } from "@dextree/core"; + +import type { SelectionTraversal, ThemeColors } from "../components/graphViewTypes.js"; +import { focusedEdgeStyle } from "./edgeStyles.js"; +import { buildFocusedGraphModel } from "./focusedGraphModel.js"; +import { computeFocusedLayout } from "./focusedLayout.js"; +import { SymbolNode } from "./SymbolNode.js"; +import styles from "./FocusedGraphView.module.css"; + +/** + * The React Flow focused view: a node's neighbourhood as boxed cards with routed + * edges. Pure-model-driven — `buildFocusedGraphModel` / `computeFocusedLayout` / + * `focusedEdgeStyle` (all unit-tested without this runtime) decide *what* and + * *where*; this component only maps those into React Flow's `Node`/`Edge` shapes + * and renders the canvas. The only `@xyflow/react` import lives here + SymbolNode, + * confined to blast-radius/ by ESLint (RULE-ARCH-002). + */ + +const nodeTypes = { symbol: SymbolNode }; + +export interface FocusedGraphViewProps { + graphNodes: readonly GraphNode[]; + graphEdges: readonly GraphEdge[]; + traversal: SelectionTraversal; + colors: ThemeColors; +} + +export function FocusedGraphView({ + graphNodes, + graphEdges, + traversal, + colors, +}: FocusedGraphViewProps): React.ReactElement { + const { nodes, edges, truncatedCount } = useMemo(() => { + const model = buildFocusedGraphModel(graphNodes, graphEdges, traversal); + const positions = computeFocusedLayout(model.nodes); + + const rfNodes: Node[] = model.nodes.map((n) => ({ + id: n.id, + type: "symbol", + position: positions.get(n.id) ?? { x: 0, y: 0 }, + data: n as unknown as Record, + })); + + const rfEdges: Edge[] = model.edges.map((e) => { + const style = focusedEdgeStyle(e, colors); + return { + id: e.id, + source: e.source, + target: e.target, + type: style.type, + style: { stroke: style.color, strokeWidth: style.strokeWidth }, + ...(style.markerEnd + ? { markerEnd: { type: MarkerType.ArrowClosed, color: style.color } } + : {}), + }; + }); + + return { nodes: rfNodes, edges: rfEdges, truncatedCount: model.truncatedCount }; + }, [graphNodes, graphEdges, traversal, colors]); + + return ( + +
+ + + + + {truncatedCount > 0 ? ( +
+ Showing the {nodes.length} most relevant nodes; {truncatedCount} more hidden. +
+ ) : null} +
+
+ ); +} diff --git a/packages/extension/src/webview/blast-radius/LazyFocusedGraphView.tsx b/packages/extension/src/webview/blast-radius/LazyFocusedGraphView.tsx new file mode 100644 index 0000000..31cfcfd --- /dev/null +++ b/packages/extension/src/webview/blast-radius/LazyFocusedGraphView.tsx @@ -0,0 +1,22 @@ +import type React from "react"; +import { lazy, Suspense } from "react"; + +import type { FocusedGraphViewProps } from "./FocusedGraphView.js"; + +/** + * Lazy entry point for the focused view. `@xyflow/react` (and its CSS) is heavy + * and only needed once a user focuses a node, so the whole `FocusedGraphView` + * module — the sole importer of React Flow — is code-split behind `React.lazy` + * and kept out of the initial graph bundle. + */ +const FocusedGraphViewInner = lazy(() => + import("./FocusedGraphView.js").then((m) => ({ default: m.FocusedGraphView })), +); + +export function LazyFocusedGraphView(props: FocusedGraphViewProps): React.ReactElement { + return ( + + + + ); +} diff --git a/packages/extension/src/webview/blast-radius/SymbolNode.module.css b/packages/extension/src/webview/blast-radius/SymbolNode.module.css new file mode 100644 index 0000000..8642538 --- /dev/null +++ b/packages/extension/src/webview/blast-radius/SymbolNode.module.css @@ -0,0 +1,57 @@ +/* Boxed-card node for the focused (React Flow) view. VS Code variables only. */ + +.card { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 220px; + padding: 6px 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + color: var(--vscode-foreground); + font-size: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); +} + +/* The focused node — emphasised with the accent border. */ +.focus { + border-color: var(--vscode-focusBorder); + border-width: 2px; +} + +/* High-importance / core nodes get a subtler emphasis than the focus node. */ +.core { + border-color: var(--vscode-charts-yellow, var(--vscode-panel-border)); +} + +.icon { + flex: 0 0 auto; + font-size: 14px; + color: var(--vscode-symbolIcon-classForeground, var(--vscode-foreground)); +} + +.label { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.badge { + flex: 0 0 auto; + padding: 1px 6px; + border-radius: 999px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 10px; + text-transform: lowercase; +} + +.handle { + width: 6px; + height: 6px; + background: var(--vscode-panel-border); + border: none; +} diff --git a/packages/extension/src/webview/blast-radius/SymbolNode.test.tsx b/packages/extension/src/webview/blast-radius/SymbolNode.test.tsx new file mode 100644 index 0000000..c989746 --- /dev/null +++ b/packages/extension/src/webview/blast-radius/SymbolNode.test.tsx @@ -0,0 +1,57 @@ +import { ReactFlowProvider } from "@xyflow/react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { FocusedNode } from "./focusedGraphModel.js"; +import { SymbolNode } from "./SymbolNode.js"; + +afterEach(cleanup); + +function node(over: Partial = {}): FocusedNode { + return { + id: "n1", + label: "getUser", + nodeType: "symbol", + symbolKind: "function", + importance: 1, + isCore: false, + ring: 1, + isFocus: false, + ...over, + }; +} + +/** SymbolNode uses React Flow's , which needs the provider context. */ +function renderNode(data: FocusedNode) { + // NodeProps has many required fields; the card only reads `data`. + const props = { data } as unknown as Parameters[0]; + return render( + + + , + ); +} + +describe("SymbolNode", () => { + it("renders the symbol name and kind badge inside a card", () => { + renderNode(node({ label: "getUser", symbolKind: "function" })); + const card = screen.getByTestId("focused-symbol-node"); + expect(card.textContent).toContain("getUser"); + expect(card.textContent).toContain("function"); + }); + + it("shows the file badge/icon for a file node", () => { + renderNode(node({ nodeType: "file", symbolKind: undefined, label: "user.ts" })); + const card = screen.getByTestId("focused-symbol-node"); + expect(card.textContent).toContain("user.ts"); + expect(card.textContent).toContain("file"); + expect(card.querySelector(".codicon-symbol-file")).not.toBeNull(); + }); + + it("renders a class node with the class icon", () => { + renderNode(node({ symbolKind: "class", label: "UserService" })); + const card = screen.getByTestId("focused-symbol-node"); + expect(card.querySelector(".codicon-symbol-class")).not.toBeNull(); + expect(card.textContent).toContain("class"); + }); +}); diff --git a/packages/extension/src/webview/blast-radius/SymbolNode.tsx b/packages/extension/src/webview/blast-radius/SymbolNode.tsx new file mode 100644 index 0000000..2cc7eb9 --- /dev/null +++ b/packages/extension/src/webview/blast-radius/SymbolNode.tsx @@ -0,0 +1,64 @@ +import type React from "react"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; + +import type { FocusedNode } from "./focusedGraphModel.js"; +import styles from "./SymbolNode.module.css"; + +/** + * Boxed-card custom node for the focused (React Flow) view: the symbol name and a + * type badge rendered *inside* a box (vs Sigma's point + outside label). Source/ + * target handles let React Flow route edges to the card's edge, not its centre, + * which is what keeps routed edges from crossing through cards. + * + * VS Code CSS variables + codicons only (project rules). The node payload is the + * pure `FocusedNode` from `focusedGraphModel`. + */ + +/** Codicon for a node's kind — symbols by symbolKind, containers by type. */ +function codiconFor(node: FocusedNode): string { + if (node.nodeType === "file") return "symbol-file"; + if (node.nodeType === "folder") return "folder"; + switch (node.symbolKind) { + case "class": + return "symbol-class"; + case "interface": + return "symbol-interface"; + case "method": + return "symbol-method"; + case "enum": + return "symbol-enum"; + case "type": + return "symbol-type-parameter"; + case "variable": + return "symbol-variable"; + case "function": + return "symbol-method"; + default: + return "symbol-misc"; + } +} + +/** Human label for the badge — the symbolKind, or the node type for containers. */ +function kindLabel(node: FocusedNode): string { + if (node.nodeType !== "symbol") return node.nodeType; + return node.symbolKind ?? "symbol"; +} + +export type SymbolNodeData = FocusedNode; + +export function SymbolNode({ data }: NodeProps): React.ReactElement { + const node = data as unknown as SymbolNodeData; + const classes = [styles.card]; + if (node.isFocus) classes.push(styles.focus); + else if (node.isCore) classes.push(styles.core); + + return ( +
+ +
+ ); +} From ec1bfbb859215d1d386de32f1f8982b1721884b6 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 22:51:15 +0530 Subject: [PATCH 09/11] =?UTF-8?q?feat(extension):=20wire=20the=20focused?= =?UTF-8?q?=20view=20=E2=80=94=20Alt/Cmd+double-click=20opens=20it=20(focu?= =?UTF-8?q?sed-graph-view=20phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alt/Cmd + double-click a node opens the React Flow boxed-card focused view as a full-canvas overlay over the Sigma graph; a Close button dismisses it. Plain double-click keeps its existing go-to-source behaviour (the spec's plain-double- click trigger would have clobbered it — resolved to a modifier gesture). - SigmaController: new onFocus callback; doubleClickNode reads event.event.original and routes modifier+double-click → onFocus, else → onNavigate. - GraphView: focusedTraversal state; openFocusedView runs computeSelection at the CURRENT depth-control value (honours the depth slider) via a ref so the Sigma mount effect doesn't re-run on depth change; overlay renders LazyFocusedGraphView with in-memory nodes/edges + theme colours; close clears it (Sigma untouched). Honest correction shipped: the webview is a single IIFE (vite lib), so React.lazy can't code-split — xyflow ships in the one bundle; the lazy wrapper defers construction only. Comment + spec corrected. Webview-only; no core/host/contract change. Full gate green: core 282 / exporters 161 / extension 661. Refs: focused-graph-view --- .../blast-radius/LazyFocusedGraphView.tsx | 13 ++++-- .../webview/components/GraphView.module.css | 31 +++++++++++++ .../src/webview/components/GraphView.tsx | 46 +++++++++++++++++++ .../components/SigmaController.test.tsx | 3 ++ .../src/webview/components/SigmaController.ts | 9 ++++ 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/webview/blast-radius/LazyFocusedGraphView.tsx b/packages/extension/src/webview/blast-radius/LazyFocusedGraphView.tsx index 31cfcfd..2534b6e 100644 --- a/packages/extension/src/webview/blast-radius/LazyFocusedGraphView.tsx +++ b/packages/extension/src/webview/blast-radius/LazyFocusedGraphView.tsx @@ -4,10 +4,15 @@ import { lazy, Suspense } from "react"; import type { FocusedGraphViewProps } from "./FocusedGraphView.js"; /** - * Lazy entry point for the focused view. `@xyflow/react` (and its CSS) is heavy - * and only needed once a user focuses a node, so the whole `FocusedGraphView` - * module — the sole importer of React Flow — is code-split behind `React.lazy` - * and kept out of the initial graph bundle. + * Lazy entry point for the focused view. `FocusedGraphView` (the sole importer of + * `@xyflow/react`) is loaded via `React.lazy` so the React Flow component tree is + * only constructed when a user actually focuses a node. + * + * NOTE: the webview is built as a single IIFE (`vite lib`, `formats: ["iife"]`), + * which cannot emit dynamic-import chunks — so this does NOT keep React Flow out + * of the bundle (it ships in the one webview.js regardless). The deferral is of + * render/construction work, not bytes. Kept because it is correct, costs nothing, + * and becomes a real code-split if the webview build ever moves to multi-chunk. */ const FocusedGraphViewInner = lazy(() => import("./FocusedGraphView.js").then((m) => ({ default: m.FocusedGraphView })), diff --git a/packages/extension/src/webview/components/GraphView.module.css b/packages/extension/src/webview/components/GraphView.module.css index 9fef9b8..2fc60c5 100644 --- a/packages/extension/src/webview/components/GraphView.module.css +++ b/packages/extension/src/webview/components/GraphView.module.css @@ -19,6 +19,8 @@ height: 100%; width: 100%; min-height: 0; + /* Anchor for the focused-view overlay (position: absolute; inset: 0). */ + position: relative; background: var(--vscode-editor-background); } @@ -182,3 +184,32 @@ grid-template-columns: 200px 1fr 240px; } } + +/* Focused (React Flow) view — full-canvas overlay above the Sigma graph. */ +.focusedOverlay { + position: absolute; + inset: 0; + z-index: 20; + background: var(--vscode-editor-background); +} + +.focusedCloseButton { + position: absolute; + top: 10px; + right: 10px; + z-index: 21; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 4px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + cursor: pointer; + font-size: 12px; +} + +.focusedCloseButton:hover { + background: var(--vscode-button-secondaryHoverBackground); +} diff --git a/packages/extension/src/webview/components/GraphView.tsx b/packages/extension/src/webview/components/GraphView.tsx index 9b5a760..448cdf9 100644 --- a/packages/extension/src/webview/components/GraphView.tsx +++ b/packages/extension/src/webview/components/GraphView.tsx @@ -14,6 +14,7 @@ import { restoreNodePositions, snapshotNodePositions, } from "./graphLayoutPresets.js"; +import { LazyFocusedGraphView } from "../blast-radius/LazyFocusedGraphView.js"; import { GraphToolbar } from "./GraphToolbar.js"; import { EdgeTypesPanel, type EdgeTypeEntry } from "./EdgeTypesPanel.js"; import { fitCameraToNodes, type NodeBoundsGraph } from "./cameraFit.js"; @@ -587,6 +588,31 @@ export function GraphView({ sigmaRef.current?.refresh(); }, []); + // The focused (React Flow boxed-card) view's input traversal, or null when the + // overlay is closed. Opened by Alt/Cmd + double-click; the Sigma canvas stays + // mounted underneath so closing restores it with no rebuild. + const [focusedTraversal, setFocusedTraversal] = useState(null); + + const openFocusedView = useCallback( + (nodeId: string): void => { + const graph = graphRef.current; + if (graph === null || !graph.hasNode(nodeId)) return; + // Honour the current depth control as the focused neighbourhood radius. + setFocusedTraversal(computeSelection(graph, nodeId, depth)); + }, + [depth], + ); + + const closeFocusedView = useCallback((): void => { + setFocusedTraversal(null); + }, []); + + // The mount effect must not re-run when `depth` changes (it would rebuild + // Sigma). Route onFocus through a ref that always holds the latest + // depth-aware opener, so the mount callback stays stable. + const openFocusedViewRef = useRef(openFocusedView); + openFocusedViewRef.current = openFocusedView; + // Select a node (highlight + neighbourhood + Inspector) the same way a canvas // single-click does, then fly the camera to it with a zoom so the move is // obvious. `selectNode` lives in the Sigma effect closure, so the state writes @@ -948,6 +974,7 @@ export function GraphView({ try { const sigma = controller.mount(container, graph, { onNavigate, + onFocus: (nodeId) => openFocusedViewRef.current(nodeId), onSelect: (nodeId) => { // React stays authoritative for the Inspector; the controller owns the // selection traversal + overlay. Selecting does NOT recenter the camera. @@ -1724,6 +1751,25 @@ export function GraphView({ {activeLayoutLabel} + + {focusedTraversal !== null ? ( +
+ + +
+ ) : null} ); } diff --git a/packages/extension/src/webview/components/SigmaController.test.tsx b/packages/extension/src/webview/components/SigmaController.test.tsx index 95ff059..75d69b1 100644 --- a/packages/extension/src/webview/components/SigmaController.test.tsx +++ b/packages/extension/src/webview/components/SigmaController.test.tsx @@ -134,6 +134,7 @@ describe("SigmaController — lifecycle", () => { const applyTheme = vi.fn(() => REDUCER_THEME); const sigma = controller.mount(stubContainer(), triadGraph(), { onNavigate: vi.fn(), + onFocus: vi.fn(), onSelect: vi.fn(), onClear: vi.fn(), onTracePick: vi.fn(), @@ -153,6 +154,7 @@ describe("SigmaController — lifecycle", () => { const controller = new SigmaController(freshStore(), options()); const sigma = controller.mount(stubContainer(), triadGraph(), { onNavigate: vi.fn(), + onFocus: vi.fn(), onSelect: vi.fn(), onClear: vi.fn(), onTracePick: vi.fn(), @@ -713,6 +715,7 @@ describe("SigmaController — edge reducer branches", () => { // mount() runs community detection; use a stub sigma + the real graph. controller.mount(stubContainer(), graph, { onNavigate: vi.fn(), + onFocus: vi.fn(), onSelect: vi.fn(), onClear: vi.fn(), onTracePick: vi.fn(), diff --git a/packages/extension/src/webview/components/SigmaController.ts b/packages/extension/src/webview/components/SigmaController.ts index c31ff7e..0f49c45 100644 --- a/packages/extension/src/webview/components/SigmaController.ts +++ b/packages/extension/src/webview/components/SigmaController.ts @@ -103,6 +103,8 @@ export interface SigmaControllerOptions { export interface SigmaMountCallbacks { /** Open the editor at a node's source location (double-click). */ onNavigate: (filePath: string, startLine: number) => void; + /** Open the focused (boxed-card) view for a node (Alt/Cmd + double-click). */ + onFocus: (nodeId: string) => void; /** A single click committed selection — React updates the Inspector. */ onSelect: (nodeId: string) => void; /** The stage was clicked — clear selection. */ @@ -736,6 +738,13 @@ export class SigmaController { } const preventable = event as unknown as { preventSigmaDefault?: () => void }; preventable.preventSigmaDefault?.(); + // Alt/Cmd + double-click opens the focused card view; plain double-click + // keeps its existing go-to-source behaviour. + const original = event.event?.original as MouseEvent | undefined; + if (original?.altKey === true || original?.metaKey === true) { + callbacks.onFocus(event.node); + return; + } const attrs = graph.getNodeAttributes(event.node) as GraphNodeAttributes; callbacks.onNavigate(attrs.filePath, attrs.startLine); }); From 8a00c4ead0a3ded49d17baaacd931ae549fe51b7 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 23:34:30 +0530 Subject: [PATCH 10/11] =?UTF-8?q?feat(extension):=20live=20force-layout=20?= =?UTF-8?q?settle=20=E2=80=94=20graph=20moves=20on=20mount,=20then=20idles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default graph used a one-shot forceAtlas2.assign (frozen on first paint), which is why it looked static vs graph-DB explorers. Add a continuous force simulation that settles then auto-stops: - LiveLayout (components/liveLayout.ts): wraps a LayoutSupervisor (start/stop/ kill/isRunning); run(ms) starts it + arms an auto-stop timeout; stop clears; dispose stops+kills. Injectable timers; the run cap scales down for large graphs so big repos don't churn the CPU. 6 unit tests (fake supervisor+timers). - SigmaController: after the community seed + quick .assign, start a LiveLayout over FA2LayoutSupervisor so nodes settle live (~2.5s) then idle; dispose kills it; re-applying the ForceAtlas2 preset re-runs the settle. Guarded by typeof Worker so jsdom/tests degrade quietly to the seed layout. Deliberately NOT on select: selection keeps the clean radial snap — re-energizing the sim would drift those rings apart (design D3). Circular/Hierarchical stay fixed (structural). Webview-only; no core/host/contract change. extension 667. --- .../src/webview/components/GraphView.tsx | 5 + .../src/webview/components/SigmaController.ts | 66 +++++++-- .../webview/components/liveLayout.test.tsx | 127 ++++++++++++++++++ .../src/webview/components/liveLayout.ts | 94 +++++++++++++ 4 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 packages/extension/src/webview/components/liveLayout.test.tsx create mode 100644 packages/extension/src/webview/components/liveLayout.ts diff --git a/packages/extension/src/webview/components/GraphView.tsx b/packages/extension/src/webview/components/GraphView.tsx index 448cdf9..63416b4 100644 --- a/packages/extension/src/webview/components/GraphView.tsx +++ b/packages/extension/src/webview/components/GraphView.tsx @@ -436,6 +436,11 @@ export function GraphView({ setLayoutSelection({ activePreset: result.preset, notice: null }); const sigma = sigmaRef.current; sigma?.refresh(); + // Re-running ForceAtlas2 → let it settle live again. Circular/Hierarchical + // are fixed structural layouts, so they don't get the live sim. + if (result.preset === "forceAtlas2") { + controllerRef.current?.restartLiveLayout(); + } // A preset can move nodes into a coordinate range outside the current // camera view (Hierarchical's origin-centred layers, Circular's ring), // which would leave the canvas looking empty. Re-frame the new layout. diff --git a/packages/extension/src/webview/components/SigmaController.ts b/packages/extension/src/webview/components/SigmaController.ts index 0f49c45..2bc7f47 100644 --- a/packages/extension/src/webview/components/SigmaController.ts +++ b/packages/extension/src/webview/components/SigmaController.ts @@ -3,6 +3,9 @@ import { NodeSquareProgram } from "@sigma/node-square"; import type { Attributes } from "graphology-types"; import type { MultiDirectedGraph } from "graphology"; import forceAtlas2 from "graphology-layout-forceatlas2"; +import FA2LayoutSupervisor from "graphology-layout-forceatlas2/worker"; + +import { LiveLayout } from "./liveLayout.js"; import Sigma from "sigma"; import { NodeCircleProgram } from "sigma/rendering"; import type { EdgeDisplayData, NodeDisplayData } from "sigma/types"; @@ -49,6 +52,17 @@ const NodeEntryProgram = createNodeBorderProgram({ const SINGLE_CLICK_DELAY_MS = 180; const FORCE_ATLAS2_ITERATIONS = 200; +/** Shared FA2 physics — used by both the seed `.assign` and the live supervisor. */ +const FORCE_ATLAS2_SETTINGS = { + gravity: 1.8, + scalingRatio: 6, + slowDown: 3, + barnesHutOptimize: true, + barnesHutTheta: 0.5, + linLogMode: true, +} as const; +/** How long the live settle runs on mount before it auto-stops (ms). */ +const LIVE_LAYOUT_MOUNT_RUN_MS = 2500; const MINIMAP_MIN_NODES = 20; // Camera tunables — identical to the values the inline GraphView handlers used, @@ -143,6 +157,8 @@ export class SigmaController { private readonly options: SigmaControllerOptions; private sigma: Sigma | null = null; + /** The settle-then-idle live force simulation for the default layout. */ + private liveLayout: LiveLayout | null = null; private graph: MultiDirectedGraph | null = null; private container: HTMLDivElement | null = null; private resizeObserver: ResizeObserver | null = null; @@ -211,6 +227,15 @@ export class SigmaController { maybeRefresh?.refresh?.(); } + /** + * Re-run the live force settle — called when the user re-applies the + * ForceAtlas2 preset so the graph settles live again. No-op if the live layout + * is unavailable (worker couldn't start). + */ + restartLiveLayout(): void { + this.liveLayout?.run(); + } + /** * Zoom the camera in by one step (smaller ratio = closer). No-op when there is * no instance or the camera lacks the animate/getState surface. @@ -661,14 +686,7 @@ export class SigmaController { seedPositionsByCommunity(graph, this.communities); forceAtlas2.assign(graph, { iterations: FORCE_ATLAS2_ITERATIONS, - settings: { - gravity: 1.8, - scalingRatio: 6, - slowDown: 3, - barnesHutOptimize: true, - barnesHutTheta: 0.5, - linLogMode: true, - }, + settings: FORCE_ATLAS2_SETTINGS, }); stabilizeFileAnchors(graph); } catch (layoutError) { @@ -770,9 +788,37 @@ export class SigmaController { attributeFilter: ["class"], }); + // Settle-then-idle live layout: nodes drift into place after the seed assign, + // then auto-stop. The seed already produced a reasonable arrangement, so if + // the worker can't start (e.g. jsdom in tests) we simply skip the live settle. + this.startLiveLayout(graph); + return sigma; } + /** + * Start the live force simulation for the default layout. No-op (falls back to + * the already-applied seed positions) if the worker supervisor cannot be + * constructed — e.g. no Worker in the test environment. + */ + private startLiveLayout(graph: MultiDirectedGraph): void { + if (graph.order === 0) return; + // The FA2 supervisor needs a Web Worker. In environments without one (e.g. + // jsdom under test) skip the live settle quietly — the seed layout stands. + if (typeof Worker === "undefined") return; + try { + this.liveLayout = new LiveLayout( + () => new FA2LayoutSupervisor(graph, { settings: FORCE_ATLAS2_SETTINGS }), + { maxRunMs: LIVE_LAYOUT_MOUNT_RUN_MS, nodeCount: graph.order }, + ); + this.liveLayout.run(); + } catch (err) { + // Defensive: any other supervisor failure also degrades to the seed layout. + this.liveLayout = null; + console.debug(`[live-layout] supervisor unavailable: ${String(err)}`); + } + } + /** The community partition over the mounted graph (empty before mount). */ get communityPartition(): CommunityPartition { return this.communities; @@ -860,6 +906,10 @@ export class SigmaController { this.resizeObserver = null; this.themeObserver?.disconnect(); this.themeObserver = null; + // Stop + kill the live force simulation before tearing down Sigma so its + // worker never ticks against a disposed graph. + this.liveLayout?.dispose(); + this.liveLayout = null; this.sigma?.kill(); this.sigma = null; this.graph = null; diff --git a/packages/extension/src/webview/components/liveLayout.test.tsx b/packages/extension/src/webview/components/liveLayout.test.tsx new file mode 100644 index 0000000..e8ffc19 --- /dev/null +++ b/packages/extension/src/webview/components/liveLayout.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; + +import { LiveLayout, type LayoutSupervisor } from "./liveLayout.js"; + +function fakeSupervisor() { + let running = false; + return { + start: vi.fn(() => { + running = true; + }), + stop: vi.fn(() => { + running = false; + }), + kill: vi.fn(), + isRunning: () => running, + } satisfies LayoutSupervisor; +} + +/** A controllable fake timer: capture the callback, fire it on demand. */ +function fakeTimers() { + const pending: { id: number; cb: () => void }[] = []; + let nextId = 1; + return { + setTimer: vi.fn((cb: () => void) => { + const id = nextId++; + pending.push({ id, cb }); + return id; + }), + clearTimer: vi.fn((handle: unknown) => { + const i = pending.findIndex((p) => p.id === handle); + if (i >= 0) pending.splice(i, 1); + }), + fireAll: () => { + const due = pending.splice(0, pending.length); + for (const p of due) p.cb(); + }, + pendingCount: () => pending.length, + }; +} + +function make(sup: LayoutSupervisor, timers: ReturnType, nodeCount = 10) { + return new LiveLayout(() => sup, { + maxRunMs: 2000, + nodeCount, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); +} + +describe("LiveLayout", () => { + it("run() starts the supervisor and arms an auto-stop timer", () => { + const sup = fakeSupervisor(); + const timers = fakeTimers(); + const live = make(sup, timers); + + live.run(); + expect(sup.start).toHaveBeenCalledTimes(1); + expect(live.running).toBe(true); + expect(timers.pendingCount()).toBe(1); + }); + + it("auto-stops when the timer fires", () => { + const sup = fakeSupervisor(); + const timers = fakeTimers(); + const live = make(sup, timers); + + live.run(); + timers.fireAll(); + expect(sup.stop).toHaveBeenCalledTimes(1); + expect(live.running).toBe(false); + }); + + it("re-run re-arms the timer without double-starting", () => { + const sup = fakeSupervisor(); + const timers = fakeTimers(); + const live = make(sup, timers); + + live.run(); + live.run(); // already running → only re-arm + expect(sup.start).toHaveBeenCalledTimes(1); + expect(timers.clearTimer).toHaveBeenCalledTimes(1); // prior timer cleared + expect(timers.pendingCount()).toBe(1); + }); + + it("stop() halts and clears the pending timer (idempotent)", () => { + const sup = fakeSupervisor(); + const timers = fakeTimers(); + const live = make(sup, timers); + + live.run(); + live.stop(); + expect(sup.stop).toHaveBeenCalledTimes(1); + expect(timers.pendingCount()).toBe(0); + live.stop(); // idempotent — no throw, no extra stop + expect(sup.stop).toHaveBeenCalledTimes(1); + }); + + it("dispose() stops and kills; run() is a no-op afterwards", () => { + const sup = fakeSupervisor(); + const timers = fakeTimers(); + const live = make(sup, timers); + + live.run(); + live.dispose(); + expect(sup.kill).toHaveBeenCalledTimes(1); + + live.run(); // disposed → no-op + expect(sup.start).toHaveBeenCalledTimes(1); // still just the first start + }); + + it("caps the run time and scales it down for large graphs", () => { + const sup = fakeSupervisor(); + const timers = fakeTimers(); + // 2000ms cap, 4000 nodes (10x the large-graph threshold of 400) → ~200ms, + // floored at 300ms. + const live = new LiveLayout(() => sup, { + maxRunMs: 2000, + nodeCount: 4000, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); + live.run(99999); // ask for huge; must be clamped to the scaled cap + const ms = timers.setTimer.mock.calls[0]![1] as number; + expect(ms).toBeLessThanOrEqual(300); + expect(ms).toBeGreaterThan(0); + }); +}); diff --git a/packages/extension/src/webview/components/liveLayout.ts b/packages/extension/src/webview/components/liveLayout.ts new file mode 100644 index 0000000..c062132 --- /dev/null +++ b/packages/extension/src/webview/components/liveLayout.ts @@ -0,0 +1,94 @@ +/** + * Settle-then-idle wrapper around a continuous force-layout supervisor. + * + * dextree's default layout used a one-shot `forceAtlas2.assign` (frozen on first + * paint). A running supervisor (graphology's `FA2LayoutSupervisor`) makes nodes + * visibly settle — the "lively" feel — but a sim that never stops pins the CPU. + * `LiveLayout` runs the supervisor and **auto-stops** after a bounded time, and + * is always stopped on dispose. The supervisor itself is injected so the + * lifecycle is unit-testable without a web worker / DOM. + */ + +/** The subset of `FA2LayoutSupervisor` this wrapper drives. */ +export interface LayoutSupervisor { + start(): void; + stop(): void; + kill(): void; + isRunning(): boolean; +} + +export interface LiveLayoutOptions { + /** Hard cap on a single run before auto-stop (ms). */ + maxRunMs: number; + /** Node count — used to scale the run time down for large graphs. */ + nodeCount: number; + /** Injectable timer fns (default to window) so tests use fake timers. */ + setTimer?: (cb: () => void, ms: number) => unknown; + clearTimer?: (handle: unknown) => void; +} + +/** Below this node count a graph gets the full run; above it, the run scales down. */ +const LARGE_GRAPH_NODES = 400; + +export class LiveLayout { + private readonly supervisor: LayoutSupervisor; + private readonly maxRunMs: number; + private readonly setTimer: (cb: () => void, ms: number) => unknown; + private readonly clearTimer: (handle: unknown) => void; + private timer: unknown = null; + private disposed = false; + + constructor(makeSupervisor: () => LayoutSupervisor, opts: LiveLayoutOptions) { + this.supervisor = makeSupervisor(); + // Large graphs settle proportionally faster-stopping so a big repo doesn't + // churn the CPU for the full window. + const scale = opts.nodeCount > LARGE_GRAPH_NODES ? LARGE_GRAPH_NODES / opts.nodeCount : 1; + this.maxRunMs = Math.max(300, Math.round(opts.maxRunMs * scale)); + this.setTimer = opts.setTimer ?? ((cb, ms) => window.setTimeout(cb, ms) as unknown); + this.clearTimer = opts.clearTimer ?? ((handle) => window.clearTimeout(handle as number)); + } + + get running(): boolean { + return this.supervisor.isRunning(); + } + + /** + * Start the simulation (if idle) and (re)arm the auto-stop. Calling `run` + * while already running just re-arms the timer — it never double-starts. + */ + run(durationMs: number = this.maxRunMs): void { + if (this.disposed) return; + this.clearTimerIfAny(); + if (!this.supervisor.isRunning()) { + this.supervisor.start(); + } + const ms = Math.min(durationMs, this.maxRunMs); + this.timer = this.setTimer(() => { + this.timer = null; + this.stop(); + }, ms); + } + + /** Stop the simulation and clear any pending auto-stop. Idempotent. */ + stop(): void { + this.clearTimerIfAny(); + if (this.supervisor.isRunning()) { + this.supervisor.stop(); + } + } + + /** Stop + kill the supervisor. After dispose, `run` is a no-op. */ + dispose(): void { + if (this.disposed) return; + this.stop(); + this.supervisor.kill(); + this.disposed = true; + } + + private clearTimerIfAny(): void { + if (this.timer !== null) { + this.clearTimer(this.timer); + this.timer = null; + } + } +} From 8d3729c44acea46b62074b8cb07e97a0a68017ef Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 23:37:22 +0530 Subject: [PATCH 11/11] chore(extension): remove temp layout-apply diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The [layout] console diagnostic (bbf67df) served its purpose — the Circular/ Hierarchical 'not visible' bug was the camera-space issue, since fixed. Removing the temporary logging. --- packages/extension/src/webview/components/GraphView.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/extension/src/webview/components/GraphView.tsx b/packages/extension/src/webview/components/GraphView.tsx index 63416b4..1de83c7 100644 --- a/packages/extension/src/webview/components/GraphView.tsx +++ b/packages/extension/src/webview/components/GraphView.tsx @@ -423,15 +423,6 @@ export function GraphView({ visibleEdgeIds, }); - // TEMP diagnostic (remove after Circular is confirmed): shows which branch - // the layout-apply took. View in Webview Developer Tools console. - console.log( - `[layout] preset=${preset} active=${layoutSelection.activePreset} ` + - `nodes=${visibleNodeIds.size} → status=${result.status}` + - (result.status === "noop" ? ` reason=${result.reason}` : "") + - (result.status === "rejected" ? ` reason=${result.reason}` : ""), - ); - if (result.status === "applied") { setLayoutSelection({ activePreset: result.preset, notice: null }); const sigma = sigmaRef.current;