@walkthru-earth/objex 1.3.1 → 1.4.0

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