@fundar/data-chart-telling 0.0.36 → 0.0.37

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 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
- import type { ComponentProps, Snippet } from 'svelte';
3
- import { Plot } from 'svelteplot';
4
- import { resolveProjection } from './resolveProjection';
5
- import { createGeoZoom } from './zoom.svelte';
6
- import { createGeoRotate, type GeoRotate } from './rotate.svelte';
7
- import type { CoordinatesConfig } from '../../types/layout/coordinates';
8
-
9
- type SveltePlotProps = ComponentProps<typeof Plot>;
10
-
11
- /**
12
- * The Coordinates layer, rendered — resolves the active coordinate system
13
- * (today, only a `d3-geo` projection; svelteplot has no other coordinate
14
- * abstraction, cartesian is implicit elsewhere) and owns `<Plot>` itself,
15
- * since `<Plot projection>` must be known before `<Plot>` mounts — nothing
16
- * nested inside it could supply that prop after the fact. Also owns the
17
- * pan/zoom/rotate gesture state and applies the resulting transform as a
18
- * `<g>` wrapper around `children`. A no-op identity transform, no
19
- * projection, for every plot kind but `GeoPlot`.
20
- */
21
- let {
22
- width,
23
- height,
24
- margin,
25
- x,
26
- y,
27
- coordinates = undefined,
28
- data,
29
- containerEl,
30
- remountKey = undefined,
31
- children,
32
- }: {
33
- width: number;
34
- height: number;
35
- margin: SveltePlotProps['margin'];
36
- x: SveltePlotProps['x'];
37
- y: SveltePlotProps['y'];
38
- coordinates?: CoordinatesConfig;
39
- /** Fed to `resolveProjection`'s `domain: 'data'` sentinel — the same rows `BasePlotLayout` uses for hover matching. */
40
- data: TData[];
41
- containerEl?: HTMLDivElement;
42
- /** See `BasePlotLayout`'s own `remountKey` doc comment — forwarded verbatim to key svelteplot's `<Plot>` itself. */
43
- remountKey?: string | number;
44
- children: Snippet;
45
- } = $props();
46
-
47
- const resolvedProjection = $derived(
48
- coordinates ? resolveProjection(coordinates.projection, data) : undefined,
49
- );
50
-
51
- const zoomEnabled = $derived(coordinates?.zoom !== false && coordinates?.zoom != null);
52
- const zoomConfig = $derived(typeof coordinates?.zoom === 'object' ? coordinates.zoom : undefined);
53
-
54
- /**
55
- * Orthographic (and other azimuthal, hemisphere-style) projections don't
56
- * gain anything from drag-to-*pan* panning just slides the same globe a
57
- * few pixels within the frame so when zoom is enabled on one, drag
58
- * instead rotates the globe (`createGeoRotate`) and wheel/pinch keeps
59
- * scaling it (`createGeoZoom`, with its own drag disabled below so the two
60
- * gestures don't fight over the same pointer events).
61
- */
62
- const projectionType = $derived(
63
- typeof resolvedProjection === 'string' ? resolvedProjection : resolvedProjection?.type,
64
- );
65
- const rotateEnabled = $derived(zoomEnabled && projectionType === 'orthographic');
66
- const baseRotate = $derived.by((): GeoRotate => {
67
- const configured = typeof resolvedProjection === 'object' ? resolvedProjection?.rotate : undefined;
68
- return [configured?.[0] ?? 0, configured?.[1] ?? 0, configured?.[2] ?? 0];
69
- });
70
-
71
- const geoZoom = createGeoZoom({
72
- enabled: () => zoomEnabled,
73
- config: () => zoomConfig,
74
- node: () => containerEl ?? null,
75
- disableDrag: () => rotateEnabled,
76
- });
77
-
78
- const geoRotate = createGeoRotate({
79
- enabled: () => rotateEnabled,
80
- baseRotate: () => baseRotate,
81
- scale: () => geoZoom.transform.k,
82
- node: () => containerEl ?? null,
83
- });
84
-
85
- const finalProjection = $derived.by(() => {
86
- if (!rotateEnabled) return resolvedProjection;
87
- return {
88
- ...(typeof resolvedProjection === 'string' ? { type: resolvedProjection } : resolvedProjection),
89
- rotate: geoRotate.rotate,
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
- <Plot {width} {height} {margin} {x} {y} projection={finalProjection as SveltePlotProps['projection']}>
96
- <g transform="translate({geoZoom.transform.x}, {geoZoom.transform.y}) scale({geoZoom.transform.k})">
97
- {@render children()}
98
- </g>
99
- </Plot>
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 the same rows `BasePlotLayout` uses for hover matching. */
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. The caller applies it as an
10
- * SVG `<g transform="translate(x,y) scale(k)">` wrapper — cheap, since the
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
- * Known v1 limitation: svelteplot's own `<Pointer>` hit-tests in the plot's
15
- * pre-zoom projected coordinate space, so hover/tooltip matching does not
16
- * compensate for an active pan/zoom transform — it still works, but targets
17
- * a shape's un-zoomed position rather than its currently-visible one.
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
- enabled: () => boolean;
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;