@fundar/data-chart-telling 0.0.19 → 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.
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  geometryCentroid,
8
8
  geometryLonLatBounds,
9
+ mercatorAspectRatio,
9
10
  geometryPartCentroids,
10
11
  filterGeometryParts,
11
12
  defaultFeatureId,
@@ -145,13 +146,19 @@
145
146
  });
146
147
  });
147
148
 
148
- // ── Tile-layer clipping — the rendered geometry's own lon/lat extent, so
149
- // `TileLayer` can clip its raster grid to where the map actually is
150
- // instead of a `domain: 'data'` fit's full (possibly letterboxed) box
151
- // see `clipBoundsToData`'s doc comment. Only computed when a tile layer is
152
- // actually configured, since it's an O(total coordinate count) pass. ────
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
+ );
153
160
  const dataLonLatBounds = $derived.by((): [number, number, number, number] | null => {
154
- if (!styles.tileLayer) return null;
161
+ if (!usesDataFit) return null;
155
162
  let minLon = Infinity;
156
163
  let maxLon = -Infinity;
157
164
  let minLat = Infinity;
@@ -167,6 +174,38 @@
167
174
  return minLon === Infinity ? null : [minLon, minLat, maxLon, maxLat];
168
175
  });
169
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
+
170
209
  // ── Tile-layer / projection compatibility ────────────────────────────────
171
210
  $effect(() => {
172
211
  if (!styles.tileLayer) return;
@@ -506,8 +545,8 @@
506
545
  {@const insetContentBox = {
507
546
  left: numericMargins.left,
508
547
  top: numericMargins.top,
509
- width: Math.max(0, width - numericMargins.left - numericMargins.right),
510
- 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),
511
550
  }}
512
551
  <InsetMarker
513
552
  marker={marker as GeoInsetMarker<TProps>}
@@ -535,9 +574,10 @@
535
574
  `equirectangular` projection, whose x/y truly are linear in lon/lat) and
536
575
  is a no-op for every other mark type.
537
576
  -->
577
+ <div class="dct-geo-contain" style:width="{width}px" style:height="{height}px">
538
578
  <BasePlotLayout
539
- {width}
540
- {height}
579
+ width={containedSize.width}
580
+ height={containedSize.height}
541
581
  data={hoverCandidates}
542
582
  getX={hoverGetX}
543
583
  getY={hoverGetY}
@@ -558,8 +598,8 @@
558
598
  >
559
599
  {#snippet children({ matchedPoints, numericMargins })}
560
600
  <GeoLayout
561
- {width}
562
- {height}
601
+ width={containedSize.width}
602
+ height={containedSize.height}
563
603
  {numericMargins}
564
604
  zoomTransform={geoZoom.transform}
565
605
  tileLayer={styles.tileLayer}
@@ -605,3 +645,17 @@
605
645
  {/if}
606
646
  {/snippet}
607
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>
@@ -20,6 +20,25 @@ export declare function geometryCentroid(geometry: GeoJSON.Geometry): [number, n
20
20
  * fit box (see `clipBoundsToData`).
21
21
  */
22
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;
23
42
  /**
24
43
  * Splits a geometry into its disjoint parts: one `Polygon` per element of a
25
44
  * `MultiPolygon`'s `coordinates`, one `LineString` per element of a
@@ -93,6 +93,36 @@ export function geometryLonLatBounds(geometry) {
93
93
  }
94
94
  return [minLon, minLat, maxLon, maxLat];
95
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
+ }
96
126
  /**
97
127
  * Splits a geometry into its disjoint parts: one `Polygon` per element of a
98
128
  * `MultiPolygon`'s `coordinates`, one `LineString` per element of a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"