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.
- package/LICENSE +25 -0
- package/LICENSE-DATA +101 -0
- package/README.md +391 -0
- package/data/india-boundary.geojson +1 -0
- package/dist/cjs/attribution.js +24 -0
- package/dist/cjs/attribution.js.map +1 -0
- package/dist/cjs/data.js +78 -0
- package/dist/cjs/data.js.map +1 -0
- package/dist/cjs/index.js +39 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/leaflet.js +152 -0
- package/dist/cjs/leaflet.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/react.js +77 -0
- package/dist/cjs/react.js.map +1 -0
- package/dist/cjs/style.js +129 -0
- package/dist/cjs/style.js.map +1 -0
- package/dist/cjs/suppress.js +462 -0
- package/dist/cjs/suppress.js.map +1 -0
- package/dist/cjs/suppressionData.js +205 -0
- package/dist/cjs/suppressionData.js.map +1 -0
- package/dist/esm/attribution.d.ts +21 -0
- package/dist/esm/attribution.d.ts.map +1 -0
- package/dist/esm/attribution.js +21 -0
- package/dist/esm/attribution.js.map +1 -0
- package/dist/esm/data.d.ts +78 -0
- package/dist/esm/data.d.ts.map +1 -0
- package/dist/esm/data.js +75 -0
- package/dist/esm/data.js.map +1 -0
- package/dist/esm/index.d.ts +19 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +19 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/leaflet.d.ts +103 -0
- package/dist/esm/leaflet.d.ts.map +1 -0
- package/dist/esm/leaflet.js +147 -0
- package/dist/esm/leaflet.js.map +1 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/react.d.ts +31 -0
- package/dist/esm/react.d.ts.map +1 -0
- package/dist/esm/react.js +69 -0
- package/dist/esm/react.js.map +1 -0
- package/dist/esm/style.d.ts +106 -0
- package/dist/esm/style.d.ts.map +1 -0
- package/dist/esm/style.js +121 -0
- package/dist/esm/style.js.map +1 -0
- package/dist/esm/suppress.d.ts +64 -0
- package/dist/esm/suppress.d.ts.map +1 -0
- package/dist/esm/suppress.js +456 -0
- package/dist/esm/suppress.js.map +1 -0
- package/dist/esm/suppressionData.d.ts +34 -0
- package/dist/esm/suppressionData.d.ts.map +1 -0
- package/dist/esm/suppressionData.js +202 -0
- package/dist/esm/suppressionData.js.map +1 -0
- package/dist/style.css +66 -0
- package/package.json +113 -0
- package/src/attribution.ts +23 -0
- package/src/data.ts +153 -0
- package/src/index.ts +45 -0
- package/src/leaflet.ts +252 -0
- package/src/react.ts +92 -0
- package/src/style.css +66 -0
- package/src/style.ts +168 -0
- package/src/suppress.ts +599 -0
- package/src/suppressionData.ts +238 -0
- package/tools/build-india-boundary.mjs +836 -0
- package/tools/postbuild.mjs +61 -0
package/src/suppress.ts
ADDED
|
@@ -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";
|