leaflet-india-boundary 0.1.0

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 (67) hide show
  1. package/LICENSE +25 -0
  2. package/LICENSE-DATA +101 -0
  3. package/README.md +391 -0
  4. package/data/india-boundary.geojson +1 -0
  5. package/dist/cjs/attribution.js +24 -0
  6. package/dist/cjs/attribution.js.map +1 -0
  7. package/dist/cjs/data.js +78 -0
  8. package/dist/cjs/data.js.map +1 -0
  9. package/dist/cjs/index.js +39 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/leaflet.js +152 -0
  12. package/dist/cjs/leaflet.js.map +1 -0
  13. package/dist/cjs/package.json +3 -0
  14. package/dist/cjs/react.js +77 -0
  15. package/dist/cjs/react.js.map +1 -0
  16. package/dist/cjs/style.js +129 -0
  17. package/dist/cjs/style.js.map +1 -0
  18. package/dist/cjs/suppress.js +462 -0
  19. package/dist/cjs/suppress.js.map +1 -0
  20. package/dist/cjs/suppressionData.js +205 -0
  21. package/dist/cjs/suppressionData.js.map +1 -0
  22. package/dist/esm/attribution.d.ts +21 -0
  23. package/dist/esm/attribution.d.ts.map +1 -0
  24. package/dist/esm/attribution.js +21 -0
  25. package/dist/esm/attribution.js.map +1 -0
  26. package/dist/esm/data.d.ts +78 -0
  27. package/dist/esm/data.d.ts.map +1 -0
  28. package/dist/esm/data.js +75 -0
  29. package/dist/esm/data.js.map +1 -0
  30. package/dist/esm/index.d.ts +19 -0
  31. package/dist/esm/index.d.ts.map +1 -0
  32. package/dist/esm/index.js +19 -0
  33. package/dist/esm/index.js.map +1 -0
  34. package/dist/esm/leaflet.d.ts +103 -0
  35. package/dist/esm/leaflet.d.ts.map +1 -0
  36. package/dist/esm/leaflet.js +147 -0
  37. package/dist/esm/leaflet.js.map +1 -0
  38. package/dist/esm/package.json +3 -0
  39. package/dist/esm/react.d.ts +31 -0
  40. package/dist/esm/react.d.ts.map +1 -0
  41. package/dist/esm/react.js +69 -0
  42. package/dist/esm/react.js.map +1 -0
  43. package/dist/esm/style.d.ts +106 -0
  44. package/dist/esm/style.d.ts.map +1 -0
  45. package/dist/esm/style.js +121 -0
  46. package/dist/esm/style.js.map +1 -0
  47. package/dist/esm/suppress.d.ts +64 -0
  48. package/dist/esm/suppress.d.ts.map +1 -0
  49. package/dist/esm/suppress.js +456 -0
  50. package/dist/esm/suppress.js.map +1 -0
  51. package/dist/esm/suppressionData.d.ts +34 -0
  52. package/dist/esm/suppressionData.d.ts.map +1 -0
  53. package/dist/esm/suppressionData.js +202 -0
  54. package/dist/esm/suppressionData.js.map +1 -0
  55. package/dist/style.css +66 -0
  56. package/package.json +113 -0
  57. package/src/attribution.ts +23 -0
  58. package/src/data.ts +153 -0
  59. package/src/index.ts +45 -0
  60. package/src/leaflet.ts +252 -0
  61. package/src/react.ts +92 -0
  62. package/src/style.css +66 -0
  63. package/src/style.ts +168 -0
  64. package/src/suppress.ts +599 -0
  65. package/src/suppressionData.ts +238 -0
  66. package/tools/build-india-boundary.mjs +836 -0
  67. package/tools/postbuild.mjs +61 -0
@@ -0,0 +1,599 @@
1
+ import {
2
+ latLng,
3
+ point,
4
+ tileLayer,
5
+ type Coords,
6
+ type DoneCallback,
7
+ type Map as LeafletMap,
8
+ type TileLayer,
9
+ type TileLayerOptions,
10
+ } from "leaflet";
11
+ import { osmAdminBoundaryWidth } from "./style.js";
12
+ import {
13
+ INDIA_BOUNDARY_SUPPRESSION,
14
+ SUPPRESSION_BBOX,
15
+ } from "./suppressionData.js";
16
+
17
+ /**
18
+ * EXPERIMENTAL — raster tile surgery.
19
+ *
20
+ * `indiaBoundaryLayer()` *adds* India's claimed boundary but cannot remove the Line
21
+ * of Control and Line of Actual Control that raster tiles already have drawn into
22
+ * them, so both are visible. This module removes them, by editing the tiles in a
23
+ * canvas before Leaflet displays them.
24
+ *
25
+ * Read the caveats near the bottom of this comment before shipping it. In
26
+ * particular it does nothing about labels, so it does not make a map match India's
27
+ * official depiction — see the README.
28
+ *
29
+ * HOW, AND WHY IT IS NOT COMPUTER VISION
30
+ *
31
+ * The obvious approach is to look for carto's border colour and paint over it. That
32
+ * does not work, and it is worth knowing why before trying: carto draws admin
33
+ * boundaries at `opacity: 0.5`, so the colour that ends up in the tile is a blend of
34
+ * the border with whatever terrain is underneath it. There is no border colour to
35
+ * search for. Measured on one z6 tile over Kashmir, only 69 pixels in the entire
36
+ * tile sit within a small distance of `#8d618b`, and widening the threshold enough
37
+ * to catch the rest starts matching a river and a road instead.
38
+ *
39
+ * So colour cannot say *what*. Geometry has to say *where* — and we have the
40
+ * geometry exactly, because it is what deriving India's boundary throws away. A
41
+ * boundary way belonging to two of the four disputed regions is internal, which is
42
+ * the Line of Control; a way shared with China is the Line of Actual Control. Both
43
+ * are discarded to leave India's claimed outer edge, and both are kept in
44
+ * `suppressionData.ts` for use here. Same database the tiles were rendered from,
45
+ * so it lines up by construction.
46
+ *
47
+ * Per tile, then: rasterise those polylines into a mask a few pixels wide at
48
+ * carto's own stroke width for the zoom, and inpaint underneath it by repeatedly
49
+ * averaging each masked pixel's unmasked neighbours, working inward from the edges
50
+ * of the ribbon. Terrain reconstructs well because the ribbon is thin and the
51
+ * terrain either side of it is locally similar.
52
+ *
53
+ * WHAT IT COSTS
54
+ *
55
+ * Almost nothing, because almost no tiles are affected. The disputed region is one
56
+ * bbox; every tile outside it takes a fast path that returns a plain `<img>` with no
57
+ * canvas and no pixel readback at all. Inside it, a tile has a few thousand masked
58
+ * pixels and converges in a handful of passes.
59
+ *
60
+ * CAVEATS
61
+ *
62
+ * - **Labels are untouched.** Urdu toponymy for Gilgit-Baltistan and Chinese labels
63
+ * across Aksai Chin stay exactly as they are. Labels are placed by carto's
64
+ * collision-avoidance engine at render time, so unlike the border there is no
65
+ * geometry to build a mask from. This is the reason this module is not a route to
66
+ * matching India's official depiction.
67
+ * - **Anything else crossing the line gets inpainted too.** A road or a label glyph
68
+ * that overlaps the ribbon is reconstructed from its surroundings, so it can be
69
+ * nicked. Keeping the ribbon at carto's actual stroke width rather than padding it
70
+ * generously is what keeps this rare.
71
+ * - **Tile-edge seams are handled, at a small cost.** Inpainting each tile in
72
+ * isolation makes adjacent tiles disagree along the join, so the work is done on a
73
+ * padded canvas seeded with the neighbours the ribbon runs into. That means a few
74
+ * extra tile requests — see `seamlessEdges` to turn it off.
75
+ * - **Requires CORS.** `getImageData` needs the tile server to send
76
+ * `Access-Control-Allow-Origin`. `tile.openstreetmap.org` does. If yours does not,
77
+ * the canvas is tainted, and by default the tile is shown unmodified rather than
78
+ * failing (see `fallbackOnError`).
79
+ * - **Breaks HTTP caching of the processed result.** The browser still caches the
80
+ * original tile; the surgery is redone on each load. Cheap, but not free.
81
+ */
82
+
83
+ /** Mean latitude of the disputed region, for metres-per-pixel. */
84
+ const REGION_LAT = 34.5;
85
+
86
+ /** Web Mercator ground resolution at the equator, metres per pixel at zoom 0. */
87
+ const EQUATOR_M_PER_PX = 156543.03392;
88
+
89
+ /** Tolerance the shipped suppression geometry was simplified at. See suppressionData.ts. */
90
+ const SIMPLIFY_TOLERANCE_M = 15;
91
+
92
+ /** Antialiasing spreads carto's stroke a little either side of its nominal width. */
93
+ const ANTIALIAS_PAD_PX = 1.6;
94
+
95
+ function metresPerPixel(zoom: number): number {
96
+ return (EQUATOR_M_PER_PX * Math.cos((REGION_LAT * Math.PI) / 180)) / 2 ** zoom;
97
+ }
98
+
99
+ /**
100
+ * Mask width in pixels: carto's own stroke for the zoom, plus its antialiasing, plus
101
+ * however many pixels the simplified geometry might be off by at this zoom.
102
+ *
103
+ * The last term is what makes a coarse simplification safe. The polyline can sit up
104
+ * to the simplification tolerance away from OSM's original nodes, so widening the
105
+ * ribbon by that much guarantees it still covers the drawn line. At 15 m that is
106
+ * nothing until about z11 and under two pixels at z14.
107
+ */
108
+ function defaultRibbonWidth(zoom: number): number {
109
+ const simplifyPx = SIMPLIFY_TOLERANCE_M / metresPerPixel(zoom);
110
+ return osmAdminBoundaryWidth(zoom) + ANTIALIAS_PAD_PX + 2 * simplifyPx;
111
+ }
112
+
113
+ export interface SuppressedTileLayerOptions extends TileLayerOptions {
114
+ /**
115
+ * Mask width in pixels, or a function of zoom. Defaults to
116
+ * {@link defaultRibbonWidth} — carto's stroke width for the zoom plus padding.
117
+ *
118
+ * Widen it if a sliver of the old line survives; narrow it if labels crossing the
119
+ * border are being chewed. Those are the only two failure modes, and they pull in
120
+ * opposite directions.
121
+ */
122
+ ribbonWidth?: number | ((zoom: number) => number);
123
+
124
+ /**
125
+ * On a CORS failure (a tainted canvas, so `getImageData` throws), show the tile
126
+ * unmodified instead of failing. Default `true`: a map with a redundant border
127
+ * beats a map with holes in it.
128
+ */
129
+ fallbackOnError?: boolean;
130
+
131
+ /**
132
+ * Paint the mask in magenta instead of inpainting under it. For checking that the
133
+ * ribbon actually lands on the line before trusting what it removes.
134
+ */
135
+ debugMask?: boolean;
136
+
137
+ /**
138
+ * Reconstruct across tile boundaries by seeding the inpaint with the neighbouring
139
+ * tiles. Default `true`.
140
+ *
141
+ * Without it, each tile is inpainted in isolation, so a pixel on the tile's edge is
142
+ * rebuilt only from the data on its own side and the two tiles disagree along the
143
+ * join — a faint but visible stitch across the erased line. With it, the work
144
+ * happens on a padded canvas seeded from the neighbours the ribbon actually runs
145
+ * into, so both sides see the same ground truth.
146
+ *
147
+ * The cost is bounded and small: only the neighbours the ribbon reaches are
148
+ * fetched — usually two or three, since the line enters and leaves — and only for
149
+ * the handful of tiles the ribbon touches at all. Those neighbours are also, almost
150
+ * always, tiles Leaflet is already displaying, so they come from the browser cache.
151
+ * Set `false` if you would rather have the seams than the requests.
152
+ */
153
+ seamlessEdges?: boolean;
154
+ }
155
+
156
+ /**
157
+ * Leaflet accepts `crossOrigin` as a string, `true` (meaning the empty attribute,
158
+ * which is anonymous) or `false`/absent. The DOM wants a string or null.
159
+ *
160
+ * `fallback` is used when nothing was configured, because for the tiles this module
161
+ * processes anonymous CORS is not optional — without it the canvas is tainted and
162
+ * there is nothing to read back.
163
+ */
164
+ function resolveCrossOrigin(
165
+ value: string | boolean | undefined,
166
+ fallback: string | null,
167
+ ): string | null {
168
+ if (value === true) return "";
169
+ if (value === false || value == null) return fallback;
170
+ return value;
171
+ }
172
+
173
+ /** Neighbour offsets for the inpaint, 8-connected. */
174
+ const NEIGHBOURS: ReadonlyArray<readonly [number, number]> = [
175
+ [1, 0],
176
+ [-1, 0],
177
+ [0, 1],
178
+ [0, -1],
179
+ [1, 1],
180
+ [1, -1],
181
+ [-1, 1],
182
+ [-1, -1],
183
+ ];
184
+
185
+ /** Cheap per-feature reject: does this polyline's own extent touch the tile at all? */
186
+ function featureBounds(
187
+ coords: number[][],
188
+ ): readonly [number, number, number, number] {
189
+ let w = Infinity;
190
+ let s = Infinity;
191
+ let e = -Infinity;
192
+ let n = -Infinity;
193
+ for (const c of coords) {
194
+ if (c[0]! < w) w = c[0]!;
195
+ if (c[0]! > e) e = c[0]!;
196
+ if (c[1]! < s) s = c[1]!;
197
+ if (c[1]! > n) n = c[1]!;
198
+ }
199
+ return [w, s, e, n];
200
+ }
201
+
202
+ const FEATURE_BOUNDS: ReadonlyArray<readonly [number, number, number, number]> =
203
+ INDIA_BOUNDARY_SUPPRESSION.features.map((f) => featureBounds(f.geometry.coordinates));
204
+
205
+ function overlaps(
206
+ a: readonly [number, number, number, number],
207
+ b: readonly [number, number, number, number],
208
+ pad: number,
209
+ ): boolean {
210
+ return !(
211
+ a[2] + pad < b[0] ||
212
+ a[0] - pad > b[2] ||
213
+ a[3] + pad < b[1] ||
214
+ a[1] - pad > b[3]
215
+ );
216
+ }
217
+
218
+ /**
219
+ * A tile layer that erases the Line of Control and Line of Actual Control from the
220
+ * tiles it loads.
221
+ *
222
+ * Compose it with the boundary overlay to get a single correct line on free raster
223
+ * tiles — this one removes what the basemap drew, that one adds India's claim:
224
+ *
225
+ * ```js
226
+ * import { suppressedTileLayer } from "leaflet-india-boundary/suppress";
227
+ * import { indiaBoundaryLayer } from "leaflet-india-boundary/leaflet";
228
+ *
229
+ * suppressedTileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
230
+ * attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
231
+ * }).addTo(map);
232
+ *
233
+ * indiaBoundaryLayer().addTo(map);
234
+ * ```
235
+ *
236
+ * Read the module comment above before shipping this. The short version: it does not
237
+ * touch labels, so it does not make a map match India's official depiction.
238
+ */
239
+ export function suppressedTileLayer(
240
+ urlTemplate: string,
241
+ options: SuppressedTileLayerOptions = {},
242
+ ): TileLayer {
243
+ const {
244
+ ribbonWidth,
245
+ fallbackOnError = true,
246
+ debugMask = false,
247
+ seamlessEdges = true,
248
+ ...tileOptions
249
+ } = options;
250
+
251
+ // Without this the canvas is tainted and getImageData throws.
252
+ const layer = tileLayer(urlTemplate, { crossOrigin: "anonymous", ...tileOptions });
253
+
254
+ const ribbonFor = (zoom: number) =>
255
+ typeof ribbonWidth === "function"
256
+ ? ribbonWidth(zoom)
257
+ : (ribbonWidth ?? defaultRibbonWidth(zoom));
258
+
259
+ // `createTile` and `_map` are both declared protected — they are Leaflet's
260
+ // documented extension points, meant to be overridden by a subclass. Overriding on
261
+ // the instance instead keeps this a plain factory with a typed return, at the cost
262
+ // of one cast to name the members being used. Nothing here is undocumented; it is
263
+ // only reached differently.
264
+ const internal = layer as unknown as {
265
+ _map: LeafletMap;
266
+ options: SuppressedTileLayerOptions;
267
+ getTileSize(): ReturnType<TileLayer["getTileSize"]>;
268
+ getTileUrl(coords: Coords): string;
269
+ createTile(coords: Coords, done: DoneCallback): HTMLElement;
270
+ };
271
+
272
+ internal.createTile = function createTile(coords, done) {
273
+ const size = this.getTileSize();
274
+ const map = this._map;
275
+ const origin = point(coords.x, coords.y).scaleBy(size);
276
+
277
+ // Tile extent in lon/lat, for the fast path and the per-feature reject.
278
+ const nw = map.unproject(origin, coords.z);
279
+ const se = map.unproject(origin.add(size), coords.z);
280
+ const tileBox: readonly [number, number, number, number] = [
281
+ Math.min(nw.lng, se.lng),
282
+ Math.min(nw.lat, se.lat),
283
+ Math.max(nw.lng, se.lng),
284
+ Math.max(nw.lat, se.lat),
285
+ ];
286
+
287
+ // Degrees of padding to cover the mask's own width at this zoom.
288
+ const ribbon = ribbonFor(coords.z);
289
+ const padDeg =
290
+ (ribbon * metresPerPixel(coords.z)) /
291
+ (111_320 * Math.cos((REGION_LAT * Math.PI) / 180));
292
+
293
+ // The overwhelming majority of tiles are nowhere near the disputed region, and
294
+ // pay nothing at all: no canvas, no readback, just the image Leaflet would have
295
+ // used anyway.
296
+ if (!overlaps(tileBox, SUPPRESSION_BBOX, padDeg)) {
297
+ const img = document.createElement("img");
298
+ img.setAttribute("role", "presentation");
299
+ img.alt = "";
300
+ // No canvas on this path, so no CORS requirement — honour whatever was asked
301
+ // for and otherwise leave it alone.
302
+ img.crossOrigin = resolveCrossOrigin(this.options.crossOrigin, null);
303
+ img.onload = () => done(undefined, img);
304
+ img.onerror = () => done(new Error("tile failed to load"), img);
305
+ img.src = this.getTileUrl(coords);
306
+ return img;
307
+ }
308
+
309
+ const canvas = document.createElement("canvas");
310
+ canvas.width = size.x;
311
+ canvas.height = size.y;
312
+ canvas.setAttribute("role", "presentation");
313
+ const ctx = canvas.getContext("2d");
314
+
315
+ const img = new Image();
316
+ img.crossOrigin = resolveCrossOrigin(this.options.crossOrigin, "anonymous");
317
+
318
+ img.onload = () => {
319
+ if (!ctx) {
320
+ done(new Error("no 2d context"), canvas);
321
+ return;
322
+ }
323
+ ctx.drawImage(img, 0, 0, size.x, size.y);
324
+
325
+ const finish = (err?: unknown) => {
326
+ // A thrown error here is almost always a SecurityError from a tainted canvas
327
+ // — the tile server sent no CORS header. The unmodified tile is still a
328
+ // usable map, so by default show it rather than failing the tile.
329
+ if (err && !fallbackOnError) done(err as Error, canvas);
330
+ else done(undefined, canvas);
331
+ };
332
+
333
+ void suppress({
334
+ ctx,
335
+ w: size.x,
336
+ h: size.y,
337
+ coords,
338
+ map,
339
+ origin,
340
+ ribbon,
341
+ debugMask,
342
+ seamlessEdges,
343
+ tileUrl: (c) => this.getTileUrl(c),
344
+ crossOrigin: img.crossOrigin,
345
+ }).then(
346
+ () => finish(),
347
+ (err) => finish(err),
348
+ );
349
+ };
350
+ img.onerror = () => done(new Error("tile failed to load"), canvas);
351
+ img.src = this.getTileUrl(coords);
352
+
353
+ return canvas;
354
+ };
355
+
356
+ return layer;
357
+ }
358
+
359
+ interface SuppressArgs {
360
+ ctx: CanvasRenderingContext2D;
361
+ w: number;
362
+ h: number;
363
+ coords: Coords;
364
+ map: LeafletMap;
365
+ origin: { x: number; y: number };
366
+ ribbon: number;
367
+ debugMask: boolean;
368
+ seamlessEdges: boolean;
369
+ tileUrl: (coords: Coords) => string;
370
+ crossOrigin: string | null;
371
+ }
372
+
373
+ /**
374
+ * Rasterise the discarded lines into `mc`, offset by `pad` pixels so the caller can
375
+ * work on a canvas larger than the tile. Returns whether anything actually landed.
376
+ */
377
+ function drawMask(
378
+ mc: CanvasRenderingContext2D,
379
+ w: number,
380
+ h: number,
381
+ pad: number,
382
+ coords: Coords,
383
+ map: LeafletMap,
384
+ origin: { x: number; y: number },
385
+ ribbon: number,
386
+ ): boolean {
387
+ mc.strokeStyle = "#fff";
388
+ mc.lineWidth = ribbon;
389
+ mc.lineJoin = "round";
390
+ mc.lineCap = "round";
391
+
392
+ const nw = map.unproject(point(origin.x - pad, origin.y - pad), coords.z);
393
+ const se = map.unproject(point(origin.x + w + pad, origin.y + h + pad), coords.z);
394
+ const box: readonly [number, number, number, number] = [
395
+ Math.min(nw.lng, se.lng),
396
+ Math.min(nw.lat, se.lat),
397
+ Math.max(nw.lng, se.lng),
398
+ Math.max(nw.lat, se.lat),
399
+ ];
400
+
401
+ let drew = false;
402
+ const features = INDIA_BOUNDARY_SUPPRESSION.features;
403
+ for (let f = 0; f < features.length; f += 1) {
404
+ if (!overlaps(box, FEATURE_BOUNDS[f]!, 0.2)) continue;
405
+ const cs = (features[f]! as { geometry: { coordinates: number[][] } }).geometry
406
+ .coordinates;
407
+ mc.beginPath();
408
+ for (let i = 0; i < cs.length; i += 1) {
409
+ const p = map.project(latLng(cs[i]![1]!, cs[i]![0]!), coords.z);
410
+ const x = p.x - origin.x + pad;
411
+ const y = p.y - origin.y + pad;
412
+ if (i === 0) mc.moveTo(x, y);
413
+ else mc.lineTo(x, y);
414
+ }
415
+ mc.stroke();
416
+ drew = true;
417
+ }
418
+ return drew;
419
+ }
420
+
421
+ /**
422
+ * Which neighbouring tiles the mask actually reaches into, as "dx,dy" keys.
423
+ *
424
+ * This is the whole reason the seam fix is affordable. A tile whose ribbon crosses
425
+ * one edge needs one neighbour, not eight, and a tile where the ribbon stays clear of
426
+ * the edges needs none. In practice the ribbon enters and leaves, so it is usually
427
+ * two or three — and those neighbours are almost always already in the browser's
428
+ * cache, because Leaflet is displaying them anyway.
429
+ */
430
+ function marginsNeeded(
431
+ maskData: Uint8ClampedArray,
432
+ W: number,
433
+ H: number,
434
+ pad: number,
435
+ ): Set<string> {
436
+ const needed = new Set<string>();
437
+ const bandX = (dx: number): [number, number] =>
438
+ dx < 0 ? [0, pad] : dx > 0 ? [W - pad, W] : [pad, W - pad];
439
+ const bandY = (dy: number): [number, number] =>
440
+ dy < 0 ? [0, pad] : dy > 0 ? [H - pad, H] : [pad, H - pad];
441
+
442
+ for (let dx = -1; dx <= 1; dx += 1) {
443
+ for (let dy = -1; dy <= 1; dy += 1) {
444
+ if (dx === 0 && dy === 0) continue;
445
+ const [x0, x1] = bandX(dx);
446
+ const [y0, y1] = bandY(dy);
447
+ let hit = false;
448
+ for (let y = y0; y < y1 && !hit; y += 1) {
449
+ for (let x = x0; x < x1; x += 1) {
450
+ if (maskData[(y * W + x) * 4 + 3]! > 32) {
451
+ hit = true;
452
+ break;
453
+ }
454
+ }
455
+ }
456
+ if (hit) needed.add(`${dx},${dy}`);
457
+ }
458
+ }
459
+ return needed;
460
+ }
461
+
462
+ function loadImage(
463
+ url: string,
464
+ crossOrigin: string | null,
465
+ ): Promise<HTMLImageElement | null> {
466
+ return new Promise((resolve) => {
467
+ const im = new Image();
468
+ if (crossOrigin != null) im.crossOrigin = crossOrigin;
469
+ im.onload = () => resolve(im);
470
+ // A neighbour that fails to load is not fatal: the seam it would have fixed just
471
+ // stays. Better a faint seam than a missing tile.
472
+ im.onerror = () => resolve(null);
473
+ im.src = url;
474
+ });
475
+ }
476
+
477
+ async function suppress(a: SuppressArgs): Promise<void> {
478
+ const { ctx, w, h, coords, map, origin, ribbon } = a;
479
+ const pad = a.seamlessEdges ? Math.ceil(ribbon) + 2 : 0;
480
+ const W = w + 2 * pad;
481
+ const H = h + 2 * pad;
482
+
483
+ const maskCanvas = document.createElement("canvas");
484
+ maskCanvas.width = W;
485
+ maskCanvas.height = H;
486
+ const mc = maskCanvas.getContext("2d");
487
+ if (!mc) throw new Error("no 2d context for mask");
488
+
489
+ if (!drawMask(mc, w, h, pad, coords, map, origin, ribbon)) return;
490
+ const maskData = mc.getImageData(0, 0, W, H).data;
491
+
492
+ if (a.debugMask) {
493
+ const dbg = ctx.getImageData(0, 0, w, h);
494
+ for (let y = 0; y < h; y += 1) {
495
+ for (let x = 0; x < w; x += 1) {
496
+ if (maskData[((y + pad) * W + (x + pad)) * 4 + 3]! > 32) {
497
+ const i = y * w + x;
498
+ dbg.data[i * 4] = 255;
499
+ dbg.data[i * 4 + 1] = 0;
500
+ dbg.data[i * 4 + 2] = 255;
501
+ }
502
+ }
503
+ }
504
+ ctx.putImageData(dbg, 0, 0);
505
+ return;
506
+ }
507
+
508
+ // Seed a padded canvas with the neighbours the ribbon runs into, so pixels at the
509
+ // tile edge are reconstructed from ground truth on both sides instead of only from
510
+ // the inside. Without this, adjacent tiles inpaint the same ribbon independently
511
+ // and disagree along the join.
512
+ const workCanvas = document.createElement("canvas");
513
+ workCanvas.width = W;
514
+ workCanvas.height = H;
515
+ const wc = workCanvas.getContext("2d");
516
+ if (!wc) throw new Error("no 2d context for work canvas");
517
+
518
+ if (pad > 0) {
519
+ const needed = marginsNeeded(maskData, W, H, pad);
520
+ const span = 2 ** coords.z;
521
+ const loads = [...needed].map(async (key) => {
522
+ const [dx, dy] = key.split(",").map(Number) as [number, number];
523
+ const ny = coords.y + dy;
524
+ if (ny < 0 || ny >= span) return;
525
+ const nx = ((((coords.x + dx) % span) + span) % span);
526
+ const nc = point(nx, ny) as unknown as Coords;
527
+ (nc as { z: number }).z = coords.z;
528
+ const im = await loadImage(a.tileUrl(nc), a.crossOrigin);
529
+ if (im) wc.drawImage(im, pad + dx * w, pad + dy * h, w, h);
530
+ });
531
+ await Promise.all(loads);
532
+ }
533
+ wc.drawImage(ctx.canvas, pad, pad, w, h);
534
+
535
+ // Throws SecurityError here if any tile server sent no CORS header.
536
+ const image = wc.getImageData(0, 0, W, H);
537
+ const data = image.data;
538
+
539
+ const todo = new Uint8Array(W * H);
540
+ let remaining = 0;
541
+ for (let i = 0; i < W * H; i += 1) {
542
+ if (maskData[i * 4 + 3]! > 32) {
543
+ todo[i] = 1;
544
+ remaining += 1;
545
+ }
546
+ }
547
+ if (!remaining) return;
548
+
549
+ // Fill inward from the edges of the ribbon: each pass writes only pixels that
550
+ // already have an unmasked neighbour, and every write is deferred to the end of the
551
+ // pass so a pass never reads its own output. Doing it in place instead would drag
552
+ // colour along the ribbon rather than across it.
553
+ const maxRounds = Math.ceil(ribbon) + 4;
554
+ const writes: number[] = [];
555
+ for (let round = 0; round < maxRounds && remaining > 0; round += 1) {
556
+ writes.length = 0;
557
+ for (let y = 0; y < H; y += 1) {
558
+ for (let x = 0; x < W; x += 1) {
559
+ const i = y * W + x;
560
+ if (!todo[i]) continue;
561
+ let r = 0;
562
+ let g = 0;
563
+ let b = 0;
564
+ let n = 0;
565
+ for (let k = 0; k < NEIGHBOURS.length; k += 1) {
566
+ const nx = x + NEIGHBOURS[k]![0];
567
+ const ny = y + NEIGHBOURS[k]![1];
568
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
569
+ const j = ny * W + nx;
570
+ if (todo[j]) continue;
571
+ r += data[j * 4]!;
572
+ g += data[j * 4 + 1]!;
573
+ b += data[j * 4 + 2]!;
574
+ n += 1;
575
+ }
576
+ if (n) writes.push(i, (r / n) | 0, (g / n) | 0, (b / n) | 0);
577
+ }
578
+ }
579
+ if (!writes.length) break;
580
+ for (let k = 0; k < writes.length; k += 4) {
581
+ const i = writes[k]!;
582
+ data[i * 4] = writes[k + 1]!;
583
+ data[i * 4 + 1] = writes[k + 2]!;
584
+ data[i * 4 + 2] = writes[k + 3]!;
585
+ todo[i] = 0;
586
+ remaining -= 1;
587
+ }
588
+ }
589
+
590
+ wc.putImageData(image, 0, 0);
591
+ ctx.drawImage(workCanvas, pad, pad, w, h, 0, 0, w, h);
592
+ }
593
+
594
+ export {
595
+ INDIA_BOUNDARY_SUPPRESSION,
596
+ SUPPRESSION_BBOX,
597
+ type SuppressionLine,
598
+ type SuppressionProperties,
599
+ } from "./suppressionData.js";