@walkthru-earth/objex 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/components/layout/ConnectionDialog.svelte +35 -3
- package/dist/components/layout/Sidebar.svelte +1 -2
- package/dist/components/viewers/CodeViewer.svelte +51 -14
- 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 +75 -8
- package/dist/components/viewers/MultiCogViewer.svelte +416 -0
- package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
- package/dist/components/viewers/StacMapViewer.svelte +19 -5
- package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
- package/dist/components/viewers/StacMosaicViewer.svelte +785 -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/ViewerRouter.svelte +155 -2
- package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
- package/dist/components/viewers/ZarrMapViewer.svelte +143 -4
- package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
- package/dist/components/viewers/ZarrViewer.svelte +1 -0
- package/dist/i18n/ar.js +27 -0
- package/dist/i18n/en.js +27 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/query/stac-geoparquet.d.ts +31 -0
- package/dist/query/stac-geoparquet.js +136 -0
- package/dist/stores/connections.svelte.d.ts +38 -23
- package/dist/stores/connections.svelte.js +105 -114
- package/dist/utils/cog-pure.d.ts +25 -0
- package/dist/utils/cog-pure.js +35 -0
- package/dist/utils/cog.d.ts +88 -43
- package/dist/utils/cog.js +192 -152
- 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/zarr.d.ts +34 -0
- package/dist/utils/zarr.js +94 -0
- package/package.json +14 -13
|
@@ -11,12 +11,14 @@ import {
|
|
|
11
11
|
type BandConfig,
|
|
12
12
|
buildDataTypeLabel,
|
|
13
13
|
type CogInfo,
|
|
14
|
+
type CustomTileData,
|
|
14
15
|
clampBounds,
|
|
15
16
|
cleanupNativeBitmap,
|
|
16
17
|
createEpsgResolver,
|
|
17
18
|
DEFAULT_RESCALE,
|
|
18
19
|
defaultBandConfig,
|
|
19
20
|
fitCogBounds,
|
|
21
|
+
HISTOGRAM_BIN_COUNT,
|
|
20
22
|
inspectCogTags,
|
|
21
23
|
needsCustomPipelineForConfig,
|
|
22
24
|
normalizeCogGeotiff,
|
|
@@ -41,6 +43,8 @@ let showControls = $state(false);
|
|
|
41
43
|
let bounds = $state<[number, number, number, number] | undefined>();
|
|
42
44
|
let cogInfo = $state<CogInfo | null>(null);
|
|
43
45
|
let bandConfig = $state<BandConfig | null>(null);
|
|
46
|
+
let histogram = $state.raw<Uint32Array | null>(null);
|
|
47
|
+
let histogramTick = $state(0);
|
|
44
48
|
let rescale = $state<RescaleConfig>({ ...DEFAULT_RESCALE });
|
|
45
49
|
// Palette-indexed COGs render through the library's Colormap module; a GPU
|
|
46
50
|
// rescale at that stage is cosmetic and would confuse the legend. Keep the
|
|
@@ -58,16 +62,19 @@ let sampleFormatRef = 1;
|
|
|
58
62
|
let isTiledRef = true;
|
|
59
63
|
let clickHandlerRef: ((e: maplibregl.MapMouseEvent) => void) | null = null;
|
|
60
64
|
let resolvedHttpsUrl: string | null = null;
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// `
|
|
65
|
-
//
|
|
65
|
+
// LinearRescale operates on a 0..1 scalar. Two cases expose a meaningful
|
|
66
|
+
// slider: (1) the library-default uint RGB pipeline (scales `color.rgb`
|
|
67
|
+
// before presentation), and (2) our custom single-band CPU + GPU Colormap
|
|
68
|
+
// path (scales `color.r` before the ramp sample). Palette COGs hide the
|
|
69
|
+
// slider, the embedded ColorMap tag already bakes the display colors.
|
|
70
|
+
// `needsCustomPipelineForConfig` only touches read-only tags, safe to call
|
|
71
|
+
// outside reactive tracking.
|
|
66
72
|
const rescaleApplicable = $derived.by(() => {
|
|
67
73
|
if (!cogInfo || !bandConfig || isPaletteIndexed) return false;
|
|
68
74
|
const g = geotiffRef;
|
|
69
75
|
if (!g) return false;
|
|
70
|
-
|
|
76
|
+
if (!needsCustomPipelineForConfig(g, bandConfig)) return true;
|
|
77
|
+
return bandConfig.mode === 'single';
|
|
71
78
|
});
|
|
72
79
|
// Tracks whether the camera has already been framed for the current tab.
|
|
73
80
|
// Prevents fitCogBounds from resetting the user's view when the band/style
|
|
@@ -110,6 +117,8 @@ $effect(() => {
|
|
|
110
117
|
error = null;
|
|
111
118
|
cogInfo = null;
|
|
112
119
|
bandConfig = null;
|
|
120
|
+
histogram = null;
|
|
121
|
+
histogramTick = 0;
|
|
113
122
|
rescale = { ...DEFAULT_RESCALE };
|
|
114
123
|
isPaletteIndexed = false;
|
|
115
124
|
pixelValue = null;
|
|
@@ -274,7 +283,10 @@ function buildAndAddLayer(
|
|
|
274
283
|
|
|
275
284
|
const cogInput = preflightGeotiff ?? resolvedHttpsUrl ?? '';
|
|
276
285
|
|
|
277
|
-
|
|
286
|
+
// Cast: `onViewportLoad` is forwarded by our pnpm patch to the inner
|
|
287
|
+
// TileLayer, but COGLayer's generated .d.ts does not expose it.
|
|
288
|
+
// biome-ignore lint/suspicious/noExplicitAny: upstream prop not yet in types
|
|
289
|
+
const cogProps: any = {
|
|
278
290
|
// Stable id per tab so rebuilds on band/style change don't force deck.gl
|
|
279
291
|
// to treat this as a brand-new layer and drop cached tile state.
|
|
280
292
|
id: `cog-layer-${tab.id}`,
|
|
@@ -283,6 +295,15 @@ function buildAndAddLayer(
|
|
|
283
295
|
epsgResolver,
|
|
284
296
|
signal,
|
|
285
297
|
...customProps,
|
|
298
|
+
// COG-native histogram: sum `content.histogram` over tiles currently
|
|
299
|
+
// visible in the viewport. Fires after every pan/zoom settles and
|
|
300
|
+
// reuses deck.gl's tile cache for free, cached tiles still carry
|
|
301
|
+
// their per-tile histogram so no rebake is needed on revisit.
|
|
302
|
+
onViewportLoad: (visibleTiles: unknown) => {
|
|
303
|
+
aggregateVisibleHistogram(
|
|
304
|
+
visibleTiles as ReadonlyArray<{ content?: unknown } | null | undefined>
|
|
305
|
+
);
|
|
306
|
+
},
|
|
286
307
|
onGeoTIFFLoad: (
|
|
287
308
|
loadedTiff: GeoTIFF,
|
|
288
309
|
{
|
|
@@ -329,7 +350,8 @@ function buildAndAddLayer(
|
|
|
329
350
|
}
|
|
330
351
|
loading = false;
|
|
331
352
|
}
|
|
332
|
-
}
|
|
353
|
+
};
|
|
354
|
+
const layer = new COGLayer(cogProps);
|
|
333
355
|
|
|
334
356
|
const overlay = new MapboxOverlay({
|
|
335
357
|
interleaved: false,
|
|
@@ -346,10 +368,54 @@ function buildAndAddLayer(
|
|
|
346
368
|
map.addControl(overlay as unknown as maplibregl.IControl);
|
|
347
369
|
}
|
|
348
370
|
|
|
371
|
+
// ─── Viewport-scoped histogram aggregation ───────────────────────
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Sum per-tile histograms from tiles currently visible in the viewport. COG
|
|
375
|
+
* pyramid semantics map cleanly: zoomed out → a handful of low-res overview
|
|
376
|
+
* tiles cover the whole scene; zoomed in → only the tiles intersecting the
|
|
377
|
+
* AOI are decoded. deck.gl reuses its tile cache on revisits so each cached
|
|
378
|
+
* tile still carries `content.histogram`, no rebake needed.
|
|
379
|
+
*/
|
|
380
|
+
function aggregateVisibleHistogram(
|
|
381
|
+
visibleTiles: ReadonlyArray<{ content?: unknown } | null | undefined>
|
|
382
|
+
): void {
|
|
383
|
+
if (!visibleTiles || visibleTiles.length === 0) {
|
|
384
|
+
histogram = null;
|
|
385
|
+
histogramTick++;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const summed = new Uint32Array(HISTOGRAM_BIN_COUNT);
|
|
389
|
+
let found = false;
|
|
390
|
+
for (const tile of visibleTiles) {
|
|
391
|
+
// COGLayer wraps our baker's return as `{data, forwardTransform,
|
|
392
|
+
// inverseTransform}` in `_getTileData`, so the histogram lives at
|
|
393
|
+
// `content.data.histogram`. MosaicLayer's sub-COGs follow the same
|
|
394
|
+
// shape. Fall back to `content.histogram` for future-proofing if
|
|
395
|
+
// upstream ever stops wrapping.
|
|
396
|
+
const content = tile?.content as
|
|
397
|
+
| { data?: CustomTileData; histogram?: Uint32Array }
|
|
398
|
+
| null
|
|
399
|
+
| undefined;
|
|
400
|
+
const bins = content?.data?.histogram ?? content?.histogram;
|
|
401
|
+
if (!bins || bins.length !== HISTOGRAM_BIN_COUNT) continue;
|
|
402
|
+
for (let i = 0; i < HISTOGRAM_BIN_COUNT; i++) summed[i] += bins[i];
|
|
403
|
+
found = true;
|
|
404
|
+
}
|
|
405
|
+
histogram = found ? summed : null;
|
|
406
|
+
histogramTick++;
|
|
407
|
+
}
|
|
408
|
+
|
|
349
409
|
// ─── Rebuild layer on band config change ─────────────────────────
|
|
350
410
|
|
|
351
411
|
function handleConfigChange(newConfig: BandConfig) {
|
|
352
412
|
bandConfig = newConfig;
|
|
413
|
+
// Only the single-band CPU baker emits `onHistogram`. Clear the buffer on
|
|
414
|
+
// every mode/band change so (a) switching back to RGB hides stale bars
|
|
415
|
+
// that the rescale slider would otherwise draw on top of, and (b) picking
|
|
416
|
+
// a different single band starts a fresh distribution.
|
|
417
|
+
histogram = null;
|
|
418
|
+
histogramTick = 0;
|
|
353
419
|
if (!mapRef || !geotiffRef || !isTiledRef) return;
|
|
354
420
|
|
|
355
421
|
// Remove old overlay
|
|
@@ -486,6 +552,7 @@ onDestroy(cleanup);
|
|
|
486
552
|
{rescale}
|
|
487
553
|
rescaleApplicable={rescaleApplicable}
|
|
488
554
|
onRescaleChange={handleRescaleChange}
|
|
555
|
+
{histogram}
|
|
489
556
|
/>
|
|
490
557
|
{/if}
|
|
491
558
|
|
|
@@ -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;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { Tab } from '../../types';
|
|
3
3
|
import { buildHttpsUrlAsync } from '../../utils/url.js';
|
|
4
4
|
|
|
5
|
-
let { tab }: { tab: Tab } = $props();
|
|
5
|
+
let { tab, variant = 'stac-map' }: { tab: Tab; variant?: 'stac-map' | 'stac-browser' } = $props();
|
|
6
6
|
|
|
7
7
|
let fileUrl = $state('');
|
|
8
8
|
|
|
@@ -19,9 +19,23 @@ $effect(() => {
|
|
|
19
19
|
};
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
const iframeSrc = $derived(
|
|
23
|
-
|
|
24
|
-
)
|
|
22
|
+
const iframeSrc = $derived.by(() => {
|
|
23
|
+
if (!fileUrl) return '';
|
|
24
|
+
if (variant === 'stac-browser') {
|
|
25
|
+
// Radiant Earth STAC Browser is a Vue Router SPA. Its
|
|
26
|
+
// `#/external/<url>` route takes the catalog URL verbatim, splitting on
|
|
27
|
+
// `/`. Feeding it an `encodeURIComponent`-encoded URL makes the router
|
|
28
|
+
// hand `https%3A%2F%2F…` to `new URL()`, which reads the collapsed
|
|
29
|
+
// authority as a malformed port and throws
|
|
30
|
+
// `Port "%2F%2Fstorage.googleapis.com%2F…" is not a valid port`.
|
|
31
|
+
// Only escape the `#` character (which would otherwise terminate the
|
|
32
|
+
// hash route) so the rest of the URL flows through intact.
|
|
33
|
+
return `https://radiantearth.github.io/stac-browser/#/external/${fileUrl.replace(/#/g, '%23')}`;
|
|
34
|
+
}
|
|
35
|
+
return `https://developmentseed.org/stac-map?href=${encodeURIComponent(fileUrl)}`;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const iframeTitle = $derived(variant === 'stac-browser' ? 'STAC Browser' : 'stac-map');
|
|
25
39
|
</script>
|
|
26
40
|
|
|
27
41
|
<div class="relative flex h-full overflow-hidden">
|
|
@@ -29,7 +43,7 @@ const iframeSrc = $derived(
|
|
|
29
43
|
<iframe
|
|
30
44
|
src={iframeSrc}
|
|
31
45
|
class="h-full w-full border-0"
|
|
32
|
-
title=
|
|
46
|
+
title={iframeTitle}
|
|
33
47
|
allow="fullscreen"
|
|
34
48
|
></iframe>
|
|
35
49
|
{/if}
|