// 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 GimbalWidgetProps = WidgetProps & { placement?: WidgetPlacement; /** View to attach to and interact with. Required when using multiple views. */ viewId?: string | null; /** Tooltip message. */ label?: string; /** Width of gimbal lines. */ strokeWidth?: number; /** Transition duration in ms when resetting rotation. */ transitionDuration?: number; /** * Callback when the gimbal reset button is clicked. * Called for each viewport that will be reset. */ onReset?: (params: { /** The view being reset */ viewId: string; /** The new rotationOrbit value (0) */ rotationOrbit: number; /** The new rotationX value (0) */ rotationX: number; }) => void; }; export class GimbalWidget extends Widget { static defaultProps: Required = { ...Widget.defaultProps, id: 'gimbal', placement: 'top-left', viewId: null, label: 'Gimbal', strokeWidth: 1.5, transitionDuration: 200, onReset: () => {} }; className = 'deck-widget-gimbal'; placement: WidgetPlacement = 'top-left'; viewports: {[id: string]: Viewport} = {}; constructor(props: GimbalWidgetProps = {}) { super(props); this.setProps(this.props); } setProps(props: Partial) { this.placement = props.placement ?? this.placement; this.viewId = props.viewId ?? this.viewId; super.setProps(props); } onRenderHTML(rootElement: HTMLElement): void { const viewId = this.viewId || Object.values(this.viewports)[0]?.id || 'default-view'; const widgetViewport = this.viewports[viewId]; const {rotationOrbit, rotationX} = this.getNormalizedRotation(widgetViewport); // Note - we use CSS 3D transforms instead of SVG 2D transforms const ui = (
); render(ui, rootElement); } onViewportChange(viewport: Viewport) { this.viewports[viewport.id] = viewport; this.updateHTML(); } resetOrbitView(viewport?: Viewport) { const viewId = this.viewId || viewport?.id || 'OrbitView'; const viewState = this.getViewState(viewId); if ('rotationOrbit' in viewState || 'rotationX' in viewState) { // Call callback this.props.onReset?.({viewId, rotationOrbit: 0, rotationX: 0}); const nextViewState = { ...viewState, rotationOrbit: 0, rotationX: 0, transitionDuration: this.props.transitionDuration, transitionInterpolator: new LinearInterpolator({ transitionProps: ['rotationOrbit', 'rotationX'] }) }; this.setViewState(viewId, nextViewState); } } getNormalizedRotation(viewport?: Viewport): {rotationOrbit: number; rotationX: number} { const viewId = this.viewId || viewport?.id || 'OrbitView'; const viewState = this.getViewState(viewId); const [rz, rx] = this.getRotation(viewState); const rotationOrbit = normalizeAndClampAngle(rz); const rotationX = normalizeAndClampAngle(rx); return {rotationOrbit, rotationX}; } getRotation(viewState?: any): [number, number] { if (viewState && ('rotationOrbit' in viewState || 'rotationX' in viewState)) { return [-(viewState.rotationOrbit || 0), viewState.rotationX || 0]; } return [0, 0]; } } function normalizeAndClampAngle(angle: number): number { // Bring angle into [-180, 180] let normalized = ((((angle + 180) % 360) + 360) % 360) - 180; // Avoid rotating the gimbal rings to close to 90 degrees as they will visually disappear const AVOID_ANGLE_DELTA = 10; const distanceFrom90 = normalized - 90; if (Math.abs(distanceFrom90) < AVOID_ANGLE_DELTA) { if (distanceFrom90 < AVOID_ANGLE_DELTA) { normalized = 90 + AVOID_ANGLE_DELTA; } else if (distanceFrom90 > -AVOID_ANGLE_DELTA) { normalized = 90 - AVOID_ANGLE_DELTA; } } // Clamp to [-80, 80] return normalized; // Math.max(-80, Math.min(80, normalized)); }