From ee672df5347e7e178b464de04ef114d26cd9c5dc Mon Sep 17 00:00:00 2001 From: acalcutt Date: Wed, 8 Oct 2025 12:44:30 -0400 Subject: [PATCH 1/3] Create sparse-tile-adapter.ts --- src/sparse-tile-adapter.ts | 210 +++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 src/sparse-tile-adapter.ts diff --git a/src/sparse-tile-adapter.ts b/src/sparse-tile-adapter.ts new file mode 100644 index 0000000..c2173bf --- /dev/null +++ b/src/sparse-tile-adapter.ts @@ -0,0 +1,210 @@ +// Add these helper functions for sparse tile support + +interface SparseTileResult { + imageData: ImageData; + mimeType: string; +} + +/** + * Calculate parent tile coordinates + */ +function getParentTile(z: number, x: number, y: number): { z: number; x: number; y: number } | null { + if (z === 0) return null; + return { + z: z - 1, + x: Math.floor(x / 2), + y: Math.floor(y / 2), + }; +} + +/** + * Calculate the offset and scaling needed to extract a child tile from a parent + */ +function getChildTileRegion( + childZ: number, + childX: number, + childY: number, + parentZ: number, + tileSize: number +): { offsetX: number; offsetY: number; scale: number } { + const zoomDiff = childZ - parentZ; + const tilesPerParent = Math.pow(2, zoomDiff); + + // Calculate which quadrant/region of the parent tile contains the child + const xOffset = childX % tilesPerParent; + const yOffset = childY % tilesPerParent; + + // Size of each child region within the parent + const regionSize = tileSize / tilesPerParent; + + return { + offsetX: xOffset * regionSize, + offsetY: yOffset * regionSize, + scale: tilesPerParent, + }; +} + +/** + * Crop and scale a region from a parent tile to create a child tile + */ +async function cropAndScaleTile( + parentImageData: ImageData, + offsetX: number, + offsetY: number, + scale: number, + targetSize: number +): Promise { + const sourceSize = targetSize / scale; + + // Create a canvas for the operation + const canvas = new OffscreenCanvas(targetSize, targetSize); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + // Create temporary canvas for source image + const sourceCanvas = new OffscreenCanvas(parentImageData.width, parentImageData.height); + const sourceCtx = sourceCanvas.getContext('2d'); + + if (!sourceCtx) { + throw new Error('Failed to get source canvas context'); + } + + // Put the parent image data onto the source canvas + sourceCtx.putImageData(parentImageData, 0, 0); + + // Use high-quality scaling (similar to Lanczos/bilinear interpolation) + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + // Draw the cropped and scaled region + ctx.drawImage( + sourceCanvas, + offsetX, offsetY, // Source x, y + sourceSize, sourceSize, // Source width, height + 0, 0, // Destination x, y + targetSize, targetSize // Destination width, height + ); + + return ctx.getImageData(0, 0, targetSize, targetSize); +} + +/** + * Fetch a sparse tile by looking for parent tiles and upscaling + */ +async function fetchSparseTile( + z: number, + x: number, + y: number, + tileSize: number, + fetcher: TileFetcher, + urlPattern: string, + verbose: boolean = false +): Promise { + let currentZ = z; + let currentX = x; + let currentY = y; + + // Try to find a parent tile at progressively lower zoom levels + while (currentZ >= 0) { + const parent = getParentTile(currentZ, currentX, currentY); + if (!parent) break; + + currentZ = parent.z; + currentX = parent.x; + currentY = parent.y; + + // Build URL for parent tile + const parentUrl = urlPattern + .replace('{z}', currentZ.toString()) + .replace('{x}', currentX.toString()) + .replace('{y}', currentY.toString()); + + if (verbose) { + console.log( + `[SparseTile] Attempting to fetch parent tile z${currentZ}/${currentX}/${currentY} for child z${z}/${x}/${y}` + ); + } + + try { + const result = await fetcher(parentUrl, new AbortController()); + + if (result.data) { + // Successfully fetched parent tile, now crop and scale it + if (verbose) { + console.log( + `[SparseTile] Found parent tile at z${currentZ}/${currentX}/${currentY}, upscaling to z${z}/${x}/${y}` + ); + } + + // Convert Blob to ImageData + const bitmap = await createImageBitmap(result.data); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + ctx.drawImage(bitmap, 0, 0); + const parentImageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height); + + // Calculate crop region + const region = getChildTileRegion(z, x, y, currentZ, tileSize); + + // Crop and scale + const childImageData = await cropAndScaleTile( + parentImageData, + region.offsetX, + region.offsetY, + region.scale, + tileSize + ); + + return { + imageData: childImageData, + mimeType: result.mimeType || 'image/png', + }; + } + } catch (error) { + // Parent tile not found, continue searching at lower zoom levels + if (verbose) { + console.log(`[SparseTile] Parent tile not found at z${currentZ}, trying lower zoom`); + } + } + } + + // No parent tile found at any zoom level + return null; +} + +/** + * Convert ImageData to Blob + */ +async function imageDataToBlob(imageData: ImageData, format: string): Promise { + const canvas = new OffscreenCanvas(imageData.width, imageData.height); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + ctx.putImageData(imageData, 0, 0); + + // Map format to MIME type + const mimeTypeMap: { [key: string]: string } = { + png: 'image/png', + webp: 'image/webp', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + }; + + const mimeType = mimeTypeMap[format] || 'image/png'; + + return await canvas.convertToBlob({ type: mimeType }); +} + +export type { SparseTileResult }; +export { fetchSparseTile, imageDataToBlob, getParentTile, getChildTileRegion, cropAndScaleTile }; \ No newline at end of file From 75bee791334edefe7603faf09571fc8e65df2656 Mon Sep 17 00:00:00 2001 From: acalcutt Date: Wed, 8 Oct 2025 19:08:03 -0400 Subject: [PATCH 2/3] Try to add option to support sparse tiles --- src/generate-contour-tile-pyramid.ts | 172 ++++++++++++++++++++- src/index.ts | 23 +++ src/sparse-tile-adapter.ts | 218 ++++++++++++++++++++++----- 3 files changed, 375 insertions(+), 38 deletions(-) diff --git a/src/generate-contour-tile-pyramid.ts b/src/generate-contour-tile-pyramid.ts index 3cdd7f8..1bab546 100644 --- a/src/generate-contour-tile-pyramid.ts +++ b/src/generate-contour-tile-pyramid.ts @@ -14,12 +14,17 @@ import { openPMtiles, pmtilesTester, } from "./pmtiles-adapter.js"; -// Import MBTiles adapter functions, tester, AND the metadata structure from openMBTiles import { openMBTiles, getMBTilesTile, mbtilesTester, } from "./mbtiles-adapter.js"; +import { + fetchSparseTile, + imageDataToBlob, + type SparseTileResult, + type ResamplingMethod, +} from "./sparse-tile-adapter.js"; import { getChildren } from "@mapbox/tilebelt"; import path from "path"; @@ -85,7 +90,23 @@ program "The image format for generated blank tiles ('png', 'webp', or 'jpeg'). This is used as a fallback if the source format cannot be determined.", "png", // Default format for blank tiles ) - // ADD THE VERBOSE OPTION HERE + .option( + "--useSparseMode", + "Enable sparse tile mode: upscale and crop lower zoom tiles instead of generating blank tiles when DEM tiles are missing.", + ) + .option( + "--sparseResamplingMethod ", + "The resampling method used for upscaling sparse tiles ('nearest', 'bilinear', or 'bicubic').", + (value) => { + if (value !== "nearest" && value !== "bilinear" && value !== "bicubic") { + throw new Error( + "Invalid value for --sparseResamplingMethod, must be 'nearest', 'bilinear', or 'bicubic'", + ); + } + return value; + }, + "bicubic", // default value + ) .option("-v, --verbose", "Enable verbose output.") .parse(process.argv); @@ -103,7 +124,9 @@ const { outputDir, blankTileSize, blankTileFormat, - verbose, // Capture the verbose option + useSparseMode, + sparseResamplingMethod, + verbose, } = options; const numX = Number(x); @@ -125,6 +148,16 @@ if (!validBlankTileFormats.includes(blankTileFormat)) { process.exit(1); } +const validSparseResamplingMethods = ["nearest", "bilinear", "bicubic"]; +if (!validSparseResamplingMethods.includes(sparseResamplingMethod)) { + console.error( + `Invalid value for --sparseResamplingMethod: ${sparseResamplingMethod}. Must be one of: ${validSparseResamplingMethods.join( + ", ", + )}`, + ); + process.exit(1); +} + // -------------------------------------------------- // mlcontour options/defaults // -------------------------------------------------- @@ -225,6 +258,51 @@ const pmtilesFetcher: TileFetcher = async ( ); if (!zxyTileData) { + // Tile not found - check if we should use sparse mode + if (useSparseMode) { + if (verbose) { + console.log( + `[Fetcher] DEM tile not found for ${url}, attempting sparse tile fetch`, + ); + } + + const sparseResult = await fetchSparseTile( + zxy.z, + zxy.x, + zxy.y, + numblankTileSize, + pmtilesFetcher, + demUrlPattern || "", + sparseResamplingMethod, + verbose, + ); + + if (sparseResult) { + if (verbose) { + console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + } + + // Convert ImageData to Blob + const sourceMimeType = pmtilesMimeType || `image/${blankTileFormat}`; + const formatForSparse = sourceMimeType.split("/")[1] || blankTileFormat; + const blob = await imageDataToBlob(sparseResult.imageData, formatForSparse); + + return { + data: blob, + mimeType: sourceMimeType, + expires: undefined, + cacheControl: undefined, + }; + } + + if (verbose) { + console.log( + `[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`, + ); + } + } + + // Fall back to blank tile generation console.warn( `DEM tile not found for ${url} (z:${zxy.z}, x:${zxy.x}, y:${zxy.y}). Generating blank tile.`, ); @@ -283,6 +361,50 @@ const mbtilesFetcher: TileFetcher = async ( ); if (!tileData || !tileData.data) { + // Tile not found - check if we should use sparse mode + if (useSparseMode) { + if (verbose) { + console.log( + `[Fetcher] DEM tile not found for ${url}, attempting sparse tile fetch`, + ); + } + + const sparseResult = await fetchSparseTile( + zxy.z, + zxy.x, + zxy.y, + numblankTileSize, + mbtilesFetcher, + demUrlPattern || "", + sparseResamplingMethod, + verbose, + ); + + if (sparseResult) { + if (verbose) { + console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + } + + const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; + const blob = await imageDataToBlob(sparseResult.imageData, sourceFormat); + const blobType = `image/${sourceFormat}`; + + return { + data: blob, + mimeType: blobType, + expires: undefined, + cacheControl: undefined, + }; + } + + if (verbose) { + console.log( + `[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`, + ); + } + } + + // Fall back to blank tile generation console.warn( `DEM tile not found for ${url} (z:${zxy.z}, x:${zxy.x}, y:${zxy.y}). Generating blank tile.`, ); @@ -319,6 +441,50 @@ const mbtilesFetcher: TileFetcher = async ( error.message.includes("Tile does not exist") || error.message.includes("no such row") ) { + // Tile not found - check if we should use sparse mode + if (useSparseMode) { + if (verbose) { + console.log( + `[Fetcher] DEM tile not found for ${url}, attempting sparse tile fetch`, + ); + } + + const sparseResult = await fetchSparseTile( + zxy.z, + zxy.x, + zxy.y, + numblankTileSize, + mbtilesFetcher, + demUrlPattern || "", + sparseResamplingMethod, + verbose, + ); + + if (sparseResult) { + if (verbose) { + console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + } + + const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; + const blob = await imageDataToBlob(sparseResult.imageData, sourceFormat); + const blobType = `image/${sourceFormat}`; + + return { + data: blob, + mimeType: blobType, + expires: undefined, + cacheControl: undefined, + }; + } + + if (verbose) { + console.log( + `[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`, + ); + } + } + + // Fall back to blank tile generation console.warn( `DEM tile not found for ${url} (z:${zxy.z}, x:${zxy.x}, y:${zxy.y}). Generating blank tile.`, ); diff --git a/src/index.ts b/src/index.ts index ed845c4..6358585 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,8 @@ type BaseOptions = { blankTileNoDataValue: number; blankTileSize: number; blankTileFormat: string; + useSparseMode: boolean; + sparseResamplingMethod: string; }; type PyramidOptions = BaseOptions & { @@ -149,6 +151,13 @@ async function processTile(options: PyramidOptions): Promise { options.blankTileFormat, ]; + // Add sparse mode options if enabled + if (options.useSparseMode) { + commandArgs.push("--useSparseMode"); + commandArgs.push("--sparseResamplingMethod"); + commandArgs.push(options.sparseResamplingMethod); + } + // Pass the verbose flag down to the child process ONLY if the orchestrator is verbose if (options.verbose) { commandArgs.push("--verbose"); // Pass the verbose flag to the child @@ -446,6 +455,20 @@ async function main(): Promise { "The image format for generated blank tiles ('png', 'webp', or 'jpeg').", defaultValue: "png", }, + { + type: "standard", + flags: "--useSparseMode", + description: + "Enable sparse tile mode: upscale and crop lower zoom tiles instead of generating blank tiles when DEM tiles are missing.", + defaultValue: false, + }, + { + type: "standard", + flags: "--sparseResamplingMethod ", + description: + "The resampling method used for upscaling sparse tiles ('nearest', 'bilinear', or 'bicubic').", + defaultValue: "bicubic", + }, { type: "standard", flags: "-v, --verbose", diff --git a/src/sparse-tile-adapter.ts b/src/sparse-tile-adapter.ts index c2173bf..5507bcb 100644 --- a/src/sparse-tile-adapter.ts +++ b/src/sparse-tile-adapter.ts @@ -5,6 +5,8 @@ interface SparseTileResult { mimeType: string; } +type ResamplingMethod = 'nearest' | 'bilinear' | 'bicubic'; + /** * Calculate parent tile coordinates */ @@ -45,50 +47,194 @@ function getChildTileRegion( } /** - * Crop and scale a region from a parent tile to create a child tile + * Cubic interpolation kernel (Catmull-Rom / bicubic) + */ +function cubicKernel(x: number): number { + const absX = Math.abs(x); + if (absX <= 1) { + return 1.5 * absX * absX * absX - 2.5 * absX * absX + 1; + } else if (absX < 2) { + return -0.5 * absX * absX * absX + 2.5 * absX * absX - 4 * absX + 2; + } + return 0; +} + +/** + * Bicubic interpolation for a single pixel + */ +function bicubicInterpolate( + sourceData: Uint8ClampedArray, + sourceWidth: number, + sourceHeight: number, + x: number, + y: number, + channel: number +): number { + const xi = Math.floor(x); + const yi = Math.floor(y); + const dx = x - xi; + const dy = y - yi; + + let value = 0; + + // 4x4 kernel + for (let m = -1; m <= 2; m++) { + for (let n = -1; n <= 2; n++) { + const sx = Math.max(0, Math.min(sourceWidth - 1, xi + n)); + const sy = Math.max(0, Math.min(sourceHeight - 1, yi + m)); + const idx = (sy * sourceWidth + sx) * 4 + channel; + const pixel = sourceData[idx]; + + value += pixel * cubicKernel(n - dx) * cubicKernel(m - dy); + } + } + + return Math.max(0, Math.min(255, value)); +} + +/** + * Bilinear interpolation for a single pixel + */ +function bilinearInterpolate( + sourceData: Uint8ClampedArray, + sourceWidth: number, + sourceHeight: number, + x: number, + y: number, + channel: number +): number { + const x1 = Math.floor(x); + const y1 = Math.floor(y); + const x2 = Math.min(x1 + 1, sourceWidth - 1); + const y2 = Math.min(y1 + 1, sourceHeight - 1); + + const dx = x - x1; + const dy = y - y1; + + const idx11 = (y1 * sourceWidth + x1) * 4 + channel; + const idx21 = (y1 * sourceWidth + x2) * 4 + channel; + const idx12 = (y2 * sourceWidth + x1) * 4 + channel; + const idx22 = (y2 * sourceWidth + x2) * 4 + channel; + + const v11 = sourceData[idx11]; + const v21 = sourceData[idx21]; + const v12 = sourceData[idx12]; + const v22 = sourceData[idx22]; + + const v1 = v11 * (1 - dx) + v21 * dx; + const v2 = v12 * (1 - dx) + v22 * dx; + + return v1 * (1 - dy) + v2 * dy; +} + +/** + * Nearest neighbor interpolation + */ +function nearestInterpolate( + sourceData: Uint8ClampedArray, + sourceWidth: number, + sourceHeight: number, + x: number, + y: number, + channel: number +): number { + const xi = Math.round(x); + const yi = Math.round(y); + const sx = Math.max(0, Math.min(sourceWidth - 1, xi)); + const sy = Math.max(0, Math.min(sourceHeight - 1, yi)); + const idx = (sy * sourceWidth + sx) * 4 + channel; + return sourceData[idx]; +} + +/** + * Crop and scale a region from a parent tile to create a child tile with custom resampling */ async function cropAndScaleTile( parentImageData: ImageData, offsetX: number, offsetY: number, scale: number, - targetSize: number + targetSize: number, + resamplingMethod: ResamplingMethod = 'bicubic' ): Promise { const sourceSize = targetSize / scale; - // Create a canvas for the operation - const canvas = new OffscreenCanvas(targetSize, targetSize); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - throw new Error('Failed to get canvas context'); + // For canvas-based resampling (fastest but lower quality) + if (resamplingMethod === 'nearest' && scale <= 2) { + const canvas = new OffscreenCanvas(targetSize, targetSize); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + const sourceCanvas = new OffscreenCanvas(parentImageData.width, parentImageData.height); + const sourceCtx = sourceCanvas.getContext('2d'); + + if (!sourceCtx) { + throw new Error('Failed to get source canvas context'); + } + + sourceCtx.putImageData(parentImageData, 0, 0); + + ctx.imageSmoothingEnabled = false; + + ctx.drawImage( + sourceCanvas, + offsetX, offsetY, + sourceSize, sourceSize, + 0, 0, + targetSize, targetSize + ); + + return ctx.getImageData(0, 0, targetSize, targetSize); } - // Create temporary canvas for source image - const sourceCanvas = new OffscreenCanvas(parentImageData.width, parentImageData.height); - const sourceCtx = sourceCanvas.getContext('2d'); + // Custom interpolation implementation + const sourceData = parentImageData.data; + const sourceWidth = parentImageData.width; + const sourceHeight = parentImageData.height; - if (!sourceCtx) { - throw new Error('Failed to get source canvas context'); - } + const outputData = new Uint8ClampedArray(targetSize * targetSize * 4); - // Put the parent image data onto the source canvas - sourceCtx.putImageData(parentImageData, 0, 0); + const scaleX = sourceSize / targetSize; + const scaleY = sourceSize / targetSize; - // Use high-quality scaling (similar to Lanczos/bilinear interpolation) - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; + // Select interpolation function + const interpolate = resamplingMethod === 'bicubic' + ? bicubicInterpolate + : resamplingMethod === 'bilinear' + ? bilinearInterpolate + : nearestInterpolate; - // Draw the cropped and scaled region - ctx.drawImage( - sourceCanvas, - offsetX, offsetY, // Source x, y - sourceSize, sourceSize, // Source width, height - 0, 0, // Destination x, y - targetSize, targetSize // Destination width, height - ); + // Resample each pixel + for (let ty = 0; ty < targetSize; ty++) { + for (let tx = 0; tx < targetSize; tx++) { + // Map target coordinates to source coordinates + const sx = offsetX + tx * scaleX; + const sy = offsetY + ty * scaleY; + + // Check bounds + if (sx < 0 || sx >= sourceWidth || sy < 0 || sy >= sourceHeight) { + const outIdx = (ty * targetSize + tx) * 4; + outputData[outIdx] = 0; + outputData[outIdx + 1] = 0; + outputData[outIdx + 2] = 0; + outputData[outIdx + 3] = 255; + continue; + } + + const outIdx = (ty * targetSize + tx) * 4; + + // Interpolate each channel (R, G, B, A) + outputData[outIdx] = interpolate(sourceData, sourceWidth, sourceHeight, sx, sy, 0); + outputData[outIdx + 1] = interpolate(sourceData, sourceWidth, sourceHeight, sx, sy, 1); + outputData[outIdx + 2] = interpolate(sourceData, sourceWidth, sourceHeight, sx, sy, 2); + outputData[outIdx + 3] = interpolate(sourceData, sourceWidth, sourceHeight, sx, sy, 3); + } + } - return ctx.getImageData(0, 0, targetSize, targetSize); + return new ImageData(outputData, targetSize, targetSize); } /** @@ -99,9 +245,10 @@ async function fetchSparseTile( x: number, y: number, tileSize: number, - fetcher: TileFetcher, + fetcher: any, urlPattern: string, - verbose: boolean = false + resamplingMethod: ResamplingMethod = 'bicubic', + verbose: boolean = false, ): Promise { let currentZ = z; let currentX = x; @@ -135,7 +282,7 @@ async function fetchSparseTile( // Successfully fetched parent tile, now crop and scale it if (verbose) { console.log( - `[SparseTile] Found parent tile at z${currentZ}/${currentX}/${currentY}, upscaling to z${z}/${x}/${y}` + `[SparseTile] Found parent tile at z${currentZ}/${currentX}/${currentY}, upscaling to z${z}/${x}/${y} using ${resamplingMethod} resampling` ); } @@ -154,13 +301,14 @@ async function fetchSparseTile( // Calculate crop region const region = getChildTileRegion(z, x, y, currentZ, tileSize); - // Crop and scale + // Crop and scale with specified resampling method const childImageData = await cropAndScaleTile( parentImageData, region.offsetX, region.offsetY, region.scale, - tileSize + tileSize, + resamplingMethod ); return { @@ -206,5 +354,5 @@ async function imageDataToBlob(imageData: ImageData, format: string): Promise Date: Tue, 21 Oct 2025 00:20:05 -0400 Subject: [PATCH 3/3] try to add sparseMaxZoomFallback --- package-lock.json | 2 +- src/generate-contour-tile-pyramid.ts | 228 +++++++++++++----------- src/sparse-tile-adapter.ts | 257 +++++++++++---------------- 3 files changed, 238 insertions(+), 249 deletions(-) diff --git a/package-lock.json b/package-lock.json index ac82f74..dd346fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "contour-generator", - "version": "2.0.6", + "version": "2.0.7", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/src/generate-contour-tile-pyramid.ts b/src/generate-contour-tile-pyramid.ts index 1bab546..ab0b53c 100644 --- a/src/generate-contour-tile-pyramid.ts +++ b/src/generate-contour-tile-pyramid.ts @@ -107,6 +107,16 @@ program }, "bicubic", // default value ) + .option( + "--sparseMaxZoomFallback ", + "Maximum number of zoom levels to fallback when searching for sparse tiles.", + "3" + ) + .option( + "--sparseTimeoutMs ", + "Timeout in milliseconds for sparse tile fetch attempts.", + "5000" + ) .option("-v, --verbose", "Enable verbose output.") .parse(process.argv); @@ -126,6 +136,8 @@ const { blankTileFormat, useSparseMode, sparseResamplingMethod, + sparseMaxZoomFallback, + sparseTimeoutMs, verbose, } = options; @@ -137,6 +149,8 @@ const numsourceMaxZoom = Number(sourceMaxZoom); const numIncrement = Number(increment); const numoutputMaxZoom = Number(outputMaxZoom); const numblankTileSize = Number(blankTileSize); +const numSparseMaxZoomFallback = Number(sparseMaxZoomFallback); +const numSparseTimeoutMs = Number(sparseTimeoutMs); const validBlankTileFormats = ["png", "webp", "jpeg"]; if (!validBlankTileFormats.includes(blankTileFormat)) { @@ -258,7 +272,6 @@ const pmtilesFetcher: TileFetcher = async ( ); if (!zxyTileData) { - // Tile not found - check if we should use sparse mode if (useSparseMode) { if (verbose) { console.log( @@ -266,46 +279,51 @@ const pmtilesFetcher: TileFetcher = async ( ); } - const sparseResult = await fetchSparseTile( - zxy.z, - zxy.x, - zxy.y, - numblankTileSize, - pmtilesFetcher, - demUrlPattern || "", - sparseResamplingMethod, - verbose, - ); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), numSparseTimeoutMs); + + try { + const sparseResult = await fetchSparseTile( + zxy.z, + zxy.x, + zxy.y, + numblankTileSize, + pmtilesFetcher, + demUrlPattern || "", + sparseResamplingMethod, + verbose, + numSparseMaxZoomFallback + ); + clearTimeout(timeout); - if (sparseResult) { + if (sparseResult) { + if (verbose) { + console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + } + + const sourceMimeType = pmtilesMimeType || `image/${blankTileFormat}`; + const formatForSparse = sourceMimeType.split("/")[1] || blankTileFormat; + const blob = await imageDataToBlob(sparseResult.imageData, formatForSparse); + + return { + data: blob, + mimeType: sourceMimeType, + expires: undefined, + cacheControl: undefined, + }; + } + } catch (error: any) { + clearTimeout(timeout); if (verbose) { - console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + console.log(`[Fetcher] Sparse tile fetch timed out or failed: ${error.message}`); } - - // Convert ImageData to Blob - const sourceMimeType = pmtilesMimeType || `image/${blankTileFormat}`; - const formatForSparse = sourceMimeType.split("/")[1] || blankTileFormat; - const blob = await imageDataToBlob(sparseResult.imageData, formatForSparse); - - return { - data: blob, - mimeType: sourceMimeType, - expires: undefined, - cacheControl: undefined, - }; } if (verbose) { - console.log( - `[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`, - ); + console.log(`[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`); } } - // Fall back to blank tile generation - console.warn( - `DEM tile not found for ${url} (z:${zxy.z}, x:${zxy.x}, y:${zxy.y}). Generating blank tile.`, - ); const sourceMimeType = pmtilesMimeType || `image/${blankTileFormat}`; const formatForBlank = sourceMimeType.split("/")[1] || blankTileFormat; @@ -317,7 +335,7 @@ const pmtilesFetcher: TileFetcher = async ( formatForBlank as any, ); return { - data: new Blob([blankTileBuffer], { type: sourceMimeType }), + data: new Blob([new Uint8Array(blankTileBuffer)], { type: sourceMimeType }), mimeType: sourceMimeType, expires: undefined, cacheControl: undefined, @@ -369,47 +387,53 @@ const mbtilesFetcher: TileFetcher = async ( ); } - const sparseResult = await fetchSparseTile( - zxy.z, - zxy.x, - zxy.y, - numblankTileSize, - mbtilesFetcher, - demUrlPattern || "", - sparseResamplingMethod, - verbose, - ); - - if (sparseResult) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), numSparseTimeoutMs); + + try { + const sparseResult = await fetchSparseTile( + zxy.z, + zxy.x, + zxy.y, + numblankTileSize, + mbtilesFetcher, + demUrlPattern || "", + sparseResamplingMethod, + verbose, + numSparseMaxZoomFallback + ); + clearTimeout(timeout); + + if (sparseResult) { + if (verbose) { + console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + } + + const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; + const blob = await imageDataToBlob(sparseResult.imageData, sourceFormat); + const blobType = `image/${sourceFormat}`; + + return { + data: blob, + mimeType: blobType, + expires: undefined, + cacheControl: undefined, + }; + } + } catch (error: any) { + clearTimeout(timeout); if (verbose) { - console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + console.log(`[Fetcher] Sparse tile fetch timed out or failed: ${error.message}`); } - - const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; - const blob = await imageDataToBlob(sparseResult.imageData, sourceFormat); - const blobType = `image/${sourceFormat}`; - - return { - data: blob, - mimeType: blobType, - expires: undefined, - cacheControl: undefined, - }; } if (verbose) { - console.log( - `[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`, - ); + console.log(`[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`); } } // Fall back to blank tile generation - console.warn( - `DEM tile not found for ${url} (z:${zxy.z}, x:${zxy.x}, y:${zxy.y}). Generating blank tile.`, - ); const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; - const blankTileBuffer = await createBlankTileImage( numblankTileSize, numblankTileSize, @@ -419,7 +443,7 @@ const mbtilesFetcher: TileFetcher = async ( ); const blobType = `image/${sourceFormat}`; return { - data: new Blob([blankTileBuffer], { type: blobType }), + data: new Blob([new Uint8Array(blankTileBuffer)], { type: blobType }), mimeType: blobType, expires: undefined, cacheControl: undefined, @@ -431,7 +455,7 @@ const mbtilesFetcher: TileFetcher = async ( blobType = tileData.contentType; } return { - data: new Blob([tileData.data], { type: blobType }), + data: new Blob([new Uint8Array(tileData.data)], { type: blobType }), mimeType: blobType, expires: undefined, cacheControl: undefined, @@ -441,7 +465,7 @@ const mbtilesFetcher: TileFetcher = async ( error.message.includes("Tile does not exist") || error.message.includes("no such row") ) { - // Tile not found - check if we should use sparse mode + // Same sparse fallback logic as above if (useSparseMode) { if (verbose) { console.log( @@ -449,47 +473,53 @@ const mbtilesFetcher: TileFetcher = async ( ); } - const sparseResult = await fetchSparseTile( - zxy.z, - zxy.x, - zxy.y, - numblankTileSize, - mbtilesFetcher, - demUrlPattern || "", - sparseResamplingMethod, - verbose, - ); - - if (sparseResult) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), numSparseTimeoutMs); + + try { + const sparseResult = await fetchSparseTile( + zxy.z, + zxy.x, + zxy.y, + numblankTileSize, + mbtilesFetcher, + demUrlPattern || "", + sparseResamplingMethod, + verbose, + numSparseMaxZoomFallback + ); + clearTimeout(timeout); + + if (sparseResult) { + if (verbose) { + console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + } + + const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; + const blob = await imageDataToBlob(sparseResult.imageData, sourceFormat); + const blobType = `image/${sourceFormat}`; + + return { + data: blob, + mimeType: blobType, + expires: undefined, + cacheControl: undefined, + }; + } + } catch (error: any) { + clearTimeout(timeout); if (verbose) { - console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + console.log(`[Fetcher] Sparse tile fetch timed out or failed: ${error.message}`); } - - const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; - const blob = await imageDataToBlob(sparseResult.imageData, sourceFormat); - const blobType = `image/${sourceFormat}`; - - return { - data: blob, - mimeType: blobType, - expires: undefined, - cacheControl: undefined, - }; } if (verbose) { - console.log( - `[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`, - ); + console.log(`[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`); } } // Fall back to blank tile generation - console.warn( - `DEM tile not found for ${url} (z:${zxy.z}, x:${zxy.x}, y:${zxy.y}). Generating blank tile.`, - ); const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; - const blankTileBuffer = await createBlankTileImage( numblankTileSize, numblankTileSize, @@ -499,7 +529,7 @@ const mbtilesFetcher: TileFetcher = async ( ); const blobType = `image/${sourceFormat}`; return { - data: new Blob([blankTileBuffer], { type: blobType }), + data: new Blob([new Uint8Array(blankTileBuffer)], { type: blobType }), mimeType: blobType, expires: undefined, cacheControl: undefined, diff --git a/src/sparse-tile-adapter.ts b/src/sparse-tile-adapter.ts index 5507bcb..8209487 100644 --- a/src/sparse-tile-adapter.ts +++ b/src/sparse-tile-adapter.ts @@ -1,16 +1,20 @@ -// Add these helper functions for sparse tile support +// sparse-tile-adapter.ts -interface SparseTileResult { +export interface SparseTileResult { imageData: ImageData; mimeType: string; } -type ResamplingMethod = 'nearest' | 'bilinear' | 'bicubic'; +export type ResamplingMethod = "nearest" | "bilinear" | "bicubic"; /** * Calculate parent tile coordinates */ -function getParentTile(z: number, x: number, y: number): { z: number; x: number; y: number } | null { +export function getParentTile( + z: number, + x: number, + y: number, +): { z: number; x: number; y: number } | null { if (z === 0) return null; return { z: z - 1, @@ -22,23 +26,19 @@ function getParentTile(z: number, x: number, y: number): { z: number; x: number; /** * Calculate the offset and scaling needed to extract a child tile from a parent */ -function getChildTileRegion( +export function getChildTileRegion( childZ: number, childX: number, childY: number, parentZ: number, - tileSize: number + tileSize: number, ): { offsetX: number; offsetY: number; scale: number } { const zoomDiff = childZ - parentZ; const tilesPerParent = Math.pow(2, zoomDiff); - - // Calculate which quadrant/region of the parent tile contains the child const xOffset = childX % tilesPerParent; const yOffset = childY % tilesPerParent; - - // Size of each child region within the parent const regionSize = tileSize / tilesPerParent; - + return { offsetX: xOffset * regionSize, offsetY: yOffset * regionSize, @@ -53,7 +53,7 @@ function cubicKernel(x: number): number { const absX = Math.abs(x); if (absX <= 1) { return 1.5 * absX * absX * absX - 2.5 * absX * absX + 1; - } else if (absX < 2) { + } else if (absX <= 2) { return -0.5 * absX * absX * absX + 2.5 * absX * absX - 4 * absX + 2; } return 0; @@ -68,27 +68,26 @@ function bicubicInterpolate( sourceHeight: number, x: number, y: number, - channel: number + channel: number, ): number { const xi = Math.floor(x); const yi = Math.floor(y); const dx = x - xi; const dy = y - yi; - + let value = 0; - - // 4x4 kernel + for (let m = -1; m <= 2; m++) { for (let n = -1; n <= 2; n++) { const sx = Math.max(0, Math.min(sourceWidth - 1, xi + n)); const sy = Math.max(0, Math.min(sourceHeight - 1, yi + m)); const idx = (sy * sourceWidth + sx) * 4 + channel; const pixel = sourceData[idx]; - + value += pixel * cubicKernel(n - dx) * cubicKernel(m - dy); } } - + return Math.max(0, Math.min(255, value)); } @@ -101,29 +100,29 @@ function bilinearInterpolate( sourceHeight: number, x: number, y: number, - channel: number + channel: number, ): number { const x1 = Math.floor(x); const y1 = Math.floor(y); const x2 = Math.min(x1 + 1, sourceWidth - 1); const y2 = Math.min(y1 + 1, sourceHeight - 1); - + const dx = x - x1; const dy = y - y1; - + const idx11 = (y1 * sourceWidth + x1) * 4 + channel; const idx21 = (y1 * sourceWidth + x2) * 4 + channel; const idx12 = (y2 * sourceWidth + x1) * 4 + channel; const idx22 = (y2 * sourceWidth + x2) * 4 + channel; - + const v11 = sourceData[idx11]; const v21 = sourceData[idx21]; const v12 = sourceData[idx12]; const v22 = sourceData[idx22]; - + const v1 = v11 * (1 - dx) + v21 * dx; const v2 = v12 * (1 - dx) + v22 * dx; - + return v1 * (1 - dy) + v2 * dy; } @@ -136,7 +135,7 @@ function nearestInterpolate( sourceHeight: number, x: number, y: number, - channel: number + channel: number, ): number { const xi = Math.round(x); const yi = Math.round(y); @@ -149,210 +148,170 @@ function nearestInterpolate( /** * Crop and scale a region from a parent tile to create a child tile with custom resampling */ -async function cropAndScaleTile( +export async function cropAndScaleTile( parentImageData: ImageData, offsetX: number, offsetY: number, scale: number, targetSize: number, - resamplingMethod: ResamplingMethod = 'bicubic' + resamplingMethod: ResamplingMethod = "bicubic", ): Promise { const sourceSize = targetSize / scale; - - // For canvas-based resampling (fastest but lower quality) - if (resamplingMethod === 'nearest' && scale <= 2) { - const canvas = new OffscreenCanvas(targetSize, targetSize); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - throw new Error('Failed to get canvas context'); - } - - const sourceCanvas = new OffscreenCanvas(parentImageData.width, parentImageData.height); - const sourceCtx = sourceCanvas.getContext('2d'); - - if (!sourceCtx) { - throw new Error('Failed to get source canvas context'); - } - - sourceCtx.putImageData(parentImageData, 0, 0); - - ctx.imageSmoothingEnabled = false; - - ctx.drawImage( - sourceCanvas, - offsetX, offsetY, - sourceSize, sourceSize, - 0, 0, - targetSize, targetSize - ); - - return ctx.getImageData(0, 0, targetSize, targetSize); - } - - // Custom interpolation implementation const sourceData = parentImageData.data; const sourceWidth = parentImageData.width; const sourceHeight = parentImageData.height; - + const outputData = new Uint8ClampedArray(targetSize * targetSize * 4); - const scaleX = sourceSize / targetSize; const scaleY = sourceSize / targetSize; - - // Select interpolation function - const interpolate = resamplingMethod === 'bicubic' - ? bicubicInterpolate - : resamplingMethod === 'bilinear' - ? bilinearInterpolate - : nearestInterpolate; - - // Resample each pixel + + const interpolate = + resamplingMethod === "bicubic" + ? bicubicInterpolate + : resamplingMethod === "bilinear" + ? bilinearInterpolate + : nearestInterpolate; + for (let ty = 0; ty < targetSize; ty++) { for (let tx = 0; tx < targetSize; tx++) { - // Map target coordinates to source coordinates const sx = offsetX + tx * scaleX; const sy = offsetY + ty * scaleY; - - // Check bounds - if (sx < 0 || sx >= sourceWidth || sy < 0 || sy >= sourceHeight) { - const outIdx = (ty * targetSize + tx) * 4; - outputData[outIdx] = 0; - outputData[outIdx + 1] = 0; - outputData[outIdx + 2] = 0; - outputData[outIdx + 3] = 255; - continue; - } - const outIdx = (ty * targetSize + tx) * 4; - - // Interpolate each channel (R, G, B, A) - outputData[outIdx] = interpolate(sourceData, sourceWidth, sourceHeight, sx, sy, 0); - outputData[outIdx + 1] = interpolate(sourceData, sourceWidth, sourceHeight, sx, sy, 1); - outputData[outIdx + 2] = interpolate(sourceData, sourceWidth, sourceHeight, sx, sy, 2); - outputData[outIdx + 3] = interpolate(sourceData, sourceWidth, sourceHeight, sx, sy, 3); + + for (let c = 0; c < 4; c++) { + outputData[outIdx + c] = interpolate( + sourceData, + sourceWidth, + sourceHeight, + sx, + sy, + c, + ); + } } } - + return new ImageData(outputData, targetSize, targetSize); } /** * Fetch a sparse tile by looking for parent tiles and upscaling */ -async function fetchSparseTile( +export async function fetchSparseTile( z: number, x: number, y: number, tileSize: number, - fetcher: any, + fetcher: ( + url: string, + abortController: AbortController, + ) => Promise<{ data: Blob | undefined; mimeType?: string }>, urlPattern: string, - resamplingMethod: ResamplingMethod = 'bicubic', + resamplingMethod: ResamplingMethod = "bicubic", verbose: boolean = false, + maxZoomFallback: number = 3, + abortController?: AbortController, ): Promise { let currentZ = z; let currentX = x; let currentY = y; - - // Try to find a parent tile at progressively lower zoom levels - while (currentZ >= 0) { + let fallbackDepth = 0; + + while (currentZ >= 0 && fallbackDepth < maxZoomFallback) { const parent = getParentTile(currentZ, currentX, currentY); if (!parent) break; - + + fallbackDepth++; currentZ = parent.z; currentX = parent.x; currentY = parent.y; - - // Build URL for parent tile + const parentUrl = urlPattern - .replace('{z}', currentZ.toString()) - .replace('{x}', currentX.toString()) - .replace('{y}', currentY.toString()); - + .replace("{z}", currentZ.toString()) + .replace("{x}", currentX.toString()) + .replace("{y}", currentY.toString()); + if (verbose) { console.log( - `[SparseTile] Attempting to fetch parent tile z${currentZ}/${currentX}/${currentY} for child z${z}/${x}/${y}` + `[SparseTile] Attempting to fetch parent tile z${currentZ}/${currentX}/${currentY}`, ); } - + try { - const result = await fetcher(parentUrl, new AbortController()); - + const result = await fetcher( + parentUrl, + abortController || new AbortController(), + ); + if (result.data) { - // Successfully fetched parent tile, now crop and scale it if (verbose) { console.log( - `[SparseTile] Found parent tile at z${currentZ}/${currentX}/${currentY}, upscaling to z${z}/${x}/${y} using ${resamplingMethod} resampling` + `[SparseTile] Found parent tile at z${currentZ}/${currentX}/${currentY}`, ); } - - // Convert Blob to ImageData + const bitmap = await createImageBitmap(result.data); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - throw new Error('Failed to get canvas context'); - } - + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas context"); + ctx.drawImage(bitmap, 0, 0); - const parentImageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height); - - // Calculate crop region + const parentImageData = ctx.getImageData( + 0, + 0, + bitmap.width, + bitmap.height, + ); + const region = getChildTileRegion(z, x, y, currentZ, tileSize); - - // Crop and scale with specified resampling method + const childImageData = await cropAndScaleTile( parentImageData, region.offsetX, region.offsetY, region.scale, tileSize, - resamplingMethod + resamplingMethod, ); - + return { imageData: childImageData, - mimeType: result.mimeType || 'image/png', + mimeType: result.mimeType || "image/png", }; } - } catch (error) { - // Parent tile not found, continue searching at lower zoom levels + } catch (error: any) { if (verbose) { - console.log(`[SparseTile] Parent tile not found at z${currentZ}, trying lower zoom`); + console.log( + `[SparseTile] Fetch failed or timed out at z${currentZ}: ${error.message}`, + ); } } } - - // No parent tile found at any zoom level + return null; } /** * Convert ImageData to Blob */ -async function imageDataToBlob(imageData: ImageData, format: string): Promise { +export async function imageDataToBlob( + imageData: ImageData, + format: string, +): Promise { const canvas = new OffscreenCanvas(imageData.width, imageData.height); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - throw new Error('Failed to get canvas context'); - } - + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas context"); + ctx.putImageData(imageData, 0, 0); - - // Map format to MIME type - const mimeTypeMap: { [key: string]: string } = { - png: 'image/png', - webp: 'image/webp', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', + + const mimeTypeMap: Record = { + png: "image/png", + webp: "image/webp", + jpeg: "image/jpeg", + jpg: "image/jpeg", }; - - const mimeType = mimeTypeMap[format] || 'image/png'; - + + const mimeType = mimeTypeMap[format] || "image/png"; return await canvas.convertToBlob({ type: mimeType }); } -export type { SparseTileResult, ResamplingMethod }; -export { fetchSparseTile, imageDataToBlob, getParentTile, getChildTileRegion, cropAndScaleTile };