@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/README.md +92 -0
- package/dist/index.cjs +2603 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +2580 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/corrections.js +280 -0
- package/src/index.d.ts +154 -0
- package/src/index.js +538 -0
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
|
+
}
|