From 03d8db9e4f941908919fa27599a05688b003e7e7 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sun, 7 Jun 2026 23:48:21 +0530 Subject: [PATCH 1/3] fix(extension): allow worker-src blob: in webview CSP so the live layout animates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE reason nodes were static: the webview CSP was default-src 'none' with no worker-src. graphology's FA2LayoutSupervisor creates its Web Worker from a Blob URL; with no worker-src the browser falls back to child-src → default-src 'none' and CSP-blocks the worker. The supervisor threw on construction, the try/catch swallowed it, and the graph silently fell back to the static seed layout — so no animation ever ran. Add 'worker-src blob:' to the CSP. Sigma already auto-refreshes on graph node-attr updates (bindGraphHandlers → scheduleRefresh), so once the worker ticks positions the canvas repaints → live settle. Added an html.test assertion so the directive can't silently regress. --- packages/extension/src/webview/html.test.ts | 8 ++++++++ packages/extension/src/webview/html.ts | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/packages/extension/src/webview/html.test.ts b/packages/extension/src/webview/html.test.ts index b2af3ec..901c84a 100644 --- a/packages/extension/src/webview/html.test.ts +++ b/packages/extension/src/webview/html.test.ts @@ -63,4 +63,12 @@ describe("getWebviewContent", () => { // img-src blob: data: the default-src 'none' fallback blocks both. expect(html).toContain("img-src blob: data:"); }); + + it("permits blob: in worker-src so the live ForceAtlas2 layout worker can start", () => { + const html = getWebviewContent(makeWebview(), makeExtensionUri()); + // graphology FA2 supervisor creates a Web Worker from a Blob URL. Without + // worker-src blob: it falls back to default-src 'none' and is CSP-blocked, + // so the graph never animates (the symptom that motivated this directive). + expect(html).toContain("worker-src blob:"); + }); }); diff --git a/packages/extension/src/webview/html.ts b/packages/extension/src/webview/html.ts index 29cc661..3b700a4 100644 --- a/packages/extension/src/webview/html.ts +++ b/packages/extension/src/webview/html.ts @@ -41,6 +41,11 @@ export function getWebviewContent(webview: vscode.Webview, extensionUri: vscode. `style-src 'nonce-${nonce}' ${webview.cspSource}`, `font-src ${webview.cspSource}`, `img-src blob: data:`, + // The live ForceAtlas2 layout runs in a Web Worker created from a Blob URL + // (graphology-layout-forceatlas2/worker). Without worker-src the worker + // falls back to child-src → default-src 'none' and is CSP-blocked, so the + // graph never animates. blob: is required because the worker is a Blob URL. + `worker-src blob:`, ].join("; "); return ` From 852e86ffad384c08f5425bc5fcf493151121e1dd Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Mon, 8 Jun 2026 00:04:37 +0530 Subject: [PATCH 2/3] feat(extension): livelier settle (scatter-then-organize) + cleaner spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lively: the graph was pre-solved (200 FA2 iterations) before first paint, so the live worker had nothing left to animate — 'moves but feels dead'. Now when the live worker will run, the seed is a light 8-iteration warm-up (grouped but loose) and the worker visibly ORGANIZES it; slowDown 3→1 makes per-tick motion energetic; run window 2.5s→3.5s. Full 200-iter pre-solve kept only for the no-worker static fallback. Cleaner spacing (both views): main-view FA2 scalingRatio 6→8 spreads nodes wider (less tangle). Focused view: ring radius now grows with node count (min arc per card) so dense rings expand outward instead of packing cards on top of each other; base ring spacing 220→280. extension 668. --- .../src/webview/blast-radius/focusedLayout.ts | 15 +++++++-- .../src/webview/components/SigmaController.ts | 33 ++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/extension/src/webview/blast-radius/focusedLayout.ts b/packages/extension/src/webview/blast-radius/focusedLayout.ts index f9a8ff5..2f2462b 100644 --- a/packages/extension/src/webview/blast-radius/focusedLayout.ts +++ b/packages/extension/src/webview/blast-radius/focusedLayout.ts @@ -17,7 +17,13 @@ export interface XY { } /** Pixel radius of the first ring; each further ring steps out by this much. */ -export const FOCUSED_RING_SPACING = 220; +export const FOCUSED_RING_SPACING = 280; +/** + * Minimum perimeter (px) reserved per card on a ring. A card is up to ~220px + * wide; reserving more than that as arc keeps neighbouring cards from touching, + * so dense rings expand outward instead of overlapping. + */ +export const FOCUSED_MIN_ARC_PER_NODE = 260; /** * Assign an `{x,y}` to every focused node. Ring 0 (the focus node) goes to the @@ -42,7 +48,12 @@ export function computeFocusedLayout(nodes: readonly FocusedNode[]): Map 0) { try { - // Seed members near their community centroid first so FA2 refines from a - // grouped, reproducible start: members attract into islands, distinct - // communities stay apart. + // Seed members near their community centroid: a grouped-but-loose start. + // Communities sit on a ring, members spiral near their centroid. seedPositionsByCommunity(graph, this.communities); + // When the live worker WILL run (worker available), only do a light + // warm-up here so the graph starts visibly loose and the user watches the + // worker ORGANIZE it (the "lively" settle). When there's no worker + // (fallback), pre-solve fully so the static first paint is already tidy. + const willRunLive = typeof Worker !== "undefined"; forceAtlas2.assign(graph, { - iterations: FORCE_ATLAS2_ITERATIONS, + iterations: willRunLive ? FORCE_ATLAS2_WARMUP_ITERATIONS : FORCE_ATLAS2_ITERATIONS, settings: FORCE_ATLAS2_SETTINGS, }); stabilizeFileAnchors(graph); From 58836ebf402fb7cec97c3e4272b8136c5f288127 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Mon, 8 Jun 2026 00:15:51 +0530 Subject: [PATCH 3/3] =?UTF-8?q?feat(extension):=20structural=20radial=20Ci?= =?UTF-8?q?rcular=20layout=20=E2=80=94=20hubs=20centre,=20leaves=20on=20th?= =?UTF-8?q?e=20rim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Circular preset was a flat single ring (all nodes equidistant, arbitrary order) — not the GitNexus-style structured circle. Rework it into a concentric radial layout driven by graph role: - leaves (outDegree 0) → outermost ring - hubs (high in-degree + entry-point bonus) → innermost rings - others ranked between by in-degree, spread across the inner rings Within each ring, nodes are ordered by the mean angle of their already-placed inner neighbours so ring→ring edges run centre→out (radial), which minimises crossings (cannot eliminate them for non-planar graphs). Ring radius grows with node count (min arc per node) so dense rings expand outward instead of stacking. Also aligned the preset-file FA2 settings (scalingRatio 8) with the tuned mount settings so re-applying ForceAtlas2 matches the initial layout. extension 669 (+ a test asserting hubs land more central than leaves). --- .../components/graphLayoutPresets.test.ts | 31 +++++ .../webview/components/graphLayoutPresets.ts | 116 ++++++++++++++++-- 2 files changed, 138 insertions(+), 9 deletions(-) diff --git a/packages/extension/src/webview/components/graphLayoutPresets.test.ts b/packages/extension/src/webview/components/graphLayoutPresets.test.ts index 204d2ed..ed8fcc4 100644 --- a/packages/extension/src/webview/components/graphLayoutPresets.test.ts +++ b/packages/extension/src/webview/components/graphLayoutPresets.test.ts @@ -173,6 +173,37 @@ describe("applyLayoutPreset — Circular (slice 025 US2)", () => { })); expect(new Set(positions.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`)).size).toBe(3); }); + + it("places hubs nearer the centre than leaves (structural radial)", () => { + // hub ← a, b, c (in-degree 3, has an outgoing edge so it's not a leaf); + // leaf1/leaf2 are pure sinks (no outgoing edges) → outer rim. + const graph = new MultiDirectedGraph(); + for (const id of ["hub", "a", "b", "c", "leaf1", "leaf2", "sink"]) { + graph.addNode(id, { x: 0, y: 0 }); + } + graph.addEdgeWithKey("a-hub", "a", "hub"); + graph.addEdgeWithKey("b-hub", "b", "hub"); + graph.addEdgeWithKey("c-hub", "c", "hub"); + graph.addEdgeWithKey("hub-sink", "hub", "sink"); // hub has an out-edge → not a leaf + graph.addEdgeWithKey("a-leaf1", "a", "leaf1"); + graph.addEdgeWithKey("b-leaf2", "b", "leaf2"); + + applyLayoutPreset(graph, "circular", { + activePreset: "forceAtlas2", + visibleNodeIds: buildVisibleSet(graph), + visibleEdgeIds: buildVisibleEdgeSet(graph), + }); + + const radius = (id: string) => + Math.hypot( + graph.getNodeAttribute(id, "x") as number, + graph.getNodeAttribute(id, "y") as number, + ); + + // The hub (highest in-degree) is more central than the pure-sink leaves. + expect(radius("hub")).toBeLessThan(radius("leaf1")); + expect(radius("hub")).toBeLessThan(radius("sink")); + }); }); describe("applyLayoutPreset — ForceAtlas2 re-application", () => { diff --git a/packages/extension/src/webview/components/graphLayoutPresets.ts b/packages/extension/src/webview/components/graphLayoutPresets.ts index 9f77ed9..9242683 100644 --- a/packages/extension/src/webview/components/graphLayoutPresets.ts +++ b/packages/extension/src/webview/components/graphLayoutPresets.ts @@ -28,7 +28,9 @@ import type { */ const FORCE_ATLAS2_SETTINGS = { gravity: 1.8, - scalingRatio: 6, + // Kept in sync with the mount-time settings in SigmaController.ts (wider + // spacing) so re-applying the ForceAtlas2 preset matches the initial layout. + scalingRatio: 8, slowDown: 3, barnesHutOptimize: true, barnesHutTheta: 0.5, @@ -79,7 +81,7 @@ export const LAYOUT_PRESET_OPTIONS: readonly LayoutPresetOption[] = [ { id: "circular", label: "Circular", - description: "Arrange every visible node on a single ring; quick structural scan.", + description: "Concentric rings by role — hubs at the centre, leaves on the rim.", }, { id: "hierarchical", @@ -145,6 +147,26 @@ function countLiveVisibleNodes( * again under a different filter setting, at which point a fresh layout * preset application will reposition them. */ +/** Concentric ring spacing (px) for the structural radial layout. */ +const RADIAL_LAYOUT_RING_SPACING = 200; +/** Minimum perimeter (px) reserved per node on a ring so dense rings expand out. */ +const RADIAL_LAYOUT_MIN_ARC = 90; +/** Number of concentric rings between the hub core and the leaf rim. */ +const RADIAL_LAYOUT_RING_COUNT = 5; + +/** + * Structural radial layout: hubs at the centre, leaves on the outer rim, others + * filling concentric rings by role. A node's ring comes from a structural score: + * + * - leaves (no outgoing edges) → outermost ring + * - hubs (high in-degree, entry points) → innermost rings + * - everything else ranked between by in-degree + * + * Within a ring, nodes are ordered by the angle of their already-placed inner + * neighbour so edges run roughly centre→out (radial) instead of across the + * circle — which minimises crossings (it cannot eliminate them for non-planar + * graphs). Replaces the old flat single-ring "circular" arrangement. + */ function assignCircularPositions( graph: MultiDirectedGraph, visibleNodeIds: ReadonlySet, @@ -155,16 +177,92 @@ function assignCircularPositions( } if (visible.length === 0) return; - // Radius scales with the visible-node count so small graphs stay compact - // and large graphs have enough perimeter to keep labels distinguishable. - const scale = Math.max(120, Math.sqrt(visible.length) * 30); - for (let i = 0; i < visible.length; i++) { - const angle = (2 * Math.PI * i) / visible.length; - graph.setNodeAttribute(visible[i] as string, "x", Math.cos(angle) * scale); - graph.setNodeAttribute(visible[i] as string, "y", Math.sin(angle) * scale); + // Structural score: higher = more central. Leaves (outDegree 0) are forced to + // the rim; otherwise rank by in-degree with an entry-point bonus. + const isLeaf = (id: string): boolean => graph.outDegree(id) === 0; + const centralityOf = (id: string): number => { + const attrs = graph.getNodeAttributes(id) as { entryKind?: string }; + const entryBonus = attrs.entryKind === "runtime" || attrs.entryKind === "handler" ? 5 : 0; + return graph.inDegree(id) + entryBonus; + }; + + const leaves = visible.filter(isLeaf); + const nonLeaves = visible + .filter((id) => !isLeaf(id)) + .sort((a, b) => centralityOf(b) - centralityOf(a)); + + // Assign rings: non-leaves spread across the inner rings (ring 0 = most + // central); leaves all land on the outermost ring. + const innerRingCount = Math.max(1, RADIAL_LAYOUT_RING_COUNT - 1); + const ringOf = new Map(); + if (nonLeaves.length > 0) { + const perRing = Math.ceil(nonLeaves.length / innerRingCount); + nonLeaves.forEach((id, i) => ringOf.set(id, Math.floor(i / perRing))); + } + const leafRing = RADIAL_LAYOUT_RING_COUNT - 1; + for (const id of leaves) ringOf.set(id, leafRing); + + // Bucket by ring, then order each ring by a connected inner node's angle so + // the ring→ring edges stay radial. + const byRing = new Map(); + for (const id of visible) { + const r = ringOf.get(id) ?? leafRing; + const bucket = byRing.get(r); + if (bucket === undefined) byRing.set(r, [id]); + else bucket.push(id); + } + + const angleOf = new Map(); + const ringIndices = [...byRing.keys()].sort((a, b) => a - b); + for (const ring of ringIndices) { + const ids = byRing.get(ring)!; + if (ring === 0 && ids.length === 1) { + graph.setNodeAttribute(ids[0]!, "x", 0); + graph.setNodeAttribute(ids[0]!, "y", 0); + angleOf.set(ids[0]!, 0); + continue; + } + // Order this ring by the mean angle of each node's inner-ring neighbours so + // children sit under their parents. + const ordered = + ring === 0 + ? ids + : [...ids] + .map((id) => ({ id, a: meanNeighbourAngle(graph, id, angleOf) })) + .sort((p, q) => p.a - q.a) + .map((p) => p.id); + + const minRadius = (ring + 1) * RADIAL_LAYOUT_RING_SPACING; + const perimeterNeed = (ordered.length * RADIAL_LAYOUT_MIN_ARC) / (2 * Math.PI); + const radius = Math.max(minRadius, perimeterNeed); + for (let i = 0; i < ordered.length; i++) { + const angle = (2 * Math.PI * i) / ordered.length; + graph.setNodeAttribute(ordered[i]!, "x", Math.cos(angle) * radius); + graph.setNodeAttribute(ordered[i]!, "y", Math.sin(angle) * radius); + angleOf.set(ordered[i]!, angle); + } } } +/** Mean angle (unit-circle avg) of a node's already-placed neighbours, or 0. */ +function meanNeighbourAngle( + graph: MultiDirectedGraph, + id: string, + angleOf: Map, +): number { + let sx = 0; + let sy = 0; + let n = 0; + graph.forEachNeighbor(id, (nb) => { + const a = angleOf.get(nb); + if (a === undefined) return; + sx += Math.cos(a); + sy += Math.sin(a); + n += 1; + }); + return n === 0 ? 0 : Math.atan2(sy, sx); +} + /** 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. */