@fundar/data-chart-telling 0.0.21 → 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 +4 -0
- package/dist/layout/geo/GeoLayout.svelte.d.ts +2 -0
- package/dist/plots/geo/Plot.svelte +26 -1
- package/dist/plots/geo/TileLayer.svelte +12 -2
- package/dist/plots/geo/TileLayer.svelte.d.ts +2 -0
- package/dist/plots/utils/geoAccessors.d.ts +8 -0
- package/dist/plots/utils/geoAccessors.js +28 -0
- package/dist/plots/utils/tiles.d.ts +2 -2
- package/dist/plots/utils/tiles.js +64 -31
- package/package.json +1 -1
|
@@ -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 `Plot.svelte`'s `dataLonLatBounds`. */
|
|
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 `Plot.svelte`'s `dataLonLatBounds`. */
|
|
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,7 @@
|
|
|
5
5
|
import { resolveFeatureStyle } from '../utils/geoSegments';
|
|
6
6
|
import {
|
|
7
7
|
geometryCentroid,
|
|
8
|
+
geometryLonLatBounds,
|
|
8
9
|
geometryPartCentroids,
|
|
9
10
|
filterGeometryParts,
|
|
10
11
|
defaultFeatureId,
|
|
@@ -156,6 +157,23 @@
|
|
|
156
157
|
}
|
|
157
158
|
});
|
|
158
159
|
|
|
160
|
+
const dataLonLatBounds = $derived.by((): [number, number, number, number] | null => {
|
|
161
|
+
if (!styles.tileLayer) 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
|
+
|
|
159
177
|
// ── Choropleth color scale (mirrors heatmap's styles.colors/scales.z) ────
|
|
160
178
|
const minColor = $derived(styles.colors?.min ?? cfg.continuous.min);
|
|
161
179
|
const maxColor = $derived(styles.colors?.max ?? cfg.continuous.max);
|
|
@@ -534,7 +552,14 @@
|
|
|
534
552
|
bind:containerEl
|
|
535
553
|
>
|
|
536
554
|
{#snippet children({ matchedPoints, numericMargins })}
|
|
537
|
-
<GeoLayout
|
|
555
|
+
<GeoLayout
|
|
556
|
+
{width}
|
|
557
|
+
{height}
|
|
558
|
+
{numericMargins}
|
|
559
|
+
zoomTransform={geoZoom.transform}
|
|
560
|
+
tileLayer={styles.tileLayer}
|
|
561
|
+
{dataLonLatBounds}
|
|
562
|
+
>
|
|
538
563
|
{#each markers as marker, i (marker.type + '-' + i)}
|
|
539
564
|
{#if isBehindMarker(marker)}
|
|
540
565
|
{@render marker_(marker, numericMargins)}
|
|
@@ -26,12 +26,15 @@
|
|
|
26
26
|
top,
|
|
27
27
|
width,
|
|
28
28
|
height,
|
|
29
|
+
dataLonLatBounds,
|
|
29
30
|
}: {
|
|
30
31
|
config: GeoTileLayerConfig;
|
|
31
32
|
left: number;
|
|
32
33
|
top: number;
|
|
33
34
|
width: number;
|
|
34
35
|
height: number;
|
|
36
|
+
/** Forwarded to `visibleTiles`/`buildMercatorFit` — see `Plot.svelte`'s `dataLonLatBounds`. */
|
|
37
|
+
dataLonLatBounds?: [number, number, number, number] | null;
|
|
35
38
|
} = $props();
|
|
36
39
|
|
|
37
40
|
const plot = usePlot();
|
|
@@ -46,7 +49,14 @@
|
|
|
46
49
|
|
|
47
50
|
const tiles = $derived.by(() => {
|
|
48
51
|
if (!projection) return [];
|
|
49
|
-
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
|
+
);
|
|
50
60
|
});
|
|
51
61
|
|
|
52
62
|
// One affine fit per zoom level, shared across every tile in `tiles` — see
|
|
@@ -54,7 +64,7 @@
|
|
|
54
64
|
// each tile's own corners individually.
|
|
55
65
|
const fit = $derived.by(() => {
|
|
56
66
|
if (!projection || tiles.length === 0) return null;
|
|
57
|
-
return buildMercatorFit(projection, { left, top, width, height }, tiles[0].z);
|
|
67
|
+
return buildMercatorFit(projection, { left, top, width, height }, tiles[0].z, dataLonLatBounds);
|
|
58
68
|
});
|
|
59
69
|
</script>
|
|
60
70
|
|
|
@@ -5,6 +5,8 @@ type $$ComponentProps = {
|
|
|
5
5
|
top: number;
|
|
6
6
|
width: number;
|
|
7
7
|
height: number;
|
|
8
|
+
/** Forwarded to `visibleTiles`/`buildMercatorFit` — see `Plot.svelte`'s `dataLonLatBounds`. */
|
|
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>;
|
|
@@ -47,6 +47,14 @@ export declare function filterGeometryParts(geometry: GeoJSON.Geometry, keep: (p
|
|
|
47
47
|
}) => boolean): GeoJSON.Geometry | null;
|
|
48
48
|
/** Default `featureId`: `feature.id`, else `properties.id`, else `properties.name`. */
|
|
49
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;
|
|
50
58
|
/**
|
|
51
59
|
* Densifies a great-circle arc between two lon/lat points into `steps + 1`
|
|
52
60
|
* points via spherical linear interpolation (slerp) — used to draw a `link`
|
|
@@ -135,6 +135,34 @@ export function defaultFeatureId(f) {
|
|
|
135
135
|
const props = f.properties;
|
|
136
136
|
return String(f.id ?? props?.id ?? props?.name ?? '');
|
|
137
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
|
+
}
|
|
138
166
|
function toRad(deg) {
|
|
139
167
|
return (deg * Math.PI) / 180;
|
|
140
168
|
}
|
|
@@ -44,7 +44,7 @@ export declare function toProjectionLike(projection: StreamingProjection): Proje
|
|
|
44
44
|
* whose tile coverage is well-defined; callers are responsible for the
|
|
45
45
|
* projection-compatibility check (see `Plot.svelte`'s validation effect).
|
|
46
46
|
*/
|
|
47
|
-
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[];
|
|
48
48
|
/** An affine tile-space → screen-space mapping (see `buildMercatorFit`), reused across every tile in a grid instead of forward-projecting each one individually. */
|
|
49
49
|
export type MercatorFit = {
|
|
50
50
|
z: number;
|
|
@@ -76,7 +76,7 @@ export type MercatorFit = {
|
|
|
76
76
|
* affine in screen space by construction, so one linear fit applies
|
|
77
77
|
* uniformly to every tile in the grid.
|
|
78
78
|
*/
|
|
79
|
-
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;
|
|
80
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. */
|
|
81
81
|
export declare function tileScreenBounds(fit: MercatorFit, tile: TileCoord): ScreenBounds | null;
|
|
82
82
|
/**
|
|
@@ -53,31 +53,6 @@ function estimateZoomLevel(projection, centerLon, centerLat, tileSize) {
|
|
|
53
53
|
* Clamps `bounds`' horizontal extent to at most *half* a world-width (180°
|
|
54
54
|
* of longitude), centered on the box's own center, before any corner of it
|
|
55
55
|
* gets inverted.
|
|
56
|
-
*
|
|
57
|
-
* A Mercator(-family) projection wraps a single world onto a *finite* pixel
|
|
58
|
-
* span at any given scale. `visibleTiles`/`buildMercatorFit` invert `bounds`'
|
|
59
|
-
* corners and assume the result increases monotonically left-to-right — true
|
|
60
|
-
* only up to one world-width; past that the inverted longitude wraps around
|
|
61
|
-
* the antimeridian and comes back the other way, silently reversing the
|
|
62
|
-
* corner ordering (`buildMercatorFit` was seen to compute a *negative*
|
|
63
|
-
* `pxPerTileX` from this). This bites specifically when a plot's actual
|
|
64
|
-
* geographic content occupies far less of its box than the box's own aspect
|
|
65
|
-
* ratio does — e.g. a tall, narrow country's choropleth fit into a short,
|
|
66
|
-
* wide facet-grid cell: the fit shrinks scale to match the constraining
|
|
67
|
-
* (height) axis, and the *unconstrained* (width) axis's letterboxed margin
|
|
68
|
-
* can then imply more of the earth than actually exists.
|
|
69
|
-
*
|
|
70
|
-
* The cap is *half* a world rather than a full one specifically so the two
|
|
71
|
-
* edges of an extreme letterboxed margin can never reach all the way out to
|
|
72
|
-
* the region exactly opposite the map's own — a full-world cap's two edges
|
|
73
|
-
* meet exactly at that antipode (e.g. Argentina's own facet map, letterboxed
|
|
74
|
-
* enough to hit a full-world cap, would show Buenos Aires' antipode —
|
|
75
|
-
* Australia — prominently at both edges). Halving it keeps every visible
|
|
76
|
-
* tile within one quarter-turn of the data's own center, which for any real
|
|
77
|
-
* facet-grid cell is already a deliberately generous amount of basemap
|
|
78
|
-
* context (ocean, neighboring countries) — typical facet aspect ratios don't
|
|
79
|
-
* come close to needing the full 180° this still allows before clamping
|
|
80
|
-
* kicks in at all.
|
|
81
56
|
*/
|
|
82
57
|
function clampBoundsToWorld(projection, bounds) {
|
|
83
58
|
if (!projection.invert)
|
|
@@ -96,6 +71,62 @@ function clampBoundsToWorld(projection, bounds) {
|
|
|
96
71
|
return bounds;
|
|
97
72
|
return { ...bounds, left: centerX - maxWidth / 2, width: maxWidth };
|
|
98
73
|
}
|
|
74
|
+
/**
|
|
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.
|
|
79
|
+
*
|
|
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.
|
|
88
|
+
*/
|
|
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)
|
|
119
|
+
return bounds;
|
|
120
|
+
const west = projection([minLon - CONTEXT_PADDING_DEGREES, centerLonLat[1]]);
|
|
121
|
+
const east = projection([maxLon + CONTEXT_PADDING_DEGREES, centerLonLat[1]]);
|
|
122
|
+
if (!west || !east)
|
|
123
|
+
return bounds;
|
|
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))
|
|
127
|
+
return bounds;
|
|
128
|
+
return { ...bounds, left, width: right - left };
|
|
129
|
+
}
|
|
99
130
|
/**
|
|
100
131
|
* Derives the visible XYZ tile grid from svelteplot's own already-fitted
|
|
101
132
|
* projection (rather than this package computing/duplicating a `d3-geo` fit
|
|
@@ -104,10 +135,11 @@ function clampBoundsToWorld(projection, bounds) {
|
|
|
104
135
|
* whose tile coverage is well-defined; callers are responsible for the
|
|
105
136
|
* projection-compatibility check (see `Plot.svelte`'s validation effect).
|
|
106
137
|
*/
|
|
107
|
-
export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0, maxZoom = 19) {
|
|
138
|
+
export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0, maxZoom = 19, dataLonLatBounds) {
|
|
108
139
|
if (!projection.invert)
|
|
109
140
|
return [];
|
|
110
|
-
const bounds = clampBoundsToWorld(projection, rawBounds);
|
|
141
|
+
const bounds = clampBoundsToDataPadding(projection, clampBoundsToWorld(projection, rawBounds), dataLonLatBounds);
|
|
142
|
+
const contextClamped = bounds.width < rawBounds.width;
|
|
111
143
|
const corners = [
|
|
112
144
|
[bounds.left, bounds.top],
|
|
113
145
|
[bounds.left + bounds.width, bounds.top],
|
|
@@ -117,11 +149,12 @@ export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0,
|
|
|
117
149
|
const lonLats = corners.map((c) => projection.invert(c)).filter((p) => p != null);
|
|
118
150
|
if (lonLats.length === 0)
|
|
119
151
|
return [];
|
|
152
|
+
const effectiveMinZoom = contextClamped ? Math.max(minZoom, MIN_ZOOM_WHEN_CONTEXT_CLAMPED) : minZoom;
|
|
120
153
|
const centerScreen = [bounds.left + bounds.width / 2, bounds.top + bounds.height / 2];
|
|
121
154
|
const center = projection.invert(centerScreen);
|
|
122
155
|
const z = center
|
|
123
|
-
? Math.max(
|
|
124
|
-
:
|
|
156
|
+
? Math.max(effectiveMinZoom, Math.min(maxZoom, estimateZoomLevel(projection, center[0], center[1], tileSize) ?? effectiveMinZoom))
|
|
157
|
+
: effectiveMinZoom;
|
|
125
158
|
const lons = lonLats.map((p) => p[0]);
|
|
126
159
|
const lats = lonLats.map((p) => p[1]);
|
|
127
160
|
const minTileX = Math.floor(lonToTileX(Math.min(...lons), z));
|
|
@@ -158,10 +191,10 @@ export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0,
|
|
|
158
191
|
* affine in screen space by construction, so one linear fit applies
|
|
159
192
|
* uniformly to every tile in the grid.
|
|
160
193
|
*/
|
|
161
|
-
export function buildMercatorFit(projection, rawBounds, z) {
|
|
194
|
+
export function buildMercatorFit(projection, rawBounds, z, dataLonLatBounds) {
|
|
162
195
|
if (!projection.invert)
|
|
163
196
|
return null;
|
|
164
|
-
const bounds = clampBoundsToWorld(projection, rawBounds);
|
|
197
|
+
const bounds = clampBoundsToDataPadding(projection, clampBoundsToWorld(projection, rawBounds), dataLonLatBounds);
|
|
165
198
|
const topLeft = projection.invert([bounds.left, bounds.top]);
|
|
166
199
|
const bottomRight = projection.invert([bounds.left + bounds.width, bounds.top + bounds.height]);
|
|
167
200
|
if (!topLeft || !bottomRight)
|