@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.
Files changed (76) hide show
  1. package/README.md +6 -3
  2. package/dist/components/browser/FileTreeSidebar.svelte +1 -1
  3. package/dist/components/layout/ConnectionDialog.svelte +35 -3
  4. package/dist/components/layout/Sidebar.svelte +28 -2
  5. package/dist/components/viewers/ArchiveViewer.svelte +4 -4
  6. package/dist/components/viewers/CodeViewer.svelte +72 -19
  7. package/dist/components/viewers/CodeViewer.svelte.d.ts +11 -1
  8. package/dist/components/viewers/CogControls.svelte +151 -22
  9. package/dist/components/viewers/CogControls.svelte.d.ts +5 -1
  10. package/dist/components/viewers/CogViewer.svelte +45 -10
  11. package/dist/components/viewers/CopcViewer.svelte +20 -2
  12. package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
  13. package/dist/components/viewers/MultiCogViewer.svelte +416 -0
  14. package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
  15. package/dist/components/viewers/PmtilesViewer.svelte +2 -2
  16. package/dist/components/viewers/StacMapViewer.svelte +34 -12
  17. package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
  18. package/dist/components/viewers/StacMosaicViewer.svelte +699 -0
  19. package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +9 -0
  20. package/dist/components/viewers/StacTabViewer.svelte +254 -0
  21. package/dist/components/viewers/StacTabViewer.svelte.d.ts +13 -0
  22. package/dist/components/viewers/TableViewer.svelte +50 -21
  23. package/dist/components/viewers/ViewerRouter.svelte +155 -2
  24. package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
  25. package/dist/components/viewers/ZarrMapViewer.svelte +147 -8
  26. package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
  27. package/dist/components/viewers/ZarrViewer.svelte +3 -2
  28. package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
  29. package/dist/i18n/ar.js +28 -0
  30. package/dist/i18n/en.js +28 -0
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.js +2 -0
  33. package/dist/query/index.d.ts +1 -1
  34. package/dist/query/index.js +1 -1
  35. package/dist/query/source.d.ts +12 -0
  36. package/dist/query/source.js +25 -8
  37. package/dist/query/stac-geoparquet.d.ts +31 -0
  38. package/dist/query/stac-geoparquet.js +136 -0
  39. package/dist/query/wasm.js +130 -23
  40. package/dist/storage/adapter.d.ts +9 -0
  41. package/dist/storage/adapter.js +13 -1
  42. package/dist/storage/browser-azure.d.ts +1 -1
  43. package/dist/storage/browser-azure.js +4 -0
  44. package/dist/storage/browser-cloud.d.ts +1 -1
  45. package/dist/storage/browser-cloud.js +7 -0
  46. package/dist/storage/presign.d.ts +13 -0
  47. package/dist/storage/presign.js +55 -0
  48. package/dist/storage/providers.d.ts +6 -0
  49. package/dist/storage/providers.js +13 -2
  50. package/dist/stores/browser.svelte.d.ts +2 -0
  51. package/dist/stores/browser.svelte.js +17 -1
  52. package/dist/stores/connections.svelte.d.ts +38 -23
  53. package/dist/stores/connections.svelte.js +105 -114
  54. package/dist/utils/cog.d.ts +80 -18
  55. package/dist/utils/cog.js +187 -125
  56. package/dist/utils/colormap-sprite.d.ts +39 -0
  57. package/dist/utils/colormap-sprite.js +77 -0
  58. package/dist/utils/connection-identity.d.ts +51 -0
  59. package/dist/utils/connection-identity.js +97 -0
  60. package/dist/utils/host-detection.js +48 -302
  61. package/dist/utils/parquet-metadata.d.ts +7 -1
  62. package/dist/utils/parquet-metadata.js +35 -1
  63. package/dist/utils/stac-geoparquet.d.ts +90 -0
  64. package/dist/utils/stac-geoparquet.js +223 -0
  65. package/dist/utils/stac-hydrate.d.ts +38 -0
  66. package/dist/utils/stac-hydrate.js +243 -0
  67. package/dist/utils/stac.d.ts +136 -0
  68. package/dist/utils/stac.js +176 -0
  69. package/dist/utils/storage-url.d.ts +26 -0
  70. package/dist/utils/storage-url.js +164 -28
  71. package/dist/utils/url.d.ts +13 -0
  72. package/dist/utils/url.js +36 -0
  73. package/dist/utils/wkb.js +22 -8
  74. package/dist/utils/zarr.d.ts +34 -0
  75. package/dist/utils/zarr.js +94 -0
  76. package/package.json +14 -13
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Canonical connection identity.
3
+ *
4
+ * Single source of truth for deciding when two connection configs point at
5
+ * the same bucket. Used by the connections store to deduplicate auto-detect,
6
+ * manual add, and edit flows, so one physical bucket never ends up with
7
+ * multiple competing local records.
8
+ *
9
+ * Identity rules, per provider:
10
+ *
11
+ * azure → (provider, endpoint, bucket) endpoint carries the account
12
+ * gcs → (provider, bucket) GCS bucket names are global
13
+ * s3 → (provider, bucket, region) AWS native: same bucket name
14
+ * can exist in exactly one region,
15
+ * but the region is load-bearing
16
+ * for signing, so a paste with a
17
+ * different region is a distinct
18
+ * connection until the user merges
19
+ * other → (provider, endpoint, bucket) r2, b2, minio, wasabi, storj,
20
+ * digitalocean, contabo, hetzner,
21
+ * linode, ovhcloud, custom
22
+ *
23
+ * Endpoint normalization is aggressive: scheme + host + non-default port +
24
+ * pathname, with trailing slashes and default ports stripped, host lowercased.
25
+ * That collapses the common trip hazards — http vs https, :443 vs empty,
26
+ * trailing slash drift, mixed case host.
27
+ */
28
+ const DEFAULT_PORTS = {
29
+ 'https:': '443',
30
+ 'http:': '80'
31
+ };
32
+ /**
33
+ * Normalize an endpoint URL to a canonical form suitable for equality checks.
34
+ * Empty / whitespace-only input returns `''` (the "no endpoint" sentinel).
35
+ * Non-URL strings are lowercased and stripped of trailing slashes as a best
36
+ * effort so the comparison is still deterministic.
37
+ */
38
+ export function normalizeEndpoint(raw) {
39
+ if (!raw)
40
+ return '';
41
+ const trimmed = raw.trim();
42
+ if (!trimmed)
43
+ return '';
44
+ try {
45
+ const url = new URL(trimmed);
46
+ const scheme = url.protocol.toLowerCase();
47
+ const host = url.hostname.toLowerCase();
48
+ const defaultPort = DEFAULT_PORTS[scheme] ?? '';
49
+ const port = url.port && url.port !== defaultPort ? `:${url.port}` : '';
50
+ const path = url.pathname.replace(/\/+$/, '');
51
+ return `${scheme}//${host}${port}${path}`;
52
+ }
53
+ catch {
54
+ return trimmed.toLowerCase().replace(/\/+$/, '');
55
+ }
56
+ }
57
+ /** Collapse unknown / empty providers to `'s3'`; otherwise lowercase. */
58
+ export function normalizeProvider(provider) {
59
+ if (!provider)
60
+ return 's3';
61
+ const p = provider.trim().toLowerCase();
62
+ if (!p || p === 'unknown')
63
+ return 's3';
64
+ return p;
65
+ }
66
+ /** Bucket names are case-sensitive on some backends, so preserve case. */
67
+ function normalizeBucket(bucket) {
68
+ return (bucket ?? '').trim().replace(/^\/+|\/+$/g, '');
69
+ }
70
+ function normalizeRegion(region) {
71
+ return (region ?? '').trim().toLowerCase();
72
+ }
73
+ /**
74
+ * Produce a canonical key for a connection's identity. Two connection
75
+ * configs with the same identity key point at the same physical bucket.
76
+ * Returns `''` when the config is too incomplete to identify a bucket.
77
+ */
78
+ export function connectionIdentityKey(input) {
79
+ const provider = normalizeProvider(input.provider);
80
+ const bucket = normalizeBucket(input.bucket);
81
+ if (!bucket)
82
+ return '';
83
+ const endpoint = normalizeEndpoint(input.endpoint);
84
+ const region = normalizeRegion(input.region);
85
+ if (provider === 'azure')
86
+ return `azure|${endpoint}|${bucket}`;
87
+ if (provider === 'gcs')
88
+ return `gcs|${bucket}`;
89
+ if (provider === 's3' && !endpoint)
90
+ return `s3|${bucket}|${region}`;
91
+ return `${provider}|${endpoint}|${bucket}`;
92
+ }
93
+ /** Convenience: true when both inputs share the same non-empty identity. */
94
+ export function isSameConnectionIdentity(a, b) {
95
+ const key = connectionIdentityKey(a);
96
+ return key !== '' && key === connectionIdentityKey(b);
97
+ }
@@ -8,7 +8,7 @@
8
8
  * Also extracts `rootPrefix` when the app is hosted inside a subfolder.
9
9
  */
10
10
  import { buildProviderBaseUrl } from '../storage/providers.js';
11
- import { parseStorageUrl } from './storage-url.js';
11
+ import { isKnownBucketHost, parseStorageUrl } from './storage-url.js';
12
12
  /**
13
13
  * Extract root prefix from pathname.
14
14
  * When hosted at `/subfolder/index.html` or `/subfolder/`, returns `subfolder/`.
@@ -32,6 +32,24 @@ function extractRootPrefix(pathname) {
32
32
  function buildBucketUrl(provider, endpoint, bucket, region) {
33
33
  return buildProviderBaseUrl((provider === 'unknown' ? 's3' : provider), endpoint, bucket, region || '');
34
34
  }
35
+ /**
36
+ * Translate a `ParsedStorageUrl` into a `DetectedHost`. Returns null when the
37
+ * parser did not recognize a bucket or when the host is not a known provider
38
+ * pattern (prevents arbitrary custom endpoints from being auto-connected).
39
+ */
40
+ function parsedToHost(parsed, host, rootPrefix) {
41
+ if (!parsed.bucket || !isKnownBucketHost(host))
42
+ return null;
43
+ const provider = parsed.provider === 'unknown' ? 's3' : parsed.provider;
44
+ return {
45
+ provider,
46
+ bucket: parsed.bucket,
47
+ region: parsed.region,
48
+ endpoint: parsed.endpoint,
49
+ rootPrefix,
50
+ bucketUrl: buildBucketUrl(parsed.provider, parsed.endpoint, parsed.bucket, parsed.region)
51
+ };
52
+ }
35
53
  /**
36
54
  * Detect hosting bucket from current URL.
37
55
  * Returns null when no hosting bucket can be determined.
@@ -40,310 +58,38 @@ export function detectHostBucket() {
40
58
  if (typeof window === 'undefined')
41
59
  return null;
42
60
  const url = new URL(window.location.href);
43
- const host = url.hostname;
44
- // --- Priority 1: ?url= query parameter ---
61
+ // Priority 1: ?url= query parameter
45
62
  const urlParam = url.searchParams.get('url');
46
63
  if (urlParam) {
47
- const parsed = parseStorageUrl(urlParam);
48
- if (parsed.bucket) {
49
- return {
50
- provider: parsed.provider === 'unknown' ? 's3' : parsed.provider,
51
- bucket: parsed.bucket,
52
- region: parsed.region,
53
- endpoint: parsed.endpoint,
54
- rootPrefix: '',
55
- bucketUrl: buildBucketUrl(parsed.provider, parsed.endpoint, parsed.bucket)
56
- };
57
- }
58
- }
59
- // --- Priority 2: Hostname pattern matching ---
60
- const pathname = url.pathname;
61
- // AWS S3 virtual-hosted: <bucket>.s3.<region>.amazonaws.com
62
- const awsVhost = host.match(/^(.+)\.s3[.-]([a-z0-9-]+)\.amazonaws\.com$/);
63
- if (awsVhost) {
64
- return {
65
- provider: 's3',
66
- bucket: awsVhost[1],
67
- region: awsVhost[2],
68
- endpoint: '',
69
- rootPrefix: extractRootPrefix(pathname),
70
- bucketUrl: `https://s3.${awsVhost[2]}.amazonaws.com/${awsVhost[1]}`
71
- };
72
- }
73
- // AWS S3 website hosting: <bucket>.s3-website-<region>.amazonaws.com or <bucket>.s3-website.<region>.amazonaws.com
74
- const awsWebsite = host.match(/^(.+)\.s3-website[.-]([a-z0-9-]+)\.amazonaws\.com$/);
75
- if (awsWebsite) {
76
- return {
77
- provider: 's3',
78
- bucket: awsWebsite[1],
79
- region: awsWebsite[2],
80
- endpoint: '',
81
- rootPrefix: extractRootPrefix(pathname),
82
- bucketUrl: `https://s3.${awsWebsite[2]}.amazonaws.com/${awsWebsite[1]}`
83
- };
84
- }
85
- // AWS S3 path-style: s3.<region>.amazonaws.com/<bucket>
86
- const awsPath = host.match(/^s3[.-]([a-z0-9-]+)\.amazonaws\.com$/);
87
- if (awsPath) {
88
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
89
- if (parts.length > 0) {
90
- return {
91
- provider: 's3',
92
- bucket: parts[0],
93
- region: awsPath[1],
94
- endpoint: '',
95
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
96
- bucketUrl: `https://s3.${awsPath[1]}.amazonaws.com/${parts[0]}`
97
- };
98
- }
99
- }
100
- // GCS: storage.googleapis.com/<bucket>
101
- if (host === 'storage.googleapis.com') {
102
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
103
- if (parts.length > 0) {
104
- return {
105
- provider: 'gcs',
106
- bucket: parts[0],
107
- region: 'us',
108
- endpoint: '',
109
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
110
- bucketUrl: `https://storage.googleapis.com/${parts[0]}`
111
- };
112
- }
113
- }
114
- // GCS virtual-hosted: <bucket>.storage.googleapis.com
115
- const gcsVhost = host.match(/^(.+)\.storage\.googleapis\.com$/);
116
- if (gcsVhost) {
117
- return {
118
- provider: 'gcs',
119
- bucket: gcsVhost[1],
120
- region: 'us',
121
- endpoint: '',
122
- rootPrefix: extractRootPrefix(pathname),
123
- bucketUrl: `https://storage.googleapis.com/${gcsVhost[1]}`
124
- };
125
- }
126
- // Azure Static Website: <account>.z<N>.web.core.windows.net
127
- const azureWeb = host.match(/^([a-z0-9]+)\.z\d+\.web\.core\.windows\.net$/);
128
- if (azureWeb) {
129
- return {
130
- provider: 'azure',
131
- bucket: '$web',
132
- region: '',
133
- endpoint: `https://${azureWeb[1]}.blob.core.windows.net`,
134
- rootPrefix: extractRootPrefix(pathname),
135
- bucketUrl: `https://${azureWeb[1]}.blob.core.windows.net/$web`
136
- };
137
- }
138
- // Azure Blob: <account>.blob.core.windows.net/<container>
139
- const azureBlob = host.match(/^([a-z0-9]+)\.blob\.core\.windows\.net$/);
140
- if (azureBlob) {
141
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
142
- if (parts.length > 0) {
143
- return {
144
- provider: 'azure',
145
- bucket: parts[0],
146
- region: '',
147
- endpoint: `https://${host}`,
148
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
149
- bucketUrl: `https://${host}/${parts[0]}`
150
- };
151
- }
152
- }
153
- // R2 public: pub-<id>.r2.dev
154
- const r2Public = host.match(/^(pub-[a-z0-9]+)\.r2\.dev$/);
155
- if (r2Public) {
156
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
157
- return {
158
- provider: 'r2',
159
- bucket: parts[0] || r2Public[1],
160
- region: 'auto',
161
- endpoint: `https://${host}`,
162
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
163
- bucketUrl: `https://${host}`
164
- };
165
- }
166
- // DigitalOcean Spaces: <bucket>.<region>.digitaloceanspaces.com
167
- const doSpaces = host.match(/^(.+)\.([a-z0-9-]+)\.digitaloceanspaces\.com$/);
168
- if (doSpaces) {
169
- return {
170
- provider: 'digitalocean',
171
- bucket: doSpaces[1],
172
- region: doSpaces[2],
173
- endpoint: `https://${doSpaces[2]}.digitaloceanspaces.com`,
174
- rootPrefix: extractRootPrefix(pathname),
175
- bucketUrl: `https://${doSpaces[2]}.digitaloceanspaces.com/${doSpaces[1]}`
176
- };
177
- }
178
- // DigitalOcean Spaces CDN: <bucket>.<region>.cdn.digitaloceanspaces.com
179
- const doCdn = host.match(/^(.+)\.([a-z0-9-]+)\.cdn\.digitaloceanspaces\.com$/);
180
- if (doCdn) {
181
- return {
182
- provider: 'digitalocean',
183
- bucket: doCdn[1],
184
- region: doCdn[2],
185
- endpoint: `https://${doCdn[2]}.digitaloceanspaces.com`,
186
- rootPrefix: extractRootPrefix(pathname),
187
- bucketUrl: `https://${doCdn[2]}.digitaloceanspaces.com/${doCdn[1]}`
188
- };
189
- }
190
- // Wasabi: s3.<region>.wasabisys.com/<bucket>
191
- const wasabi = host.match(/^s3\.([a-z0-9-]+)\.wasabisys\.com$/);
192
- if (wasabi) {
193
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
194
- if (parts.length > 0) {
195
- return {
196
- provider: 'wasabi',
197
- bucket: parts[0],
198
- region: wasabi[1],
199
- endpoint: `https://${host}`,
200
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
201
- bucketUrl: `https://${host}/${parts[0]}`
202
- };
203
- }
204
- }
205
- // B2 S3: <bucket>.s3.<region>.backblazeb2.com
206
- const b2s3 = host.match(/^(.+)\.s3\.([a-z0-9-]+)\.backblazeb2\.com$/);
207
- if (b2s3) {
208
- return {
209
- provider: 'b2',
210
- bucket: b2s3[1],
211
- region: b2s3[2],
212
- endpoint: `https://s3.${b2s3[2]}.backblazeb2.com`,
213
- rootPrefix: extractRootPrefix(pathname),
214
- bucketUrl: `https://s3.${b2s3[2]}.backblazeb2.com/${b2s3[1]}`
215
- };
216
- }
217
- // IBM COS: <bucket>.s3-web.<region>.cloud-object-storage.appdomain.cloud
218
- const ibmCos = host.match(/^(.+)\.s3-web\.([a-z0-9-]+)\.cloud-object-storage\.appdomain\.cloud$/);
219
- if (ibmCos) {
220
- return {
221
- provider: 's3',
222
- bucket: ibmCos[1],
223
- region: ibmCos[2],
224
- endpoint: `https://s3.${ibmCos[2]}.cloud-object-storage.appdomain.cloud`,
225
- rootPrefix: extractRootPrefix(pathname),
226
- bucketUrl: `https://s3.${ibmCos[2]}.cloud-object-storage.appdomain.cloud/${ibmCos[1]}`
227
- };
228
- }
229
- // Storj S3 gateway: gateway.storjshare.io/<bucket> (or gateway.<region>.storjshare.io)
230
- const storjGateway = host.match(/^gateway\.(?:([a-z0-9]+)\.)?storjshare\.io$/);
231
- if (storjGateway) {
232
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
233
- if (parts.length > 0) {
234
- return {
235
- provider: 'storj',
236
- bucket: parts[0],
237
- region: storjGateway[1] || 'us1',
238
- endpoint: `${url.protocol}//${url.host}`,
239
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
240
- bucketUrl: `${url.protocol}//${url.host}/${parts[0]}`
241
- };
242
- }
243
- }
244
- // Storj linksharing: link.storjshare.io/raw/<access>/<bucket>/...
245
- const storjLink = host.match(/^link\.(?:([a-z0-9]+)\.)?storjshare\.io$/);
246
- if (storjLink) {
247
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
248
- if (parts.length >= 3 && (parts[0] === 'raw' || parts[0] === 's')) {
249
- return {
250
- provider: 'storj',
251
- bucket: parts[2],
252
- region: storjLink[1] || 'us1',
253
- endpoint: `${url.protocol}//${url.host}/${parts[0]}/${parts[1]}`,
254
- rootPrefix: parts.length > 3 ? extractRootPrefix(`/${parts.slice(3).join('/')}`) : '',
255
- bucketUrl: `${url.protocol}//${url.host}/${parts[0]}/${parts[1]}/${parts[2]}`
256
- };
257
- }
258
- }
259
- // Contabo: <region>.contabostorage.com/<bucket>
260
- const contabo = host.match(/^([a-z0-9]+)\.contabostorage\.com$/);
261
- if (contabo) {
262
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
263
- if (parts.length > 0) {
264
- return {
265
- provider: 'contabo',
266
- bucket: parts[0],
267
- region: contabo[1],
268
- endpoint: `${url.protocol}//${url.host}`,
269
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
270
- bucketUrl: `${url.protocol}//${url.host}/${parts[0]}`
271
- };
64
+ try {
65
+ const paramUrl = new URL(urlParam);
66
+ const parsed = parseStorageUrl(urlParam);
67
+ const host = parsedToHost(parsed, paramUrl.hostname, '');
68
+ if (host)
69
+ return host;
272
70
  }
273
- }
274
- // Hetzner: <region>.your-objectstorage.com/<bucket>
275
- const hetzner = host.match(/^([a-z0-9]+)\.your-objectstorage\.com$/);
276
- if (hetzner) {
277
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
278
- if (parts.length > 0) {
279
- return {
280
- provider: 'hetzner',
281
- bucket: parts[0],
282
- region: hetzner[1],
283
- endpoint: `${url.protocol}//${url.host}`,
284
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
285
- bucketUrl: `${url.protocol}//${url.host}/${parts[0]}`
286
- };
71
+ catch {
72
+ // Not a parseable URL, fall through to location-based detection.
287
73
  }
288
74
  }
289
- // Linode / Akamai: <bucket>.<region>.linodeobjects.com or <region>.linodeobjects.com/<bucket>
290
- const linodeVhost = host.match(/^(.+)\.([a-z0-9-]+)\.linodeobjects\.com$/);
291
- if (linodeVhost) {
292
- return {
293
- provider: 'linode',
294
- bucket: linodeVhost[1],
295
- region: linodeVhost[2],
296
- endpoint: `https://${linodeVhost[2]}.linodeobjects.com`,
297
- rootPrefix: extractRootPrefix(pathname),
298
- bucketUrl: `https://${linodeVhost[2]}.linodeobjects.com/${linodeVhost[1]}`
299
- };
300
- }
301
- const linodePath = host.match(/^([a-z0-9-]+)\.linodeobjects\.com$/);
302
- if (linodePath) {
303
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
304
- if (parts.length > 0) {
305
- return {
306
- provider: 'linode',
307
- bucket: parts[0],
308
- region: linodePath[1],
309
- endpoint: `https://${url.host}`,
310
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
311
- bucketUrl: `https://${url.host}/${parts[0]}`
312
- };
313
- }
314
- }
315
- // OVHcloud: s3.<region>.io.cloud.ovh.net/<bucket>
316
- const ovh = host.match(/^s3\.([a-z0-9-]+)\.io\.cloud\.ovh\.(?:net|us)$/);
317
- if (ovh) {
318
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
319
- if (parts.length > 0) {
320
- return {
321
- provider: 'ovhcloud',
322
- bucket: parts[0],
323
- region: ovh[1],
324
- endpoint: `https://${url.host}`,
325
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
326
- bucketUrl: `https://${url.host}/${parts[0]}`
327
- };
328
- }
329
- }
330
- // MinIO / localhost / private IPs
331
- const isLocal = host === 'localhost' ||
332
- host === '127.0.0.1' ||
333
- host.startsWith('192.168.') ||
334
- host.startsWith('10.');
335
- if (isLocal) {
336
- const parts = pathname.replace(/^\//, '').split('/').filter(Boolean);
337
- if (parts.length > 0) {
338
- return {
339
- provider: 'minio',
340
- bucket: parts[0],
341
- region: 'us-east-1',
342
- endpoint: `${url.protocol}//${url.host}`,
343
- rootPrefix: parts.length > 1 ? extractRootPrefix(`/${parts.slice(1).join('/')}`) : '',
344
- bucketUrl: `${url.protocol}//${url.host}/${parts[0]}`
345
- };
346
- }
347
- }
348
- return null;
75
+ // Priority 2: window.location pattern matching via the unified parser.
76
+ // `parseStorageUrl` extracts bucket + any remaining prefix from the
77
+ // current URL. The prefix is used as the in-bucket sub-folder the app
78
+ // should root into when auto-connecting.
79
+ const parsed = parseStorageUrl(window.location.href);
80
+ if (!parsed.bucket || !isKnownBucketHost(url.hostname))
81
+ return null;
82
+ // `rootPrefix` is the in-bucket sub-folder, possibly trailing with an
83
+ // `index.html` style filename, which `extractRootPrefix` strips.
84
+ const prefixPath = parsed.prefix ? `/${parsed.prefix}` : '';
85
+ const rootPrefix = extractRootPrefix(prefixPath || '/');
86
+ const provider = parsed.provider === 'unknown' ? 's3' : parsed.provider;
87
+ return {
88
+ provider,
89
+ bucket: parsed.bucket,
90
+ region: parsed.region,
91
+ endpoint: parsed.endpoint,
92
+ rootPrefix,
93
+ bucketUrl: buildBucketUrl(parsed.provider, parsed.endpoint, parsed.bucket, parsed.region)
94
+ };
349
95
  }
@@ -20,11 +20,17 @@ export interface GeoParquetMeta {
20
20
  export interface ParquetFileMetadata {
21
21
  /** Total number of rows across all row groups. */
22
22
  rowCount: number;
23
- /** Column schema (name + type). */
23
+ /** Column schema (name + type). Leaf columns only (structs flattened to children). */
24
24
  schema: {
25
25
  name: string;
26
26
  type: string;
27
27
  }[];
28
+ /**
29
+ * Top-level column names as written. Includes struct/group parents (e.g.
30
+ * `assets`, `bbox`) that `schema` flattens away. Required for stac-geoparquet
31
+ * detection, which looks up struct columns by their parent name.
32
+ */
33
+ topLevelColumns: string[];
28
34
  /** GeoParquet "geo" metadata if present. */
29
35
  geo: GeoParquetMeta | null;
30
36
  /** True when file has legacy GeoParquet metadata (schema_version 0.x without "version" field). */
@@ -114,6 +114,19 @@ export async function readParquetMetadata(url) {
114
114
  name: col.name,
115
115
  type: mapParquetType(col)
116
116
  }));
117
+ // Walk depth-first to collect top-level column names (including struct parents).
118
+ // hyparquet serializes the schema as a flat depth-first array where the root
119
+ // carries `num_children` and each group element claims the next N siblings.
120
+ const topLevelColumns = [];
121
+ const root = metadata.schema[0];
122
+ const rootChildren = root?.num_children ?? 0;
123
+ let cursor = 1;
124
+ for (let i = 0; i < rootChildren && cursor < metadata.schema.length; i++) {
125
+ const el = metadata.schema[cursor];
126
+ if (el?.name)
127
+ topLevelColumns.push(el.name);
128
+ cursor += countSubtree(metadata.schema, cursor);
129
+ }
117
130
  // GeoParquet "geo" key-value metadata
118
131
  let geo = null;
119
132
  let legacyGeoParquet = false;
@@ -167,7 +180,28 @@ export async function readParquetMetadata(url) {
167
180
  compression = [...codecs].join(', ');
168
181
  }
169
182
  }
170
- return { rowCount, schema, geo, legacyGeoParquet, createdBy, numRowGroups, compression };
183
+ return {
184
+ rowCount,
185
+ schema,
186
+ topLevelColumns,
187
+ geo,
188
+ legacyGeoParquet,
189
+ createdBy,
190
+ numRowGroups,
191
+ compression
192
+ };
193
+ }
194
+ /** Count how many schema elements belong to the subtree rooted at `start` (inclusive). */
195
+ function countSubtree(schema, start) {
196
+ const el = schema[start];
197
+ if (!el)
198
+ return 0;
199
+ const n = el.num_children ?? 0;
200
+ let cursor = start + 1;
201
+ for (let i = 0; i < n; i++) {
202
+ cursor += countSubtree(schema, cursor);
203
+ }
204
+ return cursor - start;
171
205
  }
172
206
  /**
173
207
  * Extract EPSG code from GeoParquet CRS metadata.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * stac-geoparquet helpers.
3
+ *
4
+ * Pure TypeScript, zero Svelte / DuckDB / deck.gl dependencies. Re-exported
5
+ * through `@walkthru-earth/objex-utils` so other projects can consume the
6
+ * detection + row-to-Item transforms without pulling the Svelte lib.
7
+ *
8
+ * The module is decoupled from WKB decoding: callers pass a `wkbParser`
9
+ * callback (the objex Svelte lib threads its `parseWKB`; other consumers
10
+ * can plug in `geoarrow-wasm`, `wkx`, or any other library).
11
+ */
12
+ import type { StacAsset, StacItem } from './stac.js';
13
+ /** Minimal schema column shape. Works with hyparquet, DuckDB, Arrow. */
14
+ export interface StacGeoparquetSchemaColumn {
15
+ name: string;
16
+ type?: string;
17
+ }
18
+ /** Bbox in struct shape as produced by DuckDB's `bbox struct(xmin,ymin,xmax,ymax)`. */
19
+ export interface StacBboxStruct {
20
+ xmin: number;
21
+ ymin: number;
22
+ xmax: number;
23
+ ymax: number;
24
+ }
25
+ /** Generic Record shape representing a single stac-geoparquet row after DuckDB decoding. */
26
+ export type StacGeoparquetRow = Record<string, unknown>;
27
+ export interface StacRowToItemOptions {
28
+ /**
29
+ * Decoder for the geometry column. Accepts a Uint8Array of WKB bytes and
30
+ * returns a GeoJSON geometry object (or null on failure).
31
+ *
32
+ * Consumers in the objex Svelte lib should pass `parseWKB` from
33
+ * `@walkthru-earth/objex-utils`. Other projects can use any WKB library.
34
+ */
35
+ wkbParser?: (bytes: Uint8Array) => unknown;
36
+ /**
37
+ * Column name holding the WKB bytes. Defaults to `'geom_wkb'` because the
38
+ * recommended SQL projection is `ST_AsWKB(geometry) AS geom_wkb`.
39
+ */
40
+ wkbColumn?: string;
41
+ /** Override the column holding the pre-decoded GeoJSON geometry, when available. */
42
+ geometryColumn?: string;
43
+ }
44
+ /** Columns every stac-geoparquet file MUST carry per the stac-geoparquet spec. */
45
+ export declare const STAC_GEOPARQUET_REQUIRED_COLUMNS: readonly ["stac_version", "type", "geometry", "assets"];
46
+ /**
47
+ * Detect stac-geoparquet by presence of the required STAC columns.
48
+ *
49
+ * Deliberately type-agnostic: some pipelines know the type (DuckDB DESCRIBE,
50
+ * Arrow Field), others only have the name list (hyparquet schema walk). The
51
+ * set of names is sufficient for routing.
52
+ */
53
+ export declare function isStacGeoparquetSchema(schema: StacGeoparquetSchemaColumn[]): boolean;
54
+ /**
55
+ * Flatten a DuckDB `struct(xmin,ymin,xmax,ymax)` bbox to the `[minX, minY, maxX, maxY]`
56
+ * array shape that STAC Items and deck.gl-geotiff MosaicLayer expect.
57
+ *
58
+ * Pass-through for arrays so callers that already have `[minX,minY,maxX,maxY]`
59
+ * shape (e.g. from a Feature's `bbox` field) don't need a separate path.
60
+ */
61
+ export declare function flattenStacBbox(bbox: StacBboxStruct | number[] | null | undefined): [number, number, number, number] | null;
62
+ /**
63
+ * Resolve a possibly-relative STAC asset href against the parquet file URL.
64
+ *
65
+ * `./foo.tif` or `foo.tif` → absolute against `baseUrl`. Absolute URLs
66
+ * (`http(s)://`, `s3://`, `gs://`, etc.) are returned unchanged.
67
+ */
68
+ export declare function resolveStacAssetHref(href: string, baseUrl: string): string;
69
+ /**
70
+ * Pick the "primary" asset from a STAC Item's `assets` map.
71
+ *
72
+ * Priority: caller-specified `preferredKeys` → `data` key → first asset with
73
+ * `roles` containing `'data'` → first asset. Returns `null` if the map is
74
+ * empty or not an object.
75
+ */
76
+ export declare function pickStacPrimaryAsset(assets: Record<string, StacAsset> | null | undefined, preferredKeys?: readonly string[]): {
77
+ key: string;
78
+ asset: StacAsset;
79
+ } | null;
80
+ /**
81
+ * Convert one stac-geoparquet row into a standard STAC Item JSON object.
82
+ *
83
+ * Handles:
84
+ * - `assets` named-struct flattening + relative href resolution
85
+ * - `bbox` struct → `[minX, minY, maxX, maxY]` array
86
+ * - Optional WKB geometry → GeoJSON via `opts.wkbParser`
87
+ * - `datetime` → ISO string (passes through already-string values)
88
+ * - Promotes `properties.*` columns (`proj:*`, `datetime`) onto `item.properties`
89
+ */
90
+ export declare function stacRowToItem(row: StacGeoparquetRow, baseUrl: string, opts?: StacRowToItemOptions): StacItem;