@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,9 +1,18 @@
1
1
  <script lang="ts">
2
+ import type {
3
+ ChannelComposite,
4
+ ChannelRef,
5
+ CogAsset,
6
+ PresetDef
7
+ } from '@walkthru-earth/objex-utils';
2
8
  import { t } from '../../i18n/index.svelte.js';
3
9
  import {
4
10
  type BandConfig,
5
11
  type ColorRampId,
12
+ DEFAULT_NODATA_CONFIG,
6
13
  DEFAULT_RESCALE,
14
+ type NodataConfig,
15
+ type NodataMode,
7
16
  type RescaleConfig
8
17
  } from '../../utils/cog.js';
9
18
  import {
@@ -12,33 +21,46 @@ import {
12
21
  COLORMAP_SPRITE_LAYERS,
13
22
  COLORMAP_SPRITE_URL
14
23
  } from '../../utils/colormap-sprite.js';
24
+ import { RangeSlider } from '../ui/slider/index.js';
25
+ import ChannelPicker from './cog/ChannelPicker.svelte';
15
26
 
16
- let {
17
- bandCount,
18
- bandConfig,
19
- onConfigChange,
20
- rescale,
21
- rescaleApplicable,
22
- onRescaleChange,
23
- histogram = null,
24
- mode = 'single'
25
- }: {
26
- bandCount: number;
27
- /** Required when `mode === 'single'`, ignored when `mode === 'multi'`. */
28
- bandConfig?: BandConfig;
29
- onConfigChange: (config: BandConfig) => void;
27
+ type Props = {
28
+ /** All raster-COG-ish assets on the current item (or `[selfAsset]` for plain CogViewer). */
29
+ assets: CogAsset[];
30
+ /** Current RGB composite. Always present. */
31
+ composite: ChannelComposite;
32
+ onCompositeChange: (next: ChannelComposite) => void;
33
+ /** Presets that resolve on this item. Empty when no preset applies. */
34
+ presets: PresetDef[];
35
+ activePresetId: string;
36
+ onPresetChange: (id: string) => void;
37
+ /** Rendering mode toggle: 'rgb' uses the channel pickers; 'single' the band+ramp picker. */
38
+ mode: 'rgb' | 'single';
39
+ onModeChange: (m: 'rgb' | 'single') => void;
40
+ /** Band/ramp config used when mode === 'single'. Optional for RGB-only callers. */
41
+ bandConfig?: BandConfig | null;
42
+ bandCount?: number;
43
+ onBandConfigChange?: (next: BandConfig) => void;
30
44
  rescale: RescaleConfig;
31
45
  rescaleApplicable: boolean;
32
- onRescaleChange: (rescale: RescaleConfig) => void;
33
- /** Optional histogram bins (normalized, single-band only) for the slider overlay. */
46
+ onRescaleChange: (next: RescaleConfig) => void;
34
47
  histogram?: Uint32Array | null;
35
- mode?: 'single' | 'multi';
36
- } = $props();
48
+ /** Optional 4th channel UI affordance (alpha). When false, alpha row is hidden. */
49
+ showAlpha?: boolean;
50
+ /** User-selected nodata config. Default `{ mode: 'auto' }`. */
51
+ nodata?: NodataConfig;
52
+ /**
53
+ * Value resolved by the viewer for Auto mode (typically the GeoTIFF's
54
+ * GDAL_NODATA tag). Surfaced as a hint pill next to the segmented control.
55
+ * `null` means the file has no GDAL_NODATA tag.
56
+ */
57
+ autoNodata?: number | null;
58
+ /** Fired when the user changes nodata mode or value. */
59
+ onNodataChange?: (next: NodataConfig) => void;
60
+ };
61
+
62
+ const props: Props = $props();
37
63
 
38
- // ─── Ramp picker state ──────────────────────────────────────────
39
- // Keep a curated set pinned at the top for familiarity; the full set of
40
- // 107 is searchable underneath. Pinned names match the old UI exactly so
41
- // existing muscle memory holds.
42
64
  const PINNED_RAMPS: ColorRampId[] = [
43
65
  'gray',
44
66
  'terrain',
@@ -60,36 +82,36 @@ const filteredRamps = $derived.by(() => {
60
82
  return COLORMAP_NAMES.filter((name) => name.toLowerCase().includes(q));
61
83
  });
62
84
 
63
- // ─── Helpers ────────────────────────────────────────────────────
85
+ function setChannel(channel: 'r' | 'g' | 'b' | 'a', next: ChannelRef): void {
86
+ if (channel === 'a') {
87
+ const c = { ...props.composite, a: next.assetKey ? next : undefined };
88
+ props.onCompositeChange(c);
89
+ return;
90
+ }
91
+ props.onCompositeChange({ ...props.composite, [channel]: next });
92
+ }
64
93
 
65
- function bandOptions(count: number): { value: number; label: string }[] {
66
- return Array.from({ length: count }, (_, i) => ({
67
- value: i,
68
- label: `${t('cog.band')} ${i + 1}`
69
- }));
94
+ function setMode(m: 'rgb' | 'single'): void {
95
+ props.onModeChange(m);
70
96
  }
71
97
 
72
- function setMode(mode: 'rgb' | 'single') {
73
- if (!bandConfig) return;
74
- onConfigChange({ ...bandConfig, mode });
98
+ function setBand(value: number): void {
99
+ if (!props.bandConfig || !props.onBandConfigChange) return;
100
+ props.onBandConfigChange({ ...props.bandConfig, band: value });
75
101
  }
76
102
 
77
- function setBand(key: 'rBand' | 'gBand' | 'bBand' | 'band', value: number) {
78
- if (!bandConfig) return;
79
- onConfigChange({ ...bandConfig, [key]: value });
103
+ function setRamp(id: ColorRampId): void {
104
+ if (!props.bandConfig || !props.onBandConfigChange) return;
105
+ props.onBandConfigChange({ ...props.bandConfig, colorRamp: id });
80
106
  }
81
107
 
82
- function setRamp(id: ColorRampId) {
83
- if (!bandConfig) return;
84
- onConfigChange({ ...bandConfig, colorRamp: id });
108
+ function bandOptions(count: number): { value: number; label: string }[] {
109
+ return Array.from({ length: count }, (_, i) => ({
110
+ value: i,
111
+ label: `${t('cog.band')} ${i + 1}`
112
+ }));
85
113
  }
86
114
 
87
- /**
88
- * CSS `background` declaration that renders one sprite row at the
89
- * container's full height. Sprite is 256 wide × 107 tall (one 1px row per
90
- * ramp); we scale it vertically by the target height and offset to land on
91
- * the requested layer.
92
- */
93
115
  function rampBg(name: ColorRampId, heightPx: number): string {
94
116
  const index = COLORMAP_INDEX[name];
95
117
  if (index === undefined) return '';
@@ -103,104 +125,204 @@ function rampBg(name: ColorRampId, heightPx: number): string {
103
125
  ].join('; ');
104
126
  }
105
127
 
106
- // ─── Rescale / histogram ────────────────────────────────────────
107
-
108
128
  function clamp01(v: number): number {
109
129
  return Math.max(0, Math.min(1, v));
110
130
  }
111
131
 
112
- function setRescaleMin(value: number) {
132
+ function setRescaleMin(value: number): void {
113
133
  const clamped = clamp01(value);
114
- const next = Math.min(clamped, rescale.max - 0.001);
115
- onRescaleChange({ min: Number.isFinite(next) ? next : 0, max: rescale.max });
134
+ const next = Math.min(clamped, props.rescale.max - 0.001);
135
+ props.onRescaleChange({ min: Number.isFinite(next) ? next : 0, max: props.rescale.max });
116
136
  }
117
137
 
118
- function setRescaleMax(value: number) {
138
+ function setRescaleMax(value: number): void {
119
139
  const clamped = clamp01(value);
120
- const next = Math.max(clamped, rescale.min + 0.001);
121
- onRescaleChange({ min: rescale.min, max: Number.isFinite(next) ? next : 1 });
140
+ const next = Math.max(clamped, props.rescale.min + 0.001);
141
+ props.onRescaleChange({ min: props.rescale.min, max: Number.isFinite(next) ? next : 1 });
142
+ }
143
+
144
+ function setRescaleRange(next: [number, number]): void {
145
+ const lo = clamp01(next[0]);
146
+ const hi = clamp01(next[1]);
147
+ props.onRescaleChange({ min: Math.min(lo, hi), max: Math.max(lo, hi) });
122
148
  }
123
149
 
124
- function resetRescale() {
125
- onRescaleChange({ ...DEFAULT_RESCALE });
150
+ function resetRescale(): void {
151
+ props.onRescaleChange({ ...DEFAULT_RESCALE });
152
+ }
153
+
154
+ function fmtRescale(n: number): string {
155
+ return n.toFixed(2);
156
+ }
157
+
158
+ const nodataCfg = $derived(props.nodata ?? DEFAULT_NODATA_CONFIG);
159
+
160
+ function fmtAutoNodata(v: number | null | undefined): string {
161
+ if (v === null || v === undefined) return '';
162
+ if (Number.isNaN(v)) return 'NaN';
163
+ return String(v);
126
164
  }
127
165
 
166
+ function setNodataMode(mode: NodataMode): void {
167
+ if (!props.onNodataChange) return;
168
+ if (mode === nodataCfg.mode) return;
169
+ if (mode === 'value') {
170
+ const seed =
171
+ typeof nodataCfg.value === 'number'
172
+ ? nodataCfg.value
173
+ : typeof props.autoNodata === 'number' && Number.isFinite(props.autoNodata)
174
+ ? props.autoNodata
175
+ : 0;
176
+ props.onNodataChange({ mode: 'value', value: seed });
177
+ return;
178
+ }
179
+ props.onNodataChange({ mode });
180
+ }
181
+
182
+ function setNodataValue(raw: string): void {
183
+ if (!props.onNodataChange) return;
184
+ const trimmed = raw.trim().toLowerCase();
185
+ if (trimmed === 'nan') {
186
+ props.onNodataChange({ mode: 'value', value: Number.NaN });
187
+ return;
188
+ }
189
+ const parsed = Number(raw);
190
+ if (!Number.isFinite(parsed) && !Number.isNaN(parsed)) return;
191
+ props.onNodataChange({ mode: 'value', value: parsed });
192
+ }
193
+
194
+ // B4: Track viewport width to downsample the histogram on narrow phones.
195
+ // We only flip when crossing the `sm` breakpoint (640px) so the $derived
196
+ // below only re-runs on rotate/resize-across-threshold, not on every px.
197
+ let narrowViewport = $state(false);
198
+
199
+ $effect(() => {
200
+ if (typeof window === 'undefined') return;
201
+ const compute = () => {
202
+ narrowViewport = window.innerWidth < 640;
203
+ };
204
+ compute();
205
+ window.addEventListener('resize', compute);
206
+ return () => window.removeEventListener('resize', compute);
207
+ });
208
+
209
+ // Fold 128-bin histogram down to 64 on narrow viewports so each bar gets
210
+ // at least ~2px of width inside the slimmed-down panel.
211
+ const effectiveHistogram = $derived.by<Uint32Array | null | undefined>(() => {
212
+ const h = props.histogram;
213
+ if (!h) return h;
214
+ if (!narrowViewport || h.length !== 128) return h;
215
+ const out = new Uint32Array(64);
216
+ for (let i = 0; i < 64; i++) out[i] = h[2 * i] + h[2 * i + 1];
217
+ return out;
218
+ });
219
+
128
220
  const histogramBars = $derived.by(() => {
129
- if (!histogram || histogram.length === 0) return null;
221
+ const h = effectiveHistogram;
222
+ if (!h || h.length === 0) return null;
130
223
  let max = 0;
131
- for (const v of histogram) if (v > max) max = v;
224
+ for (const v of h) if (v > max) max = v;
132
225
  if (max === 0) return null;
133
- const bins = Array.from(histogram, (count) => count / max);
134
- return bins;
226
+ return Array.from(h, (count) => count / max);
135
227
  });
136
228
  </script>
137
229
 
138
230
  <div
139
- class="absolute right-2 top-10 z-10 w-60 rounded bg-card/90 p-2.5 text-xs text-card-foreground backdrop-blur-sm"
231
+ class="absolute right-2 top-10 z-10 w-[min(18rem,calc(100vw-1rem))] max-h-[calc(100vh-6rem)] overflow-y-auto rounded bg-card/90 p-2.5 text-xs text-card-foreground backdrop-blur-sm sm:w-72"
140
232
  >
141
- {#if mode === 'single' && bandConfig}
142
- <!-- Mode toggle -->
143
- <div class="mb-2 flex gap-1">
144
- <button
145
- class="flex-1 rounded px-2 py-1 transition-colors"
146
- class:bg-primary={bandConfig.mode === 'rgb'}
147
- class:text-primary-foreground={bandConfig.mode === 'rgb'}
148
- class:bg-muted={bandConfig.mode !== 'rgb'}
149
- onclick={() => setMode('rgb')}
150
- >
151
- RGB
152
- </button>
153
- <button
154
- class="flex-1 rounded px-2 py-1 transition-colors"
155
- class:bg-primary={bandConfig.mode === 'single'}
156
- class:text-primary-foreground={bandConfig.mode === 'single'}
157
- class:bg-muted={bandConfig.mode !== 'single'}
158
- onclick={() => setMode('single')}
159
- >
160
- {t('cog.singleBand')}
161
- </button>
162
- </div>
163
-
164
- {#if bandConfig.mode === 'rgb'}
165
- <!-- RGB band selectors -->
233
+ {#if props.presets.length > 0 && props.mode === 'rgb'}
234
+ <div class="mb-2 flex items-center gap-2">
235
+ <span class="text-muted-foreground">{t('map.multiCogPreset.label')}</span>
236
+ <select
237
+ class="flex-1 rounded border border-border bg-background px-1.5 py-0.5 text-xs"
238
+ value={props.activePresetId}
239
+ onchange={(e) => props.onPresetChange((e.target as HTMLSelectElement).value)}
240
+ >
241
+ {#if !props.activePresetId}
242
+ <option value="">{t('map.multiCogPreset.custom')}</option>
243
+ {/if}
244
+ {#each props.presets as p (p.id)}
245
+ <option value={p.id}>{t(p.labelKey)}</option>
246
+ {/each}
247
+ </select>
248
+ </div>
249
+ {/if}
250
+
251
+ {#if props.bandConfig && props.onBandConfigChange}
252
+ <div class="mb-2 flex gap-1">
253
+ <button
254
+ class="flex-1 rounded px-2 py-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
255
+ class:bg-primary={props.mode === 'rgb'}
256
+ class:text-primary-foreground={props.mode === 'rgb'}
257
+ class:bg-muted={props.mode !== 'rgb'}
258
+ onclick={() => setMode('rgb')}
259
+ >
260
+ RGB
261
+ </button>
262
+ <button
263
+ class="flex-1 rounded px-2 py-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
264
+ class:bg-primary={props.mode === 'single'}
265
+ class:text-primary-foreground={props.mode === 'single'}
266
+ class:bg-muted={props.mode !== 'single'}
267
+ onclick={() => setMode('single')}
268
+ >
269
+ {t('cog.singleBand')}
270
+ </button>
271
+ </div>
272
+ {/if}
273
+
274
+ {#if props.mode === 'rgb'}
166
275
  <div class="space-y-1">
167
- {#each [
168
- { key: 'rBand' as const, label: 'R', color: 'text-red-400' },
169
- { key: 'gBand' as const, label: 'G', color: 'text-green-400' },
170
- { key: 'bBand' as const, label: 'B', color: 'text-blue-400' }
171
- ] as ch}
172
- <div class="flex items-center gap-2">
173
- <span class="w-3 font-bold {ch.color}">{ch.label}</span>
174
- <select
175
- class="flex-1 rounded border border-border bg-background px-1.5 py-0.5 text-xs"
176
- value={bandConfig[ch.key]}
177
- onchange={(e) =>
178
- setBand(ch.key, Number((e.target as HTMLSelectElement).value))}
179
- >
180
- {#each bandOptions(bandCount) as opt}
181
- <option value={opt.value}>{opt.label}</option>
182
- {/each}
183
- </select>
184
- </div>
185
- {/each}
276
+ <ChannelPicker
277
+ channel="r"
278
+ label="R"
279
+ colorClass="text-red-400"
280
+ assets={props.assets}
281
+ value={props.composite.r}
282
+ onChange={(next) => setChannel('r', next)}
283
+ />
284
+ <ChannelPicker
285
+ channel="g"
286
+ label="G"
287
+ colorClass="text-green-400"
288
+ assets={props.assets}
289
+ value={props.composite.g}
290
+ onChange={(next) => setChannel('g', next)}
291
+ />
292
+ <ChannelPicker
293
+ channel="b"
294
+ label="B"
295
+ colorClass="text-blue-400"
296
+ assets={props.assets}
297
+ value={props.composite.b}
298
+ onChange={(next) => setChannel('b', next)}
299
+ />
300
+ {#if props.showAlpha}
301
+ <ChannelPicker
302
+ channel="a"
303
+ label="A"
304
+ colorClass="text-muted-foreground"
305
+ assets={props.assets}
306
+ value={props.composite.a ?? { assetKey: '', bandIndex: 0 }}
307
+ onChange={(next) => setChannel('a', next)}
308
+ allowNone
309
+ />
310
+ {/if}
186
311
  </div>
187
- {:else}
188
- <!-- Single band selector -->
312
+ {:else if props.bandConfig && typeof props.bandCount === 'number'}
189
313
  <div class="mb-2 flex items-center gap-2">
190
314
  <span class="text-muted-foreground">{t('cog.band')}</span>
191
315
  <select
192
316
  class="flex-1 rounded border border-border bg-background px-1.5 py-0.5 text-xs"
193
- value={bandConfig.band}
194
- onchange={(e) =>
195
- setBand('band', Number((e.target as HTMLSelectElement).value))}
317
+ value={props.bandConfig.band}
318
+ onchange={(e) => setBand(Number((e.target as HTMLSelectElement).value))}
196
319
  >
197
- {#each bandOptions(bandCount) as opt}
320
+ {#each bandOptions(props.bandCount) as opt (opt.value)}
198
321
  <option value={opt.value}>{opt.label}</option>
199
322
  {/each}
200
323
  </select>
201
324
  </div>
202
325
 
203
- <!-- Color ramp picker -->
204
326
  <div class="space-y-1">
205
327
  <div class="flex items-center justify-between">
206
328
  <span class="text-muted-foreground">{t('cog.colorRamp')}</span>
@@ -209,12 +331,11 @@ const histogramBars = $derived.by(() => {
209
331
  </span>
210
332
  </div>
211
333
 
212
- <!-- Pinned quick-access (only when no search active) -->
213
334
  {#if !rampQuery}
214
335
  <div class="grid grid-cols-2 gap-1">
215
- {#each PINNED_RAMPS as id}
336
+ {#each PINNED_RAMPS as id (id)}
216
337
  <button
217
- class="flex flex-col items-stretch rounded border px-1 py-0.5 transition-colors {bandConfig.colorRamp === id ? 'border-primary bg-muted' : 'border-transparent hover:border-border'}"
338
+ class="flex flex-col items-stretch rounded border px-1 py-0.5 transition-colors {props.bandConfig.colorRamp === id ? 'border-primary bg-muted' : 'border-transparent hover:border-border'}"
218
339
  onclick={() => setRamp(id)}
219
340
  title={id}
220
341
  >
@@ -227,7 +348,6 @@ const histogramBars = $derived.by(() => {
227
348
  </div>
228
349
  {/if}
229
350
 
230
- <!-- Search + all-ramps scroll list -->
231
351
  <input
232
352
  type="search"
233
353
  placeholder={t('cog.colorRampSearch')}
@@ -236,9 +356,9 @@ const histogramBars = $derived.by(() => {
236
356
  oninput={(e) => (rampQuery = (e.target as HTMLInputElement).value)}
237
357
  />
238
358
  <div class="max-h-40 overflow-y-auto rounded border border-border">
239
- {#each filteredRamps as id}
359
+ {#each filteredRamps as id (id)}
240
360
  <button
241
- class="flex w-full items-center gap-2 px-1.5 py-0.5 text-left text-[11px] transition-colors {bandConfig.colorRamp === id ? 'bg-muted' : 'hover:bg-muted/60'}"
361
+ class="flex w-full items-center gap-2 px-1.5 py-0.5 text-left text-[11px] transition-colors {props.bandConfig.colorRamp === id ? 'bg-muted' : 'hover:bg-muted/60'}"
242
362
  onclick={() => setRamp(id)}
243
363
  title={id}
244
364
  >
@@ -249,89 +369,123 @@ const histogramBars = $derived.by(() => {
249
369
  </div>
250
370
  </div>
251
371
  {/if}
252
- {/if}
253
372
 
254
- {#if rescaleApplicable}
255
- <!-- GPU LinearRescale slider with histogram overlay. -->
373
+ {#if props.rescaleApplicable}
256
374
  <div class="mt-2 space-y-1 border-t border-border pt-2">
257
375
  <div class="flex items-center justify-between">
258
- <span class="text-muted-foreground">{t('cog.rescale')}</span>
376
+ <span class="text-muted-foreground">{t('cog.rescale.label')}</span>
259
377
  <button
260
- class="text-[10px] text-muted-foreground hover:text-card-foreground"
378
+ class="rounded text-[10px] text-muted-foreground hover:text-card-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
261
379
  onclick={resetRescale}
262
380
  >
263
- {t('cog.rescaleReset')}
381
+ {t('cog.rescale.reset')}
264
382
  </button>
265
383
  </div>
266
384
 
267
- <!-- Histogram + range visualization -->
268
- {#if histogramBars}
269
- <div class="relative h-8 w-full rounded bg-background/60">
270
- <!-- Histogram bars -->
271
- <svg
272
- viewBox="0 0 100 100"
273
- preserveAspectRatio="none"
274
- class="absolute inset-0 h-full w-full"
275
- aria-hidden="true"
276
- >
277
- {#each histogramBars as h, i}
278
- <rect
279
- x={(i * 100) / histogramBars.length}
280
- y={100 - h * 100}
281
- width={100 / histogramBars.length}
282
- height={h * 100}
283
- class="fill-primary/40"
284
- />
285
- {/each}
286
- </svg>
287
- <!-- Active rescale window -->
288
- <div
289
- class="pointer-events-none absolute inset-y-0 border-x border-primary bg-primary/10"
290
- style="left: {rescale.min * 100}%; right: {(1 - rescale.max) * 100}%;"
291
- ></div>
292
- </div>
293
- {/if}
385
+ <RangeSlider
386
+ min={0}
387
+ max={1}
388
+ step={0.01}
389
+ value={[props.rescale.min, props.rescale.max]}
390
+ histogram={histogramBars}
391
+ formatLabel={fmtRescale}
392
+ onValueChange={setRescaleRange}
393
+ />
294
394
 
295
395
  <div class="flex items-center gap-1.5">
296
- <input
297
- type="number"
298
- min="0"
299
- max="1"
300
- step="0.01"
301
- class="w-14 rounded border border-border bg-background px-1 py-0.5 text-[11px] tabular-nums"
302
- value={rescale.min}
303
- oninput={(e) => setRescaleMin(Number((e.target as HTMLInputElement).value))}
304
- />
305
- <input
306
- type="range"
307
- min="0"
308
- max="1"
309
- step="0.01"
310
- class="flex-1 accent-primary"
311
- value={rescale.min}
312
- oninput={(e) => setRescaleMin(Number((e.target as HTMLInputElement).value))}
313
- />
396
+ <label class="flex flex-1 items-center gap-1 text-[10px] text-muted-foreground">
397
+ <span class="w-6">min</span>
398
+ <input
399
+ type="number"
400
+ inputmode="decimal"
401
+ min="0"
402
+ max="1"
403
+ step="0.01"
404
+ class="min-h-11 w-full rounded border border-border bg-background px-2 py-1.5 text-sm tabular-nums sm:min-h-0 sm:px-1 sm:py-0.5 sm:text-[11px]"
405
+ value={props.rescale.min}
406
+ oninput={(e) => setRescaleMin(Number((e.target as HTMLInputElement).value))}
407
+ />
408
+ </label>
409
+ <label class="flex flex-1 items-center gap-1 text-[10px] text-muted-foreground">
410
+ <span class="w-6">max</span>
411
+ <input
412
+ type="number"
413
+ inputmode="decimal"
414
+ min="0"
415
+ max="1"
416
+ step="0.01"
417
+ class="min-h-11 w-full rounded border border-border bg-background px-2 py-1.5 text-sm tabular-nums sm:min-h-0 sm:px-1 sm:py-0.5 sm:text-[11px]"
418
+ value={props.rescale.max}
419
+ oninput={(e) => setRescaleMax(Number((e.target as HTMLInputElement).value))}
420
+ />
421
+ </label>
314
422
  </div>
315
- <div class="flex items-center gap-1.5">
316
- <input
317
- type="number"
318
- min="0"
319
- max="1"
320
- step="0.01"
321
- class="w-14 rounded border border-border bg-background px-1 py-0.5 text-[11px] tabular-nums"
322
- value={rescale.max}
323
- oninput={(e) => setRescaleMax(Number((e.target as HTMLInputElement).value))}
324
- />
423
+ </div>
424
+ {/if}
425
+
426
+ {#if props.onNodataChange}
427
+ <div class="mt-2 space-y-1 border-t border-border pt-2">
428
+ <div class="flex items-center justify-between">
429
+ <span class="text-muted-foreground">{t('cog.nodata.label')}</span>
430
+ {#if nodataCfg.mode === 'auto'}
431
+ <span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
432
+ {props.autoNodata === null || props.autoNodata === undefined
433
+ ? t('cog.nodata.autoNone')
434
+ : t('cog.nodata.autoHint', { value: fmtAutoNodata(props.autoNodata) })}
435
+ </span>
436
+ {/if}
437
+ </div>
438
+
439
+ <div
440
+ class="flex w-full gap-1"
441
+ role="radiogroup"
442
+ aria-label={t('cog.nodata.label')}
443
+ tabindex={-1}
444
+ onkeydown={(e) => {
445
+ const modes = ['auto', 'value', 'off'] as const;
446
+ const i = modes.indexOf(nodataCfg.mode);
447
+ let next: NodataMode | null = null;
448
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = modes[(i + 1) % 3];
449
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = modes[(i + 2) % 3];
450
+ else if (e.key === 'Home') next = modes[0];
451
+ else if (e.key === 'End') next = modes[2];
452
+ if (next) {
453
+ e.preventDefault();
454
+ setNodataMode(next);
455
+ const buttons = (e.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>(
456
+ 'button[role="radio"]'
457
+ );
458
+ buttons[modes.indexOf(next)]?.focus();
459
+ }
460
+ }}
461
+ >
462
+ {#each ['auto', 'value', 'off'] as const as mode (mode)}
463
+ <button
464
+ type="button"
465
+ role="radio"
466
+ aria-checked={nodataCfg.mode === mode}
467
+ tabindex={nodataCfg.mode === mode ? 0 : -1}
468
+ class="min-h-11 flex-1 rounded px-2 py-1 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 sm:min-h-0 sm:py-1.5"
469
+ class:bg-primary={nodataCfg.mode === mode}
470
+ class:text-primary-foreground={nodataCfg.mode === mode}
471
+ class:bg-muted={nodataCfg.mode !== mode}
472
+ onclick={() => setNodataMode(mode)}
473
+ >
474
+ {t(`cog.nodata.${mode}`)}
475
+ </button>
476
+ {/each}
477
+ </div>
478
+
479
+ {#if nodataCfg.mode === 'value'}
325
480
  <input
326
- type="range"
327
- min="0"
328
- max="1"
329
- step="0.01"
330
- class="flex-1 accent-primary"
331
- value={rescale.max}
332
- oninput={(e) => setRescaleMax(Number((e.target as HTMLInputElement).value))}
481
+ type="text"
482
+ inputmode="decimal"
483
+ placeholder={t('cog.nodata.valuePlaceholder')}
484
+ class="min-h-11 w-full rounded border border-border bg-background px-2 py-1.5 text-sm tabular-nums sm:min-h-0 sm:px-1.5 sm:py-1 sm:text-[11px]"
485
+ value={Number.isNaN(nodataCfg.value as number) ? 'NaN' : (nodataCfg.value ?? '')}
486
+ oninput={(e) => setNodataValue((e.target as HTMLInputElement).value)}
333
487
  />
334
- </div>
488
+ {/if}
335
489
  </div>
336
490
  {/if}
337
491
  </div>