@fundar/data-chart-telling 0.0.17 → 0.0.19

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, {}, "">;
@@ -26,24 +26,32 @@
26
26
  // ── Drag-to-narrow-margins ────────────────────────────────────────────────
27
27
  const interaction = getLegendInteraction() ?? createLegendInteraction();
28
28
 
29
- // Seeded once from the section's own bounds (deliberately untracked — a
30
- // later `section.min`/`max` change re-clamps the existing lo/hi instead of
31
- // resetting them back out to the new full range, see below).
29
+ // Seeded once from the section's own bounds (deliberately untracked — see
30
+ // the tracking/clamping effect below for how later prop changes are
31
+ // handled instead).
32
32
  let lo = $state(untrack(() => section.min));
33
33
  let hi = $state(untrack(() => section.max));
34
34
 
35
- // `section.min`/`max` can shift under an already-active selection e.g. a
36
- // consumer swaps `data` for a different year on its own timeline, and the
37
- // value that used to be the max is no longer present. Re-clamping (instead
38
- // of leaving lo/hi at their old absolute values) keeps the handles inside
39
- // whatever range now actually exists: the lower handle can only move up to
40
- // meet a risen min, the upper handle can only move down to meet a fallen
41
- // max. Clamping each bound against *both* of the new min/max first (not
42
- // just its own side) keeps lo/hi from inverting if the domain shifts
43
- // entirely past the old selection.
35
+ // Whether the user has actually dragged *that* handle off its default
36
+ // position tracked per handle, not as one combined flag, so dragging one
37
+ // doesn't freeze the other: until the other is touched, it must keep
38
+ // tracking `section.min`/`max` exactly, since a consumer's domain routinely
39
+ // changes with no interaction at all (e.g. a different year picked on its
40
+ // own timeline), and with nothing to protect there the legend should
41
+ // always read the true current extent rather than some earlier domain's
42
+ // stale numbers.
43
+ let loAdjusted = $state(false);
44
+ let hiAdjusted = $state(false);
45
+
46
+ // Once a handle *has* been dragged, a later domain change (same scenario as
47
+ // above, but now with a selection to protect) instead re-clamps it to stay
48
+ // valid: the lower handle only moves up to meet a risen min, the upper
49
+ // handle only moves down to meet a fallen max. Clamping against *both* of
50
+ // the new min/max first (not just its own side) keeps a handle from
51
+ // crossing the other if the domain shifts entirely past its old position.
44
52
  $effect(() => {
45
- lo = Math.min(Math.max(lo, section.min), section.max);
46
- hi = Math.max(Math.min(hi, section.max), section.min);
53
+ lo = loAdjusted ? Math.min(Math.max(lo, section.min), section.max) : section.min;
54
+ hi = hiAdjusted ? Math.max(Math.min(hi, section.max), section.min) : section.max;
47
55
  });
48
56
 
49
57
  function toT(v: number): number {
@@ -89,6 +97,8 @@
89
97
  return (event: PointerEvent) => {
90
98
  if (!section.interactive) return;
91
99
  event.preventDefault();
100
+ if (which === 'lo') loAdjusted = true;
101
+ else hiAdjusted = true;
92
102
  const target = event.currentTarget as SVGElement;
93
103
  target.setPointerCapture(event.pointerId);
94
104
  const startX = event.clientX;
@@ -117,8 +127,13 @@
117
127
 
118
128
  function resetHandle(which: 'lo' | 'hi') {
119
129
  return () => {
120
- if (which === 'lo') lo = section.min;
121
- else hi = section.max;
130
+ if (which === 'lo') {
131
+ lo = section.min;
132
+ loAdjusted = false;
133
+ } else {
134
+ hi = section.max;
135
+ hiAdjusted = false;
136
+ }
122
137
  };
123
138
  }
124
139
  </script>
@@ -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,
@@ -144,6 +145,28 @@
144
145
  });
145
146
  });
146
147
 
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. ────
153
+ const dataLonLatBounds = $derived.by((): [number, number, number, number] | null => {
154
+ if (!styles.tileLayer) return null;
155
+ let minLon = Infinity;
156
+ let maxLon = -Infinity;
157
+ let minLat = Infinity;
158
+ let maxLat = -Infinity;
159
+ for (const f of features) {
160
+ const b = geometryLonLatBounds(f.geometry);
161
+ if (!b) continue;
162
+ if (b[0] < minLon) minLon = b[0];
163
+ if (b[2] > maxLon) maxLon = b[2];
164
+ if (b[1] < minLat) minLat = b[1];
165
+ if (b[3] > maxLat) maxLat = b[3];
166
+ }
167
+ return minLon === Infinity ? null : [minLon, minLat, maxLon, maxLat];
168
+ });
169
+
147
170
  // ── Tile-layer / projection compatibility ────────────────────────────────
148
171
  $effect(() => {
149
172
  if (!styles.tileLayer) return;
@@ -534,7 +557,14 @@
534
557
  bind:containerEl
535
558
  >
536
559
  {#snippet children({ matchedPoints, numericMargins })}
537
- <GeoLayout {width} {height} {numericMargins} zoomTransform={geoZoom.transform} tileLayer={styles.tileLayer}>
560
+ <GeoLayout
561
+ {width}
562
+ {height}
563
+ {numericMargins}
564
+ zoomTransform={geoZoom.transform}
565
+ tileLayer={styles.tileLayer}
566
+ {dataLonLatBounds}
567
+ >
538
568
  {#each markers as marker, i (marker.type + '-' + i)}
539
569
  {#if isBehindMarker(marker)}
540
570
  {@render marker_(marker, numericMargins)}
@@ -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,18 @@ 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;
11
23
  /**
12
24
  * Splits a geometry into its disjoint parts: one `Polygon` per element of a
13
25
  * `MultiPolygon`'s `coordinates`, one `LineString` per element of a
@@ -61,6 +61,38 @@ 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
+ }
64
96
  /**
65
97
  * Splits a geometry into its disjoint parts: one `Polygon` per element of a
66
98
  * `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
@@ -44,7 +70,7 @@ export declare function toProjectionLike(projection: StreamingProjection): Proje
44
70
  * whose tile coverage is well-defined; callers are responsible for the
45
71
  * projection-compatibility check (see `Plot.svelte`'s validation effect).
46
72
  */
47
- export declare function visibleTiles(projection: ProjectionLike, bounds: ScreenBounds, tileSize?: number, minZoom?: number, maxZoom?: number): TileCoord[];
73
+ export declare function visibleTiles(projection: ProjectionLike, rawBounds: ScreenBounds, tileSize?: number, minZoom?: number, maxZoom?: number): TileCoord[];
48
74
  /** An affine tile-space → screen-space mapping (see `buildMercatorFit`), reused across every tile in a grid instead of forward-projecting each one individually. */
49
75
  export type MercatorFit = {
50
76
  z: number;
@@ -76,7 +102,7 @@ export type MercatorFit = {
76
102
  * affine in screen space by construction, so one linear fit applies
77
103
  * uniformly to every tile in the grid.
78
104
  */
79
- export declare function buildMercatorFit(projection: ProjectionLike, bounds: ScreenBounds, z: number): MercatorFit | null;
105
+ export declare function buildMercatorFit(projection: ProjectionLike, rawBounds: ScreenBounds, z: number): MercatorFit | null;
80
106
  /** 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
107
  export declare function tileScreenBounds(fit: MercatorFit, tile: TileCoord): ScreenBounds | null;
82
108
  /**
@@ -49,6 +49,91 @@ function estimateZoomLevel(projection, centerLon, centerLat, tileSize) {
49
49
  return null;
50
50
  return Math.round(Math.log2(ratio));
51
51
  }
52
+ /**
53
+ * Clamps `bounds`' horizontal extent to at most one world-width, centered on
54
+ * the box's own center, before any corner of it gets inverted.
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.
70
+ */
71
+ function clampBoundsToWorld(projection, bounds) {
72
+ if (!projection.invert)
73
+ return bounds;
74
+ const centerX = bounds.left + bounds.width / 2;
75
+ const centerY = bounds.top + bounds.height / 2;
76
+ const centerLonLat = projection.invert([centerX, centerY]);
77
+ if (!centerLonLat)
78
+ return bounds;
79
+ const west = projection([-180, centerLonLat[1]]);
80
+ const east = projection([180, centerLonLat[1]]);
81
+ if (!west || !east)
82
+ return bounds;
83
+ const worldWidth = Math.abs(east[0] - west[0]);
84
+ if (!(worldWidth > 0) || bounds.width <= worldWidth)
85
+ return bounds;
86
+ return { ...bounds, left: centerX - worldWidth / 2, width: worldWidth };
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
+ }
52
137
  /**
53
138
  * Derives the visible XYZ tile grid from svelteplot's own already-fitted
54
139
  * projection (rather than this package computing/duplicating a `d3-geo` fit
@@ -57,9 +142,10 @@ function estimateZoomLevel(projection, centerLon, centerLat, tileSize) {
57
142
  * whose tile coverage is well-defined; callers are responsible for the
58
143
  * projection-compatibility check (see `Plot.svelte`'s validation effect).
59
144
  */
60
- export function visibleTiles(projection, bounds, tileSize = 256, minZoom = 0, maxZoom = 19) {
145
+ export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0, maxZoom = 19) {
61
146
  if (!projection.invert)
62
147
  return [];
148
+ const bounds = clampBoundsToWorld(projection, rawBounds);
63
149
  const corners = [
64
150
  [bounds.left, bounds.top],
65
151
  [bounds.left + bounds.width, bounds.top],
@@ -110,9 +196,10 @@ export function visibleTiles(projection, bounds, tileSize = 256, minZoom = 0, ma
110
196
  * affine in screen space by construction, so one linear fit applies
111
197
  * uniformly to every tile in the grid.
112
198
  */
113
- export function buildMercatorFit(projection, bounds, z) {
199
+ export function buildMercatorFit(projection, rawBounds, z) {
114
200
  if (!projection.invert)
115
201
  return null;
202
+ const bounds = clampBoundsToWorld(projection, rawBounds);
116
203
  const topLeft = projection.invert([bounds.left, bounds.top]);
117
204
  const bottomRight = projection.invert([bounds.left + bounds.width, bounds.top + bounds.height]);
118
205
  if (!topLeft || !bottomRight)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"