diff --git a/docs/api-reference/widgets/zoom-widget.md b/docs/api-reference/widgets/zoom-widget.md index 1ddd9da3cc6..fa014f4c10e 100644 --- a/docs/api-reference/widgets/zoom-widget.md +++ b/docs/api-reference/widgets/zoom-widget.md @@ -126,6 +126,13 @@ The `ZoomWidget` accepts the generic [`WidgetProps`](../core/widget.md#widgetpro Widget button orientation. Valid options are `vertical` or `horizontal`. +#### `zoomAxis` (string, optional) + +* Default: `'all'` + +Which axes to apply zoom to. One of 'X', 'Y' or 'all'. +Only effective if the current view is an [OrthographicView](../core/orthographic-view.md). + #### `zoomInLabel` (string, optional) {#zoominlabel} * Default: `'Zoom In'` @@ -157,6 +164,8 @@ Callback when zoom buttons are clicked. Called for each viewport that will be zo - `viewId`: The view being zoomed - `delta`: Zoom direction (+1 for zoom in, -1 for zoom out) - `zoom`: The new zoom level +- `zoomX`: The new zoom level on X axis, if used with an `OrthographicView`. +- `zoomY`: The new zoom level on Y axis, if used with an `OrthographicView`. ## Styles diff --git a/modules/widgets/src/zoom-widget.tsx b/modules/widgets/src/zoom-widget.tsx index a54fe820768..5fccc81bd09 100644 --- a/modules/widgets/src/zoom-widget.tsx +++ b/modules/widgets/src/zoom-widget.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {Widget, FlyToInterpolator, LinearInterpolator} from '@deck.gl/core'; -import type {WidgetProps, WidgetPlacement} from '@deck.gl/core'; +import {Widget, FlyToInterpolator, LinearInterpolator, OrthographicView} from '@deck.gl/core'; +import type {WidgetProps, WidgetPlacement, OrthographicViewState} from '@deck.gl/core'; import {render} from 'preact'; import {ButtonGroup} from './lib/components/button-group'; import {IconButton} from './lib/components/icon-button'; @@ -21,6 +21,10 @@ export type ZoomWidgetProps = WidgetProps & { zoomOutLabel?: string; /** Zoom transition duration in ms. 0 disables the transition */ transitionDuration?: number; + /** Which axes to apply zoom to. One of 'X', 'Y' or 'all'. + * Only effective if the current view is OrthographicView. + */ + zoomAxis?: 'X' | 'Y' | 'all'; /** * Callback when zoom buttons are clicked. * Called for each viewport that will be zoomed. @@ -32,6 +36,10 @@ export type ZoomWidgetProps = WidgetProps & { delta: number; /** The new zoom level */ zoom: number; + /** The new zoom level of the X axis, if using OrthographicView */ + zoomX?: number; + /** The new zoom level of the Y axis, if using OrthographicView */ + zoomY?: number; }) => void; }; @@ -44,6 +52,7 @@ export class ZoomWidget extends Widget { transitionDuration: 200, zoomInLabel: 'Zoom In', zoomOutLabel: 'Zoom Out', + zoomAxis: 'all', viewId: null, onZoom: () => {} }; @@ -80,25 +89,66 @@ export class ZoomWidget extends Widget { render(ui, rootElement); } - handleZoom(viewId: string, nextZoom: number, delta: number) { + isOrthographicView(viewId: string): boolean { + const deck = this.deck; + const view = deck?.isInitialized && deck.getView(viewId); + return view instanceof OrthographicView; + } + + handleZoom(viewId: string, delta: number) { // Respect minZoom/maxZoom constraints from the view state const viewState = this.getViewState(viewId); - if (viewState) { - const {minZoom, maxZoom} = viewState as any; - if (Number.isFinite(minZoom)) { - nextZoom = Math.max(minZoom, nextZoom); - } - if (Number.isFinite(maxZoom)) { - nextZoom = Math.min(maxZoom, nextZoom); + const newViewState: Record = {}; + + if (this.isOrthographicView(viewId)) { + const {zoomAxis} = this.props; + const {zoomX, minZoomX, maxZoomX, zoomY, minZoomY, maxZoomY} = normalizeOrthographicViewState( + viewState as any + ); + let nextZoom: number; + let nextZoomY: number; + if (zoomAxis === 'X') { + nextZoom = clamp(zoomX + delta, minZoomX, maxZoomX); + nextZoomY = zoomY; + } else if (zoomAxis === 'Y') { + nextZoom = zoomX; + nextZoomY = clamp(zoomY + delta, minZoomY, maxZoomY); + } else { + const clampedDelta = clamp( + delta, + Math.max(minZoomX - zoomX, minZoomY - zoomY), + Math.min(maxZoomX - zoomX, maxZoomY - zoomY) + ); + nextZoom = zoomX + clampedDelta; + nextZoomY = zoomY + clampedDelta; } + newViewState.zoom = [nextZoom, nextZoomY]; + newViewState.zoomX = nextZoom; + newViewState.zoomY = nextZoomY; + // Call callback + this.props.onZoom?.({ + viewId, + delta, + // `zoom` will not match the new state if using 2D zoom. Deprecated behavior for backward compatibility. + zoom: zoomAxis === 'Y' ? nextZoomY : nextZoom, + zoomX: nextZoom, + zoomY: nextZoomY + }); + } else { + const {zoom = 0, minZoom, maxZoom} = viewState as any; + const nextZoom = clamp(zoom + delta, minZoom, maxZoom); + newViewState.zoom = nextZoom; + // Call callback + this.props.onZoom?.({ + viewId, + delta, + zoom: nextZoom + }); } - // Call callback - this.props.onZoom?.({viewId, delta, zoom: nextZoom}); - const nextViewState: Record = { ...viewState, - zoom: nextZoom + ...newViewState }; if (this.props.transitionDuration > 0) { nextViewState.transitionDuration = this.props.transitionDuration; @@ -106,7 +156,7 @@ export class ZoomWidget extends Widget { 'latitude' in nextViewState ? new FlyToInterpolator() : new LinearInterpolator({ - transitionProps: ['zoom'] + transitionProps: 'zoomX' in newViewState ? ['zoomX', 'zoomY'] : ['zoom'] }); } this.setViewState(viewId, nextViewState); @@ -115,16 +165,41 @@ export class ZoomWidget extends Widget { handleZoomIn() { const viewIds = this.viewId ? [this.viewId] : (this.deck?.getViews().map(v => v.id) ?? []); for (const viewId of viewIds) { - const viewState = this.getViewState(viewId); - this.handleZoom(viewId, (viewState.zoom as number) + 1, 1); + this.handleZoom(viewId, 1); } } handleZoomOut() { const viewIds = this.viewId ? [this.viewId] : (this.deck?.getViews().map(v => v.id) ?? []); for (const viewId of viewIds) { - const viewState = this.getViewState(viewId); - this.handleZoom(viewId, (viewState.zoom as number) - 1, -1); + this.handleZoom(viewId, -1); } } } + +function clamp(zoom: number, minZoom: number, maxZoom: number): number { + return zoom < minZoom ? minZoom : zoom > maxZoom ? maxZoom : zoom; +} + +function normalizeOrthographicViewState({ + zoom = 0, + zoomX, + zoomY, + minZoom = -Infinity, + maxZoom = Infinity, + minZoomX = minZoom, + maxZoomX = maxZoom, + minZoomY = minZoom, + maxZoomY = maxZoom +}: OrthographicViewState): { + zoomX: number; + zoomY: number; + minZoomX: number; + maxZoomX: number; + minZoomY: number; + maxZoomY: number; +} { + zoomX = zoomX ?? (Array.isArray(zoom) ? zoom[0] : zoom); + zoomY = zoomY ?? (Array.isArray(zoom) ? zoom[1] : zoom); + return {zoomX, zoomY, minZoomX, minZoomY, maxZoomX, maxZoomY}; +} diff --git a/test/modules/widgets/common.ts b/test/modules/widgets/common.ts new file mode 100644 index 00000000000..9e6566ed7d7 --- /dev/null +++ b/test/modules/widgets/common.ts @@ -0,0 +1,86 @@ +import {Deck} from '@deck.gl/core'; +import type {DeckProps, View} from '@deck.gl/core'; + +const WIDTH = 600; +const HEIGHT = 400; + +export class WidgetTester { + private deck: Deck | null; + private container: HTMLDivElement | null; + + constructor(deckProps?: DeckProps) { + const container = document.createElement('div'); + container.id = 'deck-container'; + container.style.cssText = `position: absolute; left: 0; top: 0; width: ${WIDTH}px; height: ${HEIGHT}px;`; + document.body.appendChild(container); + + this.container = container; + this.deck = new Deck({ + id: 'widget-test-deck', + ...deckProps, + parent: container, + debug: true + }); + } + + setProps(deckProps: DeckProps) { + this.deck?.setProps(deckProps); + } + + idle(): Promise { + return new Promise(res => { + const timer = setInterval(() => { + if (!this.deck?.needsRedraw({clearRedrawFlags: false})) { + res(); + clearInterval(timer); + } + }, 100); + }); + } + + findElements(selector: string): Element[] { + if (!this.container) { + throw new Error('Tester has been finalized'); + } + return Array.from(this.container.querySelectorAll(`.deck-widget-container ${selector}`)); + } + + click( + selector: string, + opts: { + offsetX?: number; + offsetY?: number; + button?: number; + } = {} + ) { + if (!this.container) { + throw new Error('Tester has been finalized'); + } + const element = this.container.querySelector(`.deck-widget-container ${selector}`); + if (!element) { + throw new Error(`Element ${selector} is not found`); + } + const rect = element.getBoundingClientRect(); + const clientX = rect.left + (opts.offsetX ?? rect.width / 2); + const clientY = rect.top + (opts.offsetY ?? rect.height / 2); + const button = opts.button ?? 0; + const eventInit = { + bubbles: true, + cancelable: true, + clientX, + clientY, + button + }; + + element.dispatchEvent(new MouseEvent('mousedown', eventInit)); + element.dispatchEvent(new MouseEvent('mouseup', eventInit)); + element.dispatchEvent(new MouseEvent('click', eventInit)); + } + + destroy() { + this.deck?.finalize(); + this.container?.remove(); + this.deck = null; + this.container = null; + } +} diff --git a/test/modules/widgets/widget-state.spec.ts b/test/modules/widgets/widget-state.spec.ts index 455685b2df2..1e6070362f5 100644 --- a/test/modules/widgets/widget-state.spec.ts +++ b/test/modules/widgets/widget-state.spec.ts @@ -421,29 +421,11 @@ test('ZoomWidget - handleZoom calls onZoom with correct params', () => { // Mock setViewState to prevent actual state changes vi.spyOn(widget, 'setViewState').mockImplementation(() => {}); - widget.handleZoom('default-view', 11, 1); + widget.handleZoom('default-view', 1); expect(onZoom).toHaveBeenCalledWith({viewId: 'default-view', delta: 1, zoom: 11}); }); -test('ZoomWidget - handleZoom respects minZoom/maxZoom constraints', () => { - const onZoom = vi.fn(); - const widget = new ZoomWidget({onZoom}); - - vi.spyOn(widget, 'getViewState').mockReturnValue({zoom: 10, minZoom: 2, maxZoom: 12}); - vi.spyOn(widget, 'setViewState').mockImplementation(() => {}); - - // Try to zoom beyond maxZoom - widget.handleZoom('default-view', 15, 1); - expect(onZoom).toHaveBeenCalledWith({viewId: 'default-view', delta: 1, zoom: 12}); - - onZoom.mockClear(); - - // Try to zoom below minZoom - widget.handleZoom('default-view', 1, -1); - expect(onZoom).toHaveBeenCalledWith({viewId: 'default-view', delta: -1, zoom: 2}); -}); - // ---- CompassWidget ---- test('CompassWidget - onReset callback is called with correct params', () => { diff --git a/test/modules/widgets/zoom-widget.spec.ts b/test/modules/widgets/zoom-widget.spec.ts new file mode 100644 index 00000000000..afb1686f046 --- /dev/null +++ b/test/modules/widgets/zoom-widget.spec.ts @@ -0,0 +1,109 @@ +import {test, expect} from 'vitest'; + +import {OrthographicView, type MapViewState, type OrthographicViewState} from '@deck.gl/core'; +import {ZoomWidget} from '@deck.gl/widgets'; +import {WidgetTester} from './common'; + +test('ZoomWidget', async () => { + let viewState: MapViewState = { + longitude: -122.45, + latitude: 37.78, + zoom: 8 + }; + const testInstance = new WidgetTester({ + initialViewState: viewState, + onViewStateChange: (evt: any) => { + viewState = evt.viewState; + }, + widgets: [new ZoomWidget()] + }); + + await testInstance.idle(); + testInstance.click('.deck-widget-zoom-in'); + expect(viewState.zoom).toBe(9); + + await testInstance.idle(); + testInstance.click('.deck-widget-zoom-out'); + expect(viewState.zoom).toBe(8); + + testInstance.destroy(); +}); + +test('ZoomWidget#constraints', async () => { + let viewState: MapViewState = { + longitude: -122.45, + latitude: 37.78, + zoom: 8, + maxZoom: 8.5, + minZoom: 7.8 + }; + const testInstance = new WidgetTester({ + initialViewState: viewState, + onViewStateChange: (evt: any) => { + viewState = evt.viewState; + }, + widgets: [new ZoomWidget()] + }); + + await testInstance.idle(); + testInstance.click('.deck-widget-zoom-in'); + expect(viewState.zoom).toBe(8.5); + + await testInstance.idle(); + testInstance.click('.deck-widget-zoom-out'); + expect(viewState.zoom).toBe(7.8); + + testInstance.destroy(); +}); + +test('ZoomWidget#zoomAxis', async () => { + let viewState: OrthographicViewState = { + target: [0, 0], + zoom: [0, 2], + maxZoomX: 0.5, + minZoomY: 2 + }; + const testInstance = new WidgetTester({ + views: new OrthographicView(), + initialViewState: viewState, + onViewStateChange: (evt: any) => { + viewState = evt.viewState; + }, + widgets: [new ZoomWidget()] + }); + + await testInstance.idle(); + testInstance.click('.deck-widget-zoom-in'); + expect(viewState.zoomX).toBe(0.5); + expect(viewState.zoomY).toBe(2.5); + + await testInstance.idle(); + testInstance.click('.deck-widget-zoom-out'); + expect(viewState.zoomX).toBe(0); + expect(viewState.zoomY).toBe(2); + + testInstance.setProps({ + widgets: [new ZoomWidget({zoomAxis: 'X'})] + }); + + await testInstance.idle(); + testInstance.click('.deck-widget-zoom-in'); + expect(viewState.zoomX).toBe(0.5); + expect(viewState.zoomY).toBe(2); + + await testInstance.idle(); + testInstance.click('.deck-widget-zoom-out'); + expect(viewState.zoomX).toBe(-0.5); + expect(viewState.zoomY).toBe(2); + + testInstance.setProps({ + widgets: [new ZoomWidget({zoomAxis: 'Y'})] + }); + + await testInstance.idle(); + testInstance.click('.deck-widget-zoom-in'); + expect(viewState.zoomX).toBe(-0.5); + expect(viewState.zoomY).toBe(3); + + testInstance.destroy(); +});