@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
package/src/hooks/useDrag.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
};
|
|
@@ -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
|
+
};
|
package/src/hooks/useSpinner.ts
CHANGED
|
@@ -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 '
|
|
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
|
+
};
|
package/src/hooks/useTour.ts
CHANGED
|
@@ -2,15 +2,12 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { type Dispatch, type SetStateAction, useEffect,
|
|
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
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
@@ -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
|
+
};
|