diff --git a/desktop/src/features/dkg-memory/topology/GraphNavigationControls.tsx b/desktop/src/features/dkg-memory/topology/GraphNavigationControls.tsx new file mode 100644 index 00000000000..41fc13543e3 --- /dev/null +++ b/desktop/src/features/dkg-memory/topology/GraphNavigationControls.tsx @@ -0,0 +1,67 @@ +import { Maximize2, ZoomIn, ZoomOut } from "lucide-react"; +import type { RefObject } from "react"; +import { Button } from "@/shared/ui/button"; + +export function GraphNavigationControls({ + surfaceRef, + onFit, +}: { + surfaceRef: RefObject; + onFit: () => void; +}) { + const zoom = (direction: "in" | "out") => { + const canvas = surfaceRef.current?.querySelector("canvas"); + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + canvas.dispatchEvent( + new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: direction === "in" ? -180 : 180, + view: window, + }), + ); + }; + + return ( +
+ + + +
+ ); +} diff --git a/desktop/src/features/dkg-memory/topology/TopologyView.tsx b/desktop/src/features/dkg-memory/topology/TopologyView.tsx index 3549de851b0..82cf1f1da91 100644 --- a/desktop/src/features/dkg-memory/topology/TopologyView.tsx +++ b/desktop/src/features/dkg-memory/topology/TopologyView.tsx @@ -11,9 +11,10 @@ // NodePanel inspector. "Entity types" coloring is the node-parity default; // "Contributors" preserves the attribution view (recorded attribution, // not verification). -import { Suspense, lazy, useMemo, useState } from "react"; +import { Suspense, lazy, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { fetchTopologyTriples, type TopologyTarget } from "./client"; +import { GraphNavigationControls } from "./GraphNavigationControls"; import { applyHeaviestSubjectsCap, attributionLegend, @@ -46,6 +47,9 @@ const BUZZ_NS = "https://w3id.org/buzz-dkg/buzz#"; export const NODE_UI_GRAPH_OPTIONS = { labelMode: "humanized" as const, renderer: "2d" as const, + // RdfGraph's initialFit performs the one intended camera fit. Disabling the + // renderer's additional settle-time fit keeps later user zoom intact. + autoFitDisabled: true, labels: { predicates: NODE_UI_LABEL_PREDICATES }, style: { classColors: { @@ -96,6 +100,8 @@ export function TopologyView({ const [colorMode, setColorMode] = useState<"entity" | "attribution">( "entity", ); + const [fitRevision, setFitRevision] = useState(0); + const graphSurfaceRef = useRef(null); const channelWide = target.kind === "channel"; const query = useQuery({ queryKey: [ @@ -222,6 +228,10 @@ export function TopologyView({ ))} + setFitRevision((revision) => revision + 1)} + /> {summary.entities} entities · {summary.relationships} relationships ·{" "} {channelWide @@ -237,7 +247,12 @@ export function TopologyView({ {/* Dark node-idiom island: the renderer's palette is tuned for the DKG node UI's canvas (#0a0a0f); rendering on that background *is* the node look — bright hexagons, thin edges, light labels. */} -
+
@@ -246,6 +261,7 @@ export function TopologyView({ } > @@ -27,16 +28,27 @@ export function LensTopology({ onSelectUri: (uri: string, label?: string) => void; }) { const triples = useMemo(() => lensTriples(graph), [graph]); + const [fitRevision, setFitRevision] = useState(0); + const graphSurfaceRef = useRef(null); return (
+ setFitRevision((revision) => revision + 1)} + /> {graph.nodes.length} entities · {graph.edges.length} relationships · colors = entity types, as in your DKG node
-
+
@@ -45,6 +57,7 @@ export function LensTopology({ } > = Math.abs(deltaX) && + path.some( + (target) => + isHTMLElement(target) && + target.matches(INTERACTIVE_WHEEL_SURFACE_SELECTOR), + ) + ) { + return; + } + let firstScrollable: HTMLElement | null = null; let targetsTerminal = false; diff --git a/desktop/tests/e2e/dkg-memory-fallback.spec.ts b/desktop/tests/e2e/dkg-memory-fallback.spec.ts index 18a0eaa08d7..091105f4359 100644 --- a/desktop/tests/e2e/dkg-memory-fallback.spec.ts +++ b/desktop/tests/e2e/dkg-memory-fallback.spec.ts @@ -170,6 +170,15 @@ test("flat capture: All-decisions lens resolves evidence into Traces and Graph", overlay.getByText(/5 entities · 2 relationships/i), ).toBeVisible(); await expectPaintedGraphCanvas(overlay); + await expect( + overlay.getByRole("button", { name: "Zoom graph in" }), + ).toBeVisible(); + await expect( + overlay.getByRole("button", { name: "Zoom graph out" }), + ).toBeVisible(); + await expect( + overlay.getByRole("button", { name: "Fit graph to view" }), + ).toBeVisible(); expect(subgraphRequests).toEqual([]); await waitForAnimations(page); }); diff --git a/desktop/tests/e2e/overscroll-boundary.spec.ts b/desktop/tests/e2e/overscroll-boundary.spec.ts index e7e561c1252..2072ee4a731 100644 --- a/desktop/tests/e2e/overscroll-boundary.spec.ts +++ b/desktop/tests/e2e/overscroll-boundary.spec.ts @@ -79,6 +79,26 @@ test("locks viewport rubber-band outside conversation scrollers", async ({ deltaY: -120, }), ).resolves.toBe(false); + + // Canvas interactions such as the DKG topology use wheel gestures for zoom + // without a native scroll container. The viewport guard must not consume + // those events before the renderer receives them. + await page.evaluate(() => { + const graph = document.createElement("section"); + graph.dataset.buzzWheelSurface = ""; + graph.dataset.testid = "interactive-wheel-surface"; + document.body.append(graph); + }); + await expect( + dispatchWheelPrevented(page, '[data-testid="interactive-wheel-surface"]', { + deltaY: -120, + }), + ).resolves.toBe(false); + await expect( + dispatchWheelPrevented(page, '[data-testid="interactive-wheel-surface"]', { + deltaX: 120, + }), + ).resolves.toBe(true); }); test("locks horizontal viewport pan everywhere", async ({ page }) => {