@walkthru-earth/objex 1.1.0 → 1.2.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.
Files changed (89) hide show
  1. package/README.md +3 -1
  2. package/dist/components/browser/FileBrowser.svelte +25 -14
  3. package/dist/components/browser/FileTreeSidebar.svelte +43 -7
  4. package/dist/components/layout/ConnectionDialog.svelte +100 -1
  5. package/dist/components/layout/Sidebar.svelte +70 -25
  6. package/dist/components/viewers/ArchiveViewer.svelte +4 -4
  7. package/dist/components/viewers/CodeViewer.svelte +44 -5
  8. package/dist/components/viewers/CogControls.svelte +208 -0
  9. package/dist/components/viewers/CogControls.svelte.d.ts +12 -0
  10. package/dist/components/viewers/CogViewer.svelte +373 -1162
  11. package/dist/components/viewers/CogViewer.svelte.d.ts +1 -1
  12. package/dist/components/viewers/CopcViewer.svelte +20 -2
  13. package/dist/components/viewers/DatabaseViewer.svelte +345 -37
  14. package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
  15. package/dist/components/viewers/MarkdownViewer.svelte +1 -1
  16. package/dist/components/viewers/PmtilesViewer.svelte +2 -2
  17. package/dist/components/viewers/StacMapViewer.svelte +25 -9
  18. package/dist/components/viewers/TableViewer.svelte +162 -51
  19. package/dist/components/viewers/ZarrMapViewer.svelte +33 -4
  20. package/dist/components/viewers/ZarrViewer.svelte +3 -6
  21. package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
  22. package/dist/constants.d.ts +6 -2
  23. package/dist/constants.js +6 -2
  24. package/dist/file-icons/index.d.ts +1 -1
  25. package/dist/file-icons/index.js +12 -2
  26. package/dist/i18n/ar.js +25 -0
  27. package/dist/i18n/en.js +25 -0
  28. package/dist/i18n/index.svelte.d.ts +0 -1
  29. package/dist/i18n/index.svelte.js +0 -3
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.js +1 -0
  32. package/dist/query/engine.d.ts +20 -4
  33. package/dist/query/index.d.ts +2 -1
  34. package/dist/query/index.js +1 -0
  35. package/dist/query/source.d.ts +42 -0
  36. package/dist/query/source.js +54 -0
  37. package/dist/query/wasm.d.ts +7 -5
  38. package/dist/query/wasm.js +267 -107
  39. package/dist/storage/adapter.d.ts +9 -0
  40. package/dist/storage/adapter.js +13 -1
  41. package/dist/storage/browser-azure.d.ts +1 -1
  42. package/dist/storage/browser-azure.js +4 -0
  43. package/dist/storage/browser-cloud.d.ts +1 -1
  44. package/dist/storage/browser-cloud.js +7 -0
  45. package/dist/storage/presign.d.ts +13 -0
  46. package/dist/storage/presign.js +55 -0
  47. package/dist/storage/providers.d.ts +53 -0
  48. package/dist/storage/providers.js +171 -0
  49. package/dist/stores/browser.svelte.d.ts +2 -0
  50. package/dist/stores/browser.svelte.js +17 -1
  51. package/dist/stores/files.svelte.d.ts +1 -2
  52. package/dist/stores/files.svelte.js +1 -2
  53. package/dist/stores/tabs.svelte.d.ts +9 -2
  54. package/dist/stores/tabs.svelte.js +11 -2
  55. package/dist/types.d.ts +11 -0
  56. package/dist/utils/cog.d.ts +244 -0
  57. package/dist/utils/cog.js +1039 -0
  58. package/dist/utils/deck.d.ts +0 -18
  59. package/dist/utils/deck.js +0 -36
  60. package/dist/utils/geometry-type.d.ts +52 -0
  61. package/dist/utils/geometry-type.js +76 -0
  62. package/dist/utils/markdown-sql.d.ts +1 -1
  63. package/dist/utils/markdown-sql.js +3 -4
  64. package/dist/utils/pmtiles-tile.d.ts +0 -2
  65. package/dist/utils/pmtiles-tile.js +0 -8
  66. package/dist/utils/url-state.d.ts +6 -0
  67. package/dist/utils/url-state.js +34 -26
  68. package/dist/utils/url.d.ts +26 -9
  69. package/dist/utils/url.js +52 -25
  70. package/dist/utils/wkb.js +22 -8
  71. package/dist/utils/zarr-tab.d.ts +22 -0
  72. package/dist/utils/zarr-tab.js +30 -0
  73. package/dist/utils/zarr.d.ts +0 -2
  74. package/dist/utils/zarr.js +73 -44
  75. package/package.json +47 -43
  76. package/dist/components/ui/tabs/index.d.ts +0 -5
  77. package/dist/components/ui/tabs/index.js +0 -7
  78. package/dist/components/ui/tabs/tabs-content.svelte +0 -17
  79. package/dist/components/ui/tabs/tabs-content.svelte.d.ts +0 -4
  80. package/dist/components/ui/tabs/tabs-list.svelte +0 -16
  81. package/dist/components/ui/tabs/tabs-list.svelte.d.ts +0 -4
  82. package/dist/components/ui/tabs/tabs-trigger.svelte +0 -20
  83. package/dist/components/ui/tabs/tabs-trigger.svelte.d.ts +0 -4
  84. package/dist/components/ui/tabs/tabs.svelte +0 -19
  85. package/dist/components/ui/tabs/tabs.svelte.d.ts +0 -4
  86. package/dist/components/viewers/MapViewer.svelte +0 -234
  87. package/dist/components/viewers/MapViewer.svelte.d.ts +0 -7
  88. package/dist/components/viewers/StyleEditorOverlay.svelte +0 -27
  89. package/dist/components/viewers/StyleEditorOverlay.svelte.d.ts +0 -7
@@ -2,15 +2,24 @@
2
2
  import { format as formatSql } from 'sql-formatter';
3
3
  import { untrack } from 'svelte';
4
4
  import CodeMirrorEditor from '../editor/CodeMirrorEditor.svelte';
5
- import { buildDuckDbSource, isCloudNativeFormat } from '../../file-icons/index.js';
5
+ import { DEFAULT_TARGET_CRS } from '../../constants.js';
6
+ import { isCloudNativeFormat } from '../../file-icons/index.js';
6
7
  import { t } from '../../i18n/index.svelte.js';
7
8
  import type { MapQueryResult, SchemaField } from '../../query/engine';
8
- import { getQueryEngine, QueryCancelledError, type QueryHandle } from '../../query/index.js';
9
+ import {
10
+ getQueryEngine,
11
+ QueryCancelledError,
12
+ type QueryHandle,
13
+ type ResolvedTableSource,
14
+ resolveTableSource,
15
+ resolveTableSourceAsync
16
+ } from '../../query/index.js';
9
17
  import { queryHistory } from '../../stores/query-history.svelte.js';
10
18
  import { settings } from '../../stores/settings.svelte.js';
11
19
  import { tabResources } from '../../stores/tab-resources.svelte.js';
12
20
  import type { Tab } from '../../types';
13
21
  import type { GeoArrowGeomType } from '../../utils/geoarrow.js';
22
+ import { buildTransformExpr, wrapWkbWithCrs } from '../../utils/geometry-type.js';
14
23
  import {
15
24
  extractBounds,
16
25
  extractEpsgFromGeoMeta,
@@ -53,6 +62,8 @@ let viewMode = $state<'table' | 'map' | 'stac' | 'info'>(
53
62
  );
54
63
  let sqlQuery = $state('');
55
64
  let customSql = $state('');
65
+ // Presigned URL for the source-cooperative parquet-table iframe (external fetcher).
66
+ let parquetIframeUrl = $state('');
56
67
  let queryRunning = $state(false);
57
68
  let executionTimeMs = $state(0);
58
69
 
@@ -90,27 +101,28 @@ const columnTypes = $derived(Object.fromEntries(schema.map((f) => [f.name, f.typ
90
101
  // Columns for display — exclude internal __wkb helper
91
102
  const displayColumns = $derived(columns.filter((c) => c !== '__wkb'));
92
103
 
93
- function buildDefaultSql(offset = 0): string {
94
- const fileUrl = buildDuckDbUrl(tab);
95
- const source = buildDuckDbSource(tab.path, fileUrl);
104
+ let resolvedSource: ResolvedTableSource | null = null;
105
+
106
+ function buildDefaultSql(
107
+ offset = 0,
108
+ resolved: ResolvedTableSource = resolvedSource ?? resolveTableSource(tab)
109
+ ): string {
110
+ const source = resolved.ref;
96
111
 
97
112
  let sql: string;
98
113
  if (geoCol) {
99
114
  const quoted = `"${geoCol}"`;
100
115
  const upper = geoColType.toUpperCase();
101
- // Spatial types that ST_AsWKB accepts directly (GEOMETRY, WKB_BLOB, etc.).
102
- // Includes Arrow "Binary"/"LargeBinary" DuckDB GEOMETRY columns from
103
- // ST_ReadSHP/ST_Read appear as Arrow Binary but are NOT WKB blobs.
104
- const isSpatialType =
105
- upper === 'GEOMETRY' ||
106
- upper === 'GEOGRAPHY' ||
116
+ // Spatial types that ST_AsWKB accepts directly: GEOMETRY, GEOMETRY('EPSG:...'),
117
+ // GEOGRAPHY, WKB_BLOB, POINT, LINESTRING, POLYGON, Binary (Arrow serialization).
118
+ const spatialType =
119
+ upper.startsWith('GEOMETRY') ||
120
+ upper.startsWith('GEOGRAPHY') ||
107
121
  upper === 'WKB_BLOB' ||
108
122
  upper.includes('POINT') ||
109
123
  upper.includes('LINESTRING') ||
110
124
  upper.includes('POLYGON') ||
111
- upper.includes('BINARY'); // Arrow serialization of DuckDB GEOMETRY
112
- // Actual WKB BLOB columns (e.g. GeoParquet) need explicit ST_GeomFromWKB
113
- // because DuckDB has no implicit BLOB→GEOMETRY cast.
125
+ upper.includes('BINARY');
114
126
  const isWkbBlob = upper === 'BLOB' || upper === 'BYTEA';
115
127
 
116
128
  let wkbExpr: string;
@@ -118,13 +130,17 @@ function buildDefaultSql(offset = 0): string {
118
130
  // Already WKB — use directly, no conversion needed
119
131
  wkbExpr = `${quoted} AS __wkb`;
120
132
  } else {
121
- let geomExpr = isSpatialType
133
+ // For BLOB inputs with a known source CRS, attach it via ST_SetCRS so the
134
+ // 2-arg ST_Transform form can be used (DuckDB v1.5+).
135
+ let geomExpr = spatialType
122
136
  ? quoted
123
137
  : isWkbBlob
124
- ? `ST_GeomFromWKB(${quoted})`
138
+ ? wrapWkbWithCrs(quoted, sourceCrs)
125
139
  : `ST_GeomFromGeoJSON(${quoted})`;
126
140
  if (sourceCrs) {
127
- geomExpr = `ST_Transform(${geomExpr}, '${sourceCrs}', 'EPSG:4326', always_xy := true)`;
141
+ // geometry_always_xy is set globally at DB init, so no per-call always_xy.
142
+ const effectiveType = isWkbBlob ? `GEOMETRY('${sourceCrs}')` : geoColType;
143
+ geomExpr = buildTransformExpr(geomExpr, effectiveType, sourceCrs, DEFAULT_TARGET_CRS);
128
144
  }
129
145
  wkbExpr = `ST_AsWKB(${geomExpr}) AS __wkb`;
130
146
  }
@@ -195,6 +211,8 @@ $effect(() => {
195
211
  geoCol = null;
196
212
  knownGeomType = undefined;
197
213
  metadataBounds = null;
214
+ resolvedSource = null;
215
+ parquetIframeUrl = '';
198
216
  error = null;
199
217
  });
200
218
  return unregister;
@@ -259,6 +277,10 @@ async function forceCancel() {
259
277
 
260
278
  async function loadTable() {
261
279
  const thisGen = ++loadGeneration;
280
+ // Snapshot reactive values once. Reading `$derived` across awaits after the
281
+ // component's effect is torn down returns Svelte's destroyed-signal sentinel,
282
+ // which throws "can't convert symbol to string" in downstream template literals.
283
+ const cid = connId;
262
284
 
263
285
  // Cancel in-flight query from a previous load to prevent duplicate concurrent queries
264
286
  if (activeHandle) {
@@ -278,25 +300,41 @@ async function loadTable() {
278
300
  loadStage = t('table.preparingQuery');
279
301
  loadProgress = [];
280
302
 
281
- // Set SQL eagerly so editor shows the query while loading
282
- const initialSql = buildDefaultSql(0);
283
- sqlQuery = initialSql;
284
- customSql = initialSql;
303
+ resolvedSource = null;
304
+ const eagerSql = buildDefaultSql(0);
305
+ sqlQuery = eagerSql;
306
+ customSql = eagerSql;
285
307
 
286
308
  try {
287
- const fileUrl = buildDuckDbUrl(tab);
288
- const httpsUrl = buildHttpsUrl(tab);
289
- const cloudNative = isCloudNativeFormat(tab.path);
290
- const isParquet = /\.parquet$/i.test(tab.path);
291
- const streamable = canStreamDirectly(tab);
309
+ const resolved: ResolvedTableSource = await resolveTableSourceAsync(tab);
310
+ if (thisGen !== loadGeneration) return;
311
+ resolvedSource = resolved;
312
+ const resolvedSql = buildDefaultSql(0, resolved);
313
+ sqlQuery = resolvedSql;
314
+ // Only overwrite the editor if the user hasn't edited it during the presign await.
315
+ if (customSql === eagerSql) customSql = resolvedSql;
316
+ const isFileSource = resolved.isFileSource;
317
+ const fileUrl = resolved.fileUrl ?? '';
318
+ const httpsUrl = isFileSource ? buildHttpsUrl(tab) : '';
319
+ const cloudNative = isFileSource && isCloudNativeFormat(tab.path);
320
+ const isParquet = isFileSource && /\.parquet$/i.test(tab.path);
321
+ const streamable = isFileSource && canStreamDirectly(tab);
322
+
323
+ // Parquet-table iframe fetches from its own origin. `resolved.fileUrl`
324
+ // is already the presigned HTTPS URL for signed-s3 (or the public URL
325
+ // for anonymous / SAS), so reuse it instead of signing a second time.
326
+ if (isParquet) parquetIframeUrl = fileUrl;
292
327
 
293
328
  // Start DuckDB boot immediately (runs in parallel with hyparquet)
294
329
  loadStage = t('table.initEngine');
295
330
  const enginePromise = getQueryEngine();
296
331
 
297
332
  // ── Fast metadata via hyparquet (runs concurrently with DuckDB boot) ──
333
+ // Only applies to file-backed sources. SQL-backed sources (attached
334
+ // DuckLake tables, etc.) go straight to DuckDB for schema + CRS.
298
335
  let metaFromHyparquet = false;
299
336
  let needsDuckDbCrs = false;
337
+ let isLegacyGeoParquet = false;
300
338
  if (cloudNative && isParquet && streamable) {
301
339
  try {
302
340
  loadStage = t('table.readingMetadata');
@@ -357,11 +395,9 @@ async function loadTable() {
357
395
 
358
396
  if (meta.geo) {
359
397
  geoCol = meta.geo.primaryColumn;
360
- // With enable_geoparquet_conversion=false, DuckDB reads ALL
361
- // GeoParquet geometry columns as BLOB regardless of Parquet
362
- // logical type. Native Parquet GEOMETRY (Format 2.11+) is a
363
- // DuckDB v1.5 feature not yet in WASM (v1.33).
364
- geoColType = 'BLOB';
398
+ // DuckDB v1.5 reads GeoParquet columns as GEOMETRY('EPSG:...').
399
+ // Set GEOMETRY here as placeholder DuckDB schema will refine.
400
+ geoColType = 'GEOMETRY';
365
401
  sourceCrs = extractEpsgFromGeoMeta(meta.geo);
366
402
  const geomTypes = extractGeometryTypes(meta.geo);
367
403
  if (geomTypes.length === 1) knownGeomType = geomTypes[0];
@@ -401,9 +437,8 @@ async function loadTable() {
401
437
  const detectedGeoCol = findGeoColumn(schema);
402
438
  if (detectedGeoCol) {
403
439
  geoCol = detectedGeoCol;
404
- // DuckDB WASM v1.33 doesn't support native Parquet GEOMETRY
405
- // columns are always BLOB with enable_geoparquet_conversion=false.
406
- geoColType = 'BLOB';
440
+ // DuckDB v1.5 reads native Parquet GEOMETRY natively.
441
+ geoColType = 'GEOMETRY';
407
442
  needsDuckDbCrs = true;
408
443
  loadProgress = [
409
444
  ...loadProgress,
@@ -420,6 +455,7 @@ async function loadTable() {
420
455
  { label: t('progress.format'), value: t('progress.stacDetected') }
421
456
  ];
422
457
  }
458
+ isLegacyGeoParquet = meta.legacyGeoParquet;
423
459
  metaFromHyparquet = true;
424
460
  } catch {
425
461
  // hyparquet failed (CORS, auth, format) — fall back to DuckDB
@@ -431,11 +467,54 @@ async function loadTable() {
431
467
  const engine = await enginePromise;
432
468
  if (thisGen !== loadGeneration) return;
433
469
 
470
+ // Legacy GeoParquet (geopandas <0.12) — files with schema_version but no
471
+ // "version" field. DuckDB v1.5 may reject these with auto-conversion enabled.
472
+ // Disable conversion for this connection and fall back to BLOB handling.
473
+ if (metaFromHyparquet && isLegacyGeoParquet && geoCol) {
474
+ try {
475
+ await engine.query(cid, 'SET enable_geoparquet_conversion = false');
476
+ geoColType = 'BLOB';
477
+ } catch {
478
+ // Setting failed — DuckDB may still handle it gracefully
479
+ }
480
+ }
481
+
482
+ // DuckDB v1.5: refresh geoColType from DuckDB's actual schema.
483
+ // hyparquet reports BLOB for the physical Parquet type, but DuckDB v1.5
484
+ // reads GeoParquet as GEOMETRY('EPSG:...') with CRS embedded in the type.
485
+ if (metaFromHyparquet && geoCol && !isLegacyGeoParquet) {
486
+ try {
487
+ const duckSchema = await engine.getSchema(cid, resolved);
488
+ if (thisGen !== loadGeneration) return;
489
+ const duckGeoField = duckSchema.find((f: { name: string }) => f.name === geoCol);
490
+ if (duckGeoField) {
491
+ geoColType = duckGeoField.type;
492
+ // Extract CRS from type string — may override hyparquet CRS detection
493
+ const typeStr = duckGeoField.type.toUpperCase();
494
+ const crsMatch = duckGeoField.type.match(/^GEOMETRY\('([^']+)'\)/i);
495
+ if (crsMatch) {
496
+ const crsVal = crsMatch[1];
497
+ const isWgs84 =
498
+ crsVal === 'EPSG:4326' ||
499
+ crsVal === 'OGC:CRS84' ||
500
+ (crsVal.startsWith('EPSG:') && [4326, 4979].includes(Number(crsVal.split(':')[1])));
501
+ sourceCrs = isWgs84 ? null : crsVal;
502
+ needsDuckDbCrs = false;
503
+ } else if (typeStr.startsWith('GEOMETRY')) {
504
+ // GEOMETRY without CRS param — still need CRS from metadata
505
+ needsDuckDbCrs = !sourceCrs;
506
+ }
507
+ }
508
+ } catch {
509
+ // Schema refresh failed — continue with hyparquet-detected type
510
+ }
511
+ }
512
+
434
513
  // If hyparquet detected a geo column but couldn't determine CRS
435
514
  // (native Parquet GEOMETRY without "geo" KV metadata), use DuckDB
436
515
  if (metaFromHyparquet && needsDuckDbCrs && geoCol) {
437
516
  try {
438
- sourceCrs = await engine.detectCrs(connId, fileUrl, geoCol);
517
+ sourceCrs = await engine.detectCrs(cid, resolved, geoCol);
439
518
  if (thisGen !== loadGeneration) return;
440
519
  if (sourceCrs) {
441
520
  loadProgress = [...loadProgress, { label: t('progress.crs'), value: sourceCrs }];
@@ -450,16 +529,22 @@ async function loadTable() {
450
529
  }
451
530
  }
452
531
 
453
- if (cloudNative && !metaFromHyparquet) {
454
- // Fallback: DuckDB metadata queries
532
+ // DuckDB-backed schema + CRS pre-load. Runs when:
533
+ // - the source is SQL-backed (attached table, no hyparquet path), OR
534
+ // - it's a cloud-native file and hyparquet metadata failed (CORS, etc.)
535
+ if (!isFileSource || (cloudNative && !metaFromHyparquet)) {
536
+ // Fallback / SQL source: DuckDB metadata queries
455
537
  loadStage = t('table.loadingSchema');
456
538
  loadProgress = [
457
539
  ...loadProgress,
458
- { label: t('progress.source'), value: t('progress.duckdbFallback') }
540
+ {
541
+ label: t('progress.source'),
542
+ value: isFileSource ? t('progress.duckdbFallback') : resolved.label
543
+ }
459
544
  ];
460
545
 
461
546
  if (engine.getSchemaAndCrs) {
462
- const result = await engine.getSchemaAndCrs(connId, fileUrl, findGeoColumn);
547
+ const result = await engine.getSchemaAndCrs(cid, resolved, findGeoColumn);
463
548
  if (thisGen !== loadGeneration) return;
464
549
  schema = result.schema;
465
550
  columns = schema.map((f) => f.name);
@@ -486,7 +571,7 @@ async function loadTable() {
486
571
  }
487
572
  }
488
573
  } else {
489
- schema = await engine.getSchema(connId, fileUrl);
574
+ schema = await engine.getSchema(cid, resolved);
490
575
  if (thisGen !== loadGeneration) return;
491
576
  columns = schema.map((f) => f.name);
492
577
  const colPreview =
@@ -507,7 +592,7 @@ async function loadTable() {
507
592
  ...loadProgress,
508
593
  { label: t('progress.geometry'), value: `${detectedGeoCol} (${geoColType})` }
509
594
  ];
510
- sourceCrs = await engine.detectCrs(connId, fileUrl, detectedGeoCol);
595
+ sourceCrs = await engine.detectCrs(cid, resolved, detectedGeoCol);
511
596
  if (thisGen !== loadGeneration) return;
512
597
  if (sourceCrs) {
513
598
  loadProgress = [...loadProgress, { label: t('progress.crs'), value: sourceCrs }];
@@ -530,13 +615,34 @@ async function loadTable() {
530
615
 
531
616
  loadStage = t('table.runningQuery');
532
617
  const start = performance.now();
533
- const result = await executeQuery(sqlQuery);
618
+ let result = await executeQuery(sqlQuery);
534
619
  if (thisGen !== loadGeneration) return;
620
+
621
+ // If query failed and this is a GeoParquet file, the error may be from
622
+ // auto-conversion of CRS metadata (e.g. "stoi: no conversion").
623
+ // Retry with enable_geoparquet_conversion=false and BLOB handling.
624
+ if (!result && error && isParquet && geoCol && !isLegacyGeoParquet) {
625
+ try {
626
+ await engine.query(cid, 'SET enable_geoparquet_conversion = false');
627
+ geoColType = 'BLOB';
628
+ sqlQuery = buildDefaultSql(0);
629
+ customSql = sqlQuery;
630
+ error = null;
631
+ result = await executeQuery(sqlQuery);
632
+ if (thisGen !== loadGeneration) return;
633
+ } catch {
634
+ // Retry also failed — original error stands
635
+ }
636
+ }
637
+
535
638
  executionTimeMs = Math.round(performance.now() - start);
536
639
 
537
- if (!cloudNative && result) {
538
- // Non-cloud-native (CSV, GeoJSON, etc.): derive schema from the
539
- // query result so we avoid a second full-file download.
640
+ if (isFileSource && !cloudNative && result) {
641
+ // Non-cloud-native file (CSV, GeoJSON, etc.): derive schema from the
642
+ // query result so we avoid a second full-file download. Skipped for
643
+ // SQL-backed sources (attached DuckLake tables), which already have
644
+ // a proper schema from getSchemaAndCrs — re-deriving here would pick
645
+ // up the projected `__wkb` alias and clobber the real geo column.
540
646
  schema = (result.columns ?? []).map((col, i) => ({
541
647
  name: col,
542
648
  type: result.types?.[i] ?? 'VARCHAR',
@@ -591,7 +697,7 @@ async function loadTable() {
591
697
  } else {
592
698
  loadStage = t('table.countingRows');
593
699
  engine
594
- .getRowCount(connId, fileUrl)
700
+ .getRowCount(cid, resolved)
595
701
  .then((count) => {
596
702
  if (thisGen === loadGeneration) {
597
703
  totalRows = count;
@@ -613,11 +719,14 @@ async function loadTable() {
613
719
  }
614
720
 
615
721
  async function executeQuery(sql: string) {
722
+ // Snapshot `connId` — reading the $derived after the effect is destroyed
723
+ // returns a Symbol sentinel and crashes downstream template literals.
724
+ const cid = connId;
616
725
  try {
617
726
  const engine = await getQueryEngine();
618
727
 
619
728
  if (engine.queryCancellable) {
620
- const handle = engine.queryCancellable(connId, sql);
729
+ const handle = engine.queryCancellable(cid, sql);
621
730
  activeHandle = handle;
622
731
  try {
623
732
  const result = await handle.result;
@@ -634,7 +743,7 @@ async function executeQuery(sql: string) {
634
743
  }
635
744
  }
636
745
 
637
- const result = await engine.query(connId, sql);
746
+ const result = await engine.query(cid, sql);
638
747
  columns = result.columns;
639
748
  rows = result.rows;
640
749
  return result;
@@ -664,6 +773,8 @@ async function loadPage(page: number) {
664
773
  }
665
774
 
666
775
  async function runCustomSql() {
776
+ // Snapshot before any await — see note in executeQuery.
777
+ const cid = connId;
667
778
  queryRunning = true;
668
779
  error = null;
669
780
  isCustomQuery = true;
@@ -686,7 +797,7 @@ async function runCustomSql() {
686
797
  timestamp: Date.now(),
687
798
  durationMs: executionTimeMs,
688
799
  rowCount: rows.length,
689
- connectionId: connId || undefined
800
+ connectionId: cid || undefined
690
801
  });
691
802
  } catch (err) {
692
803
  executionTimeMs = Math.round(performance.now() - start);
@@ -698,7 +809,7 @@ async function runCustomSql() {
698
809
  durationMs: executionTimeMs,
699
810
  rowCount: 0,
700
811
  error: error ?? undefined,
701
- connectionId: connId || undefined
812
+ connectionId: cid || undefined
702
813
  });
703
814
  } finally {
704
815
  queryRunning = false;
@@ -902,7 +1013,7 @@ function setStacView() {
902
1013
  <FileInfo
903
1014
  entries={loadProgress}
904
1015
  {schema}
905
- parquetUrl={/\.parquet$/i.test(tab.path) ? buildHttpsUrl(tab) : ''}
1016
+ parquetUrl={/\.parquet$/i.test(tab.path) ? parquetIframeUrl : ''}
906
1017
  />
907
1018
  </div>
908
1019
  {:else if viewMode === 'stac'}
@@ -5,7 +5,7 @@ import { onDestroy, untrack } from 'svelte';
5
5
  import { t } from '../../i18n/index.svelte.js';
6
6
  import { tabResources } from '../../stores/tab-resources.svelte.js';
7
7
  import type { Tab } from '../../types';
8
- import { buildHttpsUrl } from '../../utils/url.js';
8
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
9
9
  import {
10
10
  ensureCodecsRegistered,
11
11
  extractZarrStoreUrl,
@@ -372,7 +372,7 @@ async function addZarrLayer(map: maplibregl.Map) {
372
372
  await ensureCodecsRegistered();
373
373
  const { ZarrLayer } = await import('@carbonplan/zarr-layer');
374
374
 
375
- const storeUrl = buildStoreUrl();
375
+ const storeUrl = await buildStoreUrl();
376
376
  const selector = buildSelector();
377
377
 
378
378
  const opts: any = {
@@ -415,6 +415,35 @@ async function addZarrLayer(map: maplibregl.Map) {
415
415
  opts.spatialDimensions = spatial;
416
416
  }
417
417
 
418
+ // Safety: warn if the array is extremely large without multiscale support.
419
+ // A global-extent array at full resolution can trigger thousands of chunk
420
+ // requests simultaneously, hanging the browser.
421
+ const meta = selectedMeta;
422
+ if (meta?.shape) {
423
+ const dims = meta.dims?.length ? meta.dims : inferDims(meta.name, meta.shape);
424
+ const yIdx = dims.findIndex((d) => ['y', 'lat', 'latitude'].includes(d.toLowerCase()));
425
+ const xIdx = dims.findIndex((d) => ['x', 'lon', 'longitude'].includes(d.toLowerCase()));
426
+ if (yIdx >= 0 && xIdx >= 0) {
427
+ const ySize = meta.shape[yIdx];
428
+ const xSize = meta.shape[xIdx];
429
+ const yChunk = meta.chunks?.[yIdx] ?? ySize;
430
+ const xChunk = meta.chunks?.[xIdx] ?? xSize;
431
+ const yTiles = Math.ceil(ySize / yChunk);
432
+ const xTiles = Math.ceil(xSize / xChunk);
433
+ const totalTiles = yTiles * xTiles;
434
+ // If more than 10 000 tiles at base resolution and no multiscale,
435
+ // the layer will flood the browser with requests at global zoom.
436
+ if (totalTiles > 10_000) {
437
+ error = t('map.zarrTooLarge', {
438
+ tiles: totalTiles.toLocaleString(),
439
+ shape: `${ySize.toLocaleString()} × ${xSize.toLocaleString()}`
440
+ });
441
+ loading = false;
442
+ return;
443
+ }
444
+ }
445
+ }
446
+
418
447
  zarrLayer = new ZarrLayer(opts);
419
448
  map.addLayer(zarrLayer);
420
449
  } catch (err) {
@@ -423,8 +452,8 @@ async function addZarrLayer(map: maplibregl.Map) {
423
452
  }
424
453
  }
425
454
 
426
- function buildStoreUrl(): string {
427
- const rawUrl = buildHttpsUrl(tab).replace(/\/+$/, '');
455
+ async function buildStoreUrl(): Promise<string> {
456
+ const rawUrl = (await buildHttpsUrlAsync(tab)).replace(/\/+$/, '');
428
457
  return extractZarrStoreUrl(rawUrl) ?? rawUrl;
429
458
  }
430
459
 
@@ -9,7 +9,7 @@ import {
9
9
  } from '../ui/resizable/index.js';
10
10
  import { t } from '../../i18n/index.svelte.js';
11
11
  import type { Tab } from '../../types';
12
- import { buildHttpsUrl } from '../../utils/url.js';
12
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
13
13
  import { getUrlView, updateUrlView } from '../../utils/url-state.js';
14
14
  import {
15
15
  computeChunkCount,
@@ -49,7 +49,6 @@ let showingStoreAttrs = $state(false);
49
49
  */
50
50
  const mapArrays = $derived.by(() => {
51
51
  if (!hierarchy) return [];
52
- // Collect root-level array names (coordinate arrays that zarr-layer can find)
53
52
  const rootArrayNames = new Set(
54
53
  hierarchy.root.children.filter((c) => c.kind === 'array').map((c) => c.name)
55
54
  );
@@ -58,7 +57,6 @@ const mapArrays = $derived.by(() => {
58
57
  function walk(n: ZarrNode) {
59
58
  if (n.kind === 'array' && n.shape && n.shape.length >= 2) {
60
59
  const dims = n.dims?.length ? n.dims : inferDims(n.name, n.shape);
61
- // Must have at least one spatial dim pair
62
60
  let hasLat = false;
63
61
  let hasLon = false;
64
62
  for (const d of dims) {
@@ -67,7 +65,6 @@ const mapArrays = $derived.by(() => {
67
65
  if (lower === 'x' || lower === 'lon' || lower === 'longitude') hasLon = true;
68
66
  }
69
67
  if (hasLat && hasLon) {
70
- // Non-spatial dims must have root-level coordinate arrays
71
68
  const nonSpatialResolvable = dims.every(
72
69
  (d) => spatialNames.has(d.toLowerCase()) || rootArrayNames.has(d)
73
70
  );
@@ -123,7 +120,7 @@ async function loadHierarchy() {
123
120
  error = null;
124
121
 
125
122
  try {
126
- const rawUrl = buildHttpsUrl(tab).replace(/\/+$/, '');
123
+ const rawUrl = (await buildHttpsUrlAsync(tab)).replace(/\/+$/, '');
127
124
  const url = extractZarrStoreUrl(rawUrl) ?? rawUrl;
128
125
  const storeName = tab.name.replace(/\.(zarr|zr3)$/, '');
129
126
 
@@ -454,7 +451,7 @@ function selectStoreAttrs() {
454
451
  >
455
452
  {t('zarr.map')}
456
453
  </Button>
457
- {/if}
454
+ {/if}
458
455
  </div>
459
456
  </div>
460
457
  </div>
@@ -12,7 +12,6 @@ import type { Tab } from '../../../types';
12
12
  import { setupSelectionLayer, updateSelection } from '../../../utils/map-selection.js';
13
13
  import { buildPmtilesLayers, getPmtilesProtocol, type PmtilesMetadata } from '../../../utils/pmtiles';
14
14
  import { layerHue } from '../../../utils/pmtiles-tile.js';
15
- import { buildHttpsUrl } from '../../../utils/url.js';
16
15
  import AttributeTable from '../map/AttributeTable.svelte';
17
16
  import MapContainer from '../map/MapContainer.svelte';
18
17
 
@@ -9,8 +9,12 @@ export declare const STORAGE_KEYS: {
9
9
  };
10
10
  /** EPSG codes considered WGS84 (no reprojection needed). */
11
11
  export declare const WGS84_CODES: Set<number>;
12
- /** Default target CRS for ST_Transform. */
13
- export declare const DEFAULT_TARGET_CRS = "EPSG:4326";
12
+ /**
13
+ * Default target CRS for ST_Transform. Uses OGC:CRS84 (longitude, latitude)
14
+ * to match GeoParquet 1.1+ spec and DuckDB v1.5's canonical form.
15
+ * Functionally equivalent to EPSG:4326 under `geometry_always_xy = true`.
16
+ */
17
+ export declare const DEFAULT_TARGET_CRS = "OGC:CRS84";
14
18
  /** DuckDB-WASM initialization timeout in ms. */
15
19
  export declare const DUCKDB_INIT_TIMEOUT_MS = 30000;
16
20
  /** Maximum entries kept in query history. */
package/dist/constants.js CHANGED
@@ -11,8 +11,12 @@ export const STORAGE_KEYS = {
11
11
  // ── Geo / CRS constants ──
12
12
  /** EPSG codes considered WGS84 (no reprojection needed). */
13
13
  export const WGS84_CODES = new Set([4326, 4979]);
14
- /** Default target CRS for ST_Transform. */
15
- export const DEFAULT_TARGET_CRS = 'EPSG:4326';
14
+ /**
15
+ * Default target CRS for ST_Transform. Uses OGC:CRS84 (longitude, latitude)
16
+ * to match GeoParquet 1.1+ spec and DuckDB v1.5's canonical form.
17
+ * Functionally equivalent to EPSG:4326 under `geometry_always_xy = true`.
18
+ */
19
+ export const DEFAULT_TARGET_CRS = 'OGC:CRS84';
16
20
  // ── Query engine constants ──
17
21
  /** DuckDB-WASM initialization timeout in ms. */
18
22
  export const DUCKDB_INIT_TIMEOUT_MS = 30_000;
@@ -53,4 +53,4 @@ export declare function isQueryable(extension: string): boolean;
53
53
  /** Returns the MIME type for a file extension. */
54
54
  export declare function getMimeType(extension: string): string;
55
55
  /** Re-export folder info for components that need it directly. */
56
- export { FOLDER_INFO, DEFAULT_INFO };
56
+ export { DEFAULT_INFO, FOLDER_INFO };
@@ -883,6 +883,16 @@ const EXTENSIONS = {
883
883
  duckdbReadFn: null,
884
884
  mimeType: 'application/octet-stream'
885
885
  },
886
+ '.ducklake': {
887
+ icon: 'Database',
888
+ color: 'text-teal-600 dark:text-teal-400',
889
+ label: 'DuckLake',
890
+ category: 'database',
891
+ viewer: 'database',
892
+ queryable: true,
893
+ duckdbReadFn: null,
894
+ mimeType: 'application/octet-stream'
895
+ },
886
896
  '.sqlite': {
887
897
  icon: 'Database',
888
898
  color: 'text-sky-600 dark:text-sky-400',
@@ -1055,7 +1065,7 @@ export function buildDuckDbSource(pathOrExt, url) {
1055
1065
  * Other cloud-native formats (.fgb, .pmtiles, .zarr) also support range
1056
1066
  * requests but have dedicated viewers and don't go through DuckDB/TableViewer.
1057
1067
  */
1058
- const CLOUD_NATIVE_EXTS = new Set(['.parquet', '.geoparquet', '.gpq', '.gparquet']);
1068
+ const CLOUD_NATIVE_EXTS = new Set(['.parquet', '.geoparquet', '.gpq', '.gparquet', '.ducklake']);
1059
1069
  export function isCloudNativeFormat(pathOrExt) {
1060
1070
  const ext = pathOrExt.includes('.') ? `.${pathOrExt.split('.').pop().toLowerCase()}` : '';
1061
1071
  return CLOUD_NATIVE_EXTS.has(ext);
@@ -1073,4 +1083,4 @@ export function getMimeType(extension) {
1073
1083
  return getFileTypeInfo(extension).mimeType;
1074
1084
  }
1075
1085
  /** Re-export folder info for components that need it directly. */
1076
- export { FOLDER_INFO, DEFAULT_INFO };
1086
+ export { DEFAULT_INFO, FOLDER_INFO };
package/dist/i18n/ar.js CHANGED
@@ -30,6 +30,14 @@ export const ar = {
30
30
  'connection.accessKey': 'مفتاح الوصول',
31
31
  'connection.secretKey': 'المفتاح السري',
32
32
  'connection.credentialNotice': 'يتم تخزين بيانات اعتمادك فقط في مدير كلمات المرور بمتصفحك إذا اخترت حفظها. لا تُرسل أبداً إلى أي خادم خارجي ولا تُخزّن في التخزين المحلي.',
33
+ 'connection.corsTitle': 'إعداد CORS',
34
+ 'connection.corsRequired': 'يتطلب الوصول عبر المتصفح تفعيل CORS على الحاوية. بدونه، سيتم حظر الطلبات.',
35
+ 'connection.corsDefault': 'CORS مفعّل افتراضياً. لا حاجة لأي إعداد.',
36
+ 'connection.corsDocs': 'توثيق CORS الرسمي',
37
+ 'connection.corsCliTitle': 'تفعيل CORS عبر سطر الأوامر',
38
+ 'connection.readOnlyTitle': 'الوصول للقراءة فقط',
39
+ 'connection.readOnlyDocs': 'توثيق الصلاحيات الرسمي',
40
+ 'connection.readOnlyCliTitle': 'تقييد عبر سطر الأوامر',
33
41
  'connection.testSuccess': 'الاتصال ناجح',
34
42
  'connection.testFail': 'فشل الاتصال. تحقق من الإعدادات وحاول مرة أخرى.',
35
43
  'connection.testButton': 'اختبار الاتصال',
@@ -194,6 +202,12 @@ export const ar = {
194
202
  'database.tablesHeader': 'الجداول',
195
203
  'database.loadingTable': 'جارٍ تحميل الجدول...',
196
204
  'database.selectTable': 'اختر جدولاً للتصفح',
205
+ // DuckLake
206
+ 'ducklake.loading': 'جا��ٍ ربط كتالوج DuckLake...',
207
+ 'ducklake.snapshot': 'لقطة',
208
+ 'ducklake.snapshots': 'لقطات',
209
+ 'ducklake.noTables': 'لا توجد جداول في هذا المخطط',
210
+ 'ducklake.extensionHint': 'قد لا يكون امتداد DuckLake متاحاً لهذا الإصدار من DuckDB-WASM.',
197
211
  // PDF Viewer
198
212
  'pdf.badge': 'PDF',
199
213
  'pdf.loading': 'جارٍ تحميل PDF...',
@@ -317,6 +331,7 @@ export const ar = {
317
331
  'map.loadingFgb': 'جارٍ تحميل FlatGeobuf...',
318
332
  'map.loadingCog': 'جارٍ تحميل COG...',
319
333
  'map.loadingZarr': 'جارٍ تحميل بيانات Zarr...',
334
+ 'map.zarrTooLarge': 'المصفوفة كبيرة جداً لعرض الخريطة ({shape}، ~{tiles} بلاطة). يحتاج هذا المجموعة إلى هرم متعدد المقاييس للعرض المبلط.',
320
335
  'map.features': 'معالم',
321
336
  'map.limit': '(الحد)',
322
337
  'map.of': 'من',
@@ -327,6 +342,7 @@ export const ar = {
327
342
  'map.flatgeobufInfo': 'معلومات FlatGeobuf',
328
343
  'map.cogInfo': 'معلومات COG',
329
344
  'map.cogCorsError': 'تعذّر تحميل COG: الخادم لا يسمح بطلبات عبر النطاقات (CORS). يجب استضافة الملف مع تفعيل ترويسات CORS.',
345
+ 'map.cogInvalidTiff': 'هذا الملف ليس ملف TIFF صالح. نوع المحتوى image/tiff لكن الأجزاء الأولى من الملف لا تطابق توقيع TIFF، قد يكون الملف تالفاً أو مشفّراً أو مُعنوناً بشكل خاطئ.',
330
346
  'map.cogUnsupportedFormat': 'يستخدم هذا الملف صيغة {{type}} غير مدعومة لعرض الخريطة. يمكن عرض ملفات COG بصيغة RGB فقط.',
331
347
  'map.noGeoColumn': 'لم يتم اكتشاف عمود هندسي في المخطط',
332
348
  'map.noData': 'لا تتوفر بيانات لعرض الخريطة',
@@ -366,6 +382,15 @@ export const ar = {
366
382
  'mapInfo.columns': 'الأعمدة',
367
383
  'mapInfo.size': 'الحجم',
368
384
  'mapInfo.bands': 'النطاقات',
385
+ // COG Controls
386
+ 'cog.style': 'النمط',
387
+ 'cog.band': 'النطاق',
388
+ 'cog.singleBand': 'نطاق واحد',
389
+ 'cog.colorRamp': 'تدرج الألوان',
390
+ 'cog.pixelValue': 'قيمة البكسل',
391
+ 'cog.reading': 'قراءة البكسل...',
392
+ 'cog.rescale': 'إعادة القياس',
393
+ 'cog.rescaleReset': 'إعادة تعيين',
369
394
  // PMTiles Viewer
370
395
  'pmtiles.mapView': 'خريطة',
371
396
  'pmtiles.archiveView': 'الأرشيف',