@walkthru-earth/objex 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +20 -12
  3. package/dist/components/browser/FileTreeSidebar.svelte +32 -17
  4. package/dist/components/layout/AboutSheet.svelte +5 -2
  5. package/dist/components/layout/ConnectionDialog.svelte +1 -1
  6. package/dist/components/layout/SettingsSheet.svelte +237 -0
  7. package/dist/components/layout/SettingsSheet.svelte.d.ts +6 -0
  8. package/dist/components/layout/Sidebar.svelte +73 -6
  9. package/dist/components/layout/Sidebar.svelte.d.ts +4 -1
  10. package/dist/components/layout/StatusBar.svelte +1 -1
  11. package/dist/components/layout/TabBar.svelte +2 -2
  12. package/dist/components/ui/context-menu/context-menu-radio-group.svelte.d.ts +1 -1
  13. package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte.d.ts +1 -1
  14. package/dist/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte.d.ts +1 -1
  15. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  16. package/dist/components/ui/resizable/index.d.ts +1 -1
  17. package/dist/components/ui/resizable/index.js +2 -2
  18. package/dist/components/ui/slider/index.d.ts +3 -0
  19. package/dist/components/ui/slider/index.js +5 -0
  20. package/dist/components/ui/slider/range-slider.svelte +94 -0
  21. package/dist/components/ui/slider/range-slider.svelte.d.ts +21 -0
  22. package/dist/components/ui/slider/slider.svelte +83 -0
  23. package/dist/components/ui/slider/slider.svelte.d.ts +7 -0
  24. package/dist/components/viewers/ArchiveViewer.svelte +2 -2
  25. package/dist/components/viewers/CodeViewer.svelte +31 -22
  26. package/dist/components/viewers/CogControls.svelte +338 -184
  27. package/dist/components/viewers/CogControls.svelte.d.ts +33 -10
  28. package/dist/components/viewers/CogViewer.svelte +320 -119
  29. package/dist/components/viewers/CopcViewer.svelte +1 -1
  30. package/dist/components/viewers/FlatGeobufViewer.svelte +1 -1
  31. package/dist/components/viewers/GeoParquetMapViewer.svelte +6 -6
  32. package/dist/components/viewers/GeoParquetMapViewer.svelte.d.ts +1 -1
  33. package/dist/components/viewers/ImageViewer.svelte +2 -2
  34. package/dist/components/viewers/MarkdownViewer.svelte +12 -9
  35. package/dist/components/viewers/MediaViewer.svelte +2 -2
  36. package/dist/components/viewers/ModelViewer.svelte +1 -1
  37. package/dist/components/viewers/MultiCogViewer.svelte +467 -102
  38. package/dist/components/viewers/MultiCogViewer.svelte.d.ts +1 -1
  39. package/dist/components/viewers/NotebookViewer.svelte +6 -3
  40. package/dist/components/viewers/PdfViewer.svelte +2 -2
  41. package/dist/components/viewers/PmtilesViewer.svelte +3 -6
  42. package/dist/components/viewers/RawViewer.svelte +6 -3
  43. package/dist/components/viewers/StacMapViewer.svelte +10 -2
  44. package/dist/components/viewers/StacMosaicViewer.svelte +1800 -362
  45. package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +1 -1
  46. package/dist/components/viewers/StacTabViewer.svelte +24 -13
  47. package/dist/components/viewers/StacTabViewer.svelte.d.ts +1 -1
  48. package/dist/components/viewers/TableGrid.svelte +4 -4
  49. package/dist/components/viewers/TableStatusBar.svelte +1 -1
  50. package/dist/components/viewers/TableToolbar.svelte +1 -1
  51. package/dist/components/viewers/TableViewer.svelte +25 -17
  52. package/dist/components/viewers/TableViewer.svelte.d.ts +1 -0
  53. package/dist/components/viewers/ViewerRouter.svelte +16 -8
  54. package/dist/components/viewers/ZarrMapViewer.svelte +11 -9
  55. package/dist/components/viewers/ZarrViewer.svelte +4 -4
  56. package/dist/components/viewers/cog/ChannelPicker.svelte +83 -0
  57. package/dist/components/viewers/cog/ChannelPicker.svelte.d.ts +13 -0
  58. package/dist/components/viewers/cog/PixelInspectorPanel.svelte +87 -0
  59. package/dist/components/viewers/cog/PixelInspectorPanel.svelte.d.ts +17 -0
  60. package/dist/components/viewers/cog/buildRgbLayer.d.ts +78 -0
  61. package/dist/components/viewers/cog/buildRgbLayer.js +176 -0
  62. package/dist/components/viewers/map/AttributeTable.svelte +1 -1
  63. package/dist/components/viewers/map/MapContainer.svelte +37 -11
  64. package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +1 -1
  65. package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +1 -1
  66. package/dist/components/viewers/stac/StacDatetimeBar.svelte +175 -0
  67. package/dist/components/viewers/stac/StacDatetimeBar.svelte.d.ts +10 -0
  68. package/dist/components/viewers/stac/StacFilterPanel.svelte +243 -0
  69. package/dist/components/viewers/stac/StacFilterPanel.svelte.d.ts +14 -0
  70. package/dist/components/viewers/stac/StacItemInspector.svelte +223 -0
  71. package/dist/components/viewers/stac/StacItemInspector.svelte.d.ts +10 -0
  72. package/dist/components/viewers/stac/StacItemStrip.svelte +228 -0
  73. package/dist/components/viewers/stac/StacItemStrip.svelte.d.ts +12 -0
  74. package/dist/file-icons/index.d.ts +1 -1
  75. package/dist/file-icons/index.js +1 -1
  76. package/dist/i18n/ar.js +110 -2
  77. package/dist/i18n/en.js +110 -2
  78. package/dist/index.d.ts +2 -28
  79. package/dist/index.js +7 -23
  80. package/dist/query/engine.d.ts +10 -0
  81. package/dist/query/source.js +1 -1
  82. package/dist/query/stac-source-factory.d.ts +65 -0
  83. package/dist/query/stac-source-factory.js +77 -0
  84. package/dist/query/stac-source-parquet.d.ts +135 -0
  85. package/dist/query/stac-source-parquet.js +465 -0
  86. package/dist/query/wasm.d.ts +8 -0
  87. package/dist/query/wasm.js +304 -2
  88. package/dist/storage/presign.js +1 -1
  89. package/dist/storage/providers.js +5 -5
  90. package/dist/stores/config.svelte.d.ts +15 -0
  91. package/dist/stores/config.svelte.js +46 -0
  92. package/dist/stores/connections.svelte.d.ts +2 -2
  93. package/dist/stores/connections.svelte.js +1 -2
  94. package/dist/stores/files.svelte.d.ts +1 -1
  95. package/dist/stores/files.svelte.js +1 -1
  96. package/dist/stores/query-history.svelte.js +1 -1
  97. package/dist/stores/settings.svelte.d.ts +16 -1
  98. package/dist/stores/settings.svelte.js +104 -48
  99. package/dist/stores/tabs.svelte.d.ts +3 -0
  100. package/dist/stores/tabs.svelte.js +17 -0
  101. package/dist/utils/cog-histogram.d.ts +121 -0
  102. package/dist/utils/cog-histogram.js +424 -0
  103. package/dist/utils/cog.d.ts +200 -60
  104. package/dist/utils/cog.js +377 -114
  105. package/dist/utils/colormap-sprite.d.ts +0 -9
  106. package/dist/utils/colormap-sprite.js +0 -21
  107. package/dist/utils/deck.d.ts +16 -12
  108. package/dist/utils/deck.js +10 -4
  109. package/dist/utils/pmtiles-tile.js +2 -2
  110. package/dist/utils/{url.d.ts → signed-url.d.ts} +15 -1
  111. package/dist/utils/{url.js → signed-url.js} +32 -10
  112. package/dist/utils/url-state.d.ts +36 -0
  113. package/dist/utils/url-state.js +72 -2
  114. package/dist/utils/zarr-tab.d.ts +1 -2
  115. package/dist/utils/zarr-tab.js +1 -2
  116. package/dist/utils/zarr.d.ts +0 -17
  117. package/dist/utils/zarr.js +1 -45
  118. package/package.json +55 -84
  119. package/dist/components/browser/Breadcrumb.svelte +0 -50
  120. package/dist/components/browser/Breadcrumb.svelte.d.ts +0 -7
  121. package/dist/components/browser/CreateFolderDialog.svelte +0 -98
  122. package/dist/components/browser/CreateFolderDialog.svelte.d.ts +0 -6
  123. package/dist/components/browser/DeleteConfirmDialog.svelte +0 -90
  124. package/dist/components/browser/DeleteConfirmDialog.svelte.d.ts +0 -8
  125. package/dist/components/browser/DropZone.svelte +0 -83
  126. package/dist/components/browser/DropZone.svelte.d.ts +0 -7
  127. package/dist/components/browser/FileBrowser.svelte +0 -252
  128. package/dist/components/browser/FileBrowser.svelte.d.ts +0 -3
  129. package/dist/components/browser/FileRow.svelte +0 -117
  130. package/dist/components/browser/FileRow.svelte.d.ts +0 -9
  131. package/dist/components/browser/RenameDialog.svelte +0 -101
  132. package/dist/components/browser/RenameDialog.svelte.d.ts +0 -8
  133. package/dist/components/browser/SearchBar.svelte +0 -40
  134. package/dist/components/browser/SearchBar.svelte.d.ts +0 -6
  135. package/dist/components/browser/UploadButton.svelte +0 -65
  136. package/dist/components/browser/UploadButton.svelte.d.ts +0 -3
  137. package/dist/query/stac-geoparquet.d.ts +0 -31
  138. package/dist/query/stac-geoparquet.js +0 -136
  139. package/dist/utils/clipboard.d.ts +0 -13
  140. package/dist/utils/clipboard.js +0 -38
  141. package/dist/utils/cloud-url.d.ts +0 -27
  142. package/dist/utils/cloud-url.js +0 -61
  143. package/dist/utils/column-types.d.ts +0 -5
  144. package/dist/utils/column-types.js +0 -137
  145. package/dist/utils/connection-identity.d.ts +0 -51
  146. package/dist/utils/connection-identity.js +0 -97
  147. package/dist/utils/error.d.ts +0 -8
  148. package/dist/utils/error.js +0 -12
  149. package/dist/utils/evidence-context.d.ts +0 -22
  150. package/dist/utils/evidence-context.js +0 -56
  151. package/dist/utils/export.d.ts +0 -22
  152. package/dist/utils/export.js +0 -76
  153. package/dist/utils/file-sort.d.ts +0 -20
  154. package/dist/utils/file-sort.js +0 -41
  155. package/dist/utils/format.d.ts +0 -24
  156. package/dist/utils/format.js +0 -78
  157. package/dist/utils/geoarrow.d.ts +0 -32
  158. package/dist/utils/geoarrow.js +0 -672
  159. package/dist/utils/geometry-type.d.ts +0 -52
  160. package/dist/utils/geometry-type.js +0 -76
  161. package/dist/utils/hex.d.ts +0 -10
  162. package/dist/utils/hex.js +0 -27
  163. package/dist/utils/host-detection.d.ts +0 -23
  164. package/dist/utils/host-detection.js +0 -95
  165. package/dist/utils/local-storage.d.ts +0 -16
  166. package/dist/utils/local-storage.js +0 -37
  167. package/dist/utils/markdown-sql.d.ts +0 -30
  168. package/dist/utils/markdown-sql.js +0 -72
  169. package/dist/utils/notebook.d.ts +0 -59
  170. package/dist/utils/notebook.js +0 -211
  171. package/dist/utils/parquet-metadata.d.ts +0 -64
  172. package/dist/utils/parquet-metadata.js +0 -262
  173. package/dist/utils/stac-geoparquet.d.ts +0 -90
  174. package/dist/utils/stac-geoparquet.js +0 -223
  175. package/dist/utils/stac-hydrate.d.ts +0 -38
  176. package/dist/utils/stac-hydrate.js +0 -243
  177. package/dist/utils/stac.d.ts +0 -136
  178. package/dist/utils/stac.js +0 -176
  179. package/dist/utils/storage-url.d.ts +0 -90
  180. package/dist/utils/storage-url.js +0 -568
  181. package/dist/utils/wkb.d.ts +0 -43
  182. package/dist/utils/wkb.js +0 -359
@@ -1,7 +1,24 @@
1
1
  <script lang="ts">
2
2
  import { MapboxOverlay } from '@deck.gl/mapbox';
3
- import { MultiCOGLayer } from '@developmentseed/deck.gl-geotiff';
4
- import { DecoderPool } from '@developmentseed/geotiff';
3
+ import { DecoderPool, GeoTIFF } from '@developmentseed/geotiff';
4
+ import {
5
+ applyPreset,
6
+ attachPixelInspector,
7
+ availablePresets,
8
+ type ChannelComposite,
9
+ type CogAsset,
10
+ compositeFromUrl,
11
+ compositeToUrl,
12
+ extractCogAssets,
13
+ isSingleAssetComposite,
14
+ isStacItem,
15
+ PRESETS,
16
+ pickNaturalColorComposite,
17
+ presetMatchesComposite,
18
+ type StacItem,
19
+ type StacRoutableKind,
20
+ smokeTestHref
21
+ } from '@walkthru-earth/objex-utils';
5
22
  import type maplibregl from 'maplibre-gl';
6
23
  import { onDestroy, untrack } from 'svelte';
7
24
  import { t } from '../../i18n/index.svelte.js';
@@ -11,78 +28,93 @@ import { connectionStore } from '../../stores/connections.svelte.js';
11
28
  import { tabResources } from '../../stores/tab-resources.svelte.js';
12
29
  import type { Tab } from '../../types.js';
13
30
  import {
14
- buildBandRenderPipeline,
31
+ buildHistogramFromGeotiff,
15
32
  clampBounds,
16
33
  cleanupNativeBitmap,
17
34
  createEpsgResolver,
35
+ DEFAULT_NODATA_CONFIG,
36
+ defaultRescaleForGeotiff,
18
37
  fitCogBounds,
19
- type RescaleConfig
38
+ loadGeoTIFF,
39
+ mapResolutionMetersPerPixel,
40
+ type NodataConfig,
41
+ normalizeCogGeotiff,
42
+ type PixelValue,
43
+ percentileFromHistogram,
44
+ type RescaleConfig,
45
+ readGdalNodata,
46
+ readPixelAtLngLat,
47
+ resolveNodata,
48
+ resolveProj4Def,
49
+ selectOverviewForResolution
20
50
  } from '../../utils/cog.js';
21
- import {
22
- type BandMap,
23
- type BandSlot,
24
- extractSentinelBandAssets,
25
- hasRgbBands,
26
- isStacItem,
27
- type StacItem,
28
- type StacRoutableKind
29
- } from '../../utils/stac.js';
30
- import { buildHttpsUrlAsync } from '../../utils/url.js';
51
+ import { buildHttpsUrlAsync } from '../../utils/signed-url.js';
52
+ import { getUrlViewParams, updateUrlViewParams } from '../../utils/url-state.js';
31
53
  import CogControls from './CogControls.svelte';
54
+ import { buildRgbLayer } from './cog/buildRgbLayer.js';
55
+ import PixelInspectorPanel, { type PixelInspectorRow } from './cog/PixelInspectorPanel.svelte';
32
56
  import MapContainer from './map/MapContainer.svelte';
33
57
 
34
- interface Preset {
35
- id: string;
36
- labelKey: string;
37
- composite: { r: BandSlot; g: BandSlot; b: BandSlot };
38
- }
39
-
40
- const PRESETS: Preset[] = [
41
- {
42
- id: 'true-color',
43
- labelKey: 'map.multiCogPreset.trueColor',
44
- composite: { r: 'red', g: 'green', b: 'blue' }
45
- },
46
- {
47
- id: 'false-color-ir',
48
- labelKey: 'map.multiCogPreset.falseColorIR',
49
- composite: { r: 'nir', g: 'red', b: 'green' }
50
- },
51
- {
52
- id: 'swir',
53
- labelKey: 'map.multiCogPreset.swir',
54
- composite: { r: 'swir2', g: 'swir1', b: 'red' }
55
- },
56
- {
57
- id: 'vegetation',
58
- labelKey: 'map.multiCogPreset.vegetation',
59
- composite: { r: 'nir', g: 'swir1', b: 'red' }
60
- },
61
- {
62
- id: 'agriculture',
63
- labelKey: 'map.multiCogPreset.agriculture',
64
- composite: { r: 'swir1', g: 'nir', b: 'blue' }
65
- }
66
- ];
67
-
68
58
  let { tab, classified }: { tab: Tab; classified?: StacRoutableKind } = $props();
69
59
 
70
60
  let loading = $state(true);
71
61
  let error = $state<string | null>(null);
72
62
  let showControls = $state(false);
73
63
  let bounds = $state<[number, number, number, number] | undefined>();
74
- let activePresetId = $state<string>('true-color');
75
- // Sentinel-2 L2A reflectance is scaled uint16 (raw / 10000 = reflectance).
76
- // After the default uint normalization the slider operates on 0..1, so 0.3
77
- // keeps typical land surfaces in the visible range without clipping.
64
+ let activePresetId = $state<string>('natural-color');
65
+ // Default rescale is bit-depth aware: uint8 visual COGs (Sentinel-2 `visual`,
66
+ // NAIP `image`) want max=0.3, uint16 reflectance bands (S2 raw `nir`/`swir`/`red`)
67
+ // want ~0.05 because r16unorm divides raw values by 65535, leaving typical
68
+ // reflectance ~0.012-0.046 in shader space. The actual default is reseeded
69
+ // from the first preflighted GeoTIFF in `buildAndAddLayer`. Until the user
70
+ // drags the slider, preset/composite swaps continue to refresh the default
71
+ // so switching from a uint8 visual to a uint16 multi-asset preset doesn't
72
+ // render near-black tiles.
78
73
  let rescale = $state<RescaleConfig>({ min: 0, max: 0.3 });
74
+ let userTouchedRescale = false;
75
+ // Single-band histogram baked once from the R-channel preflight's smallest
76
+ // overview, in the same shader-space [0,1] domain the rescale slider uses.
77
+ // Backs the histogram overlay in CogControls. Recomputed when the R-channel
78
+ // asset changes (tracked by histogramAssetKey) so swapping bands gives the
79
+ // user an accurate distribution to scrub against.
80
+ let histogram = $state.raw<Uint32Array | null>(null);
81
+ let histogramAssetKey: string | null = null;
82
+ // User-facing nodata override (Auto/Value/Off). `autoNodata` is the GDAL_NODATA
83
+ // value read from the R-channel preflight; Auto mode resolves to it via
84
+ // `resolveNodata()` at layer-build time.
85
+ let nodataConfig = $state<NodataConfig>({ ...DEFAULT_NODATA_CONFIG });
86
+ let autoNodata = $state<number | null>(null);
79
87
 
80
- let bandMap = $state.raw<BandMap>({});
88
+ let assets = $state.raw<CogAsset[]>([]);
89
+ let composite = $state.raw<ChannelComposite | null>(null);
81
90
  let abortController = new AbortController();
82
91
  let mapRef: maplibregl.Map | null = null;
83
92
  let overlayRef: MapboxOverlay | null = null;
84
93
  let hasFittedOnce = false;
85
94
  let presignCache = new Map<string, Promise<string>>();
95
+
96
+ // Pixel inspection: same UX as CogViewer / StacMosaicViewer. Click → read one
97
+ // pixel from each active composite channel's GeoTIFF and show channel/asset/value.
98
+ type MultiPixelEntry = {
99
+ channel: 'R' | 'G' | 'B' | 'A';
100
+ assetKey: string;
101
+ bandIndex: number;
102
+ value: number | null;
103
+ };
104
+ type MultiPixelValue = { lng: number; lat: number; entries: MultiPixelEntry[] };
105
+ let pixelValue = $state<MultiPixelValue | null>(null);
106
+ let inspecting = $state(false);
107
+ let proj4DefRef: string | null = null;
108
+ // Storage smoke-test result for the primary R-channel asset.
109
+ let smokeWarning = $state<string | null>(null);
110
+ let smokeProbed = false;
111
+ let detachInspector: (() => void) | null = null;
112
+ // Per-asset-key GeoTIFF cache. Opening the GeoTIFF up-front lets buildRgbLayer
113
+ // run selectCogPipeline (which inspects sampleFormat / band count) and emit a
114
+ // custom getTileData/renderTile pair that honors per-channel bandIndex picks.
115
+ // Without this, the single-asset multi-band path (e.g. Sentinel-2 `visual`,
116
+ // NAIP `image`) silently falls back to bands 0/1/2 regardless of the picker.
117
+ let geotiffCache = new Map<string, Promise<GeoTIFF>>();
86
118
  let loadGen = 0;
87
119
  let layerVersion = 0;
88
120
  let rebuildTimer: number | null = null;
@@ -96,10 +128,7 @@ const REBUILD_INTERVAL_MS = 750;
96
128
  let pool: DecoderPool | null = new DecoderPool();
97
129
  const epsgResolver = createEpsgResolver();
98
130
 
99
- const activePreset = $derived(PRESETS.find((p) => p.id === activePresetId) ?? PRESETS[0]);
100
- const availablePresets = $derived(
101
- PRESETS.filter((p) => bandMap[p.composite.r] && bandMap[p.composite.g] && bandMap[p.composite.b])
102
- );
131
+ const presetsForItem = $derived(availablePresets(assets));
103
132
 
104
133
  $effect(() => {
105
134
  if (!tab) return;
@@ -127,16 +156,115 @@ function resetViewer(): void {
127
156
  /* already destroyed */
128
157
  }
129
158
  }
159
+ removeClickHandler();
130
160
  overlayRef = null;
131
- bandMap = {};
161
+ assets = [];
162
+ composite = null;
132
163
  presignCache = new Map();
164
+ geotiffCache = new Map();
133
165
  loading = true;
134
166
  error = null;
135
167
  bounds = undefined;
136
- activePresetId = 'true-color';
168
+ activePresetId = 'natural-color';
137
169
  rescale = { min: 0, max: 0.3 };
170
+ userTouchedRescale = false;
171
+ histogram = null;
172
+ histogramAssetKey = null;
173
+ nodataConfig = { ...DEFAULT_NODATA_CONFIG };
174
+ autoNodata = null;
138
175
  hasFittedOnce = false;
139
176
  showControls = false;
177
+ pixelValue = null;
178
+ inspecting = false;
179
+ proj4DefRef = null;
180
+ smokeWarning = null;
181
+ smokeProbed = false;
182
+ }
183
+
184
+ function removeClickHandler(): void {
185
+ if (detachInspector) {
186
+ detachInspector();
187
+ detachInspector = null;
188
+ }
189
+ }
190
+
191
+ async function ensureGeotiff(assetKey: string): Promise<GeoTIFF | null> {
192
+ const asset = assets.find((a) => a.key === assetKey);
193
+ if (!asset) return null;
194
+ let promise = geotiffCache.get(assetKey);
195
+ if (!promise) {
196
+ promise = (async () => {
197
+ const url = await presignHref(asset.href);
198
+ const g = await loadGeoTIFF(url);
199
+ normalizeCogGeotiff(g);
200
+ return g;
201
+ })();
202
+ geotiffCache.set(assetKey, promise);
203
+ }
204
+ try {
205
+ return await promise;
206
+ } catch (err) {
207
+ console.warn('[MultiCogViewer] ensureGeotiff failed', { assetKey, err });
208
+ geotiffCache.delete(assetKey);
209
+ return null;
210
+ }
211
+ }
212
+
213
+ function setupClickHandler(map: maplibregl.Map): void {
214
+ removeClickHandler();
215
+ detachInspector = attachPixelInspector<MultiPixelValue>(map, {
216
+ probe: async ({ lng, lat, signal }) => {
217
+ const c = composite;
218
+ if (!c) return null;
219
+ // Match the overview that's currently on screen so the pixel readout
220
+ // reflects the visible decimation level. Computed once per click and
221
+ // re-picked per-channel because per-asset COGs may have different
222
+ // pyramids (Sentinel-2 SWIR is 20 m native, true color is 10 m).
223
+ const targetRes = mapResolutionMetersPerPixel(map.getZoom(), lat);
224
+ const channels: { channel: 'R' | 'G' | 'B' | 'A'; ref: typeof c.r | undefined }[] = [
225
+ { channel: 'R', ref: c.r },
226
+ { channel: 'G', ref: c.g },
227
+ { channel: 'B', ref: c.b },
228
+ { channel: 'A', ref: c.a }
229
+ ];
230
+ const active = channels.filter(
231
+ (x): x is { channel: 'R' | 'G' | 'B' | 'A'; ref: NonNullable<typeof c.r> } => Boolean(x.ref)
232
+ );
233
+ const entries = await Promise.all(
234
+ active.map(async ({ channel, ref }): Promise<MultiPixelEntry> => {
235
+ const geotiff = await ensureGeotiff(ref.assetKey);
236
+ if (!geotiff || signal.aborted) {
237
+ return { channel, assetKey: ref.assetKey, bandIndex: ref.bandIndex, value: null };
238
+ }
239
+ try {
240
+ const overview = selectOverviewForResolution(geotiff, targetRes);
241
+ const result: PixelValue | null = await readPixelAtLngLat(
242
+ geotiff,
243
+ lng,
244
+ lat,
245
+ proj4DefRef,
246
+ pool,
247
+ signal,
248
+ { overview }
249
+ );
250
+ const v = result?.values?.[ref.bandIndex] ?? null;
251
+ return { channel, assetKey: ref.assetKey, bandIndex: ref.bandIndex, value: v };
252
+ } catch {
253
+ return { channel, assetKey: ref.assetKey, bandIndex: ref.bandIndex, value: null };
254
+ }
255
+ })
256
+ );
257
+ if (signal.aborted) return null;
258
+ return { lng, lat, entries };
259
+ },
260
+ onStart: () => {
261
+ inspecting = true;
262
+ },
263
+ onResult: (result) => {
264
+ pixelValue = result;
265
+ inspecting = false;
266
+ }
267
+ });
140
268
  }
141
269
 
142
270
  function scheduleLayerRebuild(map: maplibregl.Map, signal: AbortSignal): void {
@@ -210,7 +338,7 @@ async function loadItem(map: maplibregl.Map): Promise<void> {
210
338
  loading = false;
211
339
  return;
212
340
  }
213
- item = parsed as StacItem;
341
+ item = parsed;
214
342
  }
215
343
  if (!item) {
216
344
  error = t('map.multiCogMissingBands');
@@ -218,13 +346,62 @@ async function loadItem(map: maplibregl.Map): Promise<void> {
218
346
  return;
219
347
  }
220
348
 
221
- const bands = extractSentinelBandAssets(item);
222
- if (!hasRgbBands(bands)) {
349
+ const next = extractCogAssets(item);
350
+ if (next.length < 1) {
223
351
  error = t('map.multiCogMissingBands');
224
352
  loading = false;
225
353
  return;
226
354
  }
227
- bandMap = bands;
355
+ assets = next;
356
+
357
+ // Hydrate composite: URL params first, then natural-color default.
358
+ const params = getUrlViewParams();
359
+ const fromUrl = compositeFromUrl(params, next);
360
+ console.debug('[MultiCogViewer] loadItem', {
361
+ assetKeys: next.map((a) => `${a.key}(bands=${a.bandCount},common=${a.eoCommon[0] ?? ''})`),
362
+ urlParams: Object.fromEntries(params.entries()),
363
+ fromUrl
364
+ });
365
+ if (fromUrl) {
366
+ composite = fromUrl;
367
+ const presetId = params.get('preset');
368
+ if (presetId && PRESETS.find((p) => p.id === presetId)) activePresetId = presetId;
369
+ else activePresetId = '';
370
+ } else {
371
+ const picked = pickNaturalColorComposite(next);
372
+ composite = picked?.composite ?? null;
373
+ activePresetId = picked?.source === 'rgb-bands' ? 'natural-color' : '';
374
+ }
375
+ console.debug('[MultiCogViewer] composite seeded', { composite, activePresetId });
376
+
377
+ if (!composite) {
378
+ error = t('map.multiCogMissingBands');
379
+ loading = false;
380
+ return;
381
+ }
382
+
383
+ // One-shot storage smoke-test against the R-channel asset. lazycogs-style
384
+ // probe surfaces auth / CORS / bucket failures at open time as an amber
385
+ // pill, fires in parallel with the rest of the load. Aborts via the
386
+ // viewer's existing controller.
387
+ if (!smokeProbed) {
388
+ smokeProbed = true;
389
+ const rAsset = next.find((a) => a.key === composite!.r.assetKey);
390
+ if (rAsset) {
391
+ void (async () => {
392
+ try {
393
+ const url = await presignHref(rAsset.href);
394
+ const result = await smokeTestHref(url, signal);
395
+ if (gen !== loadGen || signal.aborted) return;
396
+ if (!result.ok) smokeWarning = result.reason;
397
+ } catch (err) {
398
+ if (err instanceof DOMException && err.name === 'AbortError') return;
399
+ if (gen !== loadGen) return;
400
+ smokeWarning = err instanceof Error ? err.message : String(err);
401
+ }
402
+ })();
403
+ }
404
+ }
228
405
 
229
406
  if (Array.isArray(item.bbox) && item.bbox.length >= 4) {
230
407
  const clamped = clampBounds({
@@ -255,37 +432,149 @@ async function buildAndAddLayer(
255
432
  version: number,
256
433
  signal: AbortSignal
257
434
  ): Promise<void> {
258
- const composite = activePreset.composite;
259
- const sources: Record<string, { url: string }> = {};
260
- for (const slot of [composite.r, composite.g, composite.b]) {
261
- const href = bandMap[slot];
262
- if (!href) continue;
263
- const url = await presignHref(href);
264
- if (version !== layerVersion || signal.aborted) return;
265
- sources[slot] = { url };
435
+ const c = composite;
436
+ if (!c) return;
437
+
438
+ console.debug('[MultiCogViewer] buildAndAddLayer start', {
439
+ version,
440
+ composite: c,
441
+ rescale: { ...rescale }
442
+ });
443
+
444
+ // Pre-open the R-channel GeoTIFF on every path. For single-asset composites
445
+ // this lets buildRgbLayer run selectCogPipeline and honor per-channel
446
+ // bandIndex. For multi-asset composites (MultiCOGLayer) the GeoTIFF object
447
+ // is not consumed by the layer, but inspecting its tags lets us pick a
448
+ // bit-depth-appropriate default rescale so uint16 reflectance bands don't
449
+ // render near-black against a slider tuned for uint8 visuals.
450
+ let preflightGeotiff: GeoTIFF | null = null;
451
+ const rChannelKey = c.r.assetKey;
452
+ const rAsset = assets.find((a) => a.key === rChannelKey);
453
+ if (rAsset) {
454
+ let promise = geotiffCache.get(rChannelKey);
455
+ if (!promise) {
456
+ promise = (async () => {
457
+ const url = await presignHref(rAsset.href);
458
+ const g = await loadGeoTIFF(url);
459
+ normalizeCogGeotiff(g);
460
+ return g;
461
+ })();
462
+ geotiffCache.set(rChannelKey, promise);
463
+ }
464
+ try {
465
+ preflightGeotiff = await promise;
466
+ } catch (err) {
467
+ console.warn('[MultiCogViewer] preflight GeoTIFF open failed', {
468
+ assetKey: rChannelKey,
469
+ err
470
+ });
471
+ geotiffCache.delete(rChannelKey);
472
+ preflightGeotiff = null;
473
+ }
474
+ if (signal.aborted) return;
475
+ }
476
+
477
+ if (preflightGeotiff && !userTouchedRescale) {
478
+ const next = defaultRescaleForGeotiff(preflightGeotiff);
479
+ if (next.min !== rescale.min || next.max !== rescale.max) {
480
+ console.debug('[MultiCogViewer] reseeding rescale from preflight', {
481
+ assetKey: rChannelKey,
482
+ prev: { ...rescale },
483
+ next
484
+ });
485
+ rescale = next;
486
+ }
487
+ }
488
+
489
+ // Surface GDAL_NODATA from the R-channel preflight so the CogControls nodata
490
+ // hint pill and the `Auto` resolved value have a real number to show before
491
+ // the first tile decodes.
492
+ if (preflightGeotiff) {
493
+ autoNodata = readGdalNodata(preflightGeotiff);
494
+ }
495
+
496
+ // Bake the histogram once per R-channel asset. Cheap (one overview tile),
497
+ // and gives CogControls a real distribution to overlay behind the slider.
498
+ // When the user hasn't touched the slider, also reseed rescale to the
499
+ // p2/p98 of that distribution so the thumbs land where the data actually
500
+ // lives instead of at the bit-depth-aware default. This is what gives a
501
+ // preset switch (e.g. true-color → vegetation, red → swir16) auto-contrast
502
+ // without the user having to re-drag the slider every time.
503
+ if (preflightGeotiff && histogramAssetKey !== rChannelKey) {
504
+ histogramAssetKey = rChannelKey;
505
+ void (async () => {
506
+ const bins = await buildHistogramFromGeotiff(preflightGeotiff, signal);
507
+ if (signal.aborted) return;
508
+ if (histogramAssetKey !== rChannelKey) return; // user swapped while baking
509
+ histogram = bins;
510
+ if (!userTouchedRescale && bins) {
511
+ const lo = percentileFromHistogram(bins, 0.02);
512
+ const hi = percentileFromHistogram(bins, 0.98);
513
+ if (lo !== null && hi !== null && hi > lo) {
514
+ console.debug('[MultiCogViewer] reseeding rescale from histogram p2/p98', {
515
+ assetKey: rChannelKey,
516
+ prev: { ...rescale },
517
+ next: { min: lo, max: hi }
518
+ });
519
+ rescale = { min: lo, max: hi };
520
+ }
521
+ }
522
+ })();
523
+ }
524
+
525
+ // Resolve proj4 once for pixel inspection. All band assets in a STAC Item
526
+ // share the same source CRS so the R-channel preflight is sufficient.
527
+ if (preflightGeotiff && proj4DefRef === null) {
528
+ try {
529
+ proj4DefRef = await resolveProj4Def(preflightGeotiff.crs, signal);
530
+ } catch {
531
+ proj4DefRef = null;
532
+ }
533
+ if (signal.aborted) return;
266
534
  }
267
535
 
268
- const layer = new MultiCOGLayer({
536
+ // Multi-asset path doesn't consume the GeoTIFF object; only single-asset
537
+ // flows it through to selectCogPipeline. Drop the reference so buildRgbLayer
538
+ // doesn't try to translate per-channel bandIndex on a path that can't honor it.
539
+ const preflightForLayer = isSingleAssetComposite(c) ? preflightGeotiff : null;
540
+
541
+ const resolvedNodata = resolveNodata(nodataConfig, autoNodata);
542
+ const { layer, kind } = await buildRgbLayer({
269
543
  id: `multicog-${tab.id}-v${version}`,
270
- sources,
271
- composite: { r: composite.r, g: composite.g, b: composite.b },
272
- renderPipeline: buildBandRenderPipeline({ noDataVal: 0, rescale: { ...rescale } }),
273
- pool: pool ?? undefined,
544
+ assets,
545
+ composite: c,
546
+ rescale: { ...rescale },
547
+ resolveHref: presignHref,
548
+ pool,
274
549
  epsgResolver,
275
550
  signal,
276
- onGeoTIFFLoad: (_tiffs, { geographicBounds }) => {
551
+ preflightGeotiff: preflightForLayer,
552
+ noDataVal: resolvedNodata,
553
+ onLoad: ({ bounds: nextBounds }) => {
277
554
  if (version !== layerVersion || signal.aborted) return;
278
- const clamped = clampBounds(geographicBounds);
279
- if (!hasFittedOnce) {
280
- bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
281
- fitCogBounds(map, clamped);
282
- hasFittedOnce = true;
555
+ if (nextBounds) {
556
+ const clamped = clampBounds(nextBounds);
557
+ if (!hasFittedOnce) {
558
+ bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
559
+ fitCogBounds(map, clamped);
560
+ hasFittedOnce = true;
561
+ }
283
562
  }
284
563
  loading = false;
285
564
  }
286
565
  });
287
566
 
567
+ console.debug('[MultiCogViewer] buildAndAddLayer built', {
568
+ version,
569
+ kind,
570
+ layerId: (layer as { id?: string }).id,
571
+ hasOverlay: !!overlayRef
572
+ });
288
573
  if (overlayRef) {
574
+ console.debug('[MultiCogViewer] overlayRef.setProps swapping layer', {
575
+ version,
576
+ layerId: (layer as { id?: string }).id
577
+ });
289
578
  overlayRef.setProps({ layers: [layer] });
290
579
  return;
291
580
  }
@@ -295,6 +584,12 @@ async function buildAndAddLayer(
295
584
  layers: [layer],
296
585
  onError: (err: Error) => {
297
586
  if (signal.aborted) return;
587
+ console.error('[MultiCogViewer] MapboxOverlay error', {
588
+ name: err?.name,
589
+ message: err?.message,
590
+ stack: err?.stack,
591
+ err
592
+ });
298
593
  if (!error) {
299
594
  error = err?.message || String(err);
300
595
  loading = false;
@@ -302,16 +597,55 @@ async function buildAndAddLayer(
302
597
  }
303
598
  });
304
599
  overlayRef = overlay;
600
+ console.debug('[MultiCogViewer] addControl initial overlay', {
601
+ version,
602
+ layerId: (layer as { id?: string }).id
603
+ });
305
604
  map.addControl(overlay as unknown as maplibregl.IControl);
605
+ setupClickHandler(map);
606
+ }
607
+
608
+ function syncCompositeToUrl(c: ChannelComposite | null, presetId: string | null): void {
609
+ if (!c) {
610
+ updateUrlViewParams('map', null);
611
+ return;
612
+ }
613
+ updateUrlViewParams('map', compositeToUrl(c, presetId));
306
614
  }
307
615
 
308
616
  function setPreset(id: string): void {
617
+ const preset = PRESETS.find((p) => p.id === id);
618
+ if (!preset) return;
619
+ const next = applyPreset(assets, preset);
620
+ if (!next) return;
621
+ const a = composite?.a;
622
+ composite = a ? { ...next, a } : next;
309
623
  activePresetId = id;
624
+ // Asset references changed: let the next preflight reseed a bit-depth-
625
+ // appropriate default rescale (uint8 visual → 0.3, uint16 reflectance → 0.05).
626
+ userTouchedRescale = false;
627
+ console.debug('[MultiCogViewer] setPreset', { id, composite });
628
+ syncCompositeToUrl(composite, id);
629
+ if (mapRef) scheduleLayerRebuild(mapRef, abortController.signal);
630
+ }
631
+
632
+ function setComposite(next: ChannelComposite): void {
633
+ const rAssetChanged = composite?.r.assetKey !== next.r.assetKey;
634
+ composite = next;
635
+ const matching = PRESETS.find((p) => presetMatchesComposite(p, next, assets));
636
+ activePresetId = matching?.id ?? '';
637
+ // Only reseed the rescale default when the R-channel asset changed, because
638
+ // that's the asset we preflight; band-index-only swaps shouldn't stomp the
639
+ // user's slider.
640
+ if (rAssetChanged) userTouchedRescale = false;
641
+ console.debug('[MultiCogViewer] setComposite', { next, activePresetId, rAssetChanged });
642
+ syncCompositeToUrl(next, activePresetId || null);
310
643
  if (mapRef) scheduleLayerRebuild(mapRef, abortController.signal);
311
644
  }
312
645
 
313
646
  function handleRescaleChange(next: RescaleConfig): void {
314
647
  rescale = next;
648
+ userTouchedRescale = true;
315
649
  if (mapRef) scheduleLayerRebuild(mapRef, abortController.signal);
316
650
  }
317
651
 
@@ -321,6 +655,7 @@ function cleanup(): void {
321
655
  clearTimeout(rebuildTimer);
322
656
  rebuildTimer = null;
323
657
  }
658
+ removeClickHandler();
324
659
  if (mapRef && overlayRef) {
325
660
  try {
326
661
  mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
@@ -331,8 +666,15 @@ function cleanup(): void {
331
666
  if (mapRef) cleanupNativeBitmap(mapRef);
332
667
  mapRef = null;
333
668
  overlayRef = null;
334
- bandMap = {};
669
+ assets = [];
670
+ composite = null;
335
671
  presignCache.clear();
672
+ geotiffCache.clear();
673
+ pixelValue = null;
674
+ inspecting = false;
675
+ proj4DefRef = null;
676
+ histogram = null;
677
+ histogramAssetKey = null;
336
678
  const maybeDestroy = pool as unknown as { destroy?: () => void; terminate?: () => void } | null;
337
679
  if (maybeDestroy?.destroy) {
338
680
  try {
@@ -363,7 +705,7 @@ onDestroy(cleanup);
363
705
  <MapContainer {onMapReady} {bounds} />
364
706
  </div>
365
707
 
366
- <div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col gap-1">
708
+ <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">
367
709
  {#if loading}
368
710
  <div class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
369
711
  {t('map.loadingCog')}
@@ -374,24 +716,20 @@ onDestroy(cleanup);
374
716
  {error}
375
717
  </div>
376
718
  {/if}
719
+ {#if smokeWarning && !error}
720
+ <div
721
+ class="pointer-events-auto max-w-sm rounded bg-amber-900/80 px-2 py-1 text-xs text-amber-100"
722
+ title={t('stac.smokeWarningHint')}
723
+ >
724
+ {t('stac.smokeWarning', { reason: smokeWarning })}
725
+ </div>
726
+ {/if}
377
727
  </div>
378
728
 
379
- {#if !error && availablePresets.length > 0}
380
- <div class="absolute right-2 top-2 z-10 flex items-center gap-1">
381
- <label class="flex items-center gap-1 rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
382
- <span class="text-muted-foreground">{t('map.multiCogPreset.label')}</span>
383
- <select
384
- class="rounded border border-border bg-background px-1 py-0.5 text-xs"
385
- value={activePresetId}
386
- onchange={(e) => setPreset((e.target as HTMLSelectElement).value)}
387
- >
388
- {#each availablePresets as p}
389
- <option value={p.id}>{t(p.labelKey)}</option>
390
- {/each}
391
- </select>
392
- </label>
729
+ {#if !error && assets.length > 0 && composite}
730
+ <div class="absolute right-2 top-2 z-10 flex items-center gap-1" style="touch-action: manipulation;">
393
731
  <button
394
- class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
732
+ 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"
395
733
  class:ring-1={showControls}
396
734
  class:ring-primary={showControls}
397
735
  onclick={() => {
@@ -404,13 +742,40 @@ onDestroy(cleanup);
404
742
 
405
743
  {#if showControls}
406
744
  <CogControls
407
- mode="multi"
408
- bandCount={3}
409
- onConfigChange={() => {}}
745
+ {assets}
746
+ composite={composite}
747
+ onCompositeChange={setComposite}
748
+ presets={presetsForItem}
749
+ {activePresetId}
750
+ onPresetChange={setPreset}
751
+ mode="rgb"
752
+ onModeChange={() => {}}
410
753
  {rescale}
411
754
  rescaleApplicable={true}
412
755
  onRescaleChange={handleRescaleChange}
756
+ showAlpha={assets.length >= 4}
757
+ {histogram}
758
+ nodata={nodataConfig}
759
+ {autoNodata}
760
+ onNodataChange={(next) => {
761
+ nodataConfig = next;
762
+ if (mapRef) scheduleLayerRebuild(mapRef, abortController.signal);
763
+ }}
413
764
  />
414
765
  {/if}
415
766
  {/if}
767
+
768
+ <PixelInspectorPanel
769
+ lng={pixelValue?.lng ?? null}
770
+ lat={pixelValue?.lat ?? null}
771
+ rows={pixelValue
772
+ ? (pixelValue.entries.map((e) => ({
773
+ label: e.channel,
774
+ sublabel: e.assetKey,
775
+ value: e.value
776
+ })) satisfies PixelInspectorRow[])
777
+ : null}
778
+ onClose={() => (pixelValue = null)}
779
+ {inspecting}
780
+ />
416
781
  </div>