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,836 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Derives India's claimed boundary through Jammu & Kashmir, Ladakh and Aksai Chin
4
+ * from OpenStreetMap, instead of trusting a coordinate list pasted into an issue
5
+ * thread.
6
+ *
7
+ * node tools/build-india-boundary.mjs # fetch, classify, report
8
+ * node tools/build-india-boundary.mjs --cache # reuse the last download
9
+ * node tools/build-india-boundary.mjs --geojson out.json
10
+ * node tools/build-india-boundary.mjs --retag # re-check src/data.ts tags
11
+ * node tools/build-india-boundary.mjs --retag --write # …and rewrite them
12
+ *
13
+ * WHY THIS EXISTS
14
+ * The coordinates in src/data.ts were copied from a comment on Leaflet/Leaflet#8943.
15
+ * They render correctly, but nobody can check them: we do not know who produced
16
+ * them or from what. This script replaces "trust the paste" with a query anyone can
17
+ * re-run and diff, against data that is ODbL-licensed and — because it is the same
18
+ * database the tiles are rendered from — matches the basemap's own geometry by
19
+ * construction.
20
+ *
21
+ * HOW IT WORKS
22
+ * India's claim is the *outer* edge of the union of the four regions that make up
23
+ * the disputed territory. That outer edge can be found without any geometry
24
+ * library, purely from way membership:
25
+ *
26
+ * - a boundary way belonging to TWO of the four regions is internal — this is
27
+ * exactly the Line of Control, and it drops out on its own.
28
+ * - a way belonging to ONE is on the outer edge. Those are then classified by
29
+ * which neighbour shares them:
30
+ * shared with an Indian state -> internal to India, drop
31
+ * shared with China -> the LAC, i.e. control not claim, drop
32
+ * otherwise (Pakistan, Afghanistan) -> India's claimed border, keep
33
+ *
34
+ * The dropped China stretch is then refilled from ways OSM tags `claimed_by=IN`
35
+ * (named "Indian claim line", "Indian claimed border" and similar).
36
+ *
37
+ * STATUS — READ BEFORE WIRING THE OUTPUT INTO AN APP
38
+ * The derivation is trustworthy for what it is used for (`--retag`, and the
39
+ * suppression geometry). Its full output is deliberately NOT shipped as the
40
+ * boundary, and the reasons are settled rather than open:
41
+ *
42
+ * 1. The open ends are not closeable. An earlier version of this note said they
43
+ * needed "endpoint snapping with a tolerance". That was wrong, and measuring
44
+ * is what showed it: the ends are 12, 21, 33 and 158 km apart. Snapping would
45
+ * draw straight lines through the Karakoram. Four of the five stubborn ones sit
46
+ * within 10 m of the Line of Actual Control — they are where the claim data OSM
47
+ * holds runs out, and continuing means drawing China's line instead of India's.
48
+ * Only upstream mapping fixes that, not geometry.
49
+ * 2. India's claim north of the Sino-Pakistan line — the Shaksgam Valley /
50
+ * Trans-Karakoram Tract — is essentially absent from OSM. A query for
51
+ * `claimed_by` ways in that bbox returns two. A pure-OSM derivation therefore
52
+ * understates the northern claim, which is why the "hand" features in
53
+ * src/data.ts are still hand-carried.
54
+ * 3. Where OSM *can* supply better geometry — the western arc facing Pakistan and
55
+ * Afghanistan — it was measured and rejected on cost. Run `--merge` to
56
+ * reproduce: it lifts the western arc from ~280 points to ~4700, and renders
57
+ * indistinguishably from what ships. Mean deviation from the shipped line is
58
+ * 182 m, which is a fifth of a pixel at z10. The payload goes from 6 kB gzipped
59
+ * to about 40 kB. Ten times the bytes for no visible change is a bad trade in a
60
+ * browser package, so the shipped file stays at 689 points.
61
+ *
62
+ * The `--merge` investigation did pay for itself, though: it surfaced a real bug.
63
+ * 12 ways of Line of Control were being classified as India's claimed border,
64
+ * because the membership test cannot see them. See `isLineOfControl`.
65
+ *
66
+ * WHAT IS ALREADY USABLE
67
+ * `--retag` uses the derivation for the job it is actually good at: checking, for
68
+ * every feature in src/data.ts, whether OSM independently puts a boundary within
69
+ * a tolerance of it. That is what produced the `source` tags, and re-running it is
70
+ * how you find out whether upstream mapping has caught up.
71
+ */
72
+
73
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
74
+ import { tmpdir } from "node:os";
75
+ import { dirname, join } from "node:path";
76
+ import { fileURLToPath } from "node:url";
77
+
78
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
79
+ const DATA_TS = join(ROOT, "src/data.ts");
80
+ const CACHE = join(tmpdir(), "india-boundary-osm.json");
81
+
82
+ /**
83
+ * Overpass endpoint. Override with `--endpoint <url>` or `OVERPASS_URL`.
84
+ *
85
+ * This query asks for seven whole admin relations and every way in four of them,
86
+ * which is heavy enough that the main instance answers 504 Gateway Timeout
87
+ * whenever it is busy — not a fault in the query, just a queue. The mirrors below
88
+ * run the same software against the same data and are usually quieter:
89
+ *
90
+ * https://overpass.kumi.systems/api/interpreter
91
+ * https://overpass.private.coffee/api/interpreter
92
+ *
93
+ * Once a run succeeds, `--cache` reuses the download and you can stop asking.
94
+ */
95
+ const OVERPASS = "https://overpass-api.de/api/interpreter";
96
+
97
+ /** The four regions whose union is the territory India claims. */
98
+ const CLAIM_REGIONS = {
99
+ 1943188: "Jammu and Kashmir (India-administered)",
100
+ 5515045: "Ladakh (India-administered)",
101
+ 3780130: "Azad Kashmir (Pakistan-administered)",
102
+ 357995: "Gilgit-Baltistan (Pakistan-administered)",
103
+ };
104
+
105
+ /** Neighbours used only to classify the outer edge, never drawn. */
106
+ const CHINA = { 153310: "Xinjiang", 153292: "Xizang" };
107
+ const INDIAN_STATES = {
108
+ 364186: "Himachal Pradesh",
109
+ 1942686: "Punjab",
110
+ 9987086: "Uttarakhand",
111
+ };
112
+
113
+ /**
114
+ * Aksai Chin only. Do not widen this past roughly lat 36.2 / lon 80.6: it starts
115
+ * dragging in the Himachal–Tibet sector, which is a separate dispute and does not
116
+ * connect to this ring.
117
+ */
118
+ const CLAIM_WAY_BBOX = [32.5, 76.0, 36.2, 80.6];
119
+
120
+ /**
121
+ * Corroboration tolerance in metres, for `--retag`. Override with `--tolerance`.
122
+ *
123
+ * 10 km looks slack for a boundary check, and would be if the distances were spread
124
+ * evenly. They are not — measured against OSM's own derived line, the 67 shipped
125
+ * features fall into two clumps with a wide empty gap between them:
126
+ *
127
+ * 36 features 0.0 km – 5.1 km (30 of them at 0.0: OSM's exact geometry)
128
+ * ~~~~ 3x gap, nothing in it ~~~~
129
+ * 31 features 14.3 km – 180.5 km (OSM has no boundary there at all)
130
+ *
131
+ * So the classification is a fact about the data rather than a consequence of the
132
+ * threshold: every tolerance from about 6 km to 14 km sorts the features
133
+ * identically. 10 km sits in the middle of the gap, which makes it the least
134
+ * arbitrary number available. Run `--retag` to print the full distribution and
135
+ * confirm the gap is still there.
136
+ */
137
+ const DEFAULT_TOLERANCE_M = 10000;
138
+
139
+ const ids = (o) => Object.keys(o).join(",");
140
+
141
+ const QUERY = `[out:json][timeout:250];
142
+ rel(id:${ids(CLAIM_REGIONS)},${ids(CHINA)},${ids(INDIAN_STATES)})->.all;
143
+ .all out body;
144
+ rel(id:${ids(CLAIM_REGIONS)})->.claim;
145
+ way(r.claim)->.cw;
146
+ .cw out geom;
147
+ way["claimed_by"](${CLAIM_WAY_BBOX.join(",")})->.cl;
148
+ .cl out geom;`;
149
+
150
+ async function fetchOsm(useCache, endpoint) {
151
+ if (useCache && existsSync(CACHE)) {
152
+ console.log(`using cached ${CACHE}`);
153
+ return JSON.parse(readFileSync(CACHE, "utf8"));
154
+ }
155
+ console.log(`querying ${endpoint} (this takes a while)…`);
156
+ // The User-Agent matters: Apache in front of Overpass answers 406 to any request
157
+ // that does not send one, and Node's fetch does not send one. The failure looks
158
+ // exactly like a query syntax error, so it is worth being explicit about. GET for
159
+ // the same reason — POST hits the same wall, and the query fits in a URL.
160
+ const res = await fetch(`${endpoint}?${new URLSearchParams({ data: QUERY })}`, {
161
+ headers: {
162
+ "User-Agent":
163
+ "leaflet-india-boundary (+https://github.com/lux-in-tenebris-lucet/leaflet-india-boundary)",
164
+ Accept: "application/json",
165
+ },
166
+ });
167
+ const text = await res.text();
168
+ if (res.status === 504 || res.status === 429) {
169
+ throw new Error(
170
+ `Overpass ${res.status} ${res.statusText}. The instance is busy or rate-limiting; ` +
171
+ `the query itself is fine. Try a mirror:\n` +
172
+ ` node tools/build-india-boundary.mjs --endpoint https://overpass.kumi.systems/api/interpreter`,
173
+ );
174
+ }
175
+ if (!res.ok) {
176
+ throw new Error(`Overpass ${res.status} ${res.statusText}\n${text.slice(0, 400)}`);
177
+ }
178
+ if (!text.startsWith("{")) {
179
+ throw new Error(`Overpass returned non-JSON (rate limited?): ${text.slice(0, 300)}`);
180
+ }
181
+ writeFileSync(CACHE, text);
182
+ return JSON.parse(text);
183
+ }
184
+
185
+ /**
186
+ * Is this way the Line of Control, according to OSM's own tags?
187
+ *
188
+ * The membership test below — a way in two of the four claim regions is internal —
189
+ * finds most of the LoC, but it leaks. The southern stretch, the "working boundary"
190
+ * running east from near Jammu, has Pakistani *Punjab province* on the far side
191
+ * rather than Azad Kashmir. So it belongs to only one of the four regions, never
192
+ * trips the shared-by-two test, and used to come out the other end classified as
193
+ * India's claimed border: 11 ways and 2228 points of Line of Control, drawn as the
194
+ * claim and missing from the suppression mask.
195
+ *
196
+ * OSM tags it explicitly, so ask instead of inferring. Every leaked way carries
197
+ * `disputed=yes` with `disputed_by=IN;PK` and `name:en=Line of Control`, while every
198
+ * way we legitimately keep here — the Durand Line facing Afghanistan, and Azad
199
+ * Kashmir's and Gilgit-Baltistan's boundaries with Pakistan proper — has no
200
+ * `disputed` tag at all. Some of those carry `disputed_by=IN` on its own, which means
201
+ * India disputes the administration rather than that the line is the LoC, so testing
202
+ * `disputed_by` alone would throw away most of the claim edge.
203
+ *
204
+ * `claimed_by=IN` still wins: a way India's claim line runs along is kept even if
205
+ * someone has also marked it disputed.
206
+ */
207
+ function isLineOfControl(way) {
208
+ const t = way?.tags ?? {};
209
+ if (t.claimed_by === "IN") return false;
210
+ if (t.disputed !== "yes") return false;
211
+ // Must name Pakistan. This test runs before the China test, and `disputed=yes`
212
+ // alone also matches the India-China LAC — which would still be masked, but
213
+ // labelled "loc" when it is the LAC, making the suppression data lie about which
214
+ // line is which. Let those fall through to the China branch.
215
+ return /\bPK\b/.test(t.disputed_by ?? "");
216
+ }
217
+
218
+ function classify(osm) {
219
+ const relations = new Map();
220
+ const ways = new Map();
221
+ for (const el of osm.elements) {
222
+ if (el.type === "relation") relations.set(el.id, el);
223
+ else if (el.type === "way") ways.set(el.id, el);
224
+ }
225
+
226
+ const membersOf = (id) =>
227
+ new Set(
228
+ (relations.get(id)?.members ?? []).filter((m) => m.type === "way").map((m) => m.ref),
229
+ );
230
+
231
+ // How many of the four claim regions each way belongs to.
232
+ const shareCount = new Map();
233
+ for (const id of Object.keys(CLAIM_REGIONS)) {
234
+ for (const w of membersOf(Number(id))) {
235
+ shareCount.set(w, (shareCount.get(w) ?? 0) + 1);
236
+ }
237
+ }
238
+
239
+ const union = (obj) =>
240
+ new Set(Object.keys(obj).flatMap((id) => [...membersOf(Number(id))]));
241
+ const chinaWays = union(CHINA);
242
+ const stateWays = union(INDIAN_STATES);
243
+
244
+ const internal = [];
245
+ const taggedLoc = [];
246
+ const droppedChina = [];
247
+ const droppedState = [];
248
+ const keep = [];
249
+ for (const [w, n] of shareCount) {
250
+ if (n >= 2) internal.push(w);
251
+ // An explicit claimed_by=IN tag outranks the China test. Some of these ways are
252
+ // members of Xinjiang's relation as well, and dropping them as "shared with
253
+ // China" would throw away the very claim line we are trying to keep.
254
+ else if (ways.get(w)?.tags?.claimed_by === "IN") keep.push(w);
255
+ // China before the tag test, deliberately. The far-northern Sino-Pakistan line is
256
+ // tagged `disputed_by=IN;PK` too — Pakistan is a party to it — so testing tags
257
+ // first would label a stretch of the LAC as Line of Control. Both end up masked
258
+ // either way, but the labels are what a maintainer reads, so they should be true.
259
+ else if (chinaWays.has(w)) droppedChina.push(w);
260
+ // What remains: the membership test above misses the southern LoC. See
261
+ // isLineOfControl for why.
262
+ else if (isLineOfControl(ways.get(w))) taggedLoc.push(w);
263
+ else if (stateWays.has(w)) droppedState.push(w);
264
+ else keep.push(w);
265
+ }
266
+
267
+ // `claimed_by=IN` ways refill the dropped China stretch. They arrive in the same
268
+ // response but are not members of any claim region, so they are absent from
269
+ // shareCount.
270
+ const claimWays = [...ways.values()].filter(
271
+ (w) => w.tags?.claimed_by === "IN" && !shareCount.has(w.id),
272
+ );
273
+
274
+ return { ways, internal, taggedLoc, droppedChina, droppedState, keep, claimWays };
275
+ }
276
+
277
+ /**
278
+ * Dangling endpoints are where the assembled ring fails to close. OSM ways share
279
+ * nodes at junctions, so identical coordinates mean a real join.
280
+ */
281
+ function findGaps(segments) {
282
+ const seen = new Map();
283
+ const bump = (p) => {
284
+ const k = `${p[0]},${p[1]}`;
285
+ seen.set(k, (seen.get(k) ?? 0) + 1);
286
+ };
287
+ for (const s of segments) {
288
+ bump(s[0]);
289
+ bump(s[s.length - 1]);
290
+ }
291
+ return [...seen.entries()]
292
+ .filter(([, n]) => n === 1)
293
+ .map(([k]) => k.split(",").map(Number));
294
+ }
295
+
296
+ /* ------------------------------------------------------------------ *
297
+ * Corroboration: how far is each shipped feature from OSM's own line?
298
+ * ------------------------------------------------------------------ */
299
+
300
+ const R = 6371008.8;
301
+ const RAD = Math.PI / 180;
302
+ /**
303
+ * Local equirectangular projection. The whole region sits between roughly 32°N and
304
+ * 37°N, so one cosine taken at the middle is accurate to well under a percent
305
+ * here — far tighter than a kilometre-scale tolerance needs, and it keeps this
306
+ * script free of a projection dependency.
307
+ */
308
+ const LAT0_COS = Math.cos(34.5 * RAD);
309
+ const toXY = ([lon, lat]) => [lon * RAD * LAT0_COS * R, lat * RAD * R];
310
+
311
+ function pointToSegmentM(p, a, b) {
312
+ const [px, py] = p;
313
+ const [ax, ay] = a;
314
+ const [bx, by] = b;
315
+ const dx = bx - ax;
316
+ const dy = by - ay;
317
+ const len2 = dx * dx + dy * dy;
318
+ let t = len2 === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / len2;
319
+ t = Math.max(0, Math.min(1, t));
320
+ const cx = ax + t * dx;
321
+ const cy = ay + t * dy;
322
+ return Math.hypot(px - cx, py - cy);
323
+ }
324
+
325
+ /** Min distance in metres from a point to any of the reference polylines. */
326
+ function distanceToLines(point, lines) {
327
+ let best = Infinity;
328
+ for (const line of lines) {
329
+ for (let i = 1; i < line.length; i += 1) {
330
+ const d = pointToSegmentM(point, line[i - 1], line[i]);
331
+ if (d < best) best = d;
332
+ if (best === 0) return 0;
333
+ }
334
+ }
335
+ return best;
336
+ }
337
+
338
+ /**
339
+ * Reads the feature objects out of src/data.ts.
340
+ *
341
+ * data.ts is generated one feature per line precisely so this stays a line-wise
342
+ * operation: no TypeScript parser, and rewriting the tags cannot disturb the long
343
+ * provenance comment above them.
344
+ */
345
+ const FEATURE_LINE = /^ {2}(\{"type":"Feature".*\}),$/;
346
+
347
+ function readShippedFeatures() {
348
+ const text = readFileSync(DATA_TS, "utf8");
349
+ const lines = text.split("\n");
350
+ const features = [];
351
+ lines.forEach((line, i) => {
352
+ const m = FEATURE_LINE.exec(line);
353
+ if (m) features.push({ index: i, feature: JSON.parse(m[1]) });
354
+ });
355
+ if (!features.length) {
356
+ throw new Error(
357
+ `found no feature lines in ${DATA_TS} — has its formatting changed? ` +
358
+ `each feature must be a single line of the form ' {"type":"Feature",…},'`,
359
+ );
360
+ }
361
+ return { lines, features };
362
+ }
363
+
364
+ function retag(osmLines, tolerance, write) {
365
+ const { lines, features } = readShippedFeatures();
366
+
367
+ const results = features.map(({ index, feature }) => {
368
+ // Worst vertex, not the average: a feature counts as corroborated only if OSM
369
+ // backs up the whole of it. Averaging would let a segment that wanders off for
370
+ // a third of its length still pass.
371
+ let worst = 0;
372
+ for (const c of feature.geometry.coordinates) {
373
+ const d = distanceToLines(toXY(c), osmLines);
374
+ if (d > worst) worst = d;
375
+ }
376
+ return { index, feature, worst, source: worst <= tolerance ? "osm" : "hand" };
377
+ });
378
+
379
+ const was = { osm: 0, hand: 0 };
380
+ const now = { osm: 0, hand: 0 };
381
+ const flipped = [];
382
+ for (const r of results) {
383
+ was[r.feature.properties.source] += 1;
384
+ now[r.source] += 1;
385
+ if (r.source !== r.feature.properties.source) flipped.push(r);
386
+ }
387
+
388
+ console.log(`
389
+ corroboration check (tolerance ${tolerance} m, worst vertex per feature)
390
+ shipped tags ${was.osm} osm / ${was.hand} hand
391
+ recomputed ${now.osm} osm / ${now.hand} hand
392
+ disagreements ${flipped.length}`);
393
+
394
+ for (const r of flipped) {
395
+ console.log(
396
+ ` ${r.feature.properties.source} -> ${r.source} ` +
397
+ `worst ${(r.worst / 1000).toFixed(1)} km ` +
398
+ `at lon=${r.feature.geometry.coordinates[0][0].toFixed(3)} ` +
399
+ `lat=${r.feature.geometry.coordinates[0][1].toFixed(3)}`,
400
+ );
401
+ }
402
+
403
+ // The distances either side of the cutoff. If features cluster just under the
404
+ // tolerance, the split is an artefact of the threshold rather than a fact about
405
+ // OSM — so print the lot and let the reader see which it is.
406
+ const sorted = [...results].sort((a, b) => a.worst - b.worst);
407
+ console.log("\n worst-vertex distance, every feature, ascending (km)");
408
+ const row = sorted.map((r) => (r.worst / 1000).toFixed(1));
409
+ for (let i = 0; i < row.length; i += 12) {
410
+ console.log(` ${row.slice(i, i + 12).join(" ")}`);
411
+ }
412
+
413
+ // How far could the tolerance move before a single tag changed? That is the band
414
+ // between the furthest feature we called corroborated and the nearest feature we
415
+ // did not — and it is the number that says whether these tags mean anything. A
416
+ // wide band means the two groups are genuinely separate and the threshold is
417
+ // just picking a point in the empty space between them. A narrow one means the
418
+ // split is an artefact of the threshold.
419
+ const worstOf = (source) => results.filter((r) => r.source === source).map((r) => r.worst);
420
+ const osmMax = Math.max(...worstOf("osm"), 0);
421
+ const handWorst = worstOf("hand");
422
+ const handMin = handWorst.length ? Math.min(...handWorst) : Infinity;
423
+
424
+ if (Number.isFinite(handMin)) {
425
+ const ratio = osmMax > 0 ? handMin / osmMax : Infinity;
426
+ console.log(
427
+ `\n the cutoff could sit anywhere in ${(osmMax / 1000).toFixed(1)} km – ${(handMin / 1000).toFixed(1)} km ` +
428
+ `without changing a tag`,
429
+ );
430
+ console.log(
431
+ ratio >= 2
432
+ ? ` furthest corroborated feature is ${ratio.toFixed(1)}x closer than the nearest\n` +
433
+ ` uncorroborated one, so the split is a fact about the data rather than about\n` +
434
+ ` the ${tolerance} m threshold`
435
+ : ` WARNING: only ${ratio.toFixed(1)}x between the two groups. They are not cleanly\n` +
436
+ ` separated, so this split depends on the ${tolerance} m threshold and should not\n` +
437
+ ` be described as a property of the data.`,
438
+ );
439
+ }
440
+
441
+ if (!write) {
442
+ if (flipped.length) console.log("\n pass --write to apply these to src/data.ts");
443
+ return;
444
+ }
445
+ if (!flipped.length) {
446
+ console.log("\n nothing to write: src/data.ts already agrees");
447
+ return;
448
+ }
449
+ for (const r of results) {
450
+ const updated = { ...r.feature, properties: { ...r.feature.properties, source: r.source } };
451
+ lines[r.index] = ` ${JSON.stringify(updated)},`;
452
+ }
453
+ writeFileSync(DATA_TS, lines.join("\n"));
454
+ console.log(`\n wrote ${DATA_TS} (${flipped.length} tag(s) changed)`);
455
+ console.log(" remember to update the counts in the header comment if they moved");
456
+ }
457
+
458
+ /* ------------------------------------------------------------------ *
459
+ * Merge: upgrade the western arc to OSM's detail, keep the rest
460
+ * ------------------------------------------------------------------ */
461
+
462
+ /**
463
+ * Replaces the stretches of the shipped line that OSM can supply at higher detail,
464
+ * and leaves the rest alone.
465
+ *
466
+ * WHY THIS IS NOT "CLOSE THE GAPS AND SHIP THE DERIVATION"
467
+ *
468
+ * The STATUS block above used to say the derivation needed endpoint snapping before
469
+ * it could be rendered. That was wrong about the cause, and measuring it is what
470
+ * showed why. The open ends are 12, 21, 33 and 158 km apart. Snapping across those
471
+ * would draw straight lines through the Karakoram — far worse than the shorter line
472
+ * that at least follows the right alignment.
473
+ *
474
+ * They are not gaps to be closed. Four of the five stubborn ones sit within 10 m of
475
+ * the Line of Actual Control, which is to say they are exactly where the claim data
476
+ * OSM holds runs out and continuing would mean drawing China's line instead of
477
+ * India's. No amount of snapping fixes that; only upstream mapping does.
478
+ *
479
+ * What *is* available splits cleanly in two:
480
+ *
481
+ * - The western and northern arc, facing Pakistan and Afghanistan, is 69 ways and
482
+ * ~6900 points forming a chain with two open ends. It is simultaneously India's
483
+ * claim and a real administered boundary, so OSM maps it properly, and it can
484
+ * replace the shipped geometry outright at roughly ten times the detail.
485
+ * - The eastern arc — the Aksai Chin claim line and the Shaksgam claim — has 614
486
+ * points of fragmentary `claimed_by=IN` data with eight open ends. It has to stay
487
+ * hand-carried.
488
+ *
489
+ * HOW A FEATURE IS DECIDED
490
+ *
491
+ * By distance to the region-derived chain, not by its `source` tag. That distinction
492
+ * matters: a shipped feature in eastern Ladakh can be tagged "osm" because OSM has a
493
+ * boundary within the corroboration tolerance — but that boundary is the LAC, which
494
+ * is the line we exist to replace. Measuring against the region-derived chain instead
495
+ * separates "OSM has this same line, better" from "OSM has a different line nearby".
496
+ */
497
+ function merge(regionSegs, shipped, toleranceM, out) {
498
+ const chain = regionSegs.map((s) => s.map(toXY));
499
+
500
+ const decided = shipped.map((f) => {
501
+ let worst = 0;
502
+ for (const c of f.geometry.coordinates) {
503
+ const d = distanceToLines(toXY(c), chain);
504
+ if (d > worst) worst = d;
505
+ }
506
+ return { feature: f, worst, superseded: worst <= toleranceM };
507
+ });
508
+
509
+ const superseded = decided.filter((d) => d.superseded);
510
+ const kept = decided.filter((d) => !d.superseded);
511
+ const pointsOf = (fs) =>
512
+ fs.reduce((n, d) => n + d.feature.geometry.coordinates.length, 0);
513
+ const chainPoints = regionSegs.reduce((n, s) => n + s.length, 0);
514
+
515
+ const features = [
516
+ ...regionSegs.map((coords) => ({
517
+ type: "Feature",
518
+ geometry: { type: "LineString", coordinates: coords },
519
+ properties: { source: "osm" },
520
+ })),
521
+ ...kept.map((d) => d.feature),
522
+ ];
523
+ const total = features.reduce((n, f) => n + f.geometry.coordinates.length, 0);
524
+
525
+ // Does the merged ring still join up? The derived chain's two ends have to meet
526
+ // whatever the kept features leave open, or the line breaks somewhere new.
527
+ const ends = new Map();
528
+ for (const f of features) {
529
+ const cs = f.geometry.coordinates;
530
+ for (const p of [cs[0], cs[cs.length - 1]]) {
531
+ const key = `${p[0].toFixed(4)},${p[1].toFixed(4)}`;
532
+ ends.set(key, (ends.get(key) ?? 0) + 1);
533
+ }
534
+ }
535
+ const open = [...ends.entries()]
536
+ .filter(([, n]) => n === 1)
537
+ .map(([k]) => k.split(",").map(Number));
538
+
539
+ // How far is each open end from the nearest other open end? Small means a joinable
540
+ // seam; large means a real break in the depiction.
541
+ const openPairs = open.map((a) => {
542
+ let best = Infinity;
543
+ for (const b of open) {
544
+ if (a === b) continue;
545
+ const d = Math.hypot(...toXY(a).map((v, i) => v - toXY(b)[i]));
546
+ if (d < best) best = d;
547
+ }
548
+ return { a, best };
549
+ });
550
+
551
+ console.log(`
552
+ merge (supersede threshold ${toleranceM} m)
553
+ shipped features superseded by OSM detail ${superseded.length} (${pointsOf(superseded)} points)
554
+ shipped features kept as they are ${kept.length} (${pointsOf(kept)} points)
555
+ region-derived chain added ${regionSegs.length} ways (${chainPoints} points)
556
+
557
+ merged total ${features.length} features, ${total} points (was 67 / 689)
558
+
559
+ open ends ${open.length}`);
560
+ for (const { a, best } of openPairs.sort((x, y) => x.best - y.best)) {
561
+ const far = best > 3000;
562
+ console.log(
563
+ ` lon=${a[0].toFixed(4)} lat=${a[1].toFixed(4)} nearest other open end ` +
564
+ `${(best / 1000).toFixed(2)} km${far ? " <-- too far to be a seam" : ""}`,
565
+ );
566
+ }
567
+ console.log(`
568
+ kept features, by how far they sit from the derived chain:`);
569
+ for (const d of kept.sort((x, y) => x.worst - y.worst).slice(0, 8)) {
570
+ console.log(
571
+ ` ${(d.worst / 1000).toFixed(1).padStart(6)} km ${d.feature.properties.source} ` +
572
+ `starting lon=${d.feature.geometry.coordinates[0][0].toFixed(3)} ` +
573
+ `lat=${d.feature.geometry.coordinates[0][1].toFixed(3)}`,
574
+ );
575
+ }
576
+
577
+ if (out) {
578
+ writeFileSync(
579
+ out,
580
+ `${JSON.stringify({ type: "FeatureCollection", features })}\n`,
581
+ );
582
+ console.log(`\n wrote ${out} — render it before trusting it`);
583
+ }
584
+ return features;
585
+ }
586
+
587
+ /* ------------------------------------------------------------------ *
588
+ * Suppression geometry: the lines the basemap draws that we want gone
589
+ * ------------------------------------------------------------------ */
590
+
591
+ /**
592
+ * Douglas-Peucker, with the tolerance in metres via the projection above.
593
+ *
594
+ * The suppression mask is a ribbon a few pixels wide, so carrying OSM's full
595
+ * node density into the browser is pointless: what matters is that the polyline
596
+ * stays inside its own ribbon. See `--simplify` for how the tolerance relates to
597
+ * pixels per zoom.
598
+ */
599
+ function simplify(points, toleranceM) {
600
+ if (points.length < 3) return points;
601
+ const xy = points.map(toXY);
602
+ const keep = new Uint8Array(points.length);
603
+ keep[0] = keep[points.length - 1] = 1;
604
+
605
+ const stack = [[0, points.length - 1]];
606
+ while (stack.length) {
607
+ const [first, last] = stack.pop();
608
+ let worst = -1;
609
+ let worstIdx = -1;
610
+ for (let i = first + 1; i < last; i += 1) {
611
+ const d = pointToSegmentM(xy[i], xy[first], xy[last]);
612
+ if (d > worst) {
613
+ worst = d;
614
+ worstIdx = i;
615
+ }
616
+ }
617
+ if (worst > toleranceM) {
618
+ keep[worstIdx] = 1;
619
+ stack.push([first, worstIdx], [worstIdx, last]);
620
+ }
621
+ }
622
+ return points.filter((_, i) => keep[i]);
623
+ }
624
+
625
+ /** Round to 5 decimal places — about 1.1 m here, well under any useful ribbon. */
626
+ const round5 = ([lon, lat]) => [
627
+ Math.round(lon * 1e5) / 1e5,
628
+ Math.round(lat * 1e5) / 1e5,
629
+ ];
630
+
631
+ function dumpSuppression(ways, internal, droppedChina, toleranceM, out) {
632
+ const lines = [
633
+ ...internal.map((id) => ["loc", ways.get(id)]),
634
+ ...droppedChina.map((id) => ["lac", ways.get(id)]),
635
+ ]
636
+ .filter(([, w]) => (w?.geometry?.length ?? 0) >= 2)
637
+ .map(([line, w]) => [line, w.geometry.map((p) => [p.lon, p.lat])]);
638
+
639
+ const before = lines.reduce((n, [, c]) => n + c.length, 0);
640
+ const features = lines.map(([line, coords]) => ({
641
+ type: "Feature",
642
+ geometry: {
643
+ type: "LineString",
644
+ coordinates: simplify(coords, toleranceM).map(round5),
645
+ },
646
+ properties: { line },
647
+ }));
648
+ const after = features.reduce((n, f) => n + f.geometry.coordinates.length, 0);
649
+
650
+ let west = Infinity;
651
+ let south = Infinity;
652
+ let east = -Infinity;
653
+ let north = -Infinity;
654
+ for (const f of features) {
655
+ for (const [lon, lat] of f.geometry.coordinates) {
656
+ if (lon < west) west = lon;
657
+ if (lon > east) east = lon;
658
+ if (lat < south) south = lat;
659
+ if (lat > north) north = lat;
660
+ }
661
+ }
662
+
663
+ const counts = features.reduce((acc, f) => {
664
+ acc[f.properties.line] = (acc[f.properties.line] ?? 0) + 1;
665
+ return acc;
666
+ }, {});
667
+
668
+ const header = `import type { FeatureCollection, LineString } from "geojson";
669
+
670
+ /**
671
+ * The two lines a standard OSM basemap draws through this region that India's
672
+ * official depiction does not: the Line of Control and the Line of Actual Control.
673
+ *
674
+ * GENERATED — do not edit by hand. Regenerate with:
675
+ * node tools/build-india-boundary.mjs --dump-suppression src/suppressionData.ts
676
+ *
677
+ * This is the *complement* of INDIA_BOUNDARY, and it comes free with deriving it.
678
+ * The derivation in this script works by discarding ways: a boundary way in two of
679
+ * the four claim regions is internal, which is exactly the Line of Control, and a
680
+ * way shared with China is the Line of Actual Control — control rather than claim.
681
+ * Both are thrown away to leave India's claimed outer edge. Kept instead, they are
682
+ * precisely the geometry needed to mask those lines out of a raster tile, in the
683
+ * same coordinate system the tiles were rendered from.
684
+ *
685
+ * "loc" (${counts.loc ?? 0} features) — the Line of Control, India/Pakistan.
686
+ * "lac" (${counts.lac ?? 0} features) — the Line of Actual Control, India/China.
687
+ *
688
+ * Simplified with Douglas-Peucker at ${toleranceM} m, taking ${before} OSM nodes down to
689
+ * ${after}. At latitude ~34.5 one pixel is about 2015 m at z6, 126 m at z10, 31 m at
690
+ * z12 and 8 m at z14, so this stays comfortably sub-pixel to z12 and within a couple
691
+ * of pixels at z14 — far inside the mask ribbon at any of them.
692
+ *
693
+ * Coordinates are [lon, lat], WGS 84, rounded to 5 decimal places (~1.1 m).
694
+ */
695
+ export type SuppressionLine = "loc" | "lac";
696
+
697
+ export interface SuppressionProperties {
698
+ line: SuppressionLine;
699
+ }
700
+
701
+ /** [west, south, east, north] — lets a tile layer skip unaffected tiles entirely. */
702
+ export const SUPPRESSION_BBOX: readonly [number, number, number, number] = [
703
+ ${west}, ${south}, ${east}, ${north},
704
+ ];
705
+
706
+ export const INDIA_BOUNDARY_SUPPRESSION: FeatureCollection<
707
+ LineString,
708
+ SuppressionProperties
709
+ > = {
710
+ type: "FeatureCollection",
711
+ features: [
712
+ `;
713
+
714
+ writeFileSync(
715
+ out,
716
+ `${header}${features.map((f) => ` ${JSON.stringify(f)},`).join("\n")}\n ],\n};\n`,
717
+ );
718
+ console.log(`
719
+ suppression geometry
720
+ LoC features ${counts.loc ?? 0}
721
+ LAC features ${counts.lac ?? 0}
722
+ points ${before} -> ${after} (Douglas-Peucker at ${toleranceM} m)
723
+ bbox ${west.toFixed(4)}, ${south.toFixed(4)}, ${east.toFixed(4)}, ${north.toFixed(4)}
724
+ wrote ${out}`);
725
+ }
726
+
727
+ /* ------------------------------------------------------------------ *
728
+ * main
729
+ * ------------------------------------------------------------------ */
730
+
731
+ const args = process.argv.slice(2);
732
+ const flagValue = (name) => {
733
+ const i = args.indexOf(name);
734
+ return i >= 0 ? args[i + 1] : undefined;
735
+ };
736
+ const tolerance = Number(flagValue("--tolerance") ?? DEFAULT_TOLERANCE_M);
737
+ if (!Number.isFinite(tolerance) || tolerance <= 0) {
738
+ throw new Error(`--tolerance must be a positive number of metres, got ${tolerance}`);
739
+ }
740
+
741
+ const endpoint = flagValue("--endpoint") ?? process.env.OVERPASS_URL ?? OVERPASS;
742
+ const osm = await fetchOsm(args.includes("--cache"), endpoint);
743
+ const { ways, internal, taggedLoc, droppedChina, droppedState, keep, claimWays } =
744
+ classify(osm);
745
+
746
+ const coords = (w) => w.geometry.map((p) => [p.lon, p.lat]);
747
+ const segments = [...keep.map((id) => coords(ways.get(id))), ...claimWays.map(coords)];
748
+ const points = segments.reduce((n, s) => n + s.length, 0);
749
+
750
+ // Not every open end is a defect. Where the ring meets a boundary we dropped as
751
+ // internal to India, the line is *supposed* to stop: past that point the border is
752
+ // undisputed and the basemap already draws it correctly. Those endpoints are
753
+ // termini, and flagging them as gaps would send someone chasing nothing.
754
+ const indiaEdge = new Set(
755
+ droppedState.flatMap((id) => coords(ways.get(id)).map(([lon, lat]) => `${lon},${lat}`)),
756
+ );
757
+ const allGaps = findGaps(segments);
758
+ const termini = allGaps.filter(([lon, lat]) => indiaEdge.has(`${lon},${lat}`));
759
+ const gaps = allGaps.filter(([lon, lat]) => !indiaEdge.has(`${lon},${lat}`));
760
+
761
+ console.log(`
762
+ region assembly
763
+ ways across the four regions ${internal.length + taggedLoc.length + droppedChina.length + droppedState.length + keep.length}
764
+ internal (the Line of Control) ${internal.length} dropped
765
+ LoC by tag, missed by membership ${taggedLoc.length} dropped, see isLineOfControl
766
+ shared with China (the LAC) ${droppedChina.length} dropped, refilled below
767
+ shared with Indian states ${droppedState.length} dropped, internal to India
768
+ India's claimed outer edge ${keep.length} kept
769
+ claimed_by=IN refill ways ${claimWays.length} kept
770
+
771
+ result
772
+ segments ${segments.length}
773
+ points ${points} (src/data.ts ships 689)
774
+
775
+ open ends
776
+ termini at India's undisputed border ${termini.length} expected, not defects
777
+ real gaps ${gaps.length}${gaps.length ? " <-- NOT yet safe to render" : " ring closes"}`);
778
+ for (const [lon, lat] of gaps.slice(0, 20)) {
779
+ console.log(` lon=${lon.toFixed(4)} lat=${lat.toFixed(4)}`);
780
+ }
781
+ if (gaps.length) {
782
+ console.log(`
783
+ Rendering this as-is draws a broken border. See STATUS in this file's header: the
784
+ refilled China sector needs endpoint snapping, and India's claim north of the
785
+ Sino-Pakistan line (Shaksgam / Trans-Karakoram) is not in OSM to begin with.
786
+ Until both are resolved, src/data.ts stays as it is — a continuous line beats a
787
+ more precise broken one.`);
788
+ }
789
+
790
+ if (args.includes("--retag")) {
791
+ retag(
792
+ segments.map((s) => s.map(toXY)),
793
+ tolerance,
794
+ args.includes("--write"),
795
+ );
796
+ }
797
+
798
+ if (args.includes("--merge")) {
799
+ // Only the region-derived ways: the claimed_by=IN refills are fragmentary and sit
800
+ // on top of the eastern arc we are keeping, so adding them would draw two lines.
801
+ const regionSegs = keep
802
+ .filter((id) => ways.get(id)?.tags?.claimed_by !== "IN")
803
+ .map((id) => coords(ways.get(id)));
804
+ const { features: shipped } = readShippedFeatures();
805
+ merge(
806
+ regionSegs,
807
+ shipped.map((s) => s.feature),
808
+ Number(flagValue("--supersede") ?? 6000),
809
+ flagValue("--merge"),
810
+ );
811
+ }
812
+
813
+ const dump = flagValue("--dump-suppression");
814
+ if (args.includes("--dump-suppression") && dump) {
815
+ dumpSuppression(
816
+ ways,
817
+ [...internal, ...taggedLoc],
818
+ droppedChina,
819
+ Number(flagValue("--simplify") ?? 15),
820
+ dump,
821
+ );
822
+ }
823
+
824
+ const out = flagValue("--geojson");
825
+ if (args.includes("--geojson") && out) {
826
+ const fc = {
827
+ type: "FeatureCollection",
828
+ features: segments.map((c) => ({
829
+ type: "Feature",
830
+ geometry: { type: "LineString", coordinates: c },
831
+ properties: {},
832
+ })),
833
+ };
834
+ writeFileSync(out, JSON.stringify(fc));
835
+ console.log(`\nwrote ${out}`);
836
+ }