File size: 10,870 Bytes
9d2d895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
/**
 * Viewport-culling + cache-key helpers for the conflict-zone GeoJson layer
 * (#4561, follow-up to #4558; part of #4537 / #4487).
 *
 * Pure and dependency-free (geojson types only) so it is unit-testable under
 * `tsx --test` without a DOM/WebGL context. `DeckGLMap.buildConflictZoneGeoJson`
 * uses these to bound the deck.gl tessellation to the zones intersecting the
 * current map viewport instead of tessellating every zone's polygon (the
 * dominant warm-INP presentation-delay cost β€” field data 2026-06-30/07-01).
 *
 * The cull is deliberately conservative so it never hides a zone that should be
 * visible (R5): it tests each zone's axis-aligned bounding box (never the
 * polygon itself β†’ over-inclusion, never under-inclusion), pads the viewport,
 * and never culls at world/low zoom or across the antimeridian.
 */
import type { Feature, Geometry, Position } from 'geojson';

/** [west, south, east, north] in degrees. */
export type BBox = [number, number, number, number];

/** A conflict-zone feature paired with its precomputed geographic bounds. */
export interface BoundedFeature {
  bounds: BBox;
  feature: Feature;
}

/** Fraction of the viewport span added as padding on each side before culling. */
export const CULL_PAD_FRACTION = 0.5;
/** Longitude span (deg) at/above which the viewport is treated as "world" (no cull). */
export const WORLD_LON_SPAN = 300;

function walkPositions(coords: unknown, visit: (lon: number, lat: number) => void): void {
  if (!Array.isArray(coords)) return;
  if (typeof coords[0] === 'number' && typeof coords[1] === 'number') {
    visit(coords[0], coords[1]);
    return;
  }
  for (const child of coords) walkPositions(child, visit);
}

/**
 * Axis-aligned bounds of a Polygon / MultiPolygon / GeometryCollection, or null
 * when the geometry carries no coordinates. Walks nested coordinate arrays so it
 * works for both a zone's own polygon and a substituted country multipolygon.
 */
export function geometryBounds(geometry: Geometry | null | undefined): BBox | null {
  if (!geometry) return null;
  let west = Infinity;
  let south = Infinity;
  let east = -Infinity;
  let north = -Infinity;
  const visit = (lon: number, lat: number): void => {
    if (lon < west) west = lon;
    if (lon > east) east = lon;
    if (lat < south) south = lat;
    if (lat > north) north = lat;
  };
  if (geometry.type === 'GeometryCollection') {
    for (const child of geometry.geometries) {
      const b = geometryBounds(child);
      if (b) {
        visit(b[0], b[1]);
        visit(b[2], b[3]);
      }
    }
  } else if ('coordinates' in geometry) {
    walkPositions(geometry.coordinates, visit);
  }
  return Number.isFinite(west) && Number.isFinite(south) && Number.isFinite(east) && Number.isFinite(north)
    ? [west, south, east, north]
    : null;
}

/** Standard AABB overlap. Assumes both boxes are non-antimeridian-crossing (west <= east). */
export function bboxIntersects(a: BBox, b: BBox): boolean {
  return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
}

/**
 * True when the viewport must NOT be culled: it crosses the antimeridian
 * (east <= west), is degenerate/non-finite, or spans (near) the whole globe.
 * Culling in those cases could hide a visible zone, so the caller renders all.
 */
export function isWorldViewport(viewport: BBox, worldLonSpan = WORLD_LON_SPAN): boolean {
  const [west, south, east, north] = viewport;
  if (![west, south, east, north].every((v) => Number.isFinite(v))) return true;
  if (east <= west) return true; // antimeridian crossing / degenerate β†’ don't cull
  return east - west >= worldLonSpan;
}

/** Expand a bbox by a fraction of its span on each side. */
export function padViewport(viewport: BBox, fraction = CULL_PAD_FRACTION): BBox {
  const [west, south, east, north] = viewport;
  const dLon = (east - west) * fraction;
  const dLat = (north - south) * fraction;
  return [west - dLon, south - dLat, east + dLon, north + dLat];
}

/**
 * Indices (into `features`, order preserved) whose bounds intersect the padded
 * viewport. At world/low zoom (or across the antimeridian) returns every index
 * so we never under-cull. Index identity (not zone id) is what callers key their
 * tessellation cache on: a multi-country zone emits one feature per country, all
 * sharing the zone id, so an id-based key could collide two distinct feature sets.
 */
export function culledIndices(
  features: readonly BoundedFeature[],
  viewport: BBox,
  padFraction = CULL_PAD_FRACTION,
): number[] {
  if (isWorldViewport(viewport)) return features.map((_, i) => i);
  const padded = padViewport(viewport, padFraction);
  const out: number[] = [];
  for (let i = 0; i < features.length; i++) {
    const f = features[i];
    if (f && bboxIntersects(f.bounds, padded)) out.push(i);
  }
  return out;
}

/** The features whose bounds intersect the padded viewport, order preserved. */
export function cullToViewport(
  features: readonly BoundedFeature[],
  viewport: BBox,
  padFraction = CULL_PAD_FRACTION,
): Feature[] {
  const out: Feature[] = [];
  for (const i of culledIndices(features, viewport, padFraction)) {
    const f = features[i];
    if (f) out.push(f.feature);
  }
  return out;
}

// ── U2: low-zoom geometry simplification backstop ──────────────────────────────
// At world/low zoom the cull can't reduce the zone count (everything is visible),
// so the vertex-heavy country multipolygons still dominate tessellation. Below a
// zoom threshold we RDP-simplify the polygon rings β€” sub-pixel detail there is
// invisible β€” bounding the vertex count while keeping every zone present (KTD3).

/** Zoom at/above which no simplification runs (zoomed in β†’ detail matters, cull already helps). */
export const SIMPLIFY_ZOOM_THRESHOLD = 4;
/** Max RDP tolerance (deg) applied at the lowest zoom. ~1 world-view pixel at typical widths. */
export const SIMPLIFY_MAX_TOLERANCE_DEG = 0.5;

// Coordinate accessors: a GeoJSON Position always carries [lon, lat]; the `?? 0`
// only guards the (never-valid) missing-index case that noUncheckedIndexedAccess
// forces us to consider β€” it never fires for real data.
const lon = (p: Position): number => p[0] ?? 0;
const lat = (p: Position): number => p[1] ?? 0;
const samePoint = (a: Position, b: Position): boolean => lon(a) === lon(b) && lat(a) === lat(b);

/** Perpendicular distance from point p to the infinite line through a-b. */
function perpendicularDistance(p: Position, a: Position, b: Position): number {
  const dx = lon(b) - lon(a);
  const dy = lat(b) - lat(a);
  const denom = Math.hypot(dx, dy);
  if (denom === 0) return Math.hypot(lon(p) - lon(a), lat(p) - lat(a));
  return Math.abs(dy * lon(p) - dx * lat(p) + lon(b) * lat(a) - lat(b) * lon(a)) / denom;
}

/** Ramer-Douglas-Peucker on an open polyline: keeps both endpoints, drops points within tolerance. */
function rdp(points: Position[], tolerance: number): Position[] {
  if (points.length <= 2) return points.slice();
  if (!points[0] || !points[points.length - 1]) return points.slice();

  const keep = new Array<boolean>(points.length).fill(false);
  keep[0] = true;
  keep[points.length - 1] = true;
  const stack: Array<[number, number]> = [[0, points.length - 1]];

  while (stack.length > 0) {
    const segment = stack.pop();
    if (!segment) break;
    const [startIndex, endIndex] = segment;
    if (endIndex - startIndex <= 1) continue;

    const first = points[startIndex];
    const last = points[endIndex];
    if (!first || !last) continue;

    let index = 0;
    let maxDist = 0;
    for (let i = startIndex + 1; i < endIndex; i++) {
      const pt = points[i];
      if (!pt) continue;
      const d = perpendicularDistance(pt, first, last);
      if (d > maxDist) {
        maxDist = d;
        index = i;
      }
    }

    if (maxDist > tolerance) {
      keep[index] = true;
      stack.push([startIndex, index], [index, endIndex]);
    }
  }

  return points.filter((_, i) => keep[i]);
}

/**
 * Simplify a single closed ring with RDP, preserving closure and polygon
 * validity. The ring is split at its farthest vertex so RDP runs on two open
 * polylines (a closed ring's p0==pn baseline is degenerate). Output points are a
 * strict subset of the input (RDP never moves or adds a vertex), so no new
 * geometry is introduced. Falls back to the original ring if simplification
 * would drop below a valid polygon (< 4 points incl. closure) or not reduce it.
 */
export function simplifyRing(ring: Position[], tolerance: number): Position[] {
  if (tolerance <= 0 || ring.length <= 5) return ring;
  const start = ring[0];
  const finish = ring[ring.length - 1];
  if (!start || !finish) return ring;

  const open = samePoint(start, finish) ? ring.slice(0, -1) : ring.slice();
  const anchor = open[0];
  if (open.length <= 4 || !anchor) return ring;

  let far = 0;
  let farDist = -1;
  for (let i = 1; i < open.length; i++) {
    const pt = open[i];
    if (!pt) continue;
    const d = Math.hypot(lon(pt) - lon(anchor), lat(pt) - lat(anchor));
    if (d > farDist) {
      farDist = d;
      far = i;
    }
  }

  const first = rdp(open.slice(0, far + 1), tolerance);
  const second = rdp([...open.slice(far), anchor], tolerance);
  const merged = [...first.slice(0, -1), ...second.slice(0, -1)];
  const head = merged[0];
  if (merged.length < 4 || !head) return ring;

  const result = [...merged, head];
  return result.length < ring.length ? result : ring;
}

/** Apply {@link simplifyRing} to every ring of a Polygon/MultiPolygon; other geometry is returned as-is. */
export function simplifyGeometry(geometry: Geometry, tolerance: number): Geometry {
  if (tolerance <= 0) return geometry;
  if (geometry.type === 'Polygon') {
    return { type: 'Polygon', coordinates: geometry.coordinates.map((r) => simplifyRing(r, tolerance)) };
  }
  if (geometry.type === 'MultiPolygon') {
    return {
      type: 'MultiPolygon',
      coordinates: geometry.coordinates.map((poly) => poly.map((r) => simplifyRing(r, tolerance))),
    };
  }
  if (geometry.type === 'GeometryCollection') {
    return {
      type: 'GeometryCollection',
      geometries: geometry.geometries.map((g) => simplifyGeometry(g, tolerance)),
    };
  }
  return geometry;
}

/**
 * Monotonic zoom β†’ RDP tolerance (deg). Zero at/above the threshold (no
 * simplification when zoomed in); ramps linearly to {@link SIMPLIFY_MAX_TOLERANCE_DEG}
 * as zoom drops toward 0, so lower zoom = coarser simplification.
 */
export function zoomToSimplifyTolerance(
  zoom: number,
  threshold = SIMPLIFY_ZOOM_THRESHOLD,
  maxTolerance = SIMPLIFY_MAX_TOLERANCE_DEG,
): number {
  if (!Number.isFinite(zoom) || zoom >= threshold) return 0;
  const fraction = (threshold - Math.max(0, zoom)) / threshold;
  return maxTolerance * fraction;
}