@fundar/data-chart-telling 0.0.20 → 0.0.22
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/layout/geo/GeoLayout.svelte +1 -1
- package/dist/layout/geo/GeoLayout.svelte.d.ts +1 -1
- package/dist/plots/geo/Plot.svelte +19 -78
- package/dist/plots/geo/TileLayer.svelte +10 -13
- package/dist/plots/geo/TileLayer.svelte.d.ts +1 -1
- package/dist/plots/utils/geoAccessors.d.ts +8 -31
- package/dist/plots/utils/geoAccessors.js +28 -62
- package/dist/plots/utils/tiles.d.ts +2 -28
- package/dist/plots/utils/tiles.js +63 -68
- package/package.json +1 -1
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
numericMargins: { top: number; right: number; bottom: number; left: number };
|
|
31
31
|
zoomTransform: { x: number; y: number; k: number };
|
|
32
32
|
tileLayer?: GeoTileLayerConfig;
|
|
33
|
-
/** Forwarded to `TileLayer` — see
|
|
33
|
+
/** Forwarded to `TileLayer` — see `Plot.svelte`'s `dataLonLatBounds`. */
|
|
34
34
|
dataLonLatBounds?: [number, number, number, number] | null;
|
|
35
35
|
children: Snippet;
|
|
36
36
|
} = $props();
|
|
@@ -15,7 +15,7 @@ type $$ComponentProps = {
|
|
|
15
15
|
k: number;
|
|
16
16
|
};
|
|
17
17
|
tileLayer?: GeoTileLayerConfig;
|
|
18
|
-
/** Forwarded to `TileLayer` — see
|
|
18
|
+
/** Forwarded to `TileLayer` — see `Plot.svelte`'s `dataLonLatBounds`. */
|
|
19
19
|
dataLonLatBounds?: [number, number, number, number] | null;
|
|
20
20
|
children: Snippet;
|
|
21
21
|
};
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import {
|
|
7
7
|
geometryCentroid,
|
|
8
8
|
geometryLonLatBounds,
|
|
9
|
-
mercatorAspectRatio,
|
|
10
9
|
geometryPartCentroids,
|
|
11
10
|
filterGeometryParts,
|
|
12
11
|
defaultFeatureId,
|
|
@@ -146,19 +145,20 @@
|
|
|
146
145
|
});
|
|
147
146
|
});
|
|
148
147
|
|
|
149
|
-
// ──
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
148
|
+
// ── Tile-layer / projection compatibility ────────────────────────────────
|
|
149
|
+
$effect(() => {
|
|
150
|
+
if (!styles.tileLayer) return;
|
|
151
|
+
const proj = scales.projection;
|
|
152
|
+
const name = typeof proj === 'string' ? proj : proj?.type;
|
|
153
|
+
if (name && name !== 'mercator' && name !== 'transverse-mercator') {
|
|
154
|
+
console.warn(
|
|
155
|
+
`GeoPlot: styles.tileLayer only renders correctly with a mercator-family projection — got "${name}". Rendering anyway.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
160
|
const dataLonLatBounds = $derived.by((): [number, number, number, number] | null => {
|
|
161
|
-
if (!
|
|
161
|
+
if (!styles.tileLayer) return null;
|
|
162
162
|
let minLon = Infinity;
|
|
163
163
|
let maxLon = -Infinity;
|
|
164
164
|
let minLat = Infinity;
|
|
@@ -174,50 +174,6 @@
|
|
|
174
174
|
return minLon === Infinity ? null : [minLon, minLat, maxLon, maxLat];
|
|
175
175
|
});
|
|
176
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
|
-
|
|
209
|
-
// ── Tile-layer / projection compatibility ────────────────────────────────
|
|
210
|
-
$effect(() => {
|
|
211
|
-
if (!styles.tileLayer) return;
|
|
212
|
-
const proj = scales.projection;
|
|
213
|
-
const name = typeof proj === 'string' ? proj : proj?.type;
|
|
214
|
-
if (name && name !== 'mercator' && name !== 'transverse-mercator') {
|
|
215
|
-
console.warn(
|
|
216
|
-
`GeoPlot: styles.tileLayer only renders correctly with a mercator-family projection — got "${name}". Rendering anyway.`,
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
});
|
|
220
|
-
|
|
221
177
|
// ── Choropleth color scale (mirrors heatmap's styles.colors/scales.z) ────
|
|
222
178
|
const minColor = $derived(styles.colors?.min ?? cfg.continuous.min);
|
|
223
179
|
const maxColor = $derived(styles.colors?.max ?? cfg.continuous.max);
|
|
@@ -545,8 +501,8 @@
|
|
|
545
501
|
{@const insetContentBox = {
|
|
546
502
|
left: numericMargins.left,
|
|
547
503
|
top: numericMargins.top,
|
|
548
|
-
width: Math.max(0,
|
|
549
|
-
height: Math.max(0,
|
|
504
|
+
width: Math.max(0, width - numericMargins.left - numericMargins.right),
|
|
505
|
+
height: Math.max(0, height - numericMargins.top - numericMargins.bottom),
|
|
550
506
|
}}
|
|
551
507
|
<InsetMarker
|
|
552
508
|
marker={marker as GeoInsetMarker<TProps>}
|
|
@@ -574,10 +530,9 @@
|
|
|
574
530
|
`equirectangular` projection, whose x/y truly are linear in lon/lat) and
|
|
575
531
|
is a no-op for every other mark type.
|
|
576
532
|
-->
|
|
577
|
-
<div class="dct-geo-contain" style:width="{width}px" style:height="{height}px">
|
|
578
533
|
<BasePlotLayout
|
|
579
|
-
|
|
580
|
-
|
|
534
|
+
{width}
|
|
535
|
+
{height}
|
|
581
536
|
data={hoverCandidates}
|
|
582
537
|
getX={hoverGetX}
|
|
583
538
|
getY={hoverGetY}
|
|
@@ -598,8 +553,8 @@
|
|
|
598
553
|
>
|
|
599
554
|
{#snippet children({ matchedPoints, numericMargins })}
|
|
600
555
|
<GeoLayout
|
|
601
|
-
|
|
602
|
-
|
|
556
|
+
{width}
|
|
557
|
+
{height}
|
|
603
558
|
{numericMargins}
|
|
604
559
|
zoomTransform={geoZoom.transform}
|
|
605
560
|
tileLayer={styles.tileLayer}
|
|
@@ -645,17 +600,3 @@
|
|
|
645
600
|
{/if}
|
|
646
601
|
{/snippet}
|
|
647
602
|
</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,7 +6,6 @@
|
|
|
6
6
|
tileScreenBounds,
|
|
7
7
|
tileUrl,
|
|
8
8
|
toProjectionLike,
|
|
9
|
-
clipBoundsToData,
|
|
10
9
|
type StreamingProjection,
|
|
11
10
|
} from '../utils/tiles';
|
|
12
11
|
import type { GeoTileLayerConfig } from '../../types/plots/styles/geo';
|
|
@@ -34,7 +33,7 @@
|
|
|
34
33
|
top: number;
|
|
35
34
|
width: number;
|
|
36
35
|
height: number;
|
|
37
|
-
/**
|
|
36
|
+
/** Forwarded to `visibleTiles`/`buildMercatorFit` — see `Plot.svelte`'s `dataLonLatBounds`. */
|
|
38
37
|
dataLonLatBounds?: [number, number, number, number] | null;
|
|
39
38
|
} = $props();
|
|
40
39
|
|
|
@@ -48,18 +47,16 @@
|
|
|
48
47
|
const rawProjection = $derived(plot.scales.projection as unknown as StreamingProjection | undefined);
|
|
49
48
|
const projection = $derived(rawProjection ? toProjectionLike(rawProjection) : undefined);
|
|
50
49
|
|
|
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
|
-
|
|
60
50
|
const tiles = $derived.by(() => {
|
|
61
51
|
if (!projection) return [];
|
|
62
|
-
return visibleTiles(
|
|
52
|
+
return visibleTiles(
|
|
53
|
+
projection,
|
|
54
|
+
{ left, top, width, height },
|
|
55
|
+
tileSize,
|
|
56
|
+
config.minZoom ?? 0,
|
|
57
|
+
config.maxZoom ?? 19,
|
|
58
|
+
dataLonLatBounds,
|
|
59
|
+
);
|
|
63
60
|
});
|
|
64
61
|
|
|
65
62
|
// One affine fit per zoom level, shared across every tile in `tiles` — see
|
|
@@ -67,7 +64,7 @@
|
|
|
67
64
|
// each tile's own corners individually.
|
|
68
65
|
const fit = $derived.by(() => {
|
|
69
66
|
if (!projection || tiles.length === 0) return null;
|
|
70
|
-
return buildMercatorFit(projection,
|
|
67
|
+
return buildMercatorFit(projection, { left, top, width, height }, tiles[0].z, dataLonLatBounds);
|
|
71
68
|
});
|
|
72
69
|
</script>
|
|
73
70
|
|
|
@@ -5,7 +5,7 @@ type $$ComponentProps = {
|
|
|
5
5
|
top: number;
|
|
6
6
|
width: number;
|
|
7
7
|
height: number;
|
|
8
|
-
/**
|
|
8
|
+
/** Forwarded to `visibleTiles`/`buildMercatorFit` — see `Plot.svelte`'s `dataLonLatBounds`. */
|
|
9
9
|
dataLonLatBounds?: [number, number, number, number] | null;
|
|
10
10
|
};
|
|
11
11
|
declare const TileLayer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
@@ -8,37 +8,6 @@ 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;
|
|
42
11
|
/**
|
|
43
12
|
* Splits a geometry into its disjoint parts: one `Polygon` per element of a
|
|
44
13
|
* `MultiPolygon`'s `coordinates`, one `LineString` per element of a
|
|
@@ -78,6 +47,14 @@ export declare function filterGeometryParts(geometry: GeoJSON.Geometry, keep: (p
|
|
|
78
47
|
}) => boolean): GeoJSON.Geometry | null;
|
|
79
48
|
/** Default `featureId`: `feature.id`, else `properties.id`, else `properties.name`. */
|
|
80
49
|
export declare function defaultFeatureId<TProps extends Record<string, unknown>>(f: GeoFeature<TProps>): string;
|
|
50
|
+
/**
|
|
51
|
+
* `[minLon, minLat, maxLon, maxLat]` across every coordinate of a geometry —
|
|
52
|
+
* `null` for one with none (an empty `GeometryCollection`). Used by
|
|
53
|
+
* `Plot.svelte`'s `dataLonLatBounds` to cap how far a tile layer's
|
|
54
|
+
* letterboxed margin reaches (see `clampBoundsToDataPadding` in
|
|
55
|
+
* `$lib/plots/utils/tiles`).
|
|
56
|
+
*/
|
|
57
|
+
export declare function geometryLonLatBounds(geometry: GeoJSON.Geometry): [number, number, number, number] | null;
|
|
81
58
|
/**
|
|
82
59
|
* Densifies a great-circle arc between two lon/lat points into `steps + 1`
|
|
83
60
|
* points via spherical linear interpolation (slerp) — used to draw a `link`
|
|
@@ -61,68 +61,6 @@ 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
|
-
}
|
|
126
64
|
/**
|
|
127
65
|
* Splits a geometry into its disjoint parts: one `Polygon` per element of a
|
|
128
66
|
* `MultiPolygon`'s `coordinates`, one `LineString` per element of a
|
|
@@ -197,6 +135,34 @@ export function defaultFeatureId(f) {
|
|
|
197
135
|
const props = f.properties;
|
|
198
136
|
return String(f.id ?? props?.id ?? props?.name ?? '');
|
|
199
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* `[minLon, minLat, maxLon, maxLat]` across every coordinate of a geometry —
|
|
140
|
+
* `null` for one with none (an empty `GeometryCollection`). Used by
|
|
141
|
+
* `Plot.svelte`'s `dataLonLatBounds` to cap how far a tile layer's
|
|
142
|
+
* letterboxed margin reaches (see `clampBoundsToDataPadding` in
|
|
143
|
+
* `$lib/plots/utils/tiles`).
|
|
144
|
+
*/
|
|
145
|
+
export function geometryLonLatBounds(geometry) {
|
|
146
|
+
const coords = [];
|
|
147
|
+
flattenCoords(geometry, coords);
|
|
148
|
+
if (coords.length === 0)
|
|
149
|
+
return null;
|
|
150
|
+
let minLon = Infinity;
|
|
151
|
+
let maxLon = -Infinity;
|
|
152
|
+
let minLat = Infinity;
|
|
153
|
+
let maxLat = -Infinity;
|
|
154
|
+
for (const [lon, lat] of coords) {
|
|
155
|
+
if (lon < minLon)
|
|
156
|
+
minLon = lon;
|
|
157
|
+
if (lon > maxLon)
|
|
158
|
+
maxLon = lon;
|
|
159
|
+
if (lat < minLat)
|
|
160
|
+
minLat = lat;
|
|
161
|
+
if (lat > maxLat)
|
|
162
|
+
maxLat = lat;
|
|
163
|
+
}
|
|
164
|
+
return [minLon, minLat, maxLon, maxLat];
|
|
165
|
+
}
|
|
200
166
|
function toRad(deg) {
|
|
201
167
|
return (deg * Math.PI) / 180;
|
|
202
168
|
}
|
|
@@ -36,32 +36,6 @@ 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;
|
|
65
39
|
/**
|
|
66
40
|
* Derives the visible XYZ tile grid from svelteplot's own already-fitted
|
|
67
41
|
* projection (rather than this package computing/duplicating a `d3-geo` fit
|
|
@@ -70,7 +44,7 @@ export declare function clipBoundsToData(projection: ProjectionLike, bounds: Scr
|
|
|
70
44
|
* whose tile coverage is well-defined; callers are responsible for the
|
|
71
45
|
* projection-compatibility check (see `Plot.svelte`'s validation effect).
|
|
72
46
|
*/
|
|
73
|
-
export declare function visibleTiles(projection: ProjectionLike, rawBounds: ScreenBounds, tileSize?: number, minZoom?: number, maxZoom?: number): TileCoord[];
|
|
47
|
+
export declare function visibleTiles(projection: ProjectionLike, rawBounds: ScreenBounds, tileSize?: number, minZoom?: number, maxZoom?: number, dataLonLatBounds?: [number, number, number, number] | null): TileCoord[];
|
|
74
48
|
/** An affine tile-space → screen-space mapping (see `buildMercatorFit`), reused across every tile in a grid instead of forward-projecting each one individually. */
|
|
75
49
|
export type MercatorFit = {
|
|
76
50
|
z: number;
|
|
@@ -102,7 +76,7 @@ export type MercatorFit = {
|
|
|
102
76
|
* affine in screen space by construction, so one linear fit applies
|
|
103
77
|
* uniformly to every tile in the grid.
|
|
104
78
|
*/
|
|
105
|
-
export declare function buildMercatorFit(projection: ProjectionLike, rawBounds: ScreenBounds, z: number): MercatorFit | null;
|
|
79
|
+
export declare function buildMercatorFit(projection: ProjectionLike, rawBounds: ScreenBounds, z: number, dataLonLatBounds?: [number, number, number, number] | null): MercatorFit | null;
|
|
106
80
|
/** Places a tile on screen via a `MercatorFit` — `null` only if `tile` is from a different zoom level than the fit was built for. */
|
|
107
81
|
export declare function tileScreenBounds(fit: MercatorFit, tile: TileCoord): ScreenBounds | null;
|
|
108
82
|
/**
|
|
@@ -50,23 +50,9 @@ function estimateZoomLevel(projection, centerLon, centerLat, tileSize) {
|
|
|
50
50
|
return Math.round(Math.log2(ratio));
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
-
* Clamps `bounds`' horizontal extent to at most
|
|
54
|
-
* the box's own center, before any corner of it
|
|
55
|
-
*
|
|
56
|
-
* A Mercator(-family) projection wraps a single world onto a *finite* pixel
|
|
57
|
-
* span at any given scale. `visibleTiles`/`buildMercatorFit` invert `bounds`'
|
|
58
|
-
* corners and assume the result increases monotonically left-to-right — true
|
|
59
|
-
* only up to one world-width. Past that, the inverted longitude wraps around
|
|
60
|
-
* the antimeridian and comes back the other way, silently reversing the
|
|
61
|
-
* corner ordering (`buildMercatorFit` was seen to compute a *negative*
|
|
62
|
-
* `pxPerTileX` from this). This bites specifically when a plot's actual
|
|
63
|
-
* geographic content occupies far less of its box than the box's own aspect
|
|
64
|
-
* ratio does — e.g. a tall, narrow country's choropleth fit into a short,
|
|
65
|
-
* wide facet-grid cell: the fit shrinks scale to match the constraining
|
|
66
|
-
* (height) axis, and the *unconstrained* (width) axis's letterboxed margin
|
|
67
|
-
* can then span more of the earth than actually exists. Clamping first means
|
|
68
|
-
* that margin still renders tiles (real basemap, not blank) up to a full
|
|
69
|
-
* world, and simply stops there instead of wrapping into a bogus repeat.
|
|
53
|
+
* Clamps `bounds`' horizontal extent to at most *half* a world-width (180°
|
|
54
|
+
* of longitude), centered on the box's own center, before any corner of it
|
|
55
|
+
* gets inverted.
|
|
70
56
|
*/
|
|
71
57
|
function clampBoundsToWorld(projection, bounds) {
|
|
72
58
|
if (!projection.invert)
|
|
@@ -80,59 +66,66 @@ function clampBoundsToWorld(projection, bounds) {
|
|
|
80
66
|
const east = projection([180, centerLonLat[1]]);
|
|
81
67
|
if (!west || !east)
|
|
82
68
|
return bounds;
|
|
83
|
-
const
|
|
84
|
-
if (!(
|
|
69
|
+
const maxWidth = Math.abs(east[0] - west[0]) / 2;
|
|
70
|
+
if (!(maxWidth > 0) || bounds.width <= maxWidth)
|
|
85
71
|
return bounds;
|
|
86
|
-
return { ...bounds, left: centerX -
|
|
72
|
+
return { ...bounds, left: centerX - maxWidth / 2, width: maxWidth };
|
|
87
73
|
}
|
|
88
74
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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.
|
|
75
|
+
* How far a letterboxed tile margin may reach beyond the data's own
|
|
76
|
+
* bounding box (see `Plot.svelte`'s `dataLonLatBounds`), on either side —
|
|
77
|
+
* the product-level cap on how much basemap "context" gets shown, well
|
|
78
|
+
* inside `clampBoundsToWorld`'s own (much looser) antimeridian-safety cap.
|
|
106
79
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
80
|
+
* Picked to comfortably clear the nearest *other* landmass in any direction
|
|
81
|
+
* from Argentina — this package's one real-world `domain: 'data'` +
|
|
82
|
+
* tile-layer consumer. The tightest real constraint is the Atlantic side:
|
|
83
|
+
* Argentina's own east edge sits at -53°, and the nearest land across it
|
|
84
|
+
* (West Africa) starts around -17° — a 36°-wide ocean gap. A flat 20° here
|
|
85
|
+
* stays comfortably inside that gap on the tight side, while every other
|
|
86
|
+
* direction (west into the Pacific, especially) has far more real ocean to
|
|
87
|
+
* spare than this uses.
|
|
112
88
|
*/
|
|
113
|
-
|
|
114
|
-
|
|
89
|
+
const CONTEXT_PADDING_DEGREES = 20;
|
|
90
|
+
/**
|
|
91
|
+
* A raster tile is atomic — one straddling the padding boundary above still
|
|
92
|
+
* renders in full, so at a low enough zoom a single tile can overshoot
|
|
93
|
+
* `CONTEXT_PADDING_DEGREES` by tens of degrees (confirmed empirically: a
|
|
94
|
+
* small facet cell naturally estimates z≈1–2, where a tile is 90°–180°
|
|
95
|
+
* wide, dragging in all of Africa well past the intended padding). Flooring
|
|
96
|
+
* the zoom to at least this, whenever `clampBoundsToDataPadding` actually
|
|
97
|
+
* reduced the bounds, guarantees a tile is at most 360/2⁶ = 5.625° wide —
|
|
98
|
+
* comfortably under half of `CONTEXT_PADDING_DEGREES`, so the worst-case
|
|
99
|
+
* overshoot still lands well short of real land.
|
|
100
|
+
*/
|
|
101
|
+
const MIN_ZOOM_WHEN_CONTEXT_CLAMPED = 6;
|
|
102
|
+
/**
|
|
103
|
+
* Clamps `bounds`' horizontal extent to the data's own lon/lat bbox padded
|
|
104
|
+
* by `CONTEXT_PADDING_DEGREES` on each side, at the box's own center
|
|
105
|
+
* latitude — a no-op when `dataLonLatBounds` isn't supplied (a plot with no
|
|
106
|
+
* tile-layer-relevant data footprint, e.g. not a `domain: 'data'` fit; see
|
|
107
|
+
* `Plot.svelte`'s `usesDataFit`). Independent of, and applied on top of,
|
|
108
|
+
* `clampBoundsToWorld`'s own antimeridian-safety cap — that cap alone still
|
|
109
|
+
* permits up to a quarter-turn of the globe each way, comfortably reaching
|
|
110
|
+
* an unrelated continent instead of just nearby ocean (see `GeoPlot/Facet`
|
|
111
|
+
* stories' `Rows2Cols2` for the regression this fixes).
|
|
112
|
+
*/
|
|
113
|
+
function clampBoundsToDataPadding(projection, bounds, dataLonLatBounds) {
|
|
114
|
+
if (!dataLonLatBounds || !projection.invert)
|
|
115
|
+
return bounds;
|
|
116
|
+
const [minLon, , maxLon] = dataLonLatBounds;
|
|
117
|
+
const centerLonLat = projection.invert([bounds.left + bounds.width / 2, bounds.top + bounds.height / 2]);
|
|
118
|
+
if (!centerLonLat)
|
|
115
119
|
return bounds;
|
|
116
|
-
const [minLon
|
|
117
|
-
const
|
|
118
|
-
|
|
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)
|
|
120
|
+
const west = projection([minLon - CONTEXT_PADDING_DEGREES, centerLonLat[1]]);
|
|
121
|
+
const east = projection([maxLon + CONTEXT_PADDING_DEGREES, centerLonLat[1]]);
|
|
122
|
+
if (!west || !east)
|
|
124
123
|
return bounds;
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
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))
|
|
124
|
+
const left = Math.max(bounds.left, Math.min(west[0], east[0]));
|
|
125
|
+
const right = Math.min(bounds.left + bounds.width, Math.max(west[0], east[0]));
|
|
126
|
+
if (!(right > left))
|
|
134
127
|
return bounds;
|
|
135
|
-
return {
|
|
128
|
+
return { ...bounds, left, width: right - left };
|
|
136
129
|
}
|
|
137
130
|
/**
|
|
138
131
|
* Derives the visible XYZ tile grid from svelteplot's own already-fitted
|
|
@@ -142,10 +135,11 @@ export function clipBoundsToData(projection, bounds, dataLonLatBounds) {
|
|
|
142
135
|
* whose tile coverage is well-defined; callers are responsible for the
|
|
143
136
|
* projection-compatibility check (see `Plot.svelte`'s validation effect).
|
|
144
137
|
*/
|
|
145
|
-
export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0, maxZoom = 19) {
|
|
138
|
+
export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0, maxZoom = 19, dataLonLatBounds) {
|
|
146
139
|
if (!projection.invert)
|
|
147
140
|
return [];
|
|
148
|
-
const bounds = clampBoundsToWorld(projection, rawBounds);
|
|
141
|
+
const bounds = clampBoundsToDataPadding(projection, clampBoundsToWorld(projection, rawBounds), dataLonLatBounds);
|
|
142
|
+
const contextClamped = bounds.width < rawBounds.width;
|
|
149
143
|
const corners = [
|
|
150
144
|
[bounds.left, bounds.top],
|
|
151
145
|
[bounds.left + bounds.width, bounds.top],
|
|
@@ -155,11 +149,12 @@ export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0,
|
|
|
155
149
|
const lonLats = corners.map((c) => projection.invert(c)).filter((p) => p != null);
|
|
156
150
|
if (lonLats.length === 0)
|
|
157
151
|
return [];
|
|
152
|
+
const effectiveMinZoom = contextClamped ? Math.max(minZoom, MIN_ZOOM_WHEN_CONTEXT_CLAMPED) : minZoom;
|
|
158
153
|
const centerScreen = [bounds.left + bounds.width / 2, bounds.top + bounds.height / 2];
|
|
159
154
|
const center = projection.invert(centerScreen);
|
|
160
155
|
const z = center
|
|
161
|
-
? Math.max(
|
|
162
|
-
:
|
|
156
|
+
? Math.max(effectiveMinZoom, Math.min(maxZoom, estimateZoomLevel(projection, center[0], center[1], tileSize) ?? effectiveMinZoom))
|
|
157
|
+
: effectiveMinZoom;
|
|
163
158
|
const lons = lonLats.map((p) => p[0]);
|
|
164
159
|
const lats = lonLats.map((p) => p[1]);
|
|
165
160
|
const minTileX = Math.floor(lonToTileX(Math.min(...lons), z));
|
|
@@ -196,10 +191,10 @@ export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0,
|
|
|
196
191
|
* affine in screen space by construction, so one linear fit applies
|
|
197
192
|
* uniformly to every tile in the grid.
|
|
198
193
|
*/
|
|
199
|
-
export function buildMercatorFit(projection, rawBounds, z) {
|
|
194
|
+
export function buildMercatorFit(projection, rawBounds, z, dataLonLatBounds) {
|
|
200
195
|
if (!projection.invert)
|
|
201
196
|
return null;
|
|
202
|
-
const bounds = clampBoundsToWorld(projection, rawBounds);
|
|
197
|
+
const bounds = clampBoundsToDataPadding(projection, clampBoundsToWorld(projection, rawBounds), dataLonLatBounds);
|
|
203
198
|
const topLeft = projection.invert([bounds.left, bounds.top]);
|
|
204
199
|
const bottomRight = projection.invert([bounds.left + bounds.width, bounds.top + bounds.height]);
|
|
205
200
|
if (!topLeft || !bottomRight)
|