@dxos/react-ui-geo 0.8.4-staging.ac66bdf99f → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +102 -5
- package/data/countries-10m.ts +12 -0
- package/data/countries-110m.ts +4 -10579
- package/data/countries-50m.ts +12 -0
- package/dist/lib/browser/chunk-SC2FBYFU.mjs +17 -0
- package/dist/lib/browser/chunk-SC2FBYFU.mjs.map +7 -0
- package/dist/lib/browser/countries-10m-CWWDOKH7.mjs +6 -0
- package/dist/lib/browser/countries-10m-CWWDOKH7.mjs.map +7 -0
- package/dist/lib/browser/countries-110m-72QBAA5E.mjs +6 -0
- package/dist/lib/browser/countries-110m-72QBAA5E.mjs.map +7 -0
- package/dist/lib/browser/countries-50m-H7SL7KVF.mjs +6 -0
- package/dist/lib/browser/countries-50m-H7SL7KVF.mjs.map +7 -0
- package/dist/lib/browser/data.mjs +1 -1
- package/dist/lib/browser/index.mjs +774 -223
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +19 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-VZENBYLJ.mjs +19 -0
- package/dist/lib/node-esm/chunk-VZENBYLJ.mjs.map +7 -0
- package/dist/lib/node-esm/countries-10m-DJZV66KG.mjs +8 -0
- package/dist/lib/node-esm/countries-10m-DJZV66KG.mjs.map +7 -0
- package/dist/lib/node-esm/countries-110m-H3WY6K4Q.mjs +8 -0
- package/dist/lib/node-esm/countries-110m-H3WY6K4Q.mjs.map +7 -0
- package/dist/lib/node-esm/countries-50m-ZY7Z3IWD.mjs +8 -0
- package/dist/lib/node-esm/countries-50m-ZY7Z3IWD.mjs.map +7 -0
- package/dist/lib/node-esm/data.mjs +1 -1
- package/dist/lib/node-esm/index.mjs +774 -223
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/translations.mjs +21 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/data/airports.d.ts +4 -4
- package/dist/types/data/airports.d.ts.map +1 -1
- package/dist/types/data/cities.d.ts.map +1 -1
- package/dist/types/data/countries-10m.d.ts +8 -0
- package/dist/types/data/countries-10m.d.ts.map +1 -0
- package/dist/types/data/countries-110m.d.ts +2 -30
- package/dist/types/data/countries-110m.d.ts.map +1 -1
- package/dist/types/data/countries-50m.d.ts +8 -0
- package/dist/types/data/countries-50m.d.ts.map +1 -0
- package/dist/types/data/countries-dots-3.d.ts.map +1 -1
- package/dist/types/data/countries-dots-4.d.ts.map +1 -1
- package/dist/types/src/components/Globe/Globe.d.ts +18 -10
- package/dist/types/src/components/Globe/Globe.d.ts.map +1 -1
- package/dist/types/src/components/Globe/Globe.stories.d.ts +16 -8
- package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
- package/dist/types/src/components/Map/Map.d.ts +49 -13
- package/dist/types/src/components/Map/Map.d.ts.map +1 -1
- package/dist/types/src/components/Map/Map.stories.d.ts +9 -5
- package/dist/types/src/components/Map/Map.stories.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Controls.d.ts.map +1 -1
- package/dist/types/src/data.d.ts +9 -1
- package/dist/types/src/data.d.ts.map +1 -1
- package/dist/types/src/hooks/context.d.ts +37 -0
- package/dist/types/src/hooks/context.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +3 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useDrag.d.ts +22 -2
- package/dist/types/src/hooks/useDrag.d.ts.map +1 -1
- package/dist/types/src/hooks/useGlobeZoomHandler.d.ts +3 -2
- package/dist/types/src/hooks/useGlobeZoomHandler.d.ts.map +1 -1
- package/dist/types/src/hooks/useMapZoomHandler.d.ts +1 -1
- package/dist/types/src/hooks/useMapZoomHandler.d.ts.map +1 -1
- package/dist/types/src/hooks/useSimplifiedTopology.d.ts +32 -0
- package/dist/types/src/hooks/useSimplifiedTopology.d.ts.map +1 -0
- package/dist/types/src/hooks/useSpinner.d.ts +1 -1
- package/dist/types/src/hooks/useSpinner.d.ts.map +1 -1
- package/dist/types/src/hooks/useTopology.d.ts +26 -0
- package/dist/types/src/hooks/useTopology.d.ts.map +1 -0
- package/dist/types/src/hooks/useTour.d.ts +3 -2
- package/dist/types/src/hooks/useTour.d.ts.map +1 -1
- package/dist/types/src/hooks/useWheel.d.ts +24 -0
- package/dist/types/src/hooks/useWheel.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +0 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +4 -4
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/animation.d.ts +16 -0
- package/dist/types/src/util/animation.d.ts.map +1 -0
- package/dist/types/src/util/debug.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +2 -0
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/inertia.d.ts.map +1 -1
- package/dist/types/src/util/path.d.ts.map +1 -1
- package/dist/types/src/util/render.d.ts +25 -1
- package/dist/types/src/util/render.d.ts.map +1 -1
- package/dist/types/src/util/styles.d.ts +4 -0
- package/dist/types/src/util/styles.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +26 -24
- package/src/components/Globe/Globe.stories.tsx +135 -58
- package/src/components/Globe/Globe.tsx +237 -120
- package/src/components/Map/Map.stories.tsx +58 -12
- package/src/components/Map/Map.tsx +293 -91
- package/src/components/Toolbar/Controls.tsx +1 -1
- package/src/data.ts +19 -2
- package/src/hooks/context.tsx +44 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useDrag.ts +33 -5
- package/src/hooks/useGlobeZoomHandler.ts +2 -1
- package/src/hooks/useSimplifiedTopology.ts +81 -0
- package/src/hooks/useSpinner.ts +1 -1
- package/src/hooks/useTopology.ts +95 -0
- package/src/hooks/useTour.ts +70 -81
- package/src/hooks/useWheel.ts +83 -0
- package/src/index.ts +0 -2
- package/src/util/animation.ts +35 -0
- package/src/util/index.ts +2 -0
- package/src/util/inertia.ts +87 -4
- package/src/util/render.ts +105 -16
- package/src/util/styles.ts +62 -0
- package/dist/lib/browser/chunk-GMWLKTLN.mjs +0 -9
- package/dist/lib/browser/chunk-GMWLKTLN.mjs.map +0 -7
- package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs +0 -37859
- package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-JODBF4CC.mjs +0 -11
- package/dist/lib/node-esm/chunk-JODBF4CC.mjs.map +0 -7
- package/dist/lib/node-esm/countries-110m-3SFASWVD.mjs +0 -37861
- package/dist/lib/node-esm/countries-110m-3SFASWVD.mjs.map +0 -7
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
type GeoProjection,
|
|
7
|
+
selection as d3Selection,
|
|
7
8
|
easeLinear,
|
|
8
9
|
easeSinOut,
|
|
9
10
|
geoMercator,
|
|
@@ -17,7 +18,9 @@ import { type ControlPosition } from 'leaflet';
|
|
|
17
18
|
import React, {
|
|
18
19
|
type PropsWithChildren,
|
|
19
20
|
forwardRef,
|
|
21
|
+
useCallback,
|
|
20
22
|
useEffect,
|
|
23
|
+
useId,
|
|
21
24
|
useImperativeHandle,
|
|
22
25
|
useMemo,
|
|
23
26
|
useRef,
|
|
@@ -34,13 +37,24 @@ import {
|
|
|
34
37
|
useDynamicRef,
|
|
35
38
|
useThemeContext,
|
|
36
39
|
} from '@dxos/react-ui';
|
|
37
|
-
import { composable, composableProps
|
|
40
|
+
import { composable, composableProps } from '@dxos/react-ui';
|
|
41
|
+
import { mx } from '@dxos/ui-theme';
|
|
38
42
|
|
|
39
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
GlobeContext,
|
|
45
|
+
type GlobeContextType,
|
|
46
|
+
type GlobeController,
|
|
47
|
+
type Point,
|
|
48
|
+
type Size,
|
|
49
|
+
type Vector,
|
|
50
|
+
useGlobeContext,
|
|
51
|
+
} from '../../hooks';
|
|
40
52
|
import {
|
|
41
53
|
type Features,
|
|
42
54
|
type StyleSet,
|
|
43
55
|
createLayers,
|
|
56
|
+
createRotationTween,
|
|
57
|
+
flyDuration,
|
|
44
58
|
geoToPosition,
|
|
45
59
|
positionToRotation,
|
|
46
60
|
renderLayers,
|
|
@@ -56,19 +70,15 @@ const defaultStyles: Record<ThemeMode, StyleSet> = {
|
|
|
56
70
|
background: {
|
|
57
71
|
fillStyle: '#EEE',
|
|
58
72
|
},
|
|
59
|
-
|
|
60
73
|
water: {
|
|
61
74
|
fillStyle: '#555',
|
|
62
75
|
},
|
|
63
|
-
|
|
64
76
|
land: {
|
|
65
77
|
fillStyle: '#999',
|
|
66
78
|
},
|
|
67
|
-
|
|
68
79
|
line: {
|
|
69
80
|
strokeStyle: 'darkred',
|
|
70
81
|
},
|
|
71
|
-
|
|
72
82
|
point: {
|
|
73
83
|
fillStyle: '#111111',
|
|
74
84
|
strokeStyle: '#111111',
|
|
@@ -80,19 +90,15 @@ const defaultStyles: Record<ThemeMode, StyleSet> = {
|
|
|
80
90
|
background: {
|
|
81
91
|
fillStyle: '#111111',
|
|
82
92
|
},
|
|
83
|
-
|
|
84
93
|
water: {
|
|
85
94
|
fillStyle: '#123E6A',
|
|
86
95
|
},
|
|
87
|
-
|
|
88
96
|
land: {
|
|
89
97
|
fillStyle: '#032153',
|
|
90
98
|
},
|
|
91
|
-
|
|
92
99
|
line: {
|
|
93
100
|
strokeStyle: '#111111',
|
|
94
101
|
},
|
|
95
|
-
|
|
96
102
|
point: {
|
|
97
103
|
fillStyle: '#111111',
|
|
98
104
|
strokeStyle: '#111111',
|
|
@@ -102,11 +108,6 @@ const defaultStyles: Record<ThemeMode, StyleSet> = {
|
|
|
102
108
|
},
|
|
103
109
|
};
|
|
104
110
|
|
|
105
|
-
export type GlobeController = {
|
|
106
|
-
canvas: HTMLCanvasElement;
|
|
107
|
-
projection: GeoProjection;
|
|
108
|
-
} & Pick<GlobeContextType, 'zoom' | 'translation' | 'rotation' | 'setZoom' | 'setTranslation' | 'setRotation'>;
|
|
109
|
-
|
|
110
111
|
export type ProjectionType = 'orthographic' | 'mercator' | 'transverse-mercator';
|
|
111
112
|
|
|
112
113
|
const projectionMap: Record<ProjectionType, () => GeoProjection> = {
|
|
@@ -128,44 +129,97 @@ const getProjection = (type: GlobeCanvasProps['projection'] = 'orthographic'): G
|
|
|
128
129
|
// Root
|
|
129
130
|
//
|
|
130
131
|
|
|
131
|
-
|
|
132
|
+
const DEFAULT_ZOOM = 1.5;
|
|
133
|
+
|
|
134
|
+
type GlobeRootProps = Partial<Pick<GlobeContextType, 'center' | 'zoom' | 'translation' | 'rotation'>> &
|
|
135
|
+
PropsWithChildren;
|
|
132
136
|
|
|
133
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Headless context/state provider for the globe. Renders no DOM; wrap a `Globe.Viewport` to mount
|
|
139
|
+
* the measured container. The current `GlobeController` (built by `Globe.Canvas`) is exposed via
|
|
140
|
+
* this component's `ref`.
|
|
141
|
+
*/
|
|
142
|
+
const GlobeRoot = forwardRef<GlobeController | null, GlobeRootProps>(
|
|
134
143
|
(
|
|
135
|
-
{
|
|
144
|
+
{
|
|
145
|
+
children,
|
|
146
|
+
center: centerProp,
|
|
147
|
+
zoom: zoomProp = DEFAULT_ZOOM,
|
|
148
|
+
translation: translationProp,
|
|
149
|
+
rotation: rotationProp,
|
|
150
|
+
},
|
|
136
151
|
forwardedRef,
|
|
137
152
|
) => {
|
|
138
|
-
const
|
|
139
|
-
const composedRef = useComposedRefs<HTMLDivElement>(localRef, forwardedRef);
|
|
140
|
-
const { width, height } = useResizeDetector<HTMLDivElement>({ targetRef: localRef });
|
|
141
|
-
|
|
153
|
+
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
|
|
142
154
|
const [center, setCenter] = useControlledState(centerProp);
|
|
143
|
-
const [zoom, setZoom] = useControlledState(zoomProp
|
|
155
|
+
const [zoom, setZoom] = useControlledState(zoomProp);
|
|
144
156
|
const [translation, setTranslation] = useControlledState<Point>(translationProp);
|
|
145
157
|
const [rotation, setRotation] = useControlledState<Vector>(rotationProp);
|
|
146
158
|
|
|
159
|
+
// The controller is built by Globe.Canvas and registered here; Globe.Root re-exposes it via its
|
|
160
|
+
// ref. Held in state (not a ref) so that when Globe.Canvas registers a new controller, Root
|
|
161
|
+
// re-renders and the imperative handle below updates — re-running consumer effects keyed on the
|
|
162
|
+
// controller (e.g. useDrag/useWheel) once the canvas mounts.
|
|
163
|
+
const [controller, setController] = useState<GlobeController | null>(null);
|
|
164
|
+
const registerController = useCallback((next: GlobeController | null) => setController(next), []);
|
|
165
|
+
|
|
166
|
+
// Expose the live controller (or null until Globe.Canvas mounts) via Root's ref.
|
|
167
|
+
useImperativeHandle(forwardedRef, () => controller, [controller]);
|
|
168
|
+
|
|
147
169
|
return (
|
|
148
170
|
<GlobeContext.Provider
|
|
149
171
|
value={{
|
|
150
|
-
size
|
|
172
|
+
size,
|
|
151
173
|
center,
|
|
152
174
|
zoom,
|
|
153
175
|
translation,
|
|
154
176
|
rotation,
|
|
177
|
+
setSize,
|
|
155
178
|
setCenter,
|
|
156
179
|
setZoom,
|
|
157
180
|
setTranslation,
|
|
158
181
|
setRotation,
|
|
182
|
+
registerController,
|
|
159
183
|
}}
|
|
160
184
|
>
|
|
161
|
-
|
|
162
|
-
{children}
|
|
163
|
-
</div>
|
|
185
|
+
{children}
|
|
164
186
|
</GlobeContext.Provider>
|
|
165
187
|
);
|
|
166
188
|
},
|
|
167
189
|
);
|
|
168
190
|
|
|
191
|
+
GlobeRoot.displayName = 'Globe.Root';
|
|
192
|
+
|
|
193
|
+
//
|
|
194
|
+
// Viewport
|
|
195
|
+
//
|
|
196
|
+
|
|
197
|
+
/** Consumer-facing props for `Globe.Viewport` (classNames + children). */
|
|
198
|
+
type GlobeViewportProps = ThemedClassName<PropsWithChildren>;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Measured container for the globe. Renders the `relative dx-container` div, observes its size, and
|
|
202
|
+
* publishes measurements to the context so `Globe.Canvas` can size the canvas.
|
|
203
|
+
*/
|
|
204
|
+
const GlobeViewport = composable<HTMLDivElement>(({ children, ...props }, forwardedRef) => {
|
|
205
|
+
const { setSize } = useGlobeContext();
|
|
206
|
+
const localRef = useRef<HTMLDivElement>(null);
|
|
207
|
+
const composedRef = useComposedRefs<HTMLDivElement>(localRef, forwardedRef);
|
|
208
|
+
const { width, height } = useResizeDetector<HTMLDivElement>({ targetRef: localRef });
|
|
209
|
+
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
setSize({ width: width ?? 0, height: height ?? 0 });
|
|
212
|
+
}, [width, height, setSize]);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div {...composableProps(props, { classNames: 'relative dx-container' })} ref={composedRef}>
|
|
216
|
+
{children}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
GlobeViewport.displayName = 'Globe.Viewport';
|
|
222
|
+
|
|
169
223
|
//
|
|
170
224
|
// Canvas
|
|
171
225
|
//
|
|
@@ -178,103 +232,165 @@ type GlobeCanvasProps = {
|
|
|
178
232
|
};
|
|
179
233
|
|
|
180
234
|
/**
|
|
181
|
-
* Basic globe renderer.
|
|
235
|
+
* Basic globe renderer. Builds the imperative `GlobeController` and registers it with `Globe.Root`
|
|
236
|
+
* (via `registerController` from context) so consumers reading the controller from Root's ref get
|
|
237
|
+
* the live instance.
|
|
182
238
|
* https://github.com/topojson/world-atlas
|
|
183
239
|
*/
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
},
|
|
226
|
-
translation,
|
|
227
|
-
rotation,
|
|
228
|
-
setCenter,
|
|
229
|
-
setZoom: (state) => {
|
|
230
|
-
if (typeof state === 'function') {
|
|
231
|
-
const is = interpolateNumber(zoomRef.current, state(zoomRef.current));
|
|
232
|
-
// Stop easing if already zooming.
|
|
233
|
-
transition()
|
|
234
|
-
.ease(zooming.current ? easeLinear : easeSinOut)
|
|
235
|
-
.duration(200)
|
|
236
|
-
.tween('scale', () => (t) => setZoom(is(t)))
|
|
237
|
-
.on('end', () => {
|
|
238
|
-
zooming.current = false;
|
|
239
|
-
});
|
|
240
|
-
} else {
|
|
241
|
-
setZoom(state);
|
|
242
|
-
}
|
|
243
|
-
},
|
|
244
|
-
setTranslation,
|
|
245
|
-
setRotation,
|
|
246
|
-
};
|
|
247
|
-
}, [canvas]);
|
|
248
|
-
|
|
249
|
-
// https://d3js.org/d3-geo/path#geoPath
|
|
250
|
-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext
|
|
251
|
-
const generator = useMemo(
|
|
252
|
-
() => canvas && projection && geoPath(projection, canvas.getContext('2d', { alpha: false })),
|
|
253
|
-
[canvas, projection],
|
|
254
|
-
);
|
|
240
|
+
const GlobeCanvas = ({ projection: projectionProp, topology, features, styles: stylesProp }: GlobeCanvasProps) => {
|
|
241
|
+
const { themeMode } = useThemeContext();
|
|
242
|
+
const styles = useMemo(() => stylesProp ?? defaultStyles[themeMode], [stylesProp, themeMode]);
|
|
243
|
+
const { size, center, zoom, translation, rotation, setZoom, setTranslation, setRotation, registerController } =
|
|
244
|
+
useGlobeContext();
|
|
245
|
+
|
|
246
|
+
const zoomRef = useDynamicRef(zoom);
|
|
247
|
+
|
|
248
|
+
// Canvas.
|
|
249
|
+
const [canvas, setCanvas] = useState<HTMLCanvasElement>(null);
|
|
250
|
+
const canvasRef = (canvas: HTMLCanvasElement) => setCanvas(canvas);
|
|
251
|
+
|
|
252
|
+
// Projection.
|
|
253
|
+
const projection = useMemo(() => getProjection(projectionProp), [projectionProp]);
|
|
254
|
+
|
|
255
|
+
// Layers.
|
|
256
|
+
// TODO(burdon): Generate on-the-fly based on what is visible.
|
|
257
|
+
const layers = useMemo(() => {
|
|
258
|
+
return timer(() => createLayers(topology as Topology, features, styles));
|
|
259
|
+
}, [topology, features, styles]);
|
|
260
|
+
|
|
261
|
+
// Update rotation when the center changes. Preserve current zoom — callers can set zoom
|
|
262
|
+
// independently via the `zoom` prop or `setZoom` on the controller.
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (center) {
|
|
265
|
+
setRotation(positionToRotation(geoToPosition(center)));
|
|
266
|
+
}
|
|
267
|
+
}, [center]);
|
|
268
|
+
|
|
269
|
+
// Per-instance flyTo plumbing. d3 named transitions are scoped per DOM
|
|
270
|
+
// element and `d3Selection()` returns the documentElement root, so a
|
|
271
|
+
// shared name would let one globe's flyTo interrupt another's. The
|
|
272
|
+
// useId-scoped name keeps each Globe.Canvas's transition isolated.
|
|
273
|
+
const flyToSelection = useMemo(() => d3Selection(), []);
|
|
274
|
+
const flyToTransitionName = `globe-fly-to-${useId()}`;
|
|
275
|
+
useEffect(
|
|
276
|
+
() => () => {
|
|
277
|
+
flyToSelection.interrupt(flyToTransitionName);
|
|
278
|
+
},
|
|
279
|
+
[flyToSelection, flyToTransitionName],
|
|
280
|
+
);
|
|
255
281
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
282
|
+
// External controller.
|
|
283
|
+
const zooming = useRef(false);
|
|
284
|
+
const controller = useMemo<GlobeController>(() => {
|
|
285
|
+
return {
|
|
286
|
+
canvas,
|
|
287
|
+
projection,
|
|
288
|
+
get zoom() {
|
|
289
|
+
return zoomRef.current;
|
|
290
|
+
},
|
|
291
|
+
translation,
|
|
292
|
+
rotation,
|
|
293
|
+
setZoom: (state) => {
|
|
294
|
+
if (typeof state === 'function') {
|
|
295
|
+
const is = interpolateNumber(zoomRef.current, state(zoomRef.current));
|
|
296
|
+
// Stop easing if already zooming.
|
|
297
|
+
transition()
|
|
298
|
+
.ease(zooming.current ? easeLinear : easeSinOut)
|
|
299
|
+
.duration(200)
|
|
300
|
+
.tween('scale', () => (t) => setZoom(is(t)))
|
|
301
|
+
.on('end', () => {
|
|
302
|
+
zooming.current = false;
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
setZoom(state);
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
setTranslation,
|
|
309
|
+
setRotation,
|
|
310
|
+
flyTo: (target, options = {}) => {
|
|
311
|
+
const { duration = 1_200, tilt = 0, onTick } = options;
|
|
312
|
+
const p2 = geoToPosition(target);
|
|
313
|
+
const r1 = projection.rotate() as Vector;
|
|
314
|
+
const r2 = positionToRotation(p2, tilt);
|
|
315
|
+
|
|
316
|
+
// Approximate current centre from the inverse of the rotation.
|
|
317
|
+
const p1: [number, number] = [-r1[0], -r1[1]];
|
|
318
|
+
const rotationTween = createRotationTween(projection, setRotation, r1, r2);
|
|
319
|
+
const iz = target.zoom !== undefined ? interpolateNumber(zoomRef.current, target.zoom) : undefined;
|
|
320
|
+
|
|
321
|
+
flyToSelection.interrupt(flyToTransitionName);
|
|
322
|
+
const tx = flyToSelection.transition(flyToTransitionName).duration(flyDuration(p1, p2, duration, 1_500));
|
|
323
|
+
if (onTick) {
|
|
324
|
+
tx.tween('flyToOnTick', () => onTick);
|
|
325
|
+
}
|
|
326
|
+
tx.tween('flyToRotation', () => rotationTween);
|
|
327
|
+
if (iz) {
|
|
328
|
+
tx.tween('flyToZoom', () => (t: number) => setZoom(iz(t)));
|
|
329
|
+
}
|
|
330
|
+
return tx.end();
|
|
331
|
+
},
|
|
332
|
+
cancelFlyTo: () => {
|
|
333
|
+
flyToSelection.interrupt(flyToTransitionName);
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
// Keep the controller IDENTITY stable: only rebuild on these mount-stable deps. Deliberately
|
|
337
|
+
// exclude `center`/`translation`/`rotation` — they are read here as closure snapshots, and
|
|
338
|
+
// callers pass them as inline literals (e.g. `rotation={[0, 0, 0]}`), so a fresh reference every
|
|
339
|
+
// render would rebuild the controller, re-fire `registerController`, re-render Root, hand a new
|
|
340
|
+
// controller back through the consumer's `ref={setController}`, and loop forever. `projection`
|
|
341
|
+
// must stay in deps: switching between stories that pass different projection types creates a new
|
|
342
|
+
// instance via useMemo, and any consumer reading controller.projection (e.g. useDrag) would
|
|
343
|
+
// otherwise mutate a dead instance while the canvas renders the new one. The `useControlledState`
|
|
344
|
+
// setters and `zoomRef` are stable, so they need not be listed.
|
|
345
|
+
}, [canvas, projection, flyToSelection, flyToTransitionName]);
|
|
346
|
+
|
|
347
|
+
// Register the controller with Globe.Root and clear it on unmount.
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
registerController(controller);
|
|
350
|
+
return () => registerController(null);
|
|
351
|
+
}, [registerController, controller]);
|
|
352
|
+
|
|
353
|
+
// https://d3js.org/d3-geo/path#geoPath
|
|
354
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext
|
|
355
|
+
// Keep the context alpha-enabled: when a style set omits `background`, `renderLayers`
|
|
356
|
+
// clears to transparent so the canvas's themed CSS background (below) shows through the
|
|
357
|
+
// area outside the globe — correct in both light and dark mode.
|
|
358
|
+
const generator = useMemo(
|
|
359
|
+
() => canvas && projection && geoPath(projection, canvas.getContext('2d')),
|
|
360
|
+
[canvas, projection],
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// Render on change.
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
if (canvas && projection) {
|
|
366
|
+
timer(() => {
|
|
367
|
+
// https://d3js.org/d3-geo/projection
|
|
368
|
+
projection
|
|
369
|
+
.scale((Math.min(size.width, size.height) / 2) * zoom)
|
|
370
|
+
.translate([size.width / 2 + (translation?.x ?? 0), size.height / 2 + (translation?.y ?? 0)])
|
|
371
|
+
.rotate(rotation ?? [0, 0, 0]);
|
|
372
|
+
|
|
373
|
+
// Provide a view-center for per-frame culling — only meaningful for
|
|
374
|
+
// projections that present a single visible hemisphere (e.g.
|
|
375
|
+
// orthographic). For Mercator/transverse-mercator the whole sphere
|
|
376
|
+
// is always visible, so we skip culling.
|
|
377
|
+
const isOrthographic = !projectionProp || projectionProp === 'orthographic';
|
|
378
|
+
const [lambda, phi] = (rotation ?? [0, 0, 0]) as Vector;
|
|
379
|
+
const viewCenter: [number, number] | undefined = isOrthographic ? [-lambda, -phi] : undefined;
|
|
380
|
+
|
|
381
|
+
renderLayers(generator, layers, zoom, styles, viewCenter);
|
|
382
|
+
});
|
|
273
383
|
}
|
|
384
|
+
}, [generator, size, zoom, translation, rotation, layers, projectionProp]);
|
|
274
385
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
386
|
+
if (!size.width || !size.height) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return <canvas ref={canvasRef} className='bg-base-surface' width={size.width} height={size.height} />;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
GlobeCanvas.displayName = 'Globe.Canvas';
|
|
278
394
|
|
|
279
395
|
//
|
|
280
396
|
// Debug
|
|
@@ -336,6 +452,7 @@ const GlobeAction = ({ onAction, position = 'bottomright', ...props }: GlobeCont
|
|
|
336
452
|
|
|
337
453
|
export const Globe = {
|
|
338
454
|
Root: GlobeRoot,
|
|
455
|
+
Viewport: GlobeViewport,
|
|
339
456
|
Canvas: GlobeCanvas,
|
|
340
457
|
Zoom: GlobeZoom,
|
|
341
458
|
Action: GlobeAction,
|
|
@@ -343,4 +460,4 @@ export const Globe = {
|
|
|
343
460
|
Panel: GlobePanel,
|
|
344
461
|
};
|
|
345
462
|
|
|
346
|
-
export type { GlobeRootProps, GlobeCanvasProps };
|
|
463
|
+
export type { GlobeRootProps, GlobeViewportProps, GlobeCanvasProps };
|
|
@@ -3,27 +3,54 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
-
import React, { useState } from 'react';
|
|
6
|
+
import React, { useMemo, useState } from 'react';
|
|
7
7
|
|
|
8
|
+
import { Input, Panel, Toolbar } from '@dxos/react-ui';
|
|
8
9
|
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
9
10
|
|
|
10
11
|
import { useMapZoomHandler } from '../../hooks';
|
|
11
12
|
import { type GeoMarker } from '../../types';
|
|
12
|
-
import { Map, type MapController } from './Map';
|
|
13
|
+
import { Map, MapMarkersProps, MapTilesProps, type MapController } from './Map';
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
type DefaultStoryProps = Pick<MapTilesProps, 'url'> & Pick<MapMarkersProps, 'markers'>;
|
|
16
|
+
|
|
17
|
+
const DefaultStory = ({ url: urlProp, markers = [] }: DefaultStoryProps) => {
|
|
15
18
|
const [controller, setController] = useState<MapController>();
|
|
19
|
+
const [key, setKey] = useState('');
|
|
20
|
+
// Substitute the `${key}` placeholder in a keyed tile URL (e.g. MapTiler); undefined → default OSM tiles.
|
|
21
|
+
const url = useMemo(() => urlProp?.replace('${key}', key), [urlProp, key]);
|
|
22
|
+
|
|
16
23
|
const handleZoomAction = useMapZoomHandler(controller);
|
|
17
24
|
|
|
18
25
|
return (
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
<Panel.Root>
|
|
27
|
+
{urlProp && (
|
|
28
|
+
<Panel.Toolbar asChild>
|
|
29
|
+
<Toolbar.Root>
|
|
30
|
+
<Input.Root>
|
|
31
|
+
<Input.TextInput
|
|
32
|
+
spellCheck={false}
|
|
33
|
+
placeholder='API KEY'
|
|
34
|
+
value={key}
|
|
35
|
+
onChange={(ev) => setKey(ev.target.value)}
|
|
36
|
+
/>
|
|
37
|
+
</Input.Root>
|
|
38
|
+
</Toolbar.Root>
|
|
39
|
+
</Panel.Toolbar>
|
|
40
|
+
)}
|
|
41
|
+
{/* Map.Root is headless (context only), so it sits outside Panel.Content; Panel.Content asChild
|
|
42
|
+
then targets the Leaflet frame (Map.Viewport) directly — no extra wrapper element. */}
|
|
43
|
+
<Map.Root ref={setController}>
|
|
44
|
+
<Panel.Content asChild>
|
|
45
|
+
<Map.Viewport>
|
|
46
|
+
<Map.Tiles url={url} />
|
|
47
|
+
<Map.Markers markers={markers} />
|
|
48
|
+
<Map.Zoom position='bottomleft' onAction={handleZoomAction} />
|
|
49
|
+
<Map.Action position='bottomright' />
|
|
50
|
+
</Map.Viewport>
|
|
51
|
+
</Panel.Content>
|
|
52
|
+
</Map.Root>
|
|
53
|
+
</Panel.Root>
|
|
27
54
|
);
|
|
28
55
|
};
|
|
29
56
|
|
|
@@ -43,7 +70,17 @@ type Story = StoryObj<typeof meta>;
|
|
|
43
70
|
|
|
44
71
|
export const Default: Story = {};
|
|
45
72
|
|
|
46
|
-
export const
|
|
73
|
+
export const Bounds: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
markers: [
|
|
76
|
+
{ id: 'london', title: 'London', location: { lat: 51.5074, lng: -0.1278 } },
|
|
77
|
+
{ id: 'barcelona', title: 'Barcelona', location: { lat: 41.3851, lng: 2.1734 } },
|
|
78
|
+
{ id: 'warsaw', title: 'Warsaw', location: { lat: 52.2297, lng: 21.0122 } },
|
|
79
|
+
] as GeoMarker[],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const Markers: Story = {
|
|
47
84
|
args: {
|
|
48
85
|
markers: [
|
|
49
86
|
{ id: 'los angeles', title: 'Los Angeles', location: { lat: 34.0522, lng: -118.2437 } },
|
|
@@ -71,3 +108,12 @@ export const WithMarkers: Story = {
|
|
|
71
108
|
] as GeoMarker[],
|
|
72
109
|
},
|
|
73
110
|
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* https://docs.maptiler.com/leaflet
|
|
114
|
+
*/
|
|
115
|
+
export const CustomTiles: Story = {
|
|
116
|
+
args: {
|
|
117
|
+
url: 'https://api.maptiler.com/maps/streets-v4/{z}/{x}/{y}.png?&key=${key}',
|
|
118
|
+
},
|
|
119
|
+
};
|