feat(FN-4036): fix zoom-aware graph pan bounds
Fixed zoom-aware graph pan bounds in the dependency graph plugin, correcting edge-case behavior when the graph is zoomed or panned. Added regression tests for graph interactions and updated documentation to cover the corrected behavior. Fusion-Task-Id: FN-4036
This commit is contained in:
@@ -23,6 +23,7 @@ vi.mock("../useGraphInteraction", () => ({
|
||||
onPointerUp: vi.fn(),
|
||||
onWheelZoom: vi.fn(),
|
||||
handleKeyDown: vi.fn(),
|
||||
setGraphBounds: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("../useGraphInteraction", () => ({
|
||||
onPointerUp: vi.fn(),
|
||||
onWheelZoom: vi.fn(),
|
||||
handleKeyDown: vi.fn(),
|
||||
setGraphBounds: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -80,8 +81,8 @@ describe("DependencyGraph persistence", () => {
|
||||
unmount();
|
||||
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 20px");
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("top: 30px");
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 0px");
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("top: 0px");
|
||||
});
|
||||
|
||||
it("merges saved positions with auto-layout for new tasks", () => {
|
||||
@@ -89,8 +90,8 @@ describe("DependencyGraph persistence", () => {
|
||||
|
||||
render(<DependencyGraph tasks={[createTask("A"), createTask("B")]} projectId="p1" />);
|
||||
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 25px");
|
||||
expect(screen.getByTestId("graph-task-node-B").getAttribute("style")).toContain("left: 200px");
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 0px");
|
||||
expect(screen.getByTestId("graph-task-node-B").getAttribute("style")).toContain("left: 175px");
|
||||
});
|
||||
|
||||
it("fit to graph clears saved positions and reapplies auto-layout", () => {
|
||||
@@ -108,9 +109,9 @@ describe("DependencyGraph persistence", () => {
|
||||
window.localStorage.setItem("kb:p2:fusion-plugin-dependency-graph:positions", JSON.stringify({ A: { x: 33, y: 44 } }));
|
||||
|
||||
const { rerender } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 11px");
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 0px");
|
||||
|
||||
rerender(<DependencyGraph tasks={[createTask("A")]} projectId="p2" />);
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 33px");
|
||||
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 0px");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const handleKeyDown = vi.fn();
|
||||
const onPointerDown = vi.fn();
|
||||
const onPointerMove = vi.fn();
|
||||
const onPointerUp = vi.fn();
|
||||
const setGraphBounds = vi.fn();
|
||||
|
||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||
TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => (
|
||||
@@ -32,6 +33,7 @@ vi.mock("../useGraphInteraction", () => ({
|
||||
onPointerUp,
|
||||
onWheelZoom: vi.fn(),
|
||||
handleKeyDown,
|
||||
setGraphBounds,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -49,6 +51,7 @@ describe("DependencyGraph", () => {
|
||||
onPointerDown.mockReset();
|
||||
onPointerMove.mockReset();
|
||||
onPointerUp.mockReset();
|
||||
setGraphBounds.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,6 +89,7 @@ describe("DependencyGraph", () => {
|
||||
it("auto-fits on initial load with active tasks", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
expect(fitToGraph).toHaveBeenCalled();
|
||||
expect(setGraphBounds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards keyboard events to interaction hook", () => {
|
||||
|
||||
@@ -131,4 +131,20 @@ describe("dependency graph interactions", () => {
|
||||
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--highlighted");
|
||||
expect(screen.getByTestId("graph-task-node-D").className).toContain("graph-task-node--dimmed");
|
||||
});
|
||||
|
||||
it("keeps far graph content reachable after zoom-in pan", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.setGraphBounds({ minX: 0, minY: 0, maxX: 2600, maxY: 1400 });
|
||||
result.current.onWheelZoom(-120, { x: 400, y: 300 }, 800, 600);
|
||||
result.current.onPointerDown(1, { x: 350, y: 250 });
|
||||
result.current.onPointerMove(1, { x: -1200, y: 250 }, 800, 600);
|
||||
result.current.onPointerUp(1);
|
||||
});
|
||||
|
||||
const minPanX = 800 - 2600 * result.current.zoom;
|
||||
expect(result.current.pan.x).toBeGreaterThanOrEqual(minPanX);
|
||||
expect(result.current.pan.x).toBeLessThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,4 +179,58 @@ describe("useGraphInteraction", () => {
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("allows panning across full graph width when zoomed to 2x", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.setGraphBounds({ minX: 0, minY: 0, maxX: 2000, maxY: 1200 });
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
result.current.zoomIn();
|
||||
}
|
||||
result.current.onPointerDown(1, { x: 200, y: 200 });
|
||||
result.current.onPointerMove(1, { x: 1200, y: 200 }, 800, 600);
|
||||
result.current.onPointerMove(1, { x: -2200, y: 200 }, 800, 600);
|
||||
result.current.onPointerUp(1);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBeGreaterThanOrEqual(2);
|
||||
expect(result.current.pan.x).toBeGreaterThanOrEqual(800 - 2000 * result.current.zoom);
|
||||
expect(result.current.pan.x).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("uses zoom-aware pan limits at max zoom", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.setGraphBounds({ minX: 0, minY: 0, maxX: 2000, maxY: 1200 });
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
result.current.zoomIn();
|
||||
}
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPointerDown(1, { x: 300, y: 200 });
|
||||
result.current.onPointerMove(1, { x: -6000, y: 200 }, 800, 600);
|
||||
result.current.onPointerUp(1);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBe(3);
|
||||
expect(result.current.pan.x).toBe(800 - 2000 * 3);
|
||||
});
|
||||
|
||||
it("fits graphs with negative coordinates using full min/max bounds", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.fitToGraph(new Map([
|
||||
["A", { x: -500, y: -200 }],
|
||||
["B", { x: 900, y: 500 }],
|
||||
]), 900, 700, { nodeWidth: 280, nodeHeight: 100 });
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBeGreaterThan(0.1);
|
||||
expect(result.current.pan.x).toBeLessThanOrEqual(550);
|
||||
expect(result.current.pan.y).toBeLessThanOrEqual(260);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user