UNPKG

@fxi/d3-geo-zoom

Version:

Zoom and Pan D3 Geo projections (Ported from vasturiano/d3-geo-zoom)

300 lines (249 loc) 7.83 kB
import { interpolate } from "d3-interpolate"; import { select as d3Select, pointers as d3Pointers } from "d3-selection"; import { zoom as d3Zoom, ZoomBehavior, zoomIdentity, D3ZoomEvent, } from "d3-zoom"; import { GeoProjection } from "d3-geo"; import { easeCubicInOut } from "d3-ease"; import { GeoRotation } from "./geoRotation"; export class GeoZoom { private element: Element; private projection?: GeoProjection; private zoom?: ZoomBehavior<Element, unknown>; private unityScale: number = 1; private currentScale: number = 1; private initialScale: number = 1; private initialRotation: [number, number, number] = [0, 0, 0]; private scaleExtent: [number, number] = [0.1, 1e3]; private northUp: boolean = false; private transitionDuration: number = 750; private onMoveCallback?: (params: { scale: number; rotation: [number, number, number]; }) => void; // Zoom state private v0: [number, number, number] | null = null; private r0: [number, number, number] | null = null; private q0: [number, number, number, number] | null = null; constructor(element: Element) { this.element = element; this.initializeZoom(); } private initializeZoom() { this.zoom = d3Zoom() .scaleExtent(this.scaleExtent) .on("start", this.zoomStarted.bind(this)) .on("zoom", this.zoomed.bind(this)); d3Select(this.element) .call(this.zoom) .call(this.zoom.transform, zoomIdentity); } setProjection(newProjection?: GeoProjection): this { if (!newProjection?.rotate || !this.element) return this; const oldProjection = this.projection; if (!oldProjection) { this.projection = newProjection; // Store initial scale when first setting the projection this.initialScale = newProjection.scale(); this.unityScale = this.initialScale; return this; } // Store current state const oldRotation = oldProjection.rotate(); const oldScale = oldProjection.scale(); // Set initial state this.projection = newProjection; this.projection.rotate(oldRotation); this.projection.scale(oldScale); this.notifyMove(); return this; } rotateTo(rotation?: [number, number, number]): this { if (!this.projection?.rotate || !this.element || !rotation) return this; const selection = d3Select(this.element); const currentRotation = this.projection.rotate(); selection .transition() .duration(this.transitionDuration) .ease(easeCubicInOut) .tween("rotate", () => { const r = [ interpolate(currentRotation[0], rotation[0]), interpolate(currentRotation[1], rotation[1]), interpolate(currentRotation[2], rotation[2]), ]; return (t: number) => { this.projection!.rotate([r[0](t), r[1](t), r[2](t)]); this.notifyMove(); }; }); return this; } move( direction: "left" | "right" | "up" | "down" | "north", step: number = 10 ): this { if (!this.projection?.rotate) return this; const [lambda, phi, gamma] = this.projection.rotate(); let newRotation: [number, number, number] = [lambda, phi, gamma]; switch (direction) { case "up": newRotation[1] -= step; break; case "down": newRotation[1] += step; break; case "left": newRotation[0] -= step; break; case "right": newRotation[0] += step; break; case "north": newRotation[2] = 0; break; } if (this.northUp) { newRotation[2] = 0; } this.rotateTo(newRotation); return this; } reset(): this { if (!this.projection?.rotate || !this.element) return this; const selection = d3Select(this.element); // Reset zoom selection .transition() .duration(this.transitionDuration) .ease(easeCubicInOut) .call(this.zoom!.transform, zoomIdentity); // Reset rotation this.rotateTo(this.initialRotation); return this; } private notifyMove() { if (this.onMoveCallback && this.projection) { this.onMoveCallback({ scale: this.currentScale, rotation: this.projection.rotate(), }); } } // Zoom event handlers private zoomStarted(ev: D3ZoomEvent<Element, unknown>) { const proj = this.projection; if (!proj?.invert) return; const coords = this.getPointerCoords(ev); if (!coords) return; const inverted = proj.invert(coords); if (!inverted) return; this.v0 = GeoRotation.cartesian(inverted); this.r0 = proj.rotate(); this.q0 = GeoRotation.fromAngles(this.r0); } private zoomed(ev: D3ZoomEvent<Element, unknown>) { const proj = this.projection; if (!proj?.invert) return; // Update current scale this.currentScale = ev.transform.k * this.unityScale; proj.scale(this.currentScale); // For user interactions, initialize state if needed if (!this.v0 || !this.r0 || !this.q0) { const coords = this.getPointerCoords(ev); if (!coords) return; const inverted = proj.invert(coords); if (!inverted) return; this.v0 = GeoRotation.cartesian(inverted); this.r0 = proj.rotate(); this.q0 = GeoRotation.fromAngles(this.r0) as [ number, number, number, number ]; return; } const coords = this.getPointerCoords(ev); if (!coords) return; const rotated = proj.rotate(this.r0); if (!rotated.invert) return; const inverted = rotated.invert(coords); if (!inverted) return; const v1 = GeoRotation.cartesian(inverted); const q1 = GeoRotation.multiply(this.q0, GeoRotation.delta(this.v0, v1)); const rotation = GeoRotation.toAngles(q1); // Apply north up constraint if enabled const finalRotation: [number, number, number] = this.northUp ? [rotation[0], rotation[1], 0] : rotation; proj.rotate(finalRotation); this.notifyMove(); } private getPointerCoords( zoomEv: D3ZoomEvent<Element, unknown> ): [number, number] | null { const pointers = d3Pointers(zoomEv, this.element); if (pointers && pointers.length > 1) { // Calculate centroid of all points if multi-touch const avg = (vals: number[]): number => vals.reduce((agg, v) => agg + v, 0) / vals.length; return [0, 1].map((idx) => avg(pointers.map((t) => t[idx]))) as [ number, number ]; } const coord = pointers.length ? pointers[0] : null; if (!coord) { return null; } if (coord[0] === 0 && coord[1] === 0) { // For programmatic zooms, use the element's center return this.getCenter(); } return coord; } private getCenter(): [number, number] | null { const rect = this.element.getBoundingClientRect(); return [rect.x + rect.width / 2, rect.y + rect.height / 2]; } // Setters setNorthUp(enabled?: boolean): this { if (enabled === undefined) return this; if (enabled) { this.move('north'); } this.northUp = enabled; return this; } setScaleExtent(extent?: [number, number]): this { if (!extent) return this; this.scaleExtent = extent; this.zoom?.scaleExtent(extent); return this; } setTransitionDuration(duration?: number): this { if (duration === undefined) return this; this.transitionDuration = duration; return this; } onMove( callback?: (params: { scale: number; rotation: [number, number, number]; }) => void ): this { this.onMoveCallback = callback; return this; } // Getter for the zoom behavior getZoom(): ZoomBehavior<Element, unknown> { if (!this.zoom) { throw new Error("Zoom behavior not initialized"); } return this.zoom; } }