@fundar/data-chart-telling 0.0.16 → 0.0.18

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.
@@ -26,11 +26,34 @@
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 — this
30
- // component doesn't follow later `section.min`/`max` prop changes).
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).
31
32
  let lo = $state(untrack(() => section.min));
32
33
  let hi = $state(untrack(() => section.max));
33
34
 
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.
52
+ $effect(() => {
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;
55
+ });
56
+
34
57
  function toT(v: number): number {
35
58
  return (v - section.min) / (section.max - section.min || 1);
36
59
  }
@@ -74,6 +97,8 @@
74
97
  return (event: PointerEvent) => {
75
98
  if (!section.interactive) return;
76
99
  event.preventDefault();
100
+ if (which === 'lo') loAdjusted = true;
101
+ else hiAdjusted = true;
77
102
  const target = event.currentTarget as SVGElement;
78
103
  target.setPointerCapture(event.pointerId);
79
104
  const startX = event.clientX;
@@ -102,8 +127,13 @@
102
127
 
103
128
  function resetHandle(which: 'lo' | 'hi') {
104
129
  return () => {
105
- if (which === 'lo') lo = section.min;
106
- 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
+ }
107
137
  };
108
138
  }
109
139
  </script>
@@ -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, bounds: ScreenBounds, tileSize?: number, minZoom?: number, maxZoom?: number): TileCoord[];
47
+ export declare function visibleTiles(projection: ProjectionLike, rawBounds: ScreenBounds, tileSize?: number, minZoom?: number, maxZoom?: number): 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, bounds: ScreenBounds, z: number): MercatorFit | null;
79
+ export declare function buildMercatorFit(projection: ProjectionLike, rawBounds: ScreenBounds, z: number): 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
  /**
@@ -49,6 +49,42 @@ 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
+ }
52
88
  /**
53
89
  * Derives the visible XYZ tile grid from svelteplot's own already-fitted
54
90
  * projection (rather than this package computing/duplicating a `d3-geo` fit
@@ -57,9 +93,10 @@ function estimateZoomLevel(projection, centerLon, centerLat, tileSize) {
57
93
  * whose tile coverage is well-defined; callers are responsible for the
58
94
  * projection-compatibility check (see `Plot.svelte`'s validation effect).
59
95
  */
60
- export function visibleTiles(projection, bounds, tileSize = 256, minZoom = 0, maxZoom = 19) {
96
+ export function visibleTiles(projection, rawBounds, tileSize = 256, minZoom = 0, maxZoom = 19) {
61
97
  if (!projection.invert)
62
98
  return [];
99
+ const bounds = clampBoundsToWorld(projection, rawBounds);
63
100
  const corners = [
64
101
  [bounds.left, bounds.top],
65
102
  [bounds.left + bounds.width, bounds.top],
@@ -110,9 +147,10 @@ export function visibleTiles(projection, bounds, tileSize = 256, minZoom = 0, ma
110
147
  * affine in screen space by construction, so one linear fit applies
111
148
  * uniformly to every tile in the grid.
112
149
  */
113
- export function buildMercatorFit(projection, bounds, z) {
150
+ export function buildMercatorFit(projection, rawBounds, z) {
114
151
  if (!projection.invert)
115
152
  return null;
153
+ const bounds = clampBoundsToWorld(projection, rawBounds);
116
154
  const topLeft = projection.invert([bounds.left, bounds.top]);
117
155
  const bottomRight = projection.invert([bounds.left + bounds.width, bounds.top + bounds.height]);
118
156
  if (!topLeft || !bottomRight)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"