diff --git a/.env.example b/.env.example index d5b76c1..6b6f0a5 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,4 @@ NEXT_PUBLIC_RISK_ZARR_URL= NEXT_PUBLIC_REGIONS_URL= HERE_API_KEY= NEXT_PUBLIC_ADVANCED_MODE= -NEXT_PUBLIC_OCR_ENV= +NEXT_PUBLIC_GEOPARQUET_URL= diff --git a/components/results/download.tsx b/components/results/download.tsx index 6b29d19..6e36aa5 100644 --- a/components/results/download.tsx +++ b/components/results/download.tsx @@ -1,14 +1,15 @@ -import { useState } from 'react' -import { Flex, Spinner } from 'theme-ui' +import { useEffect, useRef, useState } from 'react' +import { Box, Flex, Spinner } from 'theme-ui' //@ts-expect-error - carbonplan components types not available import { Button } from '@carbonplan/components' //@ts-expect-error - carbonplan icons types not available -import { Down } from '@carbonplan/icons' -import { DATA_URLS, DATA_VERSION } from '@/lib/config' +import { Down, X } from '@carbonplan/icons' +import { DATA_VERSION, DATA_URLS, LICENSE_INFO } from '@/lib/config' import { useStore } from '@/lib/store' import { getGeographyName, getGeoid } from '@/lib/risk-utils' import { GeographyKey } from '@/types/location' import useTracking from '@/hooks/useTracking' +import { getDuckDB } from '@/lib/duckdb' export const DownloadButton = ({ label, @@ -27,30 +28,47 @@ export const DownloadButton = ({ href?: string showSuffix?: boolean }) => { + const [hovered, setHovered] = useState(false) + const [keyboardFocused, setKeyboardFocused] = useState(false) + const showCancel = loading && (hovered || keyboardFocused) + let suffix if (showSuffix) { - suffix = if (loading) { - suffix = + suffix = showCancel ? : + } else { + suffix = } } return ( - + + ) } @@ -60,9 +78,246 @@ const REGION_TYPES: Partial> = { censusBlock: 'block', } +const S3_BUCKET = new URL(DATA_URLS.parquetBase).origin + +const DURATION_BUCKETS: [number, string][] = [ + [1000, '<1s'], + [5000, '1-5s'], + [15000, '5-15s'], + [60000, '15-60s'], + [180000, '1-3m'], + [300000, '3-5m'], +] +const bucketDuration = (ms: number) => + DURATION_BUCKETS.find(([max]) => ms < max)?.[1] ?? '5m+' + +// Single source of truth for CSV output columns: [header-name, sql-expression]. +// Score columns are cast to FLOAT because DuckDB-WASM otherwise promotes them +// to DOUBLE in the COPY pipeline. +const CSV_COLUMNS: [string, string][] = [ + ['GEOID', 'GEOID'], + ['longitude', 'ROUND(ST_X(ST_Centroid(geometry)), 6)'], + ['latitude', 'ROUND(ST_Y(ST_Centroid(geometry)), 6)'], + ['rps_2011', 'rps_2011::FLOAT'], + ['rps_2047', 'rps_2047::FLOAT'], + ['bp_2011', 'bp_2011::FLOAT'], + ['bp_2047', 'bp_2047::FLOAT'], + ['crps_scott', 'crps_scott::FLOAT'], + ['bp_2011_riley', 'bp_2011_riley::FLOAT'], + ['bp_2047_riley', 'bp_2047_riley::FLOAT'], +] +const CSV_SELECT = CSV_COLUMNS.map(([name, expr]) => `${expr} AS ${name}`).join( + ', ', +) +const CSV_HEADER = CSV_COLUMNS.map(([name]) => name).join(',') + '\n' + +function trimGeoid(geoid: string, regionType: string): string { + if (regionType === 'county') return geoid.slice(0, 5) + if (regionType === 'tract') return geoid.slice(0, 11) + return geoid +} + +async function getPartitionUrls( + geoid: string, + signal?: AbortSignal, +): Promise { + const stateFips = geoid.slice(0, 2) + const countyFips = geoid.slice(2, 5) + const prefix = DATA_URLS.parquetBase.replace(S3_BUCKET + '/', '') + const partitionPrefix = `${prefix}/state_fips=${stateFips}/county_fips=${countyFips}/` + + const res = await fetch( + `${S3_BUCKET}/?list-type=2&prefix=${partitionPrefix}`, + { signal }, + ) + const doc = new DOMParser().parseFromString( + await res.text(), + 'application/xml', + ) + + const urls: string[] = [] + for (const el of doc.querySelectorAll('Contents > Key')) { + const key = el.textContent + if (key?.endsWith('.parquet')) urls.push(`${S3_BUCKET}/${key}`) + } + + if (urls.length === 0) { + throw new Error( + `No data found for state=${stateFips}, county=${countyFips}`, + ) + } + return urls +} + +function triggerBlobDownload(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +// One COPY per partition file, pulled out of the VFS between iterations — +// keeps DuckDB's working set bounded by a single partition so very large +// counties (e.g. LA) don't blow the WASM heap. +async function copyParquetTo( + geoid: string, + regionType: string, + select: string, + formatClause: string, + signal?: AbortSignal, +): Promise[]> { + const [db, partitionUrls] = await Promise.all([ + getDuckDB(), + getPartitionUrls(geoid, signal), + ]) + signal?.throwIfAborted() + const conn = await db.connect() + const trimmedGeoid = trimGeoid(geoid, regionType) + // Per-call UUID so concurrent downloads (e.g. CSV + GeoJSON at once) can't + // collide on VFS paths. + const runId = crypto.randomUUID() + // Best-effort interrupt the in-flight query when abort fires. + const onAbort = () => { + conn.cancelSent().catch(() => {}) + } + signal?.addEventListener('abort', onAbort) + + try { + const chunks: Uint8Array[] = [] + for (let i = 0; i < partitionUrls.length; i++) { + signal?.throwIfAborted() + const outPath = `/tmp/dl-${runId}-${i}.out` + try { + await conn.query(` + COPY ( + SELECT ${select} + FROM read_parquet('${partitionUrls[i]}') + WHERE GEOID LIKE '${trimmedGeoid}%' + ) TO '${outPath}' (${formatClause}) + `) + // If abort fired during the query, surface as AbortError rather than + // whatever DuckDB threw from cancelSent. + signal?.throwIfAborted() + const chunk = await db.copyFileToBuffer(outPath) + if (chunk.length > 0) { + chunks.push(chunk as Uint8Array) + } + } finally { + try { + await db.dropFile(outPath) + } catch { + // ignore cleanup errors + } + } + } + if (chunks.length === 0) { + throw new Error(`No building data found for GEOID: ${trimmedGeoid}`) + } + return chunks + } finally { + signal?.removeEventListener('abort', onAbort) + await conn.close() + } +} + +async function downloadCSV( + geoid: string, + regionType: string, + filename: string, + signal?: AbortSignal, +) { + const chunks = await copyParquetTo( + geoid, + regionType, + CSV_SELECT, + 'FORMAT CSV, HEADER false', + signal, + ) + + const metadata = `# OCR Dataset Version: ${DATA_VERSION} +# Provider: ${LICENSE_INFO.provider} +# License: ${LICENSE_INFO.licenseName} (${LICENSE_INFO.licenseUrl}) +# Terms of Access: ${LICENSE_INFO.termsOfAccess} +# Data Sources: ${LICENSE_INFO.dataSources} +# Notice: ${LICENSE_INFO.notice} +# ------------------------------------------ +` + triggerBlobDownload( + new Blob([metadata, CSV_HEADER, ...chunks], { type: 'text/csv' }), + `${filename}.csv`, + ) +} + +async function downloadGeoJSON( + geoid: string, + regionType: string, + filename: string, + signal?: AbortSignal, +) { + // Build each GeoJSON Feature entirely in SQL via ST_AsGeoJSON + to_json, + // then COPY TO streams them to the VFS — no JS JSON parsing needed. + const chunks = await copyParquetTo( + geoid, + regionType, + // Build properties manually so FLOAT::VARCHAR gives the same precision + // as the CSV output. to_json would promote FLOATs to DOUBLE precision. + // COALESCE to 'null' so a NULL column doesn't nullify the whole feature. + `'{"type":"Feature","geometry":' || ST_AsGeoJSON(ST_ReducePrecision(ST_Centroid(geometry), 0.000001)) + || ',"properties":{"GEOID":"' || GEOID + || '","rps_2011":' || COALESCE(rps_2011::FLOAT::VARCHAR, 'null') + || ',"rps_2047":' || COALESCE(rps_2047::FLOAT::VARCHAR, 'null') + || ',"bp_2011":' || COALESCE(bp_2011::FLOAT::VARCHAR, 'null') + || ',"bp_2047":' || COALESCE(bp_2047::FLOAT::VARCHAR, 'null') + || ',"crps_scott":' || COALESCE(crps_scott::FLOAT::VARCHAR, 'null') + || ',"bp_2011_riley":' || COALESCE(bp_2011_riley::FLOAT::VARCHAR, 'null') + || ',"bp_2047_riley":' || COALESCE(bp_2047_riley::FLOAT::VARCHAR, 'null') + || '}},' AS feature`, + `FORMAT CSV, HEADER false, QUOTE E'\\x01', DELIMITER E'\\x02'`, + signal, + ) + + // Each chunk is one Feature JSON per line, each with trailing comma. + // Strip the last comma from the final chunk and wrap with FeatureCollection. + const last = chunks[chunks.length - 1] + let end = last.length - 1 + while (end > 0 && last[end] !== 44) end-- // find last comma (0x2C) + // subarray is a view (slice would copy). Cast narrows ArrayBufferLike → + // ArrayBuffer so BlobPart accepts it. + chunks[chunks.length - 1] = last.subarray(0, end) as Uint8Array + + const metadata = JSON.stringify({ + dataset_version: DATA_VERSION, + provider: LICENSE_INFO.provider, + license: `${LICENSE_INFO.licenseName} (${LICENSE_INFO.licenseUrl})`, + terms_of_access: LICENSE_INFO.termsOfAccess, + data_sources: LICENSE_INFO.dataSources, + notice: LICENSE_INFO.notice, + }) + + triggerBlobDownload( + new Blob( + [ + `{"type":"FeatureCollection","metadata":${metadata},"features":[\n`, + ...chunks, + '\n]}', + ], + { type: 'application/geo+json' }, + ), + `${filename}.geojson`, + ) +} + export const Download = () => { const track = useTracking() - const [loading, setLoading] = useState({ csv: false, gpkg: false }) + const [loading, setLoading] = useState({ csv: false, geojson: false }) + const abortRefs = useRef>({ + csv: null, + geojson: null, + }) const selectedGeographyLevel = useStore( (state) => state.selectedGeographyLevel, ) @@ -76,6 +331,16 @@ export const Download = () => { const isDownloadableLevel = selectedGeographyLevel !== 'state' && selectedGeographyLevel !== 'nation' const disabled = !activeGeographies[selectedGeographyLevel] + + useEffect(() => { + return () => { + abortRefs.current.csv?.abort() + abortRefs.current.geojson?.abort() + abortRefs.current.csv = null + abortRefs.current.geojson = null + setLoading({ csv: false, geojson: false }) + } + }, [geoid, selectedGeographyLevel]) let filename: string if (selectedGeographyLevel === 'county') { filename = `${countyName?.replaceAll(' ', '-')}-County-${geoid}` @@ -85,46 +350,59 @@ export const Download = () => { filename = `Census-Block-${geoid}` } - const handleClick = async (format: 'csv' | 'gpkg') => { + const handleClick = async (format: 'csv' | 'geojson') => { + if (loading[format]) { + abortRefs.current[format]?.abort() + setLoading((prev) => ({ ...prev, [format]: false })) + return + } + const controller = new AbortController() + abortRefs.current[format] = controller + setLoading((prev) => ({ ...prev, [format]: true })) + const startTime = performance.now() try { track('data_download', { geography: selectedGeographyLevel, geoid: geoid ?? '', }) - const res = await fetch(DATA_URLS.downloads, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - environment: process.env.NEXT_PUBLIC_OCR_ENV ?? 'production', - dataset_version: DATA_VERSION, - data_format: format, - geoid: geoid, - region_type: REGION_TYPES[selectedGeographyLevel], - file_name: `${filename}.${format}`, - }), - }) - const payload = await res.json() - if (!payload?.url) { - throw Error('Error generating download') - } - const a = document.createElement('a') - a.href = payload.url - a.download = `${filename}.${format}` // explicitly trigger download and suggest filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) + if (!geoid) throw new Error('No geography selected') - setLoading((prev) => ({ ...prev, [format]: false })) + const regionType = REGION_TYPES[selectedGeographyLevel] + if (!regionType) throw new Error('Invalid geography level') + + if (format === 'csv') { + await downloadCSV(geoid, regionType, filename, controller.signal) + } else { + await downloadGeoJSON(geoid, regionType, filename, controller.signal) + } } catch (error) { - if (!(error instanceof DOMException && error.name === 'AbortError')) { + const isAbort = + controller.signal.aborted || + (error instanceof DOMException && error.name === 'AbortError') + if (isAbort) { + track('data_download_cancel', { + geography: selectedGeographyLevel, + geoid: geoid ?? '', + format, + duration_bucket: bucketDuration(performance.now() - startTime), + }) + } else { track('data_download_error', { geography: selectedGeographyLevel, geoid: geoid ?? '', }) + console.error('Download failed:', error) + } + } finally { + // A newer run for the same format may have replaced this controller — + // only clear loading/ref when we're still the active run, otherwise the + // retry gets its state wiped out mid-flight. + if (abortRefs.current[format] === controller) { + abortRefs.current[format] = null + setLoading((prev) => ({ ...prev, [format]: false })) } - setLoading((prev) => ({ ...prev, [format]: false })) } } @@ -150,14 +428,22 @@ export const Download = () => { loading={loading.csv} disabled={disabled} onClick={() => handleClick('csv')} - ariaLabel={`Download ${disabled ? 'regional' : selectedGeographyLevel} data as CSV`} + ariaLabel={ + loading.csv + ? 'Cancel CSV download' + : `Download ${disabled ? 'regional' : selectedGeographyLevel} data as CSV` + } /> handleClick('gpkg')} - ariaLabel={`Download ${disabled ? 'regional' : selectedGeographyLevel} data as GeoPackage`} + onClick={() => handleClick('geojson')} + ariaLabel={ + loading.geojson + ? 'Cancel GeoJSON download' + : `Download ${disabled ? 'regional' : selectedGeographyLevel} data as GeoJSON` + } /> ) diff --git a/lib/config.ts b/lib/config.ts index c10e5be..9dd03ab 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -17,7 +17,21 @@ export const DATA_URLS = { raster: process.env.NEXT_PUBLIC_RISK_ZARR_URL ?? `https://carbonplan-ocr.s3.amazonaws.com/output/fire-risk/pyramid/production/${DATA_VERSION}/pyramid.zarr`, - downloads: `https://wywisai6r4dyxoib6aq2j2ewiy0sdsdg.lambda-url.us-west-2.on.aws/export`, + parquetBase: + process.env.NEXT_PUBLIC_GEOPARQUET_URL ?? + `https://carbonplan-ocr.s3.us-west-2.amazonaws.com/output/fire-risk/vector/production/${DATA_VERSION}/geoparquet/buildings.parquet`, +} + +export const LICENSE_INFO = { + provider: 'CarbonPlan', + termsOfAccess: + 'https://docs.carbonplan.org/ocr/en/latest/terms-of-data-access.html', + dataSources: + 'https://docs.carbonplan.org/ocr/en/latest/reference/data-sources.html', + licenseName: 'ODBL', + licenseUrl: 'https://opendatacommons.org/licenses/odbl/', + notice: + 'Contains information from the Overture Maps Foundation database, which is made available here under the Open Database License (ODbL), a copy of which is available at https://opendatacommons.org/licenses/odbl/1-0/.', } export const LAYERS = { diff --git a/lib/duckdb.ts b/lib/duckdb.ts new file mode 100644 index 0000000..a25a43d --- /dev/null +++ b/lib/duckdb.ts @@ -0,0 +1,40 @@ +import type { AsyncDuckDB } from '@duckdb/duckdb-wasm' + +let db: AsyncDuckDB | null = null +let initPromise: Promise | null = null + +export async function getDuckDB(): Promise { + if (db) return db + if (initPromise) return initPromise + + initPromise = (async () => { + const duckdb = await import('@duckdb/duckdb-wasm') + const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles() + const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES) + + const workerUrl = URL.createObjectURL( + new Blob([`importScripts("${bundle.mainWorker!}");`], { + type: 'text/javascript', + }), + ) + + const worker = new Worker(workerUrl) + const instance = new duckdb.AsyncDuckDB(new duckdb.VoidLogger(), worker) + await instance.instantiate(bundle.mainModule, bundle.pthreadWorker) + URL.revokeObjectURL(workerUrl) + + const conn = await instance.connect() + await conn.query('SET builtin_httpfs = false;') + await conn.query('INSTALL httpfs; LOAD httpfs;') + await conn.query('INSTALL spatial; LOAD spatial;') + await conn.close() + + db = instance + return instance + })().catch((err) => { + initPromise = null + throw err + }) + + return initPromise +} diff --git a/package-lock.json b/package-lock.json index d9c15fe..ed48454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@carbonplan/prettier": "^1.2.0", "@carbonplan/theme": "^8.1.0", "@carbonplan/zarr-layer": "^0.3.1", + "@duckdb/duckdb-wasm": "^1.32.0", "@emotion/react": "^11.14.0", "@protomaps/basemaps": "^5.0.1", "@theme-ui/color": "^0.15.7", @@ -323,6 +324,15 @@ "integrity": "sha512-ARk8IM6VPzL3ltAZNenZCAm9NkzqOnrxjEvf8m2t91bmkmloDPBkRCMxMzKrMJE79/XxcZyXDDvLOI1UKlYSBQ==", "license": "MIT" }, + "node_modules/@duckdb/duckdb-wasm": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@duckdb/duckdb-wasm/-/duckdb-wasm-1.32.0.tgz", + "integrity": "sha512-IewXTNYEjsZCPE9weUWgtjGxUlMRo7qhX0GF6tq/KjK8bnY+RAl4cyUdYUfcdzbyb4b9ZxPC+FOsCcxgaKFWMg==", + "license": "MIT", + "dependencies": { + "apache-arrow": "^17.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", @@ -3697,6 +3707,18 @@ "integrity": "sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA==", "license": "MIT" }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, "node_modules/@types/d3-format": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", @@ -3736,7 +3758,6 @@ "version": "20.17.24", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.24.tgz", "integrity": "sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -4242,7 +4263,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4254,6 +4274,35 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/apache-arrow": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-17.0.0.tgz", + "integrity": "sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^20.13.0", + "command-line-args": "^5.2.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^24.3.25", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.cjs" + } + }, + "node_modules/apache-arrow/node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4283,6 +4332,15 @@ "node": ">= 0.4" } }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -4643,9 +4701,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4657,6 +4713,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, "node_modules/chroma-js": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz", @@ -4673,7 +4744,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4686,9 +4756,56 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "license": "MIT", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz", + "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.1", + "typical": "^7.3.0" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/command-line-usage/node_modules/array-back": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", + "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/command-line-usage/node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -5893,6 +6010,18 @@ "node": ">=8" } }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "license": "MIT", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -5933,6 +6062,12 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatbuffers": { + "version": "24.12.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.12.23.tgz", + "integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==", + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -6289,9 +6424,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -6972,6 +7105,14 @@ "node": ">=6" } }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7120,6 +7261,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8959,9 +9106,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -8996,6 +9141,28 @@ "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", "license": "ISC" }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/table-layout/node_modules/array-back": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", + "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -9263,6 +9430,15 @@ "node": ">=14.17" } }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -9286,7 +9462,6 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, "license": "MIT" }, "node_modules/unzipit": { @@ -9542,6 +9717,15 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/package.json b/package.json index 92ff9a9..81fca26 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@carbonplan/prettier": "^1.2.0", "@carbonplan/theme": "^8.1.0", "@carbonplan/zarr-layer": "^0.3.1", + "@duckdb/duckdb-wasm": "^1.32.0", "@emotion/react": "^11.14.0", "@protomaps/basemaps": "^5.0.1", "@theme-ui/color": "^0.15.7",