@math.gl/geospatial 5.0.0-beta.1 → 5.0.0-beta.2

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.
Files changed (90) hide show
  1. package/dist/crs/crs-identifier.d.ts +23 -0
  2. package/dist/crs/crs-identifier.d.ts.map +1 -0
  3. package/dist/crs/crs-identifier.js +74 -0
  4. package/dist/crs/crs-identifier.js.map +1 -0
  5. package/dist/crs/index.cjs +48 -0
  6. package/dist/crs/index.cjs.map +2 -2
  7. package/dist/crs/index.d.ts +1 -0
  8. package/dist/crs/index.d.ts.map +1 -1
  9. package/dist/crs/index.js +1 -0
  10. package/dist/crs/index.js.map +1 -1
  11. package/dist/discontinuous/cap-map.d.ts.map +1 -1
  12. package/dist/discontinuous/cap-map.js +197 -13
  13. package/dist/discontinuous/cap-map.js.map +1 -1
  14. package/dist/discontinuous/elastic/geometry.d.ts +1 -1
  15. package/dist/discontinuous/elastic/geometry.d.ts.map +1 -1
  16. package/dist/discontinuous/elastic/geometry.js +7 -7
  17. package/dist/discontinuous/elastic/geometry.js.map +1 -1
  18. package/dist/discontinuous/elastic/map.d.ts.map +1 -1
  19. package/dist/discontinuous/elastic/map.js +263 -23
  20. package/dist/discontinuous/elastic/map.js.map +1 -1
  21. package/dist/discontinuous/elastic/source-arc.d.ts +10 -0
  22. package/dist/discontinuous/elastic/source-arc.d.ts.map +1 -0
  23. package/dist/discontinuous/elastic/source-arc.js +111 -0
  24. package/dist/discontinuous/elastic/source-arc.js.map +1 -0
  25. package/dist/discontinuous/global-polygons.d.ts +1 -1
  26. package/dist/discontinuous/global-polygons.d.ts.map +1 -1
  27. package/dist/discontinuous/global-polygons.js +49 -1
  28. package/dist/discontinuous/global-polygons.js.map +1 -1
  29. package/dist/discontinuous/map-engine.d.ts +9 -0
  30. package/dist/discontinuous/map-engine.d.ts.map +1 -1
  31. package/dist/discontinuous/map-layout.d.ts +21 -0
  32. package/dist/discontinuous/map-layout.d.ts.map +1 -1
  33. package/dist/discontinuous/map-layout.js +116 -1
  34. package/dist/discontinuous/map-layout.js.map +1 -1
  35. package/dist/discontinuous/map-transition.d.ts.map +1 -1
  36. package/dist/discontinuous/map-transition.js +66 -3
  37. package/dist/discontinuous/map-transition.js.map +1 -1
  38. package/dist/discontinuous/mercator-map.d.ts +2 -2
  39. package/dist/discontinuous/mercator-map.d.ts.map +1 -1
  40. package/dist/discontinuous/mercator-map.js +123 -11
  41. package/dist/discontinuous/mercator-map.js.map +1 -1
  42. package/dist/discontinuous/polygon-constraints.d.ts +16 -0
  43. package/dist/discontinuous/polygon-constraints.d.ts.map +1 -0
  44. package/dist/discontinuous/polygon-constraints.js +157 -0
  45. package/dist/discontinuous/polygon-constraints.js.map +1 -0
  46. package/dist/discontinuous/prepare-features.d.ts +27 -0
  47. package/dist/discontinuous/prepare-features.d.ts.map +1 -1
  48. package/dist/discontinuous/prepare-features.js +106 -5
  49. package/dist/discontinuous/prepare-features.js.map +1 -1
  50. package/dist/discontinuous/rotated-map.d.ts +9 -0
  51. package/dist/discontinuous/rotated-map.d.ts.map +1 -1
  52. package/dist/discontinuous/source-arc.d.ts +4 -0
  53. package/dist/discontinuous/source-arc.d.ts.map +1 -0
  54. package/dist/discontinuous/source-arc.js +23 -0
  55. package/dist/discontinuous/source-arc.js.map +1 -0
  56. package/dist/discontinuous/world-kernels.d.ts +1 -1
  57. package/dist/discontinuous.cjs +1047 -75
  58. package/dist/discontinuous.cjs.map +3 -3
  59. package/dist/index.cjs +48 -0
  60. package/dist/index.cjs.map +2 -2
  61. package/dist/index.d.ts +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/turf.cjs +69 -5
  66. package/dist/turf.cjs.map +2 -2
  67. package/dist/turf.d.ts +9 -0
  68. package/dist/turf.d.ts.map +1 -1
  69. package/dist/turf.js +67 -0
  70. package/dist/turf.js.map +1 -1
  71. package/dist/workers/index.cjs +1075 -79
  72. package/dist/workers/index.cjs.map +3 -3
  73. package/dist/workers/operations.cjs +1049 -77
  74. package/dist/workers/operations.cjs.map +3 -3
  75. package/package.json +5 -5
  76. package/src/crs/crs-identifier.ts +86 -0
  77. package/src/crs/index.ts +1 -0
  78. package/src/discontinuous/cap-map.ts +248 -19
  79. package/src/discontinuous/elastic/geometry.ts +18 -7
  80. package/src/discontinuous/elastic/map.ts +334 -30
  81. package/src/discontinuous/elastic/source-arc.ts +129 -0
  82. package/src/discontinuous/global-polygons.ts +56 -2
  83. package/src/discontinuous/map-layout.ts +165 -2
  84. package/src/discontinuous/map-transition.ts +67 -3
  85. package/src/discontinuous/mercator-map.ts +151 -12
  86. package/src/discontinuous/polygon-constraints.ts +184 -0
  87. package/src/discontinuous/prepare-features.ts +154 -6
  88. package/src/discontinuous/source-arc.ts +31 -0
  89. package/src/index.ts +1 -0
  90. package/src/turf.ts +86 -0
@@ -54,15 +54,40 @@ export type MapSeam = {
54
54
  kind: 'join' | 'cut' | 'boundary';
55
55
  };
56
56
  export type MapPoint = {regionId: string; position: Position};
57
- export type MapLine = {regionId: string; coordinates: Position[]; sourceCoordinates: Position[]};
57
+ export type MapLine = {
58
+ regionId: string;
59
+ coordinates: Position[];
60
+ sourceCoordinates: Position[];
61
+ provenance?: {
62
+ segments: number[];
63
+ parameters: number[];
64
+ vertices: number[];
65
+ angularDistances: number[];
66
+ startReason?: string;
67
+ endReason?: string;
68
+ };
69
+ };
70
+ export type MapPolygonEdge = {
71
+ kind: 'source-boundary' | 'coverage-boundary' | 'chart-cut' | 'continuous-chart-join';
72
+ ring: number;
73
+ segment: number;
74
+ t0: number;
75
+ t1: number;
76
+ };
58
77
  export type MapPolygon = {
59
78
  regionId: string;
60
79
  coordinates: Position[][];
61
80
  sourceCoordinates: Position[][];
81
+ edgeProvenance?: MapPolygonEdge[][];
62
82
  };
63
83
  export type MapSamplingOptions = {
64
84
  /** Maximum angular sampling interval in degrees. Default 2, range (0, 90]. */
65
85
  maxAngularStep?: number;
86
+ /** Opt-in minor-great-circle source lineage, recorded during clipping/sampling. */
87
+ pathProvenance?: boolean;
88
+ pathDistances?: boolean;
89
+ pathBoundaries?: boolean;
90
+ polygonEdgeProvenance?: boolean;
66
91
  /** Midpoint chord-error target in placed output units. Default 1e-4. */
67
92
  tolerance?: number;
68
93
  };
@@ -243,6 +268,10 @@ export class DiscontinuousMap {
243
268
  const points = line.map(geographic);
244
269
  for (let i = 1; i < points.length; i++) validateArc(points[i - 1], points[i]);
245
270
  const results: MapLine[] = [];
271
+ const cumulative = options.pathDistances ? [0] : undefined;
272
+ if (cumulative)
273
+ for (let i = 1; i < points.length; i++)
274
+ cumulative.push(cumulative[i - 1] + angle(points[i - 1], points[i]));
246
275
  for (const r of this.compiled) {
247
276
  let current: MapLine | null = null;
248
277
  for (let i = 1; i < points.length; i++) {
@@ -251,6 +280,25 @@ export class DiscontinuousMap {
251
280
  current = null;
252
281
  continue;
253
282
  }
283
+ const reason = (p: Vector, endpoint: Vector, isEndpoint: boolean) => {
284
+ if (isEndpoint && angle(p, endpoint) < 1e-12) return 'source-endpoint';
285
+ const kinds = r.normals
286
+ .map((normal, edge) =>
287
+ Math.abs(dot(normal, p)) < 1e-9
288
+ ? this.seams.find(s =>
289
+ s.regions.some((id, i) => id === r.definition.id && s.edges[i] === edge)
290
+ )?.kind
291
+ : undefined
292
+ )
293
+ .filter(Boolean);
294
+ return kinds.includes('boundary')
295
+ ? 'coverage-boundary'
296
+ : kinds.includes('cut')
297
+ ? 'chart-cut'
298
+ : kinds.includes('join')
299
+ ? 'continuous-chart-join'
300
+ : 'coverage-boundary';
301
+ };
254
302
  const sampled = this.sample(r, clipped[0], clipped[1], settings);
255
303
  if (sampled.length < 2) continue;
256
304
  if (
@@ -265,12 +313,33 @@ export class DiscontinuousMap {
265
313
  )
266
314
  ) {
267
315
  current = {regionId: r.definition.id, coordinates: [], sourceCoordinates: []};
316
+ if (options.pathProvenance)
317
+ current.provenance = {segments: [], parameters: [], vertices: [], angularDistances: []};
318
+ if (current.provenance && options.pathBoundaries)
319
+ current.provenance.startReason = reason(clipped[0], points[0], i === 1);
268
320
  results.push(current);
269
321
  } else sampled.shift();
270
322
  for (const p of sampled) {
271
323
  current.coordinates.push(this.project(r, p));
272
324
  current.sourceCoordinates.push(coordinates(p));
325
+ if (current.provenance) {
326
+ const length = angle(points[i - 1], points[i]);
327
+ const t = length ? Math.min(1, Math.max(0, angle(points[i - 1], p) / length)) : 0;
328
+ current.provenance.segments.push(i - 1);
329
+ current.provenance.parameters.push(t);
330
+ current.provenance.vertices.push(
331
+ angle(points[i - 1], p) < 1e-12 ? i - 1 : angle(points[i], p) < 1e-12 ? i : 0xffffffff
332
+ );
333
+ if (cumulative)
334
+ current.provenance.angularDistances.push(cumulative[i - 1] + t * length);
335
+ }
273
336
  }
337
+ if (current.provenance && options.pathBoundaries)
338
+ current.provenance.endReason = reason(
339
+ clipped[1],
340
+ points[points.length - 1],
341
+ i === points.length - 1
342
+ );
274
343
  // Even an excursion returning to the same seam point starts a new part.
275
344
  if (!this.contains(r, points[i])) current = null;
276
345
  }
@@ -392,24 +461,118 @@ export class DiscontinuousMap {
392
461
  coordinates: [],
393
462
  sourceCoordinates: []
394
463
  };
464
+ if (options.polygonEdgeProvenance) output.edgeProvenance = [];
395
465
  polygon.forEach((ring, index) => {
396
466
  const sphere = ring.map(p => unit(add(center, add(scale(x, p[0]), scale(y, p[1])))));
397
467
  const points: Vector[] = [];
468
+ const edges: MapPolygonEdge[] = [];
469
+ const classify = (a: Vector, b: Vector): MapPolygonEdge => {
470
+ // Directional pole copies can differ in floating-point coordinates
471
+ // while describing the same source direction. Their zero-length
472
+ // connector is computational, never an original boundary.
473
+ if (angle(a, b) < 64 * Number.EPSILON)
474
+ return {
475
+ kind: 'continuous-chart-join',
476
+ ring: 0xffffffff,
477
+ segment: 0xffffffff,
478
+ t0: 0,
479
+ t1: 0
480
+ };
481
+ // Attribute the boolean boundary in the source clipping chart, before
482
+ // projection/resampling. Original constraints win on coincident cuts.
483
+ const candidates: {ring: number; segment: number}[] = [];
484
+ vectors.forEach((source, ring) =>
485
+ source.forEach((u, segment) => {
486
+ const v = source[(segment + 1) % source.length],
487
+ length = angle(u, v);
488
+ if (
489
+ length > 1e-12 &&
490
+ [a, b].every(
491
+ p =>
492
+ Math.abs(dot(unit(cross(u, v)), p)) < 1e-12 &&
493
+ Math.abs(angle(u, p) + angle(p, v) - length) < 1e-10
494
+ )
495
+ )
496
+ candidates.push({ring, segment});
497
+ })
498
+ );
499
+ if (candidates.length > 1) throw new Error('Ambiguous polygon boundary provenance');
500
+ if (candidates.length) {
501
+ const {ring, segment} = candidates[0],
502
+ source = vectors[ring],
503
+ u = source[segment],
504
+ length = angle(u, source[(segment + 1) % source.length]);
505
+ return {
506
+ kind: 'source-boundary',
507
+ ring,
508
+ segment,
509
+ t0: Math.min(1, angle(u, a) / length),
510
+ t1: Math.min(1, angle(u, b) / length)
511
+ };
512
+ }
513
+ const kinds = r.normals
514
+ .map((n, edge) =>
515
+ [a, b].every(p => Math.abs(dot(n, p)) < 1e-9)
516
+ ? this.seams.find(s =>
517
+ s.regions.some((id, i) => id === r.definition.id && s.edges[i] === edge)
518
+ )?.kind
519
+ : undefined
520
+ )
521
+ .filter(Boolean);
522
+ if (!kinds.length) throw new Error('Unknown polygon clipping constraint');
523
+ return {
524
+ kind: kinds.includes('boundary')
525
+ ? 'coverage-boundary'
526
+ : kinds.includes('cut')
527
+ ? 'chart-cut'
528
+ : 'continuous-chart-join',
529
+ ring: 0xffffffff,
530
+ segment: 0xffffffff,
531
+ t0: 0,
532
+ t1: 0
533
+ };
534
+ };
398
535
  for (let i = 1; i < sphere.length; i++) {
536
+ const origin =
537
+ options.polygonEdgeProvenance &&
538
+ !sphere[i - 1].every((value, k) => value === sphere[i][k])
539
+ ? classify(sphere[i - 1], sphere[i])
540
+ : undefined;
399
541
  const sampled = this.sample(r, sphere[i - 1], sphere[i], settings);
400
542
  for (const point of sampled) {
401
543
  const previous = points[points.length - 1];
402
- if (!previous || !point.every((value, k) => value === previous[k]))
544
+ if (!previous || !point.every((value, k) => value === previous[k])) {
545
+ if (previous && origin) {
546
+ const edge = {...origin};
547
+ if (edge.kind === 'source-boundary') {
548
+ const source = vectors[edge.ring],
549
+ u = source[edge.segment],
550
+ length = angle(u, source[(edge.segment + 1) % source.length]);
551
+ edge.t0 = Math.min(1, angle(u, previous) / length);
552
+ edge.t1 = Math.min(1, angle(u, point) / length);
553
+ }
554
+ edges.push(edge);
555
+ }
403
556
  points.push(point);
557
+ }
404
558
  }
405
559
  }
406
560
  if (points.length < 3) return;
407
561
  if (points[points.length - 1].every((value, k) => value === points[0][k])) points.pop();
562
+ if (options.polygonEdgeProvenance && edges.length < points.length)
563
+ edges.push(classify(points[points.length - 1], points[0]));
408
564
  points.push(points[0]);
409
565
  const xy = points.map(p => this.project(r, p));
410
566
  if (ringOrientation(xy) > 0 !== (index === 0)) {
411
567
  xy.reverse();
412
568
  points.reverse();
569
+ edges.reverse();
570
+ for (const e of edges) [e.t0, e.t1] = [e.t1, e.t0];
571
+ }
572
+ if (output.edgeProvenance) {
573
+ if (edges.length !== points.length - 1)
574
+ throw new Error('Mismatched polygon edge provenance');
575
+ output.edgeProvenance.push(edges);
413
576
  }
414
577
  output.coordinates.push(xy);
415
578
  output.sourceCoordinates.push(points.map(coordinates));
@@ -7,6 +7,71 @@ import type {GeographicPosition, MapSamplingOptions, MapRegion} from './map-layo
7
7
  import {geographic, type Position} from './math';
8
8
  import {rectangle} from './elastic/geometry';
9
9
 
10
+ // Qualified default world formulas with finite coverage and no interior cuts.
11
+ // Keep this explicit: a newly registered kernel is not automatically qualified.
12
+ // Bertin and Sinu-Mollweide need rotated-chart coverage fixes; Gringorten has
13
+ // interior cuts. Singular or folded formulas and parameter overrides remain
14
+ // conservative until their endpoint coverage and correspondence are qualified.
15
+ const directWorldProjections = new Set([
16
+ 'gilbert',
17
+ 'aitoff',
18
+ 'august',
19
+ 'baker',
20
+ 'boggs',
21
+ 'bromley',
22
+ 'collignon',
23
+ 'craster',
24
+ 'eckert1',
25
+ 'eckert2',
26
+ 'eckert3',
27
+ 'eckert4',
28
+ 'eckert5',
29
+ 'eckert6',
30
+ 'fahey',
31
+ 'foucaut',
32
+ 'ginzburg4',
33
+ 'ginzburg5',
34
+ 'ginzburg6',
35
+ 'ginzburg8',
36
+ 'ginzburg9',
37
+ 'homolosine',
38
+ 'kavrayskiy7',
39
+ 'larrivee',
40
+ 'laskowski',
41
+ 'miller',
42
+ 'mollweide',
43
+ 'mt-flat-polar-parabolic',
44
+ 'mt-flat-polar-quartic',
45
+ 'mt-flat-polar-sinusoidal',
46
+ 'natural-earth2',
47
+ 'nell-hammer',
48
+ 'patterson',
49
+ 'robinson',
50
+ 'sinusoidal',
51
+ 'times',
52
+ 'van-der-grinten',
53
+ 'van-der-grinten2',
54
+ 'van-der-grinten3',
55
+ 'van-der-grinten4',
56
+ 'wagner4',
57
+ 'wagner6',
58
+ 'winkel3',
59
+ 'bonne',
60
+ 'bottomley',
61
+ 'cylindrical-equal-area',
62
+ 'cylindrical-stereographic',
63
+ 'foucaut-sinusoidal',
64
+ 'hammer',
65
+ 'hill',
66
+ 'hufnagel',
67
+ 'hyperelliptical',
68
+ 'lagrange',
69
+ 'loximuthal',
70
+ 'wagner7',
71
+ 'quartic-authalic',
72
+ 'wagner'
73
+ ]);
74
+
10
75
  export type MapTransitionOptions = {strategy?: 'auto' | 'direct' | 'via-globe'};
11
76
  export type MapTransitionPart = Readonly<{
12
77
  kind: 'line' | 'polygon';
@@ -316,9 +381,8 @@ function fullCoverage(map: MapEngine): boolean {
316
381
  covered =
317
382
  completeSections && !overlaps && polygonClipping.difference(world, ...domains).length === 0;
318
383
  } else if (d.type === 'world') {
319
- // Deliberately bounded to global formulas with finite poles. Other kernels,
320
- // custom charts and regional maps retain the conservative globe route.
321
- covered = ['sinusoidal', 'mollweide', 'robinson'].includes(d.projection);
384
+ covered =
385
+ directWorldProjections.has(d.projection) && Object.keys(d.parameters ?? {}).length === 0;
322
386
  }
323
387
  coverageCache.set(map, covered);
324
388
  return covered;
@@ -3,6 +3,8 @@
3
3
  // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
4
4
 
5
5
  import polygonClipping from 'polygon-clipping';
6
+ import {tracePolygonConstraints, composePolygonEdge} from './polygon-constraints';
7
+ import {sourceArc} from './source-arc';
6
8
  import {
7
9
  DiscontinuousMap,
8
10
  type GeographicPosition,
@@ -10,7 +12,7 @@ import {
10
12
  type MapRegion,
11
13
  type MapSamplingOptions
12
14
  } from './map-layout';
13
- import {geographic, RAD, type Vector} from './math';
15
+ import {angle, coordinates, geographic, RAD, unit, type Vector} from './math';
14
16
 
15
17
  /** Spherical Mercator, clipped to a finite latitude band after seam splitting. */
16
18
  export class MercatorMap extends DiscontinuousMap {
@@ -117,18 +119,83 @@ export class MercatorMap extends DiscontinuousMap {
117
119
  continue;
118
120
  }
119
121
  const at = (t: number): [number, number] => [a[0] + (b[0] - a[0]) * t, a[1] + dy * t];
120
- const start = at(t0),
121
- end = at(t1);
122
+ // Locate the latitude cut on the sampled source great-circle arc, not
123
+ // on its projected chord. Retain the parent segment throughout this stage.
124
+ const endpoint = (t: number) => {
125
+ if (t === 0 || t === 1) return {xy: at(t), source: part.sourceCoordinates[i - 1 + t], t};
126
+ const va = geographic(part.sourceCoordinates[i - 1]),
127
+ vb = geographic(part.sourceCoordinates[i]),
128
+ arc = angle(va, vb);
129
+ const sourceAt = (t: number) => {
130
+ if (!arc) return part.sourceCoordinates[i - 1];
131
+ const a = Math.sin((1 - t) * arc) / Math.sin(arc),
132
+ b = Math.sin(t * arc) / Math.sin(arc);
133
+ return coordinates(unit(va.map((v, k) => a * v + b * vb[k]) as Vector));
134
+ };
135
+ const latitude = Math.sign(at(t)[1]) * this.limit;
136
+ let lo = 0,
137
+ hi = 1;
138
+ for (let j = 0; j < 52; j++) {
139
+ const mid = (lo + hi) / 2;
140
+ if (sourceAt(mid)[1] < latitude === part.sourceCoordinates[i - 1][1] < latitude)
141
+ lo = mid;
142
+ else hi = mid;
143
+ }
144
+ const f = (lo + hi) / 2,
145
+ source = sourceAt(f);
146
+ const xy = super.projectPoint(source, part.regionId)!.position;
147
+ return {xy, source, t: f};
148
+ };
149
+ const start = endpoint(t0),
150
+ end = endpoint(t1);
151
+ const append = (point: typeof start, index: number) => {
152
+ current!.coordinates.push(point.xy);
153
+ current!.sourceCoordinates.push(point.source);
154
+ const source = part.provenance,
155
+ target = current!.provenance;
156
+ if (!source || !target) return;
157
+ const segment = source.segments[i];
158
+ const from = source.segments[i - 1] === segment ? source.parameters[i - 1] : 0;
159
+ target.segments.push(point.t === 0 || point.t === 1 ? source.segments[index] : segment);
160
+ target.parameters.push(
161
+ point.t === 0 || point.t === 1
162
+ ? source.parameters[index]
163
+ : from + point.t * (source.parameters[i] - from)
164
+ );
165
+ target.vertices.push(
166
+ point.t === 0 || point.t === 1 ? source.vertices[index] : 0xffffffff
167
+ );
168
+ if (options?.pathDistances)
169
+ target.angularDistances.push(
170
+ source.angularDistances[i - 1] +
171
+ point.t * (source.angularDistances[i] - source.angularDistances[i - 1])
172
+ );
173
+ };
122
174
  if (!current || t0 > 0) {
123
175
  current = {
124
176
  regionId: part.regionId,
125
- coordinates: [start],
126
- sourceCoordinates: [this.source(start)]
177
+ coordinates: [],
178
+ sourceCoordinates: [],
179
+ ...(part.provenance
180
+ ? {
181
+ provenance: {
182
+ segments: [],
183
+ parameters: [],
184
+ vertices: [],
185
+ angularDistances: [],
186
+ ...(options?.pathBoundaries
187
+ ? {startReason: t0 > 0 ? 'coverage-boundary' : part.provenance.startReason}
188
+ : {})
189
+ }
190
+ }
191
+ : {})
127
192
  };
128
193
  result.push(current);
194
+ append(start, i - 1);
129
195
  }
130
- current.coordinates.push(end);
131
- current.sourceCoordinates.push(this.source(end));
196
+ append(end, i);
197
+ if (current.provenance && options?.pathBoundaries)
198
+ current.provenance.endReason = t1 < 1 ? 'coverage-boundary' : part.provenance!.endReason;
132
199
  if (t1 < 1) current = undefined;
133
200
  }
134
201
  }
@@ -150,11 +217,83 @@ export class MercatorMap extends DiscontinuousMap {
150
217
  ]
151
218
  ];
152
219
  return super.projectPolygon(rings, options).flatMap(part =>
153
- polygonClipping.intersection(part.coordinates, box).map(coordinates => ({
154
- regionId: part.regionId,
155
- coordinates,
156
- sourceCoordinates: coordinates.map(ring => ring.map(p => this.source(p)))
157
- }))
220
+ polygonClipping.intersection(part.coordinates, box).map(polygon => {
221
+ const traced = tracePolygonConstraints(
222
+ part.coordinates,
223
+ polygon,
224
+ 1e-10,
225
+ part.edgeProvenance
226
+ );
227
+ const sources = traced.rings.map(r => r.map(p => this.source(p)));
228
+ traced.owners.forEach((owners, r) =>
229
+ owners.forEach((owner, i) => {
230
+ if (!owner) return;
231
+ const [parentRing, parentEdge] = owner,
232
+ a = part.sourceCoordinates[parentRing][parentEdge],
233
+ b = part.sourceCoordinates[parentRing][parentEdge + 1],
234
+ at = sourceArc(a, b);
235
+ for (const j of [i, i + 1]) {
236
+ const q = traced.rings[r][j],
237
+ first = part.coordinates[parentRing][parentEdge],
238
+ last = part.coordinates[parentRing][parentEdge + 1];
239
+ if (Math.hypot(q[0] - first[0], q[1] - first[1]) < 1e-10 * this.radius)
240
+ sources[r][j] = [...a];
241
+ else if (Math.hypot(q[0] - last[0], q[1] - last[1]) < 1e-10 * this.radius)
242
+ sources[r][j] = [...b];
243
+ else {
244
+ const latitude = Math.sign(q[1]) * this.limit;
245
+ let lo = 0,
246
+ hi = 1;
247
+ if (a[1] < latitude === b[1] < latitude)
248
+ throw new Error('Unresolved Mercator source constraint intersection');
249
+ for (let k = 0; k < 52; k++) {
250
+ const mid = (lo + hi) / 2;
251
+ if (at(mid)[1] < latitude === a[1] < latitude) lo = mid;
252
+ else hi = mid;
253
+ }
254
+ sources[r][j] = at((lo + hi) / 2);
255
+ }
256
+ }
257
+ })
258
+ );
259
+ for (const r of sources) r[r.length - 1] = [...r[0]];
260
+ return {
261
+ regionId: part.regionId,
262
+ sourceCoordinates: sources,
263
+ coordinates: sources.map(r => r.map(p => super.projectPoint(p, part.regionId)!.position)),
264
+ ...(options?.polygonEdgeProvenance
265
+ ? {
266
+ edgeProvenance: traced.owners.map((owners, r) =>
267
+ owners.map((owner, i) => {
268
+ if (owner) {
269
+ const [pr, pe] = owner;
270
+ return composePolygonEdge(
271
+ part.edgeProvenance![pr][pe],
272
+ part.sourceCoordinates[pr][pe],
273
+ part.sourceCoordinates[pr][pe + 1],
274
+ sources[r][i],
275
+ sources[r][i + 1]
276
+ );
277
+ }
278
+ if (
279
+ !sources[r]
280
+ .slice(i, i + 2)
281
+ .every(p => Math.abs(Math.abs(p[1]) - this.limit) < 1e-7)
282
+ )
283
+ throw new Error('Unknown Mercator clipping constraint');
284
+ return {
285
+ kind: 'coverage-boundary' as const,
286
+ ring: 0xffffffff,
287
+ segment: 0xffffffff,
288
+ t0: 0,
289
+ t1: 0
290
+ };
291
+ })
292
+ )
293
+ }
294
+ : {})
295
+ };
296
+ })
158
297
  );
159
298
  }
160
299
  }
@@ -0,0 +1,184 @@
1
+ // math.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
4
+ import {angle, geographic, type Position} from './math';
5
+ import type {MapPolygonEdge} from './map-layout';
6
+ export type ConstraintOwner = readonly [ring: number, edge: number] | null;
7
+ /** Attribute boolean output to indexed input constraints before final map
8
+ * projection/resampling. Restore vertices erased by collinear-edge merging.
9
+ * Ambiguous ancestry rejects; target rendering coordinates never identify an
10
+ * original source segment. The caller owns this computational clipping chart.
11
+ */
12
+ export function tracePolygonConstraints(
13
+ input: Position[][],
14
+ output: Position[][],
15
+ tolerance = 1e-10,
16
+ priorities?: MapPolygonEdge[][],
17
+ accept?: (point: Position) => boolean,
18
+ degenerate?: (a: Position, b: Position) => boolean
19
+ ) {
20
+ const constraints = input.flatMap((ring, r) =>
21
+ ring.slice(0, -1).map((a, i) => ({a, b: ring[i + 1], r, i}))
22
+ );
23
+ let work = 0;
24
+ const tick = () => {
25
+ if (++work > 20000000) throw new Error('Polygon constraint work limit exceeded');
26
+ };
27
+ const fraction = (p: Position, a: Position, b: Position) => {
28
+ const dx = b[0] - a[0],
29
+ dy = b[1] - a[1],
30
+ l = dx * dx + dy * dy;
31
+ return l ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l : NaN;
32
+ };
33
+ const on = (p: Position, a: Position, b: Position) => {
34
+ tick();
35
+ const t = fraction(p, a, b),
36
+ length = Math.hypot(b[0] - a[0], b[1] - a[1]);
37
+ return (
38
+ length > 0 &&
39
+ t >= -tolerance &&
40
+ t <= 1 + tolerance &&
41
+ Math.hypot(p[0] - a[0] - t * (b[0] - a[0]), p[1] - a[1] - t * (b[1] - a[1])) <=
42
+ tolerance * Math.max(1, length)
43
+ );
44
+ };
45
+ type Box = readonly [number, number, number, number];
46
+ type Node = {box: Box; ids?: number[]; left?: Node; right?: Node};
47
+ const boxes: Box[] = constraints.map(c => [
48
+ Math.min(c.a[0], c.b[0]),
49
+ Math.min(c.a[1], c.b[1]),
50
+ Math.max(c.a[0], c.b[0]),
51
+ Math.max(c.a[1], c.b[1])
52
+ ]);
53
+ const build = (ids: number[]): Node => {
54
+ const box: [number, number, number, number] = [Infinity, Infinity, -Infinity, -Infinity];
55
+ for (const id of ids) {
56
+ tick();
57
+ const b = boxes[id];
58
+ box[0] = Math.min(box[0], b[0]);
59
+ box[1] = Math.min(box[1], b[1]);
60
+ box[2] = Math.max(box[2], b[2]);
61
+ box[3] = Math.max(box[3], b[3]);
62
+ }
63
+ if (ids.length <= 16) return {box, ids};
64
+ const axis = box[2] - box[0] >= box[3] - box[1] ? 0 : 1;
65
+ ids.sort((a, b) => {
66
+ tick();
67
+ return boxes[a][axis] + boxes[a][axis + 2] - boxes[b][axis] - boxes[b][axis + 2];
68
+ });
69
+ const middle = Math.floor(ids.length / 2);
70
+ return {box, left: build(ids.slice(0, middle)), right: build(ids.slice(middle))};
71
+ };
72
+ const root = build(constraints.map((_, i) => i));
73
+ const padding = tolerance * Math.max(1, root.box[2] - root.box[0], root.box[3] - root.box[1]);
74
+ const query = (a: Position, b: Position) => {
75
+ const box: Box = [
76
+ Math.min(a[0], b[0]) - padding,
77
+ Math.min(a[1], b[1]) - padding,
78
+ Math.max(a[0], b[0]) + padding,
79
+ Math.max(a[1], b[1]) + padding
80
+ ],
81
+ ids: number[] = [];
82
+ const overlaps = (b: Box) =>
83
+ b[0] <= box[2] && b[2] >= box[0] && b[1] <= box[3] && b[3] >= box[1];
84
+ const visit = (node: Node) => {
85
+ tick();
86
+ if (!overlaps(node.box)) return;
87
+ if (node.ids) {
88
+ for (const id of node.ids) {
89
+ tick();
90
+ if (overlaps(boxes[id])) ids.push(id);
91
+ }
92
+ } else {
93
+ visit(node.left!);
94
+ visit(node.right!);
95
+ }
96
+ };
97
+ visit(root);
98
+ return ids.map(id => constraints[id]);
99
+ };
100
+ const rings: Position[][] = [],
101
+ owners: ConstraintOwner[][] = [];
102
+ for (const ring of output) {
103
+ const points: Position[] = [];
104
+ for (let i = 1; i < ring.length; i++) {
105
+ const a = ring[i - 1],
106
+ b = ring[i];
107
+ if (a.every((v, k) => v === b[k])) continue;
108
+ const breaks: {t: number; p: Position}[] = [{t: 0, p: a}];
109
+ for (const c of query(a, b))
110
+ if (on(c.a, a, b) && (!accept || accept(c.a))) {
111
+ const t = fraction(c.a, a, b);
112
+ if (t > 1e-12 && t < 1 - 1e-12) breaks.push({t, p: c.a});
113
+ }
114
+ breaks.sort((a, b) => a.t - b.t);
115
+ for (const item of breaks)
116
+ if (
117
+ !points.length ||
118
+ Math.hypot(...points[points.length - 1].map((v, k) => v - item.p[k])) > 1e-14
119
+ )
120
+ points.push([...item.p]);
121
+ }
122
+ if (points.length < 3) continue;
123
+ points.push([...points[0]]);
124
+ const tags: ConstraintOwner[] = [];
125
+ for (let i = 1; i < points.length; i++) {
126
+ const a = points[i - 1],
127
+ b = points[i];
128
+ if (degenerate?.(a, b)) {
129
+ tags.push(null);
130
+ continue;
131
+ }
132
+ let candidates = query(a, b).filter(c => on(a, c.a, c.b) && on(b, c.a, c.b));
133
+ const same = (p: Position, q: Position) =>
134
+ Math.hypot(...p.map((v, k) => v - q[k])) <=
135
+ 64 * Number.EPSILON * Math.max(1, ...p.map(Math.abs), ...q.map(Math.abs));
136
+ const exact = candidates.filter(
137
+ c => (same(a, c.a) && same(b, c.b)) || (same(a, c.b) && same(b, c.a))
138
+ );
139
+ if (exact.length) candidates = exact;
140
+ if (candidates.some(c => priorities?.[c.r]?.[c.i]?.kind === 'source-boundary'))
141
+ candidates = candidates.filter(c => priorities?.[c.r]?.[c.i]?.kind === 'source-boundary');
142
+ if (candidates.length > 1) {
143
+ const score = (c: (typeof candidates)[number]) =>
144
+ Math.max(
145
+ ...[a, b].map(p => {
146
+ const t = Math.min(1, Math.max(0, fraction(p, c.a, c.b)));
147
+ return Math.hypot(
148
+ p[0] - c.a[0] - t * (c.b[0] - c.a[0]),
149
+ p[1] - c.a[1] - t * (c.b[1] - c.a[1])
150
+ );
151
+ })
152
+ );
153
+ candidates.sort((a, b) => score(a) - score(b));
154
+ const epsilon = 64 * Number.EPSILON * Math.max(1, ...a.map(Math.abs), ...b.map(Math.abs));
155
+ if (score(candidates[1]) - score(candidates[0]) > epsilon) candidates = [candidates[0]];
156
+ else if (Math.hypot(a[0] - b[0], a[1] - b[1]) <= 2 * epsilon) candidates = [];
157
+ else throw new Error('Ambiguous polygon clipping constraint ancestry');
158
+ }
159
+ tags.push(candidates.length ? [candidates[0].r, candidates[0].i] : null);
160
+ }
161
+ rings.push(points);
162
+ owners.push(tags);
163
+ }
164
+ return {rings, owners};
165
+ }
166
+ /** Compose directed fractions through a known parent edge; never search raw
167
+ * input geometry for an original segment after resampling or projection. */
168
+ export function composePolygonEdge(
169
+ parent: MapPolygonEdge,
170
+ a: Position,
171
+ b: Position,
172
+ start: Position,
173
+ end: Position
174
+ ): MapPolygonEdge {
175
+ if (parent.kind !== 'source-boundary') return {...parent};
176
+ const v = geographic(a),
177
+ length = angle(v, geographic(b));
178
+ const parameter = (p: Position) => (length ? Math.min(1, angle(v, geographic(p)) / length) : 0);
179
+ return {
180
+ ...parent,
181
+ t0: parent.t0 + parameter(start) * (parent.t1 - parent.t0),
182
+ t1: parent.t0 + parameter(end) * (parent.t1 - parent.t0)
183
+ };
184
+ }