@dra2020/baseclient 1.0.167 → 1.0.169

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.
@@ -0,0 +1,389 @@
1
+ type Point = [number, number];
2
+
3
+ /**
4
+ * Minimal shape this validator needs. A real topology from
5
+ * `topojson-specification` satisfies this. We require both `packed.arcs`
6
+ * (the segment data) and `packed.arcindices` + `objects` (so we can walk
7
+ * only the arcs reachable from the live object set, matching
8
+ * topojson-client's `validateneighbors.js` semantics via `forAllArcPoints`
9
+ * with `onlyOnce: true`).
10
+ */
11
+ export interface PackedTopologyLike {
12
+ objects: { [id: string]: PackedObject };
13
+ packed: {
14
+ arcs: Float64Array | number[];
15
+ arcindices: Int32Array | number[];
16
+ /** Per-topology map from object reference to its offset into `packed.arcindices`.
17
+ * Kept here (not on the object) so the same object can appear in more than one
18
+ * packed topology — see topojson-client/src/packarcindices.js. */
19
+ objectArcs: WeakMap<object, number>;
20
+ };
21
+ }
22
+
23
+ interface PackedObject {
24
+ type: string;
25
+ }
26
+
27
+ /** A line segment found in more than one arc. Endpoints are reported in their
28
+ * lexicographic-canonical order (smaller endpoint first). */
29
+ export interface DuplicateArcSegment {
30
+ segment: { s: Point; e: Point };
31
+ /** Arc indices (into `topology.packed.arcs`) that contain this segment, ascending. */
32
+ arcs: number[];
33
+ }
34
+
35
+ export interface ValidateArcSegmentsOptions {
36
+ /**
37
+ * Subset of object IDs to validate. If undefined, every object in
38
+ * `topology.objects` is walked (matching `validateneighbors.js` default,
39
+ * which uses `params.topology.objects` when no `objects` field is given).
40
+ *
41
+ * Accepts either a hash whose keys are the object IDs to include (same
42
+ * shape as `topology.objects`), or a Set of IDs.
43
+ */
44
+ objects?: { [id: string]: unknown } | Set<string>;
45
+ }
46
+
47
+ export interface ValidateArcSegmentsResult {
48
+ ok: boolean;
49
+ duplicates: DuplicateArcSegment[];
50
+ /** Number of unique arcs actually inspected (reachable from the object set). */
51
+ arcsInspected: number;
52
+ /** Total (arc, consecutive-point-pair) segments inspected across reachable arcs. */
53
+ totalSegments: number;
54
+ }
55
+
56
+ /**
57
+ * Verify that no two arcs of a packed TopoJSON topology contain the same line
58
+ * segment — i.e. the same pair of consecutive points, in either direction.
59
+ *
60
+ * Semantics mirror topojson-client's `validateneighbors.js`:
61
+ * - Only arcs **reachable from the current object set** are inspected;
62
+ * orphaned arcs in the packed buffer are ignored.
63
+ * - Each reachable arc is visited at most once (the `onlyOnce: true` flag
64
+ * in the upstream `forAllArcPoints` walker).
65
+ * - Equality is exact Float64 (no epsilon), matching `splice.js`'s
66
+ * `dedup`/`equalArcs`. Endpoint order is normalized lexicographically
67
+ * before keying, so segment `(P→Q)` in one arc and `(Q→P)` in another
68
+ * are treated as the same segment.
69
+ * - Within-arc segment repeats (the same arc containing the same segment
70
+ * twice) are ignored — only cross-arc duplicates are reported.
71
+ *
72
+ * Expected packed layout (same as topojson-client's `forAllArcPoints` / `splice`):
73
+ * topology.packed.arcs (Float64Array)
74
+ * [0] = narcs
75
+ * [1 + 2*a] = npoints for arc `a`
76
+ * [1 + 2*a + 1] = pointoffset for arc `a` (index into the same buffer)
77
+ * at pointoffset: interleaved x, y, x, y, ... (2 floats per point)
78
+ * topology.packed.arcindices (Int32Array)
79
+ * starting at object.packedarcs:
80
+ * Polygon : [nring][narc][arc...]...
81
+ * MultiPolygon : [npoly][nring][narc][arc...]...
82
+ *
83
+ * O(total reachable points) time, O(total reachable unique segments) space.
84
+ */
85
+ export function validateNoDuplicateArcSegments(
86
+ topology: PackedTopologyLike,
87
+ options: ValidateArcSegmentsOptions = {},
88
+ ): ValidateArcSegmentsResult {
89
+ const arcs = topology.packed.arcs;
90
+ const arcindices = topology.packed.arcindices;
91
+
92
+ // -- Phase 1: walk topology.objects to collect the set of reachable arcs ---
93
+ // Mirrors forAllArcPoints({ topology, objects, onlyOnce: true }) which is
94
+ // what validateneighbors.js uses. We only need each reachable arc once,
95
+ // so dedup with a Set as we go.
96
+ const seen = new Set<number>();
97
+
98
+ function walkRing(z: number): number {
99
+ const narc = arcindices[z++];
100
+ for (let i = 0; i < narc; i++, z++)
101
+ {
102
+ let arc = arcindices[z];
103
+ if (arc < 0) arc = ~arc; // negative encodes reversed direction; same arc index after ~
104
+ if (!seen.has(arc)) seen.add(arc);
105
+ }
106
+ return z;
107
+ }
108
+
109
+ function walkPolygon(z: number): number {
110
+ const nring = arcindices[z++];
111
+ for (let i = 0; i < nring; i++) z = walkRing(z);
112
+ return z;
113
+ }
114
+
115
+ function walkMultiPolygon(z: number): number {
116
+ const npoly = arcindices[z++];
117
+ for (let i = 0; i < npoly; i++) z = walkPolygon(z);
118
+ return z;
119
+ }
120
+
121
+ // Resolve the object-id list to walk. A user-supplied Set is converted to
122
+ // an iterable of keys; a user-supplied hash is treated as id→anything (same
123
+ // shape as `topology.objects`); falling back to all of `topology.objects`.
124
+ let objectIds: Iterable<string>;
125
+ if (options.objects instanceof Set) objectIds = options.objects;
126
+ else if (options.objects) objectIds = Object.keys(options.objects);
127
+ else objectIds = Object.keys(topology.objects);
128
+
129
+ const objectArcs = topology.packed.objectArcs;
130
+ for (const id of objectIds)
131
+ {
132
+ const obj = topology.objects[id];
133
+ if (!obj) continue;
134
+ const z = objectArcs ? objectArcs.get(obj) : undefined;
135
+ if (z === undefined) continue;
136
+ if (obj.type === "Polygon") walkPolygon(z);
137
+ else if (obj.type === "MultiPolygon") walkMultiPolygon(z);
138
+ // Other geometry types don't contribute polygon arcs; ignore.
139
+ }
140
+
141
+ // -- Phase 2: scan segments only on reachable arcs --------------------------
142
+
143
+ /** Canonical key for a segment, with endpoints lex-sorted (smaller first).
144
+ * Number.prototype.toString round-trips finite Float64s reversibly, so
145
+ * byte-equal floats produce byte-equal strings — same coordinate equality
146
+ * as `splice.js`'s `equalArcs`. */
147
+ function segKey(p1x: number, p1y: number, p2x: number, p2y: number): string {
148
+ const smallerFirst = p1x < p2x || (p1x === p2x && p1y < p2y);
149
+ return smallerFirst
150
+ ? `${p1x},${p1y};${p2x},${p2y}`
151
+ : `${p2x},${p2y};${p1x},${p1y}`;
152
+ }
153
+
154
+ const firstArcByKey = new Map<string, number>();
155
+ const dupByKey = new Map<string, DuplicateArcSegment>();
156
+ let totalSegments = 0;
157
+
158
+ seen.forEach((arc) => {
159
+ const hdr = 1 + arc * 2;
160
+ const npoints = arcs[hdr] | 0;
161
+ let zp = arcs[hdr + 1] | 0;
162
+ if (npoints < 2) return;
163
+
164
+ let px = arcs[zp++];
165
+ let py = arcs[zp++];
166
+ for (let i = 1; i < npoints; i++)
167
+ {
168
+ const qx = arcs[zp++];
169
+ const qy = arcs[zp++];
170
+ totalSegments++;
171
+
172
+ const key = segKey(px, py, qx, qy);
173
+ const firstArc = firstArcByKey.get(key);
174
+ if (firstArc === undefined)
175
+ {
176
+ firstArcByKey.set(key, arc);
177
+ }
178
+ else if (firstArc !== arc)
179
+ {
180
+ let entry = dupByKey.get(key);
181
+ if (!entry)
182
+ {
183
+ const pIsSmaller = px < qx || (px === qx && py < qy);
184
+ const s: Point = pIsSmaller ? [px, py] : [qx, qy];
185
+ const e: Point = pIsSmaller ? [qx, qy] : [px, py];
186
+ entry = { segment: { s, e }, arcs: [firstArc] };
187
+ dupByKey.set(key, entry);
188
+ }
189
+ // Sorted append — `seen.forEach` runs arcs in insertion order
190
+ // (object-walk order), so duplicates land in a useful sequence.
191
+ if (entry.arcs[entry.arcs.length - 1] !== arc) entry.arcs.push(arc);
192
+ }
193
+
194
+ px = qx;
195
+ py = qy;
196
+ }
197
+ });
198
+
199
+ const duplicates: DuplicateArcSegment[] = [];
200
+ dupByKey.forEach((d) => {
201
+ d.arcs.sort((a, b) => a - b);
202
+ duplicates.push(d);
203
+ });
204
+ duplicates.sort((a, b) => a.arcs[0] - b.arcs[0]);
205
+
206
+ return {
207
+ ok: duplicates.length === 0,
208
+ duplicates,
209
+ arcsInspected: seen.size,
210
+ totalSegments,
211
+ };
212
+ }
213
+
214
+ // =============================================================================
215
+ // Cross-arc interior-point-sharing diagnostic
216
+ // =============================================================================
217
+
218
+ /** One offending point: shared between arcs in a way that violates
219
+ * `ptsToArcs`'s "interior set once" invariant. */
220
+ export interface InteriorPointSharing {
221
+ point: Point;
222
+ /** Arcs in which this point is INTERIOR (not the first or last point). */
223
+ interiorInArcs: number[];
224
+ /** Arcs in which this point is an ENDPOINT (first or last point). */
225
+ endpointInArcs: number[];
226
+ }
227
+
228
+ export interface InteriorPointSharingResult {
229
+ ok: boolean;
230
+ /** Points that, in this single topology, are shared across arcs in a way that
231
+ * causes `ptsToArcs` (inside topojson-client's splice) to silently overwrite
232
+ * its `map.set(here, …)` record for that point.
233
+ *
234
+ * Two failure modes are flagged:
235
+ *
236
+ * a) **Interior in 2+ arcs.** `ptsToArcs` says "Interior points will by
237
+ * definition only be set once." If that invariant doesn't hold,
238
+ * whichever arc walks last wins the `map.set`; the earlier arc's
239
+ * record at that point is lost. `newJunctions` then can't detect that
240
+ * the lost arc needed cutting against another topology, so it stays
241
+ * whole and may produce duplicate segments after splice.
242
+ *
243
+ * b) **Interior in one arc AND endpoint in another.** The same overwrite
244
+ * happens regardless of which one walks last:
245
+ * - If the endpoint write lands last, the interior record is lost
246
+ * and the interior arc never gets its junction check.
247
+ * - If the interior write lands last, the endpoint's
248
+ * junction-forcing role at that point is lost.
249
+ * The `ptsToArcs` comment says endpoint overwrites are OK "since ANY
250
+ * endpoint forces a junction" — that only holds when *all* the
251
+ * overwrites are endpoint-on-endpoint. As soon as an interior is in
252
+ * the mix, the invariant is broken.
253
+ */
254
+ offenses: InteriorPointSharing[];
255
+ /** Number of unique arcs actually inspected (reachable from objects). */
256
+ arcsInspected: number;
257
+ }
258
+
259
+ /**
260
+ * Diagnose whether a single input topology to `splice` violates the
261
+ * `ptsToArcs` "interior set once" invariant. This is a candidate explanation
262
+ * for the case where the input topologies report no duplicated arc segments
263
+ * (per `validateNoDuplicateArcSegments`) but the spliced output does — i.e.
264
+ * the inputs share an interior point across arcs, so within ONE topology the
265
+ * pointMap built by `ptsToArcs` silently loses the loser of the overwrite,
266
+ * `newJunctions` then can't find every cut it needs, arcs aren't broken at
267
+ * the right places, and `dedup` (which only catches full-arc matches) can't
268
+ * fuse the resulting partial overlaps.
269
+ *
270
+ * Implementation mirrors `validateNoDuplicateArcSegments`: walk reachable
271
+ * arcs only (matching `forAllArcPoints({onlyOnce: true})`), then for each
272
+ * point of each reachable arc classify it as start, interior, or end and
273
+ * accumulate the per-point arc sets.
274
+ *
275
+ * Exact float equality (no epsilon) — same coordinate semantics as
276
+ * `splice.js`'s `equalArcs`/`pointEqual`.
277
+ */
278
+ export function findCrossArcInteriorPointSharing(
279
+ topology: PackedTopologyLike,
280
+ options: ValidateArcSegmentsOptions = {},
281
+ ): InteriorPointSharingResult {
282
+ const arcs = topology.packed.arcs;
283
+ const arcindices = topology.packed.arcindices;
284
+
285
+ // -- Phase 1: walk objects → reachable arcs (identical to the segment validator).
286
+ const seen = new Set<number>();
287
+
288
+ function walkRing(z: number): number {
289
+ const narc = arcindices[z++];
290
+ for (let i = 0; i < narc; i++, z++)
291
+ {
292
+ let arc = arcindices[z];
293
+ if (arc < 0) arc = ~arc;
294
+ if (!seen.has(arc)) seen.add(arc);
295
+ }
296
+ return z;
297
+ }
298
+ function walkPolygon(z: number): number {
299
+ const nring = arcindices[z++];
300
+ for (let i = 0; i < nring; i++) z = walkRing(z);
301
+ return z;
302
+ }
303
+ function walkMultiPolygon(z: number): number {
304
+ const npoly = arcindices[z++];
305
+ for (let i = 0; i < npoly; i++) z = walkPolygon(z);
306
+ return z;
307
+ }
308
+
309
+ let objectIds: Iterable<string>;
310
+ if (options.objects instanceof Set) objectIds = options.objects;
311
+ else if (options.objects) objectIds = Object.keys(options.objects);
312
+ else objectIds = Object.keys(topology.objects);
313
+
314
+ const objectArcs2 = topology.packed.objectArcs;
315
+ for (const id of objectIds)
316
+ {
317
+ const obj = topology.objects[id];
318
+ if (!obj) continue;
319
+ const z = objectArcs2 ? objectArcs2.get(obj) : undefined;
320
+ if (z === undefined) continue;
321
+ if (obj.type === "Polygon") walkPolygon(z);
322
+ else if (obj.type === "MultiPolygon") walkMultiPolygon(z);
323
+ }
324
+
325
+ // -- Phase 2: classify each point of each reachable arc as endpoint or interior,
326
+ // accumulate per-point arc sets. Exact float equality via string key.
327
+ function ptKey(x: number, y: number): string { return `${x},${y}`; }
328
+
329
+ const interiorByKey = new Map<string, Set<number>>();
330
+ const endpointByKey = new Map<string, Set<number>>();
331
+ const pointByKey = new Map<string, Point>();
332
+
333
+ seen.forEach((arc) => {
334
+ const hdr = 1 + arc * 2;
335
+ const npoints = arcs[hdr] | 0;
336
+ let zp = arcs[hdr + 1] | 0;
337
+ if (npoints < 1) return;
338
+ const lastIdx = npoints - 1;
339
+ for (let i = 0; i < npoints; i++)
340
+ {
341
+ const x = arcs[zp++];
342
+ const y = arcs[zp++];
343
+ const k = ptKey(x, y);
344
+ if (!pointByKey.has(k)) pointByKey.set(k, [x, y]);
345
+ const isEndpoint = (i === 0 || i === lastIdx);
346
+ const bucket = isEndpoint ? endpointByKey : interiorByKey;
347
+ let s = bucket.get(k);
348
+ if (!s) { s = new Set<number>(); bucket.set(k, s); }
349
+ s.add(arc);
350
+ }
351
+ });
352
+
353
+ // -- Phase 3: flag any point that's interior in 2+ arcs, OR interior in one
354
+ // arc AND endpoint in another arc. Either case breaks the
355
+ // ptsToArcs "interior set once" invariant.
356
+ const offenses: InteriorPointSharing[] = [];
357
+ interiorByKey.forEach((interiorArcs, key) => {
358
+ const endpointArcs = endpointByKey.get(key);
359
+ const interiorCount = interiorArcs.size;
360
+ const endpointCount = endpointArcs ? endpointArcs.size : 0;
361
+ if (interiorCount >= 2 || (interiorCount >= 1 && endpointCount >= 1))
362
+ {
363
+ // For the endpoint set, exclude any arc that's *also* interior at this
364
+ // point (would be a self-touching arc — same arc appearing in both
365
+ // buckets is a degenerate within-arc issue, not the cross-arc problem
366
+ // we're hunting).
367
+ const endpointArcsOther: number[] = endpointArcs
368
+ ? Array.from(endpointArcs).filter(a => !interiorArcs.has(a)).sort((a, b) => a - b)
369
+ : [];
370
+ // Re-check the condition after filtering — if interior is just 1 and
371
+ // the only endpoint occurrences were the same arc self-touching, drop
372
+ // this as a false positive.
373
+ if (interiorCount >= 2 || (interiorCount >= 1 && endpointArcsOther.length >= 1))
374
+ {
375
+ offenses.push({
376
+ point: pointByKey.get(key)!,
377
+ interiorInArcs: Array.from(interiorArcs).sort((a, b) => a - b),
378
+ endpointInArcs: endpointArcsOther,
379
+ });
380
+ }
381
+ }
382
+ });
383
+
384
+ return {
385
+ ok: offenses.length === 0,
386
+ offenses,
387
+ arcsInspected: seen.size,
388
+ };
389
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dra2020/baseclient",
3
- "version": "1.0.167",
3
+ "version": "1.0.169",
4
4
  "description": "Utility functions for Javascript projects.",
5
5
  "main": "dist/baseclient.js",
6
6
  "types": "./dist/all/all.d.ts",
@@ -44,7 +44,7 @@
44
44
  "webpack-cli": "^5.1.4"
45
45
  },
46
46
  "dependencies": {
47
- "@dra2020/topojson-client": "^3.2.16",
47
+ "@dra2020/topojson-client": "^3.2.17",
48
48
  "@dra2020/topojson-server": "^3.0.103",
49
49
  "@dra2020/topojson-simplify": "^3.0.102",
50
50
  "diff-match-patch": "^1.0.5",