@walkthru-earth/objex 1.2.0 → 1.3.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 +6 -3
- package/dist/components/browser/FileTreeSidebar.svelte +1 -1
- package/dist/components/layout/ConnectionDialog.svelte +35 -3
- package/dist/components/layout/Sidebar.svelte +28 -2
- package/dist/components/viewers/ArchiveViewer.svelte +4 -4
- package/dist/components/viewers/CodeViewer.svelte +72 -19
- package/dist/components/viewers/CodeViewer.svelte.d.ts +11 -1
- package/dist/components/viewers/CogControls.svelte +151 -22
- package/dist/components/viewers/CogControls.svelte.d.ts +5 -1
- package/dist/components/viewers/CogViewer.svelte +45 -10
- package/dist/components/viewers/CopcViewer.svelte +20 -2
- package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
- package/dist/components/viewers/MultiCogViewer.svelte +416 -0
- package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
- package/dist/components/viewers/PmtilesViewer.svelte +2 -2
- package/dist/components/viewers/StacMapViewer.svelte +34 -12
- package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
- package/dist/components/viewers/StacMosaicViewer.svelte +699 -0
- package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +9 -0
- package/dist/components/viewers/StacTabViewer.svelte +254 -0
- package/dist/components/viewers/StacTabViewer.svelte.d.ts +13 -0
- package/dist/components/viewers/TableViewer.svelte +50 -21
- package/dist/components/viewers/ViewerRouter.svelte +155 -2
- package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
- package/dist/components/viewers/ZarrMapViewer.svelte +147 -8
- package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
- package/dist/components/viewers/ZarrViewer.svelte +3 -2
- package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
- package/dist/i18n/ar.js +28 -0
- package/dist/i18n/en.js +28 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/source.d.ts +12 -0
- package/dist/query/source.js +25 -8
- package/dist/query/stac-geoparquet.d.ts +31 -0
- package/dist/query/stac-geoparquet.js +136 -0
- package/dist/query/wasm.js +130 -23
- 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 +6 -0
- package/dist/storage/providers.js +13 -2
- package/dist/stores/browser.svelte.d.ts +2 -0
- package/dist/stores/browser.svelte.js +17 -1
- package/dist/stores/connections.svelte.d.ts +38 -23
- package/dist/stores/connections.svelte.js +105 -114
- package/dist/utils/cog.d.ts +80 -18
- package/dist/utils/cog.js +187 -125
- package/dist/utils/colormap-sprite.d.ts +39 -0
- package/dist/utils/colormap-sprite.js +77 -0
- package/dist/utils/connection-identity.d.ts +51 -0
- package/dist/utils/connection-identity.js +97 -0
- package/dist/utils/host-detection.js +48 -302
- package/dist/utils/parquet-metadata.d.ts +7 -1
- package/dist/utils/parquet-metadata.js +35 -1
- package/dist/utils/stac-geoparquet.d.ts +90 -0
- package/dist/utils/stac-geoparquet.js +223 -0
- package/dist/utils/stac-hydrate.d.ts +38 -0
- package/dist/utils/stac-hydrate.js +243 -0
- package/dist/utils/stac.d.ts +136 -0
- package/dist/utils/stac.js +176 -0
- package/dist/utils/storage-url.d.ts +26 -0
- package/dist/utils/storage-url.js +164 -28
- package/dist/utils/url.d.ts +13 -0
- package/dist/utils/url.js +36 -0
- package/dist/utils/wkb.js +22 -8
- package/dist/utils/zarr.d.ts +34 -0
- package/dist/utils/zarr.js +94 -0
- package/package.json +14 -13
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
resolveProj4Def,
|
|
28
28
|
selectCogPipeline
|
|
29
29
|
} from '../../utils/cog.js';
|
|
30
|
-
import {
|
|
30
|
+
import { buildHttpsUrlAsync } from '../../utils/url.js';
|
|
31
31
|
import CogControls from './CogControls.svelte';
|
|
32
32
|
import MapContainer from './map/MapContainer.svelte';
|
|
33
33
|
|
|
@@ -41,6 +41,8 @@ let showControls = $state(false);
|
|
|
41
41
|
let bounds = $state<[number, number, number, number] | undefined>();
|
|
42
42
|
let cogInfo = $state<CogInfo | null>(null);
|
|
43
43
|
let bandConfig = $state<BandConfig | null>(null);
|
|
44
|
+
let histogram = $state.raw<Uint32Array | null>(null);
|
|
45
|
+
let histogramTick = $state(0);
|
|
44
46
|
let rescale = $state<RescaleConfig>({ ...DEFAULT_RESCALE });
|
|
45
47
|
// Palette-indexed COGs render through the library's Colormap module; a GPU
|
|
46
48
|
// rescale at that stage is cosmetic and would confuse the legend. Keep the
|
|
@@ -57,16 +59,20 @@ let proj4DefRef: string | null = null;
|
|
|
57
59
|
let sampleFormatRef = 1;
|
|
58
60
|
let isTiledRef = true;
|
|
59
61
|
let clickHandlerRef: ((e: maplibregl.MapMouseEvent) => void) | null = null;
|
|
60
|
-
|
|
61
|
-
// operates on
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
62
|
+
let resolvedHttpsUrl: string | null = null;
|
|
63
|
+
// LinearRescale operates on a 0..1 scalar. Two cases expose a meaningful
|
|
64
|
+
// slider: (1) the library-default uint RGB pipeline (scales `color.rgb`
|
|
65
|
+
// before presentation), and (2) our custom single-band CPU + GPU Colormap
|
|
66
|
+
// path (scales `color.r` before the ramp sample). Palette COGs hide the
|
|
67
|
+
// slider, the embedded ColorMap tag already bakes the display colors.
|
|
68
|
+
// `needsCustomPipelineForConfig` only touches read-only tags, safe to call
|
|
69
|
+
// outside reactive tracking.
|
|
65
70
|
const rescaleApplicable = $derived.by(() => {
|
|
66
71
|
if (!cogInfo || !bandConfig || isPaletteIndexed) return false;
|
|
67
72
|
const g = geotiffRef;
|
|
68
73
|
if (!g) return false;
|
|
69
|
-
|
|
74
|
+
if (!needsCustomPipelineForConfig(g, bandConfig)) return true;
|
|
75
|
+
return bandConfig.mode === 'single';
|
|
70
76
|
});
|
|
71
77
|
// Tracks whether the camera has already been framed for the current tab.
|
|
72
78
|
// Prevents fitCogBounds from resetting the user's view when the band/style
|
|
@@ -104,10 +110,13 @@ $effect(() => {
|
|
|
104
110
|
overlayRef = null;
|
|
105
111
|
geotiffRef = null;
|
|
106
112
|
proj4DefRef = null;
|
|
113
|
+
resolvedHttpsUrl = null;
|
|
107
114
|
loading = true;
|
|
108
115
|
error = null;
|
|
109
116
|
cogInfo = null;
|
|
110
117
|
bandConfig = null;
|
|
118
|
+
histogram = null;
|
|
119
|
+
histogramTick = 0;
|
|
111
120
|
rescale = { ...DEFAULT_RESCALE };
|
|
112
121
|
isPaletteIndexed = false;
|
|
113
122
|
pixelValue = null;
|
|
@@ -165,7 +174,9 @@ async function loadCog(map: maplibregl.Map) {
|
|
|
165
174
|
const signal = abortController.signal;
|
|
166
175
|
|
|
167
176
|
try {
|
|
168
|
-
const url =
|
|
177
|
+
const url = await buildHttpsUrlAsync(tab);
|
|
178
|
+
if (signal.aborted) return;
|
|
179
|
+
resolvedHttpsUrl = url;
|
|
169
180
|
|
|
170
181
|
// Pre-flight: read first IFD to check if tiled (single range request).
|
|
171
182
|
let isTiled = true;
|
|
@@ -187,6 +198,19 @@ async function loadCog(map: maplibregl.Map) {
|
|
|
187
198
|
}
|
|
188
199
|
} catch (preflightErr) {
|
|
189
200
|
if (signal.aborted) return;
|
|
201
|
+
// `@developmentseed/geotiff` throws "Only tiff supported version:<n>"
|
|
202
|
+
// when the first 4 bytes don't match II*\0 / MM\0* / II+\0 / MM\0+.
|
|
203
|
+
// This happens on files that advertise image/tiff but are corrupt,
|
|
204
|
+
// encrypted, or a different format entirely (GDAL reports "not
|
|
205
|
+
// recognized as being in a supported file format" on the same file).
|
|
206
|
+
// Surface a clear message and bail — COGLayer would re-invoke the
|
|
207
|
+
// same loader and throw the identical error uncaught during update.
|
|
208
|
+
const msg = preflightErr instanceof Error ? preflightErr.message : String(preflightErr);
|
|
209
|
+
if (/Only tiff supported version|not a tiff|Invalid.*magic/i.test(msg)) {
|
|
210
|
+
error = t('map.cogInvalidTiff');
|
|
211
|
+
loading = false;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
190
214
|
}
|
|
191
215
|
|
|
192
216
|
// Store refs for pixel inspection and rebuild
|
|
@@ -249,13 +273,22 @@ function buildAndAddLayer(
|
|
|
249
273
|
// Pick the library-default or one of three custom pipelines. Empty when the
|
|
250
274
|
// library-default uint path runs unchanged.
|
|
251
275
|
const customProps = preflightGeotiff
|
|
252
|
-
? selectCogPipeline(preflightGeotiff, {
|
|
276
|
+
? selectCogPipeline(preflightGeotiff, {
|
|
277
|
+
bandConfig,
|
|
278
|
+
rescale,
|
|
279
|
+
onHistogram: (bins) => {
|
|
280
|
+
// Copy once so the derived UI sees an immutable snapshot
|
|
281
|
+
// and the accumulating worker buffer is not observed mid-mutation.
|
|
282
|
+
histogram = new Uint32Array(bins);
|
|
283
|
+
histogramTick++;
|
|
284
|
+
}
|
|
285
|
+
})
|
|
253
286
|
: {};
|
|
254
287
|
|
|
255
288
|
// Apply upstream-bug workarounds in place (overview filter, 4326 bbox clamp).
|
|
256
289
|
if (preflightGeotiff) normalizeCogGeotiff(preflightGeotiff);
|
|
257
290
|
|
|
258
|
-
const cogInput = preflightGeotiff ??
|
|
291
|
+
const cogInput = preflightGeotiff ?? resolvedHttpsUrl ?? '';
|
|
259
292
|
|
|
260
293
|
const layer = new COGLayer({
|
|
261
294
|
// Stable id per tab so rebuilds on band/style change don't force deck.gl
|
|
@@ -384,6 +417,7 @@ function cleanup() {
|
|
|
384
417
|
geotiffRef = null;
|
|
385
418
|
proj4DefRef = null;
|
|
386
419
|
pixelValue = null;
|
|
420
|
+
resolvedHttpsUrl = null;
|
|
387
421
|
}
|
|
388
422
|
|
|
389
423
|
$effect(() => {
|
|
@@ -468,6 +502,7 @@ onDestroy(cleanup);
|
|
|
468
502
|
{rescale}
|
|
469
503
|
rescaleApplicable={rescaleApplicable}
|
|
470
504
|
onRescaleChange={handleRescaleChange}
|
|
505
|
+
{histogram}
|
|
471
506
|
/>
|
|
472
507
|
{/if}
|
|
473
508
|
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Tab } from '../../types';
|
|
3
|
-
import {
|
|
3
|
+
import { buildHttpsUrlAsync } from '../../utils/url.js';
|
|
4
4
|
|
|
5
5
|
let { tab }: { tab: Tab } = $props();
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
let fileUrl = $state('');
|
|
8
|
+
|
|
9
|
+
$effect(() => {
|
|
10
|
+
const id = tab.id;
|
|
11
|
+
let cancelled = false;
|
|
12
|
+
(async () => {
|
|
13
|
+
if (tab.source === 'url') {
|
|
14
|
+
fileUrl = tab.path;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const url = await buildHttpsUrlAsync(tab);
|
|
18
|
+
if (cancelled || id !== tab.id) return;
|
|
19
|
+
fileUrl = url;
|
|
20
|
+
})();
|
|
21
|
+
return () => {
|
|
22
|
+
cancelled = true;
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
8
26
|
const viewerUrl = $derived(
|
|
9
27
|
fileUrl ? `https://viewer.copc.io/?copc=${encodeURIComponent(fileUrl)}` : ''
|
|
10
28
|
);
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
hoverCursor,
|
|
20
20
|
loadDeckModules
|
|
21
21
|
} from '../../utils/deck.js';
|
|
22
|
-
import {
|
|
22
|
+
import { buildHttpsUrlAsync } from '../../utils/url.js';
|
|
23
23
|
import AttributeTable from './map/AttributeTable.svelte';
|
|
24
24
|
import MapContainer from './map/MapContainer.svelte';
|
|
25
25
|
|
|
@@ -55,6 +55,7 @@ let mapReadyPromise: Promise<void> | null = null;
|
|
|
55
55
|
// Stored from preview for load-all (skip index)
|
|
56
56
|
let storedHeader: HeaderMeta | null = null;
|
|
57
57
|
let storedFeatureOffset = 0;
|
|
58
|
+
let signedUrl: string | null = null;
|
|
58
59
|
|
|
59
60
|
// proj4 converter for reprojecting from source CRS → WGS84
|
|
60
61
|
let proj4Forward: ((coord: [number, number]) => [number, number]) | null = null;
|
|
@@ -217,6 +218,7 @@ function cleanup() {
|
|
|
217
218
|
dataVersion = 0;
|
|
218
219
|
storedHeader = null;
|
|
219
220
|
storedFeatureOffset = 0;
|
|
221
|
+
signedUrl = null;
|
|
220
222
|
proj4Forward = null;
|
|
221
223
|
}
|
|
222
224
|
|
|
@@ -261,9 +263,14 @@ async function loadFlatGeobuf() {
|
|
|
261
263
|
await mapReadyPromise;
|
|
262
264
|
if (!overlay) return;
|
|
263
265
|
|
|
266
|
+
// Sign once per load so header + feature stream share the same signature.
|
|
267
|
+
// Cached across loadAllFeatures() so the "Load all" button doesn't re-sign.
|
|
268
|
+
const url = await buildHttpsUrlAsync(tab);
|
|
269
|
+
signedUrl = url;
|
|
270
|
+
|
|
264
271
|
// Read header via range requests (fast: 1-2 small requests)
|
|
265
272
|
// Gets metadata + feature offset to skip the spatial index
|
|
266
|
-
await readHeaderWithRangeRequests();
|
|
273
|
+
await readHeaderWithRangeRequests(url);
|
|
267
274
|
|
|
268
275
|
// Set up on-the-fly reprojection if the file uses a non-WGS84 CRS
|
|
269
276
|
proj4Forward = null;
|
|
@@ -298,7 +305,7 @@ async function loadFlatGeobuf() {
|
|
|
298
305
|
}
|
|
299
306
|
|
|
300
307
|
// Stream features (skips index if header was read, else sequential)
|
|
301
|
-
await streamFeatures(settings.featureLimit);
|
|
308
|
+
await streamFeatures(url, settings.featureLimit);
|
|
302
309
|
} catch (err) {
|
|
303
310
|
console.error('[FGB]', 'loadFlatGeobuf error:', err);
|
|
304
311
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
@@ -314,9 +321,7 @@ async function loadFlatGeobuf() {
|
|
|
314
321
|
* Read header via range requests (fast: 1-2 small requests).
|
|
315
322
|
* Stores header + feature offset for the composite stream approach.
|
|
316
323
|
*/
|
|
317
|
-
async function readHeaderWithRangeRequests(): Promise<boolean> {
|
|
318
|
-
const url = buildHttpsUrl(tab);
|
|
319
|
-
|
|
324
|
+
async function readHeaderWithRangeRequests(url: string): Promise<boolean> {
|
|
320
325
|
let reader: HttpReader;
|
|
321
326
|
try {
|
|
322
327
|
reader = await HttpReader.open(url, false);
|
|
@@ -357,7 +362,9 @@ async function loadAllFeatures() {
|
|
|
357
362
|
try {
|
|
358
363
|
features = [];
|
|
359
364
|
featureCount = 0;
|
|
360
|
-
await
|
|
365
|
+
const url = signedUrl ?? (await buildHttpsUrlAsync(tab));
|
|
366
|
+
signedUrl = url;
|
|
367
|
+
await streamFeatures(url);
|
|
361
368
|
} catch (err) {
|
|
362
369
|
console.error('[FGB]', 'loadAllFeatures error:', err);
|
|
363
370
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
@@ -372,10 +379,9 @@ async function loadAllFeatures() {
|
|
|
372
379
|
* Stream features sequentially.
|
|
373
380
|
* If storedHeader is available, skips the index with a Range request + composite stream.
|
|
374
381
|
*/
|
|
375
|
-
async function streamFeatures(limit?: number) {
|
|
382
|
+
async function streamFeatures(url: string, limit?: number) {
|
|
376
383
|
const ac = new AbortController();
|
|
377
384
|
abortController = ac;
|
|
378
|
-
const url = buildHttpsUrl(tab);
|
|
379
385
|
const t0 = performance.now();
|
|
380
386
|
|
|
381
387
|
let iter: AsyncGenerator;
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
3
|
+
import { MultiCOGLayer } from '@developmentseed/deck.gl-geotiff';
|
|
4
|
+
import { DecoderPool } from '@developmentseed/geotiff';
|
|
5
|
+
import type maplibregl from 'maplibre-gl';
|
|
6
|
+
import { onDestroy, untrack } from 'svelte';
|
|
7
|
+
import { t } from '../../i18n/index.svelte.js';
|
|
8
|
+
import { getAdapter } from '../../storage/index.js';
|
|
9
|
+
import { buildProviderBaseUrl, type ProviderId } from '../../storage/providers.js';
|
|
10
|
+
import { connectionStore } from '../../stores/connections.svelte.js';
|
|
11
|
+
import { tabResources } from '../../stores/tab-resources.svelte.js';
|
|
12
|
+
import type { Tab } from '../../types.js';
|
|
13
|
+
import {
|
|
14
|
+
buildBandRenderPipeline,
|
|
15
|
+
clampBounds,
|
|
16
|
+
cleanupNativeBitmap,
|
|
17
|
+
createEpsgResolver,
|
|
18
|
+
fitCogBounds,
|
|
19
|
+
type RescaleConfig
|
|
20
|
+
} from '../../utils/cog.js';
|
|
21
|
+
import {
|
|
22
|
+
type BandMap,
|
|
23
|
+
type BandSlot,
|
|
24
|
+
extractSentinelBandAssets,
|
|
25
|
+
hasRgbBands,
|
|
26
|
+
isStacItem,
|
|
27
|
+
type StacItem,
|
|
28
|
+
type StacRoutableKind
|
|
29
|
+
} from '../../utils/stac.js';
|
|
30
|
+
import { buildHttpsUrlAsync } from '../../utils/url.js';
|
|
31
|
+
import CogControls from './CogControls.svelte';
|
|
32
|
+
import MapContainer from './map/MapContainer.svelte';
|
|
33
|
+
|
|
34
|
+
interface Preset {
|
|
35
|
+
id: string;
|
|
36
|
+
labelKey: string;
|
|
37
|
+
composite: { r: BandSlot; g: BandSlot; b: BandSlot };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PRESETS: Preset[] = [
|
|
41
|
+
{
|
|
42
|
+
id: 'true-color',
|
|
43
|
+
labelKey: 'map.multiCogPreset.trueColor',
|
|
44
|
+
composite: { r: 'red', g: 'green', b: 'blue' }
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'false-color-ir',
|
|
48
|
+
labelKey: 'map.multiCogPreset.falseColorIR',
|
|
49
|
+
composite: { r: 'nir', g: 'red', b: 'green' }
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'swir',
|
|
53
|
+
labelKey: 'map.multiCogPreset.swir',
|
|
54
|
+
composite: { r: 'swir2', g: 'swir1', b: 'red' }
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'vegetation',
|
|
58
|
+
labelKey: 'map.multiCogPreset.vegetation',
|
|
59
|
+
composite: { r: 'nir', g: 'swir1', b: 'red' }
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'agriculture',
|
|
63
|
+
labelKey: 'map.multiCogPreset.agriculture',
|
|
64
|
+
composite: { r: 'swir1', g: 'nir', b: 'blue' }
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
let { tab, classified }: { tab: Tab; classified?: StacRoutableKind } = $props();
|
|
69
|
+
|
|
70
|
+
let loading = $state(true);
|
|
71
|
+
let error = $state<string | null>(null);
|
|
72
|
+
let showControls = $state(false);
|
|
73
|
+
let bounds = $state<[number, number, number, number] | undefined>();
|
|
74
|
+
let activePresetId = $state<string>('true-color');
|
|
75
|
+
// Sentinel-2 L2A reflectance is scaled uint16 (raw / 10000 = reflectance).
|
|
76
|
+
// After the default uint normalization the slider operates on 0..1, so 0.3
|
|
77
|
+
// keeps typical land surfaces in the visible range without clipping.
|
|
78
|
+
let rescale = $state<RescaleConfig>({ min: 0, max: 0.3 });
|
|
79
|
+
|
|
80
|
+
let bandMap = $state.raw<BandMap>({});
|
|
81
|
+
let abortController = new AbortController();
|
|
82
|
+
let mapRef: maplibregl.Map | null = null;
|
|
83
|
+
let overlayRef: MapboxOverlay | null = null;
|
|
84
|
+
let hasFittedOnce = false;
|
|
85
|
+
let presignCache = new Map<string, Promise<string>>();
|
|
86
|
+
let loadGen = 0;
|
|
87
|
+
let layerVersion = 0;
|
|
88
|
+
let rebuildTimer: number | null = null;
|
|
89
|
+
let lastRebuildAt = 0;
|
|
90
|
+
|
|
91
|
+
// Throttle rebuilds so the rescale slider (oninput, fires per pixel of drag)
|
|
92
|
+
// doesn't spawn N overlapping buildAndAddLayer calls that each addControl a
|
|
93
|
+
// new MapboxOverlay, leaking every overlay but the last.
|
|
94
|
+
const REBUILD_INTERVAL_MS = 750;
|
|
95
|
+
|
|
96
|
+
let pool: DecoderPool | null = new DecoderPool();
|
|
97
|
+
const epsgResolver = createEpsgResolver();
|
|
98
|
+
|
|
99
|
+
const activePreset = $derived(PRESETS.find((p) => p.id === activePresetId) ?? PRESETS[0]);
|
|
100
|
+
const availablePresets = $derived(
|
|
101
|
+
PRESETS.filter((p) => bandMap[p.composite.r] && bandMap[p.composite.g] && bandMap[p.composite.b])
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
$effect(() => {
|
|
105
|
+
if (!tab) return;
|
|
106
|
+
tab.id;
|
|
107
|
+
untrack(() => {
|
|
108
|
+
resetViewer();
|
|
109
|
+
if (mapRef) void loadItem(mapRef);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function resetViewer(): void {
|
|
114
|
+
abortController.abort();
|
|
115
|
+
abortController = new AbortController();
|
|
116
|
+
if (rebuildTimer != null) {
|
|
117
|
+
clearTimeout(rebuildTimer);
|
|
118
|
+
rebuildTimer = null;
|
|
119
|
+
}
|
|
120
|
+
lastRebuildAt = 0;
|
|
121
|
+
layerVersion = 0;
|
|
122
|
+
if (mapRef) cleanupNativeBitmap(mapRef);
|
|
123
|
+
if (mapRef && overlayRef) {
|
|
124
|
+
try {
|
|
125
|
+
mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
|
|
126
|
+
} catch {
|
|
127
|
+
/* already destroyed */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
overlayRef = null;
|
|
131
|
+
bandMap = {};
|
|
132
|
+
presignCache = new Map();
|
|
133
|
+
loading = true;
|
|
134
|
+
error = null;
|
|
135
|
+
bounds = undefined;
|
|
136
|
+
activePresetId = 'true-color';
|
|
137
|
+
rescale = { min: 0, max: 0.3 };
|
|
138
|
+
hasFittedOnce = false;
|
|
139
|
+
showControls = false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function scheduleLayerRebuild(map: maplibregl.Map, signal: AbortSignal): void {
|
|
143
|
+
if (rebuildTimer != null || signal.aborted) return;
|
|
144
|
+
const elapsed = performance.now() - lastRebuildAt;
|
|
145
|
+
const delay = lastRebuildAt === 0 ? 0 : Math.max(0, REBUILD_INTERVAL_MS - elapsed);
|
|
146
|
+
rebuildTimer = window.setTimeout(() => {
|
|
147
|
+
rebuildTimer = null;
|
|
148
|
+
if (signal.aborted) return;
|
|
149
|
+
lastRebuildAt = performance.now();
|
|
150
|
+
void buildAndAddLayer(map, ++layerVersion, signal);
|
|
151
|
+
}, delay);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onMapReady(map: maplibregl.Map): void {
|
|
155
|
+
mapRef = map;
|
|
156
|
+
void loadItem(map);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function extractConnectionKey(href: string): string | null {
|
|
160
|
+
const conn = tab.connectionId ? connectionStore.getById(tab.connectionId) : undefined;
|
|
161
|
+
if (!conn) return null;
|
|
162
|
+
const base = buildProviderBaseUrl(
|
|
163
|
+
conn.provider as ProviderId,
|
|
164
|
+
conn.endpoint,
|
|
165
|
+
conn.bucket,
|
|
166
|
+
conn.region
|
|
167
|
+
).replace(/\/$/, '');
|
|
168
|
+
if (!base) return null;
|
|
169
|
+
const prefix = `${base}/`;
|
|
170
|
+
if (!href.startsWith(prefix)) return null;
|
|
171
|
+
return href.slice(prefix.length);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function presignHref(href: string): Promise<string> {
|
|
175
|
+
let cached = presignCache.get(href);
|
|
176
|
+
if (!cached) {
|
|
177
|
+
if (/^https?:\/\//i.test(href)) {
|
|
178
|
+
// Absolute URLs that belong to the tab's own bucket still need SigV4
|
|
179
|
+
// presigning on private buckets — `new URL(rel, base)` strips the
|
|
180
|
+
// base's query string when absolutizing band hrefs, so the signature
|
|
181
|
+
// is lost and the bare URL 403s.
|
|
182
|
+
const key = extractConnectionKey(href);
|
|
183
|
+
if (key !== null) {
|
|
184
|
+
cached = buildHttpsUrlAsync({ ...tab, path: key } as Tab).catch(() => href);
|
|
185
|
+
} else {
|
|
186
|
+
cached = Promise.resolve(href);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
cached = buildHttpsUrlAsync({ ...tab, path: href } as Tab).catch(() => href);
|
|
190
|
+
}
|
|
191
|
+
presignCache.set(href, cached);
|
|
192
|
+
}
|
|
193
|
+
return cached;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function loadItem(map: maplibregl.Map): Promise<void> {
|
|
197
|
+
const gen = ++loadGen;
|
|
198
|
+
const signal = abortController.signal;
|
|
199
|
+
try {
|
|
200
|
+
let item: StacItem | null = null;
|
|
201
|
+
if (classified && classified.kind === 'item') {
|
|
202
|
+
item = classified.item;
|
|
203
|
+
} else {
|
|
204
|
+
const adapter = getAdapter(tab.source, tab.connectionId);
|
|
205
|
+
const data = await adapter.read(tab.path, undefined, undefined, signal);
|
|
206
|
+
if (gen !== loadGen || signal.aborted) return;
|
|
207
|
+
const parsed = JSON.parse(new TextDecoder().decode(data));
|
|
208
|
+
if (!isStacItem(parsed)) {
|
|
209
|
+
error = t('map.multiCogMissingBands');
|
|
210
|
+
loading = false;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
item = parsed as StacItem;
|
|
214
|
+
}
|
|
215
|
+
if (!item) {
|
|
216
|
+
error = t('map.multiCogMissingBands');
|
|
217
|
+
loading = false;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const bands = extractSentinelBandAssets(item);
|
|
222
|
+
if (!hasRgbBands(bands)) {
|
|
223
|
+
error = t('map.multiCogMissingBands');
|
|
224
|
+
loading = false;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
bandMap = bands;
|
|
228
|
+
|
|
229
|
+
if (Array.isArray(item.bbox) && item.bbox.length >= 4) {
|
|
230
|
+
const clamped = clampBounds({
|
|
231
|
+
west: Number(item.bbox[0]),
|
|
232
|
+
south: Number(item.bbox[1]),
|
|
233
|
+
east: Number(item.bbox[2]),
|
|
234
|
+
north: Number(item.bbox[3])
|
|
235
|
+
});
|
|
236
|
+
bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
|
|
237
|
+
if (!hasFittedOnce) {
|
|
238
|
+
fitCogBounds(map, clamped);
|
|
239
|
+
hasFittedOnce = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await buildAndAddLayer(map, ++layerVersion, signal);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (gen !== loadGen) return;
|
|
246
|
+
if (signal.aborted) return;
|
|
247
|
+
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
248
|
+
error = err instanceof Error ? err.message : String(err);
|
|
249
|
+
loading = false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function buildAndAddLayer(
|
|
254
|
+
map: maplibregl.Map,
|
|
255
|
+
version: number,
|
|
256
|
+
signal: AbortSignal
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
const composite = activePreset.composite;
|
|
259
|
+
const sources: Record<string, { url: string }> = {};
|
|
260
|
+
for (const slot of [composite.r, composite.g, composite.b]) {
|
|
261
|
+
const href = bandMap[slot];
|
|
262
|
+
if (!href) continue;
|
|
263
|
+
const url = await presignHref(href);
|
|
264
|
+
if (version !== layerVersion || signal.aborted) return;
|
|
265
|
+
sources[slot] = { url };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const layer = new MultiCOGLayer({
|
|
269
|
+
id: `multicog-${tab.id}-v${version}`,
|
|
270
|
+
sources,
|
|
271
|
+
composite: { r: composite.r, g: composite.g, b: composite.b },
|
|
272
|
+
renderPipeline: buildBandRenderPipeline({ noDataVal: 0, rescale: { ...rescale } }),
|
|
273
|
+
pool: pool ?? undefined,
|
|
274
|
+
epsgResolver,
|
|
275
|
+
signal,
|
|
276
|
+
onGeoTIFFLoad: (_tiffs, { geographicBounds }) => {
|
|
277
|
+
if (version !== layerVersion || signal.aborted) return;
|
|
278
|
+
const clamped = clampBounds(geographicBounds);
|
|
279
|
+
if (!hasFittedOnce) {
|
|
280
|
+
bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
|
|
281
|
+
fitCogBounds(map, clamped);
|
|
282
|
+
hasFittedOnce = true;
|
|
283
|
+
}
|
|
284
|
+
loading = false;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (overlayRef) {
|
|
289
|
+
overlayRef.setProps({ layers: [layer] });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const overlay = new MapboxOverlay({
|
|
294
|
+
interleaved: false,
|
|
295
|
+
layers: [layer],
|
|
296
|
+
onError: (err: Error) => {
|
|
297
|
+
if (signal.aborted) return;
|
|
298
|
+
if (!error) {
|
|
299
|
+
error = err?.message || String(err);
|
|
300
|
+
loading = false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
overlayRef = overlay;
|
|
305
|
+
map.addControl(overlay as unknown as maplibregl.IControl);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function setPreset(id: string): void {
|
|
309
|
+
activePresetId = id;
|
|
310
|
+
if (mapRef) scheduleLayerRebuild(mapRef, abortController.signal);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function handleRescaleChange(next: RescaleConfig): void {
|
|
314
|
+
rescale = next;
|
|
315
|
+
if (mapRef) scheduleLayerRebuild(mapRef, abortController.signal);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function cleanup(): void {
|
|
319
|
+
abortController.abort();
|
|
320
|
+
if (rebuildTimer != null) {
|
|
321
|
+
clearTimeout(rebuildTimer);
|
|
322
|
+
rebuildTimer = null;
|
|
323
|
+
}
|
|
324
|
+
if (mapRef && overlayRef) {
|
|
325
|
+
try {
|
|
326
|
+
mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
|
|
327
|
+
} catch {
|
|
328
|
+
/* already removed */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (mapRef) cleanupNativeBitmap(mapRef);
|
|
332
|
+
mapRef = null;
|
|
333
|
+
overlayRef = null;
|
|
334
|
+
bandMap = {};
|
|
335
|
+
presignCache.clear();
|
|
336
|
+
const maybeDestroy = pool as unknown as { destroy?: () => void; terminate?: () => void } | null;
|
|
337
|
+
if (maybeDestroy?.destroy) {
|
|
338
|
+
try {
|
|
339
|
+
maybeDestroy.destroy();
|
|
340
|
+
} catch {
|
|
341
|
+
/* ignore */
|
|
342
|
+
}
|
|
343
|
+
} else if (maybeDestroy?.terminate) {
|
|
344
|
+
try {
|
|
345
|
+
maybeDestroy.terminate();
|
|
346
|
+
} catch {
|
|
347
|
+
/* ignore */
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
pool = null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
$effect(() => {
|
|
354
|
+
const id = tab.id;
|
|
355
|
+
const unregister = tabResources.register(id, cleanup);
|
|
356
|
+
return unregister;
|
|
357
|
+
});
|
|
358
|
+
onDestroy(cleanup);
|
|
359
|
+
</script>
|
|
360
|
+
|
|
361
|
+
<div class="relative flex h-full overflow-hidden">
|
|
362
|
+
<div class="flex-1">
|
|
363
|
+
<MapContainer {onMapReady} {bounds} />
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col gap-1">
|
|
367
|
+
{#if loading}
|
|
368
|
+
<div class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
|
|
369
|
+
{t('map.loadingCog')}
|
|
370
|
+
</div>
|
|
371
|
+
{/if}
|
|
372
|
+
{#if error}
|
|
373
|
+
<div class="pointer-events-auto max-w-sm rounded bg-red-900/80 px-2 py-1 text-xs text-red-200">
|
|
374
|
+
{error}
|
|
375
|
+
</div>
|
|
376
|
+
{/if}
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
{#if !error && availablePresets.length > 0}
|
|
380
|
+
<div class="absolute right-2 top-2 z-10 flex items-center gap-1">
|
|
381
|
+
<label class="flex items-center gap-1 rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
|
|
382
|
+
<span class="text-muted-foreground">{t('map.multiCogPreset.label')}</span>
|
|
383
|
+
<select
|
|
384
|
+
class="rounded border border-border bg-background px-1 py-0.5 text-xs"
|
|
385
|
+
value={activePresetId}
|
|
386
|
+
onchange={(e) => setPreset((e.target as HTMLSelectElement).value)}
|
|
387
|
+
>
|
|
388
|
+
{#each availablePresets as p}
|
|
389
|
+
<option value={p.id}>{t(p.labelKey)}</option>
|
|
390
|
+
{/each}
|
|
391
|
+
</select>
|
|
392
|
+
</label>
|
|
393
|
+
<button
|
|
394
|
+
class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
|
|
395
|
+
class:ring-1={showControls}
|
|
396
|
+
class:ring-primary={showControls}
|
|
397
|
+
onclick={() => {
|
|
398
|
+
showControls = !showControls;
|
|
399
|
+
}}
|
|
400
|
+
>
|
|
401
|
+
{t('cog.style')}
|
|
402
|
+
</button>
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
{#if showControls}
|
|
406
|
+
<CogControls
|
|
407
|
+
mode="multi"
|
|
408
|
+
bandCount={3}
|
|
409
|
+
onConfigChange={() => {}}
|
|
410
|
+
{rescale}
|
|
411
|
+
rescaleApplicable={true}
|
|
412
|
+
onRescaleChange={handleRescaleChange}
|
|
413
|
+
/>
|
|
414
|
+
{/if}
|
|
415
|
+
{/if}
|
|
416
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Tab } from '../../types.js';
|
|
2
|
+
import { type StacRoutableKind } from '../../utils/stac.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
tab: Tab;
|
|
5
|
+
classified?: StacRoutableKind;
|
|
6
|
+
};
|
|
7
|
+
declare const MultiCogViewer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type MultiCogViewer = ReturnType<typeof MultiCogViewer>;
|
|
9
|
+
export default MultiCogViewer;
|
|
@@ -11,7 +11,7 @@ import { t } from '../../i18n/index.svelte.js';
|
|
|
11
11
|
import { tabResources } from '../../stores/tab-resources.svelte.js';
|
|
12
12
|
import type { Tab } from '../../types';
|
|
13
13
|
import { loadPmtiles, type PmtilesMetadata } from '../../utils/pmtiles';
|
|
14
|
-
import {
|
|
14
|
+
import { buildHttpsUrlAsync } from '../../utils/url.js';
|
|
15
15
|
import { getUrlView, updateUrlView } from '../../utils/url-state.js';
|
|
16
16
|
|
|
17
17
|
let { tab }: { tab: Tab } = $props();
|
|
@@ -73,7 +73,7 @@ async function load() {
|
|
|
73
73
|
error = null;
|
|
74
74
|
|
|
75
75
|
try {
|
|
76
|
-
pmtilesUrl =
|
|
76
|
+
pmtilesUrl = await buildHttpsUrlAsync(tab);
|
|
77
77
|
const result = await loadPmtiles(pmtilesUrl);
|
|
78
78
|
pmtilesInstance = result.pmtiles;
|
|
79
79
|
metadata = result.metadata;
|