@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
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
3
|
+
import { COGLayer, MosaicLayer } from '@developmentseed/deck.gl-geotiff';
|
|
4
|
+
import { DecoderPool, GeoTIFF } 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 { queryStacGeoparquetFeatureCollection } from '../../query/stac-geoparquet.js';
|
|
9
|
+
import { getAdapter } from '../../storage/index.js';
|
|
10
|
+
import { buildProviderBaseUrl, type ProviderId } from '../../storage/providers.js';
|
|
11
|
+
import { connectionStore } from '../../stores/connections.svelte.js';
|
|
12
|
+
import { tabResources } from '../../stores/tab-resources.svelte.js';
|
|
13
|
+
import type { Tab } from '../../types.js';
|
|
14
|
+
import {
|
|
15
|
+
type BandConfig,
|
|
16
|
+
buildDataTypeLabel,
|
|
17
|
+
clampBounds,
|
|
18
|
+
cleanupNativeBitmap,
|
|
19
|
+
createEpsgResolver,
|
|
20
|
+
DEFAULT_RESCALE,
|
|
21
|
+
defaultBandConfig,
|
|
22
|
+
fitCogBounds,
|
|
23
|
+
normalizeCogGeotiff,
|
|
24
|
+
type PixelValue,
|
|
25
|
+
type RescaleConfig,
|
|
26
|
+
readPixelAtLngLat,
|
|
27
|
+
resolveProj4Def,
|
|
28
|
+
selectCogPipeline
|
|
29
|
+
} from '../../utils/cog.js';
|
|
30
|
+
import {
|
|
31
|
+
buildMosaicSourceMeta,
|
|
32
|
+
classifyStac,
|
|
33
|
+
type MosaicSourceMeta,
|
|
34
|
+
type StacRoutableKind
|
|
35
|
+
} from '../../utils/stac.js';
|
|
36
|
+
import { hydrateStacItems } from '../../utils/stac-hydrate.js';
|
|
37
|
+
import { buildHttpsUrlAsync } from '../../utils/url.js';
|
|
38
|
+
import CogControls from './CogControls.svelte';
|
|
39
|
+
import MapContainer from './map/MapContainer.svelte';
|
|
40
|
+
|
|
41
|
+
let { tab, classified }: { tab: Tab; classified?: StacRoutableKind } = $props();
|
|
42
|
+
|
|
43
|
+
let loading = $state(true);
|
|
44
|
+
let error = $state<string | null>(null);
|
|
45
|
+
let showControls = $state(false);
|
|
46
|
+
let showInfo = $state(false);
|
|
47
|
+
let sourceCount = $state(0);
|
|
48
|
+
let bounds = $state<[number, number, number, number] | undefined>();
|
|
49
|
+
let bandConfig = $state<BandConfig | null>(null);
|
|
50
|
+
let histogram = $state.raw<Uint32Array | null>(null);
|
|
51
|
+
let rescale = $state<RescaleConfig>({ ...DEFAULT_RESCALE });
|
|
52
|
+
let detectedBandCount = $state<number>(3);
|
|
53
|
+
let detectedDataType = $state<string>('');
|
|
54
|
+
let probedBandCount = false;
|
|
55
|
+
|
|
56
|
+
// ─── Pixel inspection ───────────────────────────────────────────
|
|
57
|
+
let pixelValue = $state<PixelValue | null>(null);
|
|
58
|
+
let pixelSourceId = $state<string | null>(null);
|
|
59
|
+
let inspecting = $state(false);
|
|
60
|
+
let clickHandlerRef: ((e: maplibregl.MapMouseEvent) => void) | null = null;
|
|
61
|
+
// Reuse GeoTIFFs resolved by MosaicLayer's `getSource` callback so click
|
|
62
|
+
// handlers don't trigger a second HTTP fetch. Keyed by `source.id`.
|
|
63
|
+
let geotiffCache = new Map<string, Promise<GeoTIFF>>();
|
|
64
|
+
|
|
65
|
+
let abortController = new AbortController();
|
|
66
|
+
let mapRef: maplibregl.Map | null = null;
|
|
67
|
+
let overlayRef: MapboxOverlay | null = null;
|
|
68
|
+
let itemsRef = $state.raw<MosaicSourceMeta[]>([]);
|
|
69
|
+
let hasFittedOnce = false;
|
|
70
|
+
let rebuildTimer: number | null = null;
|
|
71
|
+
let lastRebuildAt = 0;
|
|
72
|
+
let layerVersion = 0;
|
|
73
|
+
let presignCache = new Map<string, Promise<string>>();
|
|
74
|
+
let loadGen = 0;
|
|
75
|
+
|
|
76
|
+
// MosaicLayer builds a Flatbush spatial index at construction; deck.gl reuses
|
|
77
|
+
// the existing internal tileset when only props change, so the index never
|
|
78
|
+
// picks up new sources. Minimum interval between rebuilds + version-bumped id
|
|
79
|
+
// forces deck.gl to mount a fresh MosaicLayer with a rebuilt index, at the
|
|
80
|
+
// cost of discarding the tile cache. 750ms balances progressive feedback
|
|
81
|
+
// against cache churn.
|
|
82
|
+
const REBUILD_INTERVAL_MS = 750;
|
|
83
|
+
|
|
84
|
+
let pool: DecoderPool | null = new DecoderPool();
|
|
85
|
+
const epsgResolver = createEpsgResolver();
|
|
86
|
+
|
|
87
|
+
$effect(() => {
|
|
88
|
+
if (!tab) return;
|
|
89
|
+
tab.id;
|
|
90
|
+
untrack(() => {
|
|
91
|
+
resetViewer();
|
|
92
|
+
if (mapRef) void loadMosaic(mapRef);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function resetViewer(): void {
|
|
97
|
+
abortController.abort();
|
|
98
|
+
abortController = new AbortController();
|
|
99
|
+
if (rebuildTimer != null) {
|
|
100
|
+
clearTimeout(rebuildTimer);
|
|
101
|
+
rebuildTimer = null;
|
|
102
|
+
}
|
|
103
|
+
lastRebuildAt = 0;
|
|
104
|
+
layerVersion = 0;
|
|
105
|
+
if (mapRef) cleanupNativeBitmap(mapRef);
|
|
106
|
+
if (mapRef && overlayRef) {
|
|
107
|
+
try {
|
|
108
|
+
mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
|
|
109
|
+
} catch {
|
|
110
|
+
/* map already destroyed */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
overlayRef = null;
|
|
114
|
+
itemsRef = [];
|
|
115
|
+
presignCache = new Map();
|
|
116
|
+
geotiffCache = new Map();
|
|
117
|
+
loading = true;
|
|
118
|
+
error = null;
|
|
119
|
+
sourceCount = 0;
|
|
120
|
+
bounds = undefined;
|
|
121
|
+
bandConfig = null;
|
|
122
|
+
histogram = null;
|
|
123
|
+
rescale = { ...DEFAULT_RESCALE };
|
|
124
|
+
hasFittedOnce = false;
|
|
125
|
+
showControls = false;
|
|
126
|
+
showInfo = false;
|
|
127
|
+
detectedBandCount = 3;
|
|
128
|
+
detectedDataType = '';
|
|
129
|
+
probedBandCount = false;
|
|
130
|
+
pixelValue = null;
|
|
131
|
+
pixelSourceId = null;
|
|
132
|
+
inspecting = false;
|
|
133
|
+
if (mapRef) removeClickHandler();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function removeClickHandler(): void {
|
|
137
|
+
if (mapRef && clickHandlerRef) {
|
|
138
|
+
mapRef.off('click', clickHandlerRef);
|
|
139
|
+
clickHandlerRef = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function setupClickHandler(map: maplibregl.Map): void {
|
|
144
|
+
removeClickHandler();
|
|
145
|
+
clickHandlerRef = async (e: maplibregl.MapMouseEvent) => {
|
|
146
|
+
// Find the topmost source whose bbox contains the click. `itemsRef`
|
|
147
|
+
// is z-ordered by the mosaic so the last matching entry wins, matching
|
|
148
|
+
// MosaicLayer's tile compositing order.
|
|
149
|
+
const lng = e.lngLat.lng;
|
|
150
|
+
const lat = e.lngLat.lat;
|
|
151
|
+
const items = itemsRef;
|
|
152
|
+
let hit: MosaicSourceMeta | undefined;
|
|
153
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
154
|
+
const [w, s, east, n] = items[i].bbox;
|
|
155
|
+
if (lng >= w && lng <= east && lat >= s && lat <= n) {
|
|
156
|
+
hit = items[i];
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!hit) {
|
|
161
|
+
pixelValue = null;
|
|
162
|
+
pixelSourceId = null;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
inspecting = true;
|
|
166
|
+
try {
|
|
167
|
+
// Pull from cache; if absent (user clicked before any tile fetched
|
|
168
|
+
// this source), kick off a fresh load and cache it for later.
|
|
169
|
+
let geotiffPromise = geotiffCache.get(hit.id);
|
|
170
|
+
if (!geotiffPromise) {
|
|
171
|
+
geotiffPromise = (async () => {
|
|
172
|
+
const url = await presignHref(hit.href);
|
|
173
|
+
const g = await GeoTIFF.fromUrl(url);
|
|
174
|
+
normalizeCogGeotiff(g);
|
|
175
|
+
return g;
|
|
176
|
+
})();
|
|
177
|
+
geotiffCache.set(hit.id, geotiffPromise);
|
|
178
|
+
}
|
|
179
|
+
const geotiff = await geotiffPromise;
|
|
180
|
+
const proj4Def = await resolveProj4Def(geotiff.crs, abortController.signal);
|
|
181
|
+
const result = await readPixelAtLngLat(
|
|
182
|
+
geotiff,
|
|
183
|
+
lng,
|
|
184
|
+
lat,
|
|
185
|
+
proj4Def,
|
|
186
|
+
pool,
|
|
187
|
+
abortController.signal
|
|
188
|
+
);
|
|
189
|
+
pixelValue = result;
|
|
190
|
+
pixelSourceId = hit.id;
|
|
191
|
+
} catch {
|
|
192
|
+
pixelValue = null;
|
|
193
|
+
pixelSourceId = null;
|
|
194
|
+
} finally {
|
|
195
|
+
inspecting = false;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
map.on('click', clickHandlerRef);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function onMapReady(map: maplibregl.Map): void {
|
|
202
|
+
mapRef = map;
|
|
203
|
+
setupClickHandler(map);
|
|
204
|
+
void loadMosaic(map);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function extractConnectionKey(href: string): string | null {
|
|
208
|
+
const conn = tab.connectionId ? connectionStore.getById(tab.connectionId) : undefined;
|
|
209
|
+
if (!conn) return null;
|
|
210
|
+
const base = buildProviderBaseUrl(
|
|
211
|
+
conn.provider as ProviderId,
|
|
212
|
+
conn.endpoint,
|
|
213
|
+
conn.bucket,
|
|
214
|
+
conn.region
|
|
215
|
+
).replace(/\/$/, '');
|
|
216
|
+
if (!base) return null;
|
|
217
|
+
const prefix = `${base}/`;
|
|
218
|
+
if (!href.startsWith(prefix)) return null;
|
|
219
|
+
return href.slice(prefix.length);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function presignHref(href: string): Promise<string> {
|
|
223
|
+
let cached = presignCache.get(href);
|
|
224
|
+
if (!cached) {
|
|
225
|
+
if (/^https?:\/\//i.test(href)) {
|
|
226
|
+
// Absolute URLs that belong to the tab's own bucket still need SigV4
|
|
227
|
+
// presigning on private buckets (GCS/S3) — `new URL(rel, base)` strips
|
|
228
|
+
// the base's query string when absolutizing asset hrefs, so the
|
|
229
|
+
// signature is lost and the bare URL 403s.
|
|
230
|
+
const key = extractConnectionKey(href);
|
|
231
|
+
if (key !== null) {
|
|
232
|
+
cached = buildHttpsUrlAsync({ ...tab, path: key } as Tab).catch(() => href);
|
|
233
|
+
} else {
|
|
234
|
+
cached = Promise.resolve(href);
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
cached = buildHttpsUrlAsync({ ...tab, path: href } as Tab).catch(() => href);
|
|
238
|
+
}
|
|
239
|
+
presignCache.set(href, cached);
|
|
240
|
+
}
|
|
241
|
+
return cached;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function extendBounds(
|
|
245
|
+
current: [number, number, number, number] | null,
|
|
246
|
+
items: MosaicSourceMeta[]
|
|
247
|
+
): [number, number, number, number] | null {
|
|
248
|
+
if (items.length === 0) return current;
|
|
249
|
+
let [w, s, e, n] = current ?? items[0].bbox;
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
w = Math.min(w, item.bbox[0]);
|
|
252
|
+
s = Math.min(s, item.bbox[1]);
|
|
253
|
+
e = Math.max(e, item.bbox[2]);
|
|
254
|
+
n = Math.max(n, item.bbox[3]);
|
|
255
|
+
}
|
|
256
|
+
const clamped = clampBounds({ west: w, south: s, east: e, north: n });
|
|
257
|
+
return [clamped.west, clamped.south, clamped.east, clamped.north];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function scheduleLayerRebuild(map: maplibregl.Map, signal: AbortSignal): void {
|
|
261
|
+
if (rebuildTimer != null || signal.aborted) return;
|
|
262
|
+
const elapsed = performance.now() - lastRebuildAt;
|
|
263
|
+
const delay = lastRebuildAt === 0 ? 0 : Math.max(0, REBUILD_INTERVAL_MS - elapsed);
|
|
264
|
+
rebuildTimer = window.setTimeout(() => {
|
|
265
|
+
rebuildTimer = null;
|
|
266
|
+
if (signal.aborted) return;
|
|
267
|
+
lastRebuildAt = performance.now();
|
|
268
|
+
buildOrUpdateLayer(map, signal);
|
|
269
|
+
}, delay);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function flushPendingRebuild(map: maplibregl.Map, signal: AbortSignal): void {
|
|
273
|
+
if (rebuildTimer != null) {
|
|
274
|
+
clearTimeout(rebuildTimer);
|
|
275
|
+
rebuildTimer = null;
|
|
276
|
+
}
|
|
277
|
+
if (signal.aborted) return;
|
|
278
|
+
lastRebuildAt = performance.now();
|
|
279
|
+
buildOrUpdateLayer(map, signal);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function loadMosaic(map: maplibregl.Map): Promise<void> {
|
|
283
|
+
const gen = ++loadGen;
|
|
284
|
+
const signal = abortController.signal;
|
|
285
|
+
try {
|
|
286
|
+
const adapter = getAdapter(tab.source, tab.connectionId);
|
|
287
|
+
const ext = (tab.extension ?? '').toLowerCase();
|
|
288
|
+
|
|
289
|
+
// stac-geoparquet path: DuckDB materializes the full FeatureCollection
|
|
290
|
+
// in one query, so hydration is a single batch (no link walking).
|
|
291
|
+
if (ext === 'parquet' || ext === 'geoparquet') {
|
|
292
|
+
const fc = await queryStacGeoparquetFeatureCollection(tab, tab.connectionId ?? '', {
|
|
293
|
+
signal,
|
|
294
|
+
limit: 2000
|
|
295
|
+
});
|
|
296
|
+
if (gen !== loadGen || signal.aborted) return;
|
|
297
|
+
if (fc.features.length === 0) {
|
|
298
|
+
error = t('map.mosaicEmpty');
|
|
299
|
+
loading = false;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
await ingestParquetFeatures(map, fc.features, signal, gen);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let kind: StacRoutableKind;
|
|
307
|
+
if (classified && classified.kind !== 'none') {
|
|
308
|
+
kind = classified;
|
|
309
|
+
} else {
|
|
310
|
+
const data = await adapter.read(tab.path, undefined, undefined, signal);
|
|
311
|
+
if (gen !== loadGen || signal.aborted) return;
|
|
312
|
+
const parsed = JSON.parse(new TextDecoder().decode(data));
|
|
313
|
+
kind = classifyStac(parsed);
|
|
314
|
+
}
|
|
315
|
+
if (kind.kind === 'none') {
|
|
316
|
+
error = t('map.mosaicEmpty');
|
|
317
|
+
loading = false;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let runningBounds: [number, number, number, number] | null = null;
|
|
322
|
+
// Resolve tab.path to an absolute URL so relative hrefs in the manifest
|
|
323
|
+
// (e.g. `./item.json`) resolve against the real parent directory. For
|
|
324
|
+
// bucket-connection tabs, tab.path is a bucket-relative key and would not
|
|
325
|
+
// be a valid URL base.
|
|
326
|
+
const baseHref = await buildHttpsUrlAsync(tab);
|
|
327
|
+
if (gen !== loadGen || signal.aborted) return;
|
|
328
|
+
|
|
329
|
+
await hydrateStacItems(kind, baseHref, adapter, {
|
|
330
|
+
signal,
|
|
331
|
+
concurrency: 12,
|
|
332
|
+
limit: 2000,
|
|
333
|
+
urlToKey: extractConnectionKey,
|
|
334
|
+
onBatch: (batch) => {
|
|
335
|
+
if (gen !== loadGen || signal.aborted) return;
|
|
336
|
+
const accepted: MosaicSourceMeta[] = [];
|
|
337
|
+
for (const item of batch) {
|
|
338
|
+
const normalized = buildMosaicSourceMeta(item);
|
|
339
|
+
if (normalized) accepted.push(normalized);
|
|
340
|
+
}
|
|
341
|
+
if (accepted.length === 0) return;
|
|
342
|
+
|
|
343
|
+
for (const src of accepted) presignHref(src.href);
|
|
344
|
+
|
|
345
|
+
itemsRef = [...itemsRef, ...accepted];
|
|
346
|
+
sourceCount = itemsRef.length;
|
|
347
|
+
|
|
348
|
+
runningBounds = extendBounds(runningBounds, accepted);
|
|
349
|
+
// Only fit the camera once, on the first batch with a valid bbox.
|
|
350
|
+
// Re-assigning `bounds` on later batches would cause MapContainer
|
|
351
|
+
// to re-fly every 12-item batch, making the map unusable until
|
|
352
|
+
// hydration completes.
|
|
353
|
+
if (!hasFittedOnce && runningBounds) {
|
|
354
|
+
bounds = runningBounds;
|
|
355
|
+
fitCogBounds(map, {
|
|
356
|
+
west: runningBounds[0],
|
|
357
|
+
south: runningBounds[1],
|
|
358
|
+
east: runningBounds[2],
|
|
359
|
+
north: runningBounds[3]
|
|
360
|
+
});
|
|
361
|
+
hasFittedOnce = true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!bandConfig) bandConfig = defaultBandConfig(detectedBandCount, 1);
|
|
365
|
+
scheduleLayerRebuild(map, signal);
|
|
366
|
+
loading = false;
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (gen !== loadGen) return;
|
|
371
|
+
if (itemsRef.length === 0 && !signal.aborted) {
|
|
372
|
+
error = t('map.mosaicNoAssets');
|
|
373
|
+
loading = false;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// Final rebuild once hydration completes so every source is in the
|
|
377
|
+
// index, even if the last batch landed inside the throttle window.
|
|
378
|
+
if (!signal.aborted) flushPendingRebuild(map, signal);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (gen !== loadGen) return;
|
|
381
|
+
if (signal.aborted) return;
|
|
382
|
+
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
383
|
+
error = err instanceof Error ? err.message : String(err);
|
|
384
|
+
loading = false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Single-batch ingestion path for stac-geoparquet (already materialized). */
|
|
389
|
+
async function ingestParquetFeatures(
|
|
390
|
+
map: maplibregl.Map,
|
|
391
|
+
features: import('../../utils/stac.js').StacItem[],
|
|
392
|
+
signal: AbortSignal,
|
|
393
|
+
gen: number
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
const accepted: MosaicSourceMeta[] = [];
|
|
396
|
+
for (const item of features) {
|
|
397
|
+
const normalized = buildMosaicSourceMeta(item);
|
|
398
|
+
if (normalized) accepted.push(normalized);
|
|
399
|
+
}
|
|
400
|
+
if (gen !== loadGen || signal.aborted) return;
|
|
401
|
+
if (accepted.length === 0) {
|
|
402
|
+
error = t('map.mosaicNoAssets');
|
|
403
|
+
loading = false;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
for (const src of accepted) presignHref(src.href);
|
|
407
|
+
|
|
408
|
+
itemsRef = accepted;
|
|
409
|
+
sourceCount = itemsRef.length;
|
|
410
|
+
|
|
411
|
+
let runningBounds: [number, number, number, number] | null = null;
|
|
412
|
+
runningBounds = extendBounds(runningBounds, accepted);
|
|
413
|
+
if (runningBounds) {
|
|
414
|
+
bounds = runningBounds;
|
|
415
|
+
fitCogBounds(map, {
|
|
416
|
+
west: runningBounds[0],
|
|
417
|
+
south: runningBounds[1],
|
|
418
|
+
east: runningBounds[2],
|
|
419
|
+
north: runningBounds[3]
|
|
420
|
+
});
|
|
421
|
+
hasFittedOnce = true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!bandConfig) bandConfig = defaultBandConfig(3, 1);
|
|
425
|
+
loading = false;
|
|
426
|
+
flushPendingRebuild(map, signal);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function buildOrUpdateLayer(map: maplibregl.Map, signal: AbortSignal): void {
|
|
430
|
+
const snapshotSources = $state.snapshot(itemsRef) as MosaicSourceMeta[];
|
|
431
|
+
const bc = bandConfig ? { ...bandConfig } : null;
|
|
432
|
+
const rs = { ...rescale };
|
|
433
|
+
|
|
434
|
+
const version = ++layerVersion;
|
|
435
|
+
const layer = new MosaicLayer<MosaicSourceMeta, GeoTIFF>({
|
|
436
|
+
id: `mosaic-${tab.id}-v${version}`,
|
|
437
|
+
sources: snapshotSources,
|
|
438
|
+
maxCacheSize: 8,
|
|
439
|
+
getSource: async (source, opts) => {
|
|
440
|
+
// Reuse in-flight / resolved GeoTIFFs across MosaicLayer rebuilds
|
|
441
|
+
// (version bumps) and pixel-click handlers; otherwise every layer
|
|
442
|
+
// rebuild would re-fetch every source's header.
|
|
443
|
+
const cached = geotiffCache.get(source.id);
|
|
444
|
+
if (cached) return cached;
|
|
445
|
+
const promise = (async () => {
|
|
446
|
+
const url = await presignHref(source.href);
|
|
447
|
+
const geotiff = await GeoTIFF.fromUrl(url);
|
|
448
|
+
normalizeCogGeotiff(geotiff);
|
|
449
|
+
return geotiff;
|
|
450
|
+
})();
|
|
451
|
+
geotiffCache.set(source.id, promise);
|
|
452
|
+
const geotiff = await promise;
|
|
453
|
+
if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
454
|
+
// Seed band config from the first COG that resolves so the UI and
|
|
455
|
+
// the pipeline match the actual raster (e.g. 4-band NAIP RGB+NIR),
|
|
456
|
+
// rather than the hardcoded 3-band default. Subsequent sources are
|
|
457
|
+
// assumed to share structure within a mosaic.
|
|
458
|
+
if (!probedBandCount) {
|
|
459
|
+
probedBandCount = true;
|
|
460
|
+
const count = geotiff.count ?? 3;
|
|
461
|
+
const sf = geotiff.cachedTags.sampleFormat?.[0] ?? 1;
|
|
462
|
+
const bps = geotiff.cachedTags.bitsPerSample?.[0] ?? 8;
|
|
463
|
+
detectedBandCount = count;
|
|
464
|
+
detectedDataType = buildDataTypeLabel(sf, bps);
|
|
465
|
+
const nextConfig = defaultBandConfig(count, sf);
|
|
466
|
+
bandConfig = nextConfig;
|
|
467
|
+
if (mapRef) scheduleLayerRebuild(mapRef, signal);
|
|
468
|
+
}
|
|
469
|
+
return geotiff;
|
|
470
|
+
},
|
|
471
|
+
renderSource: (source, { data }) => {
|
|
472
|
+
if (!data) return null;
|
|
473
|
+
const customProps = selectCogPipeline(data, {
|
|
474
|
+
bandConfig: bc,
|
|
475
|
+
rescale: rs,
|
|
476
|
+
onHistogram: (bins) => {
|
|
477
|
+
histogram = new Uint32Array(bins);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
return new COGLayer({
|
|
481
|
+
id: `mosaic-${tab.id}-v${version}-${source.id}`,
|
|
482
|
+
geotiff: data,
|
|
483
|
+
pool: pool ?? undefined,
|
|
484
|
+
epsgResolver,
|
|
485
|
+
signal,
|
|
486
|
+
...customProps
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
if (overlayRef) {
|
|
492
|
+
overlayRef.setProps({ layers: [layer] });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const overlay = new MapboxOverlay({
|
|
497
|
+
interleaved: false,
|
|
498
|
+
layers: [layer],
|
|
499
|
+
onError: (err: Error) => {
|
|
500
|
+
if (signal.aborted) return;
|
|
501
|
+
if (!error) {
|
|
502
|
+
error = err?.message || String(err);
|
|
503
|
+
loading = false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
overlayRef = overlay;
|
|
508
|
+
map.addControl(overlay as unknown as maplibregl.IControl);
|
|
509
|
+
loading = false;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function handleConfigChange(next: BandConfig): void {
|
|
513
|
+
bandConfig = next;
|
|
514
|
+
if (!mapRef) return;
|
|
515
|
+
scheduleLayerRebuild(mapRef, abortController.signal);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function handleRescaleChange(next: RescaleConfig): void {
|
|
519
|
+
rescale = next;
|
|
520
|
+
if (!mapRef) return;
|
|
521
|
+
scheduleLayerRebuild(mapRef, abortController.signal);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function cleanup(): void {
|
|
525
|
+
abortController.abort();
|
|
526
|
+
if (rebuildTimer != null) {
|
|
527
|
+
clearTimeout(rebuildTimer);
|
|
528
|
+
rebuildTimer = null;
|
|
529
|
+
}
|
|
530
|
+
if (mapRef) removeClickHandler();
|
|
531
|
+
if (mapRef && overlayRef) {
|
|
532
|
+
try {
|
|
533
|
+
mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
|
|
534
|
+
} catch {
|
|
535
|
+
/* already removed */
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (mapRef) cleanupNativeBitmap(mapRef);
|
|
539
|
+
mapRef = null;
|
|
540
|
+
overlayRef = null;
|
|
541
|
+
itemsRef = [];
|
|
542
|
+
presignCache.clear();
|
|
543
|
+
geotiffCache.clear();
|
|
544
|
+
const maybeDestroy = pool as unknown as { destroy?: () => void; terminate?: () => void } | null;
|
|
545
|
+
if (maybeDestroy?.destroy) {
|
|
546
|
+
try {
|
|
547
|
+
maybeDestroy.destroy();
|
|
548
|
+
} catch {
|
|
549
|
+
/* ignore */
|
|
550
|
+
}
|
|
551
|
+
} else if (maybeDestroy?.terminate) {
|
|
552
|
+
try {
|
|
553
|
+
maybeDestroy.terminate();
|
|
554
|
+
} catch {
|
|
555
|
+
/* ignore */
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
pool = null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
$effect(() => {
|
|
562
|
+
const id = tab.id;
|
|
563
|
+
const unregister = tabResources.register(id, cleanup);
|
|
564
|
+
return unregister;
|
|
565
|
+
});
|
|
566
|
+
onDestroy(cleanup);
|
|
567
|
+
</script>
|
|
568
|
+
|
|
569
|
+
<div class="relative flex h-full overflow-hidden">
|
|
570
|
+
<div class="flex-1">
|
|
571
|
+
<MapContainer {onMapReady} {bounds} />
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
<div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col gap-1">
|
|
575
|
+
{#if loading}
|
|
576
|
+
<div class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
|
|
577
|
+
{t('map.loadingCog')}
|
|
578
|
+
</div>
|
|
579
|
+
{/if}
|
|
580
|
+
{#if sourceCount > 0}
|
|
581
|
+
<div class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
|
|
582
|
+
{sourceCount === 1
|
|
583
|
+
? t('stac.mosaicSourcesOne', { count: sourceCount })
|
|
584
|
+
: t('stac.mosaicSourcesOther', { count: sourceCount })}
|
|
585
|
+
</div>
|
|
586
|
+
{/if}
|
|
587
|
+
{#if error}
|
|
588
|
+
<div class="pointer-events-auto max-w-sm rounded bg-red-900/80 px-2 py-1 text-xs text-red-200">
|
|
589
|
+
{error}
|
|
590
|
+
</div>
|
|
591
|
+
{/if}
|
|
592
|
+
</div>
|
|
593
|
+
|
|
594
|
+
{#if sourceCount > 0 && bandConfig}
|
|
595
|
+
<div class="absolute right-2 top-2 z-10 flex gap-1">
|
|
596
|
+
<button
|
|
597
|
+
class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
|
|
598
|
+
class:ring-1={showControls}
|
|
599
|
+
class:ring-primary={showControls}
|
|
600
|
+
onclick={() => {
|
|
601
|
+
showControls = !showControls;
|
|
602
|
+
if (showControls) showInfo = false;
|
|
603
|
+
}}
|
|
604
|
+
>
|
|
605
|
+
{t('cog.style')}
|
|
606
|
+
</button>
|
|
607
|
+
<button
|
|
608
|
+
class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
|
|
609
|
+
class:ring-1={showInfo}
|
|
610
|
+
class:ring-primary={showInfo}
|
|
611
|
+
onclick={() => {
|
|
612
|
+
showInfo = !showInfo;
|
|
613
|
+
if (showInfo) showControls = false;
|
|
614
|
+
}}
|
|
615
|
+
>
|
|
616
|
+
{t('map.info')}
|
|
617
|
+
</button>
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
{#if showControls}
|
|
621
|
+
<CogControls
|
|
622
|
+
bandCount={detectedBandCount}
|
|
623
|
+
{bandConfig}
|
|
624
|
+
onConfigChange={handleConfigChange}
|
|
625
|
+
{rescale}
|
|
626
|
+
rescaleApplicable={true}
|
|
627
|
+
onRescaleChange={handleRescaleChange}
|
|
628
|
+
{histogram}
|
|
629
|
+
/>
|
|
630
|
+
{/if}
|
|
631
|
+
|
|
632
|
+
{#if showInfo}
|
|
633
|
+
<div
|
|
634
|
+
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"
|
|
635
|
+
>
|
|
636
|
+
<h3 class="mb-2 font-medium">{t('stac.mosaicInfo')}</h3>
|
|
637
|
+
<dl class="space-y-1.5">
|
|
638
|
+
<dt class="text-muted-foreground">{t('stac.mosaicSourcesLabel')}</dt>
|
|
639
|
+
<dd class="tabular-nums">{sourceCount}</dd>
|
|
640
|
+
<dt class="text-muted-foreground">{t('mapInfo.bands')}</dt>
|
|
641
|
+
<dd>
|
|
642
|
+
{detectedBandCount}{detectedDataType ? ` (${detectedDataType})` : ''}
|
|
643
|
+
</dd>
|
|
644
|
+
{#if bounds}
|
|
645
|
+
<dt class="text-muted-foreground">{t('mapInfo.bounds')}</dt>
|
|
646
|
+
<dd>
|
|
647
|
+
W {bounds[0].toFixed(4)}, S {bounds[1].toFixed(4)}<br />
|
|
648
|
+
E {bounds[2].toFixed(4)}, N {bounds[3].toFixed(4)}
|
|
649
|
+
</dd>
|
|
650
|
+
{/if}
|
|
651
|
+
</dl>
|
|
652
|
+
</div>
|
|
653
|
+
{/if}
|
|
654
|
+
{/if}
|
|
655
|
+
|
|
656
|
+
{#if pixelValue}
|
|
657
|
+
<div
|
|
658
|
+
class="absolute bottom-2 left-2 z-10 rounded bg-card/90 p-2.5 text-xs text-card-foreground backdrop-blur-sm"
|
|
659
|
+
>
|
|
660
|
+
<div class="mb-1 flex items-center justify-between gap-3">
|
|
661
|
+
<span class="font-medium">{t('cog.pixelValue')}</span>
|
|
662
|
+
<button
|
|
663
|
+
class="text-muted-foreground hover:text-card-foreground"
|
|
664
|
+
onclick={() => {
|
|
665
|
+
pixelValue = null;
|
|
666
|
+
pixelSourceId = null;
|
|
667
|
+
}}
|
|
668
|
+
>
|
|
669
|
+
×
|
|
670
|
+
</button>
|
|
671
|
+
</div>
|
|
672
|
+
<div class="space-y-0.5 text-muted-foreground">
|
|
673
|
+
<div>{pixelValue.lat.toFixed(6)}°, {pixelValue.lng.toFixed(6)}°</div>
|
|
674
|
+
<div class="text-[10px]">px ({pixelValue.col}, {pixelValue.row})</div>
|
|
675
|
+
{#if pixelSourceId}
|
|
676
|
+
<div class="truncate text-[10px]" title={pixelSourceId}>{pixelSourceId}</div>
|
|
677
|
+
{/if}
|
|
678
|
+
</div>
|
|
679
|
+
<div class="mt-1.5 space-y-0.5">
|
|
680
|
+
{#each pixelValue.values as val, i}
|
|
681
|
+
<div class="flex justify-between gap-2">
|
|
682
|
+
<span class="text-muted-foreground">{t('cog.band')} {i + 1}</span>
|
|
683
|
+
<span class="font-mono tabular-nums">
|
|
684
|
+
{Number.isInteger(val) ? val : val.toFixed(4)}
|
|
685
|
+
</span>
|
|
686
|
+
</div>
|
|
687
|
+
{/each}
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
{/if}
|
|
691
|
+
|
|
692
|
+
{#if inspecting}
|
|
693
|
+
<div
|
|
694
|
+
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"
|
|
695
|
+
>
|
|
696
|
+
{t('cog.reading')}
|
|
697
|
+
</div>
|
|
698
|
+
{/if}
|
|
699
|
+
</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 StacMosaicViewer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type StacMosaicViewer = ReturnType<typeof StacMosaicViewer>;
|
|
9
|
+
export default StacMosaicViewer;
|