@walkthru-earth/objex 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/components/browser/FileBrowser.svelte +25 -14
- package/dist/components/browser/FileTreeSidebar.svelte +43 -7
- package/dist/components/layout/ConnectionDialog.svelte +100 -1
- package/dist/components/layout/Sidebar.svelte +70 -25
- package/dist/components/viewers/ArchiveViewer.svelte +4 -4
- package/dist/components/viewers/CodeViewer.svelte +44 -5
- package/dist/components/viewers/CogControls.svelte +208 -0
- package/dist/components/viewers/CogControls.svelte.d.ts +12 -0
- package/dist/components/viewers/CogViewer.svelte +373 -1162
- package/dist/components/viewers/CogViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/CopcViewer.svelte +20 -2
- package/dist/components/viewers/DatabaseViewer.svelte +345 -37
- package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
- package/dist/components/viewers/MarkdownViewer.svelte +1 -1
- package/dist/components/viewers/PmtilesViewer.svelte +2 -2
- package/dist/components/viewers/StacMapViewer.svelte +25 -9
- package/dist/components/viewers/TableViewer.svelte +162 -51
- package/dist/components/viewers/ZarrMapViewer.svelte +33 -4
- package/dist/components/viewers/ZarrViewer.svelte +3 -6
- package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
- package/dist/constants.d.ts +6 -2
- package/dist/constants.js +6 -2
- package/dist/file-icons/index.d.ts +1 -1
- package/dist/file-icons/index.js +12 -2
- package/dist/i18n/ar.js +25 -0
- package/dist/i18n/en.js +25 -0
- package/dist/i18n/index.svelte.d.ts +0 -1
- package/dist/i18n/index.svelte.js +0 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/query/engine.d.ts +20 -4
- package/dist/query/index.d.ts +2 -1
- package/dist/query/index.js +1 -0
- package/dist/query/source.d.ts +42 -0
- package/dist/query/source.js +54 -0
- package/dist/query/wasm.d.ts +7 -5
- package/dist/query/wasm.js +267 -107
- package/dist/storage/adapter.d.ts +9 -0
- package/dist/storage/adapter.js +13 -1
- package/dist/storage/browser-azure.d.ts +1 -1
- package/dist/storage/browser-azure.js +4 -0
- package/dist/storage/browser-cloud.d.ts +1 -1
- package/dist/storage/browser-cloud.js +7 -0
- package/dist/storage/presign.d.ts +13 -0
- package/dist/storage/presign.js +55 -0
- package/dist/storage/providers.d.ts +53 -0
- package/dist/storage/providers.js +171 -0
- package/dist/stores/browser.svelte.d.ts +2 -0
- package/dist/stores/browser.svelte.js +17 -1
- package/dist/stores/files.svelte.d.ts +1 -2
- package/dist/stores/files.svelte.js +1 -2
- package/dist/stores/tabs.svelte.d.ts +9 -2
- package/dist/stores/tabs.svelte.js +11 -2
- package/dist/types.d.ts +11 -0
- package/dist/utils/cog.d.ts +244 -0
- package/dist/utils/cog.js +1039 -0
- package/dist/utils/deck.d.ts +0 -18
- package/dist/utils/deck.js +0 -36
- package/dist/utils/geometry-type.d.ts +52 -0
- package/dist/utils/geometry-type.js +76 -0
- package/dist/utils/markdown-sql.d.ts +1 -1
- package/dist/utils/markdown-sql.js +3 -4
- package/dist/utils/pmtiles-tile.d.ts +0 -2
- package/dist/utils/pmtiles-tile.js +0 -8
- package/dist/utils/url-state.d.ts +6 -0
- package/dist/utils/url-state.js +34 -26
- package/dist/utils/url.d.ts +26 -9
- package/dist/utils/url.js +52 -25
- package/dist/utils/wkb.js +22 -8
- package/dist/utils/zarr-tab.d.ts +22 -0
- package/dist/utils/zarr-tab.js +30 -0
- package/dist/utils/zarr.d.ts +0 -2
- package/dist/utils/zarr.js +73 -44
- package/package.json +47 -43
- package/dist/components/ui/tabs/index.d.ts +0 -5
- package/dist/components/ui/tabs/index.js +0 -7
- package/dist/components/ui/tabs/tabs-content.svelte +0 -17
- package/dist/components/ui/tabs/tabs-content.svelte.d.ts +0 -4
- package/dist/components/ui/tabs/tabs-list.svelte +0 -16
- package/dist/components/ui/tabs/tabs-list.svelte.d.ts +0 -4
- package/dist/components/ui/tabs/tabs-trigger.svelte +0 -20
- package/dist/components/ui/tabs/tabs-trigger.svelte.d.ts +0 -4
- package/dist/components/ui/tabs/tabs.svelte +0 -19
- package/dist/components/ui/tabs/tabs.svelte.d.ts +0 -4
- package/dist/components/viewers/MapViewer.svelte +0 -234
- package/dist/components/viewers/MapViewer.svelte.d.ts +0 -7
- package/dist/components/viewers/StyleEditorOverlay.svelte +0 -27
- package/dist/components/viewers/StyleEditorOverlay.svelte.d.ts +0 -7
|
@@ -1,529 +1,91 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
3
|
-
import { COGLayer
|
|
4
|
-
import {
|
|
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';
|
|
12
|
-
import {
|
|
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';
|
|
30
|
+
import { buildHttpsUrlAsync } from '../../utils/url.js';
|
|
31
|
+
import CogControls from './CogControls.svelte';
|
|
13
32
|
import MapContainer from './map/MapContainer.svelte';
|
|
14
33
|
|
|
15
|
-
// ───
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
506
|
-
let
|
|
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
|
+
let resolvedHttpsUrl: string | null = null;
|
|
61
|
+
// True when the library-default uint pipeline will run. LinearRescale only
|
|
62
|
+
// operates on already-normalized RGB 0..1, so the slider is meaningful only
|
|
63
|
+
// here, and only for non-palette data (palette renders through Colormap).
|
|
64
|
+
// `needsCustomPipelineForConfig` only touches read-only tags on the GeoTIFF,
|
|
65
|
+
// safe to call outside reactive tracking.
|
|
66
|
+
const rescaleApplicable = $derived.by(() => {
|
|
67
|
+
if (!cogInfo || !bandConfig || isPaletteIndexed) return false;
|
|
68
|
+
const g = geotiffRef;
|
|
69
|
+
if (!g) return false;
|
|
70
|
+
return !needsCustomPipelineForConfig(g, bandConfig);
|
|
71
|
+
});
|
|
72
|
+
// Tracks whether the camera has already been framed for the current tab.
|
|
73
|
+
// Prevents fitCogBounds from resetting the user's view when the band/style
|
|
74
|
+
// config changes and the COGLayer is rebuilt.
|
|
75
|
+
let hasFittedOnce = false;
|
|
507
76
|
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
77
|
+
// Main-thread decoder pool — worker-based DecoderPool fails in Vite dev mode
|
|
78
|
+
// (ESM workers can't load through the dev server). Main-thread decoding is
|
|
79
|
+
// reliable across all environments. COGLayer's defaultDecoderPool() would
|
|
80
|
+
// create workers that crash with NS_ERROR_CORRUPTED_CONTENT in Firefox.
|
|
81
|
+
const pool = new DecoderPool();
|
|
511
82
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
}
|
|
83
|
+
// EPSG resolver backed by the bundled `@developmentseed/epsg` WKT database.
|
|
84
|
+
// Avoids the library default that calls epsg.io at runtime. The CSV is
|
|
85
|
+
// streamed and parsed lazily on first use and cached for the session.
|
|
86
|
+
const epsgResolver = createEpsgResolver();
|
|
525
87
|
|
|
526
|
-
// ─── Tab change reset
|
|
88
|
+
// ─── Tab change reset ────────────────────────────────────────────
|
|
527
89
|
|
|
528
90
|
$effect(() => {
|
|
529
91
|
if (!tab) return;
|
|
@@ -531,170 +93,198 @@ $effect(() => {
|
|
|
531
93
|
untrack(() => {
|
|
532
94
|
abortController.abort();
|
|
533
95
|
abortController = new AbortController();
|
|
534
|
-
|
|
535
|
-
cleanupNativeBitmap();
|
|
96
|
+
removeClickHandler();
|
|
97
|
+
if (mapRef) cleanupNativeBitmap(mapRef);
|
|
536
98
|
if (mapRef && overlayRef) {
|
|
537
99
|
try {
|
|
538
|
-
mapRef.removeControl(overlayRef);
|
|
100
|
+
mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
|
|
539
101
|
} catch {
|
|
540
|
-
|
|
102
|
+
/* map may already be destroyed */
|
|
541
103
|
}
|
|
542
104
|
}
|
|
543
105
|
overlayRef = null;
|
|
106
|
+
geotiffRef = null;
|
|
107
|
+
proj4DefRef = null;
|
|
108
|
+
resolvedHttpsUrl = null;
|
|
544
109
|
loading = true;
|
|
545
110
|
error = null;
|
|
546
111
|
cogInfo = null;
|
|
112
|
+
bandConfig = null;
|
|
113
|
+
rescale = { ...DEFAULT_RESCALE };
|
|
114
|
+
isPaletteIndexed = false;
|
|
115
|
+
pixelValue = null;
|
|
547
116
|
bounds = undefined;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (mapRef) {
|
|
553
|
-
loadCog(mapRef);
|
|
554
|
-
}
|
|
117
|
+
hasFittedOnce = false;
|
|
118
|
+
showControls = false;
|
|
119
|
+
showInfo = false;
|
|
120
|
+
if (mapRef) loadCog(mapRef);
|
|
555
121
|
});
|
|
556
122
|
});
|
|
557
123
|
|
|
558
|
-
// ─── Map ready
|
|
124
|
+
// ─── Map ready ───────────────────────────────────────────────────
|
|
559
125
|
|
|
560
126
|
function onMapReady(map: maplibregl.Map) {
|
|
561
127
|
mapRef = map;
|
|
562
128
|
loadCog(map);
|
|
563
129
|
}
|
|
564
130
|
|
|
131
|
+
// ─── Click handler for pixel inspection ──────────────────────────
|
|
132
|
+
|
|
133
|
+
function removeClickHandler() {
|
|
134
|
+
if (mapRef && clickHandlerRef) {
|
|
135
|
+
mapRef.off('click', clickHandlerRef);
|
|
136
|
+
clickHandlerRef = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function setupClickHandler(map: maplibregl.Map) {
|
|
141
|
+
removeClickHandler();
|
|
142
|
+
clickHandlerRef = async (e: maplibregl.MapMouseEvent) => {
|
|
143
|
+
if (!geotiffRef) return;
|
|
144
|
+
inspecting = true;
|
|
145
|
+
try {
|
|
146
|
+
const result = await readPixelAtLngLat(
|
|
147
|
+
geotiffRef,
|
|
148
|
+
e.lngLat.lng,
|
|
149
|
+
e.lngLat.lat,
|
|
150
|
+
proj4DefRef,
|
|
151
|
+
pool,
|
|
152
|
+
abortController.signal
|
|
153
|
+
);
|
|
154
|
+
pixelValue = result;
|
|
155
|
+
} catch {
|
|
156
|
+
pixelValue = null;
|
|
157
|
+
} finally {
|
|
158
|
+
inspecting = false;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
map.on('click', clickHandlerRef);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Core load function ──────────────────────────────────────────
|
|
165
|
+
|
|
565
166
|
async function loadCog(map: maplibregl.Map) {
|
|
566
167
|
const signal = abortController.signal;
|
|
567
|
-
const loadT0 = performance.now();
|
|
568
168
|
|
|
569
169
|
try {
|
|
570
|
-
const url =
|
|
571
|
-
console.group(`[COG] loadCog: ${url}`);
|
|
572
|
-
|
|
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();
|
|
170
|
+
const url = await buildHttpsUrlAsync(tab);
|
|
579
171
|
if (signal.aborted) return;
|
|
580
|
-
|
|
172
|
+
resolvedHttpsUrl = url;
|
|
173
|
+
|
|
174
|
+
// Pre-flight: read first IFD to check if tiled (single range request).
|
|
175
|
+
let isTiled = true;
|
|
176
|
+
let preflightGeotiff: GeoTIFF | undefined;
|
|
177
|
+
try {
|
|
178
|
+
preflightGeotiff = await GeoTIFF.fromUrl(url);
|
|
179
|
+
if (signal.aborted) return;
|
|
180
|
+
isTiled = preflightGeotiff.isTiled;
|
|
581
181
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
182
|
+
// Validate CRS early
|
|
183
|
+
try {
|
|
184
|
+
const _crs = preflightGeotiff.crs;
|
|
185
|
+
void _crs;
|
|
186
|
+
} catch (crsErr) {
|
|
187
|
+
const msg = crsErr instanceof Error ? crsErr.message : String(crsErr);
|
|
188
|
+
error = `Unsupported CRS: ${msg}`;
|
|
189
|
+
loading = false;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
} catch (preflightErr) {
|
|
193
|
+
if (signal.aborted) return;
|
|
194
|
+
// `@developmentseed/geotiff` throws "Only tiff supported version:<n>"
|
|
195
|
+
// when the first 4 bytes don't match II*\0 / MM\0* / II+\0 / MM\0+.
|
|
196
|
+
// This happens on files that advertise image/tiff but are corrupt,
|
|
197
|
+
// encrypted, or a different format entirely (GDAL reports "not
|
|
198
|
+
// recognized as being in a supported file format" on the same file).
|
|
199
|
+
// Surface a clear message and bail — COGLayer would re-invoke the
|
|
200
|
+
// same loader and throw the identical error uncaught during update.
|
|
201
|
+
const msg = preflightErr instanceof Error ? preflightErr.message : String(preflightErr);
|
|
202
|
+
if (/Only tiff supported version|not a tiff|Invalid.*magic/i.test(msg)) {
|
|
203
|
+
error = t('map.cogInvalidTiff');
|
|
204
|
+
loading = false;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
600
208
|
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
209
|
+
// Store refs for pixel inspection and rebuild
|
|
210
|
+
if (preflightGeotiff) {
|
|
211
|
+
geotiffRef = preflightGeotiff;
|
|
212
|
+
isTiledRef = isTiled;
|
|
213
|
+
const tagInfo = inspectCogTags(preflightGeotiff);
|
|
214
|
+
sampleFormatRef = tagInfo.sampleFormat;
|
|
215
|
+
isPaletteIndexed = tagInfo.isPaletteIndexed;
|
|
604
216
|
|
|
605
|
-
|
|
606
|
-
|
|
217
|
+
// Resolve proj4 definition for CRS conversion (pixel inspector)
|
|
218
|
+
try {
|
|
219
|
+
proj4DefRef = await resolveProj4Def(preflightGeotiff.crs, signal);
|
|
220
|
+
} catch {
|
|
221
|
+
proj4DefRef = null;
|
|
222
|
+
}
|
|
223
|
+
if (signal.aborted) return;
|
|
607
224
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
);
|
|
225
|
+
// Set default band config
|
|
226
|
+
bandConfig = defaultBandConfig(preflightGeotiff.count, sampleFormatRef);
|
|
227
|
+
}
|
|
621
228
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
);
|
|
229
|
+
if (!isTiled && preflightGeotiff) {
|
|
230
|
+
// ── Non-tiled TIFF — render as bitmap ──
|
|
231
|
+
const info = await renderNonTiledBitmap({
|
|
232
|
+
url,
|
|
233
|
+
map,
|
|
234
|
+
signal,
|
|
235
|
+
geotiff: preflightGeotiff
|
|
236
|
+
});
|
|
237
|
+
if (signal.aborted) return;
|
|
238
|
+
cogInfo = info;
|
|
239
|
+
if (!hasFittedOnce) {
|
|
240
|
+
bounds = [info.bounds.west, info.bounds.south, info.bounds.east, info.bounds.north];
|
|
241
|
+
fitCogBounds(map, info.bounds);
|
|
242
|
+
hasFittedOnce = true;
|
|
687
243
|
}
|
|
688
|
-
|
|
689
|
-
|
|
244
|
+
setupClickHandler(map);
|
|
245
|
+
loading = false;
|
|
246
|
+
return;
|
|
690
247
|
}
|
|
248
|
+
|
|
249
|
+
// ── Tiled COG ──
|
|
250
|
+
buildAndAddLayer(map, preflightGeotiff, signal);
|
|
251
|
+
} catch (err) {
|
|
691
252
|
if (signal.aborted) return;
|
|
253
|
+
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
254
|
+
error = err instanceof Error ? err.message : String(err);
|
|
255
|
+
loading = false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
692
258
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
259
|
+
// ─── Build & add COGLayer ────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function buildAndAddLayer(
|
|
262
|
+
map: maplibregl.Map,
|
|
263
|
+
preflightGeotiff: GeoTIFF | undefined,
|
|
264
|
+
signal: AbortSignal
|
|
265
|
+
) {
|
|
266
|
+
// Pick the library-default or one of three custom pipelines. Empty when the
|
|
267
|
+
// library-default uint path runs unchanged.
|
|
268
|
+
const customProps = preflightGeotiff
|
|
269
|
+
? selectCogPipeline(preflightGeotiff, { bandConfig, rescale })
|
|
270
|
+
: {};
|
|
271
|
+
|
|
272
|
+
// Apply upstream-bug workarounds in place (overview filter, 4326 bbox clamp).
|
|
273
|
+
if (preflightGeotiff) normalizeCogGeotiff(preflightGeotiff);
|
|
274
|
+
|
|
275
|
+
const cogInput = preflightGeotiff ?? resolvedHttpsUrl ?? '';
|
|
276
|
+
|
|
277
|
+
const layer = new COGLayer({
|
|
278
|
+
// Stable id per tab so rebuilds on band/style change don't force deck.gl
|
|
279
|
+
// to treat this as a brand-new layer and drop cached tile state.
|
|
280
|
+
id: `cog-layer-${tab.id}`,
|
|
281
|
+
geotiff: cogInput,
|
|
282
|
+
pool,
|
|
283
|
+
epsgResolver,
|
|
284
|
+
signal,
|
|
285
|
+
...customProps,
|
|
286
|
+
onGeoTIFFLoad: (
|
|
287
|
+
loadedTiff: GeoTIFF,
|
|
698
288
|
{
|
|
699
289
|
geographicBounds
|
|
700
290
|
}: {
|
|
@@ -702,27 +292,31 @@ async function loadCog(map: maplibregl.Map) {
|
|
|
702
292
|
geographicBounds: { west: number; south: number; east: number; north: number };
|
|
703
293
|
}
|
|
704
294
|
) => {
|
|
705
|
-
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
);
|
|
295
|
+
const clamped = clampBounds(geographicBounds);
|
|
296
|
+
const tags = loadedTiff.cachedTags;
|
|
297
|
+
const sf = tags.sampleFormat?.[0] ?? 1;
|
|
298
|
+
const bps = tags.bitsPerSample?.[0] ?? 8;
|
|
299
|
+
|
|
711
300
|
cogInfo = {
|
|
712
|
-
width:
|
|
713
|
-
height:
|
|
714
|
-
bandCount,
|
|
715
|
-
dataType,
|
|
301
|
+
width: loadedTiff.width,
|
|
302
|
+
height: loadedTiff.height,
|
|
303
|
+
bandCount: loadedTiff.count,
|
|
304
|
+
dataType: buildDataTypeLabel(sf, bps),
|
|
716
305
|
bounds: clamped
|
|
717
306
|
};
|
|
718
|
-
|
|
719
|
-
|
|
307
|
+
// Only frame the camera on the first load of this tab. Band/style
|
|
308
|
+
// rebuilds re-fire onGeoTIFFLoad; refitting would clobber the user's
|
|
309
|
+
// current view.
|
|
310
|
+
if (!hasFittedOnce) {
|
|
311
|
+
bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
|
|
312
|
+
fitCogBounds(map, clamped);
|
|
313
|
+
hasFittedOnce = true;
|
|
314
|
+
}
|
|
315
|
+
setupClickHandler(map);
|
|
720
316
|
loading = false;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const handleError = (err: Error) => {
|
|
725
|
-
if (signal.aborted) return true;
|
|
317
|
+
},
|
|
318
|
+
onError: (err: Error) => {
|
|
319
|
+
if (signal.aborted) return;
|
|
726
320
|
const msg = err?.message || String(err);
|
|
727
321
|
if (
|
|
728
322
|
msg.includes('Request failed') ||
|
|
@@ -734,537 +328,80 @@ async function loadCog(map: maplibregl.Map) {
|
|
|
734
328
|
error = msg;
|
|
735
329
|
}
|
|
736
330
|
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
331
|
}
|
|
332
|
+
});
|
|
1021
333
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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));
|
|
334
|
+
const overlay = new MapboxOverlay({
|
|
335
|
+
interleaved: false,
|
|
336
|
+
layers: [layer],
|
|
337
|
+
onError: (err: Error) => {
|
|
338
|
+
if (signal.aborted) return;
|
|
339
|
+
if (!error) {
|
|
340
|
+
error = err?.message || String(err);
|
|
341
|
+
loading = false;
|
|
1041
342
|
}
|
|
1042
|
-
console.log('[COG] overlay attached');
|
|
1043
343
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
}
|
|
344
|
+
});
|
|
345
|
+
overlayRef = overlay;
|
|
346
|
+
map.addControl(overlay as unknown as maplibregl.IControl);
|
|
1053
347
|
}
|
|
1054
348
|
|
|
1055
|
-
// ───
|
|
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.
|
|
1060
|
-
|
|
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;
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
console.log(
|
|
1087
|
-
`[COG:custom] stats from IFD #${statsIfdIdx}: ${statsImage.getWidth()}×${statsImage.getHeight()}`
|
|
1088
|
-
);
|
|
1089
|
-
if (signal.aborted) return null;
|
|
349
|
+
// ─── Rebuild layer on band config change ─────────────────────────
|
|
1090
350
|
|
|
1091
|
-
|
|
351
|
+
function handleConfigChange(newConfig: BandConfig) {
|
|
352
|
+
bandConfig = newConfig;
|
|
353
|
+
if (!mapRef || !geotiffRef || !isTiledRef) return;
|
|
1092
354
|
|
|
1093
|
-
//
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
Math.max(0, cy - half),
|
|
1102
|
-
Math.min(statsImage.getWidth(), cx + half),
|
|
1103
|
-
Math.min(statsImage.getHeight(), cy + half)
|
|
1104
|
-
];
|
|
355
|
+
// Remove old overlay
|
|
356
|
+
if (overlayRef) {
|
|
357
|
+
try {
|
|
358
|
+
mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
|
|
359
|
+
} catch {
|
|
360
|
+
/* already removed */
|
|
361
|
+
}
|
|
362
|
+
overlayRef = null;
|
|
1105
363
|
}
|
|
1106
364
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
signal
|
|
1111
|
-
});
|
|
1112
|
-
if (signal.aborted) return null;
|
|
365
|
+
// Rebuild with new config
|
|
366
|
+
buildAndAddLayer(mapRef, geotiffRef, abortController.signal);
|
|
367
|
+
}
|
|
1113
368
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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}`);
|
|
369
|
+
function handleRescaleChange(next: RescaleConfig) {
|
|
370
|
+
rescale = next;
|
|
371
|
+
if (!mapRef || !geotiffRef || !isTiledRef) return;
|
|
1131
372
|
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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--;
|
|
373
|
+
// Remove old overlay and rebuild. deck.gl diffs on layer id, so reusing the
|
|
374
|
+
// stable per-tab id keeps tile cache state where possible.
|
|
375
|
+
if (overlayRef) {
|
|
376
|
+
try {
|
|
377
|
+
mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
|
|
378
|
+
} catch {
|
|
379
|
+
/* already removed */
|
|
1151
380
|
}
|
|
381
|
+
overlayRef = null;
|
|
1152
382
|
}
|
|
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
|
-
});
|
|
383
|
+
buildAndAddLayer(mapRef, geotiffRef, abortController.signal);
|
|
1250
384
|
}
|
|
1251
385
|
|
|
1252
|
-
// ─── Cleanup
|
|
386
|
+
// ─── Cleanup ─────────────────────────────────────────────────────
|
|
1253
387
|
|
|
1254
388
|
function cleanup() {
|
|
1255
389
|
abortController.abort();
|
|
1256
|
-
|
|
390
|
+
removeClickHandler();
|
|
391
|
+
if (mapRef) cleanupNativeBitmap(mapRef);
|
|
1257
392
|
if (mapRef && overlayRef) {
|
|
1258
393
|
try {
|
|
1259
|
-
mapRef.removeControl(overlayRef);
|
|
394
|
+
mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
|
|
1260
395
|
} catch {
|
|
1261
|
-
|
|
396
|
+
/* map may already be destroyed */
|
|
1262
397
|
}
|
|
1263
398
|
}
|
|
1264
399
|
mapRef = null;
|
|
1265
400
|
overlayRef = null;
|
|
1266
|
-
|
|
1267
|
-
|
|
401
|
+
geotiffRef = null;
|
|
402
|
+
proj4DefRef = null;
|
|
403
|
+
pixelValue = null;
|
|
404
|
+
resolvedHttpsUrl = null;
|
|
1268
405
|
}
|
|
1269
406
|
|
|
1270
407
|
$effect(() => {
|
|
@@ -1280,6 +417,7 @@ onDestroy(cleanup);
|
|
|
1280
417
|
<MapContainer {onMapReady} {bounds} />
|
|
1281
418
|
</div>
|
|
1282
419
|
|
|
420
|
+
<!-- Top-left: Loading + metadata badges -->
|
|
1283
421
|
<div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col gap-1">
|
|
1284
422
|
{#if loading}
|
|
1285
423
|
<div
|
|
@@ -1310,19 +448,48 @@ onDestroy(cleanup);
|
|
|
1310
448
|
{/if}
|
|
1311
449
|
</div>
|
|
1312
450
|
|
|
451
|
+
<!-- Top-right: Info + Style buttons -->
|
|
1313
452
|
{#if cogInfo}
|
|
1314
|
-
|
|
1315
453
|
<div class="absolute right-2 top-2 z-10 flex gap-1">
|
|
454
|
+
{#if bandConfig}
|
|
455
|
+
<button
|
|
456
|
+
class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
|
|
457
|
+
class:ring-1={showControls}
|
|
458
|
+
class:ring-primary={showControls}
|
|
459
|
+
onclick={() => {
|
|
460
|
+
showControls = !showControls;
|
|
461
|
+
if (showControls) showInfo = false;
|
|
462
|
+
}}
|
|
463
|
+
>
|
|
464
|
+
{t('cog.style')}
|
|
465
|
+
</button>
|
|
466
|
+
{/if}
|
|
1316
467
|
<button
|
|
1317
468
|
class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
|
|
1318
469
|
class:ring-1={showInfo}
|
|
1319
470
|
class:ring-primary={showInfo}
|
|
1320
|
-
onclick={() =>
|
|
471
|
+
onclick={() => {
|
|
472
|
+
showInfo = !showInfo;
|
|
473
|
+
if (showInfo) showControls = false;
|
|
474
|
+
}}
|
|
1321
475
|
>
|
|
1322
476
|
{t('map.info')}
|
|
1323
477
|
</button>
|
|
1324
478
|
</div>
|
|
1325
479
|
|
|
480
|
+
<!-- Band/Color controls panel -->
|
|
481
|
+
{#if showControls && bandConfig}
|
|
482
|
+
<CogControls
|
|
483
|
+
bandCount={cogInfo.bandCount}
|
|
484
|
+
{bandConfig}
|
|
485
|
+
onConfigChange={handleConfigChange}
|
|
486
|
+
{rescale}
|
|
487
|
+
rescaleApplicable={rescaleApplicable}
|
|
488
|
+
onRescaleChange={handleRescaleChange}
|
|
489
|
+
/>
|
|
490
|
+
{/if}
|
|
491
|
+
|
|
492
|
+
<!-- Info panel -->
|
|
1326
493
|
{#if showInfo}
|
|
1327
494
|
<div
|
|
1328
495
|
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 +502,55 @@ onDestroy(cleanup);
|
|
|
1335
502
|
<dd>{cogInfo.bandCount} ({cogInfo.dataType})</dd>
|
|
1336
503
|
<dt class="text-muted-foreground">{t('mapInfo.bounds')}</dt>
|
|
1337
504
|
<dd>
|
|
1338
|
-
W {cogInfo.bounds.west.toFixed(4)}, S {cogInfo.bounds.south.toFixed(4)}<br
|
|
505
|
+
W {cogInfo.bounds.west.toFixed(4)}, S {cogInfo.bounds.south.toFixed(4)}<br
|
|
506
|
+
/>
|
|
1339
507
|
E {cogInfo.bounds.east.toFixed(4)}, N {cogInfo.bounds.north.toFixed(4)}
|
|
1340
508
|
</dd>
|
|
1341
509
|
</dl>
|
|
1342
510
|
</div>
|
|
1343
511
|
{/if}
|
|
1344
512
|
{/if}
|
|
513
|
+
|
|
514
|
+
<!-- Bottom-left: Pixel value on click -->
|
|
515
|
+
{#if pixelValue}
|
|
516
|
+
<div
|
|
517
|
+
class="absolute bottom-2 left-2 z-10 rounded bg-card/90 p-2.5 text-xs text-card-foreground backdrop-blur-sm"
|
|
518
|
+
>
|
|
519
|
+
<div class="mb-1 flex items-center justify-between gap-3">
|
|
520
|
+
<span class="font-medium">{t('cog.pixelValue')}</span>
|
|
521
|
+
<button
|
|
522
|
+
class="text-muted-foreground hover:text-card-foreground"
|
|
523
|
+
onclick={() => (pixelValue = null)}
|
|
524
|
+
>
|
|
525
|
+
×
|
|
526
|
+
</button>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="space-y-0.5 text-muted-foreground">
|
|
529
|
+
<div>
|
|
530
|
+
{pixelValue.lat.toFixed(6)}°, {pixelValue.lng.toFixed(6)}°
|
|
531
|
+
</div>
|
|
532
|
+
<div class="text-[10px]">
|
|
533
|
+
px ({pixelValue.col}, {pixelValue.row})
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
<div class="mt-1.5 space-y-0.5">
|
|
537
|
+
{#each pixelValue.values as val, i}
|
|
538
|
+
<div class="flex justify-between gap-2">
|
|
539
|
+
<span class="text-muted-foreground">{t('cog.band')} {i + 1}</span>
|
|
540
|
+
<span class="font-mono tabular-nums">
|
|
541
|
+
{Number.isInteger(val) ? val : val.toFixed(4)}
|
|
542
|
+
</span>
|
|
543
|
+
</div>
|
|
544
|
+
{/each}
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
{/if}
|
|
548
|
+
|
|
549
|
+
{#if inspecting}
|
|
550
|
+
<div
|
|
551
|
+
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"
|
|
552
|
+
>
|
|
553
|
+
{t('cog.reading')}
|
|
554
|
+
</div>
|
|
555
|
+
{/if}
|
|
1345
556
|
</div>
|