diff --git a/package-lock.json b/package-lock.json index 2d547ce..73e0205 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,14 @@ "dependencies": { "@turf/boolean-within": "^7.2.0", "geojson": "^0.5.0", + "geotiff": "^3.0.5", "jwt-decode": "^4.0.0", "jwt-encode": "^1.0.1", + "maplibre-gl": "^5.23.0", "ol": "^10.8.0", "ol-mapbox-style": "^13.4.0", "ol-pmtiles": "^2.0.2", - "pmtiles": "^4.4.0", + "pmtiles": "^4.4.1", "vue": "^3.4.15", "vue-router": "^4.2.5", "vuetify": "^3.9.5", @@ -1481,12 +1483,53 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz", + "integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==", + "license": "BSD-2-Clause" + }, "node_modules/@mapbox/unitbezier": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", "license": "BSD-2-Clause" }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz", + "integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/@maplibre/maplibre-gl-style-spec": { "version": "24.8.1", "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz", @@ -1507,6 +1550,36 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, + "node_modules/@maplibre/mlt": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz", + "integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, "node_modules/@mdi/js": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", @@ -2445,6 +2518,15 @@ "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", "license": "MIT" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.47.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", @@ -4894,6 +4976,12 @@ "giget": "dist/cli.mjs" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -5547,6 +5635,12 @@ "ts.cryptojs256": "^1.0.1" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5658,6 +5752,40 @@ "integrity": "sha512-kvsEfzvLik34BiFj+S19bv5d70l9qSdkUzrq99dvZ9d5POaLyB4vJMQmq3BoJ5D6lFG1GYnMM7o7cm5Jh8YEEg==", "license": "BSD-2-Clause" }, + "node_modules/maplibre-gl": { + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.23.0.tgz", + "integrity": "sha512-aou8YBNFS8uVtDWFWt0W/6oorfl18wt+oIA8fnXk1kivjkbtXi9gGrQvflTpwrR3hG13aWdIdbYWeN0NFMV7ag==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5819,6 +5947,12 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6332,9 +6466,9 @@ } }, "node_modules/pmtiles": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.0.tgz", - "integrity": "sha512-tCLI1C5134MR54i8izUWhse0QUtO/EC33n9yWp1N5dYLLvyc197U0fkF5gAJhq1TdWO9Tvl+9hgvFvM0fR27Zg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.1.tgz", + "integrity": "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA==", "license": "BSD-3-Clause", "dependencies": { "fflate": "^0.8.2" @@ -6391,6 +6525,12 @@ "node": ">=4" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7392,6 +7532,15 @@ "dev": true, "license": "MIT" }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/superjson": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", diff --git a/package.json b/package.json index 88c2510..8e41394 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,14 @@ "dependencies": { "@turf/boolean-within": "^7.2.0", "geojson": "^0.5.0", + "geotiff": "^3.0.5", "jwt-decode": "^4.0.0", "jwt-encode": "^1.0.1", + "maplibre-gl": "^5.23.0", "ol": "^10.8.0", "ol-mapbox-style": "^13.4.0", "ol-pmtiles": "^2.0.2", - "pmtiles": "^4.4.0", + "pmtiles": "^4.4.1", "vue": "^3.4.15", "vue-router": "^4.2.5", "vuetify": "^3.9.5", diff --git a/src/composables/useMapLibreCogProtocol.ts b/src/composables/useMapLibreCogProtocol.ts new file mode 100644 index 0000000..b168ee3 --- /dev/null +++ b/src/composables/useMapLibreCogProtocol.ts @@ -0,0 +1,86 @@ +import maplibregl from 'maplibre-gl' +import CogWorker from '../workers/cogWorker.ts?worker' + +type CogState = { + threshold: number +} + +type CogSources = { + areaUrl: string + confidenceUrl: string +} + +type PendingTile = { + resolve: (resp: { data: ArrayBuffer }) => void + reject: (err: Error) => void +} + +const POOL_SIZE = Math.max(2, Math.min(6, (navigator.hardwareConcurrency || 4) - 1)) + +let workers: Worker[] = [] +let rrIndex = 0 +let nextId = 0 +const pending = new Map() + +function parseTileUrl(url: string): { z: number; x: number; y: number } | null { + const m = url.match(/cog:\/\/[^/]+\/(\d+)\/(\d+)\/(\d+)/) + if (!m) return null + return { z: Number(m[1]), x: Number(m[2]), y: Number(m[3]) } +} + +function pickWorker(): Worker { + const w = workers[rrIndex] + rrIndex = (rrIndex + 1) % workers.length + return w +} + +export function registerCogProtocol(sources: CogSources, state: CogState) { + for (const w of workers) w.terminate() + workers = [] + for (let i = 0; i < POOL_SIZE; i++) { + const w = new CogWorker() + w.onmessage = (e: MessageEvent) => { + const { id, ok, data, error } = e.data as { + id: number + ok: boolean + data?: ArrayBuffer + error?: string + } + const p = pending.get(id) + if (!p) return + pending.delete(id) + if (ok && data) p.resolve({ data }) + else p.reject(new Error(error ?? 'cog worker failed')) + } + w.postMessage({ type: 'init', sources }) + workers.push(w) + } + + maplibregl.addProtocol('cog', (params: { url: string }) => { + return new Promise<{ data: ArrayBuffer }>((resolve, reject) => { + const parsed = parseTileUrl(params.url) + if (!parsed || workers.length === 0) { + reject(new Error(`bad cog url or worker pool empty: ${params.url}`)) + return + } + const id = nextId++ + pending.set(id, { resolve, reject }) + pickWorker().postMessage({ + type: 'tile', + id, + z: parsed.z, + x: parsed.x, + y: parsed.y, + threshold: state.threshold, + }) + }) + }) +} + +export function unregisterCogProtocol() { + maplibregl.removeProtocol('cog') + for (const w of workers) w.terminate() + workers = [] + for (const [, p] of pending) p.reject(new Error('cog protocol unregistered')) + pending.clear() +} diff --git a/src/router/index.ts b/src/router/index.ts index d264def..811758c 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -8,6 +8,11 @@ const router = createRouter({ name: 'map', component: () => import('../views/MapView.vue'), }, + { + path: '/maplibre', + name: 'maplibre', + component: () => import('../views/MapLibreGlobalView.vue'), + }, ], }) diff --git a/src/views/MapLibreGlobalView.vue b/src/views/MapLibreGlobalView.vue new file mode 100644 index 0000000..63541ab --- /dev/null +++ b/src/views/MapLibreGlobalView.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/src/workers/cogWorker.ts b/src/workers/cogWorker.ts new file mode 100644 index 0000000..e1d7502 --- /dev/null +++ b/src/workers/cogWorker.ts @@ -0,0 +1,207 @@ +import { fromUrl, Pool, type GeoTIFF } from 'geotiff' +import { areaColorScale } from '../layers/color-scales' + +const WEB_MERCATOR_HALF = 20037508.342789244 +const TILE_SIZE = 256 +const TILE_PIXELS = TILE_SIZE * TILE_SIZE +const COG_VALUE_MAX = 200 +const NODATA = 255 +const OUT_ALPHA = 0xaa + +function hexToRgb(hex: string): [number, number, number] { + return [ + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16), + ] +} + +const C0 = hexToRgb(areaColorScale[0].color) +const C1 = hexToRgb(areaColorScale[areaColorScale.length - 1].color) +const DR = C1[0] - C0[0] +const DG = C1[1] - C0[1] +const DB = C1[2] - C0[2] + +function tileBbox3857(z: number, x: number, y: number): [number, number, number, number] { + const size = (2 * WEB_MERCATOR_HALF) / Math.pow(2, z) + const xmin = -WEB_MERCATOR_HALF + x * size + const ymax = WEB_MERCATOR_HALF - y * size + return [xmin, ymax - size, xmin + size, ymax] +} + +let areaTiffPromise: Promise | null = null +let confTiffPromise: Promise | null = null +let sources: { areaUrl: string; confidenceUrl: string } | null = null +let pool: Pool | null = null + +const MAX_ATTEMPTS = 3 +const RETRY_BASE_MS = 150 + +// Large blocks so the initial IFD read + subsequent overview reads coalesce +// into a few range requests instead of hundreds. maxRanges enables +// multi-range HTTP requests where the server supports it (S3 does). +const SOURCE_OPTIONS = { + blockSize: 512 * 1024, + cacheSize: 256, + maxRanges: 20, +} + +async function openTiff(url: string): Promise { + const tiff = await fromUrl(url, SOURCE_OPTIONS as never) + // Force IFD parse up front so tile requests skip the header round-trip. + await tiff.getImage(0) + return tiff +} + +function getAreaTiff(): Promise { + if (!areaTiffPromise) { + areaTiffPromise = openTiff(sources!.areaUrl).catch((err) => { + areaTiffPromise = null + throw err + }) + } + return areaTiffPromise +} + +function getConfTiff(): Promise { + if (!confTiffPromise) { + confTiffPromise = openTiff(sources!.confidenceUrl).catch((err) => { + confTiffPromise = null + throw err + }) + } + return confTiffPromise +} + +async function renderTileOnce( + z: number, + x: number, + y: number, + threshold: number, +): Promise { + const [areaTiff, confTiff] = await Promise.all([getAreaTiff(), getConfTiff()]) + const bbox = tileBbox3857(z, x, y) + const [area, confidence] = await Promise.all([ + readBand(areaTiff, bbox), + readBand(confTiff, bbox), + ]) + const img = composite(area, confidence, threshold) + return imageDataToPng(img) +} + +async function renderTileWithRetry( + z: number, + x: number, + y: number, + threshold: number, +): Promise { + let lastErr: unknown + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + return await renderTileOnce(z, x, y, threshold) + } catch (err) { + lastErr = err + if (attempt < MAX_ATTEMPTS - 1) { + await new Promise((r) => setTimeout(r, RETRY_BASE_MS * Math.pow(2, attempt))) + } + } + } + throw lastErr +} + +async function readBand( + tiff: GeoTIFF, + bbox: [number, number, number, number], +): Promise { + const rasters = await tiff.readRasters({ + bbox, + width: TILE_SIZE, + height: TILE_SIZE, + samples: [0], + interleave: false, + fillValue: NODATA, + pool: pool ?? undefined, + }) + return (rasters as unknown as Uint8Array[])[0] +} + +function composite( + area: Uint8Array, + confidence: Uint8Array, + threshold: number, +): ImageData { + const img = new ImageData(TILE_SIZE, TILE_SIZE) + const data = img.data + const tScaled = threshold * COG_VALUE_MAX + const inv = 1 / COG_VALUE_MAX + for (let i = 0, j = 0; i < TILE_PIXELS; i++, j += 4) { + const a = area[i] + if (a === NODATA || a <= 0) continue + const c = confidence[i] + if (c === NODATA || c <= tScaled) continue + const k = a * inv + data[j] = (C0[0] + DR * k) | 0 + data[j + 1] = (C0[1] + DG * k) | 0 + data[j + 2] = (C0[2] + DB * k) | 0 + data[j + 3] = OUT_ALPHA + } + return img +} + +async function imageDataToPng(img: ImageData): Promise { + const canvas = new OffscreenCanvas(TILE_SIZE, TILE_SIZE) + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('OffscreenCanvas 2d context unavailable') + ctx.putImageData(img, 0, 0) + const blob = await canvas.convertToBlob({ type: 'image/png' }) + return blob.arrayBuffer() +} + +interface InitMsg { + type: 'init' + sources: { areaUrl: string; confidenceUrl: string } +} +interface TileMsg { + type: 'tile' + id: number + z: number + x: number + y: number + threshold: number +} +type IncomingMsg = InitMsg | TileMsg + +self.onmessage = async (e: MessageEvent) => { + const msg = e.data + if (msg.type === 'init') { + sources = msg.sources + areaTiffPromise = null + confTiffPromise = null + try { + pool = new Pool() + } catch { + pool = null + } + // Kick off both COG opens immediately so tile requests don't wait on them + void getAreaTiff().catch(() => {}) + void getConfTiff().catch(() => {}) + return + } + if (msg.type === 'tile') { + const { id, z, x, y, threshold } = msg + if (!sources) { + ;(self as unknown as Worker).postMessage({ id, ok: false, error: 'not initialized' }) + return + } + try { + const data = await renderTileWithRetry(z, x, y, threshold) + ;(self as unknown as Worker).postMessage({ id, ok: true, data }, [data]) + } catch (err) { + ;(self as unknown as Worker).postMessage({ + id, + ok: false, + error: (err as Error)?.message ?? 'cog tile failed', + }) + } + } +}