@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
|
-
// ──
|
|
149
|
-
// `TileLayer`
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
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 (!
|
|
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
|