@fundar/data-chart-telling 0.0.14 → 0.0.16

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 (148) hide show
  1. package/README.md +121 -17
  2. package/dist/charts/{Chart.svelte → BaseChart.svelte} +2 -0
  3. package/dist/charts/{Chart.svelte.d.ts → BaseChart.svelte.d.ts} +3 -3
  4. package/dist/charts/bar/Chart.svelte +5 -5
  5. package/dist/charts/bar/Chart.svelte.d.ts +1 -2
  6. package/dist/charts/heatmap/Chart.svelte +8 -7
  7. package/dist/charts/heatmap/Chart.svelte.d.ts +2 -3
  8. package/dist/charts/line/Chart.svelte +5 -5
  9. package/dist/charts/line/Chart.svelte.d.ts +1 -2
  10. package/dist/charts/pyramid/Chart.svelte +7 -6
  11. package/dist/charts/pyramid/Chart.svelte.d.ts +2 -3
  12. package/dist/configuration/config.svelte.js +2 -1
  13. package/dist/configuration/themes/index.d.ts +60 -40
  14. package/dist/index.d.ts +27 -13
  15. package/dist/index.js +12 -7
  16. package/dist/layout/facet/FacetLayout.svelte +1 -1
  17. package/dist/layout/facet/FacetLayout.svelte.d.ts +1 -1
  18. package/dist/layout/geo/GeoLayout.svelte +47 -0
  19. package/dist/layout/geo/GeoLayout.svelte.d.ts +22 -0
  20. package/dist/layout/geo/resolveProjection.d.ts +10 -0
  21. package/dist/layout/geo/resolveProjection.js +48 -0
  22. package/dist/layout/legend/ContinuousSection.svelte +158 -17
  23. package/dist/layout/legend/DiscreteSection.svelte +56 -4
  24. package/dist/layout/legend/interaction.svelte.d.ts +7 -0
  25. package/dist/layout/legend/interaction.svelte.js +24 -0
  26. package/dist/layout/plot/AxisLayout.svelte +95 -0
  27. package/dist/layout/plot/AxisLayout.svelte.d.ts +39 -0
  28. package/dist/layout/plot/BasePlotLayout.svelte +302 -0
  29. package/dist/layout/plot/BasePlotLayout.svelte.d.ts +95 -0
  30. package/dist/layout/plot/GridLayout.svelte +30 -0
  31. package/dist/layout/plot/GridLayout.svelte.d.ts +27 -0
  32. package/dist/layout/plot/RuleLayout.svelte +111 -0
  33. package/dist/layout/plot/RuleLayout.svelte.d.ts +40 -0
  34. package/dist/layout/plot/margins.d.ts +34 -0
  35. package/dist/layout/plot/margins.js +26 -0
  36. package/dist/layout/timeline/TimelineLayout.svelte +1 -1
  37. package/dist/layout/timeline/TimelineLayout.svelte.d.ts +1 -1
  38. package/dist/layout/tooltip/Tooltip.svelte +15 -0
  39. package/dist/layout/tooltip/Tooltip.svelte.d.ts +7 -0
  40. package/dist/layout/tooltip/TooltipLayout.svelte +45 -0
  41. package/dist/layout/tooltip/TooltipLayout.svelte.d.ts +41 -0
  42. package/dist/layout/tooltip/controller.svelte.d.ts +4 -3
  43. package/dist/layout/tooltip/controller.svelte.js +5 -6
  44. package/dist/layout/tooltip/hover.svelte.d.ts +1 -1
  45. package/dist/layout/tooltip/hover.svelte.js +1 -1
  46. package/dist/layout/tooltip/utils.d.ts +2 -2
  47. package/dist/markers/ContourClipMarker.svelte +77 -0
  48. package/dist/markers/ContourClipMarker.svelte.d.ts +10 -0
  49. package/dist/{plots/markers → markers}/DeltaMarker.svelte +4 -4
  50. package/dist/{plots/markers → markers}/DeltaMarker.svelte.d.ts +3 -3
  51. package/dist/{plots/markers → markers}/DotMarker.svelte +3 -3
  52. package/dist/{plots/markers → markers}/DotMarker.svelte.d.ts +3 -3
  53. package/dist/markers/HoverMarker.svelte +136 -0
  54. package/dist/{plots/markers → markers}/HoverMarker.svelte.d.ts +4 -4
  55. package/dist/{plots/markers → markers}/PeakMarker.svelte +2 -2
  56. package/dist/{plots/markers → markers}/PeakMarker.svelte.d.ts +2 -2
  57. package/dist/{plots/markers → markers}/TextMarker.svelte +11 -4
  58. package/dist/{plots/markers → markers}/TextMarker.svelte.d.ts +3 -3
  59. package/dist/markers/inset/InsetMarker.svelte +170 -0
  60. package/dist/markers/inset/InsetMarker.svelte.d.ts +51 -0
  61. package/dist/markers/inset/insetLayout.d.ts +25 -0
  62. package/dist/markers/inset/insetLayout.js +39 -0
  63. package/dist/markers/inset/insetProjection.d.ts +8 -0
  64. package/dist/markers/inset/insetProjection.js +39 -0
  65. package/dist/plots/bar/BarSegments.svelte +69 -0
  66. package/dist/plots/bar/BarSegments.svelte.d.ts +40 -0
  67. package/dist/plots/bar/Plot.svelte +140 -74
  68. package/dist/plots/bar/Plot.svelte.d.ts +7 -3
  69. package/dist/plots/bar/ValueLabels.svelte +89 -0
  70. package/dist/plots/bar/ValueLabels.svelte.d.ts +39 -0
  71. package/dist/plots/bar/hoverPoints.d.ts +15 -0
  72. package/dist/plots/bar/hoverPoints.js +35 -0
  73. package/dist/plots/bar/layout.svelte.d.ts +37 -0
  74. package/dist/plots/bar/layout.svelte.js +132 -0
  75. package/dist/plots/geo/Plot.svelte +577 -0
  76. package/dist/plots/geo/Plot.svelte.d.ts +29 -0
  77. package/dist/plots/geo/TileLayer.svelte +77 -0
  78. package/dist/plots/geo/TileLayer.svelte.d.ts +11 -0
  79. package/dist/plots/geo/projections/argentina.d.ts +8 -0
  80. package/dist/plots/geo/projections/argentina.js +35 -0
  81. package/dist/plots/geo/projections/fit.d.ts +8 -0
  82. package/dist/plots/geo/projections/fit.js +14 -0
  83. package/dist/plots/geo/rotate.svelte.d.ts +26 -0
  84. package/dist/plots/geo/rotate.svelte.js +79 -0
  85. package/dist/plots/geo/zoom.svelte.d.ts +32 -0
  86. package/dist/plots/geo/zoom.svelte.js +50 -0
  87. package/dist/plots/heatmap/Plot.svelte +171 -58
  88. package/dist/plots/heatmap/Plot.svelte.d.ts +7 -3
  89. package/dist/plots/line/Plot.svelte +67 -30
  90. package/dist/plots/line/Plot.svelte.d.ts +7 -3
  91. package/dist/plots/pyramid/Plot.svelte +88 -35
  92. package/dist/plots/pyramid/Plot.svelte.d.ts +7 -3
  93. package/dist/plots/utils/delta.d.ts +1 -1
  94. package/dist/plots/utils/geoAccessors.d.ts +64 -0
  95. package/dist/plots/utils/geoAccessors.js +188 -0
  96. package/dist/plots/utils/geoSegments.d.ts +14 -0
  97. package/dist/plots/utils/geoSegments.js +17 -0
  98. package/dist/plots/utils/legendDisabled.d.ts +15 -0
  99. package/dist/plots/utils/legendDisabled.js +41 -0
  100. package/dist/plots/utils/segments.d.ts +14 -5
  101. package/dist/plots/utils/segments.js +25 -13
  102. package/dist/plots/utils/tiles.d.ts +89 -0
  103. package/dist/plots/utils/tiles.js +159 -0
  104. package/dist/plots/utils/topojson.d.ts +9 -0
  105. package/dist/plots/utils/topojson.js +30 -0
  106. package/dist/types/charts/common.d.ts +2 -1
  107. package/dist/types/charts/legend.d.ts +32 -1
  108. package/dist/types/charts/props.d.ts +9 -6
  109. package/dist/types/configuration/styling.d.ts +13 -5
  110. package/dist/types/layout/legend.d.ts +26 -0
  111. package/dist/types/layout/tooltip.d.ts +14 -21
  112. package/dist/types/{plots/markers.d.ts → markers/common.d.ts} +2 -18
  113. package/dist/types/markers/geo.d.ts +237 -0
  114. package/dist/types/markers/props.d.ts +11 -0
  115. package/dist/types/markers/props.js +1 -0
  116. package/dist/types/plots/axis.d.ts +55 -0
  117. package/dist/types/plots/axis.js +1 -0
  118. package/dist/types/plots/constants.d.ts +30 -0
  119. package/dist/types/plots/constants.js +1 -0
  120. package/dist/types/plots/data/common.d.ts +40 -0
  121. package/dist/types/plots/data/common.js +1 -0
  122. package/dist/types/plots/data/geo.d.ts +74 -0
  123. package/dist/types/plots/data/geo.js +1 -0
  124. package/dist/types/plots/delta.d.ts +2 -2
  125. package/dist/types/plots/props.d.ts +105 -16
  126. package/dist/types/plots/scales/geo.d.ts +44 -0
  127. package/dist/types/plots/scales/geo.js +1 -0
  128. package/dist/types/plots/segments/common.d.ts +25 -0
  129. package/dist/types/plots/segments/common.js +1 -0
  130. package/dist/types/plots/segments/config.d.ts +51 -0
  131. package/dist/types/plots/segments/config.js +1 -0
  132. package/dist/types/plots/styles/common.d.ts +37 -0
  133. package/dist/types/plots/styles/common.js +1 -0
  134. package/dist/types/plots/styles/geo.d.ts +42 -0
  135. package/dist/types/plots/styles/geo.js +1 -0
  136. package/dist/types/plots/styling.d.ts +45 -0
  137. package/dist/types/plots/styling.js +1 -0
  138. package/dist/utils/grouping.d.ts +2 -1
  139. package/dist/utils/interpolate.d.ts +2 -1
  140. package/package.json +27 -13
  141. package/dist/layout/plot/PlotLayout.svelte +0 -294
  142. package/dist/layout/plot/PlotLayout.svelte.d.ts +0 -70
  143. package/dist/plots/markers/HoverMarker.svelte +0 -98
  144. package/dist/types/plots/common.d.ts +0 -97
  145. package/dist/types/plots/styles.d.ts +0 -187
  146. /package/dist/types/{plots/common.js → layout/legend.js} +0 -0
  147. /package/dist/types/{plots/markers.js → markers/common.js} +0 -0
  148. /package/dist/types/{plots/styles.js → markers/geo.js} +0 -0
@@ -0,0 +1,577 @@
1
+ <script lang="ts" generics="TProps extends Record<string, unknown>">
2
+ import { SvelteMap } from 'svelte/reactivity';
3
+ import { Geo, Graticule, Sphere, Dot, Vector, Contour } from 'svelteplot';
4
+ import { resolveAccessor } from '../utils/accessors';
5
+ import { resolveFeatureStyle } from '../utils/geoSegments';
6
+ import {
7
+ geometryCentroid,
8
+ geometryPartCentroids,
9
+ filterGeometryParts,
10
+ defaultFeatureId,
11
+ greatCircleInterpolate,
12
+ } from '../utils/geoAccessors';
13
+ import { isTopology, topologyToGeoJson } from '../utils/topojson';
14
+ import { makeColorScale } from '../../utils/color';
15
+ import { getConfiguration } from '../../configuration/config.svelte';
16
+ import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
17
+ import { disabledFillOverride } from '../utils/legendDisabled';
18
+ import { GEO_MARGIN_ESTIMATE } from '../../layout/plot/margins';
19
+ import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
20
+ import GeoLayout from '../../layout/geo/GeoLayout.svelte';
21
+ import { resolveProjection } from '../../layout/geo/resolveProjection';
22
+ import HoverMarker from '../../markers/HoverMarker.svelte';
23
+ import { createGeoZoom } from './zoom.svelte';
24
+ import { createGeoRotate, type GeoRotate } from './rotate.svelte';
25
+ import InsetMarker from '../../markers/inset/InsetMarker.svelte';
26
+ import ContourClipMarker from '../../markers/ContourClipMarker.svelte';
27
+ import type { BasePlotProps, GeoScalesConfig } from '../../types/plots/props';
28
+ import type { GeoFeature, GeoDataProps } from '../../types/plots/data/geo';
29
+ import type { GeoStyles } from '../../types/plots/styles/geo';
30
+ import type { GeoSegmentStyle } from '../../types/plots/segments/common';
31
+ import type { GeoMarker, GeoHoverMarkerConfig, GeoDotMarker, GeoInsetMarker } from '../../types/markers/geo';
32
+
33
+ /**
34
+ * A map, rendered from GeoJSON (or, optionally, TopoJSON — see
35
+ * `$lib/plots/utils/topojson`). Unlike the other plot kinds there's no x/y/z
36
+ * axis pair: `data` IS the map. `segments` styles features by id (e.g.
37
+ * `segments.argentina`), `value` + `scales.color`/`styles.colors` drive a
38
+ * continuous choropleth ramp (mirrors heatmap's `z`/`colors` pattern), and
39
+ * everything drawn on top — points, arrows, geodesic links, contours,
40
+ * graticule/sphere — is a `markers` entry.
41
+ */
42
+ type TaggedFeature = GeoFeature<TProps> & { __id: string; __cx: number; __cy: number };
43
+
44
+ type Props = BasePlotProps<
45
+ GeoDataProps<TProps>,
46
+ GeoFeature<TProps>,
47
+ GeoStyles<TProps>,
48
+ GeoSegmentStyle,
49
+ GeoMarker,
50
+ GeoScalesConfig
51
+ >;
52
+
53
+ let {
54
+ data,
55
+ object,
56
+ featureId,
57
+ value,
58
+ excludeParts,
59
+ width,
60
+ height,
61
+ styles = {},
62
+ segments = {},
63
+ markers = [],
64
+ scales = {},
65
+ margins,
66
+ tooltip = undefined,
67
+ }: Props = $props();
68
+
69
+ const cfg = $derived(getConfiguration());
70
+
71
+ // ── Resolve data: GeoJSON is synchronous, TopoJSON is converted async ────
72
+ let topoFeatures = $state<GeoFeature<TProps>[] | null>(null);
73
+
74
+ $effect(() => {
75
+ if (!isTopology(data)) return;
76
+ if (!object) {
77
+ console.warn('GeoPlot: `data` is a TopoJSON Topology but no `object` key was given — nothing will render.');
78
+ topoFeatures = [];
79
+ return;
80
+ }
81
+ let cancelled = false;
82
+ topologyToGeoJson<TProps>(data, object)
83
+ .then((resolved) => {
84
+ if (!cancelled) topoFeatures = resolved;
85
+ })
86
+ .catch((err) => {
87
+ if (!cancelled) {
88
+ console.warn(err instanceof Error ? err.message : String(err));
89
+ topoFeatures = [];
90
+ }
91
+ });
92
+ return () => {
93
+ cancelled = true;
94
+ };
95
+ });
96
+
97
+ const rawFeatures = $derived.by((): GeoFeature<TProps>[] => {
98
+ if (isTopology(data)) return topoFeatures ?? [];
99
+ if (Array.isArray(data)) return data;
100
+ // `FeatureCollection.features` is GeoJSON's own `Feature<G, P>[]`, without
101
+ // the `Record<string, unknown>` intersection `GeoFeature` adds to satisfy
102
+ // this package's generic row-type bound — safe to assert, since every
103
+ // property `GeoFeature` promises is already present.
104
+ return data.features as GeoFeature<TProps>[];
105
+ });
106
+
107
+ const getFeatureId = $derived(
108
+ featureId ? resolveAccessor<GeoFeature<TProps>>(featureId) : defaultFeatureId<TProps>,
109
+ );
110
+
111
+ /**
112
+ * Tags each feature with a plain enumerable `__id`/`__cx`/`__cy` by
113
+ * spreading into a fresh object (never mutating the original — Svelte 5
114
+ * forbids mutating state read inside a `$derived`, and `data` may already
115
+ * be a reactive proxy) — required because svelteplot's `<Pointer>`
116
+ * shallow-clones matched rows, so per-row metadata needed at hover-match
117
+ * time must live as an own enumerable property on the object itself, not a
118
+ * side `Map` keyed by identity.
119
+ *
120
+ * Untouched by `excludeParts` — this is the full geometry, passed to
121
+ * `InsetMarker` as-is, so an inset can show a part regardless of whether
122
+ * `excludeParts` also hides that same part from the main map's own
123
+ * rendering below (see `features`).
124
+ */
125
+ const allFeatures = $derived.by((): TaggedFeature[] =>
126
+ rawFeatures.map((f) => {
127
+ const [cx, cy] = geometryCentroid(f.geometry);
128
+ return { ...f, __id: getFeatureId(f), __cx: cx, __cy: cy } as TaggedFeature;
129
+ }),
130
+ );
131
+
132
+ /**
133
+ * `allFeatures`, reduced per `excludeParts` — this is what actually
134
+ * renders (`styleGroups`/`<Geo>` below) and what `hoverCandidates` derives
135
+ * from. A feature reduced to no parts at all is dropped entirely.
136
+ */
137
+ const features = $derived.by((): TaggedFeature[] => {
138
+ if (!excludeParts) return allFeatures;
139
+ return allFeatures.flatMap((f) => {
140
+ const geometry = filterGeometryParts(f.geometry, (part) => !excludeParts(part, f));
141
+ if (!geometry) return [];
142
+ const [cx, cy] = geometryCentroid(geometry);
143
+ return [{ ...f, geometry, __cx: cx, __cy: cy } as TaggedFeature];
144
+ });
145
+ });
146
+
147
+ // ── Tile-layer / projection compatibility ────────────────────────────────
148
+ $effect(() => {
149
+ if (!styles.tileLayer) return;
150
+ const proj = scales.projection;
151
+ const name = typeof proj === 'string' ? proj : proj?.type;
152
+ if (name && name !== 'mercator' && name !== 'transverse-mercator') {
153
+ console.warn(
154
+ `GeoPlot: styles.tileLayer only renders correctly with a mercator-family projection — got "${name}". Rendering anyway.`,
155
+ );
156
+ }
157
+ });
158
+
159
+ // ── Choropleth color scale (mirrors heatmap's styles.colors/scales.z) ────
160
+ const minColor = $derived(styles.colors?.min ?? cfg.continuous.min);
161
+ const maxColor = $derived(styles.colors?.max ?? cfg.continuous.max);
162
+
163
+ const valueDomain = $derived.by((): [number, number] | null => {
164
+ if (scales.color?.domain) return scales.color.domain;
165
+ if (!value) return null;
166
+ const vals = features.map((f) => value(f)).filter((v): v is number => v != null);
167
+ return vals.length ? [Math.min(...vals), Math.max(...vals)] : null;
168
+ });
169
+
170
+ // ── Legend-driven range filter (mirrors HeatmapPlot's own) ────────────────
171
+ const legendInteraction = $derived(getLegendInteraction());
172
+ const range = $derived(legendInteraction?.continuousRange);
173
+ const effectiveDomain = $derived.by((): [number, number] | null => {
174
+ if (!valueDomain) return null;
175
+ return range && range.mode === 'rescale' ? [range.min, range.max] : valueDomain;
176
+ });
177
+
178
+ const colorScale = $derived(
179
+ effectiveDomain ? makeColorScale(effectiveDomain[0], effectiveDomain[1], minColor, maxColor) : null,
180
+ );
181
+
182
+ function resolveBaseFill(f: TaggedFeature): string {
183
+ if (value && colorScale) {
184
+ const v = value(f);
185
+ if (v != null) return colorScale(v);
186
+ }
187
+ return styles.fill ?? cfg.palette[0];
188
+ }
189
+
190
+ function rangeFilteredStyle(base: GeoSegmentStyle, f: TaggedFeature): GeoSegmentStyle {
191
+ const r = range;
192
+ if (r == null || !value) return base;
193
+ const v = value(f);
194
+ if (v == null || (v >= r.min && v <= r.max)) return base;
195
+ return { ...base, ...disabledFillOverride(r.disabledStyle) };
196
+ }
197
+
198
+ /** Per-marker color ramp for a `dot` marker's `value` channel — independent of the base map's own choropleth ramp. */
199
+ function makeDotColorScale(marker: GeoDotMarker): ((v: number) => string) | null {
200
+ if (!marker.value) return null;
201
+ const getValue = resolveAccessor(marker.value);
202
+ const vals = marker.data.map((d) => Number(getValue(d))).filter((v) => Number.isFinite(v));
203
+ if (!vals.length) return null;
204
+ const domain: [number, number] = [Math.min(...vals), Math.max(...vals)];
205
+ return makeColorScale(
206
+ domain[0],
207
+ domain[1],
208
+ marker.style?.colors?.min ?? cfg.continuous.min,
209
+ marker.style?.colors?.max ?? cfg.continuous.max,
210
+ );
211
+ }
212
+
213
+ // ── Per-feature style resolution, grouped by distinct resolved style ─────
214
+ // (one <Geo> call per group — mirrors heatmap's styleGroups: svelteplot's
215
+ // shared numeric-scale machinery collapses a per-row fillOpacity/strokeWidth
216
+ // *accessor* to a single value, so literal per-call constants are required.)
217
+ function getFeatureStyle(f: TaggedFeature): GeoSegmentStyle {
218
+ return rangeFilteredStyle(resolveFeatureStyle<GeoSegmentStyle>(segments, f.__id), f);
219
+ }
220
+
221
+ type StyleGroup = { style: GeoSegmentStyle; data: TaggedFeature[] };
222
+ const styleGroups = $derived.by((): StyleGroup[] => {
223
+ const groups = new SvelteMap<string, StyleGroup>();
224
+ for (const f of features) {
225
+ const style = getFeatureStyle(f);
226
+ const key = JSON.stringify(style);
227
+ let group = groups.get(key);
228
+ if (!group) {
229
+ group = { style, data: [] };
230
+ groups.set(key, group);
231
+ }
232
+ group.data.push(f);
233
+ }
234
+ return [...groups.values()];
235
+ });
236
+
237
+ /**
238
+ * Hover/click candidates — like `features`, but one row per disjoint
239
+ * geometry part instead of per feature, so a multi-part feature's hover
240
+ * point isn't stranded somewhere between its parts (see
241
+ * `geometryPartCentroids`). Only used for hover matching (`BasePlotLayout`'s
242
+ * `data`/`hoverPoints`) — `<Geo>` itself still renders from
243
+ * `features`/`styleGroups`, one entry per feature, unaffected by this.
244
+ */
245
+ const hoverCandidates = $derived.by((): TaggedFeature[] =>
246
+ features.flatMap((f) => geometryPartCentroids(f.geometry).map(([cx, cy]) => ({ ...f, __cx: cx, __cy: cy }))),
247
+ );
248
+
249
+ // A feature outside the active range is dimmed in place, but never
250
+ // surfaces in hover/tooltip — it's simply absent from the candidate list.
251
+ const hoverPoints = $derived(
252
+ hoverCandidates
253
+ .filter((f) => {
254
+ const r = range;
255
+ if (r == null || !value) return true;
256
+ const v = value(f);
257
+ return v == null || (v >= r.min && v <= r.max);
258
+ })
259
+ .map((f) => ({ x: f.__cx, y: f.__cy, row: f, label: f.__id })),
260
+ );
261
+ const hoverGetX = (f: TaggedFeature) => f.__cx;
262
+ const hoverGetY = (f: TaggedFeature) => f.__cy;
263
+
264
+ /**
265
+ * `markers` otherwise always render after (on top of) the base map's own
266
+ * `styleGroups`, regardless of array order — a marker opts into drawing
267
+ * underneath it instead by setting `behind: true` (see e.g.
268
+ * `GeoSphereMarker.behind`).
269
+ */
270
+ function isBehindMarker(marker: GeoMarker): boolean {
271
+ return (
272
+ (marker.type === 'graticule' || marker.type === 'sphere' || marker.type === 'contour') &&
273
+ marker.behind === true
274
+ );
275
+ }
276
+
277
+ const hoverMarkerConfig = $derived(markers.find((m): m is GeoHoverMarkerConfig => m.type === 'hover'));
278
+
279
+ $effect(() => {
280
+ if (hoverMarkerConfig?.strategy === 'nearest-point') {
281
+ console.warn(
282
+ "GeoPlot: the hover marker's strategy 'nearest-point' isn't implemented yet — falling back to 'nearest-feature'.",
283
+ );
284
+ }
285
+ });
286
+
287
+ const hoverActive = $derived(tooltip != null || hoverMarkerConfig != null);
288
+
289
+ // ── Projection ────────────────────────────────────────────────────────────
290
+ const resolvedProjection = $derived(
291
+ resolveProjection(scales.projection ?? (styles.tileLayer ? 'mercator' : undefined), hoverCandidates),
292
+ );
293
+
294
+ // ── Pan / zoom / rotate — owned here (not GeoLayout) since the resulting
295
+ // rotated projection must reach `BasePlotLayout`'s own `<Plot projection>`
296
+ // prop, and a component nested *inside* that `<Plot>` (as GeoLayout is)
297
+ // has no way to reach back out to a prop on its own ancestor. GeoLayout
298
+ // only renders the *result* (`zoomTransform`) as its `<g transform>`. ────
299
+ let containerEl = $state<HTMLDivElement>();
300
+ const zoomEnabled = $derived(styles.zoom !== false && styles.zoom != null);
301
+ const zoomConfig = $derived(typeof styles.zoom === 'object' ? styles.zoom : undefined);
302
+
303
+ /**
304
+ * Orthographic (and other azimuthal, hemisphere-style) projections don't
305
+ * gain anything from drag-to-*pan* — panning just slides the same globe a
306
+ * few pixels within the frame — so when `zoom` is enabled on one, drag
307
+ * instead rotates the globe (`createGeoRotate`) and wheel/pinch keeps
308
+ * scaling it (`createGeoZoom`, with its own drag disabled below so the two
309
+ * gestures don't fight over the same pointer events).
310
+ */
311
+ const projectionType = $derived(
312
+ typeof resolvedProjection === 'string' ? resolvedProjection : resolvedProjection?.type,
313
+ );
314
+ const rotateEnabled = $derived(zoomEnabled && projectionType === 'orthographic');
315
+ const baseRotate = $derived.by((): GeoRotate => {
316
+ const configured = typeof resolvedProjection === 'object' ? resolvedProjection?.rotate : undefined;
317
+ return [configured?.[0] ?? 0, configured?.[1] ?? 0, configured?.[2] ?? 0];
318
+ });
319
+
320
+ const geoZoom = createGeoZoom({
321
+ enabled: () => zoomEnabled,
322
+ config: () => zoomConfig,
323
+ node: () => containerEl ?? null,
324
+ disableDrag: () => rotateEnabled,
325
+ });
326
+
327
+ const geoRotate = createGeoRotate({
328
+ enabled: () => rotateEnabled,
329
+ baseRotate: () => baseRotate,
330
+ scale: () => geoZoom.transform.k,
331
+ node: () => containerEl ?? null,
332
+ });
333
+
334
+ const finalProjection = $derived.by(() => {
335
+ if (!rotateEnabled) return resolvedProjection;
336
+ return {
337
+ ...(typeof resolvedProjection === 'string' ? { type: resolvedProjection } : resolvedProjection),
338
+ rotate: geoRotate.rotate,
339
+ };
340
+ });
341
+
342
+ /**
343
+ * This plot's own hover must never fire while the cursor is actually over
344
+ * an `inset` marker's panel — an inset draws its own content at its own
345
+ * scale/projection, so the main map's `Pointer` has no way to know it's
346
+ * there and would otherwise still match/show whatever main-map feature
347
+ * happens to be nearest that screen position underneath the inset.
348
+ *
349
+ * Checked via `e.target` (which `InsetMarker`'s own hit-test `<rect>`, or
350
+ * one of its feature `<path>`s, tags with the `dct-geo-inset` ancestor —
351
+ * see `InsetMarker.svelte`) rather than by comparing pixel coordinates against a
352
+ * precomputed inset box: svelteplot's `<Plot>` doesn't necessarily render
353
+ * at a 1:1 scale between its `width`/`height` props and actual client
354
+ * pixels (its internal `viewBox` can differ), which would silently desync
355
+ * any independently-computed pixel math from what's really on screen. The
356
+ * browser's own hit-testing has no such problem — it already resolves
357
+ * `e.target` against whatever is actually painted, accounting for any such
358
+ * scaling automatically.
359
+ */
360
+ function isOverInset(e: PointerEvent): boolean {
361
+ return (e.target as Element | null)?.closest?.('.dct-geo-inset') != null;
362
+ }
363
+ </script>
364
+
365
+ {#snippet marker_(marker: GeoMarker, numericMargins: { top: number; right: number; bottom: number; left: number })}
366
+ {#if marker.type === 'graticule'}
367
+ <Graticule
368
+ step={marker.step}
369
+ stepX={marker.stepX}
370
+ stepY={marker.stepY}
371
+ stroke={marker.style?.stroke ?? 'currentColor'}
372
+ strokeWidth={marker.style?.strokeWidth ?? 0.5}
373
+ strokeOpacity={marker.style?.strokeOpacity ?? 0.3}
374
+ strokeDasharray={marker.style?.strokeDasharray}
375
+ style="vector-effect: non-scaling-stroke"
376
+ />
377
+ {:else if marker.type === 'sphere'}
378
+ <Sphere
379
+ fill={marker.style?.fill}
380
+ fillOpacity={marker.style?.fillOpacity}
381
+ stroke={marker.style?.stroke ?? 'currentColor'}
382
+ strokeWidth={marker.style?.strokeWidth ?? 1}
383
+ style="vector-effect: non-scaling-stroke"
384
+ />
385
+ {:else if marker.type === 'dot'}
386
+ {@const getDotValue = marker.value ? resolveAccessor(marker.value) : null}
387
+ {@const dotColorScale = getDotValue ? makeDotColorScale(marker) : null}
388
+ <Dot
389
+ data={marker.data}
390
+ x={marker.x}
391
+ y={marker.y}
392
+ r={marker.r ?? marker.style?.dotRadius ?? 4}
393
+ fill={dotColorScale && getDotValue
394
+ ? (d: Record<string, unknown>) => dotColorScale(Number(getDotValue(d)))
395
+ : (marker.style?.dotFill ?? cfg.palette[0])}
396
+ fillOpacity={marker.style?.dotFillOpacity ?? 1}
397
+ stroke={marker.style?.dotStroke}
398
+ strokeWidth={marker.style?.dotStrokeWidth}
399
+ />
400
+ {:else if marker.type === 'vector'}
401
+ <Vector
402
+ data={marker.data}
403
+ x={marker.x}
404
+ y={marker.y}
405
+ length={marker.length}
406
+ rotate={marker.rotate}
407
+ shape={marker.shape ?? 'arrow'}
408
+ anchor={marker.anchor}
409
+ fill={marker.style?.fill ?? 'currentColor'}
410
+ stroke={marker.style?.stroke}
411
+ strokeWidth={marker.style?.strokeWidth}
412
+ />
413
+ {:else if marker.type === 'link'}
414
+ {@const getX1 = resolveAccessor(marker.x1)}
415
+ {@const getY1 = resolveAccessor(marker.y1)}
416
+ {@const getX2 = resolveAccessor(marker.x2)}
417
+ {@const getY2 = resolveAccessor(marker.y2)}
418
+ {@const linkFeatures = marker.data.map((d) => ({
419
+ type: 'Feature' as const,
420
+ properties: {},
421
+ geometry: {
422
+ type: 'LineString' as const,
423
+ coordinates:
424
+ marker.curve === 'linear'
425
+ ? [
426
+ [Number(getX1(d)), Number(getY1(d))],
427
+ [Number(getX2(d)), Number(getY2(d))],
428
+ ]
429
+ : greatCircleInterpolate(Number(getX1(d)), Number(getY1(d)), Number(getX2(d)), Number(getY2(d))),
430
+ },
431
+ }))}
432
+ <!--
433
+ No `fill` prop here, deliberately: svelteplot's channel resolution
434
+ (`toChannelOption`) treats *any* string value — including the
435
+ literal `'none'` this used to pass — as scale-worthy unless it
436
+ parses as a real CSS color, so `fill="none"` was getting run
437
+ through the plot's color scale and painted an arbitrary category
438
+ color instead of staying unfilled (confirmed by inspecting the
439
+ rendered DOM). Omitting the prop entirely leaves svelteplot's own
440
+ per-mark default in place, which is `fill: none` for an unfilled
441
+ channel — the outcome this mark actually wants, without the buggy
442
+ detour through the color scale.
443
+ -->
444
+ <Geo
445
+ class="link"
446
+ data={linkFeatures}
447
+ stroke={marker.style?.stroke ?? 'currentColor'}
448
+ strokeWidth={marker.style?.strokeWidth ?? 1}
449
+ strokeOpacity={marker.style?.strokeOpacity}
450
+ strokeDasharray={marker.style?.strokeDasharray}
451
+ style="vector-effect: non-scaling-stroke"
452
+ />
453
+ {:else if marker.type === 'contour'}
454
+ <!--
455
+ `fill`/`stroke`: no `?? 'none'` fallback here, deliberately — same
456
+ bug as the `link` marker above. Omitting the prop when
457
+ `marker.fill` is unset leaves svelteplot's own per-mark default in
458
+ place (`fill="none"`, per Contour's own doc comment), instead of
459
+ running the literal string `'none'` through the plot's color scale
460
+ (`'none'` isn't a recognized CSS color, so svelteplot doesn't treat
461
+ it as a literal output value the way it does e.g. `'currentColor'`).
462
+ -->
463
+ <ContourClipMarker mode={marker.clip} {features}>
464
+ <Contour
465
+ class="contour"
466
+ data={marker.data}
467
+ x={marker.x}
468
+ y={marker.y}
469
+ value={marker.value}
470
+ thresholds={marker.thresholds}
471
+ fill={marker.fill}
472
+ stroke={marker.stroke ?? 'currentColor'}
473
+ strokeWidth={marker.style?.strokeWidth}
474
+ fillOpacity={marker.style?.fillOpacity}
475
+ strokeOpacity={marker.style?.strokeOpacity}
476
+ blur={marker.blur}
477
+ interpolate={marker.interpolate}
478
+ />
479
+ </ContourClipMarker>
480
+ {:else if marker.type === 'component'}
481
+ <marker.component {...marker.props} data={marker.data ?? features} />
482
+ {:else if marker.type === 'inset'}
483
+ {@const insetContentBox = {
484
+ left: numericMargins.left,
485
+ top: numericMargins.top,
486
+ width: Math.max(0, width - numericMargins.left - numericMargins.right),
487
+ height: Math.max(0, height - numericMargins.top - numericMargins.bottom),
488
+ }}
489
+ <InsetMarker
490
+ marker={marker as GeoInsetMarker<TProps>}
491
+ features={allFeatures}
492
+ width={insetContentBox.width}
493
+ height={insetContentBox.height}
494
+ left={insetContentBox.left}
495
+ top={insetContentBox.top}
496
+ defaultFill={cfg.palette[0]}
497
+ />
498
+ {/if}
499
+ {/snippet}
500
+
501
+ <!--
502
+ `x`/`y`: pins the plot's generic linear x/y scales to the full lon/lat
503
+ range. Every geo-aware mark (Geo, Dot, Vector, Link, …) bypasses these
504
+ entirely when a projection is active, routing through it instead — except
505
+ svelteplot's own `Contour` mark, whose scatter/sampler-mode grid is
506
+ hardcoded to read `plot.scales.x`/`y` (see `Contour.svelte`) regardless of
507
+ any active projection. Left unset, that scale would auto-fit to just the
508
+ contour data's own point extent — a different frame than the projection's
509
+ own (typically whole-world) fit, so the contour would sit at the wrong
510
+ scale/position relative to the map under it. Pinning both to the world
511
+ range only helps `contour` markers align (best exactly under an
512
+ `equirectangular` projection, whose x/y truly are linear in lon/lat) and
513
+ is a no-op for every other mark type.
514
+ -->
515
+ <BasePlotLayout
516
+ {width}
517
+ {height}
518
+ data={hoverCandidates}
519
+ getX={hoverGetX}
520
+ getY={hoverGetY}
521
+ {hoverPoints}
522
+ label={(f) => f.__id}
523
+ {tooltip}
524
+ {hoverActive}
525
+ hoverStrategy="punctual"
526
+ hoverSync={tooltip?.sync ?? false}
527
+ x={{ domain: [-180, 180] }}
528
+ y={{ domain: [90, -90] }}
529
+ projection={finalProjection}
530
+ {margins}
531
+ marginEstimate={GEO_MARGIN_ESTIMATE}
532
+ excludeHover={isOverInset}
533
+ touchAction={zoomEnabled ? 'none' : undefined}
534
+ bind:containerEl
535
+ >
536
+ {#snippet children({ matchedPoints, numericMargins })}
537
+ <GeoLayout {width} {height} {numericMargins} zoomTransform={geoZoom.transform} tileLayer={styles.tileLayer}>
538
+ {#each markers as marker, i (marker.type + '-' + i)}
539
+ {#if isBehindMarker(marker)}
540
+ {@render marker_(marker, numericMargins)}
541
+ {/if}
542
+ {/each}
543
+
544
+ {#each styleGroups as group (JSON.stringify(group.style))}
545
+ <Geo
546
+ data={group.data}
547
+ fill={(d: TaggedFeature) => group.style.fill ?? resolveBaseFill(d)}
548
+ fillOpacity={group.style.fillOpacity ?? styles.fillOpacity ?? 1}
549
+ stroke={group.style.stroke ?? styles.stroke ?? cfg.axis.color}
550
+ strokeWidth={group.style.strokeWidth ?? styles.strokeWidth ?? 0.5}
551
+ style="vector-effect: non-scaling-stroke"
552
+ onclick={styles.onFeatureClick
553
+ ? (e: Event, d: TaggedFeature) => styles.onFeatureClick?.(d, e)
554
+ : undefined}
555
+ />
556
+ {/each}
557
+
558
+ {#each markers as marker, i (marker.type + '-' + i)}
559
+ {#if !isBehindMarker(marker)}
560
+ {@render marker_(marker, numericMargins)}
561
+ {/if}
562
+ {/each}
563
+ </GeoLayout>
564
+
565
+ {#if hoverMarkerConfig}
566
+ <HoverMarker
567
+ data={matchedPoints}
568
+ ruleX={null}
569
+ ruleY={null}
570
+ showLabels={hoverMarkerConfig.showLabels ?? true}
571
+ format={hoverMarkerConfig.format}
572
+ dotStyle={hoverMarkerConfig.dotStyle}
573
+ fontStyle={hoverMarkerConfig.fontStyle}
574
+ />
575
+ {/if}
576
+ {/snippet}
577
+ </BasePlotLayout>
@@ -0,0 +1,29 @@
1
+ import type { BasePlotProps, GeoScalesConfig } from '../../types/plots/props';
2
+ import type { GeoFeature, GeoDataProps } from '../../types/plots/data/geo';
3
+ import type { GeoStyles } from '../../types/plots/styles/geo';
4
+ import type { GeoSegmentStyle } from '../../types/plots/segments/common';
5
+ import type { GeoMarker } from '../../types/markers/geo';
6
+ declare function $$render<TProps extends Record<string, unknown>>(): {
7
+ props: BasePlotProps<GeoDataProps<TProps>, GeoFeature<TProps>, GeoStyles<TProps>, GeoSegmentStyle, GeoMarker, GeoScalesConfig>;
8
+ exports: {};
9
+ bindings: "";
10
+ slots: {};
11
+ events: {};
12
+ };
13
+ declare class __sveltets_Render<TProps extends Record<string, unknown>> {
14
+ props(): ReturnType<typeof $$render<TProps>>['props'];
15
+ events(): ReturnType<typeof $$render<TProps>>['events'];
16
+ slots(): ReturnType<typeof $$render<TProps>>['slots'];
17
+ bindings(): "";
18
+ exports(): {};
19
+ }
20
+ interface $$IsomorphicComponent {
21
+ new <TProps extends Record<string, unknown>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TProps>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TProps>['props']>, ReturnType<__sveltets_Render<TProps>['events']>, ReturnType<__sveltets_Render<TProps>['slots']>> & {
22
+ $$bindings?: ReturnType<__sveltets_Render<TProps>['bindings']>;
23
+ } & ReturnType<__sveltets_Render<TProps>['exports']>;
24
+ <TProps extends Record<string, unknown>>(internal: unknown, props: ReturnType<__sveltets_Render<TProps>['props']> & {}): ReturnType<__sveltets_Render<TProps>['exports']>;
25
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
26
+ }
27
+ declare const Plot: $$IsomorphicComponent;
28
+ type Plot<TProps extends Record<string, unknown>> = InstanceType<typeof Plot<TProps>>;
29
+ export default Plot;
@@ -0,0 +1,77 @@
1
+ <script lang="ts">
2
+ import { usePlot } from 'svelteplot';
3
+ import {
4
+ visibleTiles,
5
+ buildMercatorFit,
6
+ tileScreenBounds,
7
+ tileUrl,
8
+ toProjectionLike,
9
+ type StreamingProjection,
10
+ } from '../utils/tiles';
11
+ import type { GeoTileLayerConfig } from '../../types/plots/styles/geo';
12
+
13
+ /**
14
+ * Renders an XYZ raster tile basemap under the vector geometry. Mounted as
15
+ * a child of svelteplot's `<Plot>` so `usePlot()` resolves its already-fit
16
+ * projection — this module never depends on `d3-geo` directly.
17
+ *
18
+ * v1 limitation: the visible tile grid is computed once for the initial
19
+ * view, not re-fetched as the pan/zoom `<g>` transform changes — tiles pan
20
+ * and scale visually with the map, but don't reveal new coverage when
21
+ * panned far, and pixelate past their native resolution when zoomed in.
22
+ */
23
+ let {
24
+ config,
25
+ left,
26
+ top,
27
+ width,
28
+ height,
29
+ }: {
30
+ config: GeoTileLayerConfig;
31
+ left: number;
32
+ top: number;
33
+ width: number;
34
+ height: number;
35
+ } = $props();
36
+
37
+ const plot = usePlot();
38
+
39
+ const tileSize = $derived(config.tileSize ?? 256);
40
+
41
+ // svelteplot's own `PlotScale` type doesn't reflect this, but at runtime
42
+ // `scales.projection` is `createProjection()`'s `{ stream, invert }`
43
+ // result (verified against svelteplot's own `helpers/projection.js`).
44
+ const rawProjection = $derived(plot.scales.projection as unknown as StreamingProjection | undefined);
45
+ const projection = $derived(rawProjection ? toProjectionLike(rawProjection) : undefined);
46
+
47
+ const tiles = $derived.by(() => {
48
+ if (!projection) return [];
49
+ return visibleTiles(projection, { left, top, width, height }, tileSize, config.minZoom ?? 0, config.maxZoom ?? 19);
50
+ });
51
+
52
+ // One affine fit per zoom level, shared across every tile in `tiles` — see
53
+ // `buildMercatorFit`'s doc comment for why this replaces forward-projecting
54
+ // each tile's own corners individually.
55
+ const fit = $derived.by(() => {
56
+ if (!projection || tiles.length === 0) return null;
57
+ return buildMercatorFit(projection, { left, top, width, height }, tiles[0].z);
58
+ });
59
+ </script>
60
+
61
+ {#if projection && fit}
62
+ <g class="dct-geo-tiles" opacity={config.opacity ?? 1}>
63
+ {#each tiles as tile (`${tile.z}/${tile.x}/${tile.y}`)}
64
+ {@const bounds = tileScreenBounds(fit, tile)}
65
+ {#if bounds}
66
+ <image
67
+ href={tileUrl(config.url, tile)}
68
+ x={bounds.left}
69
+ y={bounds.top}
70
+ width={bounds.width}
71
+ height={bounds.height}
72
+ preserveAspectRatio="none"
73
+ />
74
+ {/if}
75
+ {/each}
76
+ </g>
77
+ {/if}