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