@walkthru-earth/objex 1.3.1 → 1.5.0

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