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
|
@@ -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
|
+
}
|