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 | undefined; panScrollTimeout: ReturnType | 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((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 { 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((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((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((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, }; }