@walkthru-earth/objex 1.3.1 → 1.4.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/LICENSE +5 -0
- package/README.md +20 -12
- package/dist/components/browser/FileTreeSidebar.svelte +32 -17
- package/dist/components/layout/AboutSheet.svelte +5 -2
- package/dist/components/layout/ConnectionDialog.svelte +1 -1
- package/dist/components/layout/SettingsSheet.svelte +237 -0
- package/dist/components/layout/SettingsSheet.svelte.d.ts +6 -0
- package/dist/components/layout/Sidebar.svelte +73 -6
- package/dist/components/layout/Sidebar.svelte.d.ts +4 -1
- package/dist/components/layout/StatusBar.svelte +1 -1
- package/dist/components/layout/TabBar.svelte +2 -2
- package/dist/components/ui/context-menu/context-menu-radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte.d.ts +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte.d.ts +1 -1
- package/dist/components/ui/input/input.svelte.d.ts +1 -1
- package/dist/components/ui/resizable/index.d.ts +1 -1
- package/dist/components/ui/resizable/index.js +2 -2
- package/dist/components/ui/slider/index.d.ts +3 -0
- package/dist/components/ui/slider/index.js +5 -0
- package/dist/components/ui/slider/range-slider.svelte +94 -0
- package/dist/components/ui/slider/range-slider.svelte.d.ts +21 -0
- package/dist/components/ui/slider/slider.svelte +83 -0
- package/dist/components/ui/slider/slider.svelte.d.ts +7 -0
- package/dist/components/viewers/ArchiveViewer.svelte +2 -2
- package/dist/components/viewers/CodeViewer.svelte +31 -22
- package/dist/components/viewers/CogControls.svelte +338 -184
- package/dist/components/viewers/CogControls.svelte.d.ts +33 -10
- package/dist/components/viewers/CogViewer.svelte +263 -112
- package/dist/components/viewers/CopcViewer.svelte +1 -1
- package/dist/components/viewers/FlatGeobufViewer.svelte +1 -1
- package/dist/components/viewers/GeoParquetMapViewer.svelte +6 -6
- package/dist/components/viewers/GeoParquetMapViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/ImageViewer.svelte +2 -2
- package/dist/components/viewers/MarkdownViewer.svelte +12 -9
- package/dist/components/viewers/MediaViewer.svelte +2 -2
- package/dist/components/viewers/ModelViewer.svelte +1 -1
- package/dist/components/viewers/MultiCogViewer.svelte +467 -102
- package/dist/components/viewers/MultiCogViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/NotebookViewer.svelte +6 -3
- package/dist/components/viewers/PdfViewer.svelte +2 -2
- package/dist/components/viewers/PmtilesViewer.svelte +3 -6
- package/dist/components/viewers/RawViewer.svelte +6 -3
- package/dist/components/viewers/StacMapViewer.svelte +1 -1
- package/dist/components/viewers/StacMosaicViewer.svelte +1760 -408
- package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/StacTabViewer.svelte +24 -13
- package/dist/components/viewers/StacTabViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/TableGrid.svelte +4 -4
- package/dist/components/viewers/TableStatusBar.svelte +1 -1
- package/dist/components/viewers/TableToolbar.svelte +1 -1
- package/dist/components/viewers/TableViewer.svelte +25 -17
- package/dist/components/viewers/TableViewer.svelte.d.ts +1 -0
- package/dist/components/viewers/ViewerRouter.svelte +16 -8
- package/dist/components/viewers/ZarrMapViewer.svelte +11 -9
- package/dist/components/viewers/ZarrViewer.svelte +4 -4
- package/dist/components/viewers/cog/ChannelPicker.svelte +83 -0
- package/dist/components/viewers/cog/ChannelPicker.svelte.d.ts +13 -0
- package/dist/components/viewers/cog/PixelInspectorPanel.svelte +87 -0
- package/dist/components/viewers/cog/PixelInspectorPanel.svelte.d.ts +17 -0
- package/dist/components/viewers/cog/buildRgbLayer.d.ts +78 -0
- package/dist/components/viewers/cog/buildRgbLayer.js +176 -0
- package/dist/components/viewers/map/AttributeTable.svelte +1 -1
- package/dist/components/viewers/map/MapContainer.svelte +37 -11
- package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +1 -1
- package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +1 -1
- package/dist/components/viewers/stac/StacDatetimeBar.svelte +175 -0
- package/dist/components/viewers/stac/StacDatetimeBar.svelte.d.ts +10 -0
- package/dist/components/viewers/stac/StacFilterPanel.svelte +243 -0
- package/dist/components/viewers/stac/StacFilterPanel.svelte.d.ts +14 -0
- package/dist/components/viewers/stac/StacItemInspector.svelte +223 -0
- package/dist/components/viewers/stac/StacItemInspector.svelte.d.ts +10 -0
- package/dist/components/viewers/stac/StacItemStrip.svelte +228 -0
- package/dist/components/viewers/stac/StacItemStrip.svelte.d.ts +12 -0
- package/dist/file-icons/index.d.ts +1 -1
- package/dist/file-icons/index.js +1 -1
- package/dist/i18n/ar.js +110 -2
- package/dist/i18n/en.js +110 -2
- package/dist/index.d.ts +2 -28
- package/dist/index.js +7 -23
- package/dist/query/engine.d.ts +10 -0
- package/dist/query/source.js +1 -1
- package/dist/query/stac-source-factory.d.ts +65 -0
- package/dist/query/stac-source-factory.js +77 -0
- package/dist/query/stac-source-parquet.d.ts +135 -0
- package/dist/query/stac-source-parquet.js +465 -0
- package/dist/query/wasm.d.ts +8 -0
- package/dist/query/wasm.js +304 -2
- package/dist/storage/presign.js +1 -1
- package/dist/storage/providers.js +5 -5
- package/dist/stores/config.svelte.d.ts +15 -0
- package/dist/stores/config.svelte.js +46 -0
- package/dist/stores/connections.svelte.d.ts +2 -2
- package/dist/stores/connections.svelte.js +1 -2
- package/dist/stores/files.svelte.d.ts +1 -1
- package/dist/stores/files.svelte.js +1 -1
- package/dist/stores/query-history.svelte.js +1 -1
- package/dist/stores/settings.svelte.d.ts +16 -1
- package/dist/stores/settings.svelte.js +104 -48
- package/dist/stores/tabs.svelte.d.ts +3 -0
- package/dist/stores/tabs.svelte.js +17 -0
- package/dist/utils/cog-histogram.d.ts +121 -0
- package/dist/utils/cog-histogram.js +424 -0
- package/dist/utils/cog.d.ts +177 -20
- package/dist/utils/cog.js +361 -76
- package/dist/utils/colormap-sprite.d.ts +0 -9
- package/dist/utils/colormap-sprite.js +0 -21
- package/dist/utils/deck.d.ts +16 -12
- package/dist/utils/deck.js +10 -4
- package/dist/utils/pmtiles-tile.js +2 -2
- package/dist/utils/{url.d.ts → signed-url.d.ts} +15 -1
- package/dist/utils/{url.js → signed-url.js} +32 -10
- package/dist/utils/url-state.d.ts +36 -0
- package/dist/utils/url-state.js +72 -2
- package/dist/utils/zarr-tab.d.ts +1 -2
- package/dist/utils/zarr-tab.js +1 -2
- package/dist/utils/zarr.d.ts +0 -17
- package/dist/utils/zarr.js +1 -45
- package/package.json +55 -84
- package/dist/components/browser/Breadcrumb.svelte +0 -50
- package/dist/components/browser/Breadcrumb.svelte.d.ts +0 -7
- package/dist/components/browser/CreateFolderDialog.svelte +0 -98
- package/dist/components/browser/CreateFolderDialog.svelte.d.ts +0 -6
- package/dist/components/browser/DeleteConfirmDialog.svelte +0 -90
- package/dist/components/browser/DeleteConfirmDialog.svelte.d.ts +0 -8
- package/dist/components/browser/DropZone.svelte +0 -83
- package/dist/components/browser/DropZone.svelte.d.ts +0 -7
- package/dist/components/browser/FileBrowser.svelte +0 -252
- package/dist/components/browser/FileBrowser.svelte.d.ts +0 -3
- package/dist/components/browser/FileRow.svelte +0 -117
- package/dist/components/browser/FileRow.svelte.d.ts +0 -9
- package/dist/components/browser/RenameDialog.svelte +0 -101
- package/dist/components/browser/RenameDialog.svelte.d.ts +0 -8
- package/dist/components/browser/SearchBar.svelte +0 -40
- package/dist/components/browser/SearchBar.svelte.d.ts +0 -6
- package/dist/components/browser/UploadButton.svelte +0 -65
- package/dist/components/browser/UploadButton.svelte.d.ts +0 -3
- package/dist/query/stac-geoparquet.d.ts +0 -31
- package/dist/query/stac-geoparquet.js +0 -136
- package/dist/utils/clipboard.d.ts +0 -13
- package/dist/utils/clipboard.js +0 -38
- package/dist/utils/cloud-url.d.ts +0 -27
- package/dist/utils/cloud-url.js +0 -61
- package/dist/utils/cog-pure.d.ts +0 -25
- package/dist/utils/cog-pure.js +0 -35
- package/dist/utils/column-types.d.ts +0 -5
- package/dist/utils/column-types.js +0 -137
- package/dist/utils/connection-identity.d.ts +0 -51
- package/dist/utils/connection-identity.js +0 -97
- package/dist/utils/error.d.ts +0 -8
- package/dist/utils/error.js +0 -12
- package/dist/utils/evidence-context.d.ts +0 -22
- package/dist/utils/evidence-context.js +0 -56
- package/dist/utils/export.d.ts +0 -22
- package/dist/utils/export.js +0 -76
- package/dist/utils/file-sort.d.ts +0 -20
- package/dist/utils/file-sort.js +0 -41
- package/dist/utils/format.d.ts +0 -24
- package/dist/utils/format.js +0 -78
- package/dist/utils/geoarrow.d.ts +0 -32
- package/dist/utils/geoarrow.js +0 -672
- package/dist/utils/geometry-type.d.ts +0 -52
- package/dist/utils/geometry-type.js +0 -76
- package/dist/utils/hex.d.ts +0 -10
- package/dist/utils/hex.js +0 -27
- package/dist/utils/host-detection.d.ts +0 -23
- package/dist/utils/host-detection.js +0 -95
- package/dist/utils/local-storage.d.ts +0 -16
- package/dist/utils/local-storage.js +0 -37
- package/dist/utils/markdown-sql.d.ts +0 -30
- package/dist/utils/markdown-sql.js +0 -72
- package/dist/utils/notebook.d.ts +0 -59
- package/dist/utils/notebook.js +0 -211
- package/dist/utils/parquet-metadata.d.ts +0 -64
- package/dist/utils/parquet-metadata.js +0 -262
- package/dist/utils/stac-geoparquet.d.ts +0 -90
- package/dist/utils/stac-geoparquet.js +0 -223
- package/dist/utils/stac-hydrate.d.ts +0 -38
- package/dist/utils/stac-hydrate.js +0 -243
- package/dist/utils/stac.d.ts +0 -136
- package/dist/utils/stac.js +0 -176
- package/dist/utils/storage-url.d.ts +0 -90
- package/dist/utils/storage-url.js +0 -568
- package/dist/utils/wkb.d.ts +0 -43
- package/dist/utils/wkb.js +0 -359
|
@@ -1,134 +1,867 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { GeoJsonLayer } from '@deck.gl/layers';
|
|
2
3
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
3
|
-
import { COGLayer, MosaicLayer } from '@developmentseed/deck.gl-geotiff';
|
|
4
|
+
import { COGLayer, MosaicLayer, MultiCOGLayer } from '@developmentseed/deck.gl-geotiff';
|
|
4
5
|
import { DecoderPool, GeoTIFF } from '@developmentseed/geotiff';
|
|
6
|
+
import type { StacSource } from '@walkthru-earth/objex-utils';
|
|
7
|
+
import {
|
|
8
|
+
applyFacets,
|
|
9
|
+
applyPreset,
|
|
10
|
+
attachPixelInspector,
|
|
11
|
+
availablePresets,
|
|
12
|
+
buildFacets,
|
|
13
|
+
buildMosaicSourceMeta,
|
|
14
|
+
type ChannelComposite,
|
|
15
|
+
type CogAsset,
|
|
16
|
+
classifyStac,
|
|
17
|
+
compositeFromUrl,
|
|
18
|
+
compositeToUrl,
|
|
19
|
+
emptyFacetState,
|
|
20
|
+
extractCogAssets,
|
|
21
|
+
extractItemView,
|
|
22
|
+
extractMosaicAssets,
|
|
23
|
+
type FacetState,
|
|
24
|
+
formatFileSize,
|
|
25
|
+
hasActiveFilters,
|
|
26
|
+
isAbortError,
|
|
27
|
+
isSingleAssetComposite,
|
|
28
|
+
LruCache,
|
|
29
|
+
type MosaicSourceMeta,
|
|
30
|
+
PRESETS,
|
|
31
|
+
pickCogAssetHref,
|
|
32
|
+
pickNaturalColorComposite,
|
|
33
|
+
presetMatchesComposite,
|
|
34
|
+
type RasterBandAsset,
|
|
35
|
+
resolveCloudUrl,
|
|
36
|
+
type StacItemView,
|
|
37
|
+
type StacRoutableKind,
|
|
38
|
+
smokeTestHref,
|
|
39
|
+
spatialCellKey
|
|
40
|
+
} from '@walkthru-earth/objex-utils';
|
|
5
41
|
import type maplibregl from 'maplibre-gl';
|
|
6
42
|
import { onDestroy, untrack } from 'svelte';
|
|
7
43
|
import { t } from '../../i18n/index.svelte.js';
|
|
8
|
-
import {
|
|
44
|
+
import { createStacSourceForTab } from '../../query/stac-source-factory.js';
|
|
9
45
|
import { getAdapter } from '../../storage/index.js';
|
|
10
46
|
import { buildProviderBaseUrl, type ProviderId } from '../../storage/providers.js';
|
|
11
47
|
import { connectionStore } from '../../stores/connections.svelte.js';
|
|
48
|
+
import { settings } from '../../stores/settings.svelte.js';
|
|
12
49
|
import { tabResources } from '../../stores/tab-resources.svelte.js';
|
|
13
50
|
import type { Tab } from '../../types.js';
|
|
14
|
-
import { resolveCloudUrl } from '../../utils/cloud-url.js';
|
|
15
51
|
import {
|
|
16
52
|
type BandConfig,
|
|
53
|
+
buildBandRenderPipeline,
|
|
17
54
|
buildDataTypeLabel,
|
|
55
|
+
buildHistogramFromGeotiff,
|
|
18
56
|
type CustomTileData,
|
|
19
57
|
clampBounds,
|
|
20
58
|
cleanupNativeBitmap,
|
|
21
59
|
createEpsgResolver,
|
|
60
|
+
DEFAULT_NODATA_CONFIG,
|
|
22
61
|
DEFAULT_RESCALE,
|
|
23
62
|
defaultBandConfig,
|
|
24
63
|
fitCogBounds,
|
|
25
64
|
HISTOGRAM_BIN_COUNT,
|
|
65
|
+
loadGeoTIFF,
|
|
66
|
+
mapResolutionMetersPerPixel,
|
|
67
|
+
type NodataConfig,
|
|
26
68
|
normalizeCogGeotiff,
|
|
27
69
|
type PixelValue,
|
|
70
|
+
percentileFromHistogram,
|
|
28
71
|
type RescaleConfig,
|
|
72
|
+
readGdalNodata,
|
|
29
73
|
readPixelAtLngLat,
|
|
74
|
+
resolveNodata,
|
|
30
75
|
resolveProj4Def,
|
|
31
|
-
selectCogPipeline
|
|
76
|
+
selectCogPipeline,
|
|
77
|
+
selectOverviewForResolution
|
|
32
78
|
} from '../../utils/cog.js';
|
|
33
|
-
import {
|
|
34
|
-
|
|
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';
|
|
79
|
+
import { buildHttpsUrlAsync } from '../../utils/signed-url.js';
|
|
80
|
+
import { getUrlViewParams, updateUrlViewParams } from '../../utils/url-state.js';
|
|
41
81
|
import CogControls from './CogControls.svelte';
|
|
82
|
+
import PixelInspectorPanel, { type PixelInspectorRow } from './cog/PixelInspectorPanel.svelte';
|
|
42
83
|
import MapContainer from './map/MapContainer.svelte';
|
|
84
|
+
import StacDatetimeBar from './stac/StacDatetimeBar.svelte';
|
|
85
|
+
import StacFilterPanel from './stac/StacFilterPanel.svelte';
|
|
86
|
+
import StacItemInspector from './stac/StacItemInspector.svelte';
|
|
87
|
+
import StacItemStrip from './stac/StacItemStrip.svelte';
|
|
43
88
|
|
|
44
89
|
let { tab, classified }: { tab: Tab; classified?: StacRoutableKind } = $props();
|
|
45
90
|
|
|
91
|
+
// ─── UI / status state ─────────────────────────────────────────────
|
|
46
92
|
let loading = $state(true);
|
|
47
93
|
let error = $state<string | null>(null);
|
|
48
94
|
let showControls = $state(false);
|
|
49
95
|
let showInfo = $state(false);
|
|
50
|
-
let sourceCount = $state(0);
|
|
51
96
|
let bounds = $state<[number, number, number, number] | undefined>();
|
|
97
|
+
|
|
98
|
+
// ─── Render-pipeline state ─────────────────────────────────────────
|
|
52
99
|
let bandConfig = $state<BandConfig | null>(null);
|
|
53
100
|
let histogram = $state.raw<Uint32Array | null>(null);
|
|
54
101
|
let rescale = $state<RescaleConfig>({ ...DEFAULT_RESCALE });
|
|
55
102
|
let detectedBandCount = $state<number>(3);
|
|
56
103
|
let detectedDataType = $state<string>('');
|
|
57
104
|
let probedBandCount = false;
|
|
105
|
+
// On the multi-asset path (per-item MultiCOGLayer mosaic) the per-tile baker
|
|
106
|
+
// in `cog.ts` is bypassed, so `recordSourceHistogram` never receives bins and
|
|
107
|
+
// `aggregateSources()` keeps `histogram = null`. Fall back to a one-shot bake
|
|
108
|
+
// from the smallest overview of the first committed item's R-channel COG.
|
|
109
|
+
// Tracks `${rAssetKey}:${firstViewId}` so a preset / R-channel swap or a fresh
|
|
110
|
+
// viewport rebakes; `userTouchedRescale` gates the auto-contrast reseed.
|
|
111
|
+
let multiHistogramKey: string | null = null;
|
|
112
|
+
let userTouchedRescale = false;
|
|
113
|
+
// User-facing nodata override (Auto/Value/Off). `autoNodata` is the GDAL_NODATA
|
|
114
|
+
// value read from the first probed source's geotiff; Auto mode resolves to it
|
|
115
|
+
// via `resolveNodata()` at layer-build time.
|
|
116
|
+
let nodataConfig = $state<NodataConfig>({ ...DEFAULT_NODATA_CONFIG });
|
|
117
|
+
let autoNodata = $state<number | null>(null);
|
|
118
|
+
// ─── Asset picker (mosaic uses ONE COG per item) ──────────────────
|
|
119
|
+
// `availableAssets` is seeded from the first item that arrives so the user
|
|
120
|
+
// can pick which STAC asset (`visual` / `red` / `nir` / ...) drives the
|
|
121
|
+
// mosaic. `mosaicAssetKey` may be null until the first batch lands; while
|
|
122
|
+
// null, `buildMosaicSourceMeta(item, undefined)` falls back to the default
|
|
123
|
+
// `pickCogAssetHref` order (`visual` → `image` → `data` → `rendered_preview`
|
|
124
|
+
// → first tiff). Changing the key swaps every committed source's `href` in
|
|
125
|
+
// place via re-deriving from the cached `StacItemView.raw`, no viewport
|
|
126
|
+
// re-query needed.
|
|
127
|
+
let availableAssets = $state.raw<RasterBandAsset[]>([]);
|
|
128
|
+
let mosaicAssetKey = $state<string | null>(null);
|
|
129
|
+
|
|
130
|
+
// Unified RGB picker state (parallel to availableAssets / mosaicAssetKey for
|
|
131
|
+
// the single-asset path). `composite.r.assetKey` is mirrored into the
|
|
132
|
+
// `mosaicAssetKey` machinery so the existing buildMosaicSourceMeta path keeps
|
|
133
|
+
// working until the multi-asset path lands.
|
|
134
|
+
let cogAssets = $state.raw<CogAsset[]>([]);
|
|
135
|
+
let composite = $state.raw<ChannelComposite | null>(null);
|
|
136
|
+
let activePresetId = $state<string>('');
|
|
137
|
+
|
|
138
|
+
const presetsForMosaic = $derived(availablePresets(cogAssets));
|
|
58
139
|
|
|
59
|
-
// ─── Pixel inspection
|
|
140
|
+
// ─── Pixel inspection ──────────────────────────────────────────────
|
|
60
141
|
let pixelValue = $state<PixelValue | null>(null);
|
|
61
142
|
let pixelSourceId = $state<string | null>(null);
|
|
62
143
|
let inspecting = $state(false);
|
|
63
|
-
let
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
144
|
+
let detachInspector: (() => void) | null = null;
|
|
145
|
+
|
|
146
|
+
// ─── Caches ────────────────────────────────────────────────────────
|
|
147
|
+
// Bounded so panning does not grow memory forever. Sized larger than the
|
|
148
|
+
// inner TileLayer's tile cache so a pan-back to a previously-visited bbox
|
|
149
|
+
// finds COG headers + presigned URLs ready instead of paying a header
|
|
150
|
+
// re-fetch. Each entry is small (~16 KB IFD per geotiff, a string per
|
|
151
|
+
// presign), so 256 entries fits in well under 50 MB. Tile pixel bytes are
|
|
152
|
+
// still bounded by `MosaicLayer.maxCacheSize` (kept smaller because decoded
|
|
153
|
+
// tiles are 1-4 MB each). Histograms are evicted in `onTileUnload` because
|
|
154
|
+
// they reflect visible state, not data; the geotiff / presign / resolved
|
|
155
|
+
// caches are NOT evicted on tile-unload anymore so pan-back is fast.
|
|
156
|
+
const SOURCE_CACHE_MAX = 256;
|
|
157
|
+
const TILE_CACHE_MAX = 64;
|
|
158
|
+
let geotiffCache = new LruCache<string, Promise<GeoTIFF>>({ max: SOURCE_CACHE_MAX });
|
|
159
|
+
let presignCache = new LruCache<string, Promise<string>>({ max: SOURCE_CACHE_MAX });
|
|
160
|
+
// Parallel cache of resolved presigned URLs, keyed by the original href. The
|
|
161
|
+
// multi-asset mosaic path needs a synchronous href→url lookup so the per-item
|
|
162
|
+
// MultiCOGLayer derivation can attach all 3 channel URLs in one render tick;
|
|
163
|
+
// `presignCache` only stores the in-flight `Promise<string>`. Populated in
|
|
164
|
+
// `presignHref` once the promise resolves. Bounded LRU (cap matches
|
|
165
|
+
// `presignCache`) so non-COG hrefs from `StacItemStrip` thumbnails and
|
|
166
|
+
// `StacItemInspector` asset table cannot grow without bound (those entries
|
|
167
|
+
// are not iterated by the `commitSources()` itemsRemoved diff, which only
|
|
168
|
+
// walks `extractCogAssets`). `commitSources()` still evicts COG asset
|
|
169
|
+
// entries promptly on item drop / asset swap / viewer reset / teardown so
|
|
170
|
+
// memory tracks the rendered set rather than waiting for LRU pressure.
|
|
171
|
+
let resolvedHrefByOriginal = new LruCache<string, string>({ max: SOURCE_CACHE_MAX });
|
|
172
|
+
let sourceHrefById = new Map<string, string>();
|
|
173
|
+
// Surface only the first distinct getSource decode failure per viewer
|
|
174
|
+
// lifetime (e.g. CORS, unsupported COG flavour, presign rejection). Reset on
|
|
175
|
+
// tab reset alongside the rest of the per-source state.
|
|
176
|
+
let sourceErrorLogged = false;
|
|
177
|
+
// Per-source visible-tile histograms, summed across sources in `aggregate`.
|
|
178
|
+
let sourceHistograms = new Map<string, Uint32Array>();
|
|
179
|
+
// Dedup `onTileError` log floods. deck.gl's TileLayer retries a failed source
|
|
180
|
+
// for every visible tile that overlaps it; on `ERR_INSUFFICIENT_RESOURCES`
|
|
181
|
+
// (Chrome renderer URL-request budget exhaustion) the same href fires once
|
|
182
|
+
// per tile per pan. Logging once per source per session is enough to surface
|
|
183
|
+
// the failure without flooding the console.
|
|
184
|
+
const loggedTileErrors = new Set<string>();
|
|
185
|
+
function logTileErrorOnce(sourceId: string, err: unknown) {
|
|
186
|
+
if (loggedTileErrors.has(sourceId)) return;
|
|
187
|
+
loggedTileErrors.add(sourceId);
|
|
188
|
+
console.error(`[StacMosaic] tile error on source "${sourceId}":`, err);
|
|
189
|
+
}
|
|
67
190
|
|
|
191
|
+
// ─── Lifecycle controllers ─────────────────────────────────────────
|
|
192
|
+
// `abortController` is viewer-lifetime (only torn down on tab close / reset)
|
|
193
|
+
// so in-flight COG range fetches keep painting cached tiles across panning.
|
|
194
|
+
// `hydrationController` is per-pan: aborts only the STAC link-walk / API
|
|
195
|
+
// pagination so a viewport reload does not cascade into the COG layer.
|
|
68
196
|
let abortController = new AbortController();
|
|
197
|
+
let hydrationController = new AbortController();
|
|
69
198
|
let mapRef: maplibregl.Map | null = null;
|
|
70
199
|
let overlayRef: MapboxOverlay | null = null;
|
|
200
|
+
let loadGen = 0;
|
|
201
|
+
// Tracks the currently-running loadMosaic. reloadViewport awaits this after
|
|
202
|
+
// aborting so a rapid pan can't stack 5+ DuckDB queryStream calls in the worker
|
|
203
|
+
// (DuckDB-WASM cancelSent is best-effort at polling boundaries — meanwhile
|
|
204
|
+
// each in-flight scan keeps its STRUCT result buffers alive on the WASM heap,
|
|
205
|
+
// which OOMs at ~3.1 GiB on stac-geoparquet rows with deep `assets`/`links`).
|
|
206
|
+
let inflightLoad: Promise<void> | null = null;
|
|
207
|
+
|
|
208
|
+
// ─── Ingestion buffer ──────────────────────────────────────────────
|
|
209
|
+
// Mutated freely as STAC batches arrive. NOT consumed by the renderer.
|
|
210
|
+
// `commitSources()` is the single transition point that promotes this
|
|
211
|
+
// buffer to the `committed*` render state.
|
|
71
212
|
let itemsRef = $state.raw<MosaicSourceMeta[]>([]);
|
|
213
|
+
let itemViewsRef = $state.raw<StacItemView[]>([]);
|
|
214
|
+
|
|
215
|
+
// ─── Render state (single source of truth) ─────────────────────────
|
|
216
|
+
// Everything deck.gl ever sees flows through these three signals plus the
|
|
217
|
+
// pure $derived chain below. There is no imperative `pushLayers` /
|
|
218
|
+
// `currentMosaicLayer` path. That removes the lifecycle race that used to
|
|
219
|
+
// re-present finalized COGLayer instances to the layer tree (the
|
|
220
|
+
// `assert9(!this.internalState)` deck.gl assertion at Layer._initialize).
|
|
221
|
+
let committedSources = $state.raw<MosaicSourceMeta[]>([]);
|
|
222
|
+
let committedViews = $state.raw<StacItemView[]>([]);
|
|
223
|
+
// Bumped on inputs that must invalidate the inner TileLayer's tile cache
|
|
224
|
+
// (band/rescale/pipeline change). Sources changes already invalidate via
|
|
225
|
+
// the content hash baked into `mosaicId`.
|
|
226
|
+
let pipelineGen = $state(0);
|
|
227
|
+
|
|
228
|
+
// ─── Discovery / streaming ─────────────────────────────────────────
|
|
229
|
+
type SourceKind = 'api' | 'parquet' | 'static';
|
|
230
|
+
let kind = $state<SourceKind>('static');
|
|
231
|
+
const isViewportMode = $derived(kind !== 'static');
|
|
232
|
+
let moveHandlerRef: (() => void) | null = null;
|
|
233
|
+
let moveDebounceTimer: number | null = null;
|
|
234
|
+
const VIEWPORT_DEBOUNCE_MS = 350;
|
|
235
|
+
const VIEWPORT_PAGE_LIMIT = 250;
|
|
236
|
+
let itemLimit = $state<number>(settings.mosaicItemLimit);
|
|
237
|
+
const LATEST_KEEP_PER_CELL = 3;
|
|
238
|
+
const dedupeLatest = true;
|
|
72
239
|
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
240
|
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
241
|
+
// ─── Item interaction ──────────────────────────────────────────────
|
|
242
|
+
let hoveredId = $state<string | null>(null);
|
|
243
|
+
let selectedId = $state<string | null>(null);
|
|
244
|
+
let showFootprints = $state(false);
|
|
245
|
+
let showStrip = $state(true);
|
|
246
|
+
let filterState = $state<FacetState>(emptyFacetState());
|
|
247
|
+
// Datetime histogram + slider bounds are derived from `committedViews`, which
|
|
248
|
+
// is already bbox-scoped to the current viewport in `api` and `parquet` modes
|
|
249
|
+
// because those sources push `bbox` down (STAC API `?bbox=` and DuckDB
|
|
250
|
+
// `ST_Intersects(geometry, ST_MakeEnvelope(...))` respectively). When the user
|
|
251
|
+
// pans, `reloadViewport()` re-queries and the histogram rebuilds from the new
|
|
252
|
+
// bbox's items, so the date controls always reflect "what's available here".
|
|
253
|
+
//
|
|
254
|
+
// `static` mode walks the full advertised tree without bbox push-down, so the
|
|
255
|
+
// histogram covers the whole catalog — including items outside the current
|
|
256
|
+
// view. We deliberately do NOT bbox-clip `committedViews` client-side before
|
|
257
|
+
// faceting in static mode for this initial release: static catalogs are the
|
|
258
|
+
// minority path, and a client-side clip would diverge the histogram from the
|
|
259
|
+
// rendered footprint set (which is also un-clipped in static mode). Revisit
|
|
260
|
+
// if static-mode usage grows.
|
|
261
|
+
const facets = $derived(buildFacets(committedViews as StacItemView[]));
|
|
262
|
+
const filteredViews = $derived(applyFacets(committedViews as StacItemView[], filterState));
|
|
263
|
+
|
|
264
|
+
// Zoom-aware source culling. `MosaicTileset2D.getTileIndices` searches the
|
|
265
|
+
// full map viewport bbox and returns every overlapping source as a deck.gl
|
|
266
|
+
// "tile", which fires our `getSource` and opens the COG header (range
|
|
267
|
+
// requests for IFDs). At low zoom over a global mosaic that wastes hundreds
|
|
268
|
+
// of header fetches on COGs that span fewer than a few screen pixels and
|
|
269
|
+
// won't contribute meaningful pixels at that zoom anyway. Cull sources whose
|
|
270
|
+
// projected on-screen footprint is below `ZOOM_CULL_MIN_PIXELS`. The cull is
|
|
271
|
+
// binned by integer zoom so within a zoom level the source list (and the
|
|
272
|
+
// inner Flatbush + TileLayer cache) stays stable across pans, and only zoom
|
|
273
|
+
// transitions force a MosaicLayer rebuild.
|
|
274
|
+
const ZOOM_CULL_MIN_PIXELS = 4;
|
|
275
|
+
let mapZoomBin = $state<number | null>(null);
|
|
276
|
+
function sourcePixelSize(bbox: [number, number, number, number], zoom: number): number {
|
|
277
|
+
const [w, s, e, n] = bbox;
|
|
278
|
+
const lat = (n + s) / 2;
|
|
279
|
+
const cosLat = Math.cos((lat * Math.PI) / 180);
|
|
280
|
+
if (!Number.isFinite(cosLat) || cosLat <= 0) return Number.POSITIVE_INFINITY;
|
|
281
|
+
const widthMeters = (e - w) * 111320 * cosLat;
|
|
282
|
+
const heightMeters = (n - s) * 111320;
|
|
283
|
+
const mpp = (156543.03392 * cosLat) / 2 ** zoom;
|
|
284
|
+
if (!Number.isFinite(mpp) || mpp <= 0) return Number.POSITIVE_INFINITY;
|
|
285
|
+
return Math.min(widthMeters / mpp, heightMeters / mpp);
|
|
286
|
+
}
|
|
287
|
+
const culledSources = $derived.by(() => {
|
|
288
|
+
const z = mapZoomBin;
|
|
289
|
+
if (z == null || committedSources.length === 0) return committedSources;
|
|
290
|
+
const out: MosaicSourceMeta[] = [];
|
|
291
|
+
for (const s of committedSources) {
|
|
292
|
+
if (sourcePixelSize(s.bbox, z) >= ZOOM_CULL_MIN_PIXELS) out.push(s);
|
|
293
|
+
}
|
|
294
|
+
// If the cull would empty the mosaic (every source is sub-pixel), keep the
|
|
295
|
+
// raw set so the user sees something rather than nothing — they're zoomed
|
|
296
|
+
// way out and a single fetch is acceptable.
|
|
297
|
+
return out.length > 0 ? out : committedSources;
|
|
298
|
+
});
|
|
299
|
+
const filteredItems = $derived.by(() => {
|
|
300
|
+
if (!hasActiveFilters(filterState)) return culledSources;
|
|
301
|
+
const allowed = new Set(filteredViews.map((v) => v.id));
|
|
302
|
+
return culledSources.filter((it) => allowed.has(it.id));
|
|
303
|
+
});
|
|
304
|
+
const filtersActive = $derived(hasActiveFilters(filterState));
|
|
305
|
+
const sourceCount = $derived(committedSources.length);
|
|
306
|
+
|
|
307
|
+
// ─── Explain / cost-preview stats (Info panel) ─────────────────────
|
|
308
|
+
// Inspired by lazycogs `da.lazycogs.explain()` — a lightweight read-cost
|
|
309
|
+
// breakdown that does NOT issue any new network requests. Distinct asset
|
|
310
|
+
// keys come from cached `StacItemView.raw.assets`. Center overlap counts
|
|
311
|
+
// how many committed source bboxes contain the current viewport center.
|
|
312
|
+
// Tile bytes per item is a best-effort estimate from the first cached
|
|
313
|
+
// GeoTIFF's IFD (tileWidth × tileHeight × bandCount × bytesPerSample);
|
|
314
|
+
// returns null on failure so the UI can show a dash.
|
|
315
|
+
function bboxesIntersect(
|
|
316
|
+
a: [number, number, number, number],
|
|
317
|
+
b: [number, number, number, number]
|
|
318
|
+
): boolean {
|
|
319
|
+
return !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]);
|
|
320
|
+
}
|
|
321
|
+
const distinctAssetKeys = $derived.by(() => {
|
|
322
|
+
const set = new Set<string>();
|
|
323
|
+
for (const v of committedViews as StacItemView[]) {
|
|
324
|
+
const assets = v.raw?.assets;
|
|
325
|
+
if (!assets) continue;
|
|
326
|
+
for (const k of Object.keys(assets)) set.add(k);
|
|
327
|
+
}
|
|
328
|
+
return set.size;
|
|
329
|
+
});
|
|
330
|
+
let mapCenterTick = $state(0);
|
|
331
|
+
const centerOverlapCount = $derived.by(() => {
|
|
332
|
+
// touch the tick so panning re-evaluates
|
|
333
|
+
mapCenterTick;
|
|
334
|
+
if (!mapRef) return 0;
|
|
335
|
+
try {
|
|
336
|
+
const c = mapRef.getCenter();
|
|
337
|
+
const lng = c.lng;
|
|
338
|
+
const lat = c.lat;
|
|
339
|
+
const point: [number, number, number, number] = [lng, lat, lng, lat];
|
|
340
|
+
let n = 0;
|
|
341
|
+
for (const s of committedSources) {
|
|
342
|
+
if (bboxesIntersect(s.bbox, point)) n++;
|
|
343
|
+
}
|
|
344
|
+
return n;
|
|
345
|
+
} catch {
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
const estimatedTileBytes = $derived.by(() => {
|
|
350
|
+
try {
|
|
351
|
+
// Find any resolved GeoTIFF in the cache and probe its IFD tags.
|
|
352
|
+
// `geotiffCache` stores `Promise<GeoTIFF>`; we need a settled value,
|
|
353
|
+
// so peek by racing with a resolved-marker. To stay sync, we rely on
|
|
354
|
+
// the fact that probedBandCount only flips after a GeoTIFF resolved;
|
|
355
|
+
// look up an entry by iterating committedSources and reading the
|
|
356
|
+
// promise's settled value via `.then` is not synchronous, so instead
|
|
357
|
+
// we recompute from the detected band count + a typical tile size
|
|
358
|
+
// (256x256) and a bytesPerSample inferred from `detectedDataType`.
|
|
359
|
+
if (!probedBandCount || committedSources.length === 0) return null;
|
|
360
|
+
const bandCount = detectedBandCount;
|
|
361
|
+
const dt = detectedDataType.toLowerCase();
|
|
362
|
+
let bytesPerSample = 1;
|
|
363
|
+
if (dt.includes('16')) bytesPerSample = 2;
|
|
364
|
+
else if (dt.includes('32')) bytesPerSample = 4;
|
|
365
|
+
else if (dt.includes('64')) bytesPerSample = 8;
|
|
366
|
+
const tileW = 256;
|
|
367
|
+
const tileH = 256;
|
|
368
|
+
return tileW * tileH * bandCount * bytesPerSample;
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
const timeSpan = $derived.by(() => {
|
|
374
|
+
let minT = Number.POSITIVE_INFINITY;
|
|
375
|
+
let maxT = Number.NEGATIVE_INFINITY;
|
|
376
|
+
let minIso: string | null = null;
|
|
377
|
+
let maxIso: string | null = null;
|
|
378
|
+
for (const v of committedViews as StacItemView[]) {
|
|
379
|
+
const iso = v.datetime ?? v.endDatetime;
|
|
380
|
+
if (!iso) continue;
|
|
381
|
+
const t = Date.parse(iso);
|
|
382
|
+
if (!Number.isFinite(t)) continue;
|
|
383
|
+
if (t < minT) {
|
|
384
|
+
minT = t;
|
|
385
|
+
minIso = iso;
|
|
386
|
+
}
|
|
387
|
+
if (t > maxT) {
|
|
388
|
+
maxT = t;
|
|
389
|
+
maxIso = iso;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!minIso || !maxIso) return null;
|
|
393
|
+
return { start: minIso.slice(0, 10), end: maxIso.slice(0, 10) };
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ─── Stage HUD ─────────────────────────────────────────────────────
|
|
397
|
+
type Stage = 'idle' | 'classify' | 'fetch' | 'index' | 'render' | 'done' | 'error';
|
|
398
|
+
let stage = $state<Stage>('idle');
|
|
399
|
+
let stageFetched = $state(0);
|
|
400
|
+
let stageHinted = $state<number | null>(null);
|
|
401
|
+
let lastRefreshAt = $state<number | null>(null);
|
|
402
|
+
let stageMessage = $state<string | null>(null);
|
|
403
|
+
let showFilters = $state(false);
|
|
404
|
+
// Storage smoke-test result for the first representative COG. Inspired by
|
|
405
|
+
// lazycogs `_smoketest_store`: a one-byte ranged GET surfaces auth / CORS /
|
|
406
|
+
// presign failures at viewer load instead of waiting for the inner TileLayer
|
|
407
|
+
// to fail mid-render. Only set when probe fails, so the HUD stays quiet on
|
|
408
|
+
// the happy path. Cleared on every `loadMosaic()` retry.
|
|
409
|
+
let smokeWarning = $state<string | null>(null);
|
|
410
|
+
let smokeProbed = false;
|
|
91
411
|
|
|
92
412
|
let pool: DecoderPool | null = new DecoderPool();
|
|
93
413
|
const epsgResolver = createEpsgResolver();
|
|
94
414
|
|
|
415
|
+
// ─── Layer derivation (THE renderer) ───────────────────────────────
|
|
416
|
+
// The full deck.gl layer set is a pure function of (committedSources,
|
|
417
|
+
// bandConfig, rescale, pipelineGen, showFootprints, hoveredId, selectedId,
|
|
418
|
+
// filterState). Whenever any of these change, `layers` re-derives with a
|
|
419
|
+
// fresh layer instance and the single $effect below propagates it through
|
|
420
|
+
// `MapboxOverlay.setProps`. Layer identity is content-hashed so deck.gl
|
|
421
|
+
// reconciles in-place when content is unchanged and cleanly remounts when
|
|
422
|
+
// content changes — never reusing a finalized instance.
|
|
423
|
+
function hashSources(items: ReadonlyArray<MosaicSourceMeta>): string {
|
|
424
|
+
if (items.length === 0) return '0';
|
|
425
|
+
return `${items.length}-${items[0].id}-${items[items.length - 1].id}`;
|
|
426
|
+
}
|
|
427
|
+
// Composite signature is embedded in every mosaic / multi-cog layer id so any
|
|
428
|
+
// band-or-asset swap forces deck.gl to unmount the stale layer and mount a
|
|
429
|
+
// fresh one with freshly resolved sources. Without this, `setComposite` only
|
|
430
|
+
// updates `composite` state — `setMosaicAssetKey` early-returns when only the
|
|
431
|
+
// band index changed (single-asset path), and `setComposite` does not call
|
|
432
|
+
// `bumpPipeline` for the multi-asset path, so the layer id stays the same and
|
|
433
|
+
// deck.gl reconciles in-place over an internal source map opened under the
|
|
434
|
+
// previous composite.
|
|
435
|
+
function compositeSignature(c: ChannelComposite | null): string {
|
|
436
|
+
if (!c) return 'none';
|
|
437
|
+
return `${c.r.assetKey}.${c.r.bandIndex}-${c.g.assetKey}.${c.g.bandIndex}-${c.b.assetKey}.${c.b.bandIndex}`;
|
|
438
|
+
}
|
|
439
|
+
const mosaicId = $derived(
|
|
440
|
+
`mosaic-${hashSources(filteredItems)}-c${compositeSignature(composite)}-p${pipelineGen}`
|
|
441
|
+
);
|
|
442
|
+
const footprintId = $derived(`footprints-${tab.id}`);
|
|
443
|
+
|
|
444
|
+
const mosaicLayer = $derived.by(() => {
|
|
445
|
+
if (filteredItems.length === 0) return null;
|
|
446
|
+
const sources = $state.snapshot(filteredItems) as MosaicSourceMeta[];
|
|
447
|
+
const bc = bandConfig ? { ...bandConfig } : null;
|
|
448
|
+
const rs = { ...rescale };
|
|
449
|
+
const signal = abortController.signal;
|
|
450
|
+
const gen = pipelineGen;
|
|
451
|
+
// 0.7.0 MosaicLayer exposes `onSourceUnload(source, { data })` natively
|
|
452
|
+
// (was forwarded by our pnpm patch in 0.6.1). `source` is the resolved
|
|
453
|
+
// MosaicSourceMeta, so `source.id` is our source id. `any` widens at the
|
|
454
|
+
// boundary so we can drive Svelte-side cache eviction off the unload signal.
|
|
455
|
+
const mosaicProps: any = {
|
|
456
|
+
id: mosaicId,
|
|
457
|
+
sources,
|
|
458
|
+
maxCacheSize: TILE_CACHE_MAX,
|
|
459
|
+
// Cap concurrent COG range fetches the inner TileLayer can fire. With a
|
|
460
|
+
// dense mosaic on a single S3 host (e.g. source.coop) Chrome's per-renderer
|
|
461
|
+
// URL request budget exhausts as `net::ERR_INSUFFICIENT_RESOURCES` once
|
|
462
|
+
// hundreds of sources go in-flight together. 6 matches Chrome's HTTP/1.1
|
|
463
|
+
// per-host concurrency cap; MosaicLayer forwards `maxRequests` natively.
|
|
464
|
+
maxRequests: 6,
|
|
465
|
+
// Coalesce pan/zoom-jitter so we don't fire range fetches that get aborted
|
|
466
|
+
// half a frame later. deck.gl forwards `debounceTime` natively to TileLayer.
|
|
467
|
+
debounceTime: 200,
|
|
468
|
+
onSourceUnload: (source: { id?: string } | undefined) => {
|
|
469
|
+
const sid = source?.id;
|
|
470
|
+
if (typeof sid !== 'string') return;
|
|
471
|
+
// Keep `geotiffCache` / `presignCache` / `sourceHrefById` populated
|
|
472
|
+
// past the tile unload — they are bounded by `SOURCE_CACHE_MAX`
|
|
473
|
+
// (LRU-evicted under pressure) and are tiny per entry. This makes
|
|
474
|
+
// pan-back to a previously-visited bbox skip the COG header
|
|
475
|
+
// re-fetch and the SigV4 re-sign. Histograms reflect visible
|
|
476
|
+
// pixels, not source data, so they are still dropped here.
|
|
477
|
+
if (sourceHistograms.delete(sid)) aggregateSources();
|
|
478
|
+
},
|
|
479
|
+
getSource: async (source: MosaicSourceMeta, opts: { signal?: AbortSignal }) => {
|
|
480
|
+
const cached = geotiffCache.get(source.id);
|
|
481
|
+
if (cached) return cached.catch(() => undefined as unknown as GeoTIFF);
|
|
482
|
+
const promise = (async () => {
|
|
483
|
+
const url = await presignHref(source.href);
|
|
484
|
+
const geotiff = await loadGeoTIFF(url);
|
|
485
|
+
normalizeCogGeotiff(geotiff);
|
|
486
|
+
return geotiff;
|
|
487
|
+
})();
|
|
488
|
+
geotiffCache.set(source.id, promise);
|
|
489
|
+
sourceHrefById.set(source.id, source.href);
|
|
490
|
+
let geotiff: GeoTIFF;
|
|
491
|
+
try {
|
|
492
|
+
geotiff = await promise;
|
|
493
|
+
} catch (err) {
|
|
494
|
+
// Swallow per-source fetch/decode failures so deck.gl's TileLayer
|
|
495
|
+
// gets `data: undefined` (renderSource returns null for it) instead
|
|
496
|
+
// of a rejected promise, which surfaces as "v is null" during the
|
|
497
|
+
// TileLayer update when a mosaic covers hundreds of unreachable
|
|
498
|
+
// sources (e.g. a 302k-item global catalog). Surface only the first
|
|
499
|
+
// distinct error per session so the network panel hints why a
|
|
500
|
+
// mosaic is empty without flooding the console on bad catalogs.
|
|
501
|
+
if (!sourceErrorLogged) {
|
|
502
|
+
sourceErrorLogged = true;
|
|
503
|
+
console.warn('[StacMosaic] getSource failed', {
|
|
504
|
+
id: source.id,
|
|
505
|
+
href: source.href,
|
|
506
|
+
error: err instanceof Error ? err.message : err
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
return undefined as unknown as GeoTIFF;
|
|
510
|
+
}
|
|
511
|
+
if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
512
|
+
// Seed band config from the first COG that resolves so the UI and
|
|
513
|
+
// the pipeline match the actual raster (e.g. 4-band NAIP RGB+NIR).
|
|
514
|
+
if (!probedBandCount) {
|
|
515
|
+
probedBandCount = true;
|
|
516
|
+
const count = geotiff.count ?? 3;
|
|
517
|
+
const sf = geotiff.cachedTags.sampleFormat?.[0] ?? 1;
|
|
518
|
+
const bps = geotiff.cachedTags.bitsPerSample?.[0] ?? 8;
|
|
519
|
+
detectedBandCount = count;
|
|
520
|
+
detectedDataType = buildDataTypeLabel(sf, bps);
|
|
521
|
+
// Surface GDAL_NODATA so the CogControls Auto pill / shader filter
|
|
522
|
+
// has a real number before the multi-asset histogram bake fires.
|
|
523
|
+
autoNodata = readGdalNodata(geotiff);
|
|
524
|
+
// Catalogs without `eo:bands` / `raster:bands` / `properties.bands`
|
|
525
|
+
// (e.g. tge-labs/aef: one `data` asset, 64-band Int8 cube) seed
|
|
526
|
+
// `cogAssets` with `bandCount: 1, bandCountKnown: false`, which
|
|
527
|
+
// makes the RGB picker collapse every channel row to "Band 1".
|
|
528
|
+
// Now that we know the real count, patch the asset feeding the
|
|
529
|
+
// mosaic so the picker exposes all bands.
|
|
530
|
+
const probedKey = mosaicAssetKey ?? composite?.r.assetKey ?? cogAssets[0]?.key;
|
|
531
|
+
if (probedKey && cogAssets.length > 0) {
|
|
532
|
+
let changed = false;
|
|
533
|
+
const updated = cogAssets.map((a) => {
|
|
534
|
+
if (a.key !== probedKey) return a;
|
|
535
|
+
if (a.bandCountKnown && a.bandCount === count) return a;
|
|
536
|
+
changed = true;
|
|
537
|
+
return { ...a, bandCount: count, bandCountKnown: true };
|
|
538
|
+
});
|
|
539
|
+
if (changed) {
|
|
540
|
+
cogAssets = updated;
|
|
541
|
+
// If R/G/B all bound to the same asset at band 0 (the
|
|
542
|
+
// fallback `pickNaturalColorComposite` emits when bandCount
|
|
543
|
+
// was unknown/1), spread them across bands 0/1/2 of the
|
|
544
|
+
// now-multi-band asset so the picker shows three distinct
|
|
545
|
+
// band picks instead of three identical "Band 1" rows.
|
|
546
|
+
const cur0 = composite;
|
|
547
|
+
if (
|
|
548
|
+
cur0 &&
|
|
549
|
+
isSingleAssetComposite(cur0) &&
|
|
550
|
+
cur0.r.bandIndex === 0 &&
|
|
551
|
+
cur0.g.bandIndex === 0 &&
|
|
552
|
+
cur0.b.bandIndex === 0 &&
|
|
553
|
+
count >= 2
|
|
554
|
+
) {
|
|
555
|
+
const lim = Math.max(0, count - 1);
|
|
556
|
+
composite = {
|
|
557
|
+
r: { assetKey: cur0.r.assetKey, bandIndex: 0 },
|
|
558
|
+
g: { assetKey: cur0.g.assetKey, bandIndex: Math.min(1, lim) },
|
|
559
|
+
b: { assetKey: cur0.b.assetKey, bandIndex: Math.min(2, lim) }
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const seeded = defaultBandConfig(count, sf);
|
|
565
|
+
// If the user already has a single-asset composite (URL hash, or
|
|
566
|
+
// natural-color default with eo:bands ordering), seed `bandConfig`
|
|
567
|
+
// with those band picks so the first render honors them instead
|
|
568
|
+
// of overwriting with `defaultBandConfig`'s 0/1/2.
|
|
569
|
+
const cur = composite;
|
|
570
|
+
if (cur && isSingleAssetComposite(cur) && seeded.mode === 'rgb') {
|
|
571
|
+
const lim = Math.max(0, count - 1);
|
|
572
|
+
bandConfig = {
|
|
573
|
+
...seeded,
|
|
574
|
+
rBand: Math.min(cur.r.bandIndex, lim),
|
|
575
|
+
gBand: Math.min(cur.g.bandIndex, lim),
|
|
576
|
+
bBand: Math.min(cur.b.bandIndex, lim)
|
|
577
|
+
};
|
|
578
|
+
} else {
|
|
579
|
+
bandConfig = seeded;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return geotiff;
|
|
583
|
+
},
|
|
584
|
+
renderSource: (source: MosaicSourceMeta, { data }: { data: GeoTIFF | undefined }) => {
|
|
585
|
+
if (!data) return null;
|
|
586
|
+
const customProps = selectCogPipeline(data, { bandConfig: bc, rescale: rs });
|
|
587
|
+
// `onViewportLoad` / `onTileError` are forwarded natively by COGLayer's
|
|
588
|
+
// RasterTileLayer base in 0.7.0 (deck.gl-raster PR #546). The `any` cast
|
|
589
|
+
// remains because COGLayer's generated .d.ts still does not surface them.
|
|
590
|
+
const cogProps: any = {
|
|
591
|
+
id: `cog-${source.id}-p${gen}`,
|
|
592
|
+
geotiff: data,
|
|
593
|
+
pool: pool ?? undefined,
|
|
594
|
+
epsgResolver,
|
|
595
|
+
signal,
|
|
596
|
+
...customProps,
|
|
597
|
+
onViewportLoad: (visibleTiles: unknown) => {
|
|
598
|
+
recordSourceHistogram(
|
|
599
|
+
source.id,
|
|
600
|
+
visibleTiles as ReadonlyArray<{ content?: unknown } | null | undefined>
|
|
601
|
+
);
|
|
602
|
+
},
|
|
603
|
+
onTileError: (err: unknown) => {
|
|
604
|
+
if (isAbortError(err)) return;
|
|
605
|
+
logTileErrorOnce(source.id, err);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
return new COGLayer(cogProps);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
return new MosaicLayer<MosaicSourceMeta, GeoTIFF>(mosaicProps);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// Multi-asset mosaic memory ceiling: with N items × 3 distinct assets the
|
|
615
|
+
// worst case is 3N COG range-request streams. `mosaicItemLimit` (settings)
|
|
616
|
+
// bounds N. If `multiCogLayers.length × 3` exceeds 300 the user gets a
|
|
617
|
+
// warning HUD pill (see template).
|
|
618
|
+
const multiCogLayers = $derived.by(() => {
|
|
619
|
+
const c = composite;
|
|
620
|
+
if (!c) return [] as MultiCOGLayer[];
|
|
621
|
+
if (isSingleAssetComposite(c)) return [] as MultiCOGLayer[];
|
|
622
|
+
const views = filteredViews;
|
|
623
|
+
if (views.length === 0) return [] as MultiCOGLayer[];
|
|
624
|
+
const out: MultiCOGLayer[] = [];
|
|
625
|
+
const rs = { ...rescale };
|
|
626
|
+
const gen = pipelineGen;
|
|
627
|
+
const resolvedNodata = resolveNodata(nodataConfig, autoNodata);
|
|
628
|
+
// Hoisted: same value for every per-item layer in this derive run. Embedded
|
|
629
|
+
// in every layer id so band/asset swaps remount the layer (see
|
|
630
|
+
// `compositeSignature` doc comment above).
|
|
631
|
+
const compositeKey = compositeSignature(c);
|
|
632
|
+
for (const view of views) {
|
|
633
|
+
const item = view.raw;
|
|
634
|
+
const itemAssets = extractCogAssets(item);
|
|
635
|
+
const sources: Record<string, { url: string }> = {};
|
|
636
|
+
for (const ref of [c.r, c.g, c.b]) {
|
|
637
|
+
if (sources[ref.assetKey]) continue;
|
|
638
|
+
const a = itemAssets.find((x) => x.key === ref.assetKey);
|
|
639
|
+
if (!a) continue;
|
|
640
|
+
// Sync lookup against the resolved-URL map populated by
|
|
641
|
+
// `presignHref`. If the presign hasn't settled yet, schedule it
|
|
642
|
+
// and skip this item this tick — the next render after the
|
|
643
|
+
// promise resolves will include it (the derivation re-runs when
|
|
644
|
+
// `pipelineGen` bumps or filteredViews changes; we also poke
|
|
645
|
+
// the chain via committing on presign resolution where needed).
|
|
646
|
+
const resolved = resolvedHrefByOriginal.get(a.href);
|
|
647
|
+
if (resolved) {
|
|
648
|
+
sources[a.key] = { url: resolved };
|
|
649
|
+
} else {
|
|
650
|
+
presignHref(a.href);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// Skip items whose 3 channels don't all have resolved URLs yet.
|
|
654
|
+
if (!sources[c.r.assetKey] || !sources[c.g.assetKey] || !sources[c.b.assetKey]) continue;
|
|
655
|
+
// `onTileError` is forwarded natively by MultiCOGLayer's RasterTileLayer
|
|
656
|
+
// base in 0.7.0 (deck.gl-raster PR #546), but the generated .d.ts does
|
|
657
|
+
// not surface it. Widen at the boundary.
|
|
658
|
+
const layerProps: any = {
|
|
659
|
+
id: `mosaic-multicog-${view.id}-c${compositeKey}-p${gen}`,
|
|
660
|
+
sources,
|
|
661
|
+
composite: { r: c.r.assetKey, g: c.g.assetKey, b: c.b.assetKey },
|
|
662
|
+
renderPipeline: buildBandRenderPipeline({
|
|
663
|
+
noDataVal: resolvedNodata,
|
|
664
|
+
rescale: rs
|
|
665
|
+
}),
|
|
666
|
+
pool: pool ?? undefined,
|
|
667
|
+
epsgResolver,
|
|
668
|
+
// See MosaicLayer note above. The multi-asset path runs N per-item
|
|
669
|
+
// layers, so the aggregate concurrency budget is even tighter —
|
|
670
|
+
// keep `maxRequests` low.
|
|
671
|
+
maxRequests: 6,
|
|
672
|
+
debounceTime: 200,
|
|
673
|
+
onTileError: (err: Error) => {
|
|
674
|
+
if (isAbortError(err)) return;
|
|
675
|
+
logTileErrorOnce(view.id, err);
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
out.push(new MultiCOGLayer(layerProps));
|
|
679
|
+
}
|
|
680
|
+
return out;
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const footprintLayer = $derived.by(() => {
|
|
684
|
+
if (!showFootprints) return null;
|
|
685
|
+
const views = committedViews as StacItemView[];
|
|
686
|
+
if (views.length === 0) return null;
|
|
687
|
+
const allowedIds = new Set(filteredViews.map((v) => v.id));
|
|
688
|
+
const filtersOn = filtersActive;
|
|
689
|
+
type FootprintProps = { id: string };
|
|
690
|
+
type FootprintFeature = {
|
|
691
|
+
type: 'Feature';
|
|
692
|
+
properties: FootprintProps;
|
|
693
|
+
geometry: { type: 'Polygon'; coordinates: number[][][] };
|
|
694
|
+
};
|
|
695
|
+
const features: FootprintFeature[] = [];
|
|
696
|
+
for (const v of views) {
|
|
697
|
+
if (!v.bbox) continue;
|
|
698
|
+
const [w, s, e, n] = v.bbox;
|
|
699
|
+
features.push({
|
|
700
|
+
type: 'Feature',
|
|
701
|
+
properties: { id: v.id },
|
|
702
|
+
geometry: {
|
|
703
|
+
type: 'Polygon',
|
|
704
|
+
coordinates: [
|
|
705
|
+
[
|
|
706
|
+
[w, s],
|
|
707
|
+
[e, s],
|
|
708
|
+
[e, n],
|
|
709
|
+
[w, n],
|
|
710
|
+
[w, s]
|
|
711
|
+
]
|
|
712
|
+
]
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
if (features.length === 0) return null;
|
|
717
|
+
const hovered = hoveredId;
|
|
718
|
+
const selected = selectedId;
|
|
719
|
+
type FeatureLike = { properties?: FootprintProps | null };
|
|
720
|
+
return new GeoJsonLayer<FootprintProps>({
|
|
721
|
+
id: footprintId,
|
|
722
|
+
data: { type: 'FeatureCollection', features },
|
|
723
|
+
stroked: true,
|
|
724
|
+
// `filled: true` with a near-transparent fill makes the polygon
|
|
725
|
+
// interior pickable. With `filled: false` deck.gl's hit test only
|
|
726
|
+
// covers the stroke edge, which means clicks inside the box never
|
|
727
|
+
// fire `onClick` and the yellow selection highlight never appears.
|
|
728
|
+
filled: true,
|
|
729
|
+
pickable: true,
|
|
730
|
+
lineWidthUnits: 'pixels',
|
|
731
|
+
// Force a 1-pixel minimum so the outline never anti-aliases away at
|
|
732
|
+
// low zoom — without this, the orange grid disappears when the
|
|
733
|
+
// rendered line falls below a fragment.
|
|
734
|
+
lineWidthMinPixels: 1,
|
|
735
|
+
// `updateTriggers` keeps the GeoJsonLayer instance stable across
|
|
736
|
+
// hover/select/filter changes — only the per-feature accessors
|
|
737
|
+
// re-run, no full data re-tessellation. Cheaper than rebuilding the
|
|
738
|
+
// layer for every mouse move.
|
|
739
|
+
updateTriggers: {
|
|
740
|
+
getLineColor: [hovered, selected, filtersOn, allowedIds.size],
|
|
741
|
+
getLineWidth: [hovered, selected, filtersOn, allowedIds.size],
|
|
742
|
+
getFillColor: [hovered, selected]
|
|
743
|
+
},
|
|
744
|
+
getLineColor: (f: FeatureLike): [number, number, number, number] => {
|
|
745
|
+
const id = f.properties?.id;
|
|
746
|
+
if (id === selected) return [255, 221, 51, 255]; // amber yellow
|
|
747
|
+
if (id === hovered) return [255, 165, 0, 255]; // bright orange
|
|
748
|
+
if (filtersOn && id && !allowedIds.has(id)) return [255, 140, 0, 90]; // dim orange
|
|
749
|
+
return [255, 140, 0, 220]; // orange
|
|
750
|
+
},
|
|
751
|
+
getFillColor: (f: FeatureLike): [number, number, number, number] => {
|
|
752
|
+
const id = f.properties?.id;
|
|
753
|
+
// Selected gets a faint amber wash so it reads as filled; everything
|
|
754
|
+
// else uses alpha=1 (effectively invisible) to keep picking on
|
|
755
|
+
// without altering the visual.
|
|
756
|
+
if (id === selected) return [255, 221, 51, 40];
|
|
757
|
+
if (id === hovered) return [255, 165, 0, 24];
|
|
758
|
+
return [0, 0, 0, 1];
|
|
759
|
+
},
|
|
760
|
+
getLineWidth: (f: FeatureLike): number => {
|
|
761
|
+
const id = f.properties?.id;
|
|
762
|
+
if (id === selected) return 3;
|
|
763
|
+
if (id === hovered) return 2.5;
|
|
764
|
+
if (filtersOn && id && !allowedIds.has(id)) return 0.5;
|
|
765
|
+
return 1.5;
|
|
766
|
+
},
|
|
767
|
+
onHover: (info: { object?: FeatureLike | null }) => {
|
|
768
|
+
const id = info.object?.properties?.id ?? null;
|
|
769
|
+
if (id !== hoveredId) hoveredId = id;
|
|
770
|
+
},
|
|
771
|
+
onClick: (info: { object?: FeatureLike | null }) => {
|
|
772
|
+
const id = info.object?.properties?.id ?? null;
|
|
773
|
+
const next = selectedId === id ? null : id;
|
|
774
|
+
selectedId = next;
|
|
775
|
+
if (next) flyToSelected(next);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const layers = $derived.by(() => {
|
|
781
|
+
const out: unknown[] = [];
|
|
782
|
+
const c = composite;
|
|
783
|
+
if (c && isSingleAssetComposite(c) && mosaicLayer) {
|
|
784
|
+
out.push(mosaicLayer);
|
|
785
|
+
} else if (c && !isSingleAssetComposite(c)) {
|
|
786
|
+
out.push(...multiCogLayers);
|
|
787
|
+
} else if (mosaicLayer) {
|
|
788
|
+
// Composite hasn't resolved yet (first batch not seeded). Keep the
|
|
789
|
+
// single-asset MosaicLayer painting the default-href mosaic so the
|
|
790
|
+
// user sees a frame as soon as items arrive.
|
|
791
|
+
out.push(mosaicLayer);
|
|
792
|
+
}
|
|
793
|
+
if (footprintLayer) out.push(footprintLayer);
|
|
794
|
+
return out;
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Single push effect: every reactive change funnels here. `layers` MUST be
|
|
798
|
+
// read before any early return so Svelte tracks it as a dependency on the
|
|
799
|
+
// first run (even when `overlayRef` is still null pre-`onMapReady`).
|
|
800
|
+
// Otherwise the effect's dep set comes back empty, the reactive graph
|
|
801
|
+
// disconnects, and setProps is never called once the overlay attaches.
|
|
802
|
+
$effect(() => {
|
|
803
|
+
const ls = layers;
|
|
804
|
+
if (!overlayRef) return;
|
|
805
|
+
overlayRef.setProps({
|
|
806
|
+
layers: ls as Parameters<typeof overlayRef.setProps>[0]['layers']
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// ─── Tab lifecycle ─────────────────────────────────────────────────
|
|
95
811
|
$effect(() => {
|
|
96
812
|
if (!tab) return;
|
|
97
813
|
tab.id;
|
|
98
814
|
untrack(() => {
|
|
99
815
|
resetViewer();
|
|
100
|
-
if (mapRef)
|
|
816
|
+
if (mapRef) {
|
|
817
|
+
const restart = loadMosaic(mapRef);
|
|
818
|
+
inflightLoad = restart.catch(() => {});
|
|
819
|
+
void restart;
|
|
820
|
+
}
|
|
101
821
|
});
|
|
102
822
|
});
|
|
103
823
|
|
|
104
824
|
function resetViewer(): void {
|
|
105
825
|
abortController.abort();
|
|
106
826
|
abortController = new AbortController();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
827
|
+
hydrationController.abort();
|
|
828
|
+
hydrationController = new AbortController();
|
|
829
|
+
inflightLoad = null;
|
|
830
|
+
teardownViewportReload();
|
|
831
|
+
kind = 'static';
|
|
832
|
+
stage = 'idle';
|
|
833
|
+
stageFetched = 0;
|
|
834
|
+
stageHinted = null;
|
|
835
|
+
stageMessage = null;
|
|
836
|
+
lastRefreshAt = null;
|
|
113
837
|
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
838
|
itemsRef = [];
|
|
123
|
-
|
|
124
|
-
|
|
839
|
+
itemViewsRef = [];
|
|
840
|
+
committedSources = [];
|
|
841
|
+
committedViews = [];
|
|
842
|
+
mapZoomBin = mapRef ? Math.floor(mapRef.getZoom()) : null;
|
|
843
|
+
pipelineGen = 0;
|
|
844
|
+
hoveredId = null;
|
|
845
|
+
selectedId = null;
|
|
846
|
+
filterState = emptyFacetState();
|
|
847
|
+
presignCache = new LruCache<string, Promise<string>>({ max: SOURCE_CACHE_MAX });
|
|
848
|
+
geotiffCache = new LruCache<string, Promise<GeoTIFF>>({ max: SOURCE_CACHE_MAX });
|
|
849
|
+
resolvedHrefByOriginal = new LruCache<string, string>({ max: SOURCE_CACHE_MAX });
|
|
850
|
+
sourceHrefById = new Map();
|
|
125
851
|
sourceHistograms = new Map();
|
|
852
|
+
if (multiCogRebuildHandle !== null) {
|
|
853
|
+
cancelAnimationFrame(multiCogRebuildHandle);
|
|
854
|
+
multiCogRebuildHandle = null;
|
|
855
|
+
}
|
|
126
856
|
loading = true;
|
|
127
857
|
error = null;
|
|
128
|
-
sourceCount = 0;
|
|
129
858
|
bounds = undefined;
|
|
130
859
|
bandConfig = null;
|
|
131
860
|
histogram = null;
|
|
861
|
+
multiHistogramKey = null;
|
|
862
|
+
userTouchedRescale = false;
|
|
863
|
+
nodataConfig = { ...DEFAULT_NODATA_CONFIG };
|
|
864
|
+
autoNodata = null;
|
|
132
865
|
rescale = { ...DEFAULT_RESCALE };
|
|
133
866
|
hasFittedOnce = false;
|
|
134
867
|
showControls = false;
|
|
@@ -136,81 +869,225 @@ function resetViewer(): void {
|
|
|
136
869
|
detectedBandCount = 3;
|
|
137
870
|
detectedDataType = '';
|
|
138
871
|
probedBandCount = false;
|
|
872
|
+
availableAssets = [];
|
|
873
|
+
mosaicAssetKey = null;
|
|
874
|
+
cogAssets = [];
|
|
875
|
+
composite = null;
|
|
876
|
+
activePresetId = '';
|
|
139
877
|
pixelValue = null;
|
|
140
878
|
pixelSourceId = null;
|
|
141
879
|
inspecting = false;
|
|
142
880
|
if (mapRef) removeClickHandler();
|
|
143
881
|
}
|
|
144
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Atomic transition from the streaming buffer (`itemsRef`/`itemViewsRef`)
|
|
885
|
+
* to the rendered set (`committedSources`/`committedViews`). All layer
|
|
886
|
+
* rebuilds happen as a side effect of these two assignments via the
|
|
887
|
+
* derived chain — there is no separate "schedule rebuild" or "push layers"
|
|
888
|
+
* step.
|
|
889
|
+
*
|
|
890
|
+
* Dedup-by-id is enforced here, NOT at the accept site. The accept-site
|
|
891
|
+
* `seenIds` defends within a single loadMosaic call, but viewport reloads
|
|
892
|
+
* + paginated STAC APIs can still surface the same item.id across batches
|
|
893
|
+
* that the buffer has already mixed (revisits with different cell keys,
|
|
894
|
+
* static catalogs that re-walk overlapping links, API pages that overlap
|
|
895
|
+
* the previous response). The commit boundary is the single chokepoint
|
|
896
|
+
* before the renderer, so deduping here makes correctness independent of
|
|
897
|
+
* how the buffer was built. `MosaicSourceMeta` and `StacItemView` are kept
|
|
898
|
+
* lockstep by index, so the same predicate filters both. Snapshotting via
|
|
899
|
+
* fresh arrays defends against deck.gl seeing a proxied Svelte array
|
|
900
|
+
* (Flatbush's spatial index over a Proxy triggers deep_read on every
|
|
901
|
+
* probe) and gives Svelte's keyed `{#each ... (view.id)}` a unique-by-id
|
|
902
|
+
* list — preventing `each_key_duplicate` even when upstream sources are
|
|
903
|
+
* sloppy.
|
|
904
|
+
*/
|
|
905
|
+
function commitSources(): void {
|
|
906
|
+
const len = Math.min(itemsRef.length, itemViewsRef.length);
|
|
907
|
+
const seen = new Set<string>();
|
|
908
|
+
const sources: MosaicSourceMeta[] = [];
|
|
909
|
+
const views: StacItemView[] = [];
|
|
910
|
+
for (let i = 0; i < len; i++) {
|
|
911
|
+
const id = itemsRef[i].id;
|
|
912
|
+
if (seen.has(id)) continue;
|
|
913
|
+
seen.add(id);
|
|
914
|
+
sources.push(itemsRef[i]);
|
|
915
|
+
views.push(itemViewsRef[i]);
|
|
916
|
+
}
|
|
917
|
+
// Pan-back caching: items that drop out of `committedViews` no longer
|
|
918
|
+
// have a rendered layer, but their COG headers + presigned URLs stay in
|
|
919
|
+
// the LRU caches so pan-back to the previous bbox does not re-pay the
|
|
920
|
+
// header IFD fetch and the SigV4 re-sign. The caches are bounded by
|
|
921
|
+
// `SOURCE_CACHE_MAX` and are tiny per entry. Aggressive diff-eviction
|
|
922
|
+
// here would defeat that for both the single-asset and multi-asset
|
|
923
|
+
// paths.
|
|
924
|
+
committedSources = sources;
|
|
925
|
+
committedViews = views;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Bump pipeline generation so the inner TileLayer + per-source COGLayer
|
|
929
|
+
* trees fully unmount/remount when the GPU pipeline definition changes
|
|
930
|
+
* (band picker, rescale slider). Same content hash → same overall
|
|
931
|
+
* MosaicLayer id stem, but the `-pN` suffix forces a clean remount. */
|
|
932
|
+
function bumpPipeline(): void {
|
|
933
|
+
pipelineGen++;
|
|
934
|
+
}
|
|
935
|
+
|
|
145
936
|
function removeClickHandler(): void {
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
937
|
+
if (detachInspector) {
|
|
938
|
+
detachInspector();
|
|
939
|
+
detachInspector = null;
|
|
149
940
|
}
|
|
150
941
|
}
|
|
151
942
|
|
|
943
|
+
type MosaicProbeResult = { value: PixelValue; sourceId: string };
|
|
944
|
+
|
|
152
945
|
function setupClickHandler(map: maplibregl.Map): void {
|
|
153
946
|
removeClickHandler();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
947
|
+
detachInspector = attachPixelInspector<MosaicProbeResult>(map, {
|
|
948
|
+
probe: async ({ lng, lat, signal }) => {
|
|
949
|
+
// Click against the rendered set, not the streaming buffer, so the
|
|
950
|
+
// pixel readout matches what the user is actually looking at. Reverse
|
|
951
|
+
// iteration matches MosaicLayer's z-order (last source on top).
|
|
952
|
+
const items = committedSources;
|
|
953
|
+
let hit: MosaicSourceMeta | undefined;
|
|
954
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
955
|
+
const [w, s, east, n] = items[i].bbox;
|
|
956
|
+
if (lng >= w && lng <= east && lat >= s && lat <= n) {
|
|
957
|
+
hit = items[i];
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
167
960
|
}
|
|
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.
|
|
961
|
+
if (!hit) return null;
|
|
178
962
|
let geotiffPromise = geotiffCache.get(hit.id);
|
|
179
963
|
if (!geotiffPromise) {
|
|
180
964
|
geotiffPromise = (async () => {
|
|
181
965
|
const url = await presignHref(hit.href);
|
|
182
|
-
const g = await
|
|
966
|
+
const g = await loadGeoTIFF(url);
|
|
183
967
|
normalizeCogGeotiff(g);
|
|
184
968
|
return g;
|
|
185
969
|
})();
|
|
186
970
|
geotiffCache.set(hit.id, geotiffPromise);
|
|
971
|
+
sourceHrefById.set(hit.id, hit.href);
|
|
187
972
|
}
|
|
188
973
|
const geotiff = await geotiffPromise;
|
|
189
|
-
const proj4Def = await resolveProj4Def(geotiff.crs,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
974
|
+
const proj4Def = await resolveProj4Def(geotiff.crs, signal);
|
|
975
|
+
// Match the overview that's currently on screen so the pixel readout
|
|
976
|
+
// reflects the visible decimation level. Per-source COGs may have
|
|
977
|
+
// different overview pyramids so the pick happens after the source
|
|
978
|
+
// is resolved.
|
|
979
|
+
const targetRes = mapResolutionMetersPerPixel(map.getZoom(), lat);
|
|
980
|
+
const overview = selectOverviewForResolution(geotiff, targetRes);
|
|
981
|
+
const result = await readPixelAtLngLat(geotiff, lng, lat, proj4Def, pool, signal, {
|
|
982
|
+
overview
|
|
983
|
+
});
|
|
984
|
+
if (!result) return null;
|
|
985
|
+
return { value: result, sourceId: hit.id };
|
|
986
|
+
},
|
|
987
|
+
onStart: () => {
|
|
988
|
+
inspecting = true;
|
|
989
|
+
},
|
|
990
|
+
onResult: (result) => {
|
|
991
|
+
pixelValue = result?.value ?? null;
|
|
992
|
+
pixelSourceId = result?.sourceId ?? null;
|
|
204
993
|
inspecting = false;
|
|
205
994
|
}
|
|
206
|
-
};
|
|
207
|
-
map.on('click', clickHandlerRef);
|
|
995
|
+
});
|
|
208
996
|
}
|
|
209
997
|
|
|
210
998
|
function onMapReady(map: maplibregl.Map): void {
|
|
211
999
|
mapRef = map;
|
|
1000
|
+
// Bump the center-tick so the Explain panel's center-overlap stat
|
|
1001
|
+
// re-derives whenever the user pans / zooms. Also update `mapZoomBin`
|
|
1002
|
+
// (integer zoom) so `culledSources` re-evaluates only at zoom-level
|
|
1003
|
+
// boundaries — within a bin the source list is stable, so micro-pans
|
|
1004
|
+
// don't churn the inner TileLayer.
|
|
1005
|
+
mapZoomBin = Math.floor(map.getZoom());
|
|
1006
|
+
map.on('moveend', () => {
|
|
1007
|
+
mapCenterTick++;
|
|
1008
|
+
const z = Math.floor(map.getZoom());
|
|
1009
|
+
if (z !== mapZoomBin) mapZoomBin = z;
|
|
1010
|
+
});
|
|
212
1011
|
setupClickHandler(map);
|
|
213
|
-
|
|
1012
|
+
const overlay = new MapboxOverlay({
|
|
1013
|
+
interleaved: false,
|
|
1014
|
+
layers: [],
|
|
1015
|
+
onError: (err: Error) => {
|
|
1016
|
+
if (abortController.signal.aborted) return;
|
|
1017
|
+
if (isAbortError(err)) return;
|
|
1018
|
+
if (!error) {
|
|
1019
|
+
error = err?.message || String(err);
|
|
1020
|
+
loading = false;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
overlayRef = overlay;
|
|
1025
|
+
map.addControl(overlay as unknown as maplibregl.IControl);
|
|
1026
|
+
const initial = loadMosaic(map);
|
|
1027
|
+
inflightLoad = initial.catch(() => {});
|
|
1028
|
+
void initial;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function viewportBbox(map: maplibregl.Map): [number, number, number, number] {
|
|
1032
|
+
const b = map.getBounds();
|
|
1033
|
+
const c = clampBounds({
|
|
1034
|
+
west: b.getWest(),
|
|
1035
|
+
south: b.getSouth(),
|
|
1036
|
+
east: b.getEast(),
|
|
1037
|
+
north: b.getNorth()
|
|
1038
|
+
});
|
|
1039
|
+
return [c.west, c.south, c.east, c.north];
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function setupViewportReload(map: maplibregl.Map): void {
|
|
1043
|
+
teardownViewportReload();
|
|
1044
|
+
moveHandlerRef = () => {
|
|
1045
|
+
if (moveDebounceTimer != null) clearTimeout(moveDebounceTimer);
|
|
1046
|
+
moveDebounceTimer = window.setTimeout(() => {
|
|
1047
|
+
moveDebounceTimer = null;
|
|
1048
|
+
if (!mapRef) return;
|
|
1049
|
+
void reloadViewport();
|
|
1050
|
+
}, VIEWPORT_DEBOUNCE_MS);
|
|
1051
|
+
};
|
|
1052
|
+
map.on('moveend', moveHandlerRef);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function teardownViewportReload(): void {
|
|
1056
|
+
if (moveDebounceTimer != null) {
|
|
1057
|
+
clearTimeout(moveDebounceTimer);
|
|
1058
|
+
moveDebounceTimer = null;
|
|
1059
|
+
}
|
|
1060
|
+
if (mapRef && moveHandlerRef) {
|
|
1061
|
+
mapRef.off('moveend', moveHandlerRef);
|
|
1062
|
+
moveHandlerRef = null;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function reloadViewport(): Promise<void> {
|
|
1067
|
+
if (!mapRef) return;
|
|
1068
|
+
if (hydrationController.signal.aborted === false && stage === 'fetch') {
|
|
1069
|
+
stageMessage = t('stac.stageSuperseded');
|
|
1070
|
+
}
|
|
1071
|
+
hydrationController.abort();
|
|
1072
|
+
// Wait for the prior loadMosaic to actually settle before issuing a new
|
|
1073
|
+
// one. Without this, the JS-side abort returns instantly but the underlying
|
|
1074
|
+
// DuckDB queryStream keeps scanning the parquet (cancelSent is polled at
|
|
1075
|
+
// batch boundaries, ~10s for a Philly-sized scan). Stacking these without
|
|
1076
|
+
// waiting reproduced the 3.1 GiB OOM from rapid moveend events.
|
|
1077
|
+
if (inflightLoad) {
|
|
1078
|
+
try {
|
|
1079
|
+
await inflightLoad;
|
|
1080
|
+
} catch {
|
|
1081
|
+
/* prior was aborted or errored — fine, we're starting fresh */
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
hydrationController = new AbortController();
|
|
1085
|
+
error = null;
|
|
1086
|
+
loading = true;
|
|
1087
|
+
hasFittedOnce = true;
|
|
1088
|
+
const next = loadMosaic(mapRef);
|
|
1089
|
+
inflightLoad = next.catch(() => {});
|
|
1090
|
+
await next;
|
|
214
1091
|
}
|
|
215
1092
|
|
|
216
1093
|
function extractConnectionKey(href: string): string | null {
|
|
@@ -228,28 +1105,53 @@ function extractConnectionKey(href: string): string | null {
|
|
|
228
1105
|
return href.slice(prefix.length);
|
|
229
1106
|
}
|
|
230
1107
|
|
|
1108
|
+
function doPresign(href: string): Promise<string> {
|
|
1109
|
+
const normalized = resolveCloudUrl(href);
|
|
1110
|
+
if (/^https?:\/\//i.test(normalized)) {
|
|
1111
|
+
const key = extractConnectionKey(normalized);
|
|
1112
|
+
if (key !== null) {
|
|
1113
|
+
return buildHttpsUrlAsync({ ...tab, path: key } as Tab).catch(() => normalized);
|
|
1114
|
+
}
|
|
1115
|
+
return Promise.resolve(normalized);
|
|
1116
|
+
}
|
|
1117
|
+
return buildHttpsUrlAsync({ ...tab, path: normalized } as Tab).catch(() => normalized);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Coalesced multi-asset rebuild scheduler. Many presigns can resolve in the
|
|
1121
|
+
// same tick when a viewport batch lands; bumping `pipelineGen` per resolve
|
|
1122
|
+
// would rebuild the `$derived multiCogLayers` once per resolution and remount
|
|
1123
|
+
// every visible MultiCOGLayer mid-pan. Schedule one rebuild per animation
|
|
1124
|
+
// frame instead so resolutions coalesce into a single re-derive. The handle
|
|
1125
|
+
// is captured so teardown / asset swap / reset can cancel a pending rAF
|
|
1126
|
+
// before it writes to `pipelineGen` post-cleanup.
|
|
1127
|
+
let multiCogRebuildHandle: number | null = null;
|
|
1128
|
+
function scheduleMultiCogRebuild(): void {
|
|
1129
|
+
if (multiCogRebuildHandle !== null) return;
|
|
1130
|
+
const c = composite;
|
|
1131
|
+
if (!c || isSingleAssetComposite(c)) return;
|
|
1132
|
+
multiCogRebuildHandle = requestAnimationFrame(() => {
|
|
1133
|
+
multiCogRebuildHandle = null;
|
|
1134
|
+
const cur = composite;
|
|
1135
|
+
if (!cur || isSingleAssetComposite(cur)) return;
|
|
1136
|
+
bumpPipeline();
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
231
1140
|
function presignHref(href: string): Promise<string> {
|
|
232
1141
|
let cached = presignCache.get(href);
|
|
233
1142
|
if (!cached) {
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
}
|
|
1143
|
+
// Populate `resolvedHrefByOriginal` once the promise settles so the
|
|
1144
|
+
// multi-asset MultiCOGLayer derivation can attach URLs synchronously.
|
|
1145
|
+
// On the multi-asset path, schedule a coalesced rebuild so items whose
|
|
1146
|
+
// 3 channels just became available join the rendered set on the next
|
|
1147
|
+
// frame. Cheap on the single-asset path (early return when composite
|
|
1148
|
+
// is single-asset).
|
|
1149
|
+
cached = doPresign(href).then((url) => {
|
|
1150
|
+
const wasNew = !resolvedHrefByOriginal.has(href);
|
|
1151
|
+
resolvedHrefByOriginal.set(href, url);
|
|
1152
|
+
if (wasNew) scheduleMultiCogRebuild();
|
|
1153
|
+
return url;
|
|
1154
|
+
});
|
|
253
1155
|
presignCache.set(href, cached);
|
|
254
1156
|
}
|
|
255
1157
|
return cached;
|
|
@@ -271,295 +1173,463 @@ function extendBounds(
|
|
|
271
1173
|
return [clamped.west, clamped.south, clamped.east, clamped.north];
|
|
272
1174
|
}
|
|
273
1175
|
|
|
274
|
-
function
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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);
|
|
1176
|
+
function applyFacetsToItems(
|
|
1177
|
+
items: import('@walkthru-earth/objex-utils').StacItem[],
|
|
1178
|
+
residual: FacetState
|
|
1179
|
+
): import('@walkthru-earth/objex-utils').StacItem[] {
|
|
1180
|
+
if (!hasActiveFilters(residual)) return items;
|
|
1181
|
+
const views = items.map(extractItemView);
|
|
1182
|
+
const allowed = new Set(applyFacets(views, residual).map((v) => v.id));
|
|
1183
|
+
return items.filter((it) => allowed.has(String(it.id)));
|
|
294
1184
|
}
|
|
295
1185
|
|
|
296
1186
|
async function loadMosaic(map: maplibregl.Map): Promise<void> {
|
|
297
1187
|
const gen = ++loadGen;
|
|
298
|
-
const signal =
|
|
1188
|
+
const signal = hydrationController.signal;
|
|
1189
|
+
const cellCounts = new Map<string, number>();
|
|
1190
|
+
const seenIds = new Set<string>();
|
|
1191
|
+
const dedupeByCell = dedupeLatest;
|
|
1192
|
+
stage = 'classify';
|
|
1193
|
+
stageMessage = null;
|
|
1194
|
+
stageFetched = 0;
|
|
1195
|
+
stageHinted = null;
|
|
1196
|
+
smokeWarning = null;
|
|
1197
|
+
smokeProbed = false;
|
|
1198
|
+
loggedTileErrors.clear();
|
|
299
1199
|
try {
|
|
300
1200
|
const adapter = getAdapter(tab.source, tab.connectionId);
|
|
301
1201
|
const ext = (tab.extension ?? '').toLowerCase();
|
|
302
1202
|
|
|
303
|
-
|
|
304
|
-
// in one query, so hydration is a single batch (no link walking).
|
|
1203
|
+
let classifiedKind: StacRoutableKind;
|
|
305
1204
|
if (ext === 'parquet' || ext === 'geoparquet') {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
|
|
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;
|
|
1205
|
+
classifiedKind = {
|
|
1206
|
+
kind: 'item-collection',
|
|
1207
|
+
fc: { type: 'FeatureCollection', features: [] }
|
|
1208
|
+
};
|
|
1209
|
+
} else if (classified && classified.kind !== 'none') {
|
|
1210
|
+
classifiedKind = classified;
|
|
323
1211
|
} else {
|
|
324
1212
|
const data = await adapter.read(tab.path, undefined, undefined, signal);
|
|
325
1213
|
if (gen !== loadGen || signal.aborted) return;
|
|
326
1214
|
const parsed = JSON.parse(new TextDecoder().decode(data));
|
|
327
|
-
|
|
1215
|
+
classifiedKind = classifyStac(parsed);
|
|
328
1216
|
}
|
|
329
|
-
if (
|
|
1217
|
+
if (classifiedKind.kind === 'none') {
|
|
330
1218
|
error = t('map.mosaicEmpty');
|
|
331
1219
|
loading = false;
|
|
332
1220
|
return;
|
|
333
1221
|
}
|
|
334
1222
|
|
|
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
1223
|
const baseHref = await buildHttpsUrlAsync(tab);
|
|
341
1224
|
if (gen !== loadGen || signal.aborted) return;
|
|
342
1225
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
concurrency: 12,
|
|
346
|
-
limit: 2000,
|
|
1226
|
+
const source: StacSource = createStacSourceForTab(tab, classifiedKind, {
|
|
1227
|
+
adapter,
|
|
347
1228
|
urlToKey: extractConnectionKey,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
1229
|
+
baseHref,
|
|
1230
|
+
connectionId: tab.connectionId ?? ''
|
|
1231
|
+
});
|
|
1232
|
+
kind = source.capabilities.kind;
|
|
1233
|
+
// `api` streams over a viewport-scoped query, `parquet` re-runs the SQL
|
|
1234
|
+
// with the new bbox. Both want a moveend listener. `static` walks a
|
|
1235
|
+
// fixed advertised tree and never re-queries on pan.
|
|
1236
|
+
if (kind === 'static') {
|
|
1237
|
+
teardownViewportReload();
|
|
1238
|
+
} else {
|
|
1239
|
+
setupViewportReload(map);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const effectiveFilter: FacetState = { ...filterState };
|
|
1243
|
+
|
|
1244
|
+
const apiBacked = kind === 'api';
|
|
1245
|
+
stage = 'fetch';
|
|
1246
|
+
stageHinted = itemLimit;
|
|
1247
|
+
let firstBatch = true;
|
|
1248
|
+
let runningBounds: [number, number, number, number] | null = null;
|
|
1249
|
+
let fetchedItemCount = 0;
|
|
1250
|
+
let acceptedCount = 0;
|
|
1251
|
+
|
|
1252
|
+
for await (const batch of source.query({
|
|
1253
|
+
bbox: viewportBbox(map),
|
|
1254
|
+
filter: effectiveFilter,
|
|
1255
|
+
limit: itemLimit,
|
|
1256
|
+
pageSize: VIEWPORT_PAGE_LIMIT,
|
|
1257
|
+
signal
|
|
1258
|
+
})) {
|
|
1259
|
+
if (gen !== loadGen || signal.aborted) return;
|
|
1260
|
+
fetchedItemCount += batch.items.length;
|
|
1261
|
+
stageFetched = fetchedItemCount;
|
|
1262
|
+
|
|
1263
|
+
const residualFilteredItems = applyFacetsToItems(batch.items, batch.residual);
|
|
1264
|
+
|
|
1265
|
+
// Seed asset picker from the first item with raster assets so the user
|
|
1266
|
+
// can flip from `visual` → `red` / `nir` / etc. without a re-query.
|
|
1267
|
+
if (availableAssets.length === 0) {
|
|
1268
|
+
for (const probe of residualFilteredItems) {
|
|
1269
|
+
const probed = extractMosaicAssets(probe);
|
|
1270
|
+
if (probed.length > 0) {
|
|
1271
|
+
availableAssets = probed;
|
|
1272
|
+
if (!mosaicAssetKey) {
|
|
1273
|
+
const defaultHref = pickCogAssetHref(probe);
|
|
1274
|
+
const matched = probed.find((a) => a.href === defaultHref);
|
|
1275
|
+
mosaicAssetKey = matched?.key ?? probed[0].key;
|
|
1276
|
+
}
|
|
1277
|
+
// Also seed the unified RGB picker state. URL hash takes
|
|
1278
|
+
// priority, otherwise natural-color default.
|
|
1279
|
+
const nextCogAssets = extractCogAssets(probe);
|
|
1280
|
+
cogAssets = nextCogAssets;
|
|
1281
|
+
const params = getUrlViewParams();
|
|
1282
|
+
const fromUrl = compositeFromUrl(params, nextCogAssets);
|
|
1283
|
+
if (fromUrl && isSingleAssetComposite(fromUrl)) {
|
|
1284
|
+
composite = fromUrl;
|
|
1285
|
+
const presetId = params.get('preset');
|
|
1286
|
+
activePresetId = presetId && PRESETS.find((p) => p.id === presetId) ? presetId : '';
|
|
1287
|
+
} else {
|
|
1288
|
+
const picked = pickNaturalColorComposite(nextCogAssets);
|
|
1289
|
+
if (picked) {
|
|
1290
|
+
composite = picked.composite;
|
|
1291
|
+
activePresetId = picked.source === 'rgb-bands' ? 'natural-color' : '';
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
// Mirror composite.r.assetKey into the existing single-asset
|
|
1295
|
+
// mosaic state so buildMosaicSourceMeta keeps working.
|
|
1296
|
+
if (composite && isSingleAssetComposite(composite)) {
|
|
1297
|
+
mosaicAssetKey = composite.r.assetKey;
|
|
1298
|
+
}
|
|
1299
|
+
break;
|
|
1300
|
+
}
|
|
354
1301
|
}
|
|
355
|
-
|
|
1302
|
+
}
|
|
356
1303
|
|
|
357
|
-
|
|
1304
|
+
const accepted: MosaicSourceMeta[] = [];
|
|
1305
|
+
const acceptedViews: StacItemView[] = [];
|
|
1306
|
+
const assetKeyForBuild = mosaicAssetKey ?? undefined;
|
|
1307
|
+
for (const item of residualFilteredItems) {
|
|
1308
|
+
const normalized = buildMosaicSourceMeta(item, assetKeyForBuild);
|
|
1309
|
+
if (!normalized) continue;
|
|
1310
|
+
// Same item.id can appear across pagination batches (revisits whose
|
|
1311
|
+
// spatialCellKey differs, or static catalogs that re-walk overlapping
|
|
1312
|
+
// links). The keyed `{#each ... (view.id)}` in StacItemStrip throws
|
|
1313
|
+
// `each_key_duplicate` if we let both through, so dedup by id first.
|
|
1314
|
+
if (seenIds.has(normalized.id)) continue;
|
|
1315
|
+
if (dedupeByCell) {
|
|
1316
|
+
const key = spatialCellKey(item, normalized.bbox);
|
|
1317
|
+
const seen = cellCounts.get(key) ?? 0;
|
|
1318
|
+
if (seen >= LATEST_KEEP_PER_CELL) continue;
|
|
1319
|
+
cellCounts.set(key, seen + 1);
|
|
1320
|
+
}
|
|
1321
|
+
seenIds.add(normalized.id);
|
|
1322
|
+
accepted.push(normalized);
|
|
1323
|
+
acceptedViews.push(extractItemView(item));
|
|
1324
|
+
}
|
|
358
1325
|
|
|
1326
|
+
if (accepted.length === 0) {
|
|
1327
|
+
if (batch.done) break;
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
acceptedCount += accepted.length;
|
|
1331
|
+
for (const src of accepted) presignHref(src.href);
|
|
1332
|
+
|
|
1333
|
+
// Smoke-test a representative COG once per load. lazycogs does this
|
|
1334
|
+
// in `_smoketest_store` so credential / CORS issues surface in <1s
|
|
1335
|
+
// rather than as opaque "Failed to fetch" messages mid-tile-render.
|
|
1336
|
+
// Fire-and-forget: probe runs in parallel with the next batch and
|
|
1337
|
+
// writes to `smokeWarning` only on failure. Aborts via the per-pan
|
|
1338
|
+
// `hydrationController` so a viewport reload tears down the probe.
|
|
1339
|
+
if (!smokeProbed && accepted.length > 0) {
|
|
1340
|
+
smokeProbed = true;
|
|
1341
|
+
const probeHref = accepted[0].href;
|
|
1342
|
+
void (async () => {
|
|
1343
|
+
try {
|
|
1344
|
+
const url = await presignHref(probeHref);
|
|
1345
|
+
const result = await smokeTestHref(url, signal);
|
|
1346
|
+
if (gen !== loadGen || signal.aborted) return;
|
|
1347
|
+
if (!result.ok) smokeWarning = result.reason;
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
1350
|
+
if (gen !== loadGen) return;
|
|
1351
|
+
smokeWarning = err instanceof Error ? err.message : String(err);
|
|
1352
|
+
}
|
|
1353
|
+
})();
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// Update the streaming buffer. The renderer is intentionally NOT
|
|
1357
|
+
// driven by `itemsRef` — only by `committedSources`. We commit
|
|
1358
|
+
// only at strategic boundaries below to control rebuild cadence.
|
|
1359
|
+
if (apiBacked && firstBatch) {
|
|
1360
|
+
itemsRef = accepted.slice().reverse();
|
|
1361
|
+
itemViewsRef = acceptedViews.slice().reverse();
|
|
1362
|
+
firstBatch = false;
|
|
1363
|
+
// Atomic swap: promote the new viewport's first batch to the
|
|
1364
|
+
// renderer immediately so the user sees a frame.
|
|
1365
|
+
commitSources();
|
|
1366
|
+
} else if (apiBacked) {
|
|
1367
|
+
// Trade-off: api streams arrive newest-first via `rel="next"`
|
|
1368
|
+
// and a single rebuild per page would churn the inner
|
|
1369
|
+
// TileLayer cache mid-pan, restarting every visible COG range
|
|
1370
|
+
// fetch. So intermediate pages stay in the buffer and only
|
|
1371
|
+
// the first batch + the final flush touch the renderer. The
|
|
1372
|
+
// downside is a long-running stream (e.g. 10s for 2000 items)
|
|
1373
|
+
// shows only the freshest page until the stream completes.
|
|
1374
|
+
// Acceptable because (a) the first page is what the user
|
|
1375
|
+
// actually sees at the current zoom, (b) older pages are
|
|
1376
|
+
// dimmer revisits that mostly overlap the first, and (c) the
|
|
1377
|
+
// final flush is a single declarative re-derive, not a
|
|
1378
|
+
// per-page deck.gl rebuild. Static / parquet do not have this
|
|
1379
|
+
// constraint and commit per batch below.
|
|
1380
|
+
itemsRef = [...accepted.slice().reverse(), ...itemsRef];
|
|
1381
|
+
itemViewsRef = [...acceptedViews.slice().reverse(), ...itemViewsRef];
|
|
1382
|
+
} else if (kind === 'parquet' && firstBatch) {
|
|
1383
|
+
// Parquet re-runs `ST_Intersects(geometry, ST_MakeEnvelope(...))`
|
|
1384
|
+
// on every moveend, so the previous viewport's sources are stale.
|
|
1385
|
+
// Atomic-swap the new viewport's first (and, for our single-yield
|
|
1386
|
+
// parquet source, only) batch so sources don't accumulate across
|
|
1387
|
+
// pans, matching the "atomic source swap on viewport reload" rule.
|
|
1388
|
+
itemsRef = accepted.slice();
|
|
1389
|
+
itemViewsRef = acceptedViews.slice();
|
|
1390
|
+
firstBatch = false;
|
|
1391
|
+
commitSources();
|
|
1392
|
+
} else {
|
|
1393
|
+
// Static catalog walk: append in catalog order. Static does not
|
|
1394
|
+
// re-run on pan (moveend listener is torn down), so itemsRef
|
|
1395
|
+
// always starts empty after resetViewer and per-batch commits
|
|
1396
|
+
// are cheap.
|
|
359
1397
|
itemsRef = [...itemsRef, ...accepted];
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
}
|
|
1398
|
+
itemViewsRef = [...itemViewsRef, ...acceptedViews];
|
|
1399
|
+
commitSources();
|
|
1400
|
+
}
|
|
377
1401
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
1402
|
+
runningBounds = extendBounds(runningBounds, accepted);
|
|
1403
|
+
if (!apiBacked && !hasFittedOnce && runningBounds) {
|
|
1404
|
+
bounds = runningBounds;
|
|
1405
|
+
fitCogBounds(map, {
|
|
1406
|
+
west: runningBounds[0],
|
|
1407
|
+
south: runningBounds[1],
|
|
1408
|
+
east: runningBounds[2],
|
|
1409
|
+
north: runningBounds[3]
|
|
1410
|
+
});
|
|
1411
|
+
hasFittedOnce = true;
|
|
381
1412
|
}
|
|
382
|
-
|
|
1413
|
+
|
|
1414
|
+
if (!bandConfig) bandConfig = defaultBandConfig(detectedBandCount, 1);
|
|
1415
|
+
loading = false;
|
|
1416
|
+
|
|
1417
|
+
if (batch.done) break;
|
|
1418
|
+
}
|
|
383
1419
|
|
|
384
1420
|
if (gen !== loadGen) return;
|
|
385
|
-
if (
|
|
386
|
-
|
|
1421
|
+
if (acceptedCount === 0 && !signal.aborted) {
|
|
1422
|
+
if (kind !== 'static') {
|
|
1423
|
+
itemsRef = [];
|
|
1424
|
+
itemViewsRef = [];
|
|
1425
|
+
commitSources();
|
|
1426
|
+
}
|
|
1427
|
+
if (kind === 'parquet' && fetchedItemCount === 0) {
|
|
1428
|
+
error = t('map.mosaicEmptyViewport');
|
|
1429
|
+
} else {
|
|
1430
|
+
error = fetchedItemCount === 0 ? t('map.mosaicEmpty') : t('map.mosaicNoAssets');
|
|
1431
|
+
}
|
|
1432
|
+
stage = 'done';
|
|
1433
|
+
lastRefreshAt = performance.now();
|
|
387
1434
|
loading = false;
|
|
388
1435
|
return;
|
|
389
1436
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
1437
|
+
if (!signal.aborted) {
|
|
1438
|
+
stage = 'render';
|
|
1439
|
+
// Final flush: promote everything the streaming loop accumulated.
|
|
1440
|
+
commitSources();
|
|
1441
|
+
stage = 'done';
|
|
1442
|
+
lastRefreshAt = performance.now();
|
|
1443
|
+
}
|
|
393
1444
|
} catch (err) {
|
|
394
1445
|
if (gen !== loadGen) return;
|
|
395
1446
|
if (signal.aborted) return;
|
|
396
1447
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
397
1448
|
error = err instanceof Error ? err.message : String(err);
|
|
1449
|
+
stage = 'error';
|
|
398
1450
|
loading = false;
|
|
399
1451
|
}
|
|
400
1452
|
}
|
|
401
1453
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
)
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
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);
|
|
1454
|
+
function flyToSelected(id: string): void {
|
|
1455
|
+
if (!mapRef) return;
|
|
1456
|
+
// Read the committed (rendered) set, not the streaming buffer. Items can
|
|
1457
|
+
// sit in `itemViewsRef` after being evicted from the renderer; clicking
|
|
1458
|
+
// on a footprint that is currently visible must always resolve.
|
|
1459
|
+
const view = committedViews.find((v) => v.id === id) ?? itemViewsRef.find((v) => v.id === id);
|
|
1460
|
+
if (!view?.bbox) return;
|
|
1461
|
+
const [w, s, e, n] = view.bbox;
|
|
1462
|
+
fitCogBounds(mapRef, { west: w, south: s, east: e, north: n });
|
|
1463
|
+
}
|
|
421
1464
|
|
|
422
|
-
|
|
423
|
-
|
|
1465
|
+
const selectedView = $derived(
|
|
1466
|
+
selectedId ? (filteredViews.find((v) => v.id === selectedId) ?? null) : null
|
|
1467
|
+
);
|
|
424
1468
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1469
|
+
function toggleFootprints(): void {
|
|
1470
|
+
showFootprints = !showFootprints;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function applyFilterChange(next: FacetState): void {
|
|
1474
|
+
const prev = filterState;
|
|
1475
|
+
filterState = next;
|
|
1476
|
+
// In `api`/`parquet` modes the source's freshness window is capped at
|
|
1477
|
+
// `itemLimit` per request, so a residual-only change (e.g. cloud-cover or
|
|
1478
|
+
// platform) would otherwise leave the user looking at whichever items
|
|
1479
|
+
// happened to land in the original page rather than the freshest matches
|
|
1480
|
+
// for the new filter. Trigger a viewport reload on ANY filter change in
|
|
1481
|
+
// viewport mode so the source can re-query with the new request. Slice 1
|
|
1482
|
+
// push-down stays narrow (bbox + datetime); the new query just gives
|
|
1483
|
+
// later slices the chance to widen it without revisiting this site.
|
|
1484
|
+
if (isViewportMode && !facetStateEqual(prev, next)) {
|
|
1485
|
+
void reloadViewport();
|
|
436
1486
|
}
|
|
1487
|
+
}
|
|
437
1488
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
1489
|
+
/**
|
|
1490
|
+
* Shallow structural equality for `FacetState`. Used to skip viewport reloads
|
|
1491
|
+
* when a filter callback fires with the same effective state (e.g. the panel
|
|
1492
|
+
* re-emits on remount).
|
|
1493
|
+
*/
|
|
1494
|
+
function facetStateEqual(a: FacetState, b: FacetState): boolean {
|
|
1495
|
+
if (a === b) return true;
|
|
1496
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
1497
|
+
for (const k of keys) {
|
|
1498
|
+
const av = (a as Record<string, unknown>)[k];
|
|
1499
|
+
const bv = (b as Record<string, unknown>)[k];
|
|
1500
|
+
if (av === bv) continue;
|
|
1501
|
+
if (
|
|
1502
|
+
av &&
|
|
1503
|
+
bv &&
|
|
1504
|
+
typeof av === 'object' &&
|
|
1505
|
+
typeof bv === 'object' &&
|
|
1506
|
+
JSON.stringify(av) === JSON.stringify(bv)
|
|
1507
|
+
)
|
|
1508
|
+
continue;
|
|
1509
|
+
return false;
|
|
1510
|
+
}
|
|
1511
|
+
return true;
|
|
441
1512
|
}
|
|
442
1513
|
|
|
443
|
-
function
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
1514
|
+
function resetFilters(): void {
|
|
1515
|
+
if (!hasActiveFilters(filterState)) return;
|
|
1516
|
+
applyFilterChange(emptyFacetState());
|
|
1517
|
+
}
|
|
447
1518
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
});
|
|
1519
|
+
function handleConfigChange(next: BandConfig): void {
|
|
1520
|
+
bandConfig = next;
|
|
1521
|
+
histogram = null;
|
|
1522
|
+
multiHistogramKey = null;
|
|
1523
|
+
sourceHistograms.clear();
|
|
1524
|
+
bumpPipeline();
|
|
1525
|
+
}
|
|
523
1526
|
|
|
524
|
-
|
|
525
|
-
|
|
1527
|
+
function syncCompositeToUrl(c: ChannelComposite | null, presetId: string | null): void {
|
|
1528
|
+
if (!c) {
|
|
1529
|
+
updateUrlViewParams('map', null);
|
|
526
1530
|
return;
|
|
527
1531
|
}
|
|
1532
|
+
updateUrlViewParams('map', compositeToUrl(c, presetId));
|
|
1533
|
+
}
|
|
528
1534
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
1535
|
+
function setComposite(next: ChannelComposite): void {
|
|
1536
|
+
composite = next;
|
|
1537
|
+
const matching = PRESETS.find((p) => presetMatchesComposite(p, next, cogAssets));
|
|
1538
|
+
activePresetId = matching?.id ?? '';
|
|
1539
|
+
syncCompositeToUrl(next, activePresetId || null);
|
|
1540
|
+
|
|
1541
|
+
// Single-asset path: feed the existing setMosaicAssetKey machinery, then
|
|
1542
|
+
// mirror per-channel bandIndex into `bandConfig` so `selectCogPipeline`'s
|
|
1543
|
+
// RGB branch reads the user's picks. Without this mirror, the picker's
|
|
1544
|
+
// per-band dropdown (e.g. Hamilton 4-band NAIP COG: pick band 4 as Red)
|
|
1545
|
+
// updates `composite` + URL state but the rendered tiles keep using the
|
|
1546
|
+
// default 0/1/2 band order seeded by `defaultBandConfig()`.
|
|
1547
|
+
if (isSingleAssetComposite(next)) {
|
|
1548
|
+
setMosaicAssetKey(next.r.assetKey);
|
|
1549
|
+
if (bandConfig && bandConfig.mode === 'rgb') {
|
|
1550
|
+
if (
|
|
1551
|
+
bandConfig.rBand !== next.r.bandIndex ||
|
|
1552
|
+
bandConfig.gBand !== next.g.bandIndex ||
|
|
1553
|
+
bandConfig.bBand !== next.b.bandIndex
|
|
1554
|
+
) {
|
|
1555
|
+
bandConfig = {
|
|
1556
|
+
...bandConfig,
|
|
1557
|
+
rBand: next.r.bandIndex,
|
|
1558
|
+
gBand: next.g.bandIndex,
|
|
1559
|
+
bBand: next.b.bandIndex
|
|
1560
|
+
};
|
|
1561
|
+
bumpPipeline();
|
|
537
1562
|
}
|
|
538
1563
|
}
|
|
539
|
-
}
|
|
540
|
-
overlayRef = overlay;
|
|
541
|
-
map.addControl(overlay as unknown as maplibregl.IControl);
|
|
542
|
-
loading = false;
|
|
1564
|
+
}
|
|
543
1565
|
}
|
|
544
1566
|
|
|
545
|
-
function
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
1567
|
+
function setPreset(id: string): void {
|
|
1568
|
+
const preset = PRESETS.find((p) => p.id === id);
|
|
1569
|
+
if (!preset) return;
|
|
1570
|
+
const next = applyPreset(cogAssets, preset);
|
|
1571
|
+
if (!next) return;
|
|
1572
|
+
activePresetId = id;
|
|
1573
|
+
// New preset = new R-channel = new data distribution. Reset the
|
|
1574
|
+
// "user touched the slider" flag so the next bake's p2/p98 reseeds
|
|
1575
|
+
// rescale, otherwise switching truecolor → vegetation keeps the
|
|
1576
|
+
// previous truecolor's auto-contrast on a band where it doesn't fit.
|
|
1577
|
+
userTouchedRescale = false;
|
|
1578
|
+
multiHistogramKey = null;
|
|
1579
|
+
setComposite(next);
|
|
555
1580
|
}
|
|
556
1581
|
|
|
557
1582
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
1583
|
+
* Swap which STAC asset feeds the mosaic. Re-derives `committedSources` and
|
|
1584
|
+
* `itemsRef` from the cached `StacItemView.raw` so the deck.gl layer rebuilds
|
|
1585
|
+
* with the new hrefs but the streaming buffer / pagination state stay intact.
|
|
1586
|
+
* Resets the band config + per-source histograms because the new asset may
|
|
1587
|
+
* have a different band count / sample format (e.g. `visual` 3-band uint8 →
|
|
1588
|
+
* `nir` 1-band uint16).
|
|
562
1589
|
*/
|
|
1590
|
+
function setMosaicAssetKey(nextKey: string): void {
|
|
1591
|
+
if (nextKey === mosaicAssetKey) return;
|
|
1592
|
+
mosaicAssetKey = nextKey;
|
|
1593
|
+
bandConfig = null;
|
|
1594
|
+
probedBandCount = false;
|
|
1595
|
+
histogram = null;
|
|
1596
|
+
multiHistogramKey = null;
|
|
1597
|
+
userTouchedRescale = false;
|
|
1598
|
+
sourceHistograms.clear();
|
|
1599
|
+
geotiffCache = new LruCache<string, Promise<GeoTIFF>>({ max: SOURCE_CACHE_MAX });
|
|
1600
|
+
presignCache = new LruCache<string, Promise<string>>({ max: SOURCE_CACHE_MAX });
|
|
1601
|
+
resolvedHrefByOriginal = new LruCache<string, string>({ max: SOURCE_CACHE_MAX });
|
|
1602
|
+
sourceHrefById = new Map();
|
|
1603
|
+
if (multiCogRebuildHandle !== null) {
|
|
1604
|
+
cancelAnimationFrame(multiCogRebuildHandle);
|
|
1605
|
+
multiCogRebuildHandle = null;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const remap = (
|
|
1609
|
+
views: ReadonlyArray<StacItemView>
|
|
1610
|
+
): {
|
|
1611
|
+
sources: MosaicSourceMeta[];
|
|
1612
|
+
viewsOut: StacItemView[];
|
|
1613
|
+
} => {
|
|
1614
|
+
const sources: MosaicSourceMeta[] = [];
|
|
1615
|
+
const viewsOut: StacItemView[] = [];
|
|
1616
|
+
for (const v of views) {
|
|
1617
|
+
const meta = buildMosaicSourceMeta(v.raw, nextKey);
|
|
1618
|
+
if (!meta) continue;
|
|
1619
|
+
sources.push(meta);
|
|
1620
|
+
viewsOut.push(v);
|
|
1621
|
+
}
|
|
1622
|
+
return { sources, viewsOut };
|
|
1623
|
+
};
|
|
1624
|
+
const fromBuffer = remap(itemViewsRef);
|
|
1625
|
+
itemsRef = fromBuffer.sources;
|
|
1626
|
+
itemViewsRef = fromBuffer.viewsOut;
|
|
1627
|
+
const fromCommitted = remap(committedViews);
|
|
1628
|
+
committedSources = fromCommitted.sources;
|
|
1629
|
+
committedViews = fromCommitted.viewsOut;
|
|
1630
|
+
bumpPipeline();
|
|
1631
|
+
}
|
|
1632
|
+
|
|
563
1633
|
function recordSourceHistogram(
|
|
564
1634
|
sourceId: string,
|
|
565
1635
|
visibleTiles: ReadonlyArray<{ content?: unknown } | null | undefined>
|
|
@@ -571,10 +1641,6 @@ function recordSourceHistogram(
|
|
|
571
1641
|
const summed = new Uint32Array(HISTOGRAM_BIN_COUNT);
|
|
572
1642
|
let found = false;
|
|
573
1643
|
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
1644
|
const content = tile?.content as
|
|
579
1645
|
| { data?: CustomTileData; histogram?: Uint32Array }
|
|
580
1646
|
| null
|
|
@@ -591,6 +1657,8 @@ function recordSourceHistogram(
|
|
|
591
1657
|
|
|
592
1658
|
function aggregateSources(): void {
|
|
593
1659
|
if (sourceHistograms.size === 0) {
|
|
1660
|
+
// Don't clobber a histogram baked by the multi-asset path.
|
|
1661
|
+
if (composite && !isSingleAssetComposite(composite) && histogram) return;
|
|
594
1662
|
histogram = null;
|
|
595
1663
|
return;
|
|
596
1664
|
}
|
|
@@ -601,18 +1669,81 @@ function aggregateSources(): void {
|
|
|
601
1669
|
histogram = summed;
|
|
602
1670
|
}
|
|
603
1671
|
|
|
1672
|
+
// Multi-asset bake: pick the first committed item's R-channel COG, build a
|
|
1673
|
+
// 64-bin histogram from its smallest overview. Keyed on `rAsset:viewId` so a
|
|
1674
|
+
// preset swap (R asset changes) or a fresh viewport (first view rotates) fires
|
|
1675
|
+
// a new bake. Also reseeds rescale to p2/p98 when the user has not touched the
|
|
1676
|
+
// slider, so vegetation / SWIR composites land with auto-contrast instead of
|
|
1677
|
+
// the previous truecolor's `{0, 0.05}` lingering on uint16 reflectance.
|
|
1678
|
+
$effect(() => {
|
|
1679
|
+
const c = composite;
|
|
1680
|
+
const views = committedViews as StacItemView[];
|
|
1681
|
+
if (!c || isSingleAssetComposite(c) || views.length === 0) {
|
|
1682
|
+
multiHistogramKey = null;
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const first = views[0];
|
|
1686
|
+
const itemAssets = extractCogAssets(first.raw);
|
|
1687
|
+
const rAsset = itemAssets.find((a) => a.key === c.r.assetKey);
|
|
1688
|
+
if (!rAsset) return;
|
|
1689
|
+
const key = `${c.r.assetKey}:${first.id}`;
|
|
1690
|
+
if (multiHistogramKey === key) return;
|
|
1691
|
+
multiHistogramKey = key;
|
|
1692
|
+
const signal = abortController.signal;
|
|
1693
|
+
void (async () => {
|
|
1694
|
+
try {
|
|
1695
|
+
const url = await presignHref(rAsset.href);
|
|
1696
|
+
if (signal.aborted || multiHistogramKey !== key) return;
|
|
1697
|
+
let promise = geotiffCache.get(rAsset.href);
|
|
1698
|
+
if (!promise) {
|
|
1699
|
+
promise = (async () => {
|
|
1700
|
+
const g = await loadGeoTIFF(url);
|
|
1701
|
+
normalizeCogGeotiff(g);
|
|
1702
|
+
return g;
|
|
1703
|
+
})();
|
|
1704
|
+
geotiffCache.set(rAsset.href, promise);
|
|
1705
|
+
}
|
|
1706
|
+
const geotiff = await promise;
|
|
1707
|
+
if (signal.aborted || multiHistogramKey !== key) return;
|
|
1708
|
+
const bins = await buildHistogramFromGeotiff(geotiff, signal);
|
|
1709
|
+
if (signal.aborted || multiHistogramKey !== key) return;
|
|
1710
|
+
if (bins) {
|
|
1711
|
+
histogram = bins;
|
|
1712
|
+
if (!userTouchedRescale) {
|
|
1713
|
+
const lo = percentileFromHistogram(bins, 0.02);
|
|
1714
|
+
const hi = percentileFromHistogram(bins, 0.98);
|
|
1715
|
+
if (lo !== null && hi !== null && hi > lo) {
|
|
1716
|
+
rescale = { min: lo, max: hi };
|
|
1717
|
+
bumpPipeline();
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
console.warn('[StacMosaicViewer] multi-asset histogram bake failed', { key, err });
|
|
1723
|
+
}
|
|
1724
|
+
})();
|
|
1725
|
+
});
|
|
1726
|
+
|
|
604
1727
|
function handleRescaleChange(next: RescaleConfig): void {
|
|
605
1728
|
rescale = next;
|
|
606
|
-
|
|
607
|
-
|
|
1729
|
+
userTouchedRescale = true;
|
|
1730
|
+
bumpPipeline();
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function handleStripHover(id: string | null): void {
|
|
1734
|
+
if (id !== hoveredId) hoveredId = id;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function handleStripSelect(id: string | null): void {
|
|
1738
|
+
const next = selectedId === id ? null : id;
|
|
1739
|
+
selectedId = next;
|
|
1740
|
+
if (next) flyToSelected(next);
|
|
608
1741
|
}
|
|
609
1742
|
|
|
610
1743
|
function cleanup(): void {
|
|
611
1744
|
abortController.abort();
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
rebuildTimer = null;
|
|
615
|
-
}
|
|
1745
|
+
hydrationController.abort();
|
|
1746
|
+
teardownViewportReload();
|
|
616
1747
|
if (mapRef) removeClickHandler();
|
|
617
1748
|
if (mapRef && overlayRef) {
|
|
618
1749
|
try {
|
|
@@ -625,8 +1756,19 @@ function cleanup(): void {
|
|
|
625
1756
|
mapRef = null;
|
|
626
1757
|
overlayRef = null;
|
|
627
1758
|
itemsRef = [];
|
|
1759
|
+
itemViewsRef = [];
|
|
1760
|
+
committedSources = [];
|
|
1761
|
+
committedViews = [];
|
|
628
1762
|
presignCache.clear();
|
|
629
1763
|
geotiffCache.clear();
|
|
1764
|
+
resolvedHrefByOriginal.clear();
|
|
1765
|
+
sourceHrefById.clear();
|
|
1766
|
+
sourceHistograms.clear();
|
|
1767
|
+
sourceErrorLogged = false;
|
|
1768
|
+
if (multiCogRebuildHandle !== null) {
|
|
1769
|
+
cancelAnimationFrame(multiCogRebuildHandle);
|
|
1770
|
+
multiCogRebuildHandle = null;
|
|
1771
|
+
}
|
|
630
1772
|
const maybeDestroy = pool as unknown as { destroy?: () => void; terminate?: () => void } | null;
|
|
631
1773
|
if (maybeDestroy?.destroy) {
|
|
632
1774
|
try {
|
|
@@ -657,30 +1799,186 @@ onDestroy(cleanup);
|
|
|
657
1799
|
<MapContainer {onMapReady} {bounds} />
|
|
658
1800
|
</div>
|
|
659
1801
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
1802
|
+
<!-- Stage / progress HUD: tells the user *what is happening* so an empty
|
|
1803
|
+
map is never indistinguishable from a still-loading map. On mobile this
|
|
1804
|
+
spans the full width below any top-left/top-right buttons; on sm: it
|
|
1805
|
+
centers as a single pill. -->
|
|
1806
|
+
<div
|
|
1807
|
+
class="pointer-events-auto absolute inset-x-2 top-14 z-10 flex max-w-[calc(100%-1rem)] flex-col gap-1 rounded-md bg-card/90 px-2 py-1.5 text-xs text-card-foreground shadow backdrop-blur-sm sm:inset-x-auto sm:left-1/2 sm:top-2 sm:max-w-[min(560px,calc(100%-1rem))] sm:-translate-x-1/2"
|
|
1808
|
+
style="touch-action: manipulation;"
|
|
1809
|
+
>
|
|
1810
|
+
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
1811
|
+
{#if stage === 'classify'}
|
|
1812
|
+
<span class="size-1.5 animate-pulse rounded-full bg-amber-500"></span>
|
|
1813
|
+
<span class="font-medium">{t('stac.stageClassify')}</span>
|
|
1814
|
+
{:else if stage === 'fetch'}
|
|
1815
|
+
<span class="size-1.5 animate-pulse rounded-full bg-blue-500"></span>
|
|
1816
|
+
<span class="font-medium">{t('stac.stageFetch')}</span>
|
|
1817
|
+
<span class="tabular-nums text-muted-foreground">
|
|
1818
|
+
{stageFetched}{stageHinted != null ? ` / ${stageHinted}` : ''}
|
|
1819
|
+
</span>
|
|
1820
|
+
{:else if stage === 'index'}
|
|
1821
|
+
<span class="size-1.5 animate-pulse rounded-full bg-violet-500"></span>
|
|
1822
|
+
<span class="font-medium">{t('stac.stageIndex')}</span>
|
|
1823
|
+
{:else if stage === 'render'}
|
|
1824
|
+
<span class="size-1.5 animate-pulse rounded-full bg-cyan-500"></span>
|
|
1825
|
+
<span class="font-medium">{t('stac.stageRender')}</span>
|
|
1826
|
+
{:else if stage === 'done'}
|
|
1827
|
+
<span class="size-1.5 rounded-full bg-emerald-500"></span>
|
|
1828
|
+
<span class="font-medium">
|
|
1829
|
+
{sourceCount === 0
|
|
1830
|
+
? t('stac.stageEmpty')
|
|
1831
|
+
: sourceCount === 1
|
|
1832
|
+
? t('stac.mosaicSourcesOne', { count: sourceCount })
|
|
1833
|
+
: t('stac.mosaicSourcesOther', { count: sourceCount })}
|
|
1834
|
+
</span>
|
|
1835
|
+
{:else if stage === 'error'}
|
|
1836
|
+
<span class="size-1.5 rounded-full bg-red-500"></span>
|
|
1837
|
+
<span class="font-medium">{t('stac.stageError')}</span>
|
|
1838
|
+
{:else}
|
|
1839
|
+
<span class="size-1.5 rounded-full bg-zinc-400"></span>
|
|
1840
|
+
<span class="font-medium">{t('stac.stageIdle')}</span>
|
|
1841
|
+
{/if}
|
|
1842
|
+
<div class="ms-auto flex flex-wrap items-center gap-1">
|
|
1843
|
+
{#if isViewportMode}
|
|
1844
|
+
<button
|
|
1845
|
+
class="inline-flex min-h-9 items-center rounded border border-input px-2 py-1 text-xs hover:bg-accent sm:min-h-0 sm:px-1.5 sm:py-0.5 sm:text-[10px]"
|
|
1846
|
+
onclick={() => void reloadViewport()}
|
|
1847
|
+
title={t('stac.viewportMode')}
|
|
1848
|
+
>
|
|
1849
|
+
{t('stac.refresh')}
|
|
1850
|
+
</button>
|
|
1851
|
+
{/if}
|
|
1852
|
+
{#if sourceCount > 0}
|
|
1853
|
+
<button
|
|
1854
|
+
class="inline-flex min-h-9 items-center rounded border border-input px-2 py-1 text-xs hover:bg-accent sm:min-h-0 sm:px-1.5 sm:py-0.5 sm:text-[10px]"
|
|
1855
|
+
class:ring-1={showFootprints}
|
|
1856
|
+
class:ring-primary={showFootprints}
|
|
1857
|
+
onclick={toggleFootprints}
|
|
1858
|
+
title={t('stac.footprintsHint')}
|
|
1859
|
+
>
|
|
1860
|
+
{t('stac.footprints')}
|
|
1861
|
+
</button>
|
|
1862
|
+
<button
|
|
1863
|
+
class="inline-flex min-h-9 items-center rounded border border-input px-2 py-1 text-xs hover:bg-accent sm:min-h-0 sm:px-1.5 sm:py-0.5 sm:text-[10px]"
|
|
1864
|
+
class:ring-1={showStrip}
|
|
1865
|
+
class:ring-primary={showStrip}
|
|
1866
|
+
onclick={() => (showStrip = !showStrip)}
|
|
1867
|
+
>
|
|
1868
|
+
{t('stac.strip')}
|
|
1869
|
+
</button>
|
|
1870
|
+
{/if}
|
|
1871
|
+
<button
|
|
1872
|
+
class="relative inline-flex min-h-9 items-center rounded border border-input px-2 py-1 text-xs hover:bg-accent sm:min-h-0 sm:px-1.5 sm:py-0.5 sm:text-[10px]"
|
|
1873
|
+
class:ring-1={showFilters}
|
|
1874
|
+
class:ring-primary={showFilters}
|
|
1875
|
+
onclick={() => (showFilters = !showFilters)}
|
|
1876
|
+
>
|
|
1877
|
+
{t('stac.filters')}
|
|
1878
|
+
{#if filtersActive}
|
|
1879
|
+
<span
|
|
1880
|
+
class="absolute -end-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
|
|
1881
|
+
title={t('stac.filtersActive')}
|
|
1882
|
+
></span>
|
|
1883
|
+
{/if}
|
|
1884
|
+
</button>
|
|
1885
|
+
</div>
|
|
1886
|
+
</div>
|
|
1887
|
+
{#if stage === 'fetch' && stageHinted}
|
|
1888
|
+
<div class="h-1 w-full overflow-hidden rounded bg-zinc-200 dark:bg-zinc-700">
|
|
1889
|
+
<div
|
|
1890
|
+
class="h-full bg-blue-500 transition-all"
|
|
1891
|
+
style="width: {Math.min(100, (stageFetched / stageHinted) * 100)}%"
|
|
1892
|
+
></div>
|
|
1893
|
+
</div>
|
|
1894
|
+
{:else if stage === 'classify' || stage === 'index' || stage === 'render'}
|
|
1895
|
+
<div class="h-1 w-full overflow-hidden rounded bg-zinc-200 dark:bg-zinc-700">
|
|
1896
|
+
<div class="h-full w-1/3 animate-pulse bg-zinc-400"></div>
|
|
1897
|
+
</div>
|
|
1898
|
+
{/if}
|
|
1899
|
+
{#if stageMessage}
|
|
1900
|
+
<div class="text-[10px] text-muted-foreground">{stageMessage}</div>
|
|
1901
|
+
{/if}
|
|
1902
|
+
{#if stage === 'done' && sourceCount === itemLimit}
|
|
1903
|
+
<div class="text-[10px] text-amber-600 dark:text-amber-400">
|
|
1904
|
+
{t('stac.capReached', { limit: itemLimit })}
|
|
664
1905
|
</div>
|
|
665
1906
|
{/if}
|
|
666
|
-
{#if
|
|
667
|
-
<div class="
|
|
668
|
-
{
|
|
669
|
-
|
|
670
|
-
|
|
1907
|
+
{#if lastRefreshAt && stage === 'done'}
|
|
1908
|
+
<div class="text-[10px] text-muted-foreground">
|
|
1909
|
+
{t('stac.lastRefresh', {
|
|
1910
|
+
seconds: Math.max(0, Math.floor((performance.now() - lastRefreshAt) / 1000))
|
|
1911
|
+
})}
|
|
671
1912
|
</div>
|
|
672
1913
|
{/if}
|
|
1914
|
+
</div>
|
|
1915
|
+
|
|
1916
|
+
{#if showFilters}
|
|
1917
|
+
<StacFilterPanel
|
|
1918
|
+
{facets}
|
|
1919
|
+
state={filterState}
|
|
1920
|
+
onChange={applyFilterChange}
|
|
1921
|
+
onClose={() => (showFilters = false)}
|
|
1922
|
+
onReset={resetFilters}
|
|
1923
|
+
footer={fetchOptionsSnippet}
|
|
1924
|
+
/>
|
|
1925
|
+
{/if}
|
|
1926
|
+
|
|
1927
|
+
{#snippet fetchOptionsSnippet()}
|
|
1928
|
+
<label class="mb-1 flex items-center justify-between gap-2">
|
|
1929
|
+
<span class="text-muted-foreground">{t('stac.itemLimit')}</span>
|
|
1930
|
+
<input
|
|
1931
|
+
type="number"
|
|
1932
|
+
min="1"
|
|
1933
|
+
step="100"
|
|
1934
|
+
value={itemLimit}
|
|
1935
|
+
onchange={(e) => {
|
|
1936
|
+
const next = Number((e.target as HTMLInputElement).value);
|
|
1937
|
+
if (!Number.isFinite(next) || next < 1) return;
|
|
1938
|
+
itemLimit = Math.floor(next);
|
|
1939
|
+
settings.setMosaicItemLimit(itemLimit);
|
|
1940
|
+
if (isViewportMode) void reloadViewport();
|
|
1941
|
+
}}
|
|
1942
|
+
class="w-24 rounded border border-input bg-background px-1.5 py-0.5 text-xs tabular-nums"
|
|
1943
|
+
/>
|
|
1944
|
+
</label>
|
|
1945
|
+
<div class="text-[10px] text-muted-foreground">{t('stac.itemLimitHint')}</div>
|
|
1946
|
+
<div class="mt-2 text-[10px] text-muted-foreground">
|
|
1947
|
+
{kind === 'api'
|
|
1948
|
+
? t('stac.modeViewportApi')
|
|
1949
|
+
: kind === 'parquet'
|
|
1950
|
+
? t('stac.modeViewportParquet')
|
|
1951
|
+
: t('stac.modeStatic')}
|
|
1952
|
+
</div>
|
|
1953
|
+
{/snippet}
|
|
1954
|
+
|
|
1955
|
+
<div class="pointer-events-none absolute left-2 top-2 z-10 flex max-w-[calc(100vw-7rem)] flex-col gap-1 sm:max-w-none">
|
|
673
1956
|
{#if error}
|
|
674
1957
|
<div class="pointer-events-auto max-w-sm rounded bg-red-900/80 px-2 py-1 text-xs text-red-200">
|
|
675
1958
|
{error}
|
|
676
1959
|
</div>
|
|
677
1960
|
{/if}
|
|
1961
|
+
{#if smokeWarning && !error}
|
|
1962
|
+
<div
|
|
1963
|
+
class="pointer-events-auto max-w-sm rounded bg-amber-900/80 px-2 py-1 text-xs text-amber-100"
|
|
1964
|
+
title={t('stac.smokeWarningHint')}
|
|
1965
|
+
>
|
|
1966
|
+
{t('stac.smokeWarning', { reason: smokeWarning })}
|
|
1967
|
+
</div>
|
|
1968
|
+
{/if}
|
|
1969
|
+
{#if composite && !isSingleAssetComposite(composite) && multiCogLayers.length * 3 > 300}
|
|
1970
|
+
<div
|
|
1971
|
+
class="pointer-events-auto max-w-sm rounded bg-yellow-900/80 px-2 py-1 text-xs text-yellow-200"
|
|
1972
|
+
>
|
|
1973
|
+
{t('map.multiCogMosaicHeavy')}
|
|
1974
|
+
</div>
|
|
1975
|
+
{/if}
|
|
678
1976
|
</div>
|
|
679
1977
|
|
|
680
1978
|
{#if sourceCount > 0 && bandConfig}
|
|
681
|
-
<div class="absolute right-2 top-2 z-10 flex gap-1">
|
|
1979
|
+
<div class="absolute right-2 top-2 z-10 flex gap-1" style="touch-action: manipulation;">
|
|
682
1980
|
<button
|
|
683
|
-
class="rounded bg-card/80 px-
|
|
1981
|
+
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded bg-card/80 px-3 py-1.5 text-xs text-card-foreground backdrop-blur-sm hover:bg-card sm:min-h-0 sm:min-w-0 sm:px-2 sm:py-1"
|
|
684
1982
|
class:ring-1={showControls}
|
|
685
1983
|
class:ring-primary={showControls}
|
|
686
1984
|
onclick={() => {
|
|
@@ -691,7 +1989,7 @@ onDestroy(cleanup);
|
|
|
691
1989
|
{t('cog.style')}
|
|
692
1990
|
</button>
|
|
693
1991
|
<button
|
|
694
|
-
class="rounded bg-card/80 px-
|
|
1992
|
+
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded bg-card/80 px-3 py-1.5 text-xs text-card-foreground backdrop-blur-sm hover:bg-card sm:min-h-0 sm:min-w-0 sm:px-2 sm:py-1"
|
|
695
1993
|
class:ring-1={showInfo}
|
|
696
1994
|
class:ring-primary={showInfo}
|
|
697
1995
|
onclick={() => {
|
|
@@ -703,21 +2001,37 @@ onDestroy(cleanup);
|
|
|
703
2001
|
</button>
|
|
704
2002
|
</div>
|
|
705
2003
|
|
|
706
|
-
{#if showControls}
|
|
2004
|
+
{#if showControls && composite}
|
|
707
2005
|
<CogControls
|
|
708
|
-
|
|
2006
|
+
assets={cogAssets}
|
|
2007
|
+
{composite}
|
|
2008
|
+
onCompositeChange={setComposite}
|
|
2009
|
+
presets={presetsForMosaic}
|
|
2010
|
+
{activePresetId}
|
|
2011
|
+
onPresetChange={setPreset}
|
|
2012
|
+
mode={bandConfig?.mode ?? 'rgb'}
|
|
2013
|
+
onModeChange={(m) => {
|
|
2014
|
+
if (bandConfig) handleConfigChange({ ...bandConfig, mode: m });
|
|
2015
|
+
}}
|
|
709
2016
|
{bandConfig}
|
|
710
|
-
|
|
2017
|
+
bandCount={detectedBandCount}
|
|
2018
|
+
onBandConfigChange={handleConfigChange}
|
|
711
2019
|
{rescale}
|
|
712
|
-
rescaleApplicable={bandConfig
|
|
2020
|
+
rescaleApplicable={!!bandConfig}
|
|
713
2021
|
onRescaleChange={handleRescaleChange}
|
|
714
2022
|
{histogram}
|
|
2023
|
+
nodata={nodataConfig}
|
|
2024
|
+
{autoNodata}
|
|
2025
|
+
onNodataChange={(next) => {
|
|
2026
|
+
nodataConfig = next;
|
|
2027
|
+
bumpPipeline();
|
|
2028
|
+
}}
|
|
715
2029
|
/>
|
|
716
2030
|
{/if}
|
|
717
2031
|
|
|
718
2032
|
{#if showInfo}
|
|
719
2033
|
<div
|
|
720
|
-
class="absolute
|
|
2034
|
+
class="absolute inset-x-2 top-16 z-10 max-h-[60vh] overflow-auto rounded bg-card/90 p-3 text-xs text-card-foreground backdrop-blur-sm sm:inset-x-auto sm:right-2 sm:top-10 sm:max-h-[70vh] sm:w-64"
|
|
721
2035
|
>
|
|
722
2036
|
<h3 class="mb-2 font-medium">{t('stac.mosaicInfo')}</h3>
|
|
723
2037
|
<dl class="space-y-1.5">
|
|
@@ -735,51 +2049,89 @@ onDestroy(cleanup);
|
|
|
735
2049
|
</dd>
|
|
736
2050
|
{/if}
|
|
737
2051
|
</dl>
|
|
2052
|
+
|
|
2053
|
+
<h3 class="mb-2 mt-3 font-medium">{t('stac.explainHeading')}</h3>
|
|
2054
|
+
<dl class="space-y-1.5">
|
|
2055
|
+
<dt class="sr-only">items</dt>
|
|
2056
|
+
<dd class="text-muted-foreground">
|
|
2057
|
+
{t('stac.explainItems', {
|
|
2058
|
+
visible: filteredItems.length,
|
|
2059
|
+
total: committedSources.length
|
|
2060
|
+
})}
|
|
2061
|
+
</dd>
|
|
2062
|
+
<dt class="sr-only">assets</dt>
|
|
2063
|
+
<dd class="text-muted-foreground">
|
|
2064
|
+
{t('stac.explainAssets', { count: distinctAssetKeys })}
|
|
2065
|
+
</dd>
|
|
2066
|
+
<dt class="sr-only">overlap</dt>
|
|
2067
|
+
<dd class="text-muted-foreground">
|
|
2068
|
+
{t('stac.explainOverlap', { count: centerOverlapCount })}
|
|
2069
|
+
</dd>
|
|
2070
|
+
<dt class="sr-only">bytes</dt>
|
|
2071
|
+
<dd class="text-muted-foreground">
|
|
2072
|
+
{t('stac.explainBytes', {
|
|
2073
|
+
bytes: estimatedTileBytes != null ? formatFileSize(estimatedTileBytes) : '—'
|
|
2074
|
+
})}
|
|
2075
|
+
</dd>
|
|
2076
|
+
{#if timeSpan}
|
|
2077
|
+
<dt class="sr-only">time</dt>
|
|
2078
|
+
<dd class="text-muted-foreground">
|
|
2079
|
+
{t('stac.explainTimeSpan', { start: timeSpan.start, end: timeSpan.end })}
|
|
2080
|
+
</dd>
|
|
2081
|
+
{/if}
|
|
2082
|
+
</dl>
|
|
738
2083
|
</div>
|
|
739
2084
|
{/if}
|
|
740
2085
|
{/if}
|
|
741
2086
|
|
|
742
|
-
|
|
2087
|
+
<PixelInspectorPanel
|
|
2088
|
+
lng={pixelValue?.lng ?? null}
|
|
2089
|
+
lat={pixelValue?.lat ?? null}
|
|
2090
|
+
rows={pixelValue
|
|
2091
|
+
? (pixelValue.values.map((v, i) => ({
|
|
2092
|
+
label: `${t('cog.band')} ${i + 1}`,
|
|
2093
|
+
value: v
|
|
2094
|
+
})) satisfies PixelInspectorRow[])
|
|
2095
|
+
: null}
|
|
2096
|
+
footnote={pixelValue ? `px (${pixelValue.col}, ${pixelValue.row})` : undefined}
|
|
2097
|
+
extraLine={pixelSourceId ?? undefined}
|
|
2098
|
+
onClose={() => {
|
|
2099
|
+
pixelValue = null;
|
|
2100
|
+
pixelSourceId = null;
|
|
2101
|
+
}}
|
|
2102
|
+
{inspecting}
|
|
2103
|
+
/>
|
|
2104
|
+
|
|
2105
|
+
{#if showStrip && sourceCount > 0}
|
|
743
2106
|
<div
|
|
744
|
-
class="absolute
|
|
2107
|
+
class="pointer-events-none absolute inset-x-2 bottom-12 z-10 flex flex-col gap-2"
|
|
745
2108
|
>
|
|
746
|
-
<
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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>
|
|
2109
|
+
<StacDatetimeBar
|
|
2110
|
+
facet={facets.datetime}
|
|
2111
|
+
state={filterState}
|
|
2112
|
+
onChange={applyFilterChange}
|
|
2113
|
+
/>
|
|
2114
|
+
<StacItemStrip
|
|
2115
|
+
views={filteredViews}
|
|
2116
|
+
{hoveredId}
|
|
2117
|
+
{selectedId}
|
|
2118
|
+
presign={presignHref}
|
|
2119
|
+
onHover={handleStripHover}
|
|
2120
|
+
onSelect={handleStripSelect}
|
|
2121
|
+
/>
|
|
775
2122
|
</div>
|
|
776
2123
|
{/if}
|
|
777
2124
|
|
|
778
|
-
{#if
|
|
779
|
-
<
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
{
|
|
783
|
-
|
|
2125
|
+
{#if selectedView}
|
|
2126
|
+
<StacItemInspector
|
|
2127
|
+
view={selectedView}
|
|
2128
|
+
presign={presignHref}
|
|
2129
|
+
onClose={() => {
|
|
2130
|
+
selectedId = null;
|
|
2131
|
+
}}
|
|
2132
|
+
onFlyTo={() => {
|
|
2133
|
+
if (selectedId) flyToSelected(selectedId);
|
|
2134
|
+
}}
|
|
2135
|
+
/>
|
|
784
2136
|
{/if}
|
|
785
2137
|
</div>
|