diff --git a/examples/get-started/pure-js/widgets/app.js b/examples/get-started/pure-js/widgets/app.js index e1b118b7eba..8ff91b21dd7 100644 --- a/examples/get-started/pure-js/widgets/app.js +++ b/examples/get-started/pure-js/widgets/app.js @@ -8,6 +8,7 @@ import { CompassWidget, ZoomWidget, FullscreenWidget, + _OverviewMapWidget as OverviewMapWidget, DarkGlassTheme, LightGlassTheme } from '@deck.gl/widgets'; @@ -77,6 +78,7 @@ new Deck({ widgets: [ new ZoomWidget({style: widgetTheme}), new CompassWidget({style: widgetTheme}), - new FullscreenWidget({style: widgetTheme}) + new FullscreenWidget({style: widgetTheme}), + new OverviewMapWidget({style: widgetTheme, placement: 'bottom-right'}) ] }); diff --git a/modules/react/src/index.ts b/modules/react/src/index.ts index f1ae03044a3..f8f725dbd91 100644 --- a/modules/react/src/index.ts +++ b/modules/react/src/index.ts @@ -18,8 +18,10 @@ export {ScaleWidget as _ScaleWidget} from './widgets/scale-widget'; export {ScreenshotWidget as _ScreenshotWidget} from './widgets/screenshot-widget'; export {SplitterWidget as _SplitterWidget} from './widgets/splitter-widget'; export {ThemeWidget as _ThemeWidget} from './widgets/theme-widget'; +export {OverviewMapWidget as _OverviewMapWidget} from './widgets/overview-map-widget'; export {useWidget} from './utils/use-widget'; export type {ContextMenuWidgetProps} from '@deck.gl/widgets'; +export type {OverviewMapWidgetProps} from '@deck.gl/widgets'; // Types export type {DeckGLContextValue} from './utils/deckgl-context'; diff --git a/modules/react/src/widgets/overview-map-widget.tsx b/modules/react/src/widgets/overview-map-widget.tsx new file mode 100644 index 00000000000..f994de4fc8d --- /dev/null +++ b/modules/react/src/widgets/overview-map-widget.tsx @@ -0,0 +1,12 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {_OverviewMapWidget as OverviewMapWidgetClass} from '@deck.gl/widgets'; +import type {OverviewMapWidgetProps} from '@deck.gl/widgets'; +import {useWidget} from '../utils/use-widget'; + +export const OverviewMapWidget = (props: OverviewMapWidgetProps = {}) => { + useWidget(OverviewMapWidgetClass, props); + return null; +}; diff --git a/modules/widgets/src/index.ts b/modules/widgets/src/index.ts index 857611c2de6..dcc82c69ec4 100644 --- a/modules/widgets/src/index.ts +++ b/modules/widgets/src/index.ts @@ -30,6 +30,7 @@ export {ThemeWidget as _ThemeWidget} from './theme-widget'; export {LoadingWidget as _LoadingWidget} from './loading-widget'; export {FpsWidget as _FpsWidget} from './fps-widget'; export {StatsWidget as _StatsWidget} from './stats-widget'; +export {OverviewMapWidget as _OverviewMapWidget} from './overview-map-widget'; export type {FullscreenWidgetProps} from './fullscreen-widget'; export type {CompassWidgetProps} from './compass-widget'; @@ -48,6 +49,7 @@ export type {SplitterWidgetProps} from './splitter-widget'; export type {TimelineWidgetProps} from './timeline-widget'; export type {ViewSelectorWidgetProps} from './view-selector-widget'; export type {GimbalWidgetProps} from './gimbal-widget'; +export type {OverviewMapWidgetProps} from './overview-map-widget'; export {LightTheme, DarkTheme, LightGlassTheme, DarkGlassTheme} from './themes'; export type {DeckWidgetTheme} from './themes'; diff --git a/modules/widgets/src/overview-map-widget.tsx b/modules/widgets/src/overview-map-widget.tsx new file mode 100644 index 00000000000..ec2f18f894e --- /dev/null +++ b/modules/widgets/src/overview-map-widget.tsx @@ -0,0 +1,373 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Widget, LinearInterpolator} from '@deck.gl/core'; +import type {Viewport, WidgetPlacement, WidgetProps} from '@deck.gl/core'; +import {render} from 'preact'; + +export type OverviewMapWidgetProps = WidgetProps & { + /** Widget positioning within the view. Default 'bottom-right'. */ + placement?: WidgetPlacement; + /** View to attach to and interact with. Required when using multiple views. */ + viewId?: string | null; + /** Tooltip message. */ + label?: string; + /** Maximum size of the overview map in pixels. Default 150. */ + maxSize?: number; + /** Transition duration in ms when navigating. Default 200. */ + transitionDuration?: number; + /** Interval in ms to refresh the thumbnail. Default 1000. Set to 0 to disable auto-refresh. */ + refreshInterval?: number; + /** User-provided thumbnail URL. If not provided, auto-captures from canvas. */ + thumbnailUrl?: string; + /** Source content width for coordinate calculation. Auto-detected if not provided. */ + sourceWidth?: number; + /** Source content height for coordinate calculation. Auto-detected if not provided. */ + sourceHeight?: number; + /** Initial collapsed state. Default false. */ + collapsed?: boolean; +}; + +type ViewportBox = { + left: number; + top: number; + width: number; + height: number; +}; + +type OrthographicViewportLike = Viewport & { + target?: [number, number, number]; + zoom?: number; +}; + +type WebMercatorViewportLike = Viewport & { + latitude?: number; + longitude?: number; + zoom?: number; + getBounds?: () => [number, number, number, number]; +}; + +export class OverviewMapWidget extends Widget { + static defaultProps: Required = { + ...Widget.defaultProps, + id: 'overview-map', + placement: 'bottom-right', + viewId: null, + label: 'Overview Map', + maxSize: 150, + transitionDuration: 200, + refreshInterval: 1000, + thumbnailUrl: '', + sourceWidth: 0, + sourceHeight: 0, + collapsed: false + }; + + className = 'deck-widget-overview-map'; + placement: WidgetPlacement = 'bottom-right'; + + private viewports: {[id: string]: Viewport} = {}; + private thumbnailDataUrl: string = ''; + private isCollapsed: boolean = false; + private containerSize = {width: 150, height: 150}; + private viewportBox: ViewportBox | null = null; + private refreshTimer: number | null = null; + private lastCaptureTime: number = 0; + + constructor(props: OverviewMapWidgetProps = {}) { + super(props); + this.isCollapsed = props.collapsed ?? false; + this.setProps(this.props); + } + + setProps(props: Partial) { + this.placement = props.placement ?? this.placement; + this.viewId = props.viewId ?? this.viewId; + if (props.collapsed !== undefined) { + this.isCollapsed = props.collapsed; + } + super.setProps(props); + } + + onAdd(): void { + if (this.props.refreshInterval > 0) { + this.startRefreshTimer(); + } + } + + onRemove(): void { + this.stopRefreshTimer(); + } + + onRenderHTML(rootElement: HTMLElement): void { + const {label, thumbnailUrl} = this.props; + const displayUrl = thumbnailUrl || this.thumbnailDataUrl; + + const ui = this.isCollapsed ? ( + + ) : ( +
this.handleClick(e)} + > + + {displayUrl && ( + {label + )} + {this.viewportBox && ( +
+ )} +
+ ); + + render(ui, rootElement); + } + + onViewportChange(viewport: Viewport): void { + const prevViewport = this.viewports[viewport.id]; + this.viewports[viewport.id] = viewport; + + if (!prevViewport || !viewport.equals(prevViewport)) { + this.updateContainerSize(); + this.updateViewportBox(viewport); + this.updateHTML(); + } + } + + onRedraw(): void { + if (!this.props.thumbnailUrl && this.props.refreshInterval === 0) { + this.captureOverview(); + } + } + + private startRefreshTimer(): void { + this.stopRefreshTimer(); + this.refreshTimer = window.setInterval(() => { + this.captureOverview(); + }, this.props.refreshInterval); + } + + private stopRefreshTimer(): void { + if (this.refreshTimer !== null) { + window.clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + } + + private captureOverview(): void { + if (this.props.thumbnailUrl) return; + + const now = Date.now(); + if (now - this.lastCaptureTime < 100) return; + + const canvas = this.deck?.getCanvas?.(); + if (canvas) { + this.thumbnailDataUrl = canvas.toDataURL('image/png'); + this.lastCaptureTime = now; + this.updateHTML(); + } + } + + private updateContainerSize(): void { + const {maxSize, sourceWidth, sourceHeight} = this.props; + const viewport = this.getCurrentViewport(); + + const width = sourceWidth || viewport?.width || maxSize; + const height = sourceHeight || viewport?.height || maxSize; + + const aspectRatio = width / height; + + if (aspectRatio > 1) { + this.containerSize = { + width: maxSize, + height: Math.round(maxSize / aspectRatio) + }; + } else { + this.containerSize = { + width: Math.round(maxSize * aspectRatio), + height: maxSize + }; + } + } + + private updateViewportBox(viewport: Viewport): void { + const {sourceWidth, sourceHeight} = this.props; + const contentWidth = sourceWidth || this.deck?.width || 0; + const contentHeight = sourceHeight || this.deck?.height || 0; + + if (!contentWidth || !contentHeight) { + this.viewportBox = null; + return; + } + + const scaleX = this.containerSize.width / contentWidth; + const scaleY = this.containerSize.height / contentHeight; + + if ('target' in viewport) { + this.viewportBox = this.calculateOrthographicBox(viewport, scaleX, scaleY); + } else if ('latitude' in viewport && 'longitude' in viewport) { + this.viewportBox = this.calculateMercatorBox(viewport); + } else { + this.viewportBox = this.calculateFallbackBox(); + } + } + + private calculateOrthographicBox( + viewport: Viewport, + scaleX: number, + scaleY: number + ): ViewportBox { + const orthoViewport = viewport as OrthographicViewportLike; + const target = orthoViewport.target || [0, 0, 0]; + const zoom = orthoViewport.zoom || 0; + const scale = Math.pow(2, zoom); + const viewportWidth = viewport.width / scale; + const viewportHeight = viewport.height / scale; + const left = target[0] - viewportWidth / 2; + const top = target[1] - viewportHeight / 2; + + return { + left: Math.max(0, left * scaleX), + top: Math.max(0, top * scaleY), + width: Math.min(this.containerSize.width, viewportWidth * scaleX), + height: Math.min(this.containerSize.height, viewportHeight * scaleY) + }; + } + + private calculateMercatorBox(viewport: Viewport): ViewportBox | null { + const mercatorViewport = viewport as WebMercatorViewportLike; + const bounds = mercatorViewport.getBounds?.(); + if (!bounds) return null; + + const [west, south, east, north] = bounds; + const normalizedWest = (west + 180) / 360; + const normalizedEast = (east + 180) / 360; + const normalizedNorth = (90 - north) / 180; + const normalizedSouth = (90 - south) / 180; + + return { + left: normalizedWest * this.containerSize.width, + top: normalizedNorth * this.containerSize.height, + width: (normalizedEast - normalizedWest) * this.containerSize.width, + height: (normalizedSouth - normalizedNorth) * this.containerSize.height + }; + } + + private calculateFallbackBox(): ViewportBox { + return { + left: this.containerSize.width * 0.25, + top: this.containerSize.height * 0.25, + width: this.containerSize.width * 0.5, + height: this.containerSize.height * 0.5 + }; + } + + private getCurrentViewport(): Viewport | undefined { + const viewId = this.viewId || Object.keys(this.viewports)[0]; + return viewId ? this.viewports[viewId] : undefined; + } + + private handleClick(e: MouseEvent): void { + const target = e.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + const {sourceWidth, sourceHeight} = this.props; + const viewport = this.getCurrentViewport(); + + const contentWidth = sourceWidth || this.deck?.width || 0; + const contentHeight = sourceHeight || this.deck?.height || 0; + + if (!contentWidth || !contentHeight || !viewport) return; + + const targetX = (clickX / this.containerSize.width) * contentWidth; + const targetY = (clickY / this.containerSize.height) * contentHeight; + + this.navigateTo(viewport, targetX, targetY); + } + + private navigateTo(viewport: Viewport, x: number, y: number): void { + const viewId = this.viewId || viewport.id || 'default-view'; + + if ('target' in viewport) { + // OrthographicView + const nextViewState: Record = { + ...viewport, + target: [x, y, 0] + }; + if (this.props.transitionDuration > 0) { + nextViewState.transitionDuration = this.props.transitionDuration; + nextViewState.transitionInterpolator = new LinearInterpolator({ + transitionProps: ['target'] + }); + } + this.setViewState(viewId, nextViewState); + } else if ('latitude' in viewport && 'longitude' in viewport) { + // WebMercatorViewport + const longitude = (x / this.containerSize.width) * 360 - 180; + const latitude = 90 - (y / this.containerSize.height) * 180; + + const nextViewState: Record = { + ...viewport, + longitude, + latitude + }; + if (this.props.transitionDuration > 0) { + nextViewState.transitionDuration = this.props.transitionDuration; + nextViewState.transitionInterpolator = new LinearInterpolator({ + transitionProps: ['longitude', 'latitude'] + }); + } + this.setViewState(viewId, nextViewState); + } + } + + private setViewState(viewId: string, viewState: Record): void { + // @ts-ignore Using private method temporary until there's a public one + this.deck?._onViewStateChange({viewId, viewState, interactionState: {}}); + } + + private handleToggle(): void { + this.isCollapsed = !this.isCollapsed; + if (!this.isCollapsed && !this.props.thumbnailUrl) { + this.captureOverview(); + } + this.updateHTML(); + } +} diff --git a/modules/widgets/src/stylesheet.css b/modules/widgets/src/stylesheet.css index 93a5eaa4ada..df64699681d 100644 --- a/modules/widgets/src/stylesheet.css +++ b/modules/widgets/src/stylesheet.css @@ -294,3 +294,76 @@ background-color: var(--button-background, #fff); color: var(--button-text, rgb(24, 24, 26)); } + +/* Overview Map Widget styles */ +.deck-widget-overview-map-toggle { + width: var(--button-size, 28px); + height: var(--button-size, 28px); + background: var(--button-background, #fff); + border: var(--button-inner-stroke, unset); + border-radius: var(--button-corner-radius, 8px); + box-shadow: var(--button-shadow, 0px 0px 8px 0px rgba(0, 0, 0, 0.25)); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--button-icon-idle, #616166); + pointer-events: auto; +} + +.deck-widget-overview-map-toggle:hover { + color: var(--button-icon-hover, rgb(24, 24, 26)); +} + +.deck-widget-overview-map-container { + position: relative; + max-width: var(--overview-map-max-size, 150px); + max-height: var(--overview-map-max-size, 150px); + background: var(--button-background, rgba(0, 0, 0, 0.8)); + border: 2px solid var(--button-stroke, rgba(255, 255, 255, 0.3)); + border-radius: var(--button-corner-radius, 8px); + box-shadow: var(--button-shadow, 0px 0px 8px 0px rgba(0, 0, 0, 0.25)); + overflow: hidden; + cursor: pointer; + user-select: none; + pointer-events: auto; +} + +.deck-widget-overview-map-close { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 4px; + color: white; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + padding: 0; +} + +.deck-widget-overview-map-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +.deck-widget-overview-map-image { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + display: block; +} + +.deck-widget-overview-map-viewport-box { + position: absolute; + border: 2px solid var(--overview-map-viewport-color, #00aaff); + background: var(--overview-map-viewport-background, rgba(0, 170, 255, 0.2)); + pointer-events: none; + box-sizing: border-box; +} diff --git a/test/modules/index.ts b/test/modules/index.ts index c52e6810b46..06eb605fdbf 100644 --- a/test/modules/index.ts +++ b/test/modules/index.ts @@ -21,3 +21,6 @@ import './json'; import './jupyter-widget'; import './react'; import './main/bundle'; + +// Widgets +import './widgets'; diff --git a/test/modules/widgets/index.ts b/test/modules/widgets/index.ts new file mode 100644 index 00000000000..483ffd2efee --- /dev/null +++ b/test/modules/widgets/index.ts @@ -0,0 +1,6 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import './geocoders.spec'; +import './overview-map-widget.spec'; diff --git a/test/modules/widgets/overview-map-widget.spec.ts b/test/modules/widgets/overview-map-widget.spec.ts new file mode 100644 index 00000000000..cb171456f01 --- /dev/null +++ b/test/modules/widgets/overview-map-widget.spec.ts @@ -0,0 +1,96 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import test from 'tape'; +import {_OverviewMapWidget as OverviewMapWidget} from '@deck.gl/widgets'; + +test('OverviewMapWidget#constructor - default props', t => { + const widget = new OverviewMapWidget(); + + t.is(widget.id, 'overview-map', 'default id'); + t.is(widget.placement, 'bottom-right', 'default placement'); + t.is(widget.className, 'deck-widget-overview-map', 'className'); + t.is(widget.props.maxSize, 150, 'default maxSize'); + t.is(widget.props.transitionDuration, 200, 'default transitionDuration'); + t.is(widget.props.refreshInterval, 1000, 'default refreshInterval'); + t.is(widget.props.collapsed, false, 'default collapsed'); + + t.end(); +}); + +test('OverviewMapWidget#constructor - custom props', t => { + const widget = new OverviewMapWidget({ + id: 'custom-overview', + placement: 'top-left', + maxSize: 200, + transitionDuration: 500, + refreshInterval: 2000, + collapsed: true + }); + + t.is(widget.id, 'custom-overview', 'custom id'); + t.is(widget.placement, 'top-left', 'custom placement'); + t.is(widget.props.maxSize, 200, 'custom maxSize'); + t.is(widget.props.transitionDuration, 500, 'custom transitionDuration'); + t.is(widget.props.refreshInterval, 2000, 'custom refreshInterval'); + t.is(widget.props.collapsed, true, 'custom collapsed'); + + t.end(); +}); + +test('OverviewMapWidget#setProps - updates placement', t => { + const widget = new OverviewMapWidget(); + + t.is(widget.placement, 'bottom-right', 'initial placement'); + + widget.setProps({placement: 'top-right'}); + + t.is(widget.placement, 'top-right', 'updated placement'); + + t.end(); +}); + +test('OverviewMapWidget#setProps - updates viewId', t => { + const widget = new OverviewMapWidget(); + + t.is(widget.viewId, null, 'initial viewId is null'); + + widget.setProps({viewId: 'main-view'}); + + t.is(widget.viewId, 'main-view', 'updated viewId'); + + t.end(); +}); + +test('OverviewMapWidget#setProps - updates collapsed state', t => { + const widget = new OverviewMapWidget({collapsed: false}); + + widget.setProps({collapsed: true}); + + t.is(widget.props.collapsed, true, 'collapsed state updated'); + + t.end(); +}); + +test('OverviewMapWidget#thumbnailUrl prop', t => { + const widget = new OverviewMapWidget({ + thumbnailUrl: 'https://example.com/thumbnail.png' + }); + + t.is(widget.props.thumbnailUrl, 'https://example.com/thumbnail.png', 'thumbnailUrl set'); + + t.end(); +}); + +test('OverviewMapWidget#sourceWidth and sourceHeight props', t => { + const widget = new OverviewMapWidget({ + sourceWidth: 1920, + sourceHeight: 1080 + }); + + t.is(widget.props.sourceWidth, 1920, 'sourceWidth set'); + t.is(widget.props.sourceHeight, 1080, 'sourceHeight set'); + + t.end(); +});