@fundar/data-chart-telling 0.0.18 → 0.0.20

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.
@@ -22,6 +22,7 @@
22
22
  numericMargins,
23
23
  zoomTransform,
24
24
  tileLayer,
25
+ dataLonLatBounds,
25
26
  children,
26
27
  }: {
27
28
  width: number;
@@ -29,6 +30,8 @@
29
30
  numericMargins: { top: number; right: number; bottom: number; left: number };
30
31
  zoomTransform: { x: number; y: number; k: number };
31
32
  tileLayer?: GeoTileLayerConfig;
33
+ /** Forwarded to `TileLayer` — see its own prop doc / `clipBoundsToData`. */
34
+ dataLonLatBounds?: [number, number, number, number] | null;
32
35
  children: Snippet;
33
36
  } = $props();
34
37
  </script>
@@ -41,6 +44,7 @@
41
44
  top={numericMargins.top}
42
45
  width={Math.max(0, width - numericMargins.left - numericMargins.right)}
43
46
  height={Math.max(0, height - numericMargins.top - numericMargins.bottom)}
47
+ {dataLonLatBounds}
44
48
  />
45
49
  {/if}
46
50
  {@render children()}
@@ -15,6 +15,8 @@ type $$ComponentProps = {
15
15
  k: number;
16
16
  };
17
17
  tileLayer?: GeoTileLayerConfig;
18
+ /** Forwarded to `TileLayer` — see its own prop doc / `clipBoundsToData`. */
19
+ dataLonLatBounds?: [number, number, number, number] | null;
18
20
  children: Snippet;
19
21
  };
20
22
  declare const GeoLayout: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -5,6 +5,8 @@
5
5
  import { resolveFeatureStyle } from '../utils/geoSegments';
6
6
  import {
7
7
  geometryCentroid,
8
+ geometryLonLatBounds,
9
+ mercatorAspectRatio,
8
10
  geometryPartCentroids,
9
11
  filterGeometryParts,
10
12
  defaultFeatureId,
@@ -144,6 +146,66 @@
144
146
  });
145
147
  });
146
148
 
149
+ // ── Data footprint — the rendered geometry's own lon/lat extent. Powers
150
+ // two independent things below: `TileLayer`'s clip-to-data (see
151
+ // `clipBoundsToData`'s doc comment) and `containedSize`'s contain-and-center
152
+ // sizing. Only computed for a `domain: 'data'` fit (see `usesDataFit`) —
153
+ // that's the specific case where the projection's own scale was chosen to
154
+ // match *this* extent, so it's also the only case this bbox is a correct
155
+ // stand-in for "what the projection actually fit to". Cost is a single
156
+ // O(total coordinate count) pass. ────
157
+ const usesDataFit = $derived(
158
+ typeof scales.projection === 'object' && scales.projection?.domain === 'data',
159
+ );
160
+ const dataLonLatBounds = $derived.by((): [number, number, number, number] | null => {
161
+ if (!usesDataFit) return null;
162
+ let minLon = Infinity;
163
+ let maxLon = -Infinity;
164
+ let minLat = Infinity;
165
+ let maxLat = -Infinity;
166
+ for (const f of features) {
167
+ const b = geometryLonLatBounds(f.geometry);
168
+ if (!b) continue;
169
+ if (b[0] < minLon) minLon = b[0];
170
+ if (b[2] > maxLon) maxLon = b[2];
171
+ if (b[1] < minLat) minLat = b[1];
172
+ if (b[3] > maxLat) maxLat = b[3];
173
+ }
174
+ return minLon === Infinity ? null : [minLon, minLat, maxLon, maxLat];
175
+ });
176
+
177
+ /**
178
+ * The box the map itself actually renders into — `width`/`height`
179
+ * shrunk (never grown) to `dataLonLatBounds`' own Mercator aspect ratio,
180
+ * the same way `object-fit: contain` sizes an image. Without this, a
181
+ * `domain: 'data'` fit already internally letterboxes the *projection*
182
+ * to preserve the data's true shape whenever the outer box's aspect
183
+ * doesn't match it (e.g. a tall/narrow country handed a short/wide facet
184
+ * cell) — but previously the outer SVG itself stayed stretched to the
185
+ * full box regardless, leaving that letterboxed margin as dead space
186
+ * (blank, or — before `clipBoundsToData` — filled with irrelevant tiles).
187
+ * Shrinking the actual rendered box to match removes that margin instead
188
+ * of just hiding what was in it; the template below centers this smaller
189
+ * box within the full `{width}×{height}` area so the map still occupies
190
+ * the same visual position within its facet cell.
191
+ *
192
+ * Margins (`margins`/`GEO_MARGIN_ESTIMATE`) are deliberately not netted
193
+ * out of this fit — they're small relative to typical facet-cell sizes,
194
+ * and doing this exactly would mean duplicating `BasePlotLayout`'s own
195
+ * private margin resolution here.
196
+ */
197
+ const containedSize = $derived.by((): { width: number; height: number } => {
198
+ const full = { width, height };
199
+ if (!dataLonLatBounds) return full;
200
+ const aspect = mercatorAspectRatio(dataLonLatBounds);
201
+ if (!aspect) return full;
202
+ const boxAspect = width / height;
203
+ if (!(boxAspect > 0)) return full;
204
+ return aspect > boxAspect
205
+ ? { width, height: width / aspect }
206
+ : { width: height * aspect, height };
207
+ });
208
+
147
209
  // ── Tile-layer / projection compatibility ────────────────────────────────
148
210
  $effect(() => {
149
211
  if (!styles.tileLayer) return;
@@ -483,8 +545,8 @@
483
545
  {@const insetContentBox = {
484
546
  left: numericMargins.left,
485
547
  top: numericMargins.top,
486
- width: Math.max(0, width - numericMargins.left - numericMargins.right),
487
- height: Math.max(0, height - numericMargins.top - numericMargins.bottom),
548
+ width: Math.max(0, containedSize.width - numericMargins.left - numericMargins.right),
549
+ height: Math.max(0, containedSize.height - numericMargins.top - numericMargins.bottom),
488
550
  }}
489
551
  <InsetMarker
490
552
  marker={marker as GeoInsetMarker<TProps>}
@@ -512,9 +574,10 @@
512
574
  `equirectangular` projection, whose x/y truly are linear in lon/lat) and
513
575
  is a no-op for every other mark type.
514
576
  -->
577
+ <div class="dct-geo-contain" style:width="{width}px" style:height="{height}px">
515
578
  <BasePlotLayout
516
- {width}
517
- {height}
579
+ width={containedSize.width}
580
+ height={containedSize.height}
518
581
  data={hoverCandidates}
519
582
  getX={hoverGetX}
520
583
  getY={hoverGetY}
@@ -534,7 +597,14 @@
534
597
  bind:containerEl
535
598
  >
536
599
  {#snippet children({ matchedPoints, numericMargins })}
537
- <GeoLayout {width} {height} {numericMargins} zoomTransform={geoZoom.transform} tileLayer={styles.tileLayer}>
600
+ <GeoLayout
601
+ width={containedSize.width}
602
+ height={containedSize.height}
603
+ {numericMargins}
604
+ zoomTransform={geoZoom.transform}
605
+ tileLayer={styles.tileLayer}
606
+ {dataLonLatBounds}
607
+ >
538
608
  {#each markers as marker, i (marker.type + '-' + i)}
539
609
  {#if isBehindMarker(marker)}
540
610
  {@render marker_(marker, numericMargins)}
@@ -575,3 +645,17 @@
575
645
  {/if}
576
646
  {/snippet}
577
647
  </BasePlotLayout>
648
+ </div>
649
+
650
+ <style>
651
+ /**
652
+ * Centers `BasePlotLayout`'s own (possibly smaller, `containedSize`-fit)
653
+ * box within the full area the facet grid actually allotted this plot —
654
+ * see `containedSize`'s doc comment.
655
+ */
656
+ .dct-geo-contain {
657
+ display: flex;
658
+ align-items: center;
659
+ justify-content: center;
660
+ }
661
+ </style>
@@ -6,6 +6,7 @@
6
6
  tileScreenBounds,
7
7
  tileUrl,
8
8
  toProjectionLike,
9
+ clipBoundsToData,
9
10
  type StreamingProjection,
10
11
  } from '../utils/tiles';
11
12
  import type { GeoTileLayerConfig } from '../../types/plots/styles/geo';
@@ -26,12 +27,15 @@
26
27
  top,
27
28
  width,
28
29
  height,
30
+ dataLonLatBounds,
29
31
  }: {
30
32
  config: GeoTileLayerConfig;
31
33
  left: number;
32
34
  top: number;
33
35
  width: number;
34
36
  height: number;
37
+ /** The rendered geometry's own lon/lat extent — see `clipBoundsToData`. */
38
+ dataLonLatBounds?: [number, number, number, number] | null;
35
39
  } = $props();
36
40
 
37
41
  const plot = usePlot();
@@ -44,9 +48,18 @@
44
48
  const rawProjection = $derived(plot.scales.projection as unknown as StreamingProjection | undefined);
45
49
  const projection = $derived(rawProjection ? toProjectionLike(rawProjection) : undefined);
46
50
 
51
+ // Clipped to where the map's own geometry actually is — see
52
+ // `clipBoundsToData`'s doc comment for why the raw `{left,top,width,height}`
53
+ // box alone isn't safe to hand to `visibleTiles`/`buildMercatorFit`.
54
+ const clippedBounds = $derived.by(() => {
55
+ const full = { left, top, width, height };
56
+ if (!projection) return full;
57
+ return clipBoundsToData(projection, full, dataLonLatBounds);
58
+ });
59
+
47
60
  const tiles = $derived.by(() => {
48
61
  if (!projection) return [];
49
- return visibleTiles(projection, { left, top, width, height }, tileSize, config.minZoom ?? 0, config.maxZoom ?? 19);
62
+ return visibleTiles(projection, clippedBounds, tileSize, config.minZoom ?? 0, config.maxZoom ?? 19);
50
63
  });
51
64
 
52
65
  // One affine fit per zoom level, shared across every tile in `tiles` — see
@@ -54,7 +67,7 @@
54
67
  // each tile's own corners individually.
55
68
  const fit = $derived.by(() => {
56
69
  if (!projection || tiles.length === 0) return null;
57
- return buildMercatorFit(projection, { left, top, width, height }, tiles[0].z);
70
+ return buildMercatorFit(projection, clippedBounds, tiles[0].z);
58
71
  });
59
72
  </script>
60
73
 
@@ -5,6 +5,8 @@ type $$ComponentProps = {
5
5
  top: number;
6
6
  width: number;
7
7
  height: number;
8
+ /** The rendered geometry's own lon/lat extent — see `clipBoundsToData`. */
9
+ dataLonLatBounds?: [number, number, number, number] | null;
8
10
  };
9
11
  declare const TileLayer: import("svelte").Component<$$ComponentProps, {}, "">;
10
12
  type TileLayer = ReturnType<typeof TileLayer>;
@@ -8,6 +8,37 @@ import type { GeoFeature } from '../../types/plots/data/geo';
8
8
  * multi-part geometry whose pieces are geographically spread out.
9
9
  */
10
10
  export declare function geometryCentroid(geometry: GeoJSON.Geometry): [number, number];
11
+ /**
12
+ * A geometry's flattened lon/lat bounding box — `[minLon, minLat, maxLon,
13
+ * maxLat]`, or `null` for a geometry with no coordinates at all. Same
14
+ * bbox-not-geodesic caveat as `geometryCentroid` (no antimeridian handling),
15
+ * acceptable for the same reason: real feature sets fed through this
16
+ * package (Argentina's provinces, etc.) don't straddle it. Used to derive
17
+ * `GeoPlot`'s rendered data's own screen-space extent (see `Plot.svelte`'s
18
+ * `dataLonLatBounds`), so `TileLayer` can clip its raster grid to where the
19
+ * map's own geometry actually is instead of a projection's full letterboxed
20
+ * fit box (see `clipBoundsToData`).
21
+ */
22
+ export declare function geometryLonLatBounds(geometry: GeoJSON.Geometry): [number, number, number, number] | null;
23
+ /**
24
+ * The width:height aspect ratio a Mercator(-family) projection naturally
25
+ * produces for a `[minLon, minLat, maxLon, maxLat]` bounding box — i.e.
26
+ * what a `domain: 'data'`-style projection fit (see `resolveProjection.ts`)
27
+ * implicitly commits to before any letterboxing into a differently-shaped
28
+ * box. `null` for a degenerate box: no lon or lat span, or a latitude
29
+ * at/past ±90° where Mercator's `y` isn't finite.
30
+ *
31
+ * Longitude maps linearly to Mercator's `x`; latitude maps through the
32
+ * standard `ln(tan(π/4 + φ/2))` transform to `y` — the same one
33
+ * `tiles.ts`'s `latToTileY` uses, left unscaled by tile count here since
34
+ * only the *ratio* between the two axes is wanted. Both axes share the same
35
+ * scale by construction (Mercator is conformal), so this ratio is exactly
36
+ * what a real `d3-geo` fit would produce for the same bbox — used by
37
+ * `GeoPlot` to size itself down to its own data's footprint instead of
38
+ * stretching into whatever box a facet grid happens to hand it (see
39
+ * `Plot.svelte`'s `containedSize`).
40
+ */
41
+ export declare function mercatorAspectRatio(bounds: [number, number, number, number]): number | null;
11
42
  /**
12
43
  * Splits a geometry into its disjoint parts: one `Polygon` per element of a
13
44
  * `MultiPolygon`'s `coordinates`, one `LineString` per element of a
@@ -61,6 +61,68 @@ export function geometryCentroid(geometry) {
61
61
  flattenCoords(geometry, coords);
62
62
  return boundsCenter(coords);
63
63
  }
64
+ /**
65
+ * A geometry's flattened lon/lat bounding box — `[minLon, minLat, maxLon,
66
+ * maxLat]`, or `null` for a geometry with no coordinates at all. Same
67
+ * bbox-not-geodesic caveat as `geometryCentroid` (no antimeridian handling),
68
+ * acceptable for the same reason: real feature sets fed through this
69
+ * package (Argentina's provinces, etc.) don't straddle it. Used to derive
70
+ * `GeoPlot`'s rendered data's own screen-space extent (see `Plot.svelte`'s
71
+ * `dataLonLatBounds`), so `TileLayer` can clip its raster grid to where the
72
+ * map's own geometry actually is instead of a projection's full letterboxed
73
+ * fit box (see `clipBoundsToData`).
74
+ */
75
+ export function geometryLonLatBounds(geometry) {
76
+ const coords = [];
77
+ flattenCoords(geometry, coords);
78
+ if (coords.length === 0)
79
+ return null;
80
+ let minLon = Infinity;
81
+ let maxLon = -Infinity;
82
+ let minLat = Infinity;
83
+ let maxLat = -Infinity;
84
+ for (const [lon, lat] of coords) {
85
+ if (lon < minLon)
86
+ minLon = lon;
87
+ if (lon > maxLon)
88
+ maxLon = lon;
89
+ if (lat < minLat)
90
+ minLat = lat;
91
+ if (lat > maxLat)
92
+ maxLat = lat;
93
+ }
94
+ return [minLon, minLat, maxLon, maxLat];
95
+ }
96
+ /**
97
+ * The width:height aspect ratio a Mercator(-family) projection naturally
98
+ * produces for a `[minLon, minLat, maxLon, maxLat]` bounding box — i.e.
99
+ * what a `domain: 'data'`-style projection fit (see `resolveProjection.ts`)
100
+ * implicitly commits to before any letterboxing into a differently-shaped
101
+ * box. `null` for a degenerate box: no lon or lat span, or a latitude
102
+ * at/past ±90° where Mercator's `y` isn't finite.
103
+ *
104
+ * Longitude maps linearly to Mercator's `x`; latitude maps through the
105
+ * standard `ln(tan(π/4 + φ/2))` transform to `y` — the same one
106
+ * `tiles.ts`'s `latToTileY` uses, left unscaled by tile count here since
107
+ * only the *ratio* between the two axes is wanted. Both axes share the same
108
+ * scale by construction (Mercator is conformal), so this ratio is exactly
109
+ * what a real `d3-geo` fit would produce for the same bbox — used by
110
+ * `GeoPlot` to size itself down to its own data's footprint instead of
111
+ * stretching into whatever box a facet grid happens to hand it (see
112
+ * `Plot.svelte`'s `containedSize`).
113
+ */
114
+ export function mercatorAspectRatio(bounds) {
115
+ const [minLon, minLat, maxLon, maxLat] = bounds;
116
+ if (minLat <= -90 || maxLat >= 90)
117
+ return null;
118
+ const toRad = (deg) => (deg * Math.PI) / 180;
119
+ const lonSpan = toRad(maxLon - minLon);
120
+ const mercatorY = (lat) => Math.log(Math.tan(Math.PI / 4 + toRad(lat) / 2));
121
+ const latSpan = Math.abs(mercatorY(maxLat) - mercatorY(minLat));
122
+ if (!(lonSpan > 0) || !(latSpan > 0) || !Number.isFinite(latSpan))
123
+ return null;
124
+ return lonSpan / latSpan;
125
+ }
64
126
  /**
65
127
  * Splits a geometry into its disjoint parts: one `Polygon` per element of a
66
128
  * `MultiPolygon`'s `coordinates`, one `LineString` per element of a
@@ -36,6 +36,32 @@ export type StreamingProjection = {
36
36
  * projected output.
37
37
  */
38
38
  export declare function toProjectionLike(projection: StreamingProjection): ProjectionLike;
39
+ /**
40
+ * Clips `bounds` down to the screen-space box its actual geo data occupies
41
+ * — `dataLonLatBounds`' four extremes, forward-projected — intersected with
42
+ * `bounds` itself.
43
+ *
44
+ * A `domain: 'data'`-fitted Mercator projection scales to the *constraining*
45
+ * axis of its box (see `clampBoundsToWorld`'s doc comment): a tall/narrow
46
+ * region (Argentina) fit into a short/wide facet cell only fills a fraction
47
+ * of the box's width, and the letterboxed margin on either side is real,
48
+ * inverts to real (if occasionally distant) lon/lat, and so gets real tiles
49
+ * drawn under it — `clampBoundsToWorld` only stops that margin from
50
+ * wrapping *more* than one world-width once inverted, it doesn't stop a
51
+ * *single* world-width's worth of legitimate-but-irrelevant coverage from
52
+ * rendering there. For Argentina specifically that margin is wide enough to
53
+ * reach Argentina's own antipodal region — Australia — which is what a
54
+ * viewer actually sees. Clipping to the data's own footprint removes the
55
+ * letterboxed margin's tiles entirely rather than trying to pick a
56
+ * "more correct" set of tiles to show there.
57
+ *
58
+ * Only valid for a Mercator-family projection, same as the rest of this
59
+ * module: `x` is assumed to depend only on longitude and `y` only on
60
+ * latitude, so each axis's screen extent can be found by projecting just
61
+ * two opposite corners of the lon/lat bbox rather than tracing its full
62
+ * outline.
63
+ */
64
+ export declare function clipBoundsToData(projection: ProjectionLike, bounds: ScreenBounds, dataLonLatBounds: [number, number, number, number] | null | undefined): ScreenBounds;
39
65
  /**
40
66
  * Derives the visible XYZ tile grid from svelteplot's own already-fitted
41
67
  * projection (rather than this package computing/duplicating a `d3-geo` fit
@@ -85,6 +85,55 @@ function clampBoundsToWorld(projection, bounds) {
85
85
  return bounds;
86
86
  return { ...bounds, left: centerX - worldWidth / 2, width: worldWidth };
87
87
  }
88
+ /**
89
+ * Clips `bounds` down to the screen-space box its actual geo data occupies
90
+ * — `dataLonLatBounds`' four extremes, forward-projected — intersected with
91
+ * `bounds` itself.
92
+ *
93
+ * A `domain: 'data'`-fitted Mercator projection scales to the *constraining*
94
+ * axis of its box (see `clampBoundsToWorld`'s doc comment): a tall/narrow
95
+ * region (Argentina) fit into a short/wide facet cell only fills a fraction
96
+ * of the box's width, and the letterboxed margin on either side is real,
97
+ * inverts to real (if occasionally distant) lon/lat, and so gets real tiles
98
+ * drawn under it — `clampBoundsToWorld` only stops that margin from
99
+ * wrapping *more* than one world-width once inverted, it doesn't stop a
100
+ * *single* world-width's worth of legitimate-but-irrelevant coverage from
101
+ * rendering there. For Argentina specifically that margin is wide enough to
102
+ * reach Argentina's own antipodal region — Australia — which is what a
103
+ * viewer actually sees. Clipping to the data's own footprint removes the
104
+ * letterboxed margin's tiles entirely rather than trying to pick a
105
+ * "more correct" set of tiles to show there.
106
+ *
107
+ * Only valid for a Mercator-family projection, same as the rest of this
108
+ * module: `x` is assumed to depend only on longitude and `y` only on
109
+ * latitude, so each axis's screen extent can be found by projecting just
110
+ * two opposite corners of the lon/lat bbox rather than tracing its full
111
+ * outline.
112
+ */
113
+ export function clipBoundsToData(projection, bounds, dataLonLatBounds) {
114
+ if (!dataLonLatBounds)
115
+ return bounds;
116
+ const [minLon, minLat, maxLon, maxLat] = dataLonLatBounds;
117
+ const midLon = (minLon + maxLon) / 2;
118
+ const midLat = (minLat + maxLat) / 2;
119
+ const west = projection([minLon, midLat]);
120
+ const east = projection([maxLon, midLat]);
121
+ const south = projection([midLon, minLat]);
122
+ const north = projection([midLon, maxLat]);
123
+ if (!west || !east || !south || !north)
124
+ return bounds;
125
+ const dataLeft = Math.min(west[0], east[0]);
126
+ const dataRight = Math.max(west[0], east[0]);
127
+ const dataTop = Math.min(north[1], south[1]);
128
+ const dataBottom = Math.max(north[1], south[1]);
129
+ const left = Math.max(bounds.left, dataLeft);
130
+ const top = Math.max(bounds.top, dataTop);
131
+ const right = Math.min(bounds.left + bounds.width, dataRight);
132
+ const bottom = Math.min(bounds.top + bounds.height, dataBottom);
133
+ if (!(right > left) || !(bottom > top))
134
+ return bounds;
135
+ return { left, top, width: right - left, height: bottom - top };
136
+ }
88
137
  /**
89
138
  * Derives the visible XYZ tile grid from svelteplot's own already-fitted
90
139
  * projection (rather than this package computing/duplicating a `d3-geo` fit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"