@dxos/react-ui-geo 0.7.5-labs.071a3e2
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 +8 -0
- package/README.md +3 -0
- package/data/airports.ts +52524 -0
- package/data/cities.ts +1211 -0
- package/data/countries-110m.ts +10587 -0
- package/data/countries-dots-3.ts +42989 -0
- package/data/countries-dots-4.ts +300941 -0
- package/data/raw/airports.json +39386 -0
- package/data/raw/countries-10m.json +1 -0
- package/data/raw/countries-110m.json +1 -0
- package/data/raw/countries-50m.json +1 -0
- package/data/raw/countries.json +1 -0
- package/dist/lib/browser/chunk-ENCWOTYX.mjs +9 -0
- package/dist/lib/browser/chunk-ENCWOTYX.mjs.map +7 -0
- package/dist/lib/browser/countries-110m-WI4PCLDF.mjs +37859 -0
- package/dist/lib/browser/countries-110m-WI4PCLDF.mjs.map +7 -0
- package/dist/lib/browser/data.mjs +7 -0
- package/dist/lib/browser/data.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +1020 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/node/chunk-LAICG6L2.cjs +40 -0
- package/dist/lib/node/chunk-LAICG6L2.cjs.map +7 -0
- package/dist/lib/node/countries-110m-KQ5WAB2O.cjs +37877 -0
- package/dist/lib/node/countries-110m-KQ5WAB2O.cjs.map +7 -0
- package/dist/lib/node/data.cjs +28 -0
- package/dist/lib/node/data.cjs.map +7 -0
- package/dist/lib/node/index.cjs +1045 -0
- package/dist/lib/node/index.cjs.map +7 -0
- package/dist/lib/node/meta.json +1 -0
- package/dist/lib/node-esm/chunk-PIIEDZEU.mjs +11 -0
- package/dist/lib/node-esm/chunk-PIIEDZEU.mjs.map +7 -0
- package/dist/lib/node-esm/countries-110m-DQ4XRC4B.mjs +37861 -0
- package/dist/lib/node-esm/countries-110m-DQ4XRC4B.mjs.map +7 -0
- package/dist/lib/node-esm/data.mjs +8 -0
- package/dist/lib/node-esm/data.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +1021 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/types/data/airports.d.ts +18 -0
- package/dist/types/data/airports.d.ts.map +1 -0
- package/dist/types/data/cities.d.ts +17 -0
- package/dist/types/data/cities.d.ts.map +1 -0
- package/dist/types/data/countries-110m.d.ts +36 -0
- package/dist/types/data/countries-110m.d.ts.map +1 -0
- package/dist/types/data/countries-dots-3.d.ts +9 -0
- package/dist/types/data/countries-dots-3.d.ts.map +1 -0
- package/dist/types/data/countries-dots-4.d.ts +9 -0
- package/dist/types/data/countries-dots-4.d.ts.map +1 -0
- package/dist/types/src/components/Globe/Globe.d.ts +37 -0
- package/dist/types/src/components/Globe/Globe.d.ts.map +1 -0
- package/dist/types/src/components/Globe/Globe.stories.d.ts +15 -0
- package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -0
- package/dist/types/src/components/Globe/index.d.ts +2 -0
- package/dist/types/src/components/Globe/index.d.ts.map +1 -0
- package/dist/types/src/components/Map/Map.d.ts +34 -0
- package/dist/types/src/components/Map/Map.d.ts.map +1 -0
- package/dist/types/src/components/Map/Map.stories.d.ts +7 -0
- package/dist/types/src/components/Map/Map.stories.d.ts.map +1 -0
- package/dist/types/src/components/Map/index.d.ts +2 -0
- package/dist/types/src/components/Map/index.d.ts.map +1 -0
- package/dist/types/src/components/Toolbar/Controls.d.ts +11 -0
- package/dist/types/src/components/Toolbar/Controls.d.ts.map +1 -0
- package/dist/types/src/components/Toolbar/index.d.ts +2 -0
- package/dist/types/src/components/Toolbar/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/components/types.d.ts +14 -0
- package/dist/types/src/components/types.d.ts.map +1 -0
- package/dist/types/src/data.d.ts +3 -0
- package/dist/types/src/data.d.ts.map +1 -0
- package/dist/types/src/hooks/context.d.ts +26 -0
- package/dist/types/src/hooks/context.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +7 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useDrag.d.ts +16 -0
- package/dist/types/src/hooks/useDrag.d.ts.map +1 -0
- package/dist/types/src/hooks/useGlobeZoomHandler.d.ts +3 -0
- package/dist/types/src/hooks/useGlobeZoomHandler.d.ts.map +1 -0
- package/dist/types/src/hooks/useMapZoomHandler.d.ts +3 -0
- package/dist/types/src/hooks/useMapZoomHandler.d.ts.map +1 -0
- package/dist/types/src/hooks/useSpinner.d.ts +11 -0
- package/dist/types/src/hooks/useSpinner.d.ts.map +1 -0
- package/dist/types/src/hooks/useTour.d.ts +13 -0
- package/dist/types/src/hooks/useTour.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +7 -0
- package/dist/types/src/types.d.ts.map +1 -0
- package/dist/types/src/util/debug.d.ts +2 -0
- package/dist/types/src/util/debug.d.ts.map +1 -0
- package/dist/types/src/util/index.d.ts +5 -0
- package/dist/types/src/util/index.d.ts.map +1 -0
- package/dist/types/src/util/inertia.d.ts +16 -0
- package/dist/types/src/util/inertia.d.ts.map +1 -0
- package/dist/types/src/util/path.d.ts +15 -0
- package/dist/types/src/util/path.d.ts.map +1 -0
- package/dist/types/src/util/render.d.ts +26 -0
- package/dist/types/src/util/render.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +83 -0
- package/src/components/Globe/Globe.stories.tsx +318 -0
- package/src/components/Globe/Globe.tsx +270 -0
- package/src/components/Globe/index.ts +5 -0
- package/src/components/Map/Map.stories.tsx +39 -0
- package/src/components/Map/Map.tsx +203 -0
- package/src/components/Map/index.ts +5 -0
- package/src/components/Toolbar/Controls.tsx +71 -0
- package/src/components/Toolbar/index.ts +5 -0
- package/src/components/index.ts +9 -0
- package/src/components/types.ts +18 -0
- package/src/data.ts +9 -0
- package/src/hooks/context.tsx +59 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/useDrag.ts +55 -0
- package/src/hooks/useGlobeZoomHandler.ts +29 -0
- package/src/hooks/useMapZoomHandler.ts +29 -0
- package/src/hooks/useSpinner.ts +69 -0
- package/src/hooks/useTour.ts +114 -0
- package/src/index.ts +9 -0
- package/src/types.ts +11 -0
- package/src/util/debug.ts +16 -0
- package/src/util/index.ts +8 -0
- package/src/util/inertia.ts +197 -0
- package/src/util/path.ts +56 -0
- package/src/util/render.ts +149 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2018 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as d3 from 'd3';
|
|
6
|
+
import { type GeoProjection } from 'd3';
|
|
7
|
+
import { type ControlPosition } from 'leaflet';
|
|
8
|
+
import React, {
|
|
9
|
+
type PropsWithChildren,
|
|
10
|
+
forwardRef,
|
|
11
|
+
useEffect,
|
|
12
|
+
useImperativeHandle,
|
|
13
|
+
useMemo,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import { useResizeDetector } from 'react-resize-detector';
|
|
18
|
+
import { type Topology } from 'topojson-specification';
|
|
19
|
+
|
|
20
|
+
import { type ThemedClassName, useDynamicRef } from '@dxos/react-ui';
|
|
21
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
GlobeContextProvider,
|
|
25
|
+
type GlobeContextProviderProps,
|
|
26
|
+
type GlobeContextType,
|
|
27
|
+
useGlobeContext,
|
|
28
|
+
} from '../../hooks';
|
|
29
|
+
import {
|
|
30
|
+
type Features,
|
|
31
|
+
type Styles,
|
|
32
|
+
type StyleSet,
|
|
33
|
+
createLayers,
|
|
34
|
+
geoToPosition,
|
|
35
|
+
positionToRotation,
|
|
36
|
+
renderLayers,
|
|
37
|
+
timer,
|
|
38
|
+
} from '../../util';
|
|
39
|
+
import { ZoomControls, ActionControls, type ControlProps, controlPositions } from '../Toolbar';
|
|
40
|
+
|
|
41
|
+
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute
|
|
42
|
+
const defaultStyles: Styles = {
|
|
43
|
+
background: {
|
|
44
|
+
fillStyle: '#111111',
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
water: {
|
|
48
|
+
fillStyle: '#123E6A',
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
hex: {
|
|
52
|
+
strokeStyle: 'green',
|
|
53
|
+
fillStyle: 'gray',
|
|
54
|
+
pointRadius: 1,
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
land: {
|
|
58
|
+
fillStyle: '#032153',
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
line: {
|
|
62
|
+
strokeStyle: '#111111',
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
point: {
|
|
66
|
+
fillStyle: '#111111',
|
|
67
|
+
strokeStyle: '#111111',
|
|
68
|
+
strokeWidth: 1,
|
|
69
|
+
pointRadius: 0.5,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type GlobeController = {
|
|
74
|
+
canvas: HTMLCanvasElement;
|
|
75
|
+
projection: GeoProjection;
|
|
76
|
+
} & Pick<GlobeContextType, 'scale' | 'translation' | 'rotation' | 'setScale' | 'setTranslation' | 'setRotation'>;
|
|
77
|
+
|
|
78
|
+
export type ProjectionType = 'orthographic' | 'mercator' | 'transverse-mercator';
|
|
79
|
+
|
|
80
|
+
const projectionMap: Record<ProjectionType, () => GeoProjection> = {
|
|
81
|
+
orthographic: d3.geoOrthographic,
|
|
82
|
+
mercator: d3.geoMercator,
|
|
83
|
+
'transverse-mercator': d3.geoTransverseMercator,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const getProjection = (type: GlobeCanvasProps['projection'] = 'orthographic'): GeoProjection => {
|
|
87
|
+
if (typeof type === 'string') {
|
|
88
|
+
const constructor = projectionMap[type] ?? d3.geoOrthographic;
|
|
89
|
+
return constructor();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return type ?? d3.geoOrthographic();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
//
|
|
96
|
+
// Root
|
|
97
|
+
//
|
|
98
|
+
|
|
99
|
+
type GlobeRootProps = PropsWithChildren<ThemedClassName<GlobeContextProviderProps>>;
|
|
100
|
+
|
|
101
|
+
const GlobeRoot = ({ classNames, children, ...props }: GlobeRootProps) => {
|
|
102
|
+
const { ref, width, height } = useResizeDetector<HTMLDivElement>();
|
|
103
|
+
return (
|
|
104
|
+
<div ref={ref} className={mx('relative flex grow overflow-hidden', classNames)}>
|
|
105
|
+
<GlobeContextProvider size={{ width, height }} {...props}>
|
|
106
|
+
{children}
|
|
107
|
+
</GlobeContextProvider>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
//
|
|
113
|
+
// Canvas
|
|
114
|
+
//
|
|
115
|
+
|
|
116
|
+
type GlobeCanvasProps = {
|
|
117
|
+
projection?: ProjectionType | GeoProjection;
|
|
118
|
+
topology?: Topology;
|
|
119
|
+
features?: Features;
|
|
120
|
+
styles?: StyleSet;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Basic globe renderer.
|
|
125
|
+
* https://github.com/topojson/world-atlas
|
|
126
|
+
*/
|
|
127
|
+
const GlobeCanvas = forwardRef<GlobeController, GlobeCanvasProps>(
|
|
128
|
+
({ projection: _projection, topology, features, styles = defaultStyles }, forwardRef) => {
|
|
129
|
+
// Canvas.
|
|
130
|
+
const [canvas, setCanvas] = useState<HTMLCanvasElement>(null);
|
|
131
|
+
const canvasRef = (canvas: HTMLCanvasElement) => setCanvas(canvas);
|
|
132
|
+
|
|
133
|
+
// Projection.
|
|
134
|
+
const projection = useMemo(() => getProjection(_projection), [_projection]);
|
|
135
|
+
|
|
136
|
+
// Layers.
|
|
137
|
+
// TODO(burdon): Generate on the fly based on what is visible.
|
|
138
|
+
const layers = useMemo(() => {
|
|
139
|
+
return timer(() => createLayers(topology as Topology, features, styles));
|
|
140
|
+
}, [topology, features, styles]);
|
|
141
|
+
|
|
142
|
+
// State.
|
|
143
|
+
const { size, center, scale, translation, rotation, setCenter, setScale, setTranslation, setRotation } =
|
|
144
|
+
useGlobeContext();
|
|
145
|
+
|
|
146
|
+
const scaleRef = useDynamicRef(scale);
|
|
147
|
+
|
|
148
|
+
// Update rotation.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (center) {
|
|
151
|
+
setScale(1);
|
|
152
|
+
setRotation(positionToRotation(geoToPosition(center)));
|
|
153
|
+
}
|
|
154
|
+
}, [center]);
|
|
155
|
+
|
|
156
|
+
// External controller.
|
|
157
|
+
const zooming = useRef(false);
|
|
158
|
+
useImperativeHandle<GlobeController, GlobeController>(
|
|
159
|
+
forwardRef,
|
|
160
|
+
() => {
|
|
161
|
+
return {
|
|
162
|
+
canvas,
|
|
163
|
+
projection,
|
|
164
|
+
center,
|
|
165
|
+
get scale() {
|
|
166
|
+
return scaleRef.current;
|
|
167
|
+
},
|
|
168
|
+
translation,
|
|
169
|
+
rotation,
|
|
170
|
+
setCenter,
|
|
171
|
+
setScale: (s) => {
|
|
172
|
+
if (typeof s === 'function') {
|
|
173
|
+
const is = d3.interpolateNumber(scaleRef.current, s(scaleRef.current));
|
|
174
|
+
// Stop easing if already zooming.
|
|
175
|
+
d3.transition()
|
|
176
|
+
.ease(zooming.current ? d3.easeLinear : d3.easeSinOut)
|
|
177
|
+
.duration(200)
|
|
178
|
+
.tween('scale', () => (t) => setScale(is(t)))
|
|
179
|
+
.on('end', () => {
|
|
180
|
+
zooming.current = false;
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
setScale(s);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
setTranslation,
|
|
187
|
+
setRotation,
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
[canvas],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// https://d3js.org/d3-geo/path#geoPath
|
|
194
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/getContext
|
|
195
|
+
const generator = useMemo(
|
|
196
|
+
() => canvas && projection && d3.geoPath(projection, canvas.getContext('2d', { alpha: false })),
|
|
197
|
+
[canvas, projection],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Render on change.
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (canvas && projection) {
|
|
203
|
+
timer(() => {
|
|
204
|
+
// https://d3js.org/d3-geo/projection
|
|
205
|
+
projection
|
|
206
|
+
.scale((Math.min(size.width, size.height) / 2) * scale)
|
|
207
|
+
.translate([size.width / 2 + (translation?.x ?? 0), size.height / 2 + (translation?.y ?? 0)])
|
|
208
|
+
.rotate(rotation ?? [0, 0, 0]);
|
|
209
|
+
|
|
210
|
+
renderLayers(generator, layers, scale);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}, [generator, size, scale, translation, rotation, layers]);
|
|
214
|
+
|
|
215
|
+
if (!size.width || !size.height) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return <canvas ref={canvasRef} width={size.width} height={size.height} />;
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const GlobeDebug = ({ position = 'topleft' }: { position?: ControlPosition }) => {
|
|
224
|
+
const { size, scale, translation, rotation } = useGlobeContext();
|
|
225
|
+
return (
|
|
226
|
+
<div
|
|
227
|
+
className={mx(
|
|
228
|
+
'z-10 absolute w-96 p-2 overflow-hidden border border-green-700 rounded',
|
|
229
|
+
controlPositions[position],
|
|
230
|
+
)}
|
|
231
|
+
>
|
|
232
|
+
<pre className='font-mono text-xs text-green-700'>
|
|
233
|
+
{JSON.stringify({ size, scale, translation, rotation }, null, 2)}
|
|
234
|
+
</pre>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const GlobePanel = ({
|
|
240
|
+
position,
|
|
241
|
+
classNames,
|
|
242
|
+
children,
|
|
243
|
+
}: ThemedClassName<PropsWithChildren & { position?: ControlPosition }>) => {
|
|
244
|
+
return <div className={mx('z-10 absolute overflow-hidden', controlPositions[position], classNames)}>{children}</div>;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const CustomControl = ({ position, children }: PropsWithChildren<{ position: ControlPosition }>) => {
|
|
248
|
+
return <div className={mx('z-10 absolute overflow-hidden', controlPositions[position])}>{children}</div>;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
type GlobeControlProps = { position?: ControlPosition } & Pick<ControlProps, 'onAction'>;
|
|
252
|
+
|
|
253
|
+
export const Globe = {
|
|
254
|
+
Root: GlobeRoot,
|
|
255
|
+
Canvas: GlobeCanvas,
|
|
256
|
+
Zoom: ({ onAction, position = 'bottomleft', ...props }: GlobeControlProps) => (
|
|
257
|
+
<CustomControl position={position} {...props}>
|
|
258
|
+
<ZoomControls onAction={onAction} />
|
|
259
|
+
</CustomControl>
|
|
260
|
+
),
|
|
261
|
+
Action: ({ onAction, position = 'bottomright', ...props }: GlobeControlProps) => (
|
|
262
|
+
<CustomControl position={position} {...props}>
|
|
263
|
+
<ActionControls onAction={onAction} />
|
|
264
|
+
</CustomControl>
|
|
265
|
+
),
|
|
266
|
+
Debug: GlobeDebug,
|
|
267
|
+
Panel: GlobePanel,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export type { GlobeRootProps, GlobeCanvasProps };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxos-theme';
|
|
6
|
+
|
|
7
|
+
import { type StoryObj, type Meta } from '@storybook/react';
|
|
8
|
+
import React, { useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { withLayout, withTheme } from '@dxos/storybook-utils';
|
|
11
|
+
|
|
12
|
+
import { Map, type MapController } from './Map';
|
|
13
|
+
import { useMapZoomHandler } from '../../hooks';
|
|
14
|
+
|
|
15
|
+
const Render = () => {
|
|
16
|
+
const [controller, setController] = useState<MapController>();
|
|
17
|
+
const handleZoomAction = useMapZoomHandler(controller);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Map.Root>
|
|
21
|
+
<Map.Canvas ref={setController} />
|
|
22
|
+
<Map.Zoom position='bottomleft' onAction={handleZoomAction} />
|
|
23
|
+
<Map.Action position='bottomright' />
|
|
24
|
+
</Map.Root>
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const meta: Meta = {
|
|
29
|
+
title: 'ui/react-ui-geo/Map',
|
|
30
|
+
component: Map.Root,
|
|
31
|
+
render: Render,
|
|
32
|
+
decorators: [withTheme, withLayout({ fullscreen: true, tooltips: true })],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default meta;
|
|
36
|
+
|
|
37
|
+
type Story = StoryObj;
|
|
38
|
+
|
|
39
|
+
export const Default: Story = {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line no-restricted-imports
|
|
6
|
+
import 'leaflet/dist/leaflet.css';
|
|
7
|
+
|
|
8
|
+
import type L from 'leaflet';
|
|
9
|
+
import { type ControlPosition, Control, DomEvent, DomUtil, type LatLngExpression, latLngBounds } from 'leaflet';
|
|
10
|
+
import React, { forwardRef, type PropsWithChildren, useEffect, useImperativeHandle } from 'react';
|
|
11
|
+
import { createRoot } from 'react-dom/client';
|
|
12
|
+
import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet';
|
|
13
|
+
import { type MapContainerProps } from 'react-leaflet/lib/MapContainer';
|
|
14
|
+
import { useResizeDetector } from 'react-resize-detector';
|
|
15
|
+
|
|
16
|
+
import { debounce } from '@dxos/async';
|
|
17
|
+
import { Tooltip, ThemeProvider, type ThemedClassName } from '@dxos/react-ui';
|
|
18
|
+
import { defaultTx, mx } from '@dxos/react-ui-theme';
|
|
19
|
+
|
|
20
|
+
import { ActionControls, controlPositions, ZoomControls, type ControlProps } from '../Toolbar';
|
|
21
|
+
import { type MapCanvasProps } from '../types';
|
|
22
|
+
|
|
23
|
+
// TODO(burdon): Explore plugins: https://www.npmjs.com/search?q=keywords%3Areact-leaflet-v4
|
|
24
|
+
// TODO(burdon): react-leaflet v5 is not compatible with react 18.
|
|
25
|
+
|
|
26
|
+
const defaults = {
|
|
27
|
+
// TODO(burdon): Guess location.
|
|
28
|
+
center: { lat: 51, lng: 0 } as L.LatLngExpression,
|
|
29
|
+
zoom: 4,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
//
|
|
33
|
+
// Root
|
|
34
|
+
//
|
|
35
|
+
|
|
36
|
+
type MapRootProps = ThemedClassName<MapContainerProps>;
|
|
37
|
+
|
|
38
|
+
// https://react-leaflet.js.org/docs/api-map
|
|
39
|
+
const MapRoot = ({ classNames, center = defaults.center, zoom = defaults.zoom, ...props }: MapRootProps) => {
|
|
40
|
+
return (
|
|
41
|
+
<MapContainer
|
|
42
|
+
className={mx('relative flex w-full h-full grow bg-baseSurface', classNames)}
|
|
43
|
+
attributionControl={false}
|
|
44
|
+
// TODO(burdon): Only if attention.
|
|
45
|
+
scrollWheelZoom={true}
|
|
46
|
+
zoomControl={false}
|
|
47
|
+
center={center}
|
|
48
|
+
zoom={zoom}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
//
|
|
55
|
+
// Control
|
|
56
|
+
//
|
|
57
|
+
|
|
58
|
+
// TODO(burdon): Normalize with Globe.
|
|
59
|
+
type MapController = {
|
|
60
|
+
setCenter: (center: LatLngExpression, zoom?: number) => void;
|
|
61
|
+
setZoom: (cb: (zoom: number) => number) => void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const MapCanvas = forwardRef<MapController, MapCanvasProps>(
|
|
65
|
+
({ markers = [], center, zoom, onChange }, forwardedRef) => {
|
|
66
|
+
const { ref, width, height } = useResizeDetector({ refreshRate: 200 });
|
|
67
|
+
const map = useMap();
|
|
68
|
+
|
|
69
|
+
useImperativeHandle(
|
|
70
|
+
forwardedRef,
|
|
71
|
+
() => ({
|
|
72
|
+
setCenter: (center: LatLngExpression, zoom?: number) => {
|
|
73
|
+
map.setView(center, zoom);
|
|
74
|
+
},
|
|
75
|
+
setZoom: (cb) => {
|
|
76
|
+
map.setZoom(cb(map.getZoom()));
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
[map],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Resize.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (width && height) {
|
|
85
|
+
map.invalidateSize();
|
|
86
|
+
}
|
|
87
|
+
}, [width, height]);
|
|
88
|
+
|
|
89
|
+
// Position.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (center) {
|
|
92
|
+
map.setView(center, zoom);
|
|
93
|
+
} else if (zoom !== undefined) {
|
|
94
|
+
map.setZoom(zoom);
|
|
95
|
+
}
|
|
96
|
+
}, [center, zoom]);
|
|
97
|
+
|
|
98
|
+
// Events.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const handler = debounce(() => {
|
|
101
|
+
onChange?.({ center: map.getCenter(), zoom: map.getZoom() });
|
|
102
|
+
}, 100);
|
|
103
|
+
map.on('move', handler);
|
|
104
|
+
map.on('zoom', handler);
|
|
105
|
+
return () => {
|
|
106
|
+
map.off('move', handler);
|
|
107
|
+
map.off('zoom', handler);
|
|
108
|
+
};
|
|
109
|
+
}, [map, onChange]);
|
|
110
|
+
|
|
111
|
+
// Set the viewport around the markers, or show the whole world map if `markers` is empty.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (markers.length > 0) {
|
|
114
|
+
const bounds = latLngBounds(markers.map((marker) => marker.location));
|
|
115
|
+
map.fitBounds(bounds);
|
|
116
|
+
} else {
|
|
117
|
+
map.setView(defaults.center, defaults.zoom);
|
|
118
|
+
}
|
|
119
|
+
}, [markers]);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div ref={ref} className='flex w-full h-full overflow-hidden bg-baseSurface'>
|
|
123
|
+
{/* Map tiles. */}
|
|
124
|
+
<TileLayer
|
|
125
|
+
className='dark:filter dark:grayscale dark:invert'
|
|
126
|
+
url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
{/* Markers. */}
|
|
130
|
+
{/* TODO(burdon): Marker icon doesn't load on mobile? */}
|
|
131
|
+
{markers.map(({ id, title, location: { lat, lng } }) => {
|
|
132
|
+
return (
|
|
133
|
+
<Marker key={id} position={{ lat, lng }}>
|
|
134
|
+
{title && <Popup>{title}</Popup>}
|
|
135
|
+
</Marker>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
//
|
|
144
|
+
// Controls
|
|
145
|
+
// Integrates with Leaflet custom controls.
|
|
146
|
+
//
|
|
147
|
+
|
|
148
|
+
const CustomControl = ({
|
|
149
|
+
position,
|
|
150
|
+
children,
|
|
151
|
+
}: PropsWithChildren<{
|
|
152
|
+
position: ControlPosition;
|
|
153
|
+
}>) => {
|
|
154
|
+
const map = useMap();
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const control = new Control({ position });
|
|
158
|
+
control.onAdd = () => {
|
|
159
|
+
const container = DomUtil.create('div', mx('!m-0', controlPositions[position]));
|
|
160
|
+
DomEvent.disableClickPropagation(container);
|
|
161
|
+
DomEvent.disableScrollPropagation(container);
|
|
162
|
+
|
|
163
|
+
const root = createRoot(container);
|
|
164
|
+
root.render(
|
|
165
|
+
<ThemeProvider tx={defaultTx}>
|
|
166
|
+
<Tooltip.Provider>{children}</Tooltip.Provider>
|
|
167
|
+
</ThemeProvider>,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return container;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
control.addTo(map);
|
|
174
|
+
return () => {
|
|
175
|
+
control.remove();
|
|
176
|
+
};
|
|
177
|
+
}, [map, position, children]);
|
|
178
|
+
|
|
179
|
+
return null;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
type MapControlProps = { position?: ControlPosition } & Pick<ControlProps, 'onAction'>;
|
|
183
|
+
|
|
184
|
+
//
|
|
185
|
+
// Map
|
|
186
|
+
//
|
|
187
|
+
|
|
188
|
+
export const Map = {
|
|
189
|
+
Root: MapRoot,
|
|
190
|
+
Canvas: MapCanvas,
|
|
191
|
+
Zoom: ({ onAction, position = 'bottomleft', ...props }: MapControlProps) => (
|
|
192
|
+
<CustomControl position={position} {...props}>
|
|
193
|
+
<ZoomControls onAction={onAction} />
|
|
194
|
+
</CustomControl>
|
|
195
|
+
),
|
|
196
|
+
Action: ({ onAction, position = 'bottomright', ...props }: MapControlProps) => (
|
|
197
|
+
<CustomControl position={position} {...props}>
|
|
198
|
+
<ActionControls onAction={onAction} />
|
|
199
|
+
</CustomControl>
|
|
200
|
+
),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export { type MapCanvasProps, type MapController };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type ControlPosition } from 'leaflet';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
|
|
8
|
+
import { IconButton, type ThemedClassName, Toolbar } from '@dxos/react-ui';
|
|
9
|
+
|
|
10
|
+
export type ControlAction = 'toggle' | 'start' | 'zoom-in' | 'zoom-out';
|
|
11
|
+
|
|
12
|
+
export type ControlProps = ThemedClassName<{
|
|
13
|
+
onAction?: (action: ControlAction) => void;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
export const controlPositions: Record<ControlPosition, string> = {
|
|
17
|
+
topleft: 'top-2 left-2',
|
|
18
|
+
topright: 'top-2 right-2',
|
|
19
|
+
bottomleft: 'bottom-2 left-2',
|
|
20
|
+
bottomright: 'bottom-2 right-2',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const ZoomControls = ({ classNames, onAction }: ControlProps) => {
|
|
24
|
+
return (
|
|
25
|
+
<Toolbar.Root classNames={['gap-1', classNames]}>
|
|
26
|
+
<IconButton
|
|
27
|
+
//
|
|
28
|
+
icon='ph--plus--regular'
|
|
29
|
+
label='zoom in'
|
|
30
|
+
iconOnly
|
|
31
|
+
size={5}
|
|
32
|
+
classNames='px-0 aspect-square'
|
|
33
|
+
onClick={() => onAction?.('zoom-in')}
|
|
34
|
+
/>
|
|
35
|
+
<IconButton
|
|
36
|
+
//
|
|
37
|
+
icon='ph--minus--regular'
|
|
38
|
+
label='zoom out'
|
|
39
|
+
iconOnly
|
|
40
|
+
size={5}
|
|
41
|
+
classNames='px-0 aspect-square'
|
|
42
|
+
onClick={() => onAction?.('zoom-out')}
|
|
43
|
+
/>
|
|
44
|
+
</Toolbar.Root>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const ActionControls = ({ classNames, onAction }: ControlProps) => {
|
|
49
|
+
return (
|
|
50
|
+
<Toolbar.Root classNames={['gap-1', classNames]}>
|
|
51
|
+
<IconButton
|
|
52
|
+
//
|
|
53
|
+
icon='ph--play--regular'
|
|
54
|
+
label='start'
|
|
55
|
+
iconOnly
|
|
56
|
+
size={5}
|
|
57
|
+
classNames='px-0 aspect-square'
|
|
58
|
+
onClick={() => onAction?.('start')}
|
|
59
|
+
/>
|
|
60
|
+
<IconButton
|
|
61
|
+
//
|
|
62
|
+
icon='ph--globe-hemisphere-west--regular'
|
|
63
|
+
label='toggle'
|
|
64
|
+
iconOnly
|
|
65
|
+
size={5}
|
|
66
|
+
classNames='px-0 aspect-square'
|
|
67
|
+
onClick={() => onAction?.('toggle')}
|
|
68
|
+
/>
|
|
69
|
+
</Toolbar.Root>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type LatLngLiteral } from 'leaflet';
|
|
6
|
+
|
|
7
|
+
import { type ThemedClassName } from '@dxos/react-ui';
|
|
8
|
+
|
|
9
|
+
import { type MapMarker } from '../types';
|
|
10
|
+
|
|
11
|
+
export type { LatLngLiteral };
|
|
12
|
+
|
|
13
|
+
export type MapCanvasProps = ThemedClassName<{
|
|
14
|
+
markers?: MapMarker[];
|
|
15
|
+
zoom?: number;
|
|
16
|
+
center?: LatLngLiteral;
|
|
17
|
+
onChange?: (ev: { center: LatLngLiteral; zoom: number }) => void;
|
|
18
|
+
}>;
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { createContext, type Dispatch, type PropsWithChildren, type SetStateAction, useContext } from 'react';
|
|
6
|
+
|
|
7
|
+
import { raise } from '@dxos/debug';
|
|
8
|
+
import { useControlledValue } from '@dxos/react-ui';
|
|
9
|
+
|
|
10
|
+
import { type LatLng } from '../util';
|
|
11
|
+
|
|
12
|
+
// TODO(burdon): Factor out common geometry types.
|
|
13
|
+
export type Size = { width: number; height: number };
|
|
14
|
+
export type Point = { x: number; y: number };
|
|
15
|
+
export type Vector = [number, number, number];
|
|
16
|
+
|
|
17
|
+
export type GlobeContextType = {
|
|
18
|
+
size: Size;
|
|
19
|
+
center: LatLng;
|
|
20
|
+
scale: number;
|
|
21
|
+
translation: Point;
|
|
22
|
+
rotation: Vector;
|
|
23
|
+
setCenter: Dispatch<SetStateAction<LatLng>>;
|
|
24
|
+
setScale: Dispatch<SetStateAction<number>>;
|
|
25
|
+
setTranslation: Dispatch<SetStateAction<Point>>;
|
|
26
|
+
setRotation: Dispatch<SetStateAction<Vector>>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const GlobeContext = createContext<GlobeContextType>(undefined);
|
|
30
|
+
|
|
31
|
+
export type GlobeContextProviderProps = PropsWithChildren<
|
|
32
|
+
Partial<Pick<GlobeContextType, 'size' | 'center' | 'scale' | 'translation' | 'rotation'>>
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
export const GlobeContextProvider = ({
|
|
36
|
+
children,
|
|
37
|
+
size,
|
|
38
|
+
center: _center,
|
|
39
|
+
scale: _scale,
|
|
40
|
+
translation: _translation,
|
|
41
|
+
rotation: _rotation,
|
|
42
|
+
}: GlobeContextProviderProps) => {
|
|
43
|
+
const [center, setCenter] = useControlledValue(_center);
|
|
44
|
+
const [scale, setScale] = useControlledValue(_scale);
|
|
45
|
+
const [translation, setTranslation] = useControlledValue<Point>(_translation);
|
|
46
|
+
const [rotation, setRotation] = useControlledValue<Vector>(_rotation);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<GlobeContext.Provider
|
|
50
|
+
value={{ size, center, scale, translation, rotation, setCenter, setScale, setTranslation, setRotation }}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</GlobeContext.Provider>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const useGlobeContext = () => {
|
|
58
|
+
return useContext(GlobeContext) ?? raise(new Error('Missing GlobeContext'));
|
|
59
|
+
};
|