|
| 1 | +// deck.gl |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | +// Copyright (c) vis.gl contributors |
| 4 | + |
| 5 | +import {Widget, LinearInterpolator} from '@deck.gl/core'; |
| 6 | +import type {Viewport, WidgetPlacement, WidgetProps} from '@deck.gl/core'; |
| 7 | +import {render} from 'preact'; |
| 8 | + |
| 9 | +export type GimbalWidgetProps = WidgetProps & { |
| 10 | + placement?: WidgetPlacement; |
| 11 | + /** View to attach to and interact with. Required when using multiple views. */ |
| 12 | + viewId?: string | null; |
| 13 | + /** Tooltip message. */ |
| 14 | + label?: string; |
| 15 | + /** Width of gimbal lines */ |
| 16 | + strokeWidth?: number; |
| 17 | + /** Transition duration in ms when resetting rotation. */ |
| 18 | + transitionDuration?: number; |
| 19 | +}; |
| 20 | + |
| 21 | +export class GimbalWidget extends Widget<GimbalWidgetProps> { |
| 22 | + static defaultProps: Required<GimbalWidgetProps> = { |
| 23 | + ...Widget.defaultProps, |
| 24 | + id: 'gimbal', |
| 25 | + placement: 'top-left', |
| 26 | + viewId: undefined!, |
| 27 | + label: 'Gimbal', |
| 28 | + strokeWidth: 1.5, |
| 29 | + transitionDuration: 200 |
| 30 | + }; |
| 31 | + |
| 32 | + className = 'deck-widget-gimbal'; |
| 33 | + placement: WidgetPlacement = 'top-left'; |
| 34 | + viewId?: string | null = null; |
| 35 | + |
| 36 | + constructor(props: GimbalWidgetProps = {}) { |
| 37 | + super(props, GimbalWidget.defaultProps); |
| 38 | + this.setProps(this.props); |
| 39 | + } |
| 40 | + |
| 41 | + setProps(props: Partial<GimbalWidgetProps>) { |
| 42 | + this.placement = props.placement ?? this.placement; |
| 43 | + this.viewId = props.viewId ?? this.viewId; |
| 44 | + super.setProps(props); |
| 45 | + } |
| 46 | + |
| 47 | + onRenderHTML(rootElement: HTMLElement): void { |
| 48 | + const {rotationOrbit, rotationX} = this.getNormalizedRotation(); |
| 49 | + // Note - we use CSS 3D transforms instead of SVG 2D transforms |
| 50 | + const ui = ( |
| 51 | + <div className="deck-widget-button" style={{perspective: 100, pointerEvents: 'auto'}}> |
| 52 | + <button |
| 53 | + type="button" |
| 54 | + onClick={() => { |
| 55 | + this.resetOrbitView(); |
| 56 | + }} |
| 57 | + title={this.props.label} |
| 58 | + style={{position: 'relative', width: 26, height: 26}} |
| 59 | + > |
| 60 | + {/* Outer ring */} |
| 61 | + <svg |
| 62 | + className="gimbal-outer-ring" |
| 63 | + width="100%" |
| 64 | + height="100%" |
| 65 | + viewBox="0 0 26 26" |
| 66 | + style={{ |
| 67 | + position: 'absolute', |
| 68 | + top: 0, |
| 69 | + left: 0, |
| 70 | + transform: `rotateY(${rotationOrbit}deg)` |
| 71 | + }} |
| 72 | + > |
| 73 | + <circle |
| 74 | + cx="13" |
| 75 | + cy="13" |
| 76 | + r="10" |
| 77 | + stroke="var(--icon-gimbal-outer-color, rgb(68, 92, 204))" |
| 78 | + strokeWidth={this.props.strokeWidth} |
| 79 | + fill="none" |
| 80 | + /> |
| 81 | + </svg> |
| 82 | + |
| 83 | + {/* Inner ring */} |
| 84 | + <svg |
| 85 | + className="gimbal-inner-ring" |
| 86 | + width="100%" |
| 87 | + height="100%" |
| 88 | + viewBox="0 0 26 26" |
| 89 | + style={{ |
| 90 | + position: 'absolute', |
| 91 | + top: 0, |
| 92 | + left: 0, |
| 93 | + transform: `rotateX(${rotationX}deg)` |
| 94 | + }} |
| 95 | + > |
| 96 | + <circle |
| 97 | + cx="13" |
| 98 | + cy="13" |
| 99 | + r="7" |
| 100 | + stroke="var(--icon-gimbal-inner-color, rgb(240, 92, 68))" |
| 101 | + strokeWidth={this.props.strokeWidth} |
| 102 | + fill="none" |
| 103 | + /> |
| 104 | + </svg> |
| 105 | + </button> |
| 106 | + </div> |
| 107 | + ); |
| 108 | + |
| 109 | + render(ui, rootElement); |
| 110 | + } |
| 111 | + |
| 112 | + onViewportChange(viewport: Viewport) { |
| 113 | + this.updateHTML(); |
| 114 | + } |
| 115 | + |
| 116 | + resetOrbitView() { |
| 117 | + const viewId = this.getViewId(); |
| 118 | + const viewState = this.getViewState(viewId); |
| 119 | + if ('rotationOrbit' in viewState || 'rotationX' in viewState) { |
| 120 | + const nextViewState = { |
| 121 | + ...viewState, |
| 122 | + rotationOrbit: 0, |
| 123 | + rotationX: 0, |
| 124 | + transitionDuration: this.props.transitionDuration, |
| 125 | + transitionInterpolator: new LinearInterpolator({ |
| 126 | + transitionProps: ['rotationOrbit', 'rotationX'] |
| 127 | + }) |
| 128 | + }; |
| 129 | + // @ts-ignore Using private method temporary until there's a public one |
| 130 | + this.deck._onViewStateChange({viewId, viewState: nextViewState, interactionState: {}}); |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + getNormalizedRotation() { |
| 135 | + const viewState = this.getViewState(this.getViewId()); |
| 136 | + const [rz, rx] = this.getRotation(viewState); |
| 137 | + const rotationOrbit = normalizeAndClampAngle(rz); |
| 138 | + const rotationX = normalizeAndClampAngle(rx); |
| 139 | + return {rotationOrbit, rotationX}; |
| 140 | + } |
| 141 | + |
| 142 | + getRotation(viewState?: any): [number, number] { |
| 143 | + if (viewState && ('rotationOrbit' in viewState || 'rotationX' in viewState)) { |
| 144 | + return [-(viewState.rotationOrbit || 0), viewState.rotationX || 0]; |
| 145 | + } |
| 146 | + return [0, 0]; |
| 147 | + } |
| 148 | + |
| 149 | + // Move to Widget/WidgetManager? |
| 150 | + |
| 151 | + getViewId() { |
| 152 | + const viewId = this.viewId || 'OrbitView'; |
| 153 | + return viewId; |
| 154 | + } |
| 155 | + |
| 156 | + getViewState(viewId: string) { |
| 157 | + const viewManager = this.getViewManager(); |
| 158 | + const viewState = (viewId && viewManager.getViewState(viewId)) || viewManager.viewState; |
| 159 | + return viewState; |
| 160 | + } |
| 161 | + |
| 162 | + getViewManager() { |
| 163 | + // @ts-expect-error protected |
| 164 | + const viewManager = this.deck?.viewManager; |
| 165 | + if (!viewManager) { |
| 166 | + throw new Error('wigdet must be added to a deck instance'); |
| 167 | + } |
| 168 | + return viewManager; |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +function normalizeAndClampAngle(angle: number): number { |
| 173 | + // Bring angle into [-180, 180] |
| 174 | + let normalized = ((((angle + 180) % 360) + 360) % 360) - 180; |
| 175 | + |
| 176 | + // Avoid rotating the gimbal rings to close to 90 degrees as they will visually disappear |
| 177 | + const AVOID_ANGLE_DELTA = 10; |
| 178 | + const distanceFrom90 = normalized - 90; |
| 179 | + if (Math.abs(distanceFrom90) < AVOID_ANGLE_DELTA) { |
| 180 | + if (distanceFrom90 < AVOID_ANGLE_DELTA) { |
| 181 | + normalized = 90 + AVOID_ANGLE_DELTA; |
| 182 | + } else if (distanceFrom90 > -AVOID_ANGLE_DELTA) { |
| 183 | + normalized = 90 - AVOID_ANGLE_DELTA; |
| 184 | + } |
| 185 | + } |
| 186 | + // Clamp to [-80, 80] |
| 187 | + return normalized; // Math.max(-80, Math.min(80, normalized)); |
| 188 | +} |
0 commit comments