@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.
Files changed (126) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +3 -0
  3. package/data/airports.ts +52524 -0
  4. package/data/cities.ts +1211 -0
  5. package/data/countries-110m.ts +10587 -0
  6. package/data/countries-dots-3.ts +42989 -0
  7. package/data/countries-dots-4.ts +300941 -0
  8. package/data/raw/airports.json +39386 -0
  9. package/data/raw/countries-10m.json +1 -0
  10. package/data/raw/countries-110m.json +1 -0
  11. package/data/raw/countries-50m.json +1 -0
  12. package/data/raw/countries.json +1 -0
  13. package/dist/lib/browser/chunk-ENCWOTYX.mjs +9 -0
  14. package/dist/lib/browser/chunk-ENCWOTYX.mjs.map +7 -0
  15. package/dist/lib/browser/countries-110m-WI4PCLDF.mjs +37859 -0
  16. package/dist/lib/browser/countries-110m-WI4PCLDF.mjs.map +7 -0
  17. package/dist/lib/browser/data.mjs +7 -0
  18. package/dist/lib/browser/data.mjs.map +7 -0
  19. package/dist/lib/browser/index.mjs +1020 -0
  20. package/dist/lib/browser/index.mjs.map +7 -0
  21. package/dist/lib/browser/meta.json +1 -0
  22. package/dist/lib/node/chunk-LAICG6L2.cjs +40 -0
  23. package/dist/lib/node/chunk-LAICG6L2.cjs.map +7 -0
  24. package/dist/lib/node/countries-110m-KQ5WAB2O.cjs +37877 -0
  25. package/dist/lib/node/countries-110m-KQ5WAB2O.cjs.map +7 -0
  26. package/dist/lib/node/data.cjs +28 -0
  27. package/dist/lib/node/data.cjs.map +7 -0
  28. package/dist/lib/node/index.cjs +1045 -0
  29. package/dist/lib/node/index.cjs.map +7 -0
  30. package/dist/lib/node/meta.json +1 -0
  31. package/dist/lib/node-esm/chunk-PIIEDZEU.mjs +11 -0
  32. package/dist/lib/node-esm/chunk-PIIEDZEU.mjs.map +7 -0
  33. package/dist/lib/node-esm/countries-110m-DQ4XRC4B.mjs +37861 -0
  34. package/dist/lib/node-esm/countries-110m-DQ4XRC4B.mjs.map +7 -0
  35. package/dist/lib/node-esm/data.mjs +8 -0
  36. package/dist/lib/node-esm/data.mjs.map +7 -0
  37. package/dist/lib/node-esm/index.mjs +1021 -0
  38. package/dist/lib/node-esm/index.mjs.map +7 -0
  39. package/dist/lib/node-esm/meta.json +1 -0
  40. package/dist/types/data/airports.d.ts +18 -0
  41. package/dist/types/data/airports.d.ts.map +1 -0
  42. package/dist/types/data/cities.d.ts +17 -0
  43. package/dist/types/data/cities.d.ts.map +1 -0
  44. package/dist/types/data/countries-110m.d.ts +36 -0
  45. package/dist/types/data/countries-110m.d.ts.map +1 -0
  46. package/dist/types/data/countries-dots-3.d.ts +9 -0
  47. package/dist/types/data/countries-dots-3.d.ts.map +1 -0
  48. package/dist/types/data/countries-dots-4.d.ts +9 -0
  49. package/dist/types/data/countries-dots-4.d.ts.map +1 -0
  50. package/dist/types/src/components/Globe/Globe.d.ts +37 -0
  51. package/dist/types/src/components/Globe/Globe.d.ts.map +1 -0
  52. package/dist/types/src/components/Globe/Globe.stories.d.ts +15 -0
  53. package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -0
  54. package/dist/types/src/components/Globe/index.d.ts +2 -0
  55. package/dist/types/src/components/Globe/index.d.ts.map +1 -0
  56. package/dist/types/src/components/Map/Map.d.ts +34 -0
  57. package/dist/types/src/components/Map/Map.d.ts.map +1 -0
  58. package/dist/types/src/components/Map/Map.stories.d.ts +7 -0
  59. package/dist/types/src/components/Map/Map.stories.d.ts.map +1 -0
  60. package/dist/types/src/components/Map/index.d.ts +2 -0
  61. package/dist/types/src/components/Map/index.d.ts.map +1 -0
  62. package/dist/types/src/components/Toolbar/Controls.d.ts +11 -0
  63. package/dist/types/src/components/Toolbar/Controls.d.ts.map +1 -0
  64. package/dist/types/src/components/Toolbar/index.d.ts +2 -0
  65. package/dist/types/src/components/Toolbar/index.d.ts.map +1 -0
  66. package/dist/types/src/components/index.d.ts +5 -0
  67. package/dist/types/src/components/index.d.ts.map +1 -0
  68. package/dist/types/src/components/types.d.ts +14 -0
  69. package/dist/types/src/components/types.d.ts.map +1 -0
  70. package/dist/types/src/data.d.ts +3 -0
  71. package/dist/types/src/data.d.ts.map +1 -0
  72. package/dist/types/src/hooks/context.d.ts +26 -0
  73. package/dist/types/src/hooks/context.d.ts.map +1 -0
  74. package/dist/types/src/hooks/index.d.ts +7 -0
  75. package/dist/types/src/hooks/index.d.ts.map +1 -0
  76. package/dist/types/src/hooks/useDrag.d.ts +16 -0
  77. package/dist/types/src/hooks/useDrag.d.ts.map +1 -0
  78. package/dist/types/src/hooks/useGlobeZoomHandler.d.ts +3 -0
  79. package/dist/types/src/hooks/useGlobeZoomHandler.d.ts.map +1 -0
  80. package/dist/types/src/hooks/useMapZoomHandler.d.ts +3 -0
  81. package/dist/types/src/hooks/useMapZoomHandler.d.ts.map +1 -0
  82. package/dist/types/src/hooks/useSpinner.d.ts +11 -0
  83. package/dist/types/src/hooks/useSpinner.d.ts.map +1 -0
  84. package/dist/types/src/hooks/useTour.d.ts +13 -0
  85. package/dist/types/src/hooks/useTour.d.ts.map +1 -0
  86. package/dist/types/src/index.d.ts +6 -0
  87. package/dist/types/src/index.d.ts.map +1 -0
  88. package/dist/types/src/types.d.ts +7 -0
  89. package/dist/types/src/types.d.ts.map +1 -0
  90. package/dist/types/src/util/debug.d.ts +2 -0
  91. package/dist/types/src/util/debug.d.ts.map +1 -0
  92. package/dist/types/src/util/index.d.ts +5 -0
  93. package/dist/types/src/util/index.d.ts.map +1 -0
  94. package/dist/types/src/util/inertia.d.ts +16 -0
  95. package/dist/types/src/util/inertia.d.ts.map +1 -0
  96. package/dist/types/src/util/path.d.ts +15 -0
  97. package/dist/types/src/util/path.d.ts.map +1 -0
  98. package/dist/types/src/util/render.d.ts +26 -0
  99. package/dist/types/src/util/render.d.ts.map +1 -0
  100. package/dist/types/tsconfig.tsbuildinfo +1 -0
  101. package/package.json +83 -0
  102. package/src/components/Globe/Globe.stories.tsx +318 -0
  103. package/src/components/Globe/Globe.tsx +270 -0
  104. package/src/components/Globe/index.ts +5 -0
  105. package/src/components/Map/Map.stories.tsx +39 -0
  106. package/src/components/Map/Map.tsx +203 -0
  107. package/src/components/Map/index.ts +5 -0
  108. package/src/components/Toolbar/Controls.tsx +71 -0
  109. package/src/components/Toolbar/index.ts +5 -0
  110. package/src/components/index.ts +9 -0
  111. package/src/components/types.ts +18 -0
  112. package/src/data.ts +9 -0
  113. package/src/hooks/context.tsx +59 -0
  114. package/src/hooks/index.ts +10 -0
  115. package/src/hooks/useDrag.ts +55 -0
  116. package/src/hooks/useGlobeZoomHandler.ts +29 -0
  117. package/src/hooks/useMapZoomHandler.ts +29 -0
  118. package/src/hooks/useSpinner.ts +69 -0
  119. package/src/hooks/useTour.ts +114 -0
  120. package/src/index.ts +9 -0
  121. package/src/types.ts +11 -0
  122. package/src/util/debug.ts +16 -0
  123. package/src/util/index.ts +8 -0
  124. package/src/util/inertia.ts +197 -0
  125. package/src/util/path.ts +56 -0
  126. 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,5 @@
1
+ //
2
+ // Copyright 2019 DXOS.org
3
+ //
4
+
5
+ export * from './Globe';
@@ -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,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './Map';
@@ -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,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './Controls';
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2019 DXOS.org
3
+ //
4
+
5
+ export * from './types';
6
+
7
+ export * from './Globe';
8
+ export * from './Map';
9
+ export * from './Toolbar';
@@ -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,9 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Topology } from 'topojson-specification';
6
+
7
+ export const loadTopology = async (): Promise<Topology> => {
8
+ return (await import('../data/countries-110m.ts')).default;
9
+ };
@@ -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
+ };