Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/extension/src/webview/blast-radius/focusedLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,7 +48,12 @@ export function computeFocusedLayout(nodes: readonly FocusedNode[]): Map<string,
for (const id of ids) positions.set(id, { x: 0, y: 0 });
continue;
}
const radius = ring * FOCUSED_RING_SPACING;
// Grow the ring radius with the node count so each card keeps a minimum arc
// of perimeter — otherwise a dense ring packs cards on top of each other
// (the opposite of "clean"). radius = max(ring spacing, perimeter need).
const minRadius = ring * FOCUSED_RING_SPACING;
const perimeterNeed = (ids.length * FOCUSED_MIN_ARC_PER_NODE) / (2 * Math.PI);
const radius = Math.max(minRadius, perimeterNeed);
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 });
Expand Down
33 changes: 24 additions & 9 deletions packages/extension/src/webview/components/SigmaController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,30 @@ const NodeEntryProgram = createNodeBorderProgram({
});

const SINGLE_CLICK_DELAY_MS = 180;
/** Full pre-solve iterations — used only as the no-worker static fallback. */
const FORCE_ATLAS2_ITERATIONS = 200;
/**
* Light warm-up before the live worker takes over. Enough to roughly place
* communities (so the start isn't chaotic) but NOT enough to pre-settle — the
* user then watches the worker organize the rest (the "lively" settle). Keep
* this low: a high value pre-solves the layout and there's nothing left to
* animate, which reads as "moves a little but feels dead".
*/
const FORCE_ATLAS2_WARMUP_ITERATIONS = 8;
/** Shared FA2 physics — used by both the seed `.assign` and the live supervisor. */
const FORCE_ATLAS2_SETTINGS = {
gravity: 1.8,
scalingRatio: 6,
slowDown: 3,
scalingRatio: 8,
// Lower slowDown = more energetic per-tick motion so the live settle is
// visibly lively (graphology divides forces by slowDown). The auto-stop ends
// it; we don't need heavy damping to converge.
slowDown: 1,
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 LIVE_LAYOUT_MOUNT_RUN_MS = 3500;
const MINIMAP_MIN_NODES = 20;

// Camera tunables — identical to the values the inline GraphView handlers used,
Expand Down Expand Up @@ -676,16 +688,19 @@ export class SigmaController {
// the edge reducer, and the per-community hull drawing.
this.communities = detectCommunities(graph);

// Run the force-directed layout before constructing Sigma so the first paint
// is already settled. Layout failure is non-fatal — render the raw positions.
// Seed + force layout before constructing Sigma. Layout failure is non-fatal.
if (graph.order > 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
116 changes: 107 additions & 9 deletions packages/extension/src/webview/components/graphLayoutPresets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<string>,
Expand All @@ -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<string, number>();
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<number, string[]>();
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<string, number>();
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<string, number>,
): 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. */
Expand Down
8 changes: 8 additions & 0 deletions packages/extension/src/webview/html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:");
});
});
5 changes: 5 additions & 0 deletions packages/extension/src/webview/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!DOCTYPE html>
Expand Down
Loading