@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.
- package/README.md +11 -2
- package/dist/components/browser/FileBrowser.svelte +41 -54
- package/dist/components/browser/FileTreeSidebar.svelte +43 -7
- package/dist/components/layout/ConnectionDialog.svelte +100 -1
- package/dist/components/layout/Sidebar.svelte +43 -25
- package/dist/components/viewers/CodeViewer.svelte +23 -0
- package/dist/components/viewers/CogControls.svelte +208 -0
- package/dist/components/viewers/CogControls.svelte.d.ts +12 -0
- package/dist/components/viewers/CogViewer.svelte +353 -1160
- package/dist/components/viewers/CogViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/DatabaseViewer.svelte +345 -37
- package/dist/components/viewers/MarkdownViewer.svelte +1 -1
- package/dist/components/viewers/TableViewer.svelte +123 -41
- package/dist/components/viewers/ZarrMapViewer.svelte +29 -0
- package/dist/components/viewers/ZarrViewer.svelte +1 -4
- 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 +24 -0
- package/dist/i18n/en.js +24 -0
- package/dist/i18n/index.svelte.d.ts +0 -1
- package/dist/i18n/index.svelte.js +0 -3
- package/dist/index.d.ts +11 -0
- package/dist/index.js +10 -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 +30 -0
- package/dist/query/source.js +37 -0
- package/dist/query/wasm.d.ts +7 -5
- package/dist/query/wasm.js +138 -85
- package/dist/storage/providers.d.ts +47 -0
- package/dist/storage/providers.js +160 -0
- package/dist/stores/connections.svelte.js +5 -31
- package/dist/stores/files.svelte.d.ts +2 -8
- package/dist/stores/files.svelte.js +5 -38
- package/dist/stores/query-history.svelte.js +3 -25
- package/dist/stores/settings.svelte.d.ts +1 -0
- package/dist/stores/settings.svelte.js +10 -30
- 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/cloud-url.d.ts +27 -0
- package/dist/utils/cloud-url.js +61 -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/export.d.ts +22 -2
- package/dist/utils/export.js +35 -10
- package/dist/utils/file-sort.d.ts +20 -0
- package/dist/utils/file-sort.js +41 -0
- package/dist/utils/geometry-type.d.ts +52 -0
- package/dist/utils/geometry-type.js +76 -0
- package/dist/utils/local-storage.d.ts +16 -0
- package/dist/utils/local-storage.js +37 -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 +13 -25
- package/dist/utils/url.js +17 -78
- 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 +50 -46
- 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,90 @@
|
|
|
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';
|
|
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
|
-
// ───
|
|
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
|
+
// 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
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
-
);
|
|
208
|
+
// Set default band config
|
|
209
|
+
bandConfig = defaultBandConfig(preflightGeotiff.count, sampleFormatRef);
|
|
210
|
+
}
|
|
621
211
|
|
|
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
|
-
);
|
|
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
|
-
|
|
689
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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:
|
|
713
|
-
height:
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
|
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
|
-
|
|
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
|
-
}
|
|
327
|
+
});
|
|
328
|
+
overlayRef = overlay;
|
|
329
|
+
map.addControl(overlay as unknown as maplibregl.IControl);
|
|
1053
330
|
}
|
|
1054
331
|
|
|
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.
|
|
332
|
+
// ─── Rebuild layer on band config change ─────────────────────────
|
|
1060
333
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
//
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
+
/* map may already be destroyed */
|
|
1262
380
|
}
|
|
1263
381
|
}
|
|
1264
382
|
mapRef = null;
|
|
1265
383
|
overlayRef = null;
|
|
1266
|
-
|
|
1267
|
-
|
|
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={() =>
|
|
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
|
+
×
|
|
508
|
+
</button>
|
|
509
|
+
</div>
|
|
510
|
+
<div class="space-y-0.5 text-muted-foreground">
|
|
511
|
+
<div>
|
|
512
|
+
{pixelValue.lat.toFixed(6)}°, {pixelValue.lng.toFixed(6)}°
|
|
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>
|