Skip to content

Commit d000516

Browse files
feat(widgets): New GimbalWidget (#9614)
1 parent ff05a7f commit d000516

File tree

7 files changed

+233
-24
lines changed

7 files changed

+233
-24
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {WidgetPreview} from '@site/src/doc-demos/widgets';
2+
import {GimbalWidget} from '@deck.gl/widgets';
3+
4+
# GimbalWidget
5+
6+
Visualizes the orientation of an `OrbitView` using nested circles. Clicking resets `rotationOrbit` and `rotationX` to `0`.
7+
8+
<WidgetPreview cls={GimbalWidget}/>
9+
10+
```ts
11+
import {GimbalWidget} from '@deck.gl/widgets';
12+
import {Deck} from '@deck.gl/core';
13+
14+
const deck = new Deck({
15+
widgets: [new GimbalWidget()]
16+
});
17+
```
18+
19+
## Props
20+
21+
- `id`: `'gimbal'`
22+
- `label`: `'Gimbal'`
23+
- `transitionDuration`: `200`
24+
25+
## Styles
26+
27+
| Name | Default |
28+
| ---- | ------- |
29+
| `--icon-gimbal-outer-color` | `rgb(68, 92, 204)` |
30+
| `--icon-gimbal-inner-color` | `rgb(240, 92, 68)` |
31+

docs/api-reference/widgets/overview.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ This module contains the following widgets:
88

99
- [ZoomWidget](./zoom-widget.md)
1010
- [ResetViewWidget](./reset-view-widget.md)
11+
<!-- - [GimbalWidget](./gimbal-widget.md) -->
1112

1213
### Geospatial Widgets
1314

1415
- [CompassWidget](./compass-widget.md)
15-
- [ScaleWidget](./scale-widget.md)
16-
- [GeolocateWidget](./geolocate-widget.md)
16+
<!-- - [ScaleWidget](./scale-widget.md) -->
17+
<!-- - [GeolocateWidget](./geolocate-widget.md) -->
1718

1819
### Utility Widgets
1920

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
}

modules/widgets/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export {ResetViewWidget} from './reset-view-widget';
88

99
// Geospatial widgets
1010
export {CompassWidget} from './compass-widget';
11+
export {GimbalWidget} from './gimbal-widget';
1112
export {ScaleWidget as _ScaleWidget} from './scale-widget';
1213
export {GeolocateWidget as _GeolocateWidget} from './geolocate-widget';
1314

@@ -38,6 +39,7 @@ export type {ContextMenuWidgetProps} from './context-menu-widget';
3839
export type {SplitterWidgetProps} from './splitter-widget';
3940
export type {TimelineWidgetProps} from './timeline-widget';
4041
export type {ViewSelectorWidgetProps} from './view-selector-widget';
42+
export type {GimbalWidgetProps} from './gimbal-widget';
4143

4244
export {LightTheme, DarkTheme, LightGlassTheme, DarkGlassTheme} from './themes';
4345
export type {DeckWidgetTheme} from './themes';

test/apps/widgets-infovis/app.js renamed to test/apps/widgets-infovis/app.ts

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,13 @@
22
// SPDX-License-Identifier: MIT
33
// Copyright (c) vis.gl contributors
44

5-
import {Deck, OrbitView} from '@deck.gl/core';
5+
import {Deck, OrbitView, OrbitViewState} from '@deck.gl/core';
66
import {ScatterplotLayer} from '@deck.gl/layers';
7-
import {
8-
CompassWidget,
9-
ZoomWidget,
10-
FullscreenWidget,
11-
DarkGlassTheme,
12-
LightGlassTheme
13-
} from '@deck.gl/widgets';
7+
import {GimbalWidget, ZoomWidget, FullscreenWidget} from '@deck.gl/widgets';
148
import '@deck.gl/widgets/stylesheet.css';
159

16-
/* global window */
17-
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
18-
const widgetTheme = prefersDarkScheme.matches ? DarkGlassTheme : LightGlassTheme;
19-
2010
function generateData(count) {
21-
const result = [];
11+
const result: {position: number[]; color: number[]}[] = [];
2212
for (let i = 0; i < count; i++) {
2313
result.push({
2414
position: [Math.random() * 100 - 50, Math.random() * 100 - 50, Math.random() * 100 - 50],
@@ -33,7 +23,7 @@ const INITIAL_VIEW_STATE = {
3323
rotationX: 45,
3424
rotationOrbit: 0,
3525
zoom: 0
36-
};
26+
} as const satisfies OrbitViewState;
3727

3828
new Deck({
3929
views: new OrbitView(),
@@ -47,12 +37,9 @@ new Deck({
4737
getFillColor: d => d.color,
4838
getRadius: 3,
4939
pickable: true,
50-
autoHighlight: true
40+
autoHighlight: true,
41+
billboard: true
5142
})
5243
],
53-
widgets: [
54-
new ZoomWidget({style: widgetTheme}),
55-
new CompassWidget({style: widgetTheme}),
56-
new FullscreenWidget({style: widgetTheme})
57-
]
44+
widgets: [new ZoomWidget(), new GimbalWidget(), new FullscreenWidget()]
5845
});

test/apps/widgets-infovis/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width" />
77
</head>
88
<body>
9-
<script type="module" src="./app.js"></script>
9+
<script type="module" src="./app.ts"></script>
1010
</body>
1111
</html>

test/apps/widgets-infovis/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"license": "MIT",
66
"scripts": {
77
"start": "vite --open",
8-
"start-local": "vite --config ../../../vite.config.local.mjs",
8+
"start-local": "vite --config ../vite.config.local.mjs",
99
"build": "vite build"
1010
},
1111
"dependencies": {

0 commit comments

Comments
 (0)