Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/api-reference/widgets/zoom-widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`
Expand Down Expand Up @@ -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

Expand Down
113 changes: 94 additions & 19 deletions modules/widgets/src/zoom-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer lowercase here. Do we capitalize other string APIs?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm good point... sadly OrthographicController's zoomAxis (since v8) uses uppercase X and Y

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah dang, ok better to match in this case

/**
* Callback when zoom buttons are clicked.
* Called for each viewport that will be zoomed.
Expand All @@ -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;
};

Expand All @@ -44,6 +52,7 @@ export class ZoomWidget extends Widget<ZoomWidgetProps> {
transitionDuration: 200,
zoomInLabel: 'Zoom In',
zoomOutLabel: 'Zoom Out',
zoomAxis: 'all',
viewId: null,
onZoom: () => {}
};
Expand Down Expand Up @@ -80,33 +89,74 @@ export class ZoomWidget extends Widget<ZoomWidgetProps> {
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<string, unknown> = {};

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<string, unknown> = {
...viewState,
zoom: nextZoom
...newViewState
};
if (this.props.transitionDuration > 0) {
nextViewState.transitionDuration = this.props.transitionDuration;
nextViewState.transitionInterpolator =
'latitude' in nextViewState
? new FlyToInterpolator()
: new LinearInterpolator({
transitionProps: ['zoom']
transitionProps: 'zoomX' in newViewState ? ['zoomX', 'zoomY'] : ['zoom']
});
}
this.setViewState(viewId, nextViewState);
Expand All @@ -115,16 +165,41 @@ export class ZoomWidget extends Widget<ZoomWidgetProps> {
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};
}
86 changes: 86 additions & 0 deletions test/modules/widgets/common.ts
Original file line number Diff line number Diff line change
@@ -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<any> | null;
private container: HTMLDivElement | null;

constructor(deckProps?: DeckProps<any>) {
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<void> {
return new Promise<void>(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;
}
}
20 changes: 1 addition & 19 deletions test/modules/widgets/widget-state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading