Files
fusion/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts
Fusion 7b35a5b426 feat(FN-4549): complete Step 4 — align edges and bounds to measured heights
Fusion-Task-Id: FN-4549
Fusion-Task-Lineage: 7c6b974d-cad9-418c-bf02-9d8490725c6e
2026-05-14 22:16:43 -07:00

345 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import type { LayoutOptions } from "./layout.js";
import type { GraphPosition } from "./types.js";
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 3;
const FIT_PADDING = 40;
const TRANSITION_TIMEOUT_MS = 200;
interface PointerPoint {
x: number;
y: number;
}
interface PinchState {
distance: number;
zoom: number;
pan: PointerPoint;
midpoint: PointerPoint;
}
interface GraphBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return target.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select";
}
export function useGraphInteraction() {
const [pan, setPan] = useState<PointerPoint>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [transitioning, setTransitioning] = useState(false);
const panRef = useRef(pan);
const zoomRef = useRef(zoom);
const transitionTimerRef = useRef<number | null>(null);
const dragStateRef = useRef<{ start: PointerPoint; panStart: PointerPoint } | null>(null);
const pointersRef = useRef<Map<number, PointerPoint>>(new Map());
const pinchRef = useRef<PinchState | null>(null);
const graphBoundsRef = useRef<GraphBounds>({ minX: 0, minY: 0, maxX: 0, maxY: 0 });
useEffect(() => {
panRef.current = pan;
}, [pan]);
useEffect(() => {
zoomRef.current = zoom;
}, [zoom]);
useEffect(() => () => {
if (transitionTimerRef.current !== null) {
window.clearTimeout(transitionTimerRef.current);
}
}, []);
const transform = useMemo(() => `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, [pan.x, pan.y, zoom]);
const zoomPercent = useMemo(() => Math.round(zoom * 100), [zoom]);
const setAnimate = useCallback((enabled: boolean) => {
if (!enabled) {
if (transitionTimerRef.current !== null) {
window.clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = null;
}
setTransitioning(false);
return;
}
setTransitioning(true);
if (transitionTimerRef.current !== null) {
window.clearTimeout(transitionTimerRef.current);
}
transitionTimerRef.current = window.setTimeout(() => {
setTransitioning(false);
transitionTimerRef.current = null;
}, TRANSITION_TIMEOUT_MS);
}, []);
const clampPan = useCallback((nextPan: PointerPoint, viewportWidth: number, viewportHeight: number, nextZoom = zoomRef.current) => {
const bounds = graphBoundsRef.current;
if (bounds.maxX <= bounds.minX || bounds.maxY <= bounds.minY) {
return {
x: clamp(nextPan.x, -viewportWidth, viewportWidth),
y: clamp(nextPan.y, -viewportHeight, viewportHeight),
};
}
const minPanX = viewportWidth - bounds.maxX * nextZoom;
const maxPanX = -bounds.minX * nextZoom;
const minPanY = viewportHeight - bounds.maxY * nextZoom;
const maxPanY = -bounds.minY * nextZoom;
const clampedX = minPanX > maxPanX ? (minPanX + maxPanX) / 2 : clamp(nextPan.x, minPanX, maxPanX);
const clampedY = minPanY > maxPanY ? (minPanY + maxPanY) / 2 : clamp(nextPan.y, minPanY, maxPanY);
return { x: clampedX, y: clampedY };
}, []);
const zoomAtPoint = useCallback((
nextZoomRaw: number,
anchor: PointerPoint,
viewportWidth: number,
viewportHeight: number,
) => {
const currentZoom = zoomRef.current;
const currentPan = panRef.current;
const nextZoom = clamp(nextZoomRaw, MIN_ZOOM, MAX_ZOOM);
const scaleRatio = nextZoom / currentZoom;
const nextPan = clampPan({
x: anchor.x - (anchor.x - currentPan.x) * scaleRatio,
y: anchor.y - (anchor.y - currentPan.y) * scaleRatio,
}, viewportWidth, viewportHeight, nextZoom);
setZoom(nextZoom);
setPan(nextPan);
}, [clampPan]);
const zoomByFactor = useCallback((factor: number, viewportWidth: number, viewportHeight: number, anchor?: PointerPoint) => {
setAnimate(false);
const point = anchor ?? { x: viewportWidth / 2, y: viewportHeight / 2 };
zoomAtPoint(zoomRef.current * factor, point, viewportWidth, viewportHeight);
}, [setAnimate, zoomAtPoint]);
const zoomIn = useCallback((viewportWidth?: number, viewportHeight?: number) => {
if (viewportWidth && viewportHeight) {
zoomByFactor(1.2, viewportWidth, viewportHeight);
return;
}
setZoom((current) => clamp(current + 0.1, MIN_ZOOM, MAX_ZOOM));
}, [zoomByFactor]);
const zoomOut = useCallback((viewportWidth?: number, viewportHeight?: number) => {
if (viewportWidth && viewportHeight) {
zoomByFactor(1 / 1.2, viewportWidth, viewportHeight);
return;
}
setZoom((current) => clamp(current - 0.1, MIN_ZOOM, MAX_ZOOM));
}, [zoomByFactor]);
const resetView = useCallback(() => {
setAnimate(true);
setPan({ x: 0, y: 0 });
setZoom(1);
}, [setAnimate]);
const fitToGraph = useCallback((
positions: Map<string, GraphPosition>,
viewportWidth: number,
viewportHeight: number,
layoutOptions?: LayoutOptions,
) => {
setAnimate(true);
if (positions.size === 0) {
setPan({ x: 0, y: 0 });
setZoom(1);
return;
}
const nodeWidth = layoutOptions?.nodeWidth ?? 280;
const nodeHeight = layoutOptions?.nodeHeight ?? 100;
const entries = Array.from(positions.entries());
const minX = Math.min(...entries.map(([, p]) => p.x));
const minY = Math.min(...entries.map(([, p]) => p.y));
const maxX = Math.max(...entries.map(([, p]) => p.x + nodeWidth));
const maxY = Math.max(
...entries.map(([taskId, p]) => p.y + (layoutOptions?.measuredHeights?.get(taskId) ?? nodeHeight)),
);
const graphWidth = Math.max(1, maxX - minX);
const graphHeight = Math.max(1, maxY - minY);
const availableWidth = Math.max(1, viewportWidth - FIT_PADDING * 2);
const availableHeight = Math.max(1, viewportHeight - FIT_PADDING * 2);
const nextZoom = clamp(Math.min(availableWidth / graphWidth, availableHeight / graphHeight), MIN_ZOOM, MAX_ZOOM);
const panX = (viewportWidth - graphWidth * nextZoom) / 2 - minX * nextZoom;
const panY = (viewportHeight - graphHeight * nextZoom) / 2 - minY * nextZoom;
setZoom(nextZoom);
setPan(clampPan({ x: panX, y: panY }, viewportWidth, viewportHeight, nextZoom));
}, [clampPan, setAnimate]);
const onPointerDown = useCallback((pointerId: number, point: PointerPoint) => {
pointersRef.current.set(pointerId, point);
if (pointersRef.current.size === 2) {
const [a, b] = Array.from(pointersRef.current.values());
pinchRef.current = {
distance: Math.hypot(a.x - b.x, a.y - b.y),
zoom: zoomRef.current,
pan: panRef.current,
midpoint: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
};
dragStateRef.current = null;
return;
}
dragStateRef.current = { start: point, panStart: panRef.current };
}, []);
const onPointerMove = useCallback((pointerId: number, point: PointerPoint, viewportWidth: number, viewportHeight: number) => {
if (pointersRef.current.has(pointerId)) pointersRef.current.set(pointerId, point);
if (pointersRef.current.size === 2 && pinchRef.current) {
setAnimate(false);
const [a, b] = Array.from(pointersRef.current.values());
const distance = Math.hypot(a.x - b.x, a.y - b.y);
const factor = distance / Math.max(1, pinchRef.current.distance);
const midpoint = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const nextZoom = clamp(pinchRef.current.zoom * factor, MIN_ZOOM, MAX_ZOOM);
const ratio = nextZoom / pinchRef.current.zoom;
const nextPan = clampPan({
x: midpoint.x - (pinchRef.current.midpoint.x - pinchRef.current.pan.x) * ratio,
y: midpoint.y - (pinchRef.current.midpoint.y - pinchRef.current.pan.y) * ratio,
}, viewportWidth, viewportHeight);
setZoom(nextZoom);
setPan(nextPan);
return;
}
const dragState = dragStateRef.current;
if (!dragState) return;
setAnimate(false);
const nextPan = {
x: dragState.panStart.x + (point.x - dragState.start.x),
y: dragState.panStart.y + (point.y - dragState.start.y),
};
setPan(clampPan(nextPan, viewportWidth, viewportHeight));
}, [clampPan, setAnimate]);
const onPointerUp = useCallback((pointerId: number) => {
pointersRef.current.delete(pointerId);
if (pointersRef.current.size < 2) {
pinchRef.current = null;
}
if (pointersRef.current.size === 1) {
const [remainingPoint] = Array.from(pointersRef.current.values());
dragStateRef.current = { start: remainingPoint, panStart: panRef.current };
return;
}
if (pointersRef.current.size === 0) {
dragStateRef.current = null;
}
}, []);
const onWheelPan = useCallback((deltaX: number, deltaY: number, viewportWidth: number, viewportHeight: number) => {
setAnimate(false);
const nextPan = {
x: panRef.current.x - deltaX,
y: panRef.current.y - deltaY,
};
setPan(clampPan(nextPan, viewportWidth, viewportHeight));
}, [clampPan, setAnimate]);
const onWheelZoom = useCallback((
deltaY: number,
point: PointerPoint,
viewportWidth: number,
viewportHeight: number,
) => {
const factor = deltaY < 0 ? 1.1 : 0.9;
setAnimate(false);
zoomAtPoint(zoomRef.current * factor, point, viewportWidth, viewportHeight);
}, [setAnimate, zoomAtPoint]);
const setGraphBounds = useCallback((bounds: GraphBounds) => {
graphBoundsRef.current = bounds;
}, []);
const handleKeyDown = useCallback((
event: ReactKeyboardEvent,
viewportWidth: number,
viewportHeight: number,
positions: Map<string, GraphPosition>,
layoutOptions?: LayoutOptions,
) => {
if (isEditableTarget(event.target)) return;
const modifier = event.metaKey || event.ctrlKey;
if (event.key === "Escape") {
event.preventDefault();
resetView();
return;
}
if (!modifier) return;
if (event.key === "=" || event.key === "+") {
event.preventDefault();
zoomByFactor(1.2, viewportWidth, viewportHeight);
return;
}
if (event.key === "-") {
event.preventDefault();
zoomByFactor(1 / 1.2, viewportWidth, viewportHeight);
return;
}
if (event.key === "0") {
event.preventDefault();
resetView();
return;
}
if ((event.key === "f" || event.key === "F") && event.shiftKey) {
event.preventDefault();
fitToGraph(positions, viewportWidth, viewportHeight, layoutOptions);
}
}, [fitToGraph, resetView, zoomByFactor]);
return {
pan,
zoom,
zoomPercent,
transform,
transitioning,
zoomIn,
zoomOut,
resetView,
fitToGraph,
setAnimate,
onPointerDown,
onPointerMove,
onPointerUp,
onWheelPan,
onWheelZoom,
handleKeyDown,
setGraphBounds,
};
}