@walkthru-earth/objex 1.3.1 → 1.5.0

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