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