@walkthru-earth/objex 1.2.0 → 1.3.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.
- package/README.md +6 -3
- package/dist/components/browser/FileTreeSidebar.svelte +1 -1
- package/dist/components/layout/ConnectionDialog.svelte +35 -3
- package/dist/components/layout/Sidebar.svelte +28 -2
- package/dist/components/viewers/ArchiveViewer.svelte +4 -4
- package/dist/components/viewers/CodeViewer.svelte +72 -19
- 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 +45 -10
- package/dist/components/viewers/CopcViewer.svelte +20 -2
- package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
- package/dist/components/viewers/MultiCogViewer.svelte +416 -0
- package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
- package/dist/components/viewers/PmtilesViewer.svelte +2 -2
- package/dist/components/viewers/StacMapViewer.svelte +34 -12
- package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
- package/dist/components/viewers/StacMosaicViewer.svelte +699 -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/TableViewer.svelte +50 -21
- package/dist/components/viewers/ViewerRouter.svelte +155 -2
- package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
- package/dist/components/viewers/ZarrMapViewer.svelte +147 -8
- package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
- package/dist/components/viewers/ZarrViewer.svelte +3 -2
- package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
- package/dist/i18n/ar.js +28 -0
- package/dist/i18n/en.js +28 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/source.d.ts +12 -0
- package/dist/query/source.js +25 -8
- package/dist/query/stac-geoparquet.d.ts +31 -0
- package/dist/query/stac-geoparquet.js +136 -0
- package/dist/query/wasm.js +130 -23
- package/dist/storage/adapter.d.ts +9 -0
- package/dist/storage/adapter.js +13 -1
- package/dist/storage/browser-azure.d.ts +1 -1
- package/dist/storage/browser-azure.js +4 -0
- package/dist/storage/browser-cloud.d.ts +1 -1
- package/dist/storage/browser-cloud.js +7 -0
- package/dist/storage/presign.d.ts +13 -0
- package/dist/storage/presign.js +55 -0
- package/dist/storage/providers.d.ts +6 -0
- package/dist/storage/providers.js +13 -2
- package/dist/stores/browser.svelte.d.ts +2 -0
- package/dist/stores/browser.svelte.js +17 -1
- package/dist/stores/connections.svelte.d.ts +38 -23
- package/dist/stores/connections.svelte.js +105 -114
- package/dist/utils/cog.d.ts +80 -18
- package/dist/utils/cog.js +187 -125
- 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/url.d.ts +13 -0
- package/dist/utils/url.js +36 -0
- package/dist/utils/wkb.js +22 -8
- 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
|
|
|
@@ -356,7 +356,7 @@ async function expandToPath(path: string) {
|
|
|
356
356
|
? await findNodeAtRoot(accumulatedPath)
|
|
357
357
|
: await findNodeInParent(parentNode, accumulatedPath);
|
|
358
358
|
|
|
359
|
-
if (!node
|
|
359
|
+
if (!node?.entry.is_dir) break;
|
|
360
360
|
|
|
361
361
|
if (node.children.length === 0) {
|
|
362
362
|
await loadChildren(node);
|
|
@@ -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">
|
|
@@ -57,6 +57,33 @@ $effect(() => {
|
|
|
57
57
|
}
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
// Auto-detected ?url= buckets are saved anonymously (zero-click demo flow).
|
|
61
|
+
// If the first LIST returns 401/403, the bucket is actually private — flip
|
|
62
|
+
// the connection to non-anonymous and open the credential dialog so the
|
|
63
|
+
// user can paste keys instead of seeing a silent failure.
|
|
64
|
+
$effect(() => {
|
|
65
|
+
const conn = browser.authRequired;
|
|
66
|
+
if (!conn) return;
|
|
67
|
+
handleAuthRequired(conn);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
async function handleAuthRequired(conn: Connection) {
|
|
71
|
+
browser.clearAuthRequired();
|
|
72
|
+
await connections.update(conn.id, {
|
|
73
|
+
name: conn.name,
|
|
74
|
+
provider: conn.provider,
|
|
75
|
+
endpoint: conn.endpoint,
|
|
76
|
+
bucket: conn.bucket,
|
|
77
|
+
region: conn.region,
|
|
78
|
+
anonymous: false,
|
|
79
|
+
authMethod: conn.authMethod,
|
|
80
|
+
rootPrefix: conn.rootPrefix
|
|
81
|
+
});
|
|
82
|
+
const updated = connections.getById(conn.id);
|
|
83
|
+
if (!updated) return;
|
|
84
|
+
await ensureCredentials(updated);
|
|
85
|
+
}
|
|
86
|
+
|
|
60
87
|
async function handleAutoDetection() {
|
|
61
88
|
const url = new URL(window.location.href);
|
|
62
89
|
const rawUrl = url.searchParams.get('url');
|
|
@@ -141,7 +168,7 @@ async function handleAutoDetection() {
|
|
|
141
168
|
}
|
|
142
169
|
|
|
143
170
|
async function loadDemoConnection() {
|
|
144
|
-
const id = await connections.save({
|
|
171
|
+
const { id } = await connections.save({
|
|
145
172
|
name: 'Source Cooperative',
|
|
146
173
|
provider: 's3',
|
|
147
174
|
endpoint: '',
|
|
@@ -149,7 +176,6 @@ async function loadDemoConnection() {
|
|
|
149
176
|
region: 'us-west-2',
|
|
150
177
|
anonymous: true
|
|
151
178
|
});
|
|
152
|
-
if (!id) return;
|
|
153
179
|
const conn = connections.getById(id);
|
|
154
180
|
if (conn) {
|
|
155
181
|
browser.browse(conn);
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
streamZipEntriesFromUrl
|
|
30
30
|
} from '../../utils/archive';
|
|
31
31
|
import { formatFileSize } from '../../utils/format';
|
|
32
|
-
import {
|
|
32
|
+
import { buildHttpsUrlAsync } from '../../utils/url.js';
|
|
33
33
|
|
|
34
34
|
let { tab }: { tab: Tab } = $props();
|
|
35
35
|
|
|
@@ -177,7 +177,7 @@ async function loadZip() {
|
|
|
177
177
|
const signal = abortController!.signal;
|
|
178
178
|
|
|
179
179
|
if (tab.source === 'remote') {
|
|
180
|
-
const url =
|
|
180
|
+
const url = await buildHttpsUrlAsync(tab);
|
|
181
181
|
try {
|
|
182
182
|
scanning = true;
|
|
183
183
|
for await (const batch of streamZipEntriesFromUrl(url, signal)) {
|
|
@@ -208,7 +208,7 @@ async function loadTar() {
|
|
|
208
208
|
const signal = abortController!.signal;
|
|
209
209
|
|
|
210
210
|
if (tab.source === 'remote') {
|
|
211
|
-
const url =
|
|
211
|
+
const url = await buildHttpsUrlAsync(tab);
|
|
212
212
|
try {
|
|
213
213
|
scanning = true;
|
|
214
214
|
remoteUrl = url;
|
|
@@ -240,7 +240,7 @@ async function loadTarGz() {
|
|
|
240
240
|
|
|
241
241
|
// For remote URLs: stream-fetch → decompress → parse progressively
|
|
242
242
|
if (tab.source === 'remote' || tab.source === 'url') {
|
|
243
|
-
const url =
|
|
243
|
+
const url = await buildHttpsUrlAsync(tab);
|
|
244
244
|
try {
|
|
245
245
|
scanning = true;
|
|
246
246
|
const decompressedChunks: Uint8Array[] = [];
|
|
@@ -11,18 +11,36 @@ import type { Tab } from '../../types';
|
|
|
11
11
|
import { copyToClipboard } from '../../utils/clipboard.js';
|
|
12
12
|
import { handleLoadError } from '../../utils/error.js';
|
|
13
13
|
import { extensionToShikiLang, highlightCode } from '../../utils/shiki';
|
|
14
|
-
import { buildHttpsUrl } from '../../utils/url.js';
|
|
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';
|
|
@@ -96,7 +114,23 @@ const stacBadgeKey = $derived<Record<string, string>>({
|
|
|
96
114
|
'stac-collection': 'code.stacCollection',
|
|
97
115
|
'stac-item': 'code.stacItem'
|
|
98
116
|
});
|
|
99
|
-
|
|
117
|
+
// Third-party iframes can't route through the storage adapter, so the URL
|
|
118
|
+
// must carry auth. Public/SAS connections resolve synchronously; `signed-s3`
|
|
119
|
+
// must wait for the presign so the iframe never loads a bare `s3://` href.
|
|
120
|
+
let styleUrl = $state('');
|
|
121
|
+
$effect(() => {
|
|
122
|
+
const id = tab.id;
|
|
123
|
+
styleUrl = canStreamDirectly(tab) ? buildHttpsUrl(tab) : '';
|
|
124
|
+
let cancelled = false;
|
|
125
|
+
(async () => {
|
|
126
|
+
const url = await buildHttpsUrlAsync(tab);
|
|
127
|
+
if (cancelled || id !== tab.id) return;
|
|
128
|
+
styleUrl = url;
|
|
129
|
+
})();
|
|
130
|
+
return () => {
|
|
131
|
+
cancelled = true;
|
|
132
|
+
};
|
|
133
|
+
});
|
|
100
134
|
const stacBrowserSrc = $derived(
|
|
101
135
|
`https://radiantearth.github.io/stac-browser/#/external/${styleUrl}`
|
|
102
136
|
);
|
|
@@ -160,9 +194,24 @@ const language = $derived(languageMap[ext] ?? 'Plain Text');
|
|
|
160
194
|
/** File types that support native formatting */
|
|
161
195
|
const canFormat = $derived(['.json', '.sql', '.css', '.html', '.xml'].includes(ext));
|
|
162
196
|
|
|
163
|
-
//
|
|
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.
|
|
164
212
|
let stacAutoSwitched = false;
|
|
165
213
|
$effect(() => {
|
|
214
|
+
if (nested) return;
|
|
166
215
|
if (isStacJson && !stacAutoSwitched && viewMode === 'code' && urlView !== 'code') {
|
|
167
216
|
stacAutoSwitched = true;
|
|
168
217
|
viewMode = 'stac-browser';
|
|
@@ -311,6 +360,7 @@ async function copyCode() {
|
|
|
311
360
|
</script>
|
|
312
361
|
|
|
313
362
|
<div class="flex h-full flex-col">
|
|
363
|
+
{#if !nested}
|
|
314
364
|
<div
|
|
315
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"
|
|
316
366
|
>
|
|
@@ -338,14 +388,16 @@ async function copyCode() {
|
|
|
338
388
|
<Badge variant="outline" class="hidden border-emerald-200 text-emerald-600 sm:inline-flex dark:border-emerald-800 dark:text-emerald-300">
|
|
339
389
|
{t(stacBadgeKey[jsonKind] ?? 'code.stacItem')}
|
|
340
390
|
</Badge>
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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}
|
|
349
401
|
{:else if jsonKind === 'kepler'}
|
|
350
402
|
<Badge variant="outline" class="hidden border-violet-200 text-violet-600 sm:inline-flex dark:border-violet-800 dark:text-violet-300">
|
|
351
403
|
{t('code.keplerGl')}
|
|
@@ -480,8 +532,9 @@ async function copyCode() {
|
|
|
480
532
|
</div>
|
|
481
533
|
</div>
|
|
482
534
|
</div>
|
|
535
|
+
{/if}
|
|
483
536
|
|
|
484
|
-
{#if viewMode === 'stac-browser'}
|
|
537
|
+
{#if viewMode === 'stac-browser' && styleUrl}
|
|
485
538
|
<div class="flex-1 overflow-hidden">
|
|
486
539
|
<iframe
|
|
487
540
|
src={stacBrowserSrc}
|
|
@@ -490,7 +543,7 @@ async function copyCode() {
|
|
|
490
543
|
allow="fullscreen"
|
|
491
544
|
></iframe>
|
|
492
545
|
</div>
|
|
493
|
-
{:else if viewMode === 'kepler'}
|
|
546
|
+
{:else if viewMode === 'kepler' && styleUrl}
|
|
494
547
|
<div class="flex-1 overflow-hidden">
|
|
495
548
|
<iframe
|
|
496
549
|
src={keplerSrc}
|
|
@@ -499,7 +552,7 @@ async function copyCode() {
|
|
|
499
552
|
allow="fullscreen"
|
|
500
553
|
></iframe>
|
|
501
554
|
</div>
|
|
502
|
-
{:else if viewMode === 'maputnik'}
|
|
555
|
+
{:else if viewMode === 'maputnik' && styleUrl}
|
|
503
556
|
<div class="flex-1 overflow-hidden">
|
|
504
557
|
<iframe
|
|
505
558
|
src={maputnikSrc}
|
|
@@ -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>;
|