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