UNPKG

@equinor/esv-intersection

Version:

Intersection component package with testing and automatic documentation.

504 lines (425 loc) 12.2 kB
import { select, Selection } from 'd3-selection'; import { scaleLinear, ScaleLinear } from 'd3-scale'; import { zoom, zoomIdentity, ZoomBehavior, ZoomTransform } from 'd3-zoom'; import { ZoomAndPanOptions, OnRescaleEvent } from '../interfaces'; const DEFAULT_MIN_ZOOM_LEVEL = 0.1; const DEFAULT_MAX_ZOOM_LEVEL = 256; export type RescaleFunction = (event: OnRescaleEvent) => void; /** * Handle zoom and pan for intersection layers */ export class ZoomPanHandler { zoom!: ZoomBehavior<HTMLElement, unknown>; container: Selection<HTMLElement, unknown, null, undefined>; onRescale: RescaleFunction; options: ZoomAndPanOptions; xBounds: [number, number] = [0, 1]; yBounds: [number, number] = [0, 1]; translateBoundsX: [number, number] = [0, 1]; translateBoundsY: [number, number] = [0, 1]; scaleX: ScaleLinear<number, number>; scaleY: ScaleLinear<number, number>; _zFactor = 1; _enableTranslateExtent = false; currentTransform: ZoomTransform | undefined; /** * Constructor * @param elm, - * @param options - options */ constructor( elm: HTMLElement, onRescale: RescaleFunction, options: ZoomAndPanOptions = { maxZoomLevel: DEFAULT_MAX_ZOOM_LEVEL, minZoomLevel: DEFAULT_MIN_ZOOM_LEVEL, }, ) { this.container = select(elm); this.options = options; this.onRescale = onRescale; this.onZoom = this.onZoom.bind(this); this.calculateTransform = this.calculateTransform.bind(this); this.applyTransform = this.applyTransform.bind(this); this.recalculateZoomTransform = this.recalculateZoomTransform.bind(this); this.rescale = this.rescale.bind(this); this.adjustToSize = this.adjustToSize.bind(this); this.setViewport = this.setViewport.bind(this); this.currentStateAsEvent = this.currentStateAsEvent.bind(this); this.updateTranslateExtent = this.updateTranslateExtent.bind(this); this.scaleX = scaleLinear().domain(this.xBounds).range([0, 1]); this.scaleY = scaleLinear().domain(this.yBounds).range([0, 1]); this.init(); } /** * Getter returning width of target * @returns width */ get width(): number { return this.scaleX.range()[1] ?? 0; } /** * Getter returning height of target * @returns height */ get height(): number { return this.scaleY.range()[1] ?? 0; } /** * Getter which calculate span from x bounds * @returns x span */ get xSpan(): number { const { xBounds } = this; const xspan: number = Math.abs(xBounds[1] - xBounds[0]); return xspan; } /** * Calculate span from y bounds * @returns y span */ get ySpan(): number { const { yBounds } = this; const yspan: number = Math.abs(yBounds[1] - yBounds[0]); return yspan; } /** * Ratio between height and width * @returns ratio */ get viewportRatio(): number { const ratio: number = this.width / (this.height || 1); return ratio; } /** * x ratios screen to value ratio * @returns ratio */ get xRatio(): number { const domain = this.scaleX.domain() as [number, number]; const ratio = Math.abs(this.width / (domain[1] - domain[0])); return ratio; } /** * y scale screen to value ratio * @returns ratio */ get yRatio(): number { const domain = this.scaleY.domain() as [number, number]; const ratio = Math.abs(this.height / (domain[1] - domain[0])); return ratio; } /** * Get z-factor * @returns z-factor */ get zFactor(): number { return this._zFactor; } /** * Set z factor * @param factor */ set zFactor(factor: number) { this._zFactor = factor; this.recalculateZoomTransform(); } /** * Check if x is inverted (right to left is positive) from x bounds * @returns true if inverted */ get isXInverted(): boolean { return this.xBounds[1] < this.xBounds[0]; } /** * Check if y is inverted (bottom to top is positive) from y bounds * @returns true if inverted */ get isYInverted(): boolean { return this.yBounds[1] < this.yBounds[0]; } /** * Get if enable translate extent (pan limit) * @returns true if enabled */ get enableTranslateExtent(): boolean { return this._enableTranslateExtent; } /** * Set enable translate extent (pan limit) * @param enabled - If should be enabled */ set enableTranslateExtent(enabled: boolean) { this._enableTranslateExtent = enabled; this.updateTranslateExtent(); } /** * Update translate extent (pan limits) */ updateTranslateExtent(): void { const { width, xSpan, zFactor, enableTranslateExtent, translateBoundsX, translateBoundsY, } = this; let x1 = -Infinity; let y1 = -Infinity; let x2 = +Infinity; let y2 = +Infinity; if (enableTranslateExtent) { const ppu: number = width / xSpan; x1 = translateBoundsX[0] * ppu; x2 = translateBoundsX[1] * ppu; y1 = translateBoundsY[0] * ppu * zFactor; y2 = translateBoundsY[1] * ppu * zFactor; } this.zoom.translateExtent([ [x1, y1], [x2, y2], ]); } /** * Create an event object from current state */ currentStateAsEvent(): OnRescaleEvent { const { scaleX, scaleY, xBounds, yBounds, zFactor, viewportRatio, currentTransform, xRatio, yRatio, width, height, } = this; return { xScale: scaleX.copy(), yScale: scaleY.copy(), xBounds: xBounds, yBounds: yBounds, zFactor: zFactor, viewportRatio, xRatio: xRatio, yRatio: yRatio, width: width, height: height, transform: Object.assign({}, currentTransform), }; } /** * Update scale */ rescale(): void { const { currentStateAsEvent } = this; this.onRescale(currentStateAsEvent()); } /** * Initialized handler */ init(): void { this.zoom = zoom<HTMLElement, unknown>() .scaleExtent([this.options.minZoomLevel, this.options.maxZoomLevel]) .on('zoom', this.onZoom); this.container.call(this.zoom); } /** * Handle zoom */ onZoom(event: { transform: ZoomTransform }): void { const { transform } = event; if (!transform) { return; } this.applyTransform(transform); this.rescale(); } /** * Update scale */ applyTransform(transform: ZoomTransform): void { const { width, scaleX, scaleY, xSpan, xBounds, yBounds, zFactor } = this; const { viewportRatio: ratio, isXInverted, isYInverted } = this; const newWidth: number = width * transform.k; const unitsPerPixels: number = xSpan / newWidth; const newXSpan: number = xSpan / transform.k; const newYSpan: number = newXSpan / zFactor / ratio; const shiftx: number = unitsPerPixels * transform.x; const shifty: number = (unitsPerPixels / zFactor) * transform.y; const dx0: number = xBounds[0] - (isXInverted ? -shiftx : shiftx); const dy0: number = yBounds[0] - (isYInverted ? -shifty : shifty); scaleX.domain([dx0, dx0 + (isXInverted ? -newXSpan : newXSpan)]); scaleY.domain([dy0, dy0 + (isYInverted ? -newYSpan : newYSpan)]); this.currentTransform = transform; } /** * Set new viewport * @param cx - center X pos * @param cy - center Y pos * @param displ * @param duration - duration of transition * @returns a merge of filter and payload */ setViewport( cx?: number, cy?: number, displ?: number, duration?: number, ): void { const { zoom, container, calculateTransform, scaleX, scaleY, isXInverted } = this; if (cx == null || displ == null || isNaN(cx) || isNaN(displ)) { const xd = scaleX.domain() as [number, number]; const dspan: number = xd[1] - xd[0]; if (cx == null || isNaN(cx)) { cx = xd[0] + dspan / 2 || 0; } if (displ == null || isNaN(displ)) { displ = Math.abs(dspan) || 1; } } if (cy == null || isNaN(cy)) { const yd = scaleY.domain() as [number, number]; cy = yd[0] + (yd[1] - yd[0]) / 2 || 0; } const xdispl: number = isXInverted ? -displ : displ; const dx0: number = cx - xdispl / 2; const dx1: number = dx0 + xdispl; const t: ZoomTransform = calculateTransform(dx0, dx1, cy); if (duration != null && Number.isFinite(duration) && duration > 0) { zoom.transform(container.transition().duration(duration), t); } else { zoom.transform(container, t); } } /** * Set bounds */ setBounds(xBounds: [number, number], yBounds: [number, number]): void { this.xBounds = xBounds; this.yBounds = yBounds; this.recalculateZoomTransform(); } /** * Set bounds */ setTranslateBounds( xBounds: [number, number], yBounds: [number, number], ): void { this.translateBoundsX = xBounds; this.translateBoundsY = yBounds; this.updateTranslateExtent(); } /** * Adjust zoom due to changes in size of target * @param force - force update even if size did not change */ adjustToSize(): void; adjustToSize(autoAdjust: boolean): void; adjustToSize(width: number, height: number, force: boolean): void; adjustToSize( widthOrAutoAdjust?: unknown, height?: number, force = false, ): void { const { width: oldWidth, height: oldHeight, scaleX, scaleY, recalculateZoomTransform, } = this; let w = 0; let h = 0; if (typeof widthOrAutoAdjust === 'number' && typeof height === 'number') { h = height; w = widthOrAutoAdjust; } else { const containerEl = this.container.node(); if (containerEl) { const { width: containerWidth, height: containerHeight } = containerEl.getBoundingClientRect(); w = containerWidth; h = containerHeight; } } const newWidth: number = Math.max(1, w); const newHeight: number = Math.max(1, h); // exit early if nothing has changed if (!force && oldWidth === newWidth && oldHeight === newHeight) { return; } scaleX.range([0, newWidth]); scaleY.range([0, newHeight]); recalculateZoomTransform(); this.onRescale(this.currentStateAsEvent()); } /** * Calculate new transform * @param dx0 * @param dx1 * @param dy * @returns New transformation matrix */ calculateTransform(dx0: number, dx1: number, dy: number): ZoomTransform { const { scaleX, xSpan, xBounds, yBounds, zFactor, viewportRatio: ratio, isXInverted, isYInverted, } = this; const [rx1, rx2] = scaleX.range() as [number, number]; const displ = Math.abs(dx1 - dx0); const k = xSpan / displ; const unitsPerPixels = displ / (rx2 - rx1); const dy0 = dy - (isYInverted ? -displ : displ) / zFactor / ratio / 2; const tx = (xBounds[0] - dx0) / (isXInverted ? -unitsPerPixels : unitsPerPixels); const ty = (yBounds[0] - dy0) / ((isYInverted ? -unitsPerPixels : unitsPerPixels) / zFactor); return zoomIdentity.translate(tx, ty).scale(k); } /** * Recalcualate the transform */ recalculateZoomTransform(): void { const { scaleX, scaleY, container, calculateTransform, updateTranslateExtent, } = this; const [dx0, dx1] = scaleX.domain() as [number, number]; const [dy0, dy1] = scaleY.domain() as [number, number]; const dy: number = dy0 + (dy1 - dy0) / 2; const transform: ZoomTransform = calculateTransform(dx0, dx1, dy); updateTranslateExtent(); this.zoom.transform(container, transform); } setZoomLevelBoundary(zoomlevels: [number, number]): ZoomPanHandler { this.zoom.scaleExtent(zoomlevels); return this; } setMaxZoomLevel(zoomlevel: number): ZoomPanHandler { const zoomLevels = this.zoom.scaleExtent(); this.zoom.scaleExtent([zoomLevels[0], zoomlevel]); return this; } setMinZoomLevel(zoomlevel: number): ZoomPanHandler { const zoomLevels = this.zoom.scaleExtent(); this.zoom.scaleExtent([zoomlevel, zoomLevels[1]]); return this; } }