@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.
Files changed (120) hide show
  1. package/LICENSE +102 -5
  2. package/data/countries-10m.ts +12 -0
  3. package/data/countries-110m.ts +4 -10579
  4. package/data/countries-50m.ts +12 -0
  5. package/dist/lib/browser/chunk-SC2FBYFU.mjs +17 -0
  6. package/dist/lib/browser/chunk-SC2FBYFU.mjs.map +7 -0
  7. package/dist/lib/browser/countries-10m-CWWDOKH7.mjs +6 -0
  8. package/dist/lib/browser/countries-10m-CWWDOKH7.mjs.map +7 -0
  9. package/dist/lib/browser/countries-110m-72QBAA5E.mjs +6 -0
  10. package/dist/lib/browser/countries-110m-72QBAA5E.mjs.map +7 -0
  11. package/dist/lib/browser/countries-50m-H7SL7KVF.mjs +6 -0
  12. package/dist/lib/browser/countries-50m-H7SL7KVF.mjs.map +7 -0
  13. package/dist/lib/browser/data.mjs +1 -1
  14. package/dist/lib/browser/index.mjs +774 -223
  15. package/dist/lib/browser/index.mjs.map +4 -4
  16. package/dist/lib/browser/meta.json +1 -1
  17. package/dist/lib/browser/translations.mjs +19 -0
  18. package/dist/lib/browser/translations.mjs.map +7 -0
  19. package/dist/lib/node-esm/chunk-VZENBYLJ.mjs +19 -0
  20. package/dist/lib/node-esm/chunk-VZENBYLJ.mjs.map +7 -0
  21. package/dist/lib/node-esm/countries-10m-DJZV66KG.mjs +8 -0
  22. package/dist/lib/node-esm/countries-10m-DJZV66KG.mjs.map +7 -0
  23. package/dist/lib/node-esm/countries-110m-H3WY6K4Q.mjs +8 -0
  24. package/dist/lib/node-esm/countries-110m-H3WY6K4Q.mjs.map +7 -0
  25. package/dist/lib/node-esm/countries-50m-ZY7Z3IWD.mjs +8 -0
  26. package/dist/lib/node-esm/countries-50m-ZY7Z3IWD.mjs.map +7 -0
  27. package/dist/lib/node-esm/data.mjs +1 -1
  28. package/dist/lib/node-esm/index.mjs +774 -223
  29. package/dist/lib/node-esm/index.mjs.map +4 -4
  30. package/dist/lib/node-esm/meta.json +1 -1
  31. package/dist/lib/node-esm/translations.mjs +21 -0
  32. package/dist/lib/node-esm/translations.mjs.map +7 -0
  33. package/dist/types/data/airports.d.ts +4 -4
  34. package/dist/types/data/airports.d.ts.map +1 -1
  35. package/dist/types/data/cities.d.ts.map +1 -1
  36. package/dist/types/data/countries-10m.d.ts +8 -0
  37. package/dist/types/data/countries-10m.d.ts.map +1 -0
  38. package/dist/types/data/countries-110m.d.ts +2 -30
  39. package/dist/types/data/countries-110m.d.ts.map +1 -1
  40. package/dist/types/data/countries-50m.d.ts +8 -0
  41. package/dist/types/data/countries-50m.d.ts.map +1 -0
  42. package/dist/types/data/countries-dots-3.d.ts.map +1 -1
  43. package/dist/types/data/countries-dots-4.d.ts.map +1 -1
  44. package/dist/types/src/components/Globe/Globe.d.ts +18 -10
  45. package/dist/types/src/components/Globe/Globe.d.ts.map +1 -1
  46. package/dist/types/src/components/Globe/Globe.stories.d.ts +16 -8
  47. package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
  48. package/dist/types/src/components/Map/Map.d.ts +49 -13
  49. package/dist/types/src/components/Map/Map.d.ts.map +1 -1
  50. package/dist/types/src/components/Map/Map.stories.d.ts +9 -5
  51. package/dist/types/src/components/Map/Map.stories.d.ts.map +1 -1
  52. package/dist/types/src/components/Toolbar/Controls.d.ts.map +1 -1
  53. package/dist/types/src/data.d.ts +9 -1
  54. package/dist/types/src/data.d.ts.map +1 -1
  55. package/dist/types/src/hooks/context.d.ts +37 -0
  56. package/dist/types/src/hooks/context.d.ts.map +1 -1
  57. package/dist/types/src/hooks/index.d.ts +3 -0
  58. package/dist/types/src/hooks/index.d.ts.map +1 -1
  59. package/dist/types/src/hooks/useDrag.d.ts +22 -2
  60. package/dist/types/src/hooks/useDrag.d.ts.map +1 -1
  61. package/dist/types/src/hooks/useGlobeZoomHandler.d.ts +3 -2
  62. package/dist/types/src/hooks/useGlobeZoomHandler.d.ts.map +1 -1
  63. package/dist/types/src/hooks/useMapZoomHandler.d.ts +1 -1
  64. package/dist/types/src/hooks/useMapZoomHandler.d.ts.map +1 -1
  65. package/dist/types/src/hooks/useSimplifiedTopology.d.ts +32 -0
  66. package/dist/types/src/hooks/useSimplifiedTopology.d.ts.map +1 -0
  67. package/dist/types/src/hooks/useSpinner.d.ts +1 -1
  68. package/dist/types/src/hooks/useSpinner.d.ts.map +1 -1
  69. package/dist/types/src/hooks/useTopology.d.ts +26 -0
  70. package/dist/types/src/hooks/useTopology.d.ts.map +1 -0
  71. package/dist/types/src/hooks/useTour.d.ts +3 -2
  72. package/dist/types/src/hooks/useTour.d.ts.map +1 -1
  73. package/dist/types/src/hooks/useWheel.d.ts +24 -0
  74. package/dist/types/src/hooks/useWheel.d.ts.map +1 -0
  75. package/dist/types/src/index.d.ts +0 -2
  76. package/dist/types/src/index.d.ts.map +1 -1
  77. package/dist/types/src/translations.d.ts +4 -4
  78. package/dist/types/src/translations.d.ts.map +1 -1
  79. package/dist/types/src/util/animation.d.ts +16 -0
  80. package/dist/types/src/util/animation.d.ts.map +1 -0
  81. package/dist/types/src/util/debug.d.ts.map +1 -1
  82. package/dist/types/src/util/index.d.ts +2 -0
  83. package/dist/types/src/util/index.d.ts.map +1 -1
  84. package/dist/types/src/util/inertia.d.ts.map +1 -1
  85. package/dist/types/src/util/path.d.ts.map +1 -1
  86. package/dist/types/src/util/render.d.ts +25 -1
  87. package/dist/types/src/util/render.d.ts.map +1 -1
  88. package/dist/types/src/util/styles.d.ts +4 -0
  89. package/dist/types/src/util/styles.d.ts.map +1 -0
  90. package/dist/types/tsconfig.tsbuildinfo +1 -1
  91. package/package.json +26 -24
  92. package/src/components/Globe/Globe.stories.tsx +135 -58
  93. package/src/components/Globe/Globe.tsx +237 -120
  94. package/src/components/Map/Map.stories.tsx +58 -12
  95. package/src/components/Map/Map.tsx +293 -91
  96. package/src/components/Toolbar/Controls.tsx +1 -1
  97. package/src/data.ts +19 -2
  98. package/src/hooks/context.tsx +44 -0
  99. package/src/hooks/index.ts +3 -0
  100. package/src/hooks/useDrag.ts +33 -5
  101. package/src/hooks/useGlobeZoomHandler.ts +2 -1
  102. package/src/hooks/useSimplifiedTopology.ts +81 -0
  103. package/src/hooks/useSpinner.ts +1 -1
  104. package/src/hooks/useTopology.ts +95 -0
  105. package/src/hooks/useTour.ts +70 -81
  106. package/src/hooks/useWheel.ts +83 -0
  107. package/src/index.ts +0 -2
  108. package/src/util/animation.ts +35 -0
  109. package/src/util/index.ts +2 -0
  110. package/src/util/inertia.ts +87 -4
  111. package/src/util/render.ts +105 -16
  112. package/src/util/styles.ts +62 -0
  113. package/dist/lib/browser/chunk-GMWLKTLN.mjs +0 -9
  114. package/dist/lib/browser/chunk-GMWLKTLN.mjs.map +0 -7
  115. package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs +0 -37859
  116. package/dist/lib/browser/countries-110m-ZM3ZIEFS.mjs.map +0 -7
  117. package/dist/lib/node-esm/chunk-JODBF4CC.mjs +0 -11
  118. package/dist/lib/node-esm/chunk-JODBF4CC.mjs.map +0 -7
  119. package/dist/lib/node-esm/countries-110m-3SFASWVD.mjs +0 -37861
  120. package/dist/lib/node-esm/countries-110m-3SFASWVD.mjs.map +0 -7
@@ -5,8 +5,8 @@
5
5
  import { select } from 'd3';
6
6
  import { useEffect } from 'react';
7
7
 
8
- import { type GlobeController } from '../components';
9
8
  import { geoInertiaDrag } from '../util';
9
+ import { type GlobeController } from './context';
10
10
 
11
11
  export type GlobeDragEvent = {
12
12
  type: 'start' | 'move' | 'end';
@@ -16,7 +16,27 @@ export type GlobeDragEvent = {
16
16
  export type DragOptions = {
17
17
  disabled?: boolean;
18
18
  duration?: number;
19
- xAxis?: boolean; // TODO(burdon): Generalize.
19
+ /**
20
+ * When true, drag is constrained to rotation around the polar (vertical)
21
+ * axis only — i.e. longitude changes freely but the camera's tilt
22
+ * (latitude / phi) stays pinned at whatever value the root's `rotation`
23
+ * prop was initialised with. Useful for "earth-spinning-at-an-angle"
24
+ * presentations where the inclination should not change.
25
+ */
26
+ lockTilt?: boolean;
27
+ /**
28
+ * Drag rotation mode:
29
+ * - `linear` (default): direct pixel-to-Euler mapping (Δx → lambda,
30
+ * Δy → phi, gamma fixed at 0). Rotation is constrained to two axes;
31
+ * no roll. The dragged point does not track the cursor exactly.
32
+ * - `versor`: quaternion-based rotation so that the dragged point
33
+ * follows the cursor exactly. May induce roll (gamma).
34
+ */
35
+ mode?: 'linear' | 'versor';
36
+ /**
37
+ * Degrees of rotation per pixel of drag, in linear mode. Default 0.25.
38
+ */
39
+ sensitivity?: number;
20
40
  onUpdate?: (event: GlobeDragEvent) => void;
21
41
  };
22
42
 
@@ -30,7 +50,7 @@ export const useDrag = (controller?: GlobeController | null, options: DragOption
30
50
  return;
31
51
  }
32
52
 
33
- geoInertiaDrag(
53
+ const inertia = geoInertiaDrag(
34
54
  select(canvas),
35
55
  () => {
36
56
  controller.setRotation(controller.projection.rotate());
@@ -38,16 +58,24 @@ export const useDrag = (controller?: GlobeController | null, options: DragOption
38
58
  },
39
59
  controller.projection,
40
60
  {
41
- xAxis: options.xAxis,
61
+ lockTilt: options.lockTilt,
62
+ mode: options.mode,
63
+ sensitivity: options.sensitivity,
64
+ // Zoom-driven gain: matches useWheel — degrees-per-pixel shrinks as the
65
+ // globe gets larger on screen so the drag feel is consistent across zoom.
66
+ getZoom: () => controller.zoom,
42
67
  time: 3_000,
43
68
  start: () => options.onUpdate?.({ type: 'start', controller }),
44
69
  finish: () => options.onUpdate?.({ type: 'end', controller }),
45
70
  },
46
71
  );
47
72
 
48
- // TODO(burdon): Cancel drag timer.
49
73
  return () => {
50
74
  cancelDrag(select(canvas));
75
+ // Stop any in-flight inertia: otherwise its d3-timer keeps writing
76
+ // through the (stable) setRotation closure into the live React state,
77
+ // even after this effect has been replaced.
78
+ inertia?.timer?.stop();
51
79
  };
52
80
  }, [controller, JSON.stringify(options)]);
53
81
  };
@@ -4,7 +4,8 @@
4
4
 
5
5
  import { useCallback } from 'react';
6
6
 
7
- import { type ControlProps, type GlobeController } from '../components';
7
+ import { type ControlProps } from '../components';
8
+ import { type GlobeController } from './context';
8
9
 
9
10
  const ZOOM_FACTOR = 0.1;
10
11
 
@@ -0,0 +1,81 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { useMemo } from 'react';
6
+ import { presimplify, quantile, simplify } from 'topojson-simplify';
7
+ import { type Topology } from 'topojson-specification';
8
+
9
+ /**
10
+ * Zoom → minWeight policy. The default thresholds were chosen empirically
11
+ * against the 10m world-atlas dataset: each tier targets a percentile of
12
+ * removable points so that the per-frame d3-geo path cost stays roughly
13
+ * constant as the user zooms in.
14
+ */
15
+ export type SimplifyTier = {
16
+ /** Lower bound (inclusive) of the zoom range this tier applies to. */
17
+ minZoom: number;
18
+ /** Percentile in [0, 1] passed to `topojson.quantile`. 1 = keep no points; 0 = keep all. */
19
+ percentile: number;
20
+ };
21
+
22
+ const DEFAULT_TIERS: SimplifyTier[] = [
23
+ { minZoom: 0, percentile: 0.95 },
24
+ { minZoom: 2, percentile: 0.85 },
25
+ { minZoom: 4, percentile: 0.6 },
26
+ { minZoom: 7, percentile: 0.3 },
27
+ { minZoom: 12, percentile: 0 },
28
+ ];
29
+
30
+ const pickTier = (zoom: number, tiers: SimplifyTier[]): SimplifyTier => {
31
+ let match = tiers[0];
32
+ for (const tier of tiers) {
33
+ if (zoom >= tier.minZoom) {
34
+ match = tier;
35
+ }
36
+ }
37
+ return match;
38
+ };
39
+
40
+ export type UseSimplifiedTopologyOptions = {
41
+ /**
42
+ * Zoom buckets that map a zoom value to a simplification percentile. The
43
+ * hook picks the highest-`minZoom` tier whose bound is ≤ the current zoom,
44
+ * so tiers may be listed in any order but ascending is conventional.
45
+ */
46
+ tiers?: SimplifyTier[];
47
+ };
48
+
49
+ /**
50
+ * Returns a simplified copy of `topology` whose detail tracks the current
51
+ * `zoom`. The source topology is annotated with point weights once via
52
+ * `presimplify`; subsequent zoom changes reuse that work and only re-run
53
+ * the cheap `simplify` pass against a tier-bucketed `minWeight`.
54
+ *
55
+ * Pass the highest-resolution source you have (typically `10m`) and let the
56
+ * hook decimate at low zoom — that's the cheap end of the curve.
57
+ */
58
+ export const useSimplifiedTopology = (
59
+ topology: Topology | undefined,
60
+ zoom: number,
61
+ options: UseSimplifiedTopologyOptions = {},
62
+ ): Topology | undefined => {
63
+ const { tiers = DEFAULT_TIERS } = options;
64
+
65
+ // One-shot weight annotation per source topology.
66
+ const presimplified = useMemo(() => (topology ? presimplify(topology) : undefined), [topology]);
67
+
68
+ // Stable per-tier quantile lookup so identical zoom buckets do not retrigger.
69
+ const tier = pickTier(zoom, tiers);
70
+
71
+ return useMemo(() => {
72
+ if (!presimplified) {
73
+ return undefined;
74
+ }
75
+ if (tier.percentile <= 0) {
76
+ return presimplified;
77
+ }
78
+ const minWeight = quantile(presimplified, tier.percentile);
79
+ return simplify(presimplified, minWeight);
80
+ }, [presimplified, tier]);
81
+ };
@@ -6,7 +6,7 @@ import { timer as d3Timer } from 'd3';
6
6
  import { type Timer } from 'd3';
7
7
  import { useEffect, useState } from 'react';
8
8
 
9
- import { type GlobeController } from '../components';
9
+ import { type GlobeController } from './context';
10
10
  import { type Vector } from './context';
11
11
 
12
12
  export type SpinnerOptions = {
@@ -0,0 +1,95 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { useEffect, useState } from 'react';
6
+ import { type Topology } from 'topojson-specification';
7
+
8
+ import { log } from '@dxos/log';
9
+
10
+ import { type CountriesResolution, loadTopology } from '../data';
11
+
12
+ export type Level = CountriesResolution;
13
+
14
+ export type LevelTier = {
15
+ /** Lower bound (inclusive) of the zoom range. */
16
+ minZoom: number;
17
+ /** Topology resolution to load when this tier is active. */
18
+ level: CountriesResolution;
19
+ };
20
+
21
+ /** Default zoom buckets: 110m below zoom 3, 50m at zoom 3 and above. */
22
+ const DEFAULT_TIERS: LevelTier[] = [
23
+ { minZoom: 0, level: '110m' },
24
+ // TODO(burdon): Too slow.
25
+ // { minZoom: 3, level: '50m' },
26
+ // { minZoom: 6, level: '10m' },
27
+ ];
28
+
29
+ const pickTier = (zoom: number, tiers: LevelTier[]): LevelTier => {
30
+ let match = tiers[0];
31
+ for (const tier of tiers) {
32
+ if (zoom >= tier.minZoom) {
33
+ match = tier;
34
+ }
35
+ }
36
+
37
+ return match;
38
+ };
39
+
40
+ export type UseTopologyOptions = {
41
+ /** Zoom buckets that map a zoom value to a resolution. Default: 110m / 50m at 0 / 3. */
42
+ tiers?: LevelTier[];
43
+ };
44
+
45
+ // Previously-loaded topologies are kept in a module-level cache so re-crossing a tier boundary
46
+ // doesn't re-fetch/re-parse the file.
47
+ const topologyCache = new Map<CountriesResolution, Topology>();
48
+
49
+ /**
50
+ * Loads TopoJSON country data.
51
+ *
52
+ * - With no arguments, loads the default `110m` resolution.
53
+ * - With a `zoom`, loads the resolution matching the current zoom tier (progressive level-of-detail).
54
+ * Each resolution is a separate dynamic `import()`, so unused detail levels are never fetched, and
55
+ * while a heavier tier loads the previously-displayed topology stays on screen (no blank canvas).
56
+ */
57
+ export const useTopology: {
58
+ (): Topology | undefined;
59
+ (zoom: number, options?: UseTopologyOptions): Topology | undefined;
60
+ } = (zoom?: number, options: UseTopologyOptions = {}): Topology | undefined => {
61
+ const { tiers = DEFAULT_TIERS } = options;
62
+ const level: CountriesResolution = zoom === undefined ? '110m' : pickTier(zoom, tiers).level;
63
+
64
+ const [topology, setTopology] = useState<Topology | undefined>(() => topologyCache.get(level));
65
+
66
+ useEffect(() => {
67
+ const cached = topologyCache.get(level);
68
+ if (cached) {
69
+ setTopology(cached);
70
+ return;
71
+ }
72
+
73
+ let disposed = false;
74
+ void loadTopology(level)
75
+ .then((loaded) => {
76
+ topologyCache.set(level, loaded);
77
+ if (!disposed) {
78
+ setTopology(loaded);
79
+ }
80
+ })
81
+ .catch((err) => {
82
+ // A chunk/network failure leaves the previously-displayed topology on screen; log rather
83
+ // than surfacing an unhandled rejection.
84
+ if (!disposed) {
85
+ log.warn('failed to load topology', { level, err });
86
+ }
87
+ });
88
+
89
+ return () => {
90
+ disposed = true;
91
+ };
92
+ }, [level]);
93
+
94
+ return topology;
95
+ };
@@ -2,15 +2,12 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { selection as d3Selection, geoDistance, geoInterpolate, geoPath } from 'd3';
6
- import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from 'react';
7
- import versor from 'versor';
5
+ import { geoInterpolate, geoPath } from 'd3';
6
+ import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
8
7
 
9
- import type { GlobeController } from '../components';
10
8
  import { type LatLngLiteral } from '../types';
11
- import { type StyleSet, geoToPosition, positionToRotation } from '../util';
12
-
13
- const TRANSITION_NAME = 'globe-tour';
9
+ import { type StyleSet, geoToPosition } from '../util';
10
+ import type { GlobeController } from './context';
14
11
 
15
12
  const defaultDuration = 1_500;
16
13
 
@@ -25,7 +22,8 @@ export type TourOptions = {
25
22
  };
26
23
 
27
24
  /**
28
- * Iterates between points.
25
+ * Iterates between points by chaining `controller.flyTo` calls, rendering
26
+ * an arc + cursor on the canvas per frame via the flyTo `onTick` hook.
29
27
  * Inspired by: https://observablehq.com/@mbostock/top-100-cities
30
28
  */
31
29
  export const useTour = (
@@ -33,91 +31,82 @@ export const useTour = (
33
31
  points?: LatLngLiteral[],
34
32
  options: TourOptions = {},
35
33
  ): [boolean, Dispatch<SetStateAction<boolean>>] => {
36
- const selection = useMemo(() => d3Selection(), []);
37
34
  // TODO(burdon): Redo controlled state.
38
35
  const [running, setRunning] = useState(options.running ?? false);
39
36
  useEffect(() => {
40
- if (!running) {
41
- selection.interrupt(TRANSITION_NAME);
37
+ if (!controller || !running) {
42
38
  return;
43
39
  }
44
40
 
45
- let t: ReturnType<typeof setTimeout>;
46
- if (controller && running) {
47
- t = setTimeout(async () => {
48
- const { canvas, projection, setRotation } = controller;
49
- const context = canvas.getContext('2d', { alpha: false });
50
- const path = geoPath(projection, context).pointRadius(2);
41
+ let cancelled = false;
42
+ const t = setTimeout(async () => {
43
+ const { canvas, projection } = controller;
44
+ const context = canvas.getContext('2d', { alpha: false });
45
+ const path = geoPath(projection, context).pointRadius(2);
46
+
47
+ try {
48
+ const tourPoints = [...points];
49
+ if (options.loop) {
50
+ tourPoints.push(tourPoints[0]);
51
+ }
51
52
 
52
- const tilt = options.tilt ?? 0;
53
- let last: LatLngLiteral;
54
- try {
55
- const p = [...points];
56
- if (options.loop) {
57
- p.push(p[0]);
53
+ let last: LatLngLiteral | undefined;
54
+ for (const next of tourPoints) {
55
+ if (cancelled) {
56
+ break;
58
57
  }
59
58
 
60
- for (const next of p) {
61
- if (!running) {
62
- break;
59
+ const p1 = last ? geoToPosition(last) : undefined;
60
+ const p2 = geoToPosition(next);
61
+ const ip = geoInterpolate(p1 ?? p2, p2);
62
+
63
+ // Cursor + trailing arc render per frame. Must run before the
64
+ // rotation tween advances the projection — flyTo registers
65
+ // `onTick` before its rotation tween, preserving this ordering.
66
+ const onTick = (t: number) => {
67
+ const t1 = Math.max(0, Math.min(1, t * 2 - 1));
68
+ const t2 = Math.min(1, t * 2);
69
+
70
+ context.save();
71
+ try {
72
+ context.beginPath();
73
+ context.strokeStyle = options.styles?.arc?.strokeStyle ?? 'yellow';
74
+ context.lineWidth = (options.styles?.arc?.lineWidth ?? 1.5) * (controller.zoom ?? 1);
75
+ context.setLineDash(options.styles?.arc?.lineDash ?? []);
76
+ path({ type: 'LineString', coordinates: [ip(t1), ip(t2)] });
77
+ context.stroke();
78
+
79
+ context.beginPath();
80
+ context.fillStyle = options.styles?.cursor?.fillStyle ?? 'orange';
81
+ path.pointRadius((options.styles?.cursor?.pointRadius ?? 2) * (controller.zoom ?? 1));
82
+ path({ type: 'Point', coordinates: ip(t2) });
83
+ context.fill();
84
+ } finally {
85
+ context.restore();
63
86
  }
64
-
65
- // Points.
66
- const p1 = last ? geoToPosition(last) : undefined;
67
- const p2 = geoToPosition(next);
68
- const ip = geoInterpolate(p1 || p2, p2);
69
- const distance = geoDistance(p1 || p2, p2);
70
-
71
- // Rotation.
72
- const r1 = p1 ? positionToRotation(p1, tilt) : controller.projection.rotate();
73
- const r2 = positionToRotation(p2, tilt);
74
- const iv = versor.interpolate(r1, r2);
75
-
76
- const transition = selection
77
- .transition(TRANSITION_NAME)
78
- .duration(Math.max(options.duration ?? defaultDuration, distance * 2_000))
79
- .tween('render', () => (t) => {
80
- const t1 = Math.max(0, Math.min(1, t * 2 - 1));
81
- const t2 = Math.min(1, t * 2);
82
-
83
- context.save();
84
- {
85
- context.beginPath();
86
- context.strokeStyle = options?.styles?.arc?.strokeStyle ?? 'yellow';
87
- context.lineWidth = (options?.styles?.arc?.lineWidth ?? 1.5) * (controller?.zoom ?? 1);
88
- context.setLineDash(options?.styles?.arc?.lineDash ?? []);
89
- path({ type: 'LineString', coordinates: [ip(t1), ip(t2)] });
90
- context.stroke();
91
-
92
- context.beginPath();
93
- context.fillStyle = options?.styles?.cursor?.fillStyle ?? 'orange';
94
- path.pointRadius((options?.styles?.cursor?.pointRadius ?? 2) * (controller?.zoom ?? 1));
95
- path({ type: 'Point', coordinates: ip(t2) });
96
- context.fill();
97
- }
98
- context.restore();
99
-
100
- // TODO(burdon): This has to come after rendering above. Add to features to correct order?
101
- projection.rotate(iv(t));
102
- setRotation(projection.rotate());
103
- });
104
-
105
- // Throws if interrupted.
106
- await transition.end();
107
- last = next;
108
- }
109
- } catch {
110
- // Ignore.
111
- } finally {
87
+ };
88
+
89
+ await controller.flyTo(next, {
90
+ duration: options.duration ?? defaultDuration,
91
+ tilt: options.tilt ?? 0,
92
+ onTick,
93
+ });
94
+ last = next;
95
+ }
96
+ } catch {
97
+ // Interrupted (e.g. external flyTo or tour stopped).
98
+ } finally {
99
+ if (!cancelled) {
112
100
  setRunning(false);
113
101
  }
114
- });
115
-
116
- return () => {
117
- clearTimeout(t);
118
- selection.interrupt(TRANSITION_NAME);
119
- };
120
- }
102
+ }
103
+ });
104
+
105
+ return () => {
106
+ cancelled = true;
107
+ clearTimeout(t);
108
+ controller.cancelFlyTo();
109
+ };
121
110
  }, [controller, running, JSON.stringify(options)]);
122
111
 
123
112
  return [running, setRunning];
@@ -0,0 +1,83 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { useEffect } from 'react';
6
+
7
+ import { type GlobeController } from './context';
8
+ import { type Vector } from './context';
9
+
10
+ export type WheelOptions = {
11
+ disabled?: boolean;
12
+ /**
13
+ * Degrees of rotation per pixel of wheel delta (non-zoom gestures).
14
+ */
15
+ sensitivity?: number;
16
+ /**
17
+ * Zoom factor per pixel of pinch / ctrl+scroll delta. Applied exponentially
18
+ * so equal pinch-in / pinch-out deltas cancel exactly. Default 0.01.
19
+ */
20
+ zoomSensitivity?: number;
21
+ onUpdate?: (controller: GlobeController) => void;
22
+ };
23
+
24
+ const DEFAULT_SENSITIVITY = 0.25;
25
+ const DEFAULT_ZOOM_SENSITIVITY = 0.01;
26
+
27
+ /**
28
+ * Map mouse-wheel / trackpad gestures to globe motion:
29
+ * - Scroll (or two-finger pan on a trackpad) rotates the globe:
30
+ * deltaY → phi (tilt around screen-X), deltaX → lambda (polar spin).
31
+ * - Pinch-to-zoom on a trackpad — and ctrl+scroll on a mouse — are
32
+ * delivered as `wheel` events with `ctrlKey: true`. Those are routed
33
+ * to `setZoom` instead so the gesture matches the user's intent.
34
+ */
35
+ export const useWheel = (controller?: GlobeController | null, options: WheelOptions = {}) => {
36
+ useEffect(() => {
37
+ const canvas = controller?.canvas;
38
+ if (!canvas || options.disabled) {
39
+ return;
40
+ }
41
+
42
+ const sensitivity = options.sensitivity ?? DEFAULT_SENSITIVITY;
43
+ const zoomSensitivity = options.zoomSensitivity ?? DEFAULT_ZOOM_SENSITIVITY;
44
+
45
+ const handleWheel = (event: WheelEvent) => {
46
+ event.preventDefault();
47
+ if (event.ctrlKey) {
48
+ // Pinch-to-zoom on trackpads, or ctrl+scroll on a mouse. The
49
+ // browser synthesises ctrlKey on pinch even when ctrl is not held.
50
+ // Read live zoom from controller.zoom (a getter on zoomRef.current)
51
+ // and set the new value directly: useControlledState's functional
52
+ // setter resolves against the prop, not the state, and passing a
53
+ // function to controller.setZoom triggers the 200ms eased transition
54
+ // intended for button clicks.
55
+ const factor = Math.exp(-event.deltaY * zoomSensitivity);
56
+ controller.setZoom(controller.zoom * factor);
57
+ } else {
58
+ // Read the live rotation off the projection (kept in sync by the
59
+ // render effect). The React-state path can't be used here:
60
+ // - `controller.rotation` is a snapshot captured by useImperativeHandle.
61
+ // - `useControlledState`'s functional setter resolves `prev` against
62
+ // the latest *prop*, not the latest state, so each event would
63
+ // start from the initial rotation and just jitter around it.
64
+ // Mutating the projection here matches how useDrag accumulates.
65
+ // Both deltas are negated so the wheel feels like "natural scroll":
66
+ // scrolling/swiping in a direction moves the globe content the same way.
67
+ // Scale by 1/zoom so the gesture feels consistent at any zoom level
68
+ // (a bigger on-screen globe needs smaller angular rotation per pixel).
69
+ const [lambda, phi, gamma] = controller.projection.rotate() as Vector;
70
+ const k = sensitivity / Math.max(controller.zoom, 0.1);
71
+ const next: Vector = [lambda - event.deltaX * k, phi + event.deltaY * k, gamma];
72
+ controller.projection.rotate(next);
73
+ controller.setRotation(controller.projection.rotate() as Vector);
74
+ }
75
+ options.onUpdate?.(controller);
76
+ };
77
+
78
+ canvas.addEventListener('wheel', handleWheel, { passive: false });
79
+ return () => {
80
+ canvas.removeEventListener('wheel', handleWheel);
81
+ };
82
+ }, [controller, options.disabled, options.sensitivity, options.zoomSensitivity, options.onUpdate]);
83
+ };
package/src/index.ts CHANGED
@@ -3,8 +3,6 @@
3
3
  //
4
4
 
5
5
  export * from './components';
6
- export * from './data';
7
6
  export * from './hooks';
8
- export * from './translations';
9
7
  export type * from './types';
10
8
  export * from './util';
@@ -0,0 +1,35 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type GeoProjection, geoDistance } from 'd3';
6
+ import versor from 'versor';
7
+
8
+ import { type Vector } from '../hooks/context';
9
+
10
+ /**
11
+ * Duration scaled by great-circle distance between two geo positions.
12
+ * Ensures long jumps animate longer than short ones while clamping to a
13
+ * minimum base. `scale` controls how steeply duration grows with distance
14
+ * (ms per radian of arc).
15
+ */
16
+ export const flyDuration = (p1: [number, number], p2: [number, number], base: number, scale: number): number =>
17
+ Math.max(base, geoDistance(p1, p2) * scale);
18
+
19
+ /**
20
+ * Per-frame tween that interpolates the projection's rotation between two
21
+ * Euler triples along the shortest great-circle arc using versors. Mutates
22
+ * the projection and pushes the normalised rotation through `setRotation`.
23
+ */
24
+ export const createRotationTween = (
25
+ projection: GeoProjection,
26
+ setRotation: (rotation: Vector) => void,
27
+ r1: Vector,
28
+ r2: Vector,
29
+ ): ((t: number) => void) => {
30
+ const iv = versor.interpolate(r1, r2);
31
+ return (t: number) => {
32
+ projection.rotate(iv(t));
33
+ setRotation(projection.rotate() as Vector);
34
+ };
35
+ };
package/src/util/index.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  // Copyright 2019 DXOS.org
3
3
  //
4
4
 
5
+ export * from './animation';
5
6
  export * from './debug';
6
7
  export * from './inertia';
7
8
  export * from './path';
8
9
  export * from './render';
10
+ export * from './styles';