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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,10 @@
labels. Add bounded 1–4-hop selection isolation with incoming/outgoing edge
filtering, layout spacing, a navigable minimap, and discoverable keyboard
shortcuts. Resuming a settled nested community graph now reheats its layout
so pause/resume produces visible physics movement. Consolidate workbench
so pause/resume produces visible physics movement. Freeze the force layout
before the canvas becomes interactive and automatically pause it when a user
hovers a node or starts dragging, keeping click targets stable in both HTML
exports and VS Code. Consolidate workbench
filters into the top graph-control rail, allow neighborhood settings to be
prepared before selection, keep filters scoped to the visible overview or
community detail, fit newly isolated neighborhoods, and scale layout reheating
Expand Down
56 changes: 28 additions & 28 deletions crates/compass-output/assets/viewer/graph.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions crates/compass-output/assets/viewer/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"viewerSchema": "compass.viewer.graph/1",
"files": {
"graph.js": {
"bytes": 1068010,
"sha256": "7db353557e68e63a4d3d48450ddb17bf01d7a09242ee3f4183b9a9c44f6dfe06"
"bytes": 1068368,
"sha256": "d4aaf028cdcfe96a788d71fcc3a3d886ba3771e65466db1bf535e790144515b3"
},
"viewer.css": {
"bytes": 234489,
Expand Down
6 changes: 6 additions & 0 deletions packages/compass-viewer/src/graph/CompassGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ function CompassGraphView({
setEdgeHover(null);
dispatch({ type: "focus", nodeId });
}, []);
const pauseForInteraction = useCallback(() => {
if (state.physicsRunning) {
dispatch({ type: "setPhysics", running: false });
}
}, [state.physicsRunning]);
const clear = useCallback(() => {
setHover(null);
setEdgeHover(null);
Expand Down Expand Up @@ -475,6 +480,7 @@ function CompassGraphView({
onFocus={focus}
onOpenSource={activateNode}
onOpenRelationshipSource={activateRelationship}
onInteractionStart={pauseForInteraction}
onHover={setHover}
onHoverEdge={setEdgeHover}
onClear={clear}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,35 @@ describe("VisNetworkCanvas hover lifecycle", () => {
expect(onStabilized).toHaveBeenCalledTimes(2);
});

it("stops layout motion before publishing the stabilized canvas", () => {
const onStabilized = vi.fn();
render(<VisNetworkCanvas
model={model}
focusedNodeId={null}
physicsRunning={true}
layoutStyle="automatic"
forceLabels={false}
hiddenCommunities={new Set()}
hiddenChanges={new Set()}
onFocus={vi.fn()}
onOpenSource={vi.fn()}
onOpenRelationshipSource={vi.fn()}
onInteractionStart={vi.fn()}
onHover={vi.fn()}
onHoverEdge={vi.fn()}
onClear={vi.fn()}
onStabilized={onStabilized}
/>);

const stopsBeforeStabilizing = mock.simulationStops;
for (const handler of [...mock.eventHandlers.get("stabilizationIterationsDone") ?? []]) {
handler();
}

expect(mock.simulationStops).toBeGreaterThan(stopsBeforeStabilizing);
expect(onStabilized).toHaveBeenCalledTimes(1);
});

it("scales the reheat so motion stays visible when a large graph is fit", () => {
const callbacks = {
onFocus: vi.fn(),
Expand Down
12 changes: 12 additions & 0 deletions packages/compass-viewer/src/graph/VisNetworkCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type Props = {
onFocus(nodeId: string): void;
onOpenSource(nodeId: string): void;
onOpenRelationshipSource(edgeId: string): void;
onInteractionStart?(): void;
onHover(change: GraphHover | null): void;
onHoverEdge(change: GraphEdgeHover | null): void;
onClear(): void;
Expand Down Expand Up @@ -415,6 +416,7 @@ export const VisNetworkCanvas = forwardRef<GraphCanvasHandle, Props>(
onFocus,
onOpenSource,
onOpenRelationshipSource,
onInteractionStart = () => undefined,
onHover,
onHoverEdge,
onClear,
Expand All @@ -430,6 +432,7 @@ export const VisNetworkCanvas = forwardRef<GraphCanvasHandle, Props>(
onFocus,
onOpenSource,
onOpenRelationshipSource,
onInteractionStart,
onHover,
onHoverEdge,
onClear
Expand All @@ -438,6 +441,7 @@ export const VisNetworkCanvas = forwardRef<GraphCanvasHandle, Props>(
onFocus,
onOpenSource,
onOpenRelationshipSource,
onInteractionStart,
onHover,
onHoverEdge,
onClear
Expand Down Expand Up @@ -707,11 +711,19 @@ export const VisNetworkCanvas = forwardRef<GraphCanvasHandle, Props>(
onFocus: (nodeId) => eventHandlersRef.current.onFocus(nodeId),
onOpenSource: (nodeId) => eventHandlersRef.current.onOpenSource(nodeId),
onOpenRelationshipSource: (edgeId) => eventHandlersRef.current.onOpenRelationshipSource(edgeId),
onInteractionStart: () => {
if (physicsRunningRef.current) network.stopSimulation();
eventHandlersRef.current.onInteractionStart();
},
onHover: (change) => eventHandlersRef.current.onHover(change),
onHoverEdge: (change) => eventHandlersRef.current.onHoverEdge(change),
onClear: () => eventHandlersRef.current.onClear()
});
network.on("stabilizationIterationsDone", () => {
// vis-network can continue its dynamic phase after the configured
// stabilization iterations. Freeze synchronously before React removes
// the loading screen so the first interactive frame cannot drift.
network.stopSimulation();
initialViewRef.current = {
position: network.getViewPosition(),
scale: network.getScale()
Expand Down
3 changes: 3 additions & 0 deletions packages/compass-viewer/src/graph/networkEvents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function fixture() {
onFocus: vi.fn(),
onOpenSource: vi.fn(),
onOpenRelationshipSource: vi.fn(),
onInteractionStart: vi.fn(),
onHover: vi.fn(),
onHoverEdge: vi.fn(),
onClear: vi.fn()
Expand Down Expand Up @@ -105,6 +106,7 @@ describe("bindGraphNetworkEvents", () => {

expect(handlers.onHover).toHaveBeenCalledWith(null);
expect(handlers.onHoverEdge).toHaveBeenCalledWith(null);
expect(handlers.onInteractionStart).toHaveBeenCalledTimes(1);
});

it("clears an edge card before showing a node card", () => {
Expand All @@ -116,5 +118,6 @@ describe("bindGraphNetworkEvents", () => {

expect(handlers.onHoverEdge).toHaveBeenCalledWith(null);
expect(handlers.onHover).toHaveBeenCalledWith({ nodeId: "run", x: 10, y: 20 });
expect(handlers.onInteractionStart).toHaveBeenCalledTimes(1);
});
});
3 changes: 3 additions & 0 deletions packages/compass-viewer/src/graph/networkEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type GraphNetworkHandlers = {
onFocus(nodeId: string): void;
onOpenSource(nodeId: string): void;
onOpenRelationshipSource(edgeId: string): void;
onInteractionStart(): void;
onHover(change: GraphHover | null): void;
onHoverEdge(change: GraphEdgeHover | null): void;
onClear(): void;
Expand Down Expand Up @@ -48,6 +49,7 @@ export function bindGraphNetworkEvents(
});
network.on("hoverNode", (parameters) => {
if (parameters.node === undefined) return;
handlers.onInteractionStart();
handlers.onHoverEdge(null);
handlers.onHover({
nodeId: String(parameters.node),
Expand All @@ -68,6 +70,7 @@ export function bindGraphNetworkEvents(
});
network.on("blurEdge", () => handlers.onHoverEdge(null));
network.on("dragStart", () => {
handlers.onInteractionStart();
handlers.onHover(null);
handlers.onHoverEdge(null);
});
Expand Down
28 changes: 28 additions & 0 deletions tests/viewer/graph-parity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ test("VS Code graph mirrors Compass export structure and exposes source metadata
expect(external).toEqual([]);
});

test("HTML export graph stays still once it becomes interactive", async ({ page }) => {
await page.goto("/graph.html");
await expect(page.getByRole("button", { name: "Run layout" })).toBeVisible();
const canvas = page.locator(".compass-canvas canvas").first();

await page.waitForTimeout(100);
const firstInteractiveFrame = await canvas.evaluate(
(element: HTMLCanvasElement) => element.toDataURL()
);
await page.waitForTimeout(250);

await expect(canvas.evaluate(
(element: HTMLCanvasElement) => element.toDataURL()
)).resolves.toBe(firstInteractiveFrame);
});

test("file-only graph nodes stay inspectable without source navigation", async ({ page }) => {
await page.goto("/graph.html");
await page.evaluate(() => {
Expand Down Expand Up @@ -264,6 +280,18 @@ test("community double-click enters lazy detail, source opens, and Back restores
await search.fill("run");
await page.getByRole("option", { name: /^run/i }).click();
await page.waitForTimeout(300);
await runLayout.click();
await expect(page.getByRole("button", { name: "Stop layout" })).toBeVisible();
await graphCanvas.hover();
await expect(page.getByRole("button", { name: "Run layout" })).toBeVisible();
await page.waitForTimeout(100);
const interactionFrame = await graphCanvas.evaluate(
(canvas: HTMLCanvasElement) => canvas.toDataURL()
);
await page.waitForTimeout(250);
await expect(graphCanvas.evaluate(
(canvas: HTMLCanvasElement) => canvas.toDataURL()
)).resolves.toBe(interactionFrame);
await page.locator("canvas").dblclick();
await expect.poll(() => page.evaluate(
() => (window as typeof window & { openedSource?: unknown }).openedSource
Expand Down
Loading