@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
@@ -2,6 +2,13 @@
2
2
  import { MapboxOverlay } from '@deck.gl/mapbox';
3
3
  import { COGLayer } from '@developmentseed/deck.gl-geotiff';
4
4
  import { DecoderPool, GeoTIFF } from '@developmentseed/geotiff';
5
+ import {
6
+ attachPixelInspector,
7
+ type ChannelComposite,
8
+ type CogAsset,
9
+ smokeTestHref,
10
+ syntheticSelfAsset
11
+ } from '@walkthru-earth/objex-utils';
5
12
  import type maplibregl from 'maplibre-gl';
6
13
  import { onDestroy, untrack } from 'svelte';
7
14
  import { t } from '../../i18n/index.svelte.js';
@@ -9,26 +16,39 @@ import { tabResources } from '../../stores/tab-resources.svelte.js';
9
16
  import type { Tab } from '../../types.js';
10
17
  import {
11
18
  type BandConfig,
19
+ buildCustomRenderTile,
12
20
  buildDataTypeLabel,
13
21
  type CogInfo,
22
+ type ConfigurableTileLoader,
23
+ type CustomTileData,
14
24
  clampBounds,
15
25
  cleanupNativeBitmap,
26
+ createConfigurableGetTileData,
16
27
  createEpsgResolver,
28
+ DEFAULT_NODATA_CONFIG,
17
29
  DEFAULT_RESCALE,
18
30
  defaultBandConfig,
19
31
  fitCogBounds,
32
+ HISTOGRAM_BIN_COUNT,
20
33
  inspectCogTags,
34
+ loadGeoTIFF,
35
+ mapResolutionMetersPerPixel,
36
+ type NodataConfig,
21
37
  needsCustomPipelineForConfig,
22
38
  normalizeCogGeotiff,
23
39
  type PixelValue,
24
40
  type RescaleConfig,
41
+ readGdalNodata,
25
42
  readPixelAtLngLat,
26
43
  renderNonTiledBitmap,
27
44
  resolveProj4Def,
28
- selectCogPipeline
45
+ selectCogPipeline,
46
+ selectOverviewForResolution
29
47
  } from '../../utils/cog.js';
30
- import { buildHttpsUrlAsync } from '../../utils/url.js';
48
+ import { seedRescaleFromGeotiff } from '../../utils/cog-histogram.js';
49
+ import { buildHttpsUrlAsync } from '../../utils/signed-url.js';
31
50
  import CogControls from './CogControls.svelte';
51
+ import PixelInspectorPanel, { type PixelInspectorRow } from './cog/PixelInspectorPanel.svelte';
32
52
  import MapContainer from './map/MapContainer.svelte';
33
53
 
34
54
  // ─── State ───────────────────────────────────────────────────────
@@ -41,24 +61,69 @@ let showControls = $state(false);
41
61
  let bounds = $state<[number, number, number, number] | undefined>();
42
62
  let cogInfo = $state<CogInfo | null>(null);
43
63
  let bandConfig = $state<BandConfig | null>(null);
64
+ let resolvedHrefForControls = $state<string | null>(null);
65
+ let probedBandCount = $state<number | null>(null);
66
+
67
+ const cogControlsAssets = $derived.by<CogAsset[]>(() => {
68
+ const href = resolvedHrefForControls;
69
+ if (!href) return [];
70
+ return [syntheticSelfAsset(href, probedBandCount ?? undefined)];
71
+ });
72
+
73
+ const cogControlsComposite = $derived.by<ChannelComposite>(() => {
74
+ const bc = bandConfig;
75
+ if (!bc) {
76
+ return {
77
+ r: { assetKey: 'self', bandIndex: 0 },
78
+ g: { assetKey: 'self', bandIndex: 0 },
79
+ b: { assetKey: 'self', bandIndex: 0 }
80
+ };
81
+ }
82
+ if (bc.mode === 'rgb') {
83
+ return {
84
+ r: { assetKey: 'self', bandIndex: bc.rBand ?? 0 },
85
+ g: { assetKey: 'self', bandIndex: bc.gBand ?? 0 },
86
+ b: { assetKey: 'self', bandIndex: bc.bBand ?? 0 }
87
+ };
88
+ }
89
+ const i = bc.band ?? 0;
90
+ return {
91
+ r: { assetKey: 'self', bandIndex: i },
92
+ g: { assetKey: 'self', bandIndex: i },
93
+ b: { assetKey: 'self', bandIndex: i }
94
+ };
95
+ });
44
96
  let histogram = $state.raw<Uint32Array | null>(null);
45
97
  let histogramTick = $state(0);
46
98
  let rescale = $state<RescaleConfig>({ ...DEFAULT_RESCALE });
99
+ // User-facing nodata override (Auto/Value/Off). Auto resolves at read time
100
+ // from the GeoTIFF's GDAL_NODATA tag, surfaced as a hint pill in CogControls.
101
+ let nodataConfig = $state<NodataConfig>({ ...DEFAULT_NODATA_CONFIG });
102
+ let autoNodata = $state<number | null>(null);
47
103
  // Palette-indexed COGs render through the library's Colormap module; a GPU
48
104
  // rescale at that stage is cosmetic and would confuse the legend. Keep the
49
105
  // slider hidden when a ColorMap tag is present.
50
106
  let isPaletteIndexed = $state(false);
51
107
  let pixelValue = $state<PixelValue | null>(null);
52
108
  let inspecting = $state(false);
109
+ // Storage smoke-test result for the primary asset href. Inspired by
110
+ // lazycogs `_smoketest_store`, surfaces auth / CORS / bucket failures at
111
+ // open time as a small amber pill, never blocks the layer mount.
112
+ let smokeWarning = $state<string | null>(null);
113
+ let smokeProbed = false;
53
114
 
54
115
  let abortController = new AbortController();
55
116
  let mapRef: maplibregl.Map | null = null;
56
117
  let overlayRef: MapboxOverlay | null = null;
57
118
  let geotiffRef: GeoTIFF | null = null;
119
+ // Identity-stable tile loader for the configurable CPU path. Lives for the
120
+ // duration of the current GeoTIFF identity, so deck.gl's TileLayer cache
121
+ // survives band/ramp swaps (a fresh getTileData reference would invalidate it).
122
+ let tileLoaderRef: ConfigurableTileLoader | null = null;
58
123
  let proj4DefRef: string | null = null;
59
124
  let sampleFormatRef = 1;
60
125
  let isTiledRef = true;
61
- let clickHandlerRef: ((e: maplibregl.MapMouseEvent) => void) | null = null;
126
+ let detachInspector: (() => void) | null = null;
62
127
  let resolvedHttpsUrl: string | null = null;
63
128
  // LinearRescale operates on a 0..1 scalar. Two cases expose a meaningful
64
129
  // slider: (1) the library-default uint RGB pipeline (scales `color.rgb`
@@ -109,8 +174,11 @@ $effect(() => {
109
174
  }
110
175
  overlayRef = null;
111
176
  geotiffRef = null;
177
+ tileLoaderRef = null;
112
178
  proj4DefRef = null;
113
179
  resolvedHttpsUrl = null;
180
+ resolvedHrefForControls = null;
181
+ probedBandCount = null;
114
182
  loading = true;
115
183
  error = null;
116
184
  cogInfo = null;
@@ -118,8 +186,12 @@ $effect(() => {
118
186
  histogram = null;
119
187
  histogramTick = 0;
120
188
  rescale = { ...DEFAULT_RESCALE };
189
+ nodataConfig = { ...DEFAULT_NODATA_CONFIG };
190
+ autoNodata = null;
121
191
  isPaletteIndexed = false;
122
192
  pixelValue = null;
193
+ smokeWarning = null;
194
+ smokeProbed = false;
123
195
  bounds = undefined;
124
196
  hasFittedOnce = false;
125
197
  showControls = false;
@@ -138,34 +210,32 @@ function onMapReady(map: maplibregl.Map) {
138
210
  // ─── Click handler for pixel inspection ──────────────────────────
139
211
 
140
212
  function removeClickHandler() {
141
- if (mapRef && clickHandlerRef) {
142
- mapRef.off('click', clickHandlerRef);
143
- clickHandlerRef = null;
213
+ if (detachInspector) {
214
+ detachInspector();
215
+ detachInspector = null;
144
216
  }
145
217
  }
146
218
 
147
219
  function setupClickHandler(map: maplibregl.Map) {
148
220
  removeClickHandler();
149
- clickHandlerRef = async (e: maplibregl.MapMouseEvent) => {
150
- if (!geotiffRef) return;
151
- inspecting = true;
152
- try {
153
- const result = await readPixelAtLngLat(
154
- geotiffRef,
155
- e.lngLat.lng,
156
- e.lngLat.lat,
157
- proj4DefRef,
158
- pool,
159
- abortController.signal
160
- );
221
+ detachInspector = attachPixelInspector<PixelValue>(map, {
222
+ probe: async ({ lng, lat, signal }) => {
223
+ if (!geotiffRef) return null;
224
+ // matches overview shown on screen
225
+ const targetRes = mapResolutionMetersPerPixel(map.getZoom(), lat);
226
+ const overview = selectOverviewForResolution(geotiffRef, targetRes);
227
+ return readPixelAtLngLat(geotiffRef, lng, lat, proj4DefRef, pool, signal, {
228
+ overview
229
+ });
230
+ },
231
+ onStart: () => {
232
+ inspecting = true;
233
+ },
234
+ onResult: (result) => {
161
235
  pixelValue = result;
162
- } catch {
163
- pixelValue = null;
164
- } finally {
165
236
  inspecting = false;
166
237
  }
167
- };
168
- map.on('click', clickHandlerRef);
238
+ });
169
239
  }
170
240
 
171
241
  // ─── Core load function ──────────────────────────────────────────
@@ -177,12 +247,30 @@ async function loadCog(map: maplibregl.Map) {
177
247
  const url = await buildHttpsUrlAsync(tab);
178
248
  if (signal.aborted) return;
179
249
  resolvedHttpsUrl = url;
250
+ resolvedHrefForControls = url;
251
+
252
+ // One-shot storage smoke-test. lazycogs-style probe surfaces auth /
253
+ // CORS / bucket failures at open time as an amber pill, never blocks
254
+ // the layer mount. Aborts via the viewer's existing controller.
255
+ if (!smokeProbed) {
256
+ smokeProbed = true;
257
+ void (async () => {
258
+ try {
259
+ const result = await smokeTestHref(url, signal);
260
+ if (signal.aborted) return;
261
+ if (!result.ok) smokeWarning = result.reason;
262
+ } catch (err) {
263
+ if (err instanceof DOMException && err.name === 'AbortError') return;
264
+ smokeWarning = err instanceof Error ? err.message : String(err);
265
+ }
266
+ })();
267
+ }
180
268
 
181
269
  // Pre-flight: read first IFD to check if tiled (single range request).
182
270
  let isTiled = true;
183
271
  let preflightGeotiff: GeoTIFF | undefined;
184
272
  try {
185
- preflightGeotiff = await GeoTIFF.fromUrl(url);
273
+ preflightGeotiff = await loadGeoTIFF(url);
186
274
  if (signal.aborted) return;
187
275
  isTiled = preflightGeotiff.isTiled;
188
276
 
@@ -231,6 +319,22 @@ async function loadCog(map: maplibregl.Map) {
231
319
 
232
320
  // Set default band config
233
321
  bandConfig = defaultBandConfig(preflightGeotiff.count, sampleFormatRef);
322
+ probedBandCount = preflightGeotiff.count;
323
+
324
+ // Surface GDAL_NODATA + a shader-space rescale seed (when present) so
325
+ // the nodata hint pill and rescale slider have meaningful defaults
326
+ // before the first tile decodes — matches source-cooperative/cog-viewer
327
+ // UX. The slider operates in normalized [0, 1] shader space, so
328
+ // `seedRescaleFromGeotiff` divides GDAL STATISTICS_MIN/MAX by the
329
+ // sample-format factor and falls back to a p2/p98 histogram + the
330
+ // bit-depth-aware default.
331
+ autoNodata = readGdalNodata(preflightGeotiff);
332
+ try {
333
+ rescale = await seedRescaleFromGeotiff(preflightGeotiff, { signal });
334
+ } catch {
335
+ // fall through, defaults remain
336
+ }
337
+ if (signal.aborted) return;
234
338
  }
235
339
 
236
340
  if (!isTiled && preflightGeotiff) {
@@ -265,32 +369,49 @@ async function loadCog(map: maplibregl.Map) {
265
369
 
266
370
  // ─── Build & add COGLayer ────────────────────────────────────────
267
371
 
268
- function buildAndAddLayer(
372
+ // Build the pipeline props (getTileData/renderTile/etc) for the current state.
373
+ // When the configurable CPU path applies, the tile loader is created once per
374
+ // GeoTIFF identity and its `getTileData` reference is reused across rebuilds so
375
+ // deck.gl's TileLayer cache survives band/ramp swaps. Only `renderTile` and
376
+ // downstream uniforms vary across style changes.
377
+ function buildPipelineProps(geotiff: GeoTIFF | undefined): Record<string, unknown> {
378
+ if (!geotiff || !bandConfig) {
379
+ return geotiff ? selectCogPipeline(geotiff, { bandConfig, rescale }) : {};
380
+ }
381
+ if (needsCustomPipelineForConfig(geotiff, bandConfig)) {
382
+ if (!tileLoaderRef) {
383
+ tileLoaderRef = createConfigurableGetTileData(geotiff, bandConfig);
384
+ } else {
385
+ tileLoaderRef.updateConfig(bandConfig);
386
+ }
387
+ return {
388
+ getTileData: tileLoaderRef.getTileData,
389
+ renderTile: buildCustomRenderTile(bandConfig, rescale)
390
+ };
391
+ }
392
+ // Library-default or rescaled-only path. The loader (if previously seeded)
393
+ // is harmless to keep, but the upcoming rebuild won't reference it.
394
+ return selectCogPipeline(geotiff, { bandConfig, rescale });
395
+ }
396
+
397
+ function buildCogLayer(
269
398
  map: maplibregl.Map,
270
399
  preflightGeotiff: GeoTIFF | undefined,
271
400
  signal: AbortSignal
272
- ) {
401
+ ): COGLayer {
273
402
  // Pick the library-default or one of three custom pipelines. Empty when the
274
403
  // library-default uint path runs unchanged.
275
- const customProps = preflightGeotiff
276
- ? selectCogPipeline(preflightGeotiff, {
277
- bandConfig,
278
- rescale,
279
- onHistogram: (bins) => {
280
- // Copy once so the derived UI sees an immutable snapshot
281
- // and the accumulating worker buffer is not observed mid-mutation.
282
- histogram = new Uint32Array(bins);
283
- histogramTick++;
284
- }
285
- })
286
- : {};
404
+ const customProps = buildPipelineProps(preflightGeotiff);
287
405
 
288
406
  // Apply upstream-bug workarounds in place (overview filter, 4326 bbox clamp).
289
407
  if (preflightGeotiff) normalizeCogGeotiff(preflightGeotiff);
290
408
 
291
409
  const cogInput = preflightGeotiff ?? resolvedHttpsUrl ?? '';
292
410
 
293
- const layer = new COGLayer({
411
+ // Cast: `onViewportLoad` is forwarded natively by COGLayer's RasterTileLayer
412
+ // base in 0.7.0 (deck.gl-raster PR #546), but COGLayer's generated .d.ts does
413
+ // not surface it.
414
+ const cogProps: any = {
294
415
  // Stable id per tab so rebuilds on band/style change don't force deck.gl
295
416
  // to treat this as a brand-new layer and drop cached tile state.
296
417
  id: `cog-layer-${tab.id}`,
@@ -299,6 +420,15 @@ function buildAndAddLayer(
299
420
  epsgResolver,
300
421
  signal,
301
422
  ...customProps,
423
+ // COG-native histogram: sum `content.histogram` over tiles currently
424
+ // visible in the viewport. Fires after every pan/zoom settles and
425
+ // reuses deck.gl's tile cache for free, cached tiles still carry
426
+ // their per-tile histogram so no rebake is needed on revisit.
427
+ onViewportLoad: (visibleTiles: unknown) => {
428
+ aggregateVisibleHistogram(
429
+ visibleTiles as ReadonlyArray<{ content?: unknown } | null | undefined>
430
+ );
431
+ },
302
432
  onGeoTIFFLoad: (
303
433
  loadedTiff: GeoTIFF,
304
434
  {
@@ -345,7 +475,19 @@ function buildAndAddLayer(
345
475
  }
346
476
  loading = false;
347
477
  }
348
- });
478
+ };
479
+ return new COGLayer(cogProps);
480
+ }
481
+
482
+ // First-mount: create the MapboxOverlay once and attach via addControl.
483
+ // Subsequent style changes go through pushLayer() which only calls setProps,
484
+ // preserving deck.gl's WebGL context and tile cache.
485
+ function buildAndAddLayer(
486
+ map: maplibregl.Map,
487
+ preflightGeotiff: GeoTIFF | undefined,
488
+ signal: AbortSignal
489
+ ) {
490
+ const layer = buildCogLayer(map, preflightGeotiff, signal);
349
491
 
350
492
  const overlay = new MapboxOverlay({
351
493
  interleaved: false,
@@ -362,41 +504,100 @@ function buildAndAddLayer(
362
504
  map.addControl(overlay as unknown as maplibregl.IControl);
363
505
  }
364
506
 
507
+ // Style-change update path: swap layers in place via setProps. Identity of the
508
+ // COGLayer's `id` and `getTileData` is preserved so deck.gl reconciles the
509
+ // existing layer instance and keeps its tile cache.
510
+ function pushLayer() {
511
+ if (!mapRef || !geotiffRef || !overlayRef) return;
512
+ const layer = buildCogLayer(mapRef, geotiffRef, abortController.signal);
513
+ overlayRef.setProps({ layers: [layer] });
514
+ }
515
+
516
+ // ─── Viewport-scoped histogram aggregation ───────────────────────
517
+
518
+ /**
519
+ * Sum per-tile histograms from tiles currently visible in the viewport. COG
520
+ * pyramid semantics map cleanly: zoomed out → a handful of low-res overview
521
+ * tiles cover the whole scene; zoomed in → only the tiles intersecting the
522
+ * AOI are decoded. deck.gl reuses its tile cache on revisits so each cached
523
+ * tile still carries `content.histogram`, no rebake needed.
524
+ */
525
+ function aggregateVisibleHistogram(
526
+ visibleTiles: ReadonlyArray<{ content?: unknown } | null | undefined>
527
+ ): void {
528
+ if (!visibleTiles || visibleTiles.length === 0) {
529
+ histogram = null;
530
+ histogramTick++;
531
+ return;
532
+ }
533
+ const summed = new Uint32Array(HISTOGRAM_BIN_COUNT);
534
+ let found = false;
535
+ for (const tile of visibleTiles) {
536
+ // COGLayer wraps our baker's return as `{data, forwardTransform,
537
+ // inverseTransform}` in `_getTileData`, so the histogram lives at
538
+ // `content.data.histogram`. MosaicLayer's sub-COGs follow the same
539
+ // shape. Fall back to `content.histogram` for future-proofing if
540
+ // upstream ever stops wrapping.
541
+ const content = tile?.content as
542
+ | { data?: CustomTileData; histogram?: Uint32Array }
543
+ | null
544
+ | undefined;
545
+ const bins = content?.data?.histogram ?? content?.histogram;
546
+ if (!bins || bins.length !== HISTOGRAM_BIN_COUNT) continue;
547
+ for (let i = 0; i < HISTOGRAM_BIN_COUNT; i++) summed[i] += bins[i];
548
+ found = true;
549
+ }
550
+ histogram = found ? summed : null;
551
+ histogramTick++;
552
+ }
553
+
365
554
  // ─── Rebuild layer on band config change ─────────────────────────
366
555
 
367
556
  function handleConfigChange(newConfig: BandConfig) {
368
557
  bandConfig = newConfig;
558
+ // Only the single-band CPU baker emits `onHistogram`. Clear the buffer on
559
+ // every mode/band change so (a) switching back to RGB hides stale bars
560
+ // that the rescale slider would otherwise draw on top of, and (b) picking
561
+ // a different single band starts a fresh distribution.
562
+ histogram = null;
563
+ histogramTick = 0;
369
564
  if (!mapRef || !geotiffRef || !isTiledRef) return;
370
565
 
371
- // Remove old overlay
372
- if (overlayRef) {
373
- try {
374
- mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
375
- } catch {
376
- /* already removed */
377
- }
378
- overlayRef = null;
379
- }
380
-
381
- // Rebuild with new config
382
- buildAndAddLayer(mapRef, geotiffRef, abortController.signal);
566
+ // Swap layers in place: deck.gl diffs on layer id and reuses the stable
567
+ // `getTileData` reference held by `tileLoaderRef`, so the tile cache and
568
+ // in-flight fetches survive band/style changes.
569
+ pushLayer();
383
570
  }
384
571
 
385
572
  function handleRescaleChange(next: RescaleConfig) {
386
573
  rescale = next;
387
574
  if (!mapRef || !geotiffRef || !isTiledRef) return;
575
+ pushLayer();
576
+ }
388
577
 
389
- // Remove old overlay and rebuild. deck.gl diffs on layer id, so reusing the
390
- // stable per-tab id keeps tile cache state where possible.
391
- if (overlayRef) {
392
- try {
393
- mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
394
- } catch {
395
- /* already removed */
396
- }
397
- overlayRef = null;
578
+ // ─── Unified picker change handlers ──────────────────────────────
579
+
580
+ function handleCompositeChange(next: ChannelComposite): void {
581
+ if (!bandConfig) return;
582
+ if (bandConfig.mode === 'rgb') {
583
+ handleConfigChange({
584
+ ...bandConfig,
585
+ rBand: next.r.bandIndex,
586
+ gBand: next.g.bandIndex,
587
+ bBand: next.b.bandIndex
588
+ });
589
+ } else {
590
+ handleConfigChange({ ...bandConfig, band: next.r.bandIndex });
398
591
  }
399
- buildAndAddLayer(mapRef, geotiffRef, abortController.signal);
592
+ }
593
+
594
+ function handleModeChange(m: 'rgb' | 'single'): void {
595
+ if (!bandConfig) return;
596
+ handleConfigChange({ ...bandConfig, mode: m });
597
+ }
598
+
599
+ function handleBandConfigChange(next: BandConfig): void {
600
+ handleConfigChange(next);
400
601
  }
401
602
 
402
603
  // ─── Cleanup ─────────────────────────────────────────────────────
@@ -415,9 +616,12 @@ function cleanup() {
415
616
  mapRef = null;
416
617
  overlayRef = null;
417
618
  geotiffRef = null;
619
+ tileLoaderRef = null;
418
620
  proj4DefRef = null;
419
621
  pixelValue = null;
420
622
  resolvedHttpsUrl = null;
623
+ resolvedHrefForControls = null;
624
+ probedBandCount = null;
421
625
  }
422
626
 
423
627
  $effect(() => {
@@ -434,7 +638,7 @@ onDestroy(cleanup);
434
638
  </div>
435
639
 
436
640
  <!-- Top-left: Loading + metadata badges -->
437
- <div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col gap-1">
641
+ <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">
438
642
  {#if loading}
439
643
  <div
440
644
  class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm"
@@ -462,14 +666,23 @@ onDestroy(cleanup);
462
666
  {error}
463
667
  </div>
464
668
  {/if}
669
+
670
+ {#if smokeWarning && !error}
671
+ <div
672
+ class="pointer-events-auto max-w-sm rounded bg-amber-900/80 px-2 py-1 text-xs text-amber-100"
673
+ title={t('stac.smokeWarningHint')}
674
+ >
675
+ {t('stac.smokeWarning', { reason: smokeWarning })}
676
+ </div>
677
+ {/if}
465
678
  </div>
466
679
 
467
680
  <!-- Top-right: Info + Style buttons -->
468
681
  {#if cogInfo}
469
- <div class="absolute right-2 top-2 z-10 flex gap-1">
682
+ <div class="absolute right-2 top-2 z-10 flex gap-1" style="touch-action: manipulation;">
470
683
  {#if bandConfig}
471
684
  <button
472
- class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
685
+ 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"
473
686
  class:ring-1={showControls}
474
687
  class:ring-primary={showControls}
475
688
  onclick={() => {
@@ -481,7 +694,7 @@ onDestroy(cleanup);
481
694
  </button>
482
695
  {/if}
483
696
  <button
484
- class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
697
+ 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"
485
698
  class:ring-1={showInfo}
486
699
  class:ring-primary={showInfo}
487
700
  onclick={() => {
@@ -493,23 +706,39 @@ onDestroy(cleanup);
493
706
  </button>
494
707
  </div>
495
708
 
496
- <!-- Band/Color controls panel -->
497
- {#if showControls && bandConfig}
498
- <CogControls
499
- bandCount={cogInfo.bandCount}
500
- {bandConfig}
501
- onConfigChange={handleConfigChange}
502
- {rescale}
503
- rescaleApplicable={rescaleApplicable}
504
- onRescaleChange={handleRescaleChange}
505
- {histogram}
506
- />
709
+ <!-- Band/Color controls panel. Kept mounted so slider drag state and focus
710
+ survive every visibility toggle; only the `hidden` class is flipped. -->
711
+ {#if bandConfig}
712
+ <div class={showControls ? 'contents' : 'hidden'}>
713
+ <CogControls
714
+ assets={cogControlsAssets}
715
+ composite={cogControlsComposite}
716
+ onCompositeChange={handleCompositeChange}
717
+ presets={[]}
718
+ activePresetId=""
719
+ onPresetChange={() => {}}
720
+ mode={bandConfig?.mode ?? 'rgb'}
721
+ onModeChange={handleModeChange}
722
+ {bandConfig}
723
+ bandCount={probedBandCount ?? cogInfo.bandCount}
724
+ onBandConfigChange={handleBandConfigChange}
725
+ {rescale}
726
+ rescaleApplicable={rescaleApplicable}
727
+ onRescaleChange={handleRescaleChange}
728
+ {histogram}
729
+ nodata={nodataConfig}
730
+ {autoNodata}
731
+ onNodataChange={(next) => {
732
+ nodataConfig = next;
733
+ }}
734
+ />
735
+ </div>
507
736
  {/if}
508
737
 
509
738
  <!-- Info panel -->
510
739
  {#if showInfo}
511
740
  <div
512
- 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"
741
+ 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"
513
742
  >
514
743
  <h3 class="mb-2 font-medium">{t('map.cogInfo')}</h3>
515
744
  <dl class="space-y-1.5">
@@ -529,45 +758,17 @@ onDestroy(cleanup);
529
758
  {/if}
530
759
 
531
760
  <!-- Bottom-left: Pixel value on click -->
532
- {#if pixelValue}
533
- <div
534
- class="absolute bottom-2 left-2 z-10 rounded bg-card/90 p-2.5 text-xs text-card-foreground backdrop-blur-sm"
535
- >
536
- <div class="mb-1 flex items-center justify-between gap-3">
537
- <span class="font-medium">{t('cog.pixelValue')}</span>
538
- <button
539
- class="text-muted-foreground hover:text-card-foreground"
540
- onclick={() => (pixelValue = null)}
541
- >
542
- &times;
543
- </button>
544
- </div>
545
- <div class="space-y-0.5 text-muted-foreground">
546
- <div>
547
- {pixelValue.lat.toFixed(6)}&deg;, {pixelValue.lng.toFixed(6)}&deg;
548
- </div>
549
- <div class="text-[10px]">
550
- px ({pixelValue.col}, {pixelValue.row})
551
- </div>
552
- </div>
553
- <div class="mt-1.5 space-y-0.5">
554
- {#each pixelValue.values as val, i}
555
- <div class="flex justify-between gap-2">
556
- <span class="text-muted-foreground">{t('cog.band')} {i + 1}</span>
557
- <span class="font-mono tabular-nums">
558
- {Number.isInteger(val) ? val : val.toFixed(4)}
559
- </span>
560
- </div>
561
- {/each}
562
- </div>
563
- </div>
564
- {/if}
565
-
566
- {#if inspecting}
567
- <div
568
- 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"
569
- >
570
- {t('cog.reading')}
571
- </div>
572
- {/if}
761
+ <PixelInspectorPanel
762
+ lng={pixelValue?.lng ?? null}
763
+ lat={pixelValue?.lat ?? null}
764
+ rows={pixelValue
765
+ ? (pixelValue.values.map((v, i) => ({
766
+ label: `${t('cog.band')} ${i + 1}`,
767
+ value: v
768
+ })) satisfies PixelInspectorRow[])
769
+ : null}
770
+ footnote={pixelValue ? `px (${pixelValue.col}, ${pixelValue.row})` : undefined}
771
+ onClose={() => (pixelValue = null)}
772
+ {inspecting}
773
+ />
573
774
  </div>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Tab } from '../../types';
3
- import { buildHttpsUrlAsync } from '../../utils/url.js';
3
+ import { buildHttpsUrlAsync } from '../../utils/signed-url.js';
4
4
 
5
5
  let { tab }: { tab: Tab } = $props();
6
6
 
@@ -19,7 +19,7 @@ import {
19
19
  hoverCursor,
20
20
  loadDeckModules
21
21
  } from '../../utils/deck.js';
22
- import { buildHttpsUrlAsync } from '../../utils/url.js';
22
+ import { buildHttpsUrlAsync } from '../../utils/signed-url.js';
23
23
  import AttributeTable from './map/AttributeTable.svelte';
24
24
  import MapContainer from './map/MapContainer.svelte';
25
25