@walkthru-earth/objex 1.2.1 → 1.3.1
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.
- package/README.md +6 -3
- package/dist/components/layout/ConnectionDialog.svelte +35 -3
- package/dist/components/layout/Sidebar.svelte +1 -2
- package/dist/components/viewers/CodeViewer.svelte +51 -14
- package/dist/components/viewers/CodeViewer.svelte.d.ts +11 -1
- package/dist/components/viewers/CogControls.svelte +151 -22
- package/dist/components/viewers/CogControls.svelte.d.ts +5 -1
- package/dist/components/viewers/CogViewer.svelte +75 -8
- package/dist/components/viewers/MultiCogViewer.svelte +416 -0
- package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
- package/dist/components/viewers/StacMapViewer.svelte +19 -5
- package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
- package/dist/components/viewers/StacMosaicViewer.svelte +785 -0
- package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +9 -0
- package/dist/components/viewers/StacTabViewer.svelte +254 -0
- package/dist/components/viewers/StacTabViewer.svelte.d.ts +13 -0
- package/dist/components/viewers/ViewerRouter.svelte +155 -2
- package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
- package/dist/components/viewers/ZarrMapViewer.svelte +143 -4
- package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
- package/dist/components/viewers/ZarrViewer.svelte +1 -0
- package/dist/i18n/ar.js +27 -0
- package/dist/i18n/en.js +27 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/query/stac-geoparquet.d.ts +31 -0
- package/dist/query/stac-geoparquet.js +136 -0
- package/dist/stores/connections.svelte.d.ts +38 -23
- package/dist/stores/connections.svelte.js +105 -114
- package/dist/utils/cog-pure.d.ts +25 -0
- package/dist/utils/cog-pure.js +35 -0
- package/dist/utils/cog.d.ts +88 -43
- package/dist/utils/cog.js +192 -152
- package/dist/utils/colormap-sprite.d.ts +39 -0
- package/dist/utils/colormap-sprite.js +77 -0
- package/dist/utils/connection-identity.d.ts +51 -0
- package/dist/utils/connection-identity.js +97 -0
- package/dist/utils/host-detection.js +48 -302
- package/dist/utils/parquet-metadata.d.ts +7 -1
- package/dist/utils/parquet-metadata.js +35 -1
- package/dist/utils/stac-geoparquet.d.ts +90 -0
- package/dist/utils/stac-geoparquet.js +223 -0
- package/dist/utils/stac-hydrate.d.ts +38 -0
- package/dist/utils/stac-hydrate.js +243 -0
- package/dist/utils/stac.d.ts +136 -0
- package/dist/utils/stac.js +176 -0
- package/dist/utils/storage-url.d.ts +26 -0
- package/dist/utils/storage-url.js +164 -28
- package/dist/utils/zarr.d.ts +34 -0
- package/dist/utils/zarr.js +94 -0
- package/package.json +14 -13
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ graph LR
|
|
|
20
20
|
|
|
21
21
|
- **Browse** cloud storage (S3, GCS, Azure, R2, B2, DigitalOcean, Wasabi, Storj, Hetzner, Contabo, Linode, OVHcloud, MinIO, direct URLs)
|
|
22
22
|
- **Query** Parquet, CSV, JSONL with SQL (DuckDB-WASM, cancellable queries)
|
|
23
|
-
- **Visualize** GeoParquet, GeoJSON, COG, PMTiles, FlatGeobuf, Zarr on maps (MapLibre + deck.gl)
|
|
23
|
+
- **Visualize** GeoParquet, GeoJSON, COG, PMTiles, FlatGeobuf, Zarr (incl. GeoZarr), STAC catalogs, and stac-geoparquet on maps (MapLibre + deck.gl)
|
|
24
24
|
- **View** 100+ file formats: code (30+ languages), Jupyter notebooks, PDF, 3D models, archives, media
|
|
25
25
|
- **Share** via URL -- `?url=<storage-url>#<view>` encodes full viewer state
|
|
26
26
|
- **i18n** -- English + Arabic with automatic RTL layout
|
|
@@ -32,7 +32,8 @@ graph LR
|
|
|
32
32
|
|----------|---------|
|
|
33
33
|
| Tabular | Parquet, CSV, TSV, JSONL, NDJSON |
|
|
34
34
|
| Geo vector | GeoParquet, GeoJSON, Shapefile, GeoPackage, FlatGeobuf |
|
|
35
|
-
| Geo raster | COG, PMTiles, Zarr v2/v3 |
|
|
35
|
+
| Geo raster | COG, PMTiles, Zarr v2/v3, GeoZarr |
|
|
36
|
+
| Geo catalog | STAC Item / Collection / Catalog / FeatureCollection (JSON), stac-geoparquet |
|
|
36
37
|
| Point cloud | COPC, LAZ, LAS |
|
|
37
38
|
| Notebooks | Jupyter (.ipynb), marimo |
|
|
38
39
|
| Code | 30+ languages (Python, TS, Rust, Go, SQL...) |
|
|
@@ -79,6 +80,8 @@ import {
|
|
|
79
80
|
parseWKB,
|
|
80
81
|
buildGeoArrowTables,
|
|
81
82
|
readParquetMetadata,
|
|
83
|
+
isStacGeoparquetSchema,
|
|
84
|
+
stacRowToItem,
|
|
82
85
|
getFileTypeInfo,
|
|
83
86
|
formatFileSize,
|
|
84
87
|
generateHexDump,
|
|
@@ -105,7 +108,7 @@ Full per-module reference docs: [`packages/objex-utils/docs/`](packages/objex-ut
|
|
|
105
108
|
| `./file-icons` | `getFileTypeInfo`, `getDuckDbReadFn`, `getViewerKind` |
|
|
106
109
|
| `./types` | `FileEntry`, `Connection`, `Tab`, `WriteResult`, `Theme` |
|
|
107
110
|
|
|
108
|
-
The main export also includes `copyToClipboard`, `handleLoadError`, and shared constants (`WGS84_CODES`, `STORAGE_KEYS`, `DEFAULT_TARGET_CRS`, etc.).
|
|
111
|
+
The main export also includes `copyToClipboard`, `handleLoadError`, the stac-geoparquet helpers (`isStacGeoparquetSchema`, `stacRowToItem`, `flattenStacBbox`, `pickStacPrimaryAsset`, `resolveStacAssetHref`), and shared constants (`WGS84_CODES`, `STORAGE_KEYS`, `DEFAULT_TARGET_CRS`, etc.).
|
|
109
112
|
|
|
110
113
|
## Quick Start (Development)
|
|
111
114
|
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
type ProviderId,
|
|
32
32
|
READ_ONLY_HELP
|
|
33
33
|
} from '../../storage/providers.js';
|
|
34
|
-
import { connections } from '../../stores/connections.svelte.js';
|
|
34
|
+
import { connections, DuplicateConnectionError } from '../../stores/connections.svelte.js';
|
|
35
35
|
import type { Connection, ConnectionConfig } from '../../types.js';
|
|
36
36
|
import { describeParseResult, looksLikeUrl, parseStorageUrl } from '../../utils/storage-url.js';
|
|
37
37
|
|
|
@@ -65,6 +65,7 @@ let sasToken = $state('');
|
|
|
65
65
|
let saving = $state(false);
|
|
66
66
|
let testing = $state(false);
|
|
67
67
|
let testResult = $state<'success' | 'error' | null>(null);
|
|
68
|
+
let duplicateNotice = $state<{ kind: 'merged' | 'blocked'; name: string } | null>(null);
|
|
68
69
|
let parsedHint = $state<string | null>(null);
|
|
69
70
|
let endpointAutoFilled = $state(false);
|
|
70
71
|
|
|
@@ -109,6 +110,7 @@ function resetForm(conn: Connection | null | undefined) {
|
|
|
109
110
|
testResult = null;
|
|
110
111
|
parsedHint = null;
|
|
111
112
|
endpointAutoFilled = false;
|
|
113
|
+
duplicateNotice = null;
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
function selectProvider(id: ProviderId) {
|
|
@@ -225,17 +227,33 @@ $effect(() => {
|
|
|
225
227
|
async function handleSave() {
|
|
226
228
|
if (!canSave) return;
|
|
227
229
|
saving = true;
|
|
230
|
+
duplicateNotice = null;
|
|
228
231
|
try {
|
|
229
232
|
const config = buildConfig();
|
|
230
233
|
if (isEditMode && editConnection) {
|
|
231
234
|
await connections.update(editConnection.id, config);
|
|
232
235
|
} else {
|
|
233
|
-
await connections.save(config);
|
|
236
|
+
const result = await connections.save(config);
|
|
237
|
+
if (result.existed) {
|
|
238
|
+
const existing = connections.getById(result.id);
|
|
239
|
+
duplicateNotice = { kind: 'merged', name: existing?.name ?? config.name };
|
|
240
|
+
// Keep the dialog open briefly so the user sees the notice, then close.
|
|
241
|
+
saving = false;
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
onSaved();
|
|
244
|
+
open = false;
|
|
245
|
+
}, 1200);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
234
248
|
}
|
|
235
249
|
onSaved();
|
|
236
250
|
open = false;
|
|
237
251
|
} catch (err) {
|
|
238
|
-
|
|
252
|
+
if (err instanceof DuplicateConnectionError) {
|
|
253
|
+
duplicateNotice = { kind: 'blocked', name: err.existingName };
|
|
254
|
+
} else {
|
|
255
|
+
console.error('Failed to save connection:', err);
|
|
256
|
+
}
|
|
239
257
|
} finally {
|
|
240
258
|
saving = false;
|
|
241
259
|
}
|
|
@@ -532,6 +550,20 @@ async function handleTestConnection() {
|
|
|
532
550
|
</details>
|
|
533
551
|
{/if}
|
|
534
552
|
|
|
553
|
+
<!-- Duplicate-connection notice -->
|
|
554
|
+
{#if duplicateNotice}
|
|
555
|
+
<div
|
|
556
|
+
class="flex items-start gap-2 rounded-md border px-3 py-2 text-sm {duplicateNotice.kind === 'merged' ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400' : 'border-destructive/30 bg-destructive/10 text-destructive'}"
|
|
557
|
+
>
|
|
558
|
+
<CloudIcon class="mt-0.5 size-4 shrink-0" />
|
|
559
|
+
<span>
|
|
560
|
+
{duplicateNotice.kind === 'merged'
|
|
561
|
+
? t('connection.duplicateMerged', { name: duplicateNotice.name })
|
|
562
|
+
: t('connection.duplicateBlocked', { name: duplicateNotice.name })}
|
|
563
|
+
</span>
|
|
564
|
+
</div>
|
|
565
|
+
{/if}
|
|
566
|
+
|
|
535
567
|
<!-- Test Connection Result -->
|
|
536
568
|
{#if testResult === 'success'}
|
|
537
569
|
<div class="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400">
|
|
@@ -168,7 +168,7 @@ async function handleAutoDetection() {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
async function loadDemoConnection() {
|
|
171
|
-
const id = await connections.save({
|
|
171
|
+
const { id } = await connections.save({
|
|
172
172
|
name: 'Source Cooperative',
|
|
173
173
|
provider: 's3',
|
|
174
174
|
endpoint: '',
|
|
@@ -176,7 +176,6 @@ async function loadDemoConnection() {
|
|
|
176
176
|
region: 'us-west-2',
|
|
177
177
|
anonymous: true
|
|
178
178
|
});
|
|
179
|
-
if (!id) return;
|
|
180
179
|
const conn = connections.getById(id);
|
|
181
180
|
if (conn) {
|
|
182
181
|
browser.browse(conn);
|
|
@@ -14,15 +14,33 @@ import { extensionToShikiLang, highlightCode } from '../../utils/shiki';
|
|
|
14
14
|
import { buildHttpsUrl, buildHttpsUrlAsync, canStreamDirectly } from '../../utils/url.js';
|
|
15
15
|
import { getUrlView, updateUrlView } from '../../utils/url-state.js';
|
|
16
16
|
import { openZarrTab } from '../../utils/zarr-tab.js';
|
|
17
|
+
import { isStacCatalog, isStacCollection, isStacItem } from '../../utils/stac.js';
|
|
18
|
+
|
|
19
|
+
interface CodeActions {
|
|
20
|
+
toggleFormat: () => Promise<void>;
|
|
21
|
+
copyCode: () => Promise<void>;
|
|
22
|
+
canFormat: boolean;
|
|
23
|
+
formatted: boolean;
|
|
24
|
+
copied: boolean;
|
|
25
|
+
}
|
|
17
26
|
|
|
18
|
-
let {
|
|
27
|
+
let {
|
|
28
|
+
tab,
|
|
29
|
+
nested = false,
|
|
30
|
+
wordWrap = $bindable(false),
|
|
31
|
+
actions = $bindable<CodeActions | null>(null)
|
|
32
|
+
}: {
|
|
33
|
+
tab: Tab;
|
|
34
|
+
nested?: boolean;
|
|
35
|
+
wordWrap?: boolean;
|
|
36
|
+
actions?: CodeActions | null;
|
|
37
|
+
} = $props();
|
|
19
38
|
|
|
20
39
|
let abortController: AbortController | null = null;
|
|
21
40
|
let html = $state('');
|
|
22
41
|
let rawCode = $state('');
|
|
23
42
|
let loading = $state(true);
|
|
24
43
|
let error = $state<string | null>(null);
|
|
25
|
-
let wordWrap = $state(false);
|
|
26
44
|
let copied = $state(false);
|
|
27
45
|
let formatted = $state(false);
|
|
28
46
|
const urlView = getUrlView();
|
|
@@ -73,9 +91,9 @@ function detectJsonKind(code: string): JsonKind {
|
|
|
73
91
|
if (obj && typeof obj === 'object') {
|
|
74
92
|
if (obj.version === 8 && obj.sources && obj.layers) return 'maplibre-style';
|
|
75
93
|
if (obj.tilejson && obj.tiles) return 'tilejson';
|
|
76
|
-
if (obj
|
|
77
|
-
if (obj
|
|
78
|
-
if (obj
|
|
94
|
+
if (isStacCatalog(obj)) return 'stac-catalog';
|
|
95
|
+
if (isStacCollection(obj)) return 'stac-collection';
|
|
96
|
+
if (isStacItem(obj)) return 'stac-item';
|
|
79
97
|
if (obj.info?.app === 'kepler.gl' && obj.config) return 'kepler';
|
|
80
98
|
if (obj.zarr_format === 3) return 'zarr-v3';
|
|
81
99
|
if (obj.zarr_format === 2) return 'zarr-v2';
|
|
@@ -176,9 +194,24 @@ const language = $derived(languageMap[ext] ?? 'Plain Text');
|
|
|
176
194
|
/** File types that support native formatting */
|
|
177
195
|
const canFormat = $derived(['.json', '.sql', '.css', '.html', '.xml'].includes(ext));
|
|
178
196
|
|
|
179
|
-
//
|
|
197
|
+
// Expose imperative actions to the parent so a shared outer toolbar (e.g. the
|
|
198
|
+
// one rendered by StacTabViewer when nested) can invoke Format/Wrap/Copy
|
|
199
|
+
// without duplicating the text state.
|
|
200
|
+
$effect(() => {
|
|
201
|
+
actions = {
|
|
202
|
+
toggleFormat,
|
|
203
|
+
copyCode,
|
|
204
|
+
canFormat,
|
|
205
|
+
formatted,
|
|
206
|
+
copied
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Auto-switch to STAC Browser when STAC JSON is detected (unless URL explicitly set #code).
|
|
211
|
+
// Skipped when nested in StacTabViewer since the outer wrapper owns the view toggle.
|
|
180
212
|
let stacAutoSwitched = false;
|
|
181
213
|
$effect(() => {
|
|
214
|
+
if (nested) return;
|
|
182
215
|
if (isStacJson && !stacAutoSwitched && viewMode === 'code' && urlView !== 'code') {
|
|
183
216
|
stacAutoSwitched = true;
|
|
184
217
|
viewMode = 'stac-browser';
|
|
@@ -327,6 +360,7 @@ async function copyCode() {
|
|
|
327
360
|
</script>
|
|
328
361
|
|
|
329
362
|
<div class="flex h-full flex-col">
|
|
363
|
+
{#if !nested}
|
|
330
364
|
<div
|
|
331
365
|
class="flex items-center gap-1 border-b border-zinc-200 px-2 py-1.5 sm:gap-2 sm:px-4 dark:border-zinc-800"
|
|
332
366
|
>
|
|
@@ -354,14 +388,16 @@ async function copyCode() {
|
|
|
354
388
|
<Badge variant="outline" class="hidden border-emerald-200 text-emerald-600 sm:inline-flex dark:border-emerald-800 dark:text-emerald-300">
|
|
355
389
|
{t(stacBadgeKey[jsonKind] ?? 'code.stacItem')}
|
|
356
390
|
</Badge>
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
391
|
+
{#if !nested}
|
|
392
|
+
<Button
|
|
393
|
+
variant={viewMode === 'stac-browser' ? 'default' : 'outline'}
|
|
394
|
+
size="sm"
|
|
395
|
+
class="h-7 gap-1 px-2 text-xs {viewMode !== 'stac-browser' ? 'border-blue-300 text-blue-600 hover:bg-blue-50 hover:text-blue-700 dark:border-blue-700 dark:text-blue-400 dark:hover:bg-blue-950' : ''}"
|
|
396
|
+
onclick={() => setViewMode('stac-browser')}
|
|
397
|
+
>
|
|
398
|
+
{viewMode === 'stac-browser' ? t('code.code') : t('code.browseStac')}
|
|
399
|
+
</Button>
|
|
400
|
+
{/if}
|
|
365
401
|
{:else if jsonKind === 'kepler'}
|
|
366
402
|
<Badge variant="outline" class="hidden border-violet-200 text-violet-600 sm:inline-flex dark:border-violet-800 dark:text-violet-300">
|
|
367
403
|
{t('code.keplerGl')}
|
|
@@ -496,6 +532,7 @@ async function copyCode() {
|
|
|
496
532
|
</div>
|
|
497
533
|
</div>
|
|
498
534
|
</div>
|
|
535
|
+
{/if}
|
|
499
536
|
|
|
500
537
|
{#if viewMode === 'stac-browser' && styleUrl}
|
|
501
538
|
<div class="flex-1 overflow-hidden">
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { Tab } from '../../types';
|
|
2
|
+
interface CodeActions {
|
|
3
|
+
toggleFormat: () => Promise<void>;
|
|
4
|
+
copyCode: () => Promise<void>;
|
|
5
|
+
canFormat: boolean;
|
|
6
|
+
formatted: boolean;
|
|
7
|
+
copied: boolean;
|
|
8
|
+
}
|
|
2
9
|
type $$ComponentProps = {
|
|
3
10
|
tab: Tab;
|
|
11
|
+
nested?: boolean;
|
|
12
|
+
wordWrap?: boolean;
|
|
13
|
+
actions?: CodeActions | null;
|
|
4
14
|
};
|
|
5
|
-
declare const CodeViewer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
15
|
+
declare const CodeViewer: import("svelte").Component<$$ComponentProps, {}, "wordWrap" | "actions">;
|
|
6
16
|
type CodeViewer = ReturnType<typeof CodeViewer>;
|
|
7
17
|
export default CodeViewer;
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
import { t } from '../../i18n/index.svelte.js';
|
|
3
3
|
import {
|
|
4
4
|
type BandConfig,
|
|
5
|
-
COLOR_RAMP_STOPS,
|
|
6
5
|
type ColorRampId,
|
|
7
6
|
DEFAULT_RESCALE,
|
|
8
|
-
type RescaleConfig
|
|
9
|
-
rampToGradientCss
|
|
7
|
+
type RescaleConfig
|
|
10
8
|
} from '../../utils/cog.js';
|
|
9
|
+
import {
|
|
10
|
+
COLORMAP_INDEX,
|
|
11
|
+
COLORMAP_NAMES,
|
|
12
|
+
COLORMAP_SPRITE_LAYERS,
|
|
13
|
+
COLORMAP_SPRITE_URL
|
|
14
|
+
} from '../../utils/colormap-sprite.js';
|
|
11
15
|
|
|
12
16
|
let {
|
|
13
17
|
bandCount,
|
|
@@ -15,17 +19,48 @@ let {
|
|
|
15
19
|
onConfigChange,
|
|
16
20
|
rescale,
|
|
17
21
|
rescaleApplicable,
|
|
18
|
-
onRescaleChange
|
|
22
|
+
onRescaleChange,
|
|
23
|
+
histogram = null,
|
|
24
|
+
mode = 'single'
|
|
19
25
|
}: {
|
|
20
26
|
bandCount: number;
|
|
21
|
-
|
|
27
|
+
/** Required when `mode === 'single'`, ignored when `mode === 'multi'`. */
|
|
28
|
+
bandConfig?: BandConfig;
|
|
22
29
|
onConfigChange: (config: BandConfig) => void;
|
|
23
30
|
rescale: RescaleConfig;
|
|
24
31
|
rescaleApplicable: boolean;
|
|
25
32
|
onRescaleChange: (rescale: RescaleConfig) => void;
|
|
33
|
+
/** Optional histogram bins (normalized, single-band only) for the slider overlay. */
|
|
34
|
+
histogram?: Uint32Array | null;
|
|
35
|
+
mode?: 'single' | 'multi';
|
|
26
36
|
} = $props();
|
|
27
37
|
|
|
28
|
-
|
|
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
|
+
const PINNED_RAMPS: ColorRampId[] = [
|
|
43
|
+
'gray',
|
|
44
|
+
'terrain',
|
|
45
|
+
'viridis',
|
|
46
|
+
'magma',
|
|
47
|
+
'turbo',
|
|
48
|
+
'spectral',
|
|
49
|
+
'inferno',
|
|
50
|
+
'plasma',
|
|
51
|
+
'cividis',
|
|
52
|
+
'rdylgn'
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
let rampQuery = $state('');
|
|
56
|
+
|
|
57
|
+
const filteredRamps = $derived.by(() => {
|
|
58
|
+
const q = rampQuery.trim().toLowerCase();
|
|
59
|
+
if (!q) return COLORMAP_NAMES;
|
|
60
|
+
return COLORMAP_NAMES.filter((name) => name.toLowerCase().includes(q));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ─── Helpers ────────────────────────────────────────────────────
|
|
29
64
|
|
|
30
65
|
function bandOptions(count: number): { value: number; label: string }[] {
|
|
31
66
|
return Array.from({ length: count }, (_, i) => ({
|
|
@@ -35,26 +70,53 @@ function bandOptions(count: number): { value: number; label: string }[] {
|
|
|
35
70
|
}
|
|
36
71
|
|
|
37
72
|
function setMode(mode: 'rgb' | 'single') {
|
|
73
|
+
if (!bandConfig) return;
|
|
38
74
|
onConfigChange({ ...bandConfig, mode });
|
|
39
75
|
}
|
|
40
76
|
|
|
41
77
|
function setBand(key: 'rBand' | 'gBand' | 'bBand' | 'band', value: number) {
|
|
78
|
+
if (!bandConfig) return;
|
|
42
79
|
onConfigChange({ ...bandConfig, [key]: value });
|
|
43
80
|
}
|
|
44
81
|
|
|
45
82
|
function setRamp(id: ColorRampId) {
|
|
83
|
+
if (!bandConfig) return;
|
|
46
84
|
onConfigChange({ ...bandConfig, colorRamp: id });
|
|
47
85
|
}
|
|
48
86
|
|
|
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
|
+
function rampBg(name: ColorRampId, heightPx: number): string {
|
|
94
|
+
const index = COLORMAP_INDEX[name];
|
|
95
|
+
if (index === undefined) return '';
|
|
96
|
+
const totalHeight = COLORMAP_SPRITE_LAYERS * heightPx;
|
|
97
|
+
const yOffset = index * heightPx;
|
|
98
|
+
return [
|
|
99
|
+
`background-image: url("${COLORMAP_SPRITE_URL}")`,
|
|
100
|
+
'background-repeat: no-repeat',
|
|
101
|
+
`background-size: 100% ${totalHeight}px`,
|
|
102
|
+
`background-position: 0 -${yOffset}px`
|
|
103
|
+
].join('; ');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Rescale / histogram ────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function clamp01(v: number): number {
|
|
109
|
+
return Math.max(0, Math.min(1, v));
|
|
110
|
+
}
|
|
111
|
+
|
|
49
112
|
function setRescaleMin(value: number) {
|
|
50
|
-
|
|
51
|
-
const clamped = Math.max(0, Math.min(1, value));
|
|
113
|
+
const clamped = clamp01(value);
|
|
52
114
|
const next = Math.min(clamped, rescale.max - 0.001);
|
|
53
115
|
onRescaleChange({ min: Number.isFinite(next) ? next : 0, max: rescale.max });
|
|
54
116
|
}
|
|
55
117
|
|
|
56
118
|
function setRescaleMax(value: number) {
|
|
57
|
-
const clamped =
|
|
119
|
+
const clamped = clamp01(value);
|
|
58
120
|
const next = Math.max(clamped, rescale.min + 0.001);
|
|
59
121
|
onRescaleChange({ min: rescale.min, max: Number.isFinite(next) ? next : 1 });
|
|
60
122
|
}
|
|
@@ -62,11 +124,21 @@ function setRescaleMax(value: number) {
|
|
|
62
124
|
function resetRescale() {
|
|
63
125
|
onRescaleChange({ ...DEFAULT_RESCALE });
|
|
64
126
|
}
|
|
127
|
+
|
|
128
|
+
const histogramBars = $derived.by(() => {
|
|
129
|
+
if (!histogram || histogram.length === 0) return null;
|
|
130
|
+
let max = 0;
|
|
131
|
+
for (const v of histogram) if (v > max) max = v;
|
|
132
|
+
if (max === 0) return null;
|
|
133
|
+
const bins = Array.from(histogram, (count) => count / max);
|
|
134
|
+
return bins;
|
|
135
|
+
});
|
|
65
136
|
</script>
|
|
66
137
|
|
|
67
138
|
<div
|
|
68
|
-
class="absolute right-2 top-10 z-10 w-
|
|
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"
|
|
69
140
|
>
|
|
141
|
+
{#if mode === 'single' && bandConfig}
|
|
70
142
|
<!-- Mode toggle -->
|
|
71
143
|
<div class="mb-2 flex gap-1">
|
|
72
144
|
<button
|
|
@@ -130,29 +202,57 @@ function resetRescale() {
|
|
|
130
202
|
|
|
131
203
|
<!-- Color ramp picker -->
|
|
132
204
|
<div class="space-y-1">
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
205
|
+
<div class="flex items-center justify-between">
|
|
206
|
+
<span class="text-muted-foreground">{t('cog.colorRamp')}</span>
|
|
207
|
+
<span class="text-[10px] text-muted-foreground tabular-nums">
|
|
208
|
+
{filteredRamps.length}/{COLORMAP_NAMES.length}
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<!-- Pinned quick-access (only when no search active) -->
|
|
213
|
+
{#if !rampQuery}
|
|
214
|
+
<div class="grid grid-cols-2 gap-1">
|
|
215
|
+
{#each PINNED_RAMPS as id}
|
|
216
|
+
<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'}"
|
|
218
|
+
onclick={() => setRamp(id)}
|
|
219
|
+
title={id}
|
|
220
|
+
>
|
|
221
|
+
<div class="h-2.5 w-full rounded-sm" style={rampBg(id, 10)}></div>
|
|
222
|
+
<span class="mt-0.5 text-center text-[10px] capitalize text-muted-foreground">
|
|
223
|
+
{id}
|
|
224
|
+
</span>
|
|
225
|
+
</button>
|
|
226
|
+
{/each}
|
|
227
|
+
</div>
|
|
228
|
+
{/if}
|
|
229
|
+
|
|
230
|
+
<!-- Search + all-ramps scroll list -->
|
|
231
|
+
<input
|
|
232
|
+
type="search"
|
|
233
|
+
placeholder={t('cog.colorRampSearch')}
|
|
234
|
+
class="w-full rounded border border-border bg-background px-1.5 py-0.5 text-[11px]"
|
|
235
|
+
value={rampQuery}
|
|
236
|
+
oninput={(e) => (rampQuery = (e.target as HTMLInputElement).value)}
|
|
237
|
+
/>
|
|
238
|
+
<div class="max-h-40 overflow-y-auto rounded border border-border">
|
|
239
|
+
{#each filteredRamps as id}
|
|
136
240
|
<button
|
|
137
|
-
class="flex
|
|
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'}"
|
|
138
242
|
onclick={() => setRamp(id)}
|
|
139
243
|
title={id}
|
|
140
244
|
>
|
|
141
|
-
<div
|
|
142
|
-
|
|
143
|
-
style="background: {rampToGradientCss(id)}"
|
|
144
|
-
></div>
|
|
145
|
-
<span class="mt-0.5 text-center text-[10px] capitalize text-muted-foreground">
|
|
146
|
-
{id}
|
|
147
|
-
</span>
|
|
245
|
+
<div class="h-2.5 w-14 flex-shrink-0 rounded-sm" style={rampBg(id, 10)}></div>
|
|
246
|
+
<span class="truncate text-muted-foreground">{id}</span>
|
|
148
247
|
</button>
|
|
149
248
|
{/each}
|
|
150
249
|
</div>
|
|
151
250
|
</div>
|
|
152
251
|
{/if}
|
|
252
|
+
{/if}
|
|
153
253
|
|
|
154
254
|
{#if rescaleApplicable}
|
|
155
|
-
<!-- GPU LinearRescale slider
|
|
255
|
+
<!-- GPU LinearRescale slider with histogram overlay. -->
|
|
156
256
|
<div class="mt-2 space-y-1 border-t border-border pt-2">
|
|
157
257
|
<div class="flex items-center justify-between">
|
|
158
258
|
<span class="text-muted-foreground">{t('cog.rescale')}</span>
|
|
@@ -163,6 +263,35 @@ function resetRescale() {
|
|
|
163
263
|
{t('cog.rescaleReset')}
|
|
164
264
|
</button>
|
|
165
265
|
</div>
|
|
266
|
+
|
|
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}
|
|
294
|
+
|
|
166
295
|
<div class="flex items-center gap-1.5">
|
|
167
296
|
<input
|
|
168
297
|
type="number"
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { type BandConfig, type RescaleConfig } from '../../utils/cog.js';
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
bandCount: number;
|
|
4
|
-
|
|
4
|
+
/** Required when `mode === 'single'`, ignored when `mode === 'multi'`. */
|
|
5
|
+
bandConfig?: BandConfig;
|
|
5
6
|
onConfigChange: (config: BandConfig) => void;
|
|
6
7
|
rescale: RescaleConfig;
|
|
7
8
|
rescaleApplicable: boolean;
|
|
8
9
|
onRescaleChange: (rescale: RescaleConfig) => void;
|
|
10
|
+
/** Optional histogram bins (normalized, single-band only) for the slider overlay. */
|
|
11
|
+
histogram?: Uint32Array | null;
|
|
12
|
+
mode?: 'single' | 'multi';
|
|
9
13
|
};
|
|
10
14
|
declare const CogControls: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
15
|
type CogControls = ReturnType<typeof CogControls>;
|