/** @jsxImportSource preact */ import {OrthographicViewport, Widget} from '@deck.gl/core'; import {render} from 'preact'; import {IconButton, makeTextIcon} from '../widget-components/icon-button'; import type {Deck, OrthographicViewState, WidgetPlacement, WidgetProps} from '@deck.gl/core'; import type {JSX} from 'preact'; export type YZoomWidgetProps = WidgetProps & { contentBounds?: [[number, number], [number, number]]; viewId?: string | null; targetViewId?: string | null; placement?: WidgetPlacement; minZoom?: number; maxZoom?: number; step?: number; }; const DEFAULT_ZOOM_LIMITS = {min: -20, max: 20} as const; type RenderedZoomState = { clampedZoom: number; minZoom: number; maxZoom: number; }; const WRAPPER_STYLE: JSX.CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px', background: 'var(--button-background)', border: '1px solid var(--button-stroke)', boxShadow: 'var(--button-shadow)', borderRadius: 'var(--button-corner-radius)', margin: 'var(--widget-margin) 0', padding: '4px 2px', width: 'var(--button-size)', boxSizing: 'border-box', userSelect: 'none', pointerEvents: 'auto' }; const ZOOM_BUTTON_STYLE: JSX.CSSProperties = { boxShadow: 'none' }; const SLIDER_CONTAINER_STYLE: JSX.CSSProperties = { display: 'flex', alignItems: 'stretch', height: '120px', padding: '2px 0' }; const VERTICAL_SLIDER_STYLE: JSX.CSSProperties = { writingMode: 'vertical-lr', height: '120px', width: '14px', margin: 0, padding: 0 }; function cloneViewState(viewState: unknown): Record { return viewState && typeof viewState === 'object' ? {...(viewState as Record)} : {}; } function hasViewManager(deck: Deck): boolean { return Boolean((deck as Deck & {viewManager?: unknown}).viewManager); } function stopEventPropagation(event: Event) { event.stopPropagation(); if ( typeof (event as {stopImmediatePropagation?: () => void}).stopImmediatePropagation === 'function' ) { event.stopImmediatePropagation(); } } function getZoomLimitForAxis( limit: OrthographicViewState['minZoom' | 'maxZoom'] | undefined, axisIndex: 0 | 1 ): number | undefined { if (Array.isArray(limit)) { const [x, y] = limit; return axisIndex === 0 ? x : (y ?? x); } return typeof limit === 'number' && Number.isFinite(limit) ? limit : undefined; } function clamp(value: number, min: number, max: number): number { if (value < min) return min; if (value > max) return max; return value; } export class YZoomWidget extends Widget { static override defaultProps = { ...Widget.defaultProps, id: 'y-zoom', viewId: null, targetViewId: null, placement: 'top-left', minZoom: undefined, maxZoom: undefined, step: 0.1 } satisfies Required & Required> & YZoomWidgetProps; placement: WidgetPlacement = 'top-left'; className = 'deck-widget-y-zoom'; step: number; currentZoom = 0; inferredMinZoom: number | null = null; inferredMaxZoom: number | null = null; lastRenderedZoomState: RenderedZoomState | null = null; constructor(props: YZoomWidgetProps = {}) { super({...YZoomWidget.defaultProps, ...props}); this.viewId = props.viewId ?? null; this.placement = props.placement ?? 'top-left'; this.step = props.step ?? YZoomWidget.defaultProps.step; } override setProps(props: Partial): void { if (props.viewId !== undefined) { this.viewId = props.viewId; } if (props.placement !== undefined) { this.placement = props.placement; } if (props.step !== undefined) { this.step = props.step; } super.setProps(props); } override onAdd({deck, viewId}: {deck: Deck; viewId: string | null}): void { this.deck = deck; if (this.viewId === undefined) { this.viewId = viewId; } } override onRemove(): void { this.deck = undefined; } override onRenderHTML(rootElement: HTMLElement): void { const {minZoom, maxZoom, clampedZoom} = this.getRenderedZoomState(); this.lastRenderedZoomState = {minZoom, maxZoom, clampedZoom}; const handleInput = (event: Event) => { stopEventPropagation(event); this.handleZoomTo(Number((event.target as HTMLInputElement).value)); }; const addVerticalOrient = (element: HTMLInputElement | null) => { element?.setAttribute('orient', 'vertical'); }; const stopPointerPropagation: JSX.PointerEventHandler = (event) => { stopEventPropagation(event as unknown as Event); }; const stopWheelPropagation: JSX.WheelEventHandler = (event) => { stopEventPropagation(event as unknown as Event); }; const stopMousePropagation: JSX.MouseEventHandler = (event) => { stopEventPropagation(event as unknown as Event); }; render(
this.handleZoomDelta(-this.step)} />
this.handleZoomDelta(this.step)} />
, rootElement ); } override onViewportChange(viewport: OrthographicViewport): void { const targetViewId = this.props.targetViewId ?? this.viewId; if (targetViewId && targetViewId !== viewport.id) return; const viewState = this.getViewportViewState(viewport); const zoomY = viewport.zoomY; this.currentZoom = zoomY; if (this.props.minZoom === undefined) { const minZoom = getZoomLimitForAxis(viewState.minZoom, 1); if (Number.isFinite(minZoom)) { this.inferredMinZoom = minZoom; } } if (this.props.maxZoom === undefined) { const maxZoom = getZoomLimitForAxis(viewState.maxZoom, 1); if (Number.isFinite(maxZoom)) { this.inferredMaxZoom = maxZoom; } } this.updateHTMLIfRenderedStateChanged(); } /** * Returns the effective min/max bounds and the clamped zoom value currently shown in the slider. */ private getRenderedZoomState(): RenderedZoomState { const {minZoom, maxZoom} = this.getZoomBounds(); return { minZoom, maxZoom, clampedZoom: clamp(this.currentZoom, minZoom, maxZoom) }; } /** * Re-renders the widget only when the displayed slider state changed. */ private updateHTMLIfRenderedStateChanged(): void { const nextState = this.getRenderedZoomState(); const previousState = this.lastRenderedZoomState; if ( previousState && previousState.clampedZoom === nextState.clampedZoom && previousState.minZoom === nextState.minZoom && previousState.maxZoom === nextState.maxZoom ) { return; } this.lastRenderedZoomState = nextState; this.updateHTML(); } /** * Resolves the slider min/max bounds from explicit props or inferred viewport limits. */ private getZoomBounds(): {minZoom: number; maxZoom: number} { const minZoom = this.props.minZoom ?? this.inferredMinZoom ?? Number.NEGATIVE_INFINITY; const maxZoom = this.props.maxZoom ?? this.inferredMaxZoom ?? Number.POSITIVE_INFINITY; if (minZoom > maxZoom) { return {minZoom: maxZoom, maxZoom: minZoom}; } return { minZoom: Number.isFinite(minZoom) ? minZoom : DEFAULT_ZOOM_LIMITS.min, maxZoom: Number.isFinite(maxZoom) ? maxZoom : DEFAULT_ZOOM_LIMITS.max }; } private getTargetViewports(): OrthographicViewport[] { const deck = this.deck; if (!deck) { return []; } if (this.viewId) { if (hasViewManager(deck)) { const viewport = (deck as Deck & {viewManager?: any}).viewManager?.getViewport( this.props.targetViewId ?? this.viewId ); return viewport ? [viewport] : []; } return []; } return deck.getViewports() as OrthographicViewport[]; } private getViewportViewState(viewport: OrthographicViewport): OrthographicViewState { const deck = this.deck; const viewManager = deck && hasViewManager(deck) ? (deck as Deck & {viewManager?: any}).viewManager : null; const viewId = this.viewId || viewport.id; if (viewManager) { try { return {...viewManager.getViewState(viewId)} as OrthographicViewState; } catch (err) { return cloneViewState(viewManager.viewState) as OrthographicViewState; } } return cloneViewState(viewport) as OrthographicViewState; } private handleZoomDelta(delta: number) { const {minZoom, maxZoom} = this.getZoomBounds(); for (const viewport of this.getTargetViewports()) { const zoomY = viewport.zoomY; const nextZoom = clamp(zoomY + delta, minZoom, maxZoom); this.updateViewState(viewport, nextZoom); } } private handleZoomTo(zoom: number) { const {minZoom, maxZoom} = this.getZoomBounds(); const nextZoom = clamp(zoom, minZoom, maxZoom); for (const viewport of this.getTargetViewports()) { this.updateViewState(viewport, nextZoom); } } private updateViewState(viewport: OrthographicViewport, nextZoomY: number) { if (!this.deck) { return; } const {contentBounds} = this.props; const viewState = this.getViewportViewState(viewport); const zoomX = viewport.zoomX; const newViewState = {...viewState, zoom: [zoomX, nextZoomY]}; if (contentBounds) { const targetY = viewport.target?.[1] ?? 0; const minY = contentBounds[0][1]; const maxY = contentBounds[1][1]; if (targetY < minY || targetY > maxY) { const nextViewport = new OrthographicViewport({...viewport, zoomY: nextZoomY}); const anchor = [contentBounds[0][0], Math.max(minY, Math.min(maxY, targetY))]; const anchorPixels = viewport.project(anchor); newViewState.target = nextViewport.panByPosition(anchor, anchorPixels).target; } } const viewId = this.viewId || viewport.id || 'default-view'; this.currentZoom = nextZoomY; this.updateHTMLIfRenderedStateChanged(); // @ts-expect-error Using private method until a public alternative is available this.deck._onViewStateChange({viewId, viewState: newViewState, interactionState: {}}); } }