@walkthru-earth/objex 1.3.0 → 1.4.0

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