@fundar/data-chart-telling 0.0.36 → 0.0.38
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/dist/index.d.ts +1 -0
- package/dist/layout/coordinates/CameraFit.svelte +111 -0
- package/dist/layout/coordinates/CameraFit.svelte.d.ts +15 -0
- package/dist/layout/coordinates/CoordinatesLayout.svelte +169 -95
- package/dist/layout/coordinates/CoordinatesLayout.svelte.d.ts +1 -1
- package/dist/layout/coordinates/camera.svelte.d.ts +17 -0
- package/dist/layout/coordinates/camera.svelte.js +106 -0
- package/dist/layout/coordinates/resolveCameraTarget.d.ts +14 -0
- package/dist/layout/coordinates/resolveCameraTarget.js +19 -0
- package/dist/layout/coordinates/zoom.svelte.d.ts +11 -15
- package/dist/layout/coordinates/zoom.svelte.js +15 -12
- package/dist/layout/plot/BasePlotLayout.svelte +312 -282
- package/dist/plots/line/Plot.svelte +6 -18
- package/dist/plots/line/ValueLabels.svelte +103 -83
- package/dist/plots/line/ValueLabels.svelte.d.ts +1 -1
- package/dist/plots/utils/declutter.js +7 -4
- package/dist/plots/utils/labelOverflow.svelte.js +13 -4
- package/dist/types/layout/coordinates.d.ts +11 -5
- package/dist/types/plots/camera.d.ts +42 -0
- package/dist/types/plots/camera.js +1 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ export type { MarkerProps } from './types/markers/props';
|
|
|
51
51
|
export type { GeoFeature, GeoJsonInput, GeoDataProps, Topology } from './types/plots/data/geo';
|
|
52
52
|
export type { ScatterDataProps } from './types/plots/data/scatter';
|
|
53
53
|
export type { GeoProjectionName, GeoProjectionConfig, GeoCustomProjection, GeoCustomProjectionFactory, GeoScalesConfig, GeoZoomConfig, CoordinatesConfig } from './types/layout/coordinates';
|
|
54
|
+
export type { GeoCameraTarget, GeoCameraConfig, GeoCameraTransition } from './types/plots/camera';
|
|
54
55
|
export type { GeoMarkersConfig, GeoInsetMarkerConfig, GeoInsetTooltipConfig, GeoInsetLocation, GeoInsetProjectionName, GeoInsetProjectionConfig } from './types/markers/geo';
|
|
55
56
|
export type { ChartPlotContext, ChartPlotSnippet } from './types/charts/common';
|
|
56
57
|
export type { TimeValue, Orientation, TimelineConfig } from './types/layout/timeline';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { geoBounds, type GeoPermissibleObjects } from 'd3-geo';
|
|
3
|
+
import { usePlot } from 'svelteplot';
|
|
4
|
+
import {
|
|
5
|
+
toProjectionLike,
|
|
6
|
+
type StreamingProjection,
|
|
7
|
+
type ProjectionLike
|
|
8
|
+
} from '../../plots/utils/tiles';
|
|
9
|
+
import { parseInsetLocation } from '../../markers/inset/insetLayout';
|
|
10
|
+
import type { ResolvedGeoCameraTarget } from './resolveCameraTarget';
|
|
11
|
+
import type { GeoZoomTransform } from './zoom.svelte';
|
|
12
|
+
import type { GeoInsetLocation } from '../../types/markers/geo';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Mounted inside `<Plot>` to reach `usePlot()` for the live fitted
|
|
16
|
+
* projection (see `TileLayer` for the same pattern). Computes the pixel
|
|
17
|
+
* `{x,y,k}` that fits/anchors `target` within `width`×`height`, reporting
|
|
18
|
+
* it reactively via `onFit`.
|
|
19
|
+
*/
|
|
20
|
+
let {
|
|
21
|
+
target,
|
|
22
|
+
anchor,
|
|
23
|
+
padding = 24,
|
|
24
|
+
zoom = 1,
|
|
25
|
+
width,
|
|
26
|
+
height,
|
|
27
|
+
onFit
|
|
28
|
+
}: {
|
|
29
|
+
target: ResolvedGeoCameraTarget;
|
|
30
|
+
anchor?: GeoInsetLocation;
|
|
31
|
+
padding?: number;
|
|
32
|
+
zoom?: number;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
onFit: (fit: GeoZoomTransform) => void;
|
|
36
|
+
} = $props();
|
|
37
|
+
|
|
38
|
+
const plot = usePlot();
|
|
39
|
+
|
|
40
|
+
// Runtime shape verified against svelteplot's own projection helper — see TileLayer.svelte.
|
|
41
|
+
const rawProjection = $derived(
|
|
42
|
+
plot.scales.projection as unknown as StreamingProjection | undefined
|
|
43
|
+
);
|
|
44
|
+
const projection = $derived(rawProjection ? toProjectionLike(rawProjection) : undefined);
|
|
45
|
+
|
|
46
|
+
/** A grid of lon/lat samples approximating `target`'s extent, antimeridian-safe. */
|
|
47
|
+
function sampleLonLat(t: ResolvedGeoCameraTarget): [number, number][] {
|
|
48
|
+
if (t.kind === 'point') return [[t.lon, t.lat]];
|
|
49
|
+
const [[w, s], [e, n]] = geoBounds(t.geometry as GeoPermissibleObjects);
|
|
50
|
+
const east = e < w ? e + 360 : e;
|
|
51
|
+
const lons = [w, (w + east) / 2, east].map((lon) => ((((lon + 180) % 360) + 360) % 360) - 180);
|
|
52
|
+
const lats = [s, (s + n) / 2, n];
|
|
53
|
+
const points: [number, number][] = [];
|
|
54
|
+
for (const lon of lons) for (const lat of lats) points.push([lon, lat]);
|
|
55
|
+
return points;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function project(proj: ProjectionLike, points: [number, number][]): [number, number][] {
|
|
59
|
+
const out: [number, number][] = [];
|
|
60
|
+
for (const p of points) {
|
|
61
|
+
const projected = proj(p);
|
|
62
|
+
if (projected) out.push(projected);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
$effect(() => {
|
|
68
|
+
if (!projection) return;
|
|
69
|
+
const projected = project(projection, sampleLonLat(target));
|
|
70
|
+
if (!projected.length) return;
|
|
71
|
+
|
|
72
|
+
let minX = Infinity;
|
|
73
|
+
let maxX = -Infinity;
|
|
74
|
+
let minY = Infinity;
|
|
75
|
+
let maxY = -Infinity;
|
|
76
|
+
for (const [x, y] of projected) {
|
|
77
|
+
if (x < minX) minX = x;
|
|
78
|
+
if (x > maxX) maxX = x;
|
|
79
|
+
if (y < minY) minY = y;
|
|
80
|
+
if (y > maxY) maxY = y;
|
|
81
|
+
}
|
|
82
|
+
const bboxW = maxX - minX;
|
|
83
|
+
const bboxH = maxY - minY;
|
|
84
|
+
|
|
85
|
+
// No extent to fit for a plain point — `zoom` is used directly as the scale.
|
|
86
|
+
const availW = Math.max(1, width - padding * 2);
|
|
87
|
+
const availH = Math.max(1, height - padding * 2);
|
|
88
|
+
const fitScale =
|
|
89
|
+
bboxW > 0 && bboxH > 0
|
|
90
|
+
? Math.min(availW / bboxW, availH / bboxH)
|
|
91
|
+
: bboxW > 0
|
|
92
|
+
? availW / bboxW
|
|
93
|
+
: bboxH > 0
|
|
94
|
+
? availH / bboxH
|
|
95
|
+
: 1;
|
|
96
|
+
const k = fitScale * zoom;
|
|
97
|
+
|
|
98
|
+
const centerX = (minX + maxX) / 2;
|
|
99
|
+
const centerY = (minY + maxY) / 2;
|
|
100
|
+
|
|
101
|
+
const { h, v } = parseInsetLocation(anchor ?? 'middle-middle');
|
|
102
|
+
const halfW = (bboxW * k) / 2;
|
|
103
|
+
const halfH = (bboxH * k) / 2;
|
|
104
|
+
const targetX =
|
|
105
|
+
h === 'left' ? padding + halfW : h === 'right' ? width - padding - halfW : width / 2;
|
|
106
|
+
const targetY =
|
|
107
|
+
v === 'top' ? padding + halfH : v === 'bottom' ? height - padding - halfH : height / 2;
|
|
108
|
+
|
|
109
|
+
onFit({ x: targetX - centerX * k, y: targetY - centerY * k, k });
|
|
110
|
+
});
|
|
111
|
+
</script>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResolvedGeoCameraTarget } from './resolveCameraTarget';
|
|
2
|
+
import type { GeoZoomTransform } from './zoom.svelte';
|
|
3
|
+
import type { GeoInsetLocation } from '../../types/markers/geo';
|
|
4
|
+
type $$ComponentProps = {
|
|
5
|
+
target: ResolvedGeoCameraTarget;
|
|
6
|
+
anchor?: GeoInsetLocation;
|
|
7
|
+
padding?: number;
|
|
8
|
+
zoom?: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
onFit: (fit: GeoZoomTransform) => void;
|
|
12
|
+
};
|
|
13
|
+
declare const CameraFit: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
|
+
type CameraFit = ReturnType<typeof CameraFit>;
|
|
15
|
+
export default CameraFit;
|
|
@@ -1,100 +1,174 @@
|
|
|
1
1
|
<script lang="ts" generics="TData extends Record<string, unknown>">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
2
|
+
import type { ComponentProps, Snippet } from 'svelte';
|
|
3
|
+
import { geoCentroid, type GeoPermissibleObjects } from 'd3-geo';
|
|
4
|
+
import { Plot } from 'svelteplot';
|
|
5
|
+
import { resolveProjection } from './resolveProjection';
|
|
6
|
+
import { resolveCameraTarget } from './resolveCameraTarget';
|
|
7
|
+
import { createGeoZoom, type GeoZoomTransform } from './zoom.svelte';
|
|
8
|
+
import { createGeoRotate, type GeoRotate } from './rotate.svelte';
|
|
9
|
+
import {
|
|
10
|
+
createCameraTransformTween,
|
|
11
|
+
createCameraRotateTween
|
|
12
|
+
} from './camera.svelte';
|
|
13
|
+
import CameraFit from './CameraFit.svelte';
|
|
14
|
+
import type { CoordinatesConfig } from '../../types/layout/coordinates';
|
|
15
|
+
|
|
16
|
+
type SveltePlotProps = ComponentProps<typeof Plot>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the active coordinate system (a `d3-geo` projection) and owns
|
|
20
|
+
* `<Plot>` itself, since `projection` must be known before it mounts.
|
|
21
|
+
* Also owns pan/zoom/rotate gesture state and the declarative `camera`,
|
|
22
|
+
* composed into the `<g>` transform wrapper (and, for `camera`/`rotate`
|
|
23
|
+
* on `orthographic`, into the projection's own `rotate`). A no-op for
|
|
24
|
+
* every plot kind but `GeoPlot`.
|
|
25
|
+
*/
|
|
26
|
+
let {
|
|
27
|
+
width,
|
|
28
|
+
height,
|
|
29
|
+
margin,
|
|
30
|
+
x,
|
|
31
|
+
y,
|
|
32
|
+
coordinates = undefined,
|
|
33
|
+
data,
|
|
34
|
+
containerEl,
|
|
35
|
+
remountKey = undefined,
|
|
36
|
+
children
|
|
37
|
+
}: {
|
|
38
|
+
width: number;
|
|
39
|
+
height: number;
|
|
40
|
+
margin: SveltePlotProps['margin'];
|
|
41
|
+
x: SveltePlotProps['x'];
|
|
42
|
+
y: SveltePlotProps['y'];
|
|
43
|
+
coordinates?: CoordinatesConfig;
|
|
44
|
+
/** Fed to `resolveProjection`'s `domain: 'data'` sentinel and to `coordinates.camera`'s `{features}` target resolution. */
|
|
45
|
+
data: TData[];
|
|
46
|
+
containerEl?: HTMLDivElement;
|
|
47
|
+
/** See `BasePlotLayout`'s own `remountKey` doc comment — forwarded verbatim to key svelteplot's `<Plot>` itself. */
|
|
48
|
+
remountKey?: string | number;
|
|
49
|
+
children: Snippet;
|
|
50
|
+
} = $props();
|
|
51
|
+
|
|
52
|
+
const resolvedProjection = $derived(
|
|
53
|
+
coordinates ? resolveProjection(coordinates.projection, data) : undefined
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const zoomEnabled = $derived(coordinates?.zoom !== false && coordinates?.zoom != null);
|
|
57
|
+
const zoomConfig = $derived(typeof coordinates?.zoom === 'object' ? coordinates.zoom : undefined);
|
|
58
|
+
const panEnabled = $derived(coordinates?.pan ?? false);
|
|
59
|
+
|
|
60
|
+
const projectionType = $derived(
|
|
61
|
+
typeof resolvedProjection === 'string' ? resolvedProjection : resolvedProjection?.type
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/** `rotate` claims the drag gesture from `pan` on `orthographic` (spins the globe instead of panning it). `zoom`'s wheel/pinch scaling is unaffected. */
|
|
65
|
+
const rotateEnabled = $derived(
|
|
66
|
+
(coordinates?.rotate ?? false) && projectionType === 'orthographic'
|
|
67
|
+
);
|
|
68
|
+
const baseRotate = $derived.by((): GeoRotate => {
|
|
69
|
+
const configured =
|
|
70
|
+
typeof resolvedProjection === 'object' ? resolvedProjection?.rotate : undefined;
|
|
71
|
+
return [configured?.[0] ?? 0, configured?.[1] ?? 0, configured?.[2] ?? 0];
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const geoZoom = createGeoZoom({
|
|
75
|
+
panEnabled: () => panEnabled && !rotateEnabled,
|
|
76
|
+
zoomEnabled: () => zoomEnabled,
|
|
77
|
+
config: () => zoomConfig,
|
|
78
|
+
node: () => containerEl ?? null,
|
|
79
|
+
disableDrag: () => rotateEnabled
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const geoRotate = createGeoRotate({
|
|
83
|
+
enabled: () => rotateEnabled,
|
|
84
|
+
baseRotate: () => baseRotate,
|
|
85
|
+
scale: () => geoZoom.transform.k,
|
|
86
|
+
node: () => containerEl ?? null
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── Declarative camera ────────────────────────────────────────────────────
|
|
90
|
+
const cameraConfig = $derived(coordinates?.camera);
|
|
91
|
+
const resolvedCameraTarget = $derived(
|
|
92
|
+
cameraConfig
|
|
93
|
+
? resolveCameraTarget(
|
|
94
|
+
cameraConfig.target,
|
|
95
|
+
data as unknown as { __id: string; geometry: GeoJSON.Geometry }[]
|
|
96
|
+
)
|
|
97
|
+
: undefined
|
|
98
|
+
);
|
|
99
|
+
const cameraActive = $derived(resolvedCameraTarget != null);
|
|
100
|
+
|
|
101
|
+
/** `[-lon, -lat, gamma]` faces the target toward the viewer — only applied on `orthographic`, see `activeRotate`. */
|
|
102
|
+
const cameraRotateTarget = $derived.by((): GeoRotate | undefined => {
|
|
103
|
+
if (!resolvedCameraTarget) return undefined;
|
|
104
|
+
if (resolvedCameraTarget.kind === 'point') {
|
|
105
|
+
return [-resolvedCameraTarget.lon, -resolvedCameraTarget.lat, baseRotate[2]];
|
|
106
|
+
}
|
|
107
|
+
const [lon, lat] = geoCentroid(resolvedCameraTarget.geometry as GeoPermissibleObjects);
|
|
108
|
+
return [-lon, -lat, baseRotate[2]];
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const cameraRotateTween = createCameraRotateTween({
|
|
112
|
+
target: () => (projectionType === 'orthographic' ? cameraRotateTarget : undefined),
|
|
113
|
+
transition: () => cameraConfig?.transition
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** `camera` claims `rotate` outright on `orthographic` (only one `rotate` value exists at a time) — interactive `rotate` takes back over once `camera` is unset. */
|
|
117
|
+
const cameraOrthoRotate = $derived(
|
|
118
|
+
cameraActive && projectionType === 'orthographic' ? cameraRotateTween.current : undefined
|
|
119
|
+
);
|
|
120
|
+
const activeRotate = $derived(
|
|
121
|
+
cameraOrthoRotate ?? (rotateEnabled ? geoRotate.rotate : baseRotate)
|
|
122
|
+
);
|
|
123
|
+
const rotateOverrideActive = $derived(cameraOrthoRotate != null || rotateEnabled);
|
|
124
|
+
|
|
125
|
+
const finalProjection = $derived.by(() => {
|
|
126
|
+
if (!rotateOverrideActive) return resolvedProjection;
|
|
127
|
+
return {
|
|
128
|
+
...(typeof resolvedProjection === 'string'
|
|
129
|
+
? { type: resolvedProjection }
|
|
130
|
+
: resolvedProjection),
|
|
131
|
+
rotate: activeRotate
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// `CameraFit` (mounted inside `<Plot>`, below) reports the pixel `{x,y,k}` fitting `resolvedCameraTarget` against the live projection.
|
|
136
|
+
let cameraFit = $state<GeoZoomTransform | undefined>(undefined);
|
|
137
|
+
|
|
138
|
+
const cameraTransformTween = createCameraTransformTween({
|
|
139
|
+
target: () => cameraFit,
|
|
140
|
+
transition: () => cameraConfig?.transition
|
|
141
|
+
});
|
|
142
|
+
const cameraTransform = $derived(cameraTransformTween.current ?? { x: 0, y: 0, k: 1 });
|
|
92
143
|
</script>
|
|
93
144
|
|
|
94
145
|
{#key remountKey}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
146
|
+
<Plot
|
|
147
|
+
{width}
|
|
148
|
+
{height}
|
|
149
|
+
{margin}
|
|
150
|
+
{x}
|
|
151
|
+
{y}
|
|
152
|
+
projection={finalProjection as SveltePlotProps['projection']}
|
|
153
|
+
>
|
|
154
|
+
{#if cameraActive && resolvedCameraTarget}
|
|
155
|
+
<CameraFit
|
|
156
|
+
target={resolvedCameraTarget}
|
|
157
|
+
anchor={cameraConfig?.anchor}
|
|
158
|
+
padding={cameraConfig?.padding}
|
|
159
|
+
zoom={cameraConfig?.zoom}
|
|
160
|
+
{width}
|
|
161
|
+
{height}
|
|
162
|
+
onFit={(fit) => (cameraFit = fit)}
|
|
163
|
+
/>
|
|
164
|
+
{/if}
|
|
165
|
+
<g transform="translate({cameraTransform.x}, {cameraTransform.y}) scale({cameraTransform.k})">
|
|
166
|
+
<g
|
|
167
|
+
transform="translate({geoZoom.transform.x}, {geoZoom.transform.y}) scale({geoZoom.transform
|
|
168
|
+
.k})"
|
|
169
|
+
>
|
|
170
|
+
{@render children()}
|
|
171
|
+
</g>
|
|
172
|
+
</g>
|
|
173
|
+
</Plot>
|
|
100
174
|
{/key}
|
|
@@ -8,7 +8,7 @@ declare function $$render<TData extends Record<string, unknown>>(): {
|
|
|
8
8
|
x: false | import("svelteplot/types/data.js").RawValue[] | Partial<import("svelteplot/types/scale.js").XScaleOptions> | undefined;
|
|
9
9
|
y: false | import("svelteplot/types/data.js").RawValue[] | Partial<import("svelteplot/types/scale.js").YScaleOptions> | undefined;
|
|
10
10
|
coordinates?: CoordinatesConfig;
|
|
11
|
-
/** Fed to `resolveProjection`'s `domain: 'data'` sentinel
|
|
11
|
+
/** Fed to `resolveProjection`'s `domain: 'data'` sentinel and to `coordinates.camera`'s `{features}` target resolution. */
|
|
12
12
|
data: TData[];
|
|
13
13
|
containerEl?: HTMLDivElement;
|
|
14
14
|
/** See `BasePlotLayout`'s own `remountKey` doc comment — forwarded verbatim to key svelteplot's `<Plot>` itself. */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GeoCameraTransition } from '../../types/plots/camera';
|
|
2
|
+
import type { GeoRotate } from './rotate.svelte';
|
|
3
|
+
import type { GeoZoomTransform } from './zoom.svelte';
|
|
4
|
+
/** Tweens the camera's pixel-space `{x,y,k}` transform toward `target()` — see `tweenState`'s doc comment for the two transition modes. */
|
|
5
|
+
export declare function createCameraTransformTween(args: {
|
|
6
|
+
target: () => GeoZoomTransform | undefined;
|
|
7
|
+
transition: () => GeoCameraTransition | undefined;
|
|
8
|
+
}): {
|
|
9
|
+
readonly current: GeoZoomTransform | undefined;
|
|
10
|
+
};
|
|
11
|
+
/** Tweens the camera's `[lambda, phi, gamma]` rotate toward `target()`, honoring `transition().direction`. */
|
|
12
|
+
export declare function createCameraRotateTween(args: {
|
|
13
|
+
target: () => GeoRotate | undefined;
|
|
14
|
+
transition: () => GeoCameraTransition | undefined;
|
|
15
|
+
}): {
|
|
16
|
+
readonly current: GeoRotate | undefined;
|
|
17
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { resolveEasing } from '../../utils/interpolate';
|
|
2
|
+
function clamp01(t) {
|
|
3
|
+
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Tweens `T` toward `target()`, either auto-playing over `transition().duration`
|
|
7
|
+
* or, when `transition().progress` is set, as a pure function of it.
|
|
8
|
+
* Interrupting mid-flight continues from the current value. Shared by
|
|
9
|
+
* `createCameraTransformTween`/`createCameraRotateTween`.
|
|
10
|
+
*/
|
|
11
|
+
function tweenState(args) {
|
|
12
|
+
let current = $state(undefined);
|
|
13
|
+
// Mirrors `current` without being reactive — read this, not `current`,
|
|
14
|
+
// inside the effect below, or the async `tick()` write to `current` would
|
|
15
|
+
// re-trigger this very effect (see `rotate.svelte.ts`'s own warning).
|
|
16
|
+
let lastValue;
|
|
17
|
+
let from;
|
|
18
|
+
let to;
|
|
19
|
+
let raf;
|
|
20
|
+
function setCurrent(value) {
|
|
21
|
+
current = value;
|
|
22
|
+
lastValue = value;
|
|
23
|
+
}
|
|
24
|
+
$effect(() => {
|
|
25
|
+
const target = args.target();
|
|
26
|
+
if (target === undefined)
|
|
27
|
+
return;
|
|
28
|
+
const transition = args.transition();
|
|
29
|
+
const progress = transition?.progress;
|
|
30
|
+
const direction = transition?.direction ?? 'shortest';
|
|
31
|
+
const changed = to === undefined || !args.equals(to, target);
|
|
32
|
+
if (changed) {
|
|
33
|
+
// At progress 0 every easing resolves to `from`, so a target
|
|
34
|
+
// correction while idle there must re-snapshot `from` too, or it
|
|
35
|
+
// stays pinned to a stale value.
|
|
36
|
+
const atStart = progress != null && clamp01(progress) <= 0;
|
|
37
|
+
from = atStart ? target : (lastValue ?? target);
|
|
38
|
+
to = target;
|
|
39
|
+
}
|
|
40
|
+
if (raf != null) {
|
|
41
|
+
cancelAnimationFrame(raf);
|
|
42
|
+
raf = undefined;
|
|
43
|
+
}
|
|
44
|
+
if (progress != null) {
|
|
45
|
+
setCurrent(args.lerp(from, to, resolveEasing(transition?.easing)(clamp01(progress)), direction));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!changed)
|
|
49
|
+
return;
|
|
50
|
+
const duration = Math.max(0, transition?.duration ?? 750);
|
|
51
|
+
const easing = resolveEasing(transition?.easing);
|
|
52
|
+
const startFrom = from;
|
|
53
|
+
const endTo = to;
|
|
54
|
+
const start = performance.now();
|
|
55
|
+
function tick(now) {
|
|
56
|
+
const t = duration === 0 ? 1 : Math.min(1, (now - start) / duration);
|
|
57
|
+
setCurrent(args.lerp(startFrom, endTo, easing(t), direction));
|
|
58
|
+
raf = t < 1 ? requestAnimationFrame(tick) : undefined;
|
|
59
|
+
}
|
|
60
|
+
raf = requestAnimationFrame(tick);
|
|
61
|
+
return () => {
|
|
62
|
+
if (raf != null)
|
|
63
|
+
cancelAnimationFrame(raf);
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
get current() {
|
|
68
|
+
return current;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function lerpTransform(from, to, t) {
|
|
73
|
+
return {
|
|
74
|
+
x: from.x + (to.x - from.x) * t,
|
|
75
|
+
y: from.y + (to.y - from.y) * t,
|
|
76
|
+
k: from.k + (to.k - from.k) * t
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function transformEquals(a, b) {
|
|
80
|
+
return a.x === b.x && a.y === b.y && a.k === b.k;
|
|
81
|
+
}
|
|
82
|
+
/** Signed angular delta from `a` to `b` in degrees — the smaller one for `'shortest'`, the way around it for `'longest'`. */
|
|
83
|
+
function angleDelta(a, b, direction) {
|
|
84
|
+
const shortest = ((((b - a + 180) % 360) + 360) % 360) - 180;
|
|
85
|
+
if (direction === 'shortest')
|
|
86
|
+
return shortest;
|
|
87
|
+
return shortest > 0 ? shortest - 360 : shortest + 360;
|
|
88
|
+
}
|
|
89
|
+
function lerpRotate(from, to, t, direction) {
|
|
90
|
+
return [
|
|
91
|
+
from[0] + angleDelta(from[0], to[0], direction) * t,
|
|
92
|
+
from[1] + (to[1] - from[1]) * t,
|
|
93
|
+
from[2] + (to[2] - from[2]) * t
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
function rotateEquals(a, b) {
|
|
97
|
+
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
98
|
+
}
|
|
99
|
+
/** Tweens the camera's pixel-space `{x,y,k}` transform toward `target()` — see `tweenState`'s doc comment for the two transition modes. */
|
|
100
|
+
export function createCameraTransformTween(args) {
|
|
101
|
+
return tweenState({ ...args, lerp: lerpTransform, equals: transformEquals });
|
|
102
|
+
}
|
|
103
|
+
/** Tweens the camera's `[lambda, phi, gamma]` rotate toward `target()`, honoring `transition().direction`. */
|
|
104
|
+
export function createCameraRotateTween(args) {
|
|
105
|
+
return tweenState({ ...args, lerp: lerpRotate, equals: rotateEquals });
|
|
106
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { GeoCameraTarget } from '../../types/plots/camera';
|
|
2
|
+
/** `GeoCameraTarget` resolved to actual geometry — `features` looked up by `__id` and unioned into one `FeatureCollection`; `undefined` if none match. */
|
|
3
|
+
export type ResolvedGeoCameraTarget = {
|
|
4
|
+
kind: 'point';
|
|
5
|
+
lon: number;
|
|
6
|
+
lat: number;
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'bounds';
|
|
9
|
+
geometry: GeoJSON.GeoJsonObject;
|
|
10
|
+
};
|
|
11
|
+
export declare function resolveCameraTarget(target: GeoCameraTarget | undefined, features: {
|
|
12
|
+
__id: string;
|
|
13
|
+
geometry: GeoJSON.Geometry;
|
|
14
|
+
}[]): ResolvedGeoCameraTarget | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function resolveCameraTarget(target, features) {
|
|
2
|
+
if (!target)
|
|
3
|
+
return undefined;
|
|
4
|
+
if ('center' in target)
|
|
5
|
+
return { kind: 'point', lon: target.center[0], lat: target.center[1] };
|
|
6
|
+
if ('bounds' in target)
|
|
7
|
+
return { kind: 'bounds', geometry: target.bounds };
|
|
8
|
+
const wanted = new Set(target.features);
|
|
9
|
+
const matched = features.filter((f) => wanted.has(f.__id));
|
|
10
|
+
if (!matched.length)
|
|
11
|
+
return undefined;
|
|
12
|
+
return {
|
|
13
|
+
kind: 'bounds',
|
|
14
|
+
geometry: {
|
|
15
|
+
type: 'FeatureCollection',
|
|
16
|
+
features: matched.map((f) => ({ type: 'Feature', properties: null, geometry: f.geometry }))
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -6,26 +6,22 @@ export type GeoZoomTransform = {
|
|
|
6
6
|
};
|
|
7
7
|
/**
|
|
8
8
|
* Wires drag-pan + scroll-zoom onto a DOM node via d3-zoom, exposing the
|
|
9
|
-
* resulting transform as reactive Svelte state
|
|
10
|
-
* SVG `<g transform="translate(x,y) scale(k)">` wrapper
|
|
11
|
-
* underlying `d3-geo` projection is computed once by svelteplot and never
|
|
12
|
-
* recomputed per zoom tick.
|
|
9
|
+
* resulting transform as reactive Svelte state, applied by the caller as an
|
|
10
|
+
* SVG `<g transform="translate(x,y) scale(k)">` wrapper.
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* `panEnabled`/`zoomEnabled` gate drag-translate and wheel/pinch-scale
|
|
13
|
+
* independently, as separate `.filter()`s on the same underlying instance.
|
|
14
|
+
*
|
|
15
|
+
* Limitations: hover/tooltip hit-testing doesn't compensate for an active
|
|
16
|
+
* transform. Touch pinch-to-zoom is gated by `panEnabled`, not `zoomEnabled`
|
|
17
|
+
* (d3-zoom can't distinguish a pinch from a one-finger drag until underway).
|
|
18
18
|
*/
|
|
19
19
|
export declare function createGeoZoom(args: {
|
|
20
|
-
|
|
20
|
+
panEnabled: () => boolean;
|
|
21
|
+
zoomEnabled: () => boolean;
|
|
21
22
|
config: () => GeoZoomConfig | undefined;
|
|
22
23
|
node: () => HTMLElement | null;
|
|
23
|
-
/**
|
|
24
|
-
* When true, drag/touch gestures are left for something else to handle
|
|
25
|
-
* (see `createGeoRotate`) — only wheel/pinch scaling stays wired to this
|
|
26
|
-
* behavior. Checked per-event, so toggling it doesn't need to tear down
|
|
27
|
-
* and recreate the underlying d3-zoom behavior.
|
|
28
|
-
*/
|
|
24
|
+
/** When true, drag/touch is left for something else (e.g. `createGeoRotate`) — only wheel/pinch scaling stays wired here. */
|
|
29
25
|
disableDrag?: () => boolean;
|
|
30
26
|
}): {
|
|
31
27
|
readonly transform: GeoZoomTransform;
|