@india-boundary-corrector/tilefixer 0.0.1

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/src/index.js ADDED
@@ -0,0 +1,538 @@
1
+ import { CorrectionsSource } from './corrections.js';
2
+
3
+ /**
4
+ * Minimum line width used when extrapolating below the lowest zoom stop.
5
+ */
6
+ export const MIN_LINE_WIDTH = 0.5;
7
+
8
+ /**
9
+ * Interpolate or extrapolate line width from a zoom-to-width map.
10
+ * @param {number} zoom - Zoom level
11
+ * @param {Object<number, number>} lineWidthStops - Map of zoom level to line width (at least 2 entries)
12
+ * @returns {number}
13
+ */
14
+ export function getLineWidth(zoom, lineWidthStops) {
15
+ const zooms = Object.keys(lineWidthStops).map(Number).sort((a, b) => a - b);
16
+
17
+ // Exact match
18
+ if (lineWidthStops[zoom] !== undefined) {
19
+ return lineWidthStops[zoom];
20
+ }
21
+
22
+ // Below lowest zoom - extrapolate
23
+ if (zoom < zooms[0]) {
24
+ const z1 = zooms[0];
25
+ const z2 = zooms[1];
26
+ const w1 = lineWidthStops[z1];
27
+ const w2 = lineWidthStops[z2];
28
+ const slope = (w2 - w1) / (z2 - z1);
29
+ return Math.max(MIN_LINE_WIDTH, w1 + slope * (zoom - z1));
30
+ }
31
+
32
+ // Above highest zoom - extrapolate
33
+ if (zoom > zooms[zooms.length - 1]) {
34
+ const z1 = zooms[zooms.length - 2];
35
+ const z2 = zooms[zooms.length - 1];
36
+ const w1 = lineWidthStops[z1];
37
+ const w2 = lineWidthStops[z2];
38
+ const slope = (w2 - w1) / (z2 - z1);
39
+ return Math.max(MIN_LINE_WIDTH, w2 + slope * (zoom - z2));
40
+ }
41
+
42
+ // Interpolate between two stops
43
+ for (let i = 0; i < zooms.length - 1; i++) {
44
+ if (zoom > zooms[i] && zoom < zooms[i + 1]) {
45
+ const z1 = zooms[i];
46
+ const z2 = zooms[i + 1];
47
+ const w1 = lineWidthStops[z1];
48
+ const w2 = lineWidthStops[z2];
49
+ const t = (zoom - z1) / (z2 - z1);
50
+ return w1 + t * (w2 - w1);
51
+ }
52
+ }
53
+
54
+ return 1; // fallback
55
+ }
56
+
57
+ /**
58
+ * Calculate the bounding box of features in pixel coordinates.
59
+ * @param {Array} features - Array of features with geometry
60
+ * @param {number} tileSize - Size of the tile in pixels
61
+ * @param {number} padding - Padding to add around the bounding box
62
+ * @returns {{minX: number, minY: number, maxX: number, maxY: number}}
63
+ */
64
+ function getFeaturesBoundingBox(features, tileSize, padding = 0) {
65
+ let minX = Infinity, minY = Infinity;
66
+ let maxX = -Infinity, maxY = -Infinity;
67
+
68
+ for (const feature of features) {
69
+ const scale = tileSize / feature.extent;
70
+ for (const ring of feature.geometry) {
71
+ for (const point of ring) {
72
+ const px = point.x * scale;
73
+ const py = point.y * scale;
74
+ if (px < minX) minX = px;
75
+ if (py < minY) minY = py;
76
+ if (px > maxX) maxX = px;
77
+ if (py > maxY) maxY = py;
78
+ }
79
+ }
80
+ }
81
+
82
+ // Apply padding and clamp to tile bounds
83
+ return {
84
+ minX: Math.max(0, Math.floor(minX - padding)),
85
+ minY: Math.max(0, Math.floor(minY - padding)),
86
+ maxX: Math.min(tileSize, Math.ceil(maxX + padding)),
87
+ maxY: Math.min(tileSize, Math.ceil(maxY + padding))
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Compute median of 8-bit values using histogram bucket sort (O(N) vs O(N log N)).
93
+ * @param {Uint16Array} histogram - Pre-allocated 256-element histogram to reuse
94
+ * @param {number[]} values - Array of 8-bit values (0-255)
95
+ * @returns {number}
96
+ */
97
+ function medianFromHistogram(histogram, values) {
98
+ const count = values.length;
99
+ if (count === 0) return 0;
100
+
101
+ // Clear and populate histogram
102
+ histogram.fill(0);
103
+ for (let i = 0; i < count; i++) {
104
+ histogram[values[i]]++;
105
+ }
106
+
107
+ // Find median by walking histogram
108
+ const medianPos = count >> 1; // Math.floor(count / 2)
109
+ let cumulative = 0;
110
+ for (let v = 0; v < 256; v++) {
111
+ cumulative += histogram[v];
112
+ if (cumulative > medianPos) {
113
+ return v;
114
+ }
115
+ }
116
+ return 0;
117
+ }
118
+
119
+ /**
120
+ * Apply median blur along deletion paths to erase boundary lines.
121
+ * @param {CanvasRenderingContext2D} ctx - Canvas context with the image
122
+ * @param {Array} features - Array of deletion features
123
+ * @param {number} lineWidth - Width of the blur path
124
+ * @param {number} tileSize - Size of the tile in pixels
125
+ * @param {OffscreenCanvas} [maskCanvas] - Optional reusable mask canvas
126
+ */
127
+ function applyMedianBlurAlongPath(ctx, features, lineWidth, tileSize, maskCanvas) {
128
+ if (features.length === 0) return;
129
+
130
+ // Get the image data
131
+ const imageData = ctx.getImageData(0, 0, tileSize, tileSize);
132
+ const data = imageData.data;
133
+ const width = tileSize;
134
+ const height = tileSize;
135
+
136
+ // Use provided canvas or create new one
137
+ if (!maskCanvas || maskCanvas.width !== tileSize || maskCanvas.height !== tileSize) {
138
+ maskCanvas = new OffscreenCanvas(tileSize, tileSize);
139
+ }
140
+ const maskCtx = maskCanvas.getContext('2d');
141
+ maskCtx.fillStyle = 'black';
142
+ maskCtx.fillRect(0, 0, tileSize, tileSize);
143
+
144
+ // Draw the deletion paths on the mask
145
+ maskCtx.strokeStyle = 'white';
146
+ maskCtx.lineWidth = lineWidth;
147
+ maskCtx.lineCap = 'round';
148
+ maskCtx.lineJoin = 'round';
149
+
150
+ for (const feature of features) {
151
+ const scale = tileSize / feature.extent;
152
+ for (const ring of feature.geometry) {
153
+ if (ring.length === 0) continue;
154
+ maskCtx.beginPath();
155
+ maskCtx.moveTo(ring[0].x * scale, ring[0].y * scale);
156
+ for (let i = 1; i < ring.length; i++) {
157
+ maskCtx.lineTo(ring[i].x * scale, ring[i].y * scale);
158
+ }
159
+ maskCtx.stroke();
160
+ }
161
+ }
162
+
163
+ const maskData = maskCtx.getImageData(0, 0, tileSize, tileSize).data;
164
+
165
+ // Blur radius based on line width
166
+ const radius = Math.max(2, Math.ceil(lineWidth / 2) + 1);
167
+
168
+ // Create output buffer
169
+ const output = new Uint8ClampedArray(data);
170
+
171
+ // Calculate bounding box of features to limit iteration
172
+ const bbox = getFeaturesBoundingBox(features, tileSize, radius);
173
+
174
+ // Pre-allocate reusable arrays for histogram median calculation
175
+ const histogram = new Uint16Array(256);
176
+ const rValues = [];
177
+ const gValues = [];
178
+ const bValues = [];
179
+
180
+ // Apply median filter to masked pixels within bounding box
181
+ for (let y = bbox.minY; y < bbox.maxY; y++) {
182
+ for (let x = bbox.minX; x < bbox.maxX; x++) {
183
+ const maskIdx = (y * width + x) * 4;
184
+
185
+ // Only process pixels on the deletion path (white in mask)
186
+ if (maskData[maskIdx] < 128) continue;
187
+
188
+ // Clear arrays for reuse
189
+ rValues.length = 0;
190
+ gValues.length = 0;
191
+ bValues.length = 0;
192
+
193
+ // Collect neighbor pixels (excluding masked pixels)
194
+ for (let dy = -radius; dy <= radius; dy++) {
195
+ for (let dx = -radius; dx <= radius; dx++) {
196
+ const nx = x + dx;
197
+ const ny = y + dy;
198
+
199
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
200
+
201
+ const nMaskIdx = (ny * width + nx) * 4;
202
+ // Only use pixels that are NOT on the deletion path
203
+ if (maskData[nMaskIdx] >= 128) continue;
204
+
205
+ const nIdx = nMaskIdx;
206
+ rValues.push(data[nIdx]);
207
+ gValues.push(data[nIdx + 1]);
208
+ bValues.push(data[nIdx + 2]);
209
+ }
210
+ }
211
+
212
+ // Apply median if we have enough samples
213
+ if (rValues.length >= 3) {
214
+ const idx = maskIdx;
215
+ output[idx] = medianFromHistogram(histogram, rValues);
216
+ output[idx + 1] = medianFromHistogram(histogram, gValues);
217
+ output[idx + 2] = medianFromHistogram(histogram, bValues);
218
+ // Keep alpha unchanged
219
+ }
220
+ }
221
+ }
222
+
223
+ // Write back the result
224
+ ctx.putImageData(new ImageData(output, width, height), 0, 0);
225
+ }
226
+
227
+ /**
228
+ * Check if a point is at the edge of the tile extent (within tolerance).
229
+ * @param {number} coord - Coordinate value in extent units
230
+ * @param {number} extent - Tile extent
231
+ * @param {number} tolerance - Edge tolerance as fraction of extent (default 0.01)
232
+ * @returns {boolean}
233
+ */
234
+ function isAtExtentEdge(coord, extent, tolerance = 0.01) {
235
+ const tol = extent * tolerance;
236
+ return coord <= tol || coord >= extent - tol;
237
+ }
238
+
239
+ /**
240
+ * Extend features that end inside the tile (not at edges) by a given length.
241
+ * @param {Array} features - Array of features with geometry
242
+ * @param {number} extensionLength - Extension length in extent units
243
+ * @returns {Array} New array of features with extended geometry
244
+ */
245
+ function extendFeaturesEnds(features, extensionLength) {
246
+ return features.map(feature => {
247
+ const extent = feature.extent;
248
+ const newGeometry = feature.geometry.map(ring => {
249
+ if (ring.length < 2) return ring;
250
+
251
+ const newRing = [...ring];
252
+
253
+ // Check and extend start point
254
+ const start = ring[0];
255
+ const second = ring[1];
256
+ if (!isAtExtentEdge(start.x, extent) && !isAtExtentEdge(start.y, extent)) {
257
+ const dx = start.x - second.x;
258
+ const dy = start.y - second.y;
259
+ const len = Math.sqrt(dx * dx + dy * dy);
260
+ if (len > 0) {
261
+ const ux = dx / len;
262
+ const uy = dy / len;
263
+ newRing[0] = {
264
+ x: start.x + ux * extensionLength,
265
+ y: start.y + uy * extensionLength,
266
+ };
267
+ }
268
+ }
269
+
270
+ // Check and extend end point
271
+ const lastIdx = ring.length - 1;
272
+ const end = ring[lastIdx];
273
+ const prev = ring[lastIdx - 1];
274
+ if (!isAtExtentEdge(end.x, extent) && !isAtExtentEdge(end.y, extent)) {
275
+ const dx = end.x - prev.x;
276
+ const dy = end.y - prev.y;
277
+ const len = Math.sqrt(dx * dx + dy * dy);
278
+ if (len > 0) {
279
+ const ux = dx / len;
280
+ const uy = dy / len;
281
+ newRing[lastIdx] = {
282
+ x: end.x + ux * extensionLength,
283
+ y: end.y + uy * extensionLength,
284
+ };
285
+ }
286
+ }
287
+
288
+ return newRing;
289
+ });
290
+
291
+ return { ...feature, geometry: newGeometry };
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Draw features on a canvas context.
297
+ * @param {CanvasRenderingContext2D} ctx - Canvas context
298
+ * @param {Array} features - Array of features to draw
299
+ * @param {string} color - Line color
300
+ * @param {number} lineWidth - Line width
301
+ * @param {number} tileSize - Size of the tile in pixels
302
+ * @param {number[]} [dashArray] - Dash array pattern (omit for solid line)
303
+ * @param {number} [alpha] - Opacity/alpha value from 0 to 1
304
+ */
305
+ function drawFeatures(ctx, features, color, lineWidth, tileSize, dashArray, alpha) {
306
+ const prevAlpha = ctx.globalAlpha;
307
+ if (alpha !== undefined) {
308
+ ctx.globalAlpha = alpha;
309
+ }
310
+ ctx.strokeStyle = color;
311
+ ctx.lineWidth = lineWidth;
312
+ ctx.lineJoin = 'round';
313
+
314
+ if (dashArray && dashArray.length > 0) {
315
+ ctx.setLineDash(dashArray);
316
+ ctx.lineCap = 'butt'; // Use butt cap for dashed lines to show gaps clearly
317
+ } else {
318
+ ctx.setLineDash([]);
319
+ ctx.lineCap = 'round';
320
+ }
321
+
322
+ for (const feature of features) {
323
+ const scale = tileSize / feature.extent;
324
+ for (const ring of feature.geometry) {
325
+ if (ring.length === 0) continue;
326
+ ctx.beginPath();
327
+ ctx.moveTo(ring[0].x * scale, ring[0].y * scale);
328
+ for (let i = 1; i < ring.length; i++) {
329
+ ctx.lineTo(ring[i].x * scale, ring[i].y * scale);
330
+ }
331
+ ctx.stroke();
332
+ }
333
+ }
334
+ if (alpha !== undefined) {
335
+ ctx.globalAlpha = prevAlpha;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Boundary corrector that applies corrections to raster tiles.
341
+ */
342
+ export class BoundaryCorrector {
343
+ /**
344
+ * @param {string} pmtilesUrl - URL to the PMTiles file
345
+ * @param {Object} [options] - Options
346
+ * @param {number} [options.cacheSize=64] - Maximum number of tiles to cache
347
+ * @param {number} [options.maxDataZoom] - Maximum zoom level in PMTiles (auto-detected if not provided)
348
+ */
349
+ constructor(pmtilesUrl, options = {}) {
350
+ this.correctionsSource = new CorrectionsSource(pmtilesUrl, options);
351
+ /** @type {OffscreenCanvas|null} Reusable scratch canvas for mask operations */
352
+ this._maskCanvas = null;
353
+ }
354
+
355
+ /**
356
+ * Get the PMTiles source object.
357
+ * @returns {PMTiles}
358
+ */
359
+ getSource() {
360
+ return this.correctionsSource.getSource();
361
+ }
362
+
363
+ /**
364
+ * Clear the tile cache.
365
+ */
366
+ clearCache() {
367
+ this.correctionsSource.clearCache();
368
+ }
369
+
370
+ /**
371
+ * Get corrections for a tile as a dict of layer name to features.
372
+ * Supports overzoom beyond maxDataZoom by scaling parent tile data.
373
+ * @param {number} z - Zoom level
374
+ * @param {number} x - Tile X coordinate
375
+ * @param {number} y - Tile Y coordinate
376
+ * @returns {Promise<Object<string, Array>>} Map of layer name to array of features
377
+ */
378
+ async getCorrections(z, x, y) {
379
+ return await this.correctionsSource.get(z, x, y);
380
+ }
381
+
382
+ /**
383
+ * Apply corrections to a raster tile.
384
+ * @param {Object<string, Array>} corrections - Feature map from getCorrections
385
+ * @param {ArrayBuffer} rasterTile - The original raster tile as ArrayBuffer
386
+ * @param {Object} layerConfig - Layer configuration with colors and styles
387
+ * @param {number} zoom - Current zoom level
388
+ * @param {number} [tileSize=256] - Size of the tile in pixels
389
+ * @returns {Promise<ArrayBuffer>} The corrected tile as ArrayBuffer (PNG)
390
+ */
391
+ async fixTile(corrections, rasterTile, layerConfig, zoom, tileSize = 256) {
392
+ const {
393
+ startZoom = 0,
394
+ zoomThreshold,
395
+ lineWidthStops,
396
+ delWidthFactor,
397
+ } = layerConfig;
398
+
399
+ // Don't apply corrections below startZoom
400
+ if (zoom < startZoom) {
401
+ return rasterTile;
402
+ }
403
+
404
+ // Get line styles active at this zoom level
405
+ let activeLineStyles;
406
+ if (layerConfig.getLineStylesForZoom) {
407
+ activeLineStyles = layerConfig.getLineStylesForZoom(zoom);
408
+ } else {
409
+ // Fallback for plain objects: filter by startZoom/endZoom
410
+ const allStyles = layerConfig.lineStyles || [];
411
+ activeLineStyles = allStyles.filter(style => {
412
+ const styleStart = style.startZoom ?? startZoom;
413
+ const styleEnd = style.endZoom ?? Infinity;
414
+ return zoom >= styleStart && zoom <= styleEnd;
415
+ });
416
+ }
417
+
418
+ // Determine which data source to use based on zoom
419
+ const useOsm = zoom >= zoomThreshold;
420
+ const addLayerName = useOsm ? 'to-add-osm' : 'to-add-ne';
421
+ const delLayerName = useOsm ? 'to-del-osm' : 'to-del-ne';
422
+
423
+ // Create OffscreenCanvas
424
+ const canvas = new OffscreenCanvas(tileSize, tileSize);
425
+ const ctx = canvas.getContext('2d');
426
+
427
+ // Draw original raster tile
428
+ const blob = new Blob([rasterTile]);
429
+ const imageBitmap = await createImageBitmap(blob);
430
+ ctx.drawImage(imageBitmap, 0, 0, tileSize, tileSize);
431
+
432
+ // Calculate base line width
433
+ const baseLineWidth = getLineWidth(zoom, lineWidthStops);
434
+
435
+ // Calculate deletion width based on the thickest add line
436
+ const maxWidthFraction = activeLineStyles.length > 0
437
+ ? Math.max(...activeLineStyles.map(s => s.widthFraction ?? 1.0))
438
+ : 1.0;
439
+ const delLineWidth = baseLineWidth * maxWidthFraction * delWidthFactor;
440
+
441
+ // Apply median blur along deletion paths to erase incorrect boundaries
442
+ const delFeatures = corrections[delLayerName] || [];
443
+ if (delFeatures.length > 0) {
444
+ // Get or create reusable mask canvas
445
+ if (!this._maskCanvas || this._maskCanvas.width !== tileSize) {
446
+ this._maskCanvas = new OffscreenCanvas(tileSize, tileSize);
447
+ }
448
+ applyMedianBlurAlongPath(ctx, delFeatures, delLineWidth, tileSize, this._maskCanvas);
449
+ }
450
+
451
+ // Draw addition lines using active lineStyles (in order)
452
+ let addFeatures = corrections[addLayerName] || [];
453
+ if (addFeatures.length > 0 && activeLineStyles.length > 0) {
454
+ // Extend add lines if factor > 0 (to cover where deleted lines meet the boundary)
455
+ const extensionFactor = layerConfig.lineExtensionFactor ?? 0.5;
456
+ if (extensionFactor > 0 && delFeatures.length > 0) {
457
+ // Extension length in extent units
458
+ const extent = addFeatures[0]?.extent || 4096;
459
+ const extensionLength = (delLineWidth * extensionFactor / tileSize) * extent;
460
+ addFeatures = extendFeaturesEnds(addFeatures, extensionLength);
461
+ }
462
+
463
+ for (const style of activeLineStyles) {
464
+ const { color, widthFraction = 1.0, dashArray, alpha = 1.0 } = style;
465
+ const lineWidth = baseLineWidth * widthFraction;
466
+ drawFeatures(ctx, addFeatures, color, lineWidth, tileSize, dashArray, alpha);
467
+ }
468
+ }
469
+
470
+ // Convert canvas to ArrayBuffer (PNG)
471
+ const outputBlob = await canvas.convertToBlob({ type: 'image/png' });
472
+ return outputBlob.arrayBuffer();
473
+ }
474
+
475
+ /**
476
+ * Fetch a tile, apply corrections, and return the result.
477
+ * @param {string} tileUrl - URL of the raster tile
478
+ * @param {number} z - Zoom level
479
+ * @param {number} x - Tile X coordinate
480
+ * @param {number} y - Tile Y coordinate
481
+ * @param {Object} layerConfig - Layer configuration with colors and styles
482
+ * @param {Object} [options] - Fetch options
483
+ * @param {number} [options.tileSize=256] - Tile size in pixels
484
+ * @param {AbortSignal} [options.signal] - Abort signal for fetch
485
+ * @param {RequestMode} [options.mode] - Fetch mode (e.g., 'cors')
486
+ * @returns {Promise<{data: ArrayBuffer, wasFixed: boolean}>}
487
+ */
488
+ async fetchAndFixTile(tileUrl, z, x, y, layerConfig, options = {}) {
489
+ const { tileSize = 256, signal, mode } = options;
490
+ const fetchOptions = {};
491
+ if (signal) fetchOptions.signal = signal;
492
+ if (mode) fetchOptions.mode = mode;
493
+
494
+ // No layerConfig means no corrections needed
495
+ if (!layerConfig) {
496
+ const response = await fetch(tileUrl, fetchOptions);
497
+ if (!response.ok) throw new Error(`Tile fetch failed: ${response.status}`);
498
+ return { data: await response.arrayBuffer(), wasFixed: false };
499
+ }
500
+
501
+ // Fetch tile and corrections in parallel
502
+ const [tileResult, correctionsResult] = await Promise.allSettled([
503
+ fetch(tileUrl, fetchOptions).then(r => {
504
+ if (!r.ok) throw new Error(`Tile fetch failed: ${r.status}`);
505
+ return r.arrayBuffer();
506
+ }),
507
+ this.getCorrections(z, x, y)
508
+ ]);
509
+
510
+ // Check if aborted before proceeding with CPU-intensive work
511
+ if (signal?.aborted) {
512
+ throw new DOMException('Aborted', 'AbortError');
513
+ }
514
+
515
+ // Handle fetch failure
516
+ if (tileResult.status === 'rejected') {
517
+ throw tileResult.reason;
518
+ }
519
+
520
+ const tileData = tileResult.value;
521
+
522
+ // Check if corrections fetch failed
523
+ const correctionsFailed = correctionsResult.status === 'rejected';
524
+ const correctionsError = correctionsFailed ? correctionsResult.reason : null;
525
+ const corrections = correctionsResult.status === 'fulfilled' ? correctionsResult.value : {};
526
+
527
+ // Check if there are any corrections to apply
528
+ const hasCorrections = Object.values(corrections).some(arr => arr && arr.length > 0);
529
+
530
+ if (!hasCorrections) {
531
+ return { data: tileData, wasFixed: false, correctionsFailed, correctionsError };
532
+ }
533
+
534
+ // Apply corrections
535
+ const fixedData = await this.fixTile(corrections, tileData, layerConfig, z, tileSize);
536
+ return { data: fixedData, wasFixed: true, correctionsFailed: false, correctionsError: null };
537
+ }
538
+ }