Files
xyflow/packages/system/src/xypanzoom/XYPanZoom.ts
2025-02-11 15:18:41 +01:00

295 lines
7.7 KiB
TypeScript

import { type ZoomTransform, zoom, zoomTransform } from 'd3-zoom';
import { select } from 'd3-selection';
import {
type CoordinateExtent,
type Viewport,
PanZoomTransformOptions,
PanZoomUpdateOptions,
PanZoomParams,
PanZoomInstance,
} from '../types';
import { clamp, isNumeric } from '../utils';
import { getD3Transition, viewportToTransform, wheelDelta } from './utils';
import {
createPanOnScrollHandler,
createPanZoomEndHandler,
createPanZoomHandler,
createPanZoomStartHandler,
createZoomOnScrollHandler,
} from './eventhandler';
import { createFilter } from './filter';
export type ZoomPanValues = {
isZoomingOrPanning: boolean;
usedRightMouseButton: boolean;
prevViewport: Viewport;
mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined;
panScrollTimeout: ReturnType<typeof setTimeout> | undefined;
isPanScrolling: boolean;
};
export function XYPanZoom({
domNode,
minZoom,
maxZoom,
paneClickDistance,
translateExtent,
viewport,
onPanZoom,
onPanZoomStart,
onPanZoomEnd,
onDraggingChange,
}: PanZoomParams): PanZoomInstance {
const zoomPanValues: ZoomPanValues = {
isZoomingOrPanning: false,
usedRightMouseButton: false,
prevViewport: { x: 0, y: 0, zoom: 0 },
mouseButton: 0,
timerId: undefined,
panScrollTimeout: undefined,
isPanScrolling: false,
};
const bbox = domNode.getBoundingClientRect();
const d3ZoomInstance = zoom()
.clickDistance(!isNumeric(paneClickDistance) || paneClickDistance < 0 ? 0 : paneClickDistance)
.scaleExtent([minZoom, maxZoom])
.translateExtent(translateExtent);
const d3Selection = select(domNode).call(d3ZoomInstance);
setViewportConstrained(
{
x: viewport.x,
y: viewport.y,
zoom: clamp(viewport.zoom, minZoom, maxZoom),
},
[
[0, 0],
[bbox.width, bbox.height],
],
translateExtent
);
const d3ZoomHandler = d3Selection.on('wheel.zoom')!;
const d3DblClickZoomHandler = d3Selection.on('dblclick.zoom')!;
d3ZoomInstance.wheelDelta(wheelDelta);
function setTransform(transform: ZoomTransform, options?: PanZoomTransformOptions) {
if (d3Selection) {
return new Promise<boolean>((resolve) => {
d3ZoomInstance?.transform(
getD3Transition(d3Selection, options?.duration, () => resolve(true)),
transform
);
});
}
return Promise.resolve(false);
}
// public functions
function update({
noWheelClassName,
noPanClassName,
onPaneContextMenu,
userSelectionActive,
panOnScroll,
panOnDrag,
panOnScrollMode,
panOnScrollSpeed,
preventScrolling,
zoomOnPinch,
zoomOnScroll,
zoomOnDoubleClick,
zoomActivationKeyPressed,
lib,
onTransformChange,
}: PanZoomUpdateOptions) {
if (userSelectionActive && !zoomPanValues.isZoomingOrPanning) {
destroy();
}
const isPanOnScroll = panOnScroll && !zoomActivationKeyPressed && !userSelectionActive;
const wheelHandler = isPanOnScroll
? createPanOnScrollHandler({
zoomPanValues,
noWheelClassName,
d3Selection,
d3Zoom: d3ZoomInstance,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
onPanZoomStart,
onPanZoom,
onPanZoomEnd,
})
: createZoomOnScrollHandler({
noWheelClassName,
preventScrolling,
d3ZoomHandler,
});
d3Selection.on('wheel.zoom', wheelHandler, { passive: false });
if (!userSelectionActive) {
// pan zoom start
const startHandler = createPanZoomStartHandler({
zoomPanValues,
onDraggingChange,
onPanZoomStart,
});
d3ZoomInstance.on('start', startHandler);
// pan zoom
const panZoomHandler = createPanZoomHandler({
zoomPanValues,
panOnDrag,
onPaneContextMenu: !!onPaneContextMenu,
onPanZoom,
onTransformChange,
});
d3ZoomInstance.on('zoom', panZoomHandler);
// pan zoom end
const panZoomEndHandler = createPanZoomEndHandler({
zoomPanValues,
panOnDrag,
panOnScroll,
onPaneContextMenu,
onPanZoomEnd,
onDraggingChange,
});
d3ZoomInstance.on('end', panZoomEndHandler);
}
const filter = createFilter({
zoomActivationKeyPressed,
panOnDrag,
zoomOnScroll,
panOnScroll,
zoomOnDoubleClick,
zoomOnPinch,
userSelectionActive,
noPanClassName,
noWheelClassName,
lib,
});
d3ZoomInstance.filter(filter);
/*
* We cannot add zoomOnDoubleClick to the filter above because
* double tapping on touch screens circumvents the filter and
* dblclick.zoom is fired on the selection directly
*/
if (zoomOnDoubleClick) {
d3Selection.on('dblclick.zoom', d3DblClickZoomHandler);
} else {
d3Selection.on('dblclick.zoom', null);
}
}
function destroy() {
d3ZoomInstance.on('zoom', null);
}
async function setViewportConstrained(
viewport: Viewport,
extent: CoordinateExtent,
translateExtent: CoordinateExtent
): Promise<ZoomTransform | undefined> {
const nextTransform = viewportToTransform(viewport);
const contrainedTransform = d3ZoomInstance?.constrain()(nextTransform, extent, translateExtent);
if (contrainedTransform) {
await setTransform(contrainedTransform);
}
return new Promise((resolve) => resolve(contrainedTransform));
}
async function setViewport(viewport: Viewport, options?: PanZoomTransformOptions) {
const nextTransform = viewportToTransform(viewport);
await setTransform(nextTransform, options);
return new Promise<ZoomTransform>((resolve) => resolve(nextTransform));
}
function syncViewport(viewport: Viewport) {
if (d3Selection) {
const nextTransform = viewportToTransform(viewport);
const currentTransform = d3Selection.property('__zoom');
if (
currentTransform.k !== viewport.zoom ||
currentTransform.x !== viewport.x ||
currentTransform.y !== viewport.y
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
d3ZoomInstance?.transform(d3Selection, nextTransform, null, { sync: true });
}
}
}
function getViewport(): Viewport {
const transform = d3Selection ? zoomTransform(d3Selection.node() as Element) : { x: 0, y: 0, k: 1 };
return { x: transform.x, y: transform.y, zoom: transform.k };
}
function scaleTo(zoom: number, options?: PanZoomTransformOptions) {
if (d3Selection) {
return new Promise<boolean>((resolve) => {
d3ZoomInstance?.scaleTo(
getD3Transition(d3Selection, options?.duration, () => resolve(true)),
zoom
);
});
}
return Promise.resolve(false);
}
function scaleBy(factor: number, options?: PanZoomTransformOptions) {
if (d3Selection) {
return new Promise<boolean>((resolve) => {
d3ZoomInstance?.scaleBy(
getD3Transition(d3Selection, options?.duration, () => resolve(true)),
factor
);
});
}
return Promise.resolve(false);
}
function setScaleExtent(scaleExtent: [number, number]) {
d3ZoomInstance?.scaleExtent(scaleExtent);
}
function setTranslateExtent(translateExtent: CoordinateExtent) {
d3ZoomInstance?.translateExtent(translateExtent);
}
function setClickDistance(distance: number) {
const validDistance = !isNumeric(distance) || distance < 0 ? 0 : distance;
d3ZoomInstance?.clickDistance(validDistance);
}
return {
update,
destroy,
setViewport,
setViewportConstrained,
getViewport,
scaleTo,
scaleBy,
setScaleExtent,
setTranslateExtent,
syncViewport,
setClickDistance,
};
}