@walkthru-earth/objex 1.0.0 → 1.2.0

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.
Files changed (84) hide show
  1. package/README.md +11 -2
  2. package/dist/components/browser/FileBrowser.svelte +41 -54
  3. package/dist/components/browser/FileTreeSidebar.svelte +43 -7
  4. package/dist/components/layout/ConnectionDialog.svelte +100 -1
  5. package/dist/components/layout/Sidebar.svelte +43 -25
  6. package/dist/components/viewers/CodeViewer.svelte +23 -0
  7. package/dist/components/viewers/CogControls.svelte +208 -0
  8. package/dist/components/viewers/CogControls.svelte.d.ts +12 -0
  9. package/dist/components/viewers/CogViewer.svelte +353 -1160
  10. package/dist/components/viewers/CogViewer.svelte.d.ts +1 -1
  11. package/dist/components/viewers/DatabaseViewer.svelte +345 -37
  12. package/dist/components/viewers/MarkdownViewer.svelte +1 -1
  13. package/dist/components/viewers/TableViewer.svelte +123 -41
  14. package/dist/components/viewers/ZarrMapViewer.svelte +29 -0
  15. package/dist/components/viewers/ZarrViewer.svelte +1 -4
  16. package/dist/constants.d.ts +6 -2
  17. package/dist/constants.js +6 -2
  18. package/dist/file-icons/index.d.ts +1 -1
  19. package/dist/file-icons/index.js +12 -2
  20. package/dist/i18n/ar.js +24 -0
  21. package/dist/i18n/en.js +24 -0
  22. package/dist/i18n/index.svelte.d.ts +0 -1
  23. package/dist/i18n/index.svelte.js +0 -3
  24. package/dist/index.d.ts +11 -0
  25. package/dist/index.js +10 -0
  26. package/dist/query/engine.d.ts +20 -4
  27. package/dist/query/index.d.ts +2 -1
  28. package/dist/query/index.js +1 -0
  29. package/dist/query/source.d.ts +30 -0
  30. package/dist/query/source.js +37 -0
  31. package/dist/query/wasm.d.ts +7 -5
  32. package/dist/query/wasm.js +138 -85
  33. package/dist/storage/providers.d.ts +47 -0
  34. package/dist/storage/providers.js +160 -0
  35. package/dist/stores/connections.svelte.js +5 -31
  36. package/dist/stores/files.svelte.d.ts +2 -8
  37. package/dist/stores/files.svelte.js +5 -38
  38. package/dist/stores/query-history.svelte.js +3 -25
  39. package/dist/stores/settings.svelte.d.ts +1 -0
  40. package/dist/stores/settings.svelte.js +10 -30
  41. package/dist/stores/tabs.svelte.d.ts +9 -2
  42. package/dist/stores/tabs.svelte.js +11 -2
  43. package/dist/types.d.ts +11 -0
  44. package/dist/utils/cloud-url.d.ts +27 -0
  45. package/dist/utils/cloud-url.js +61 -0
  46. package/dist/utils/cog.d.ts +244 -0
  47. package/dist/utils/cog.js +1039 -0
  48. package/dist/utils/deck.d.ts +0 -18
  49. package/dist/utils/deck.js +0 -36
  50. package/dist/utils/export.d.ts +22 -2
  51. package/dist/utils/export.js +35 -10
  52. package/dist/utils/file-sort.d.ts +20 -0
  53. package/dist/utils/file-sort.js +41 -0
  54. package/dist/utils/geometry-type.d.ts +52 -0
  55. package/dist/utils/geometry-type.js +76 -0
  56. package/dist/utils/local-storage.d.ts +16 -0
  57. package/dist/utils/local-storage.js +37 -0
  58. package/dist/utils/markdown-sql.d.ts +1 -1
  59. package/dist/utils/markdown-sql.js +3 -4
  60. package/dist/utils/pmtiles-tile.d.ts +0 -2
  61. package/dist/utils/pmtiles-tile.js +0 -8
  62. package/dist/utils/url-state.d.ts +6 -0
  63. package/dist/utils/url-state.js +34 -26
  64. package/dist/utils/url.d.ts +13 -25
  65. package/dist/utils/url.js +17 -78
  66. package/dist/utils/zarr-tab.d.ts +22 -0
  67. package/dist/utils/zarr-tab.js +30 -0
  68. package/dist/utils/zarr.d.ts +0 -2
  69. package/dist/utils/zarr.js +73 -44
  70. package/package.json +50 -46
  71. package/dist/components/ui/tabs/index.d.ts +0 -5
  72. package/dist/components/ui/tabs/index.js +0 -7
  73. package/dist/components/ui/tabs/tabs-content.svelte +0 -17
  74. package/dist/components/ui/tabs/tabs-content.svelte.d.ts +0 -4
  75. package/dist/components/ui/tabs/tabs-list.svelte +0 -16
  76. package/dist/components/ui/tabs/tabs-list.svelte.d.ts +0 -4
  77. package/dist/components/ui/tabs/tabs-trigger.svelte +0 -20
  78. package/dist/components/ui/tabs/tabs-trigger.svelte.d.ts +0 -4
  79. package/dist/components/ui/tabs/tabs.svelte +0 -19
  80. package/dist/components/ui/tabs/tabs.svelte.d.ts +0 -4
  81. package/dist/components/viewers/MapViewer.svelte +0 -234
  82. package/dist/components/viewers/MapViewer.svelte.d.ts +0 -7
  83. package/dist/components/viewers/StyleEditorOverlay.svelte +0 -27
  84. package/dist/components/viewers/StyleEditorOverlay.svelte.d.ts +0 -7
@@ -1,529 +1,90 @@
1
1
  <script lang="ts">
2
2
  import { MapboxOverlay } from '@deck.gl/mapbox';
3
- import { COGLayer, parseCOGTileMatrixSet, proj } from '@developmentseed/deck.gl-geotiff';
4
- import { fromUrl } from 'geotiff';
5
- import { toProj4 } from 'geotiff-geokeys-to-proj4';
3
+ import { COGLayer } from '@developmentseed/deck.gl-geotiff';
4
+ import { DecoderPool, GeoTIFF } from '@developmentseed/geotiff';
6
5
  import type maplibregl from 'maplibre-gl';
7
- import proj4Lib from 'proj4';
8
6
  import { onDestroy, untrack } from 'svelte';
9
7
  import { t } from '../../i18n/index.svelte.js';
10
8
  import { tabResources } from '../../stores/tab-resources.svelte.js';
11
- import type { Tab } from '../../types';
9
+ import type { Tab } from '../../types.js';
10
+ import {
11
+ type BandConfig,
12
+ buildDataTypeLabel,
13
+ type CogInfo,
14
+ clampBounds,
15
+ cleanupNativeBitmap,
16
+ createEpsgResolver,
17
+ DEFAULT_RESCALE,
18
+ defaultBandConfig,
19
+ fitCogBounds,
20
+ inspectCogTags,
21
+ needsCustomPipelineForConfig,
22
+ normalizeCogGeotiff,
23
+ type PixelValue,
24
+ type RescaleConfig,
25
+ readPixelAtLngLat,
26
+ renderNonTiledBitmap,
27
+ resolveProj4Def,
28
+ selectCogPipeline
29
+ } from '../../utils/cog.js';
12
30
  import { buildHttpsUrl } from '../../utils/url.js';
31
+ import CogControls from './CogControls.svelte';
13
32
  import MapContainer from './map/MapContainer.svelte';
14
33
 
15
- // ─── Helpers ─────────────────────────────────────────────────────
16
-
17
- const SF_LABELS: Record<number, string> = {
18
- 1: 'uint',
19
- 2: 'int',
20
- 3: 'float',
21
- 4: 'void',
22
- 5: 'complex int',
23
- 6: 'complex float'
24
- };
25
-
26
- /**
27
- * GeoTIFF projection code → proj4 mapping for projections that
28
- * geotiff-geokeys-to-proj4 doesn't recognise (returns longlat fallback).
29
- * Key = ProjCoordTransGeoKey value from GeoTIFF spec section 6.3.3.3.
30
- */
31
- const PROJ_CT_FALLBACK: Record<number, string> = {
32
- 11: '+proj=moll', // CT_Mollweide
33
- 12: '+proj=eck4', // CT_EckertIV
34
- 13: '+proj=eck6', // CT_EckertVI
35
- 14: '+proj=vandg', // CT_VanDerGrinten
36
- 15: '+proj=robin', // CT_Robinson
37
- 16: '+proj=sinu' // CT_Sinusoidal
38
- };
39
-
40
- /**
41
- * ESRI PE String PROJECTION name → proj4 +proj parameter.
42
- * Many GeoTIFFs (especially from ArcGIS/rasterio) embed the projection
43
- * as an ESRI PE String in PCSCitationGeoKey rather than setting
44
- * ProjCoordTransGeoKey. This map handles common pseudo-cylindrical
45
- * and other projections that toProj4 can't parse.
46
- */
47
- const ESRI_PROJ_MAP: Record<string, string> = {
48
- Mollweide: '+proj=moll',
49
- Eckert_IV: '+proj=eck4',
50
- Eckert_VI: '+proj=eck6',
51
- Van_der_Grinten_I: '+proj=vandg',
52
- Robinson: '+proj=robin',
53
- Sinusoidal: '+proj=sinu',
54
- Goode_Homolosine: '+proj=igh',
55
- Winkel_Tripel: '+proj=wintri'
56
- };
57
-
58
- /**
59
- * Try to extract a proj4 string from ESRI PE String in PCSCitationGeoKey.
60
- * Returns null if no recognized projection is found.
61
- */
62
- function tryParseEsriCitation(geoKeys: Record<string, unknown>): string | null {
63
- const citation = geoKeys.PCSCitationGeoKey as string | undefined;
64
- if (!citation) return null;
65
-
66
- // Extract PROJECTION["Name"] from the ESRI PE string
67
- const match = citation.match(/PROJECTION\["([^"]+)"\]/);
68
- if (!match) return null;
69
-
70
- const projName = match[1];
71
- const proj4Proj = ESRI_PROJ_MAP[projName];
72
- if (!proj4Proj) return null;
73
-
74
- // Extract parameters
75
- const params: Record<string, number> = {};
76
- const paramRe = /PARAMETER\["([^"]+)",([\s\S]*?)\]/g;
77
- for (const m of citation.matchAll(paramRe)) {
78
- params[m[1]] = parseFloat(m[2]);
79
- }
80
-
81
- const lon0 = params.Central_Meridian ?? params.central_meridian ?? 0;
82
- const fe = params.False_Easting ?? params.false_easting ?? 0;
83
- const fn = params.False_Northing ?? params.false_northing ?? 0;
84
-
85
- return `${proj4Proj} +lon_0=${lon0} +x_0=${fe} +y_0=${fn} +datum=WGS84 +units=m +no_defs`;
86
- }
87
-
88
- /**
89
- * Custom GeoKeys parser using geotiff-geokeys-to-proj4.
90
- * Bypasses the default proj4 EPSG lookup (which fails for non-standard CRS codes
91
- * like EPSG:32767) by parsing GeoKeys directly into a proj4 definition string.
92
- *
93
- * Falls back to manual proj4 construction for user-defined projections
94
- * detected via ProjCoordTransGeoKey or ESRI PE String in PCSCitationGeoKey.
95
- */
96
- async function geoKeysParser(
97
- geoKeys: Record<string, unknown>
98
- ): Promise<proj.ProjectionInfo | null> {
99
- try {
100
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
- const projDef = toProj4(geoKeys as any);
102
- const def = projDef.proj4 as string;
103
-
104
- // Detect misdetected CRS: toProj4 returns +proj=longlat for projections
105
- // it doesn't handle. Check two sources:
106
- // 1. ProjCoordTransGeoKey (GeoTIFF standard projection codes)
107
- // 2. ESRI PE String in PCSCitationGeoKey (common in ArcGIS/rasterio output)
108
- if (def.includes('+proj=longlat')) {
109
- let corrected: string | null = null;
110
-
111
- // Try ProjCoordTransGeoKey first
112
- const ct = geoKeys.ProjCoordTransGeoKey as number | undefined;
113
- if (ct && PROJ_CT_FALLBACK[ct]) {
114
- const lon0 = (geoKeys.ProjNatOriginLongGeoKey ??
115
- geoKeys.ProjCenterLongGeoKey ??
116
- 0) as number;
117
- const fe = (geoKeys.ProjFalseEastingGeoKey ?? 0) as number;
118
- const fn = (geoKeys.ProjFalseNorthingGeoKey ?? 0) as number;
119
- corrected = `${PROJ_CT_FALLBACK[ct]} +lon_0=${lon0} +x_0=${fe} +y_0=${fn} +datum=WGS84 +units=m +no_defs`;
120
- }
121
-
122
- // Try ESRI PE String in PCSCitationGeoKey
123
- if (!corrected) {
124
- corrected = tryParseEsriCitation(geoKeys);
125
- }
126
-
127
- if (corrected) {
128
- console.log(`[COG:geokeys] corrected CRS from longlat → ${corrected}`);
129
- return {
130
- def: corrected,
131
- parsed: proj.parseCrs(corrected),
132
- coordinatesUnits: 'metre' as proj.SupportedCrsUnit
133
- };
134
- }
135
- }
136
-
137
- return {
138
- def,
139
- parsed: proj.parseCrs(def),
140
- coordinatesUnits: projDef.coordinatesUnits as proj.SupportedCrsUnit
141
- };
142
- } catch {
143
- return null;
144
- }
145
- }
146
-
147
- /**
148
- * Query the browser's WebGL MAX_TEXTURE_SIZE from a MapLibre map.
149
- * This is the hard limit for any single texture upload (canvas, tile, image source).
150
- * Falls back to 4096 (lowest common denominator for mobile GPUs).
151
- */
152
- function getMaxTextureSize(map: maplibregl.Map): number {
153
- try {
154
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
155
- const gl = (map as any).painter?.context?.gl as WebGL2RenderingContext | null;
156
- if (gl) return gl.getParameter(gl.MAX_TEXTURE_SIZE) as number;
157
- } catch {
158
- /* fallback */
159
- }
160
- return 4096;
161
- }
162
-
163
- /** Safely clamp a number to a range, treating NaN/Infinity as the fallback. */
164
- function safeClamp(v: number, lo: number, hi: number, fallback: number): number {
165
- return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : fallback;
166
- }
167
-
168
- /** Clamp geographic bounds to valid MapLibre web-Mercator range. */
169
- function clampBounds(b: { west: number; south: number; east: number; north: number }) {
170
- return {
171
- west: safeClamp(b.west, -180, 180, -180),
172
- south: safeClamp(b.south, -85.051129, 85.051129, -85.051129),
173
- east: safeClamp(b.east, -180, 180, 180),
174
- north: safeClamp(b.north, -85.051129, 85.051129, 85.051129)
175
- };
176
- }
177
-
178
- /**
179
- * Fit the map to COG bounds with responsive padding.
180
- * Uses smaller padding on mobile to zoom in closer, ensuring overviews load
181
- * properly instead of appearing black at very low zoom levels.
182
- * After fitting, bumps zoom +1 when the viewport settles at a very low level.
183
- */
184
- function fitCogBounds(
185
- map: maplibregl.Map,
186
- b: { west: number; south: number; east: number; north: number }
187
- ) {
188
- const isMobile = window.innerWidth < 640;
189
- const viewportMin = Math.min(window.innerWidth, window.innerHeight);
190
- const padding = isMobile ? 5 : Math.max(10, Math.round(viewportMin * 0.04));
191
- map.fitBounds(
192
- [
193
- [b.west, b.south],
194
- [b.east, b.north]
195
- ],
196
- { padding, maxZoom: 18, speed: 1.2, maxDuration: 2000 }
197
- );
198
- // On small screens, fitBounds settles at a zoom too low for overviews
199
- // to render (appears black). Bump zoom so the first overview tile loads.
200
- // Skip for large-extent COGs — bumping zoom at global scale pushes the
201
- // TileLayer into its finest overview, requesting hundreds of tiles.
202
- const lonSpan = b.east - b.west;
203
- const latSpan = b.north - b.south;
204
- const isLargeExtent = lonSpan > 90 || latSpan > 45;
205
- if (!isLargeExtent) {
206
- map.once('moveend', () => {
207
- const z = map.getZoom();
208
- const minZoom = isMobile ? 10 : 8;
209
- if (z < minZoom) {
210
- map.zoomTo(z + 2, { duration: 500 });
211
- }
212
- });
213
- }
214
- }
215
-
216
- /**
217
- * Fix metadata from parseCOGTileMatrixSet for projections where corner
218
- * reprojection produces NaN/extreme values (Mollweide, global EPSG:4326, etc.).
219
- * Clamps wgsBounds and wraps projectTo3857/projectToWgs84 with safe fallbacks.
220
- */
221
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
222
- function patchMetadataBounds(metadata: any) {
223
- // Clamp wgsBounds — corners of projections like Mollweide can be
224
- // outside the valid domain, producing extreme longitudes (e.g. ±277°)
225
- // or latitudes slightly outside [-90,90] (e.g. EPSG:4326 at ±90.002°).
226
- // deck.gl's lngLatToWorld asserts lat ∈ [-90,90].
227
- const wb = metadata.wgsBounds;
228
- if (wb) {
229
- metadata.wgsBounds = {
230
- lowerLeft: [
231
- safeClamp(wb.lowerLeft[0], -180, 180, -180),
232
- safeClamp(wb.lowerLeft[1], -85.051129, 85.051129, -85.051129)
233
- ],
234
- upperRight: [
235
- safeClamp(wb.upperRight[0], -180, 180, 180),
236
- safeClamp(wb.upperRight[1], -85.051129, 85.051129, 85.051129)
237
- ]
238
- };
239
- }
240
-
241
- // Wrap projectTo3857 — out-of-domain points produce NaN/Infinity in EPSG:3857
242
- const origTo3857 = metadata.projectTo3857;
243
- if (origTo3857) {
244
- metadata.projectTo3857 = (point: [number, number]) => {
245
- const r = origTo3857(point);
246
- if (Number.isFinite(r[0]) && Number.isFinite(r[1])) return r;
247
- // Sign-based edge fallback: map out-of-domain points to the
248
- // nearest edge of EPSG:3857 instead of the origin, reducing
249
- // adaptive mesh distortion for edge tiles.
250
- return [
251
- point[0] >= 0 ? 20037508.34 : -20037508.34,
252
- point[1] >= 0 ? 20037508.34 : -20037508.34
253
- ];
254
- };
255
- }
256
-
257
- // Wrap projectToWgs84 — clamp extreme lon/lat from edge reprojection
258
- const origToWgs84 = metadata.projectToWgs84;
259
- if (origToWgs84) {
260
- metadata.projectToWgs84 = (point: [number, number]) => {
261
- const r = origToWgs84(point);
262
- return [safeClamp(r[0], -180, 180, 0), safeClamp(r[1], -85.051129, 85.051129, 0)];
263
- };
264
- }
265
- }
266
-
267
- // ─── Monkey-patch COGLayer._parseGeoTIFF ─────────────────────────
268
- // The library's inferRenderPipeline throws/hangs for PI=0/1 (Gray) and
269
- // non-uint SampleFormat. For custom pipelines (getTileData/renderTile),
270
- // skip _origParse entirely and reconstruct state from our v3 GeoTIFF.
271
- // For default pipelines, _origParse runs normally with a timeout guard.
272
-
273
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
274
- let capturedV2Geotiff: any = null;
275
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
- let currentV3Tiff: any = null;
277
-
278
- // Guard against HMR re-patching: always reference the true original
279
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
280
- const _origParse = (COGLayer as any).__origParseGeoTIFF ?? COGLayer.prototype._parseGeoTIFF;
281
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
282
- (COGLayer as any).__origParseGeoTIFF = _origParse;
283
-
284
- /** Shared reconstruction: build layer state from a geotiff (v2 or v3). */
285
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
286
- async function reconstructLayerState(layer: any, geotiff: any) {
287
- const t0 = performance.now();
288
- const gkParser = layer.props.geoKeysParser;
289
-
290
- console.log('[COG:reconstruct] parsing tile matrix set...');
291
- const metadata = await parseCOGTileMatrixSet(geotiff, gkParser);
292
- patchMetadataBounds(metadata);
293
-
294
- const image = await geotiff.getImage();
295
- const imageCount = await geotiff.getImageCount();
296
- let images: unknown[] = [];
297
- for (let i = 0; i < imageCount; i++) {
298
- images.push(await geotiff.getImage(i));
299
- }
300
-
301
- const allLevels = images.map((img, i) => {
302
- const im = img as { getWidth(): number; getHeight(): number };
303
- return ` [${i}] ${im.getWidth()}×${im.getHeight()}`;
304
- });
305
- console.log(
306
- `[COG:reconstruct] ${imageCount} IFDs, ${metadata.tileMatrices.length} tile matrices\n${allLevels.join('\n')}`
307
- );
308
-
309
- // Skip overviews smaller than tile size — their tile bounds span
310
- // most of the globe and produce NaN when projected to Web Mercator.
311
- let firstValidZ = 0;
312
- for (let z = 0; z < metadata.tileMatrices.length; z++) {
313
- const img = images[images.length - 1 - z] as {
314
- getWidth(): number;
315
- getHeight(): number;
316
- };
317
- const tm = metadata.tileMatrices[z];
318
- if (img.getWidth() >= tm.tileWidth && img.getHeight() >= tm.tileHeight) {
319
- firstValidZ = z;
320
- break;
321
- }
322
- }
323
- if (firstValidZ > 0) {
324
- console.log(`[COG:reconstruct] skipping ${firstValidZ} sub-tile overviews`);
325
- metadata.tileMatrices = metadata.tileMatrices.slice(firstValidZ);
326
- images = images.slice(0, images.length - firstValidZ);
327
- }
328
-
329
- // Cap zoom levels — decompression runs synchronously on the main
330
- // thread (WASM). Too many fine levels overwhelms the browser.
331
- // Also cap by image dimension: skip levels where the overview has
332
- // more than MAX_DIM_PER_LEVEL pixels in any direction (too many tiles
333
- // would be visible at once, blocking the main thread on decode).
334
- const MAX_TILE_LEVELS = 12;
335
- const MAX_DIM_PER_LEVEL = 8_192;
336
- if (metadata.tileMatrices.length > MAX_TILE_LEVELS) {
337
- const trimmed = metadata.tileMatrices.length - MAX_TILE_LEVELS;
338
- console.log(
339
- `[COG:reconstruct] capping zoom: trimming ${trimmed} levels (>${MAX_TILE_LEVELS} max)`
340
- );
341
- metadata.tileMatrices = metadata.tileMatrices.slice(0, MAX_TILE_LEVELS);
342
- images = images.slice(images.length - MAX_TILE_LEVELS);
343
- }
344
- // Trim finest levels whose source images are too large
345
- let dimTrimCount = 0;
346
- while (images.length > 1) {
347
- const finest = images[0] as { getWidth(): number; getHeight(): number };
348
- if (finest.getWidth() <= MAX_DIM_PER_LEVEL && finest.getHeight() <= MAX_DIM_PER_LEVEL) break;
349
- dimTrimCount++;
350
- images.shift();
351
- metadata.tileMatrices.pop();
352
- }
353
- if (dimTrimCount > 0) {
354
- console.log(
355
- `[COG:reconstruct] trimmed ${dimTrimCount} finest levels (>${MAX_DIM_PER_LEVEL}px per dim)`
356
- );
357
- }
358
-
359
- const finalLevels = images.map((img, i) => {
360
- const im = img as { getWidth(): number; getHeight(): number };
361
- return ` z${i}: ${im.getWidth()}×${im.getHeight()}`;
362
- });
363
- console.log(
364
- `[COG:reconstruct] final: ${images.length} levels, ${metadata.tileMatrices.length} tile matrices\n${finalLevels.join('\n')}`
365
- );
366
-
367
- const sourceProjection = await gkParser(image.getGeoKeys());
368
- if (!sourceProjection) throw new Error('Could not determine source projection');
369
- console.log(`[COG:reconstruct] CRS: ${sourceProjection.def.substring(0, 80)}...`);
370
-
371
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
372
- const converter = proj4Lib(sourceProjection.def, 'EPSG:4326') as any;
373
- const forwardReproject = (x: number, y: number) => {
374
- const r = converter.forward([x, y], false);
375
- const lon = Number.isFinite(r[0]) ? Math.max(-180, Math.min(180, r[0])) : x >= 0 ? 180 : -180;
376
- const lat = Number.isFinite(r[1])
377
- ? Math.max(-85.051129, Math.min(85.051129, r[1]))
378
- : y >= 0
379
- ? 85.051129
380
- : -85.051129;
381
- return [lon, lat];
382
- };
383
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
384
- const inverseReproject = (x: number, y: number) => converter.inverse([x, y], false);
385
-
386
- layer.setState({
387
- metadata,
388
- forwardReproject,
389
- inverseReproject,
390
- images,
391
- defaultGetTileData: null,
392
- defaultRenderTile: null
393
- });
394
-
395
- // Trigger onGeoTIFFLoad so the component updates info panel and fits bounds.
396
- // When we skip _origParse the library never calls this callback itself.
397
- if (layer.props.onGeoTIFFLoad && metadata.wgsBounds) {
398
- const wb = metadata.wgsBounds;
399
- const geographicBounds = {
400
- west: wb.lowerLeft[0],
401
- south: wb.lowerLeft[1],
402
- east: wb.upperRight[0],
403
- north: wb.upperRight[1]
404
- };
405
- console.log(
406
- `[COG:reconstruct] triggering onGeoTIFFLoad with bounds: ` +
407
- `W${geographicBounds.west.toFixed(2)} S${geographicBounds.south.toFixed(2)} ` +
408
- `E${geographicBounds.east.toFixed(2)} N${geographicBounds.north.toFixed(2)}`
409
- );
410
- layer.props.onGeoTIFFLoad(geotiff, { projection: null, geographicBounds });
411
- }
412
-
413
- console.log(`[COG:reconstruct] done in ${(performance.now() - t0).toFixed(0)}ms`);
414
- }
415
-
416
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
417
- COGLayer.prototype._parseGeoTIFF = async function (this: any) {
418
- const hasCustomPipeline = this.props.getTileData && this.props.renderTile;
419
- console.log(
420
- `[COG:patch] _parseGeoTIFF called — customPipeline=${hasCustomPipeline}, hasV3Tiff=${!!currentV3Tiff}`
421
- );
422
-
423
- // ── Custom pipeline (Gray / float / int) ──
424
- // Skip _origParse entirely — it hangs for Gray/float COGs because
425
- // inferRenderPipeline fails internally without throwing to our catch.
426
- // Reconstruct directly from our pre-opened v3 geotiff.
427
- if (hasCustomPipeline && currentV3Tiff) {
428
- console.log('[COG:patch] custom pipeline → skipping _origParse, reconstructing from v3 tiff');
429
- try {
430
- await reconstructLayerState(this, currentV3Tiff);
431
- console.log('[COG:patch] custom pipeline reconstruction succeeded');
432
- } catch (err) {
433
- console.error('[COG:patch] custom pipeline reconstruction failed:', err);
434
- if (this.props.onError) {
435
- this.props.onError(err instanceof Error ? err : new Error(String(err)));
436
- }
437
- }
438
- return;
439
- }
440
-
441
- // ── Default pipeline (RGB / Palette / CMYK) ──
442
- // Run the library's original parser with a timeout guard.
443
- const TIMEOUT_MS = 15_000;
444
- console.log(`[COG:patch] default pipeline → running _origParse with ${TIMEOUT_MS}ms timeout`);
445
- const t0 = performance.now();
446
- try {
447
- await Promise.race([
448
- _origParse.call(this),
449
- new Promise((_, reject) =>
450
- setTimeout(() => reject(new Error('COG parsing timed out')), TIMEOUT_MS)
451
- )
452
- ]);
453
- console.log(`[COG:patch] _origParse completed in ${(performance.now() - t0).toFixed(0)}ms`);
454
- } catch (err) {
455
- console.warn(
456
- `[COG:patch] _origParse failed after ${(performance.now() - t0).toFixed(0)}ms:`,
457
- err instanceof Error ? err.message : err
458
- );
459
- // Only attempt reconstruction for custom pipeline COGs (Gray/float/int)
460
- // that have getTileData/renderTile props. Default pipeline COGs need
461
- // the library's inferRenderPipeline to set defaultGetTileData — without
462
- // it the library throws "getTileData is not a function".
463
- const hasCustomPipeline = this.props.getTileData && this.props.renderTile;
464
- const geotiff = capturedV2Geotiff || currentV3Tiff;
465
- console.log(
466
- `[COG:patch] fallback — customPipeline=${!!hasCustomPipeline}, hasV2=${!!capturedV2Geotiff}, hasV3=${!!currentV3Tiff}`
467
- );
468
- if (hasCustomPipeline && geotiff) {
469
- try {
470
- await reconstructLayerState(this, geotiff);
471
- console.log('[COG:patch] fallback reconstruction succeeded');
472
- } catch (reconstructErr) {
473
- console.error('[COG:patch] fallback reconstruction failed:', reconstructErr);
474
- if (this.props.onError) {
475
- this.props.onError(
476
- reconstructErr instanceof Error ? reconstructErr : new Error(String(reconstructErr))
477
- );
478
- }
479
- }
480
- } else if (this.props.onError) {
481
- this.props.onError(err instanceof Error ? err : new Error(String(err)));
482
- }
483
- }
484
- };
485
-
486
- // ─── Props & State ───────────────────────────────────────────────
34
+ // ─── State ───────────────────────────────────────────────────────
487
35
 
488
36
  let { tab }: { tab: Tab } = $props();
489
-
490
37
  let loading = $state(true);
491
38
  let error = $state<string | null>(null);
492
39
  let showInfo = $state(false);
40
+ let showControls = $state(false);
493
41
  let bounds = $state<[number, number, number, number] | undefined>();
494
- let cogInfo = $state<{
495
- width: number;
496
- height: number;
497
- bandCount: number;
498
- dataType: string;
499
- bounds: { west: number; south: number; east: number; north: number };
500
- downsampled?: boolean;
501
- } | null>(null);
42
+ let cogInfo = $state<CogInfo | null>(null);
43
+ let bandConfig = $state<BandConfig | null>(null);
44
+ let rescale = $state<RescaleConfig>({ ...DEFAULT_RESCALE });
45
+ // Palette-indexed COGs render through the library's Colormap module; a GPU
46
+ // rescale at that stage is cosmetic and would confuse the legend. Keep the
47
+ // slider hidden when a ColorMap tag is present.
48
+ let isPaletteIndexed = $state(false);
49
+ let pixelValue = $state<PixelValue | null>(null);
50
+ let inspecting = $state(false);
502
51
 
503
52
  let abortController = new AbortController();
504
53
  let mapRef: maplibregl.Map | null = null;
505
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
506
- let overlayRef: any = null;
54
+ let overlayRef: MapboxOverlay | null = null;
55
+ let geotiffRef: GeoTIFF | null = null;
56
+ let proj4DefRef: string | null = null;
57
+ let sampleFormatRef = 1;
58
+ let isTiledRef = true;
59
+ let clickHandlerRef: ((e: maplibregl.MapMouseEvent) => void) | null = null;
60
+ // True when the library-default uint pipeline will run. LinearRescale only
61
+ // operates on already-normalized RGB 0..1, so the slider is meaningful only
62
+ // here, and only for non-palette data (palette renders through Colormap).
63
+ // `needsCustomPipelineForConfig` only touches read-only tags on the GeoTIFF,
64
+ // safe to call outside reactive tracking.
65
+ const rescaleApplicable = $derived.by(() => {
66
+ if (!cogInfo || !bandConfig || isPaletteIndexed) return false;
67
+ const g = geotiffRef;
68
+ if (!g) return false;
69
+ return !needsCustomPipelineForConfig(g, bandConfig);
70
+ });
71
+ // Tracks whether the camera has already been framed for the current tab.
72
+ // Prevents fitCogBounds from resetting the user's view when the band/style
73
+ // config changes and the COGLayer is rebuilt.
74
+ let hasFittedOnce = false;
507
75
 
508
- // Native MapLibre image source for non-tiled GeoTIFFs (bypasses deck.gl)
509
- const BITMAP_SOURCE = 'geotiff-bitmap-src';
510
- const BITMAP_LAYER = 'geotiff-bitmap-layer';
76
+ // Main-thread decoder pool worker-based DecoderPool fails in Vite dev mode
77
+ // (ESM workers can't load through the dev server). Main-thread decoding is
78
+ // reliable across all environments. COGLayer's defaultDecoderPool() would
79
+ // create workers that crash with NS_ERROR_CORRUPTED_CONTENT in Firefox.
80
+ const pool = new DecoderPool();
511
81
 
512
- function cleanupNativeBitmap() {
513
- if (!mapRef) return;
514
- try {
515
- if (mapRef.getLayer(BITMAP_LAYER)) mapRef.removeLayer(BITMAP_LAYER);
516
- } catch {
517
- /* already removed */
518
- }
519
- try {
520
- if (mapRef.getSource(BITMAP_SOURCE)) mapRef.removeSource(BITMAP_SOURCE);
521
- } catch {
522
- /* already removed */
523
- }
524
- }
82
+ // EPSG resolver backed by the bundled `@developmentseed/epsg` WKT database.
83
+ // Avoids the library default that calls epsg.io at runtime. The CSV is
84
+ // streamed and parsed lazily on first use and cached for the session.
85
+ const epsgResolver = createEpsgResolver();
525
86
 
526
- // ─── Tab change reset ───────────────────────────────────────────
87
+ // ─── Tab change reset ────────────────────────────────────────────
527
88
 
528
89
  $effect(() => {
529
90
  if (!tab) return;
@@ -531,170 +92,182 @@ $effect(() => {
531
92
  untrack(() => {
532
93
  abortController.abort();
533
94
  abortController = new AbortController();
534
- // Remove previous overlay/sources from map before loading new COG
535
- cleanupNativeBitmap();
95
+ removeClickHandler();
96
+ if (mapRef) cleanupNativeBitmap(mapRef);
536
97
  if (mapRef && overlayRef) {
537
98
  try {
538
- mapRef.removeControl(overlayRef);
99
+ mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
539
100
  } catch {
540
- // map may already be destroyed
101
+ /* map may already be destroyed */
541
102
  }
542
103
  }
543
104
  overlayRef = null;
105
+ geotiffRef = null;
106
+ proj4DefRef = null;
544
107
  loading = true;
545
108
  error = null;
546
109
  cogInfo = null;
110
+ bandConfig = null;
111
+ rescale = { ...DEFAULT_RESCALE };
112
+ isPaletteIndexed = false;
113
+ pixelValue = null;
547
114
  bounds = undefined;
548
- capturedV2Geotiff = null;
549
- currentV3Tiff = null;
550
- // Re-trigger loading if map is already initialized (tab switch).
551
- // On first mount mapRef is null — onMapReady will handle it.
552
- if (mapRef) {
553
- loadCog(mapRef);
554
- }
115
+ hasFittedOnce = false;
116
+ showControls = false;
117
+ showInfo = false;
118
+ if (mapRef) loadCog(mapRef);
555
119
  });
556
120
  });
557
121
 
558
- // ─── Map ready ──────────────────────────────────────────────────
122
+ // ─── Map ready ───────────────────────────────────────────────────
559
123
 
560
124
  function onMapReady(map: maplibregl.Map) {
561
125
  mapRef = map;
562
126
  loadCog(map);
563
127
  }
564
128
 
129
+ // ─── Click handler for pixel inspection ──────────────────────────
130
+
131
+ function removeClickHandler() {
132
+ if (mapRef && clickHandlerRef) {
133
+ mapRef.off('click', clickHandlerRef);
134
+ clickHandlerRef = null;
135
+ }
136
+ }
137
+
138
+ function setupClickHandler(map: maplibregl.Map) {
139
+ removeClickHandler();
140
+ clickHandlerRef = async (e: maplibregl.MapMouseEvent) => {
141
+ if (!geotiffRef) return;
142
+ inspecting = true;
143
+ try {
144
+ const result = await readPixelAtLngLat(
145
+ geotiffRef,
146
+ e.lngLat.lng,
147
+ e.lngLat.lat,
148
+ proj4DefRef,
149
+ pool,
150
+ abortController.signal
151
+ );
152
+ pixelValue = result;
153
+ } catch {
154
+ pixelValue = null;
155
+ } finally {
156
+ inspecting = false;
157
+ }
158
+ };
159
+ map.on('click', clickHandlerRef);
160
+ }
161
+
162
+ // ─── Core load function ──────────────────────────────────────────
163
+
565
164
  async function loadCog(map: maplibregl.Map) {
566
165
  const signal = abortController.signal;
567
- const loadT0 = performance.now();
568
166
 
569
167
  try {
570
168
  const url = buildHttpsUrl(tab);
571
- console.group(`[COG] loadCog: ${url}`);
572
169
 
573
- // Pre-flight: read first IFD with geotiff@3 (single small range request)
574
- console.log('[COG] opening GeoTIFF via geotiff@3...');
575
- const tiffT0 = performance.now();
576
- const tiff = await fromUrl(url, {}, signal);
577
- currentV3Tiff = tiff; // expose to monkey-patch as fallback
578
- const firstImage = await tiff.getImage();
579
- if (signal.aborted) return;
580
- console.log(`[COG] first IFD loaded in ${(performance.now() - tiffT0).toFixed(0)}ms`);
170
+ // Pre-flight: read first IFD to check if tiled (single range request).
171
+ let isTiled = true;
172
+ let preflightGeotiff: GeoTIFF | undefined;
173
+ try {
174
+ preflightGeotiff = await GeoTIFF.fromUrl(url);
175
+ if (signal.aborted) return;
176
+ isTiled = preflightGeotiff.isTiled;
581
177
 
582
- // ─── v3-compatible metadata access ───
583
- // Load deferred GDAL_NODATA tag (42113) — geotiff v3 defers large/custom
584
- // TIFF tags and throws if accessed synchronously before loading.
585
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
586
- await (firstImage as any).fileDirectory?.loadValue?.(42113);
587
- const isTiled = firstImage.isTiled;
588
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
589
- const pi = (firstImage as any).fileDirectory?.actualizedFields?.get?.(262) as
590
- | number
591
- | undefined;
592
- const sfVal = firstImage.getSampleFormat();
593
- const bandCount = firstImage.getSamplesPerPixel();
594
- const bpsVal = firstImage.getBitsPerSample();
595
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
596
- const compression = (firstImage as any).fileDirectory?.Compression ?? 'unknown';
597
- const tileWidth = isTiled ? firstImage.getTileWidth() : 'N/A';
598
- const tileHeight = isTiled ? firstImage.getTileHeight() : 'N/A';
599
- const noData = firstImage.getGDALNoData();
178
+ // Validate CRS early
179
+ try {
180
+ const _crs = preflightGeotiff.crs;
181
+ void _crs;
182
+ } catch (crsErr) {
183
+ const msg = crsErr instanceof Error ? crsErr.message : String(crsErr);
184
+ error = `Unsupported CRS: ${msg}`;
185
+ loading = false;
186
+ return;
187
+ }
188
+ } catch (preflightErr) {
189
+ if (signal.aborted) return;
190
+ }
600
191
 
601
- // Routing: default pipeline = uint (SF=1) + PI >= 2 (RGB/Palette/CMYK/YCbCr/CIELab)
602
- const isUint = sfVal === 1;
603
- const isDefaultPipeline = isUint && pi !== undefined && pi >= 2;
192
+ // Store refs for pixel inspection and rebuild
193
+ if (preflightGeotiff) {
194
+ geotiffRef = preflightGeotiff;
195
+ isTiledRef = isTiled;
196
+ const tagInfo = inspectCogTags(preflightGeotiff);
197
+ sampleFormatRef = tagInfo.sampleFormat;
198
+ isPaletteIndexed = tagInfo.isPaletteIndexed;
604
199
 
605
- // Data type label for info panel
606
- const dataType = `${SF_LABELS[sfVal] ?? `sf${sfVal}`}${bpsVal ?? ''}`;
200
+ // Resolve proj4 definition for CRS conversion (pixel inspector)
201
+ try {
202
+ proj4DefRef = await resolveProj4Def(preflightGeotiff.crs, signal);
203
+ } catch {
204
+ proj4DefRef = null;
205
+ }
206
+ if (signal.aborted) return;
607
207
 
608
- console.log(
609
- `[COG] pre-flight metadata:\n` +
610
- ` dimensions: ${firstImage.getWidth()}×${firstImage.getHeight()}\n` +
611
- ` tiled: ${isTiled} (${tileWidth}×${tileHeight})\n` +
612
- ` PI: ${pi} (${pi === 0 || pi === 1 ? 'Gray' : pi === 2 ? 'RGB' : pi === 3 ? 'Palette' : `code ${pi}`})\n` +
613
- ` sampleFormat: ${sfVal} (${SF_LABELS[sfVal] ?? 'unknown'})\n` +
614
- ` bitsPerSample: ${bpsVal}\n` +
615
- ` bands: ${bandCount}\n` +
616
- ` compression: ${compression}\n` +
617
- ` noData: ${noData}\n` +
618
- ` pipeline: ${isDefaultPipeline ? 'DEFAULT (library)' : 'CUSTOM (Gray/float/int)'}\n` +
619
- ` route: ${isTiled ? (isDefaultPipeline ? 'tiled-default' : 'tiled-custom') : 'non-tiled-bitmap'}`
620
- );
208
+ // Set default band config
209
+ bandConfig = defaultBandConfig(preflightGeotiff.count, sampleFormatRef);
210
+ }
621
211
 
622
- // Compute geographic bounds with edge sampling — the library's
623
- // getGeographicBounds uses only 4 corners, which is inaccurate for
624
- // projections where edges curve (UTM at high latitudes, Mollweide,
625
- // sinusoidal). Sampling edge midpoints captures the true extent.
626
- let preFlightBounds: { west: number; south: number; east: number; north: number } | null = null;
627
- try {
628
- const boundsT0 = performance.now();
629
- const geoKeys = firstImage.getGeoKeys() as Record<string, unknown> | null;
630
- const projInfo = geoKeys ? await geoKeysParser(geoKeys) : null;
631
- if (projInfo) {
632
- const bbox = firstImage.getBoundingBox();
633
- const [x0, y0, x1, y1] = bbox;
634
- console.log(
635
- `[COG] bounds: native bbox=[${x0.toFixed(2)}, ${y0.toFixed(2)}, ${x1.toFixed(2)}, ${y1.toFixed(2)}], ` +
636
- `CRS=${String(projInfo.def).substring(0, 60)}`
637
- );
638
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
639
- const conv = proj4Lib(projInfo.def, 'EPSG:4326') as any;
640
- const N = 4; // samples per edge (including endpoints = 5 points)
641
- const pts: [number, number][] = [];
642
- for (let i = 0; i <= N; i++) {
643
- const t = i / N;
644
- pts.push([x0 + t * (x1 - x0), y0]); // bottom edge
645
- pts.push([x0 + t * (x1 - x0), y1]); // top edge
646
- pts.push([x0, y0 + t * (y1 - y0)]); // left edge
647
- pts.push([x1, y0 + t * (y1 - y0)]); // right edge
648
- }
649
- let w = 180,
650
- s = 90,
651
- e = -180,
652
- n = -90;
653
- let validCount = 0;
654
- let rejectedSamples: string[] = [];
655
- for (const [px, py] of pts) {
656
- const r = conv.forward([px, py], false);
657
- if (
658
- Number.isFinite(r[0]) &&
659
- Number.isFinite(r[1]) &&
660
- Math.abs(r[0]) <= 180 &&
661
- Math.abs(r[1]) <= 90
662
- ) {
663
- validCount++;
664
- w = Math.min(w, r[0]);
665
- e = Math.max(e, r[0]);
666
- s = Math.min(s, r[1]);
667
- n = Math.max(n, r[1]);
668
- } else if (rejectedSamples.length < 4) {
669
- rejectedSamples.push(
670
- `(${px.toFixed(2)},${py.toFixed(2)})→(${r[0]?.toFixed(2)},${r[1]?.toFixed(2)})`
671
- );
672
- }
673
- }
674
- if (w < e && s < n) {
675
- preFlightBounds = clampBounds({ west: w, south: s, east: e, north: n });
676
- }
677
- console.log(
678
- `[COG] bounds computed in ${(performance.now() - boundsT0).toFixed(0)}ms: ` +
679
- `${validCount}/${pts.length} valid points` +
680
- (preFlightBounds
681
- ? ` → W${preFlightBounds.west.toFixed(2)} S${preFlightBounds.south.toFixed(2)} E${preFlightBounds.east.toFixed(2)} N${preFlightBounds.north.toFixed(2)}`
682
- : ' → FAILED') +
683
- (rejectedSamples.length > 0
684
- ? `\n rejected samples: ${rejectedSamples.join(', ')}`
685
- : '')
686
- );
212
+ if (!isTiled && preflightGeotiff) {
213
+ // ── Non-tiled TIFF render as bitmap ──
214
+ const info = await renderNonTiledBitmap({
215
+ url,
216
+ map,
217
+ signal,
218
+ geotiff: preflightGeotiff
219
+ });
220
+ if (signal.aborted) return;
221
+ cogInfo = info;
222
+ if (!hasFittedOnce) {
223
+ bounds = [info.bounds.west, info.bounds.south, info.bounds.east, info.bounds.north];
224
+ fitCogBounds(map, info.bounds);
225
+ hasFittedOnce = true;
687
226
  }
688
- } catch (boundsErr) {
689
- console.warn('[COG] bounds computation failed:', boundsErr);
227
+ setupClickHandler(map);
228
+ loading = false;
229
+ return;
690
230
  }
231
+
232
+ // ── Tiled COG ──
233
+ buildAndAddLayer(map, preflightGeotiff, signal);
234
+ } catch (err) {
691
235
  if (signal.aborted) return;
236
+ if (err instanceof DOMException && err.name === 'AbortError') return;
237
+ error = err instanceof Error ? err.message : String(err);
238
+ loading = false;
239
+ }
240
+ }
241
+
242
+ // ─── Build & add COGLayer ────────────────────────────────────────
692
243
 
693
- // Shared onGeoTIFFLoad callback — populates info panel and fits bounds.
694
- // Also captures the library's internal v2 GeoTIFF for the monkey-patch.
695
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
696
- const handleGeoTIFFLoad = (
697
- v2tiff: any,
244
+ function buildAndAddLayer(
245
+ map: maplibregl.Map,
246
+ preflightGeotiff: GeoTIFF | undefined,
247
+ signal: AbortSignal
248
+ ) {
249
+ // Pick the library-default or one of three custom pipelines. Empty when the
250
+ // library-default uint path runs unchanged.
251
+ const customProps = preflightGeotiff
252
+ ? selectCogPipeline(preflightGeotiff, { bandConfig, rescale })
253
+ : {};
254
+
255
+ // Apply upstream-bug workarounds in place (overview filter, 4326 bbox clamp).
256
+ if (preflightGeotiff) normalizeCogGeotiff(preflightGeotiff);
257
+
258
+ const cogInput = preflightGeotiff ?? buildHttpsUrl(tab);
259
+
260
+ const layer = new COGLayer({
261
+ // Stable id per tab so rebuilds on band/style change don't force deck.gl
262
+ // to treat this as a brand-new layer and drop cached tile state.
263
+ id: `cog-layer-${tab.id}`,
264
+ geotiff: cogInput,
265
+ pool,
266
+ epsgResolver,
267
+ signal,
268
+ ...customProps,
269
+ onGeoTIFFLoad: (
270
+ loadedTiff: GeoTIFF,
698
271
  {
699
272
  geographicBounds
700
273
  }: {
@@ -702,27 +275,31 @@ async function loadCog(map: maplibregl.Map) {
702
275
  geographicBounds: { west: number; south: number; east: number; north: number };
703
276
  }
704
277
  ) => {
705
- capturedV2Geotiff = v2tiff;
706
- const clamped = preFlightBounds || clampBounds(geographicBounds);
707
- console.log(
708
- `[COG] onGeoTIFFLoad fired using ${preFlightBounds ? 'pre-flight' : 'library'} bounds, ` +
709
- `loading took ${(performance.now() - loadT0).toFixed(0)}ms`
710
- );
278
+ const clamped = clampBounds(geographicBounds);
279
+ const tags = loadedTiff.cachedTags;
280
+ const sf = tags.sampleFormat?.[0] ?? 1;
281
+ const bps = tags.bitsPerSample?.[0] ?? 8;
282
+
711
283
  cogInfo = {
712
- width: firstImage.getWidth(),
713
- height: firstImage.getHeight(),
714
- bandCount,
715
- dataType,
284
+ width: loadedTiff.width,
285
+ height: loadedTiff.height,
286
+ bandCount: loadedTiff.count,
287
+ dataType: buildDataTypeLabel(sf, bps),
716
288
  bounds: clamped
717
289
  };
718
- bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
719
- fitCogBounds(map, clamped);
290
+ // Only frame the camera on the first load of this tab. Band/style
291
+ // rebuilds re-fire onGeoTIFFLoad; refitting would clobber the user's
292
+ // current view.
293
+ if (!hasFittedOnce) {
294
+ bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
295
+ fitCogBounds(map, clamped);
296
+ hasFittedOnce = true;
297
+ }
298
+ setupClickHandler(map);
720
299
  loading = false;
721
- };
722
-
723
- // Shared error handler
724
- const handleError = (err: Error) => {
725
- if (signal.aborted) return true;
300
+ },
301
+ onError: (err: Error) => {
302
+ if (signal.aborted) return;
726
303
  const msg = err?.message || String(err);
727
304
  if (
728
305
  msg.includes('Request failed') ||
@@ -734,537 +311,79 @@ async function loadCog(map: maplibregl.Map) {
734
311
  error = msg;
735
312
  }
736
313
  loading = false;
737
- return true;
738
- };
739
-
740
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
741
- let layer: any;
742
-
743
- if (isTiled && isDefaultPipeline) {
744
- // ── Tiled COG, default pipeline (RGB / Palette / CMYK / YCbCr / CIELab) ──
745
- console.log('[COG] route: tiled-default → creating COGLayer with library pipeline');
746
- layer = new COGLayer({
747
- id: 'cog-layer',
748
- geotiff: url,
749
- geoKeysParser,
750
- onError: handleError,
751
- onGeoTIFFLoad: handleGeoTIFFLoad
752
- });
753
- } else if (isTiled) {
754
- // ── Tiled COG, custom single-band pipeline (Gray / float / int) ──
755
- // Render a moderate overview as a single bitmap via MapLibre image
756
- // source. Per-tile WASM ZSTD/LZW decode on the main thread blocks
757
- // the UI for 200-1000ms per tile, making the TileLayer approach
758
- // unusable for large Gray/float COGs (hundreds of visible tiles).
759
- // A bitmap preview loads one overview in a single async read.
760
- if (!preFlightBounds) {
761
- throw new Error('Cannot determine geographic bounds for custom-pipeline COG');
762
- }
763
- console.log('[COG] route: tiled-custom → bitmap preview from overview');
764
- const customT0 = performance.now();
765
- const maxTexDim = getMaxTextureSize(map);
766
- // Cap preview at 4096 to balance quality vs ZSTD decode time.
767
- // A 4096×2000 overview (~8M pixels) decodes in 1-3 seconds.
768
- const PREVIEW_MAX = Math.min(maxTexDim, 4096);
769
-
770
- // Find the best overview: largest that fits within PREVIEW_MAX.
771
- // Iterate from finest (IFD 0) to coarsest, pick first that fits.
772
- const imageCount = await tiff.getImageCount();
773
- let previewImage = firstImage;
774
- let previewIdx = 0;
775
- for (let i = 0; i < imageCount; i++) {
776
- const img = await tiff.getImage(i);
777
- const w = img.getWidth();
778
- const h = img.getHeight();
779
- if (w <= PREVIEW_MAX && h <= PREVIEW_MAX) {
780
- previewImage = img;
781
- previewIdx = i;
782
- break;
783
- }
784
- }
785
- if (signal.aborted) return;
786
-
787
- const pvW = previewImage.getWidth();
788
- const pvH = previewImage.getHeight();
789
- console.log(`[COG] preview: IFD #${previewIdx} ${pvW}×${pvH} (maxTex=${maxTexDim})`);
790
-
791
- const noData = firstImage.getGDALNoData();
792
- const readT0 = performance.now();
793
- const rasters = await previewImage.readRasters({
794
- samples: [0],
795
- signal
796
- });
797
- if (signal.aborted) return;
798
- console.log(`[COG] preview: readRasters took ${(performance.now() - readT0).toFixed(0)}ms`);
799
-
800
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
801
- const band = (rasters as any)[0] as ArrayLike<number>;
802
- let bMin = Infinity;
803
- let bMax = -Infinity;
804
- for (let i = 0; i < band.length; i++) {
805
- const v = band[i];
806
- if (noData !== null && v === noData) continue;
807
- if (!Number.isFinite(v)) continue;
808
- if (v < bMin) bMin = v;
809
- if (v > bMax) bMax = v;
810
- }
811
- if (!Number.isFinite(bMin)) {
812
- bMin = 0;
813
- bMax = 1;
814
- }
815
- const bRange = bMax - bMin || 1;
816
- console.log(`[COG] preview band stats: min=${bMin}, max=${bMax}, noData=${noData}`);
817
-
818
- const rgba = new Uint8ClampedArray(pvW * pvH * 4);
819
- for (let i = 0; i < band.length; i++) {
820
- const v = band[i];
821
- const isND = (noData !== null && v === noData) || !Number.isFinite(v);
822
- const g = isND ? 0 : Math.round(((v - bMin) / bRange) * 255);
823
- const idx = i * 4;
824
- rgba[idx] = g;
825
- rgba[idx + 1] = g;
826
- rgba[idx + 2] = g;
827
- rgba[idx + 3] = isND ? 0 : 255;
828
- }
829
-
830
- const canvas = document.createElement('canvas');
831
- canvas.width = pvW;
832
- canvas.height = pvH;
833
- const ctx = canvas.getContext('2d')!;
834
- ctx.putImageData(new ImageData(rgba, pvW, pvH), 0, 0);
835
- const dataUrl = canvas.toDataURL();
836
- canvas.width = 0;
837
- canvas.height = 0;
838
-
839
- const clamped = preFlightBounds;
840
- cogInfo = {
841
- width: firstImage.getWidth(),
842
- height: firstImage.getHeight(),
843
- bandCount,
844
- dataType,
845
- bounds: clamped,
846
- downsampled: true
847
- };
848
- bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
849
- fitCogBounds(map, clamped);
850
-
851
- cleanupNativeBitmap();
852
- map.addSource(BITMAP_SOURCE, {
853
- type: 'image',
854
- url: dataUrl,
855
- coordinates: [
856
- [clamped.west, clamped.north],
857
- [clamped.east, clamped.north],
858
- [clamped.east, clamped.south],
859
- [clamped.west, clamped.south]
860
- ]
861
- });
862
- map.addLayer({
863
- id: BITMAP_LAYER,
864
- source: BITMAP_SOURCE,
865
- type: 'raster',
866
- paint: { 'raster-opacity': 1 }
867
- });
868
-
869
- loading = false;
870
- layer = null;
871
- console.log(`[COG] preview rendered in ${(performance.now() - customT0).toFixed(0)}ms`);
872
- } else {
873
- // ── Non-tiled TIFF — render as bitmap ──
874
- console.log('[COG] route: non-tiled-bitmap');
875
- // GeoTIFFLayer is broken: it passes `texture` to RasterLayer but
876
- // RasterLayer expects `renderPipeline`, causing a Symbol.iterator
877
- // crash in MeshTextureLayer. Read the raster ourselves instead.
878
- if (!preFlightBounds) {
879
- throw new Error('Cannot determine geographic bounds for non-tiled GeoTIFF');
880
- }
881
-
882
- const imgW = firstImage.getWidth();
883
- const imgH = firstImage.getHeight();
884
- const totalPixels = imgW * imgH;
885
-
886
- // Size gates — non-tiled TIFFs are read as a single strip-based
887
- // blob (no random tile access). Protect against OOM / browser hang.
888
- const MAX_NONTILED_PIXELS = 100_000_000; // 100M — refuse above
889
- // Use actual GPU texture limit instead of a hardcoded value.
890
- // High-end desktop GPUs: 16384, mobile/integrated: 4096–8192.
891
- const maxTexDim = getMaxTextureSize(map);
892
-
893
- console.log(
894
- `[COG] non-tiled: ${imgW}×${imgH} = ${(totalPixels / 1e6).toFixed(1)}M px, maxTex=${maxTexDim}`
895
- );
896
-
897
- if (totalPixels > MAX_NONTILED_PIXELS) {
898
- console.warn(
899
- `[COG] non-tiled: REFUSED — ${(totalPixels / 1e6).toFixed(0)}M px > ${MAX_NONTILED_PIXELS / 1e6}M limit`
900
- );
901
- const clamped = preFlightBounds;
902
- cogInfo = { width: imgW, height: imgH, bandCount, dataType, bounds: clamped };
903
- bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
904
- fitCogBounds(map, clamped);
905
- throw new Error(
906
- `Non-tiled GeoTIFF too large (${imgW.toLocaleString()} × ${imgH.toLocaleString()} = ` +
907
- `${(totalPixels / 1e6).toFixed(0)}M pixels). Convert to COG: ` +
908
- `gdal_translate -of COG input.tif output.tif`
909
- );
910
- }
911
-
912
- // Cap output to GPU texture limit — keeps RGBA array + canvas within
913
- // what this browser/device can actually upload as a single texture.
914
- const needsDownsample = imgW > maxTexDim || imgH > maxTexDim;
915
- let readW = imgW;
916
- let readH = imgH;
917
- if (needsDownsample) {
918
- const scale = Math.min(maxTexDim / imgW, maxTexDim / imgH);
919
- readW = Math.max(1, Math.round(imgW * scale));
920
- readH = Math.max(1, Math.round(imgH * scale));
921
- }
922
-
923
- if (needsDownsample) {
924
- console.log(`[COG] non-tiled: downsampling ${imgW}×${imgH} → ${readW}×${readH}`);
925
- }
926
-
927
- const readT0 = performance.now();
928
- const noData = firstImage.getGDALNoData();
929
- const rasters = await firstImage.readRasters({
930
- samples: [0],
931
- signal,
932
- ...(needsDownsample ? { width: readW, height: readH } : {})
933
- });
934
- if (signal.aborted) return;
935
- console.log(`[COG] non-tiled: readRasters took ${(performance.now() - readT0).toFixed(0)}ms`);
936
-
937
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
938
- const band = (rasters as any)[0] as ArrayLike<number>;
939
- let bMin = Infinity;
940
- let bMax = -Infinity;
941
- for (let i = 0; i < band.length; i++) {
942
- const v = band[i];
943
- if (noData !== null && v === noData) continue;
944
- if (!Number.isFinite(v)) continue;
945
- if (v < bMin) bMin = v;
946
- if (v > bMax) bMax = v;
947
- }
948
- if (!Number.isFinite(bMin)) {
949
- bMin = 0;
950
- bMax = 1;
951
- }
952
- const bRange = bMax - bMin || 1;
953
- console.log('[COG] non-tiled band 0 stats:', {
954
- bMin,
955
- bMax,
956
- bRange,
957
- noData,
958
- len: band.length,
959
- readW,
960
- readH,
961
- maxTexDim,
962
- downsampled: needsDownsample
963
- });
964
-
965
- const rgba = new Uint8ClampedArray(readW * readH * 4);
966
- for (let i = 0; i < band.length; i++) {
967
- const v = band[i];
968
- const isND = (noData !== null && v === noData) || !Number.isFinite(v);
969
- const g = isND ? 0 : Math.round(((v - bMin) / bRange) * 255);
970
- const idx = i * 4;
971
- rgba[idx] = g;
972
- rgba[idx + 1] = g;
973
- rgba[idx + 2] = g;
974
- rgba[idx + 3] = isND ? 0 : 255;
975
- }
976
-
977
- // Render via MapLibre native image source — bypasses deck.gl
978
- // entirely, avoiding WebGL texture upload issues in Firefox.
979
- const canvas = document.createElement('canvas');
980
- canvas.width = readW;
981
- canvas.height = readH;
982
- const ctx = canvas.getContext('2d')!;
983
- ctx.putImageData(new ImageData(rgba, readW, readH), 0, 0);
984
- const dataUrl = canvas.toDataURL();
985
- canvas.width = 0;
986
- canvas.height = 0;
987
-
988
- const clamped = preFlightBounds;
989
- cogInfo = {
990
- width: imgW,
991
- height: imgH,
992
- bandCount,
993
- dataType,
994
- bounds: clamped,
995
- downsampled: needsDownsample
996
- };
997
- bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
998
- fitCogBounds(map, clamped);
999
-
1000
- cleanupNativeBitmap();
1001
- map.addSource(BITMAP_SOURCE, {
1002
- type: 'image',
1003
- url: dataUrl,
1004
- coordinates: [
1005
- [clamped.west, clamped.north], // top-left
1006
- [clamped.east, clamped.north], // top-right
1007
- [clamped.east, clamped.south], // bottom-right
1008
- [clamped.west, clamped.south] // bottom-left
1009
- ]
1010
- });
1011
- map.addLayer({
1012
- id: BITMAP_LAYER,
1013
- source: BITMAP_SOURCE,
1014
- type: 'raster',
1015
- paint: { 'raster-opacity': 1 }
1016
- });
1017
-
1018
- loading = false;
1019
- layer = null; // no deck.gl layer needed
1020
314
  }
315
+ });
1021
316
 
1022
- // Attach deck.gl overlay to the map (skip for native bitmap path)
1023
- if (layer) {
1024
- console.log('[COG] attaching deck.gl overlay to map...');
1025
- const overlay = new MapboxOverlay({
1026
- interleaved: false,
1027
- layers: [layer],
1028
- onError: (err: Error) => {
1029
- if (!error && !signal.aborted) {
1030
- error = err?.message || String(err);
1031
- loading = false;
1032
- }
1033
- }
1034
- });
1035
- overlayRef = overlay;
1036
-
1037
- if (map.loaded()) {
1038
- map.addControl(overlay as unknown as maplibregl.IControl);
1039
- } else {
1040
- map.once('load', () => map.addControl(overlay as unknown as maplibregl.IControl));
317
+ const overlay = new MapboxOverlay({
318
+ interleaved: false,
319
+ layers: [layer],
320
+ onError: (err: Error) => {
321
+ if (signal.aborted) return;
322
+ if (!error) {
323
+ error = err?.message || String(err);
324
+ loading = false;
1041
325
  }
1042
- console.log('[COG] overlay attached');
1043
326
  }
1044
- console.log(`[COG] loadCog completed in ${(performance.now() - loadT0).toFixed(0)}ms`);
1045
- console.groupEnd();
1046
- } catch (err) {
1047
- if (signal.aborted) return;
1048
- console.error(`[COG] loadCog failed after ${(performance.now() - loadT0).toFixed(0)}ms:`, err);
1049
- console.groupEnd();
1050
- error = err instanceof Error ? err.message : String(err);
1051
- loading = false;
1052
- }
327
+ });
328
+ overlayRef = overlay;
329
+ map.addControl(overlay as unknown as maplibregl.IControl);
1053
330
  }
1054
331
 
1055
- // ─── Custom single-band COGLayer builder ────────────────────────
1056
- // Uses our geotiff@3 pre-flight to compute min/max stats for normalization,
1057
- // then passes the URL to COGLayer so the library opens its own v2 GeoTIFF.
1058
- // The monkey-patched _parseGeoTIFF catches inferRenderPipeline's throw
1059
- // and reconstructs state from the captured v2 GeoTIFF.
332
+ // ─── Rebuild layer on band config change ─────────────────────────
1060
333
 
1061
- async function buildCustomCogLayer(
1062
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1063
- tiff: any,
1064
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1065
- firstImage: any,
1066
- url: string,
1067
- signal: AbortSignal,
1068
- onError: (err: Error) => boolean,
1069
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1070
- onGeoTIFFLoad: any
1071
- ) {
1072
- // Compute global min/max from a small overview for normalization
1073
- const imageCount = await tiff.getImageCount();
1074
- console.log(`[COG:custom] ${imageCount} IFDs, searching for stats overview (64–1024px wide)...`);
1075
- let statsImage = firstImage;
1076
- let statsIfdIdx = 0;
1077
- for (let i = imageCount - 1; i >= 1; i--) {
1078
- const img = await tiff.getImage(i);
1079
- const w = img.getWidth();
1080
- if (w >= 64 && w <= 1024) {
1081
- statsImage = img;
1082
- statsIfdIdx = i;
1083
- break;
334
+ function handleConfigChange(newConfig: BandConfig) {
335
+ bandConfig = newConfig;
336
+ if (!mapRef || !geotiffRef || !isTiledRef) return;
337
+
338
+ // Remove old overlay
339
+ if (overlayRef) {
340
+ try {
341
+ mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
342
+ } catch {
343
+ /* already removed */
1084
344
  }
345
+ overlayRef = null;
1085
346
  }
1086
- console.log(
1087
- `[COG:custom] stats from IFD #${statsIfdIdx}: ${statsImage.getWidth()}×${statsImage.getHeight()}`
1088
- );
1089
- if (signal.aborted) return null;
1090
347
 
1091
- const noData = firstImage.getGDALNoData();
1092
-
1093
- // For large images without a suitable overview, sample a center crop
1094
- let statsWindow: [number, number, number, number] | undefined;
1095
- if (statsImage.getWidth() > 1024) {
1096
- const cx = Math.floor(statsImage.getWidth() / 2);
1097
- const cy = Math.floor(statsImage.getHeight() / 2);
1098
- const half = 512;
1099
- statsWindow = [
1100
- Math.max(0, cx - half),
1101
- Math.max(0, cy - half),
1102
- Math.min(statsImage.getWidth(), cx + half),
1103
- Math.min(statsImage.getHeight(), cy + half)
1104
- ];
1105
- }
348
+ // Rebuild with new config
349
+ buildAndAddLayer(mapRef, geotiffRef, abortController.signal);
350
+ }
1106
351
 
1107
- const rasters = await statsImage.readRasters({
1108
- samples: [0],
1109
- window: statsWindow,
1110
- signal
1111
- });
1112
- if (signal.aborted) return null;
352
+ function handleRescaleChange(next: RescaleConfig) {
353
+ rescale = next;
354
+ if (!mapRef || !geotiffRef || !isTiledRef) return;
1113
355
 
1114
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1115
- const statsBand = (rasters as any)[0] as ArrayLike<number>;
1116
- let min = Infinity;
1117
- let max = -Infinity;
1118
- for (let i = 0; i < statsBand.length; i++) {
1119
- const v = statsBand[i];
1120
- if (noData !== null && v === noData) continue;
1121
- if (!Number.isFinite(v)) continue;
1122
- if (v < min) min = v;
1123
- if (v > max) max = v;
1124
- }
1125
- if (!Number.isFinite(min)) {
1126
- min = 0;
1127
- max = 1;
1128
- }
1129
- const range = max - min || 1;
1130
- console.log(`[COG:custom] band stats: min=${min}, max=${max}, range=${range}, noData=${noData}`);
1131
-
1132
- // Concurrency limiter — ZSTD/LZW decode is synchronous WASM on the
1133
- // main thread. Without a cap, deck.gl fires dozens of tile requests
1134
- // at once, each blocking for 100-300ms, completely freezing the UI.
1135
- const MAX_CONCURRENT_TILES = 1;
1136
- let activeTiles = 0;
1137
- const tileQueue: (() => void)[] = [];
1138
- function acquireTileSlot(): Promise<void> {
1139
- if (activeTiles < MAX_CONCURRENT_TILES) {
1140
- activeTiles++;
1141
- return Promise.resolve();
1142
- }
1143
- return new Promise((resolve) => tileQueue.push(resolve));
1144
- }
1145
- function releaseTileSlot() {
1146
- const next = tileQueue.shift();
1147
- if (next) {
1148
- next(); // keep activeTiles the same — slot transfers
1149
- } else {
1150
- activeTiles--;
356
+ // Remove old overlay and rebuild. deck.gl diffs on layer id, so reusing the
357
+ // stable per-tab id keeps tile cache state where possible.
358
+ if (overlayRef) {
359
+ try {
360
+ mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
361
+ } catch {
362
+ /* already removed */
1151
363
  }
364
+ overlayRef = null;
1152
365
  }
1153
-
1154
- // Lazy cache: v3 images are loaded on-demand per zoom level.
1155
- // geotiff v3 supports modern codecs (Zstandard 50000, WebP 50001) that
1156
- // the library's bundled v2 does not. No Pool — avoids worker module
1157
- // resolution failures in Vite dev that cause the browser to hang.
1158
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1159
- const v3ImageCache = new Map<string, any>();
1160
-
1161
- // Pass URL so the library opens its own v2 GeoTIFF for metadata/tile-matrix.
1162
- // Custom getTileData uses v3 images (which support more compression methods).
1163
- return new COGLayer({
1164
- id: 'cog-layer',
1165
- geotiff: url,
1166
- geoKeysParser,
1167
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1168
- getTileData: async (image: any, options: any) => {
1169
- const { window: win, signal: tileSig } = options;
1170
- const tileT0 = performance.now();
1171
- const winLabel = win ? `[${win[0]},${win[1]}→${win[2]},${win[3]}]` : 'full';
1172
- const key = `${image.getWidth()}x${image.getHeight()}`;
1173
-
1174
- // Wait for a decode slot — limits main-thread blocking
1175
- await acquireTileSlot();
1176
- try {
1177
- // Yield two animation frames before each tile decompression.
1178
- // ZSTD/LZW decode via geotiff is synchronous WASM on the main
1179
- // thread. Two rAF cycles guarantee the browser paints at least
1180
- // one frame and processes input between tile decodes.
1181
- await new Promise<void>((r) =>
1182
- requestAnimationFrame(() => requestAnimationFrame(() => r()))
1183
- );
1184
- if (tileSig?.aborted) return null;
1185
-
1186
- // Lazily find/cache the matching v3 image by dimensions
1187
- let v3Img = v3ImageCache.get(key);
1188
- if (!v3Img) {
1189
- const count = await tiff.getImageCount();
1190
- for (let i = 0; i < count; i++) {
1191
- const img = await tiff.getImage(i); // cached by geotiff.js
1192
- const k = `${img.getWidth()}x${img.getHeight()}`;
1193
- v3ImageCache.set(k, img);
1194
- if (k === key) {
1195
- v3Img = img;
1196
- break;
1197
- }
1198
- }
1199
- }
1200
-
1201
- // Read band 0 — no Pool (main-thread async decode avoids worker hangs)
1202
- const readT0 = performance.now();
1203
- const r = await (v3Img || image).readRasters({
1204
- samples: [0],
1205
- window: win,
1206
- signal: tileSig,
1207
- interleave: false,
1208
- // Use v2 pool only when falling back to v2 image
1209
- ...(v3Img ? {} : { pool: options.pool })
1210
- });
1211
- const readMs = performance.now() - readT0;
1212
-
1213
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1214
- const band = (r as any)[0] as ArrayLike<number>;
1215
- const w = win ? win[2] - win[0] : image.getWidth();
1216
- const h = win ? win[3] - win[1] : image.getHeight();
1217
- const rgba = new Uint8ClampedArray(w * h * 4);
1218
-
1219
- const rgbaT0 = performance.now();
1220
- for (let i = 0; i < band.length; i++) {
1221
- const v = band[i];
1222
- const isND = (noData !== null && v === noData) || !Number.isFinite(v);
1223
- const g = isND ? 0 : Math.round(((v - min) / range) * 255);
1224
- const idx = i * 4;
1225
- rgba[idx] = g;
1226
- rgba[idx + 1] = g;
1227
- rgba[idx + 2] = g;
1228
- rgba[idx + 3] = isND ? 0 : 255;
1229
- }
1230
- const rgbaMs = performance.now() - rgbaT0;
1231
-
1232
- const totalMs = performance.now() - tileT0;
1233
- console.log(
1234
- `[COG:tile] ${key} ${winLabel} ${w}×${h} — ` +
1235
- `read=${readMs.toFixed(0)}ms rgba=${rgbaMs.toFixed(0)}ms total=${totalMs.toFixed(0)}ms ` +
1236
- `queue=${tileQueue.length} active=${activeTiles}` +
1237
- (v3Img ? ' (v3)' : ' (v2-fallback)')
1238
- );
1239
-
1240
- return { texture: new ImageData(rgba, w, h), width: w, height: h };
1241
- } finally {
1242
- releaseTileSlot();
1243
- }
1244
- },
1245
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1246
- renderTile: (data: any) => data.texture,
1247
- onError,
1248
- onGeoTIFFLoad
1249
- });
366
+ buildAndAddLayer(mapRef, geotiffRef, abortController.signal);
1250
367
  }
1251
368
 
1252
- // ─── Cleanup ────────────────────────────────────────────────────
369
+ // ─── Cleanup ─────────────────────────────────────────────────────
1253
370
 
1254
371
  function cleanup() {
1255
372
  abortController.abort();
1256
- cleanupNativeBitmap();
373
+ removeClickHandler();
374
+ if (mapRef) cleanupNativeBitmap(mapRef);
1257
375
  if (mapRef && overlayRef) {
1258
376
  try {
1259
- mapRef.removeControl(overlayRef);
377
+ mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
1260
378
  } catch {
1261
- // map may already be destroyed
379
+ /* map may already be destroyed */
1262
380
  }
1263
381
  }
1264
382
  mapRef = null;
1265
383
  overlayRef = null;
1266
- capturedV2Geotiff = null;
1267
- currentV3Tiff = null;
384
+ geotiffRef = null;
385
+ proj4DefRef = null;
386
+ pixelValue = null;
1268
387
  }
1269
388
 
1270
389
  $effect(() => {
@@ -1280,6 +399,7 @@ onDestroy(cleanup);
1280
399
  <MapContainer {onMapReady} {bounds} />
1281
400
  </div>
1282
401
 
402
+ <!-- Top-left: Loading + metadata badges -->
1283
403
  <div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col gap-1">
1284
404
  {#if loading}
1285
405
  <div
@@ -1310,19 +430,48 @@ onDestroy(cleanup);
1310
430
  {/if}
1311
431
  </div>
1312
432
 
433
+ <!-- Top-right: Info + Style buttons -->
1313
434
  {#if cogInfo}
1314
-
1315
435
  <div class="absolute right-2 top-2 z-10 flex gap-1">
436
+ {#if bandConfig}
437
+ <button
438
+ class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
439
+ class:ring-1={showControls}
440
+ class:ring-primary={showControls}
441
+ onclick={() => {
442
+ showControls = !showControls;
443
+ if (showControls) showInfo = false;
444
+ }}
445
+ >
446
+ {t('cog.style')}
447
+ </button>
448
+ {/if}
1316
449
  <button
1317
450
  class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
1318
451
  class:ring-1={showInfo}
1319
452
  class:ring-primary={showInfo}
1320
- onclick={() => (showInfo = !showInfo)}
453
+ onclick={() => {
454
+ showInfo = !showInfo;
455
+ if (showInfo) showControls = false;
456
+ }}
1321
457
  >
1322
458
  {t('map.info')}
1323
459
  </button>
1324
460
  </div>
1325
461
 
462
+ <!-- Band/Color controls panel -->
463
+ {#if showControls && bandConfig}
464
+ <CogControls
465
+ bandCount={cogInfo.bandCount}
466
+ {bandConfig}
467
+ onConfigChange={handleConfigChange}
468
+ {rescale}
469
+ rescaleApplicable={rescaleApplicable}
470
+ onRescaleChange={handleRescaleChange}
471
+ />
472
+ {/if}
473
+
474
+ <!-- Info panel -->
1326
475
  {#if showInfo}
1327
476
  <div
1328
477
  class="absolute right-2 top-10 z-10 max-h-[70vh] w-64 overflow-auto rounded bg-card/90 p-3 text-xs text-card-foreground backdrop-blur-sm"
@@ -1335,11 +484,55 @@ onDestroy(cleanup);
1335
484
  <dd>{cogInfo.bandCount} ({cogInfo.dataType})</dd>
1336
485
  <dt class="text-muted-foreground">{t('mapInfo.bounds')}</dt>
1337
486
  <dd>
1338
- W {cogInfo.bounds.west.toFixed(4)}, S {cogInfo.bounds.south.toFixed(4)}<br />
487
+ W {cogInfo.bounds.west.toFixed(4)}, S {cogInfo.bounds.south.toFixed(4)}<br
488
+ />
1339
489
  E {cogInfo.bounds.east.toFixed(4)}, N {cogInfo.bounds.north.toFixed(4)}
1340
490
  </dd>
1341
491
  </dl>
1342
492
  </div>
1343
493
  {/if}
1344
494
  {/if}
495
+
496
+ <!-- Bottom-left: Pixel value on click -->
497
+ {#if pixelValue}
498
+ <div
499
+ class="absolute bottom-2 left-2 z-10 rounded bg-card/90 p-2.5 text-xs text-card-foreground backdrop-blur-sm"
500
+ >
501
+ <div class="mb-1 flex items-center justify-between gap-3">
502
+ <span class="font-medium">{t('cog.pixelValue')}</span>
503
+ <button
504
+ class="text-muted-foreground hover:text-card-foreground"
505
+ onclick={() => (pixelValue = null)}
506
+ >
507
+ &times;
508
+ </button>
509
+ </div>
510
+ <div class="space-y-0.5 text-muted-foreground">
511
+ <div>
512
+ {pixelValue.lat.toFixed(6)}&deg;, {pixelValue.lng.toFixed(6)}&deg;
513
+ </div>
514
+ <div class="text-[10px]">
515
+ px ({pixelValue.col}, {pixelValue.row})
516
+ </div>
517
+ </div>
518
+ <div class="mt-1.5 space-y-0.5">
519
+ {#each pixelValue.values as val, i}
520
+ <div class="flex justify-between gap-2">
521
+ <span class="text-muted-foreground">{t('cog.band')} {i + 1}</span>
522
+ <span class="font-mono tabular-nums">
523
+ {Number.isInteger(val) ? val : val.toFixed(4)}
524
+ </span>
525
+ </div>
526
+ {/each}
527
+ </div>
528
+ </div>
529
+ {/if}
530
+
531
+ {#if inspecting}
532
+ <div
533
+ class="pointer-events-none absolute bottom-2 left-2 z-10 rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm"
534
+ >
535
+ {t('cog.reading')}
536
+ </div>
537
+ {/if}
1345
538
  </div>