From 2ee88d518fc0e84963ee88da2d909e20ff65ceeb Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Mon, 18 May 2026 10:03:03 -0600 Subject: [PATCH] feat: make duckdb/geoparquet optional --- src/components/app.tsx | 92 ++++---- src/components/examples.tsx | 13 +- src/components/href-input.tsx | 16 +- src/components/items.tsx | 27 +-- src/components/panel.tsx | 215 +++--------------- .../stac-geoparquet/duckdb-provider.tsx | 79 +++++++ src/components/stac-geoparquet/hooks.ts | 57 +++++ src/components/stac-geoparquet/index.tsx | 10 + .../stac-geoparquet/parquet-export-button.tsx | 29 +++ .../stac-geoparquet/parquet-panel.tsx | 119 ++++++++++ .../parquet-view.tsx} | 12 +- .../stac-geoparquet/stac-geoparquet-utils.ts} | 8 +- .../stac-geoparquet/stac-wasm-loader.ts} | 0 src/components/ui/panel-frame.tsx | 53 +++++ src/components/ui/settings.tsx | 32 +-- src/components/value.tsx | 12 +- src/config.ts | 2 + src/contexts/stac-geoparquet.ts | 21 ++ src/hooks/stac.ts | 57 +---- src/utils/upload.ts | 11 +- src/vite-env.d.ts | 1 + tests/config.spec.ts | 12 + .../stac-wasm-loader.spec.ts} | 5 +- 23 files changed, 533 insertions(+), 350 deletions(-) create mode 100644 src/components/stac-geoparquet/duckdb-provider.tsx create mode 100644 src/components/stac-geoparquet/hooks.ts create mode 100644 src/components/stac-geoparquet/index.tsx create mode 100644 src/components/stac-geoparquet/parquet-export-button.tsx create mode 100644 src/components/stac-geoparquet/parquet-panel.tsx rename src/components/{stac-geoparquet.tsx => stac-geoparquet/parquet-view.tsx} (94%) rename src/{utils/stac-geoparquet.ts => components/stac-geoparquet/stac-geoparquet-utils.ts} (97%) rename src/{utils/stac-wasm.ts => components/stac-geoparquet/stac-wasm-loader.ts} (100%) create mode 100644 src/components/ui/panel-frame.tsx create mode 100644 src/config.ts create mode 100644 src/contexts/stac-geoparquet.ts create mode 100644 tests/config.spec.ts rename tests/{utils/stac-wasm.spec.ts => stac-geoparquet/stac-wasm-loader.spec.ts} (91%) diff --git a/src/components/app.tsx b/src/components/app.tsx index 3ae56b4..4dbc5c8 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -1,15 +1,19 @@ import { AbsoluteCenter, Box, Center, FileUpload } from "@chakra-ui/react"; -import { useDuckDb } from "duckdb-wasm-kit"; -import { type ReactNode, useEffect } from "react"; +import { lazy, Suspense, type ReactNode } from "react"; import { ErrorBoundary } from "react-error-boundary"; +import { useStacGeoparquet } from "../contexts/stac-geoparquet"; import { useStore } from "../store"; -import { warmStacWasm } from "../utils/stac-wasm"; import { uploadFile } from "../utils/upload"; import Map from "./map"; import Overlay from "./overlay"; import type { ExtraLayerProps } from "./stac-map"; import { ErrorBoundaryAlert } from "./ui/error-alert"; +const StacGeoparquetFeature = + import.meta.env.VITE_STAC_GEOPARQUET === "false" + ? null + : lazy(() => import("./stac-geoparquet")); + function MapFallback({ error }: { error: unknown }) { return ( @@ -28,6 +32,34 @@ function OverlayFallback({ error }: { error: unknown }) { ); } +function AppContents({ extraLayers }: { extraLayers?: ExtraLayerProps[] }) { + const setUploadedFile = useStore((state) => state.setUploadedFile); + const parquetCtx = useStacGeoparquet(); + return ( + + { + void uploadFile({ + file: details.files[0], + setUploadedFile, + registerParquet: parquetCtx?.registerParquet, + }); + }} + h={"100%"} + w={"100%"} + > + + + + + + + + + ); +} + export default function App({ footer, extraLayers, @@ -35,54 +67,22 @@ export default function App({ footer?: ReactNode; extraLayers?: ExtraLayerProps[]; }) { - const setUploadedFile = useStore((state) => state.setUploadedFile); - const setConnection = useStore((state) => state.setConnection); - const { db } = useDuckDb(); - - useEffect(() => { - if (db) { - (async () => { - const connection = await db.connect(); - await connection.query("LOAD spatial;"); - await connection.query("LOAD icu;"); - await connection.query("LOAD httpfs;"); - setConnection(connection); - })(); - } - }, [db, setConnection]); - - useEffect(() => { - warmStacWasm(); - }, []); - - return ( + const inner = ( <> - - { - uploadFile({ - file: details.files[0], - setUploadedFile, - db, - }); - }} - disabled={!db} - h={"100%"} - w={"100%"} - > - - - - - - - - + {footer} ); + + if (StacGeoparquetFeature) { + return ( + + {inner} + + ); + } + return inner; } diff --git a/src/components/examples.tsx b/src/components/examples.tsx index 961b796..cc185ad 100644 --- a/src/components/examples.tsx +++ b/src/components/examples.tsx @@ -1,18 +1,27 @@ import { Badge, Menu, Portal, Span } from "@chakra-ui/react"; -import { type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { useExamples } from "../contexts/examples"; +import { useStacGeoparquet } from "../contexts/stac-geoparquet"; import { useStore } from "../store"; export function Examples({ children }: { children: ReactNode }) { const setHref = useStore((store) => store.setHref); const examples = useExamples(); + const parquetCtx = useStacGeoparquet(); + const visibleExamples = useMemo( + () => + parquetCtx + ? examples + : examples.filter((example) => example.badge !== "stac-geoparquet"), + [examples, parquetCtx] + ); return ( setHref(details.value)}> {children} - {examples.map(({ title, badge, href }, index) => ( + {visibleExamples.map(({ title, badge, href }, index) => ( {title} diff --git a/src/components/href-input.tsx b/src/components/href-input.tsx index 7c23339..988d8f2 100644 --- a/src/components/href-input.tsx +++ b/src/components/href-input.tsx @@ -5,9 +5,9 @@ import { Input, InputGroup, } from "@chakra-ui/react"; -import { useDuckDb } from "duckdb-wasm-kit"; import { useState } from "react"; import { LuUpload } from "react-icons/lu"; +import { useStacGeoparquet } from "../contexts/stac-geoparquet"; import { useStore } from "../store"; import { uploadFile } from "../utils/upload"; @@ -15,7 +15,7 @@ export default function HrefInput() { const href = useStore((store) => store.href); const setHref = useStore((state) => state.setHref); const setUploadedFile = useStore((store) => store.setUploadedFile); - const { db } = useDuckDb(); + const parquetCtx = useStacGeoparquet(); const [input, setInput] = useState(href || ""); const [lastHref, setLastHref] = useState(href); @@ -24,6 +24,10 @@ export default function HrefInput() { setInput(href || ""); } + const placeholder = parquetCtx + ? "Enter a url to a STAC API, JSON, or GeoParquet" + : "Enter a url to a STAC API or JSON"; + return ( - uploadFile({ + void uploadFile({ file: details.files[0], setUploadedFile, - db, + registerParquet: parquetCtx?.registerParquet, }) } > - + @@ -55,7 +59,7 @@ export default function HrefInput() { > setInput(e.target.value)} /> diff --git a/src/components/items.tsx b/src/components/items.tsx index 4c11505..2172447 100644 --- a/src/components/items.tsx +++ b/src/components/items.tsx @@ -2,7 +2,7 @@ import { type BBox2D, useStore } from "@/store"; import { getItemsDatetimeExtent, itemMatchesFilter } from "@/utils/datetime"; import { fitBoundsToBbox } from "@/utils/map"; import { fetchStacValue, getSelfHref } from "@/utils/stac"; -import { loadStacWasm } from "@/utils/stac-wasm"; +import { useStacGeoparquet } from "../contexts/stac-geoparquet"; import { Button, ButtonGroup, @@ -11,7 +11,6 @@ import { DownloadTrigger, Input, InputGroup, - Spinner, Stack, } from "@chakra-ui/react"; import { GeoJsonLayer } from "@deck.gl/layers"; @@ -30,7 +29,7 @@ export function Items({ items }: { items: StacItem[] }) { const [filterByMapBbox, setFilterByMapBbox] = useState(true); const [filterText, setFilterText] = useState(""); const [hovered, setHovered] = useState(); - const [isExporting, setIsExporting] = useState(false); + const parquetCtx = useStacGeoparquet(); const setHref = useStore((store) => store.setHref); const setLayer = useStore((store) => store.setLayer); const fillColor = useStore((store) => store.fillColor); @@ -159,27 +158,7 @@ export function Items({ items }: { items: StacItem[] }) { JSON - { - try { - setIsExporting(true); - const stacWasm = await loadStacWasm(); - return new Blob([ - stacWasm.stacJsonToParquet(items) as BlobPart, - ]); - } finally { - setIsExporting(false); - } - }} - asChild - > - - + {parquetCtx && } store.href); const hrefIsParquet = useStore((store) => store.hrefIsParquet); - const connection = useStore((store) => store.connection); - return href ? ( - hrefIsParquet ? ( - connection ? ( - - ) : ( - - ) - ) : ( - - ) - ) : ( - - ); + const parquetCtx = useStacGeoparquet(); + + if (!href) return ; + if (hrefIsParquet) { + if (!parquetCtx) { + return ( + + + + + stac-geoparquet not enabled + + This deployment does not include stac-geoparquet support. + + + + + ); + } + return ; + } + return ; } function HrefPanel({ href }: { href: string }) { @@ -54,106 +47,23 @@ function HrefPanel({ href }: { href: string }) { ); } -function StacGeoparquetHrefPanel({ - href, - connection, -}: { - href: string; - connection: AsyncDuckDBConnection; -}) { - const result = useStacGeoparquetValue({ href, connection }); - const stacGeoparquetId = useStore((store) => store.stacGeoparquetId); - - return result.data ? ( - stacGeoparquetId ? ( - - ) : ( - - ) - ) : result.isLoading ? ( - - ) : ( - - ); -} - -function StacGeoparquetItemPanel({ - href, - connection, - id, -}: { - href: string; - connection: AsyncDuckDBConnection; - id: string; -}) { - const result = useStacGeoparquetItem({ href, connection, id }); - const setStacGeoparquetId = useStore((store) => store.setStacGeoparquetId); - return result.data ? ( - <> - - - - - - {id} - - - - - - - - ) : result.isLoading ? ( - - ) : ( - - ); -} - -function ValuePanel({ - href, - value, - connection, -}: { - href: string; - value: StacValue; - connection?: AsyncDuckDBConnection; -}) { - const header = ( - }> - {getStacId(value)} - - ); +function ValuePanel({ href, value }: { href: string; value: StacValue }) { return ( - - + }> + {getStacId(value)} + + } + > + ); } function LoadingPanel({ href }: { href: string }) { - const header = }>Loading {href}; - return ( - - - - ); -} - -function LoadingDuckdbPanel() { - const header = }>Loading DuckDB...; return ( - + }>Loading {href}}> ); @@ -196,54 +106,3 @@ function IntroductionPanel() { ); return ; } - -function BasePanel({ - header, - children, -}: { - header: ReactNode; - children: ReactNode; -}) { - return ( - - - {header} - - - {children} - - - ); -} - -function PanelHeader({ - icon, - children, - actions, -}: { - icon?: ReactNode; - children: ReactNode; - actions?: ReactNode; -}) { - return ( - - {icon} - - {children} - - {actions} - - ); -} diff --git a/src/components/stac-geoparquet/duckdb-provider.tsx b/src/components/stac-geoparquet/duckdb-provider.tsx new file mode 100644 index 0000000..dd43065 --- /dev/null +++ b/src/components/stac-geoparquet/duckdb-provider.tsx @@ -0,0 +1,79 @@ +import { + StacGeoparquetProvider, + useStacGeoparquet, + type StacGeoparquetContextValue, +} from "@/contexts/stac-geoparquet"; +import { useStore } from "@/store"; +import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; +import { useDuckDb } from "duckdb-wasm-kit"; +import { + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import ParquetExportButton from "./parquet-export-button"; +import ParquetPanel from "./parquet-panel"; +import ParquetView from "./parquet-view"; +import { warmStacWasm } from "./stac-wasm-loader"; + +function ContextParquetView({ href }: { href: string }) { + const ctx = useStacGeoparquet(); + if (!ctx?.connection) return null; + return ; +} + +function ContextParquetPanel({ href }: { href: string }) { + const ctx = useStacGeoparquet(); + if (!ctx?.connection) return null; + return ; +} + +export default function DuckDbProvider({ children }: { children: ReactNode }) { + const setConnection = useStore((store) => store.setConnection); + const { db } = useDuckDb(); + const [connection, setLocalConnection] = + useState(null); + + useEffect(() => { + if (!db) return; + let cancelled = false; + (async () => { + const conn = await db.connect(); + await conn.query("LOAD spatial;"); + await conn.query("LOAD icu;"); + await conn.query("LOAD httpfs;"); + if (cancelled) return; + setLocalConnection(conn); + setConnection(conn); + })(); + return () => { + cancelled = true; + }; + }, [db, setConnection]); + + useEffect(() => { + warmStacWasm(); + }, []); + + const value = useMemo( + () => ({ + connection, + registerParquet: async (file: File) => { + if (!db) return; + await db.registerFileBuffer( + file.name, + new Uint8Array(await file.arrayBuffer()) + ); + }, + ParquetView: ContextParquetView, + ParquetPanel: ContextParquetPanel, + ParquetExportButton, + }), + [connection, db] + ); + + return ( + {children} + ); +} diff --git a/src/components/stac-geoparquet/hooks.ts b/src/components/stac-geoparquet/hooks.ts new file mode 100644 index 0000000..e7c3993 --- /dev/null +++ b/src/components/stac-geoparquet/hooks.ts @@ -0,0 +1,57 @@ +import { useStore } from "@/store"; +import { + fetchStacGeoparquetItem, + fetchStacGeoparquetTable, + fetchStacGeoparquetValue, +} from "@/components/stac-geoparquet/stac-geoparquet-utils"; +import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; +import { useQuery } from "@tanstack/react-query"; + +export function useStacGeoparquetValue({ + href, + connection, +}: { + href: string; + connection: AsyncDuckDBConnection; +}) { + const hivePartitioning = useStore((store) => store.hivePartitioning); + return useQuery({ + queryKey: ["stac-geoparquet-value", href], + queryFn: async () => + fetchStacGeoparquetValue({ href, connection, hivePartitioning }), + }); +} + +export function useStacGeoparquetTable({ + href, + connection, + where, +}: { + href: string; + connection: AsyncDuckDBConnection; + where?: string; +}) { + const hivePartitioning = useStore((store) => store.hivePartitioning); + return useQuery({ + queryKey: ["stac-geoparquet-table", href, where ?? null], + queryFn: async () => + fetchStacGeoparquetTable({ href, connection, hivePartitioning, where }), + }); +} + +export function useStacGeoparquetItem({ + href, + connection, + id, +}: { + href: string; + connection: AsyncDuckDBConnection; + id: string; +}) { + const hivePartitioning = useStore((store) => store.hivePartitioning); + return useQuery({ + queryKey: ["stac-geoparquet-item", href, id], + queryFn: async () => + fetchStacGeoparquetItem({ href, connection, hivePartitioning, id }), + }); +} diff --git a/src/components/stac-geoparquet/index.tsx b/src/components/stac-geoparquet/index.tsx new file mode 100644 index 0000000..89d241e --- /dev/null +++ b/src/components/stac-geoparquet/index.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import DuckDbProvider from "./duckdb-provider"; + +export default function StacGeoparquetFeature({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/src/components/stac-geoparquet/parquet-export-button.tsx b/src/components/stac-geoparquet/parquet-export-button.tsx new file mode 100644 index 0000000..c2aed0b --- /dev/null +++ b/src/components/stac-geoparquet/parquet-export-button.tsx @@ -0,0 +1,29 @@ +import { Button, DownloadTrigger, Spinner } from "@chakra-ui/react"; +import { useState } from "react"; +import { LuDownload } from "react-icons/lu"; +import type { StacItem } from "stac-ts"; +import { loadStacWasm } from "./stac-wasm-loader"; + +export default function ParquetExportButton({ items }: { items: StacItem[] }) { + const [isExporting, setIsExporting] = useState(false); + return ( + { + try { + setIsExporting(true); + const stacWasm = await loadStacWasm(); + return new Blob([stacWasm.stacJsonToParquet(items) as BlobPart]); + } finally { + setIsExporting(false); + } + }} + asChild + > + + + ); +} diff --git a/src/components/stac-geoparquet/parquet-panel.tsx b/src/components/stac-geoparquet/parquet-panel.tsx new file mode 100644 index 0000000..59f8d37 --- /dev/null +++ b/src/components/stac-geoparquet/parquet-panel.tsx @@ -0,0 +1,119 @@ +import { useStore } from "@/store"; +import { getStacId } from "@/utils/stac"; +import { + ActionBar, + Alert, + Button, + Portal, + SkeletonText, +} from "@chakra-ui/react"; +import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; +import { LuLoader, LuX } from "react-icons/lu"; +import { BasePanel, PanelHeader } from "../ui/panel-frame"; +import { StacIcon } from "../ui/stac"; +import Value from "../value"; +import { useStacGeoparquetItem, useStacGeoparquetValue } from "./hooks"; + +export default function ParquetPanel({ + href, + connection, +}: { + href: string; + connection: AsyncDuckDBConnection; +}) { + const result = useStacGeoparquetValue({ href, connection }); + const stacGeoparquetId = useStore((store) => store.stacGeoparquetId); + + if (result.isLoading) return ; + if (!result.data) return ; + + if (stacGeoparquetId) { + return ( + + ); + } + + const value = result.data; + return ( + }> + {getStacId(value)} + + } + > + + + ); +} + +function ParquetItem({ + href, + connection, + id, +}: { + href: string; + connection: AsyncDuckDBConnection; + id: string; +}) { + const result = useStacGeoparquetItem({ href, connection, id }); + const setStacGeoparquetId = useStore((store) => store.setStacGeoparquetId); + if (result.isLoading) return ; + if (!result.data) return ; + + const value = result.data; + return ( + <> + }> + {getStacId(value)} + + } + > + + + + + + + {id} + + + + + + + + ); +} + +function ParquetLoading({ href }: { href: string }) { + return ( + }>Loading {href}} + > + + + ); +} + +function ParquetError({ href, error }: { href: string; error: Error | null }) { + return ( + + + + + {error ? error.name : "Unknown error"} + {error && {error.message}} + + + + ); +} diff --git a/src/components/stac-geoparquet.tsx b/src/components/stac-geoparquet/parquet-view.tsx similarity index 94% rename from src/components/stac-geoparquet.tsx rename to src/components/stac-geoparquet/parquet-view.tsx index 1403f47..b20db75 100644 --- a/src/components/stac-geoparquet.tsx +++ b/src/components/stac-geoparquet/parquet-view.tsx @@ -1,6 +1,4 @@ -import { useStacGeoparquetTable, useStacGeoparquetValue } from "@/hooks/stac"; import { useStore } from "@/store"; -import type { SupportedGeometryType } from "@/utils/stac-geoparquet"; import type { Color } from "@deck.gl/core"; import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; import { @@ -10,9 +8,11 @@ import { } from "@geoarrow/deck.gl-layers"; import type { Table } from "apache-arrow"; import { useEffect, useMemo, useState } from "react"; -import { ErrorAlert } from "./ui/error-alert"; +import { ErrorAlert } from "../ui/error-alert"; +import { useStacGeoparquetTable, useStacGeoparquetValue } from "./hooks"; +import type { SupportedGeometryType } from "./stac-geoparquet-utils"; -export default function StacGeoparquet({ +export default function ParquetView({ href, connection, }: { @@ -44,14 +44,14 @@ export default function StacGeoparquet({ return ; if (!result.data?.table || !result.data.geometryType) return null; return ( - ); } -function StacGeoparquetTable({ +function ParquetViewTable({ table, geometryType, }: { diff --git a/src/utils/stac-geoparquet.ts b/src/components/stac-geoparquet/stac-geoparquet-utils.ts similarity index 97% rename from src/utils/stac-geoparquet.ts rename to src/components/stac-geoparquet/stac-geoparquet-utils.ts index 5d2fa0f..690d6b3 100644 --- a/src/utils/stac-geoparquet.ts +++ b/src/components/stac-geoparquet/stac-geoparquet-utils.ts @@ -1,4 +1,4 @@ -import { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; +import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; import { data, io } from "@geoarrow/geoarrow-js"; import { Binary, @@ -8,9 +8,9 @@ import { Table, vectorFromArray, } from "apache-arrow"; -import { toaster } from "../components/ui/toaster"; -import type { StacItemCollection } from "../types/stac"; -import { loadStacWasm } from "./stac-wasm"; +import { toaster } from "../ui/toaster"; +import type { StacItemCollection } from "../../types/stac"; +import { loadStacWasm } from "./stac-wasm-loader"; const SUPPORTED_GEOMETRY_TYPES = ["point", "polygon", "linestring"] as const; diff --git a/src/utils/stac-wasm.ts b/src/components/stac-geoparquet/stac-wasm-loader.ts similarity index 100% rename from src/utils/stac-wasm.ts rename to src/components/stac-geoparquet/stac-wasm-loader.ts diff --git a/src/components/ui/panel-frame.tsx b/src/components/ui/panel-frame.tsx new file mode 100644 index 0000000..9bc7148 --- /dev/null +++ b/src/components/ui/panel-frame.tsx @@ -0,0 +1,53 @@ +import { Box, HStack, Span } from "@chakra-ui/react"; +import { type ReactNode } from "react"; + +export function BasePanel({ + header, + children, +}: { + header: ReactNode; + children: ReactNode; +}) { + return ( + + + {header} + + + {children} + + + ); +} + +export function PanelHeader({ + icon, + children, + actions, +}: { + icon?: ReactNode; + children: ReactNode; + actions?: ReactNode; +}) { + return ( + + {icon} + + {children} + + {actions} + + ); +} diff --git a/src/components/ui/settings.tsx b/src/components/ui/settings.tsx index f2b3f6c..e5a1ac1 100644 --- a/src/components/ui/settings.tsx +++ b/src/components/ui/settings.tsx @@ -17,6 +17,7 @@ import { import { useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { LuPlus, LuSettings, LuTrash2 } from "react-icons/lu"; +import { useStacGeoparquet } from "../../contexts/stac-geoparquet"; import { useStore } from "../../store"; export function SettingsButton() { @@ -24,6 +25,7 @@ export function SettingsButton() { const setHivePartitioning = useStore((store) => store.setHivePartitioning); const addErrorListener = useStore((store) => store.addErrorListener); const setAddErrorListener = useStore((store) => store.setAddErrorListener); + const parquetCtx = useStacGeoparquet(); return ( @@ -43,19 +45,23 @@ export function SettingsButton() { - setHivePartitioning(!!e.checked)} - alignItems={"flex-start"} - > - - - - - Use hive partitioning for stac-geoparquet queries - - - + {parquetCtx && ( + + setHivePartitioning(!!e.checked) + } + alignItems={"flex-start"} + > + + + + + Use hive partitioning for stac-geoparquet queries + + + + )} setAddErrorListener(!!e.checked)} diff --git a/src/components/value.tsx b/src/components/value.tsx index 4feba71..725ce83 100644 --- a/src/components/value.tsx +++ b/src/components/value.tsx @@ -12,7 +12,6 @@ import { } from "@/utils/stac"; import { Badge, Heading, HStack, Stack } from "@chakra-ui/react"; import { GeoJsonLayer } from "@deck.gl/layers"; -import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; import bbox from "@turf/bbox"; import bboxPolygon from "@turf/bbox-polygon"; import type { Feature, FeatureCollection, GeoJsonProperties } from "geojson"; @@ -28,7 +27,7 @@ import { ItemLinks } from "./items"; import Links from "./links"; import Properties from "./properties"; import Search from "./search"; -import StacGeoparquet from "./stac-geoparquet"; +import { useStacGeoparquet } from "../contexts/stac-geoparquet"; import Description from "./ui/description"; import Thumbnail from "./ui/thumbnail"; import Visualization from "./visualization"; @@ -36,17 +35,16 @@ import Visualization from "./visualization"; export default function Value({ href, value, - connection, }: { href: string; value: StacValue; - connection?: AsyncDuckDBConnection; }) { const lineColor = useStore((store) => store.lineColor); const setValueBbox = useStore((store) => store.setValueBbox); const setLayer = useStore((store) => store.setLayer); const hrefIsParquet = useStore((store) => store.hrefIsParquet); const setProjection = useStore((store) => store.setProjection); + const parquetCtx = useStacGeoparquet(); const version = value.stac_version as string; const thumbnailAsset = getThumbnailAsset(value); const description = value.description as string; @@ -74,7 +72,7 @@ export default function Value({ break; } } - }, [value, setValueBbox, hrefIsParquet, setProjection]); + }, [value, setValueBbox, setProjection]); useEffect(() => { setLayer( @@ -138,9 +136,7 @@ export default function Value({ {value.links && value.links.length > 0 && } - {hrefIsParquet && connection && ( - - )} + {hrefIsParquet && parquetCtx && } ); } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6b4838f --- /dev/null +++ b/src/config.ts @@ -0,0 +1,2 @@ +export const STAC_GEOPARQUET_ENABLED = + import.meta.env.VITE_STAC_GEOPARQUET !== "false"; diff --git a/src/contexts/stac-geoparquet.ts b/src/contexts/stac-geoparquet.ts new file mode 100644 index 0000000..4e1e607 --- /dev/null +++ b/src/contexts/stac-geoparquet.ts @@ -0,0 +1,21 @@ +import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; +import { createContext, useContext, type ComponentType } from "react"; +import type { StacItem } from "stac-ts"; + +export interface StacGeoparquetContextValue { + connection: AsyncDuckDBConnection | null; + registerParquet: (file: File) => Promise; + ParquetView: ComponentType<{ href: string }>; + ParquetPanel: ComponentType<{ href: string }>; + ParquetExportButton: ComponentType<{ items: StacItem[] }>; +} + +const StacGeoparquetContext = createContext( + null +); + +export const StacGeoparquetProvider = StacGeoparquetContext.Provider; + +export function useStacGeoparquet(): StacGeoparquetContextValue | null { + return useContext(StacGeoparquetContext); +} diff --git a/src/hooks/stac.ts b/src/hooks/stac.ts index 39533b3..e3a9bd1 100644 --- a/src/hooks/stac.ts +++ b/src/hooks/stac.ts @@ -1,11 +1,5 @@ -import { useStore } from "@/store"; import { loadGeoTIFF } from "@/utils/geotiff"; -import { - fetchStacGeoparquetItem, - fetchStacGeoparquetTable, - fetchStacGeoparquetValue, -} from "@/utils/stac-geoparquet"; -import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; +import { useStore } from "@/store"; import { useQuery } from "@tanstack/react-query"; import { fetchStacValue } from "../utils/stac"; @@ -17,55 +11,6 @@ export function useStacValue({ href }: { href: string }) { }); } -export function useStacGeoparquetValue({ - href, - connection, -}: { - href: string; - connection: AsyncDuckDBConnection; -}) { - const hivePartitioning = useStore((store) => store.hivePartitioning); - return useQuery({ - queryKey: ["stac-geoparquet-value", href], - queryFn: async () => - fetchStacGeoparquetValue({ href, connection, hivePartitioning }), - }); -} - -export function useStacGeoparquetTable({ - href, - connection, - where, -}: { - href: string; - connection: AsyncDuckDBConnection; - where?: string; -}) { - const hivePartitioning = useStore((store) => store.hivePartitioning); - return useQuery({ - queryKey: ["stac-geoparquet-table", href, where ?? null], - queryFn: async () => - fetchStacGeoparquetTable({ href, connection, hivePartitioning, where }), - }); -} - -export function useStacGeoparquetItem({ - href, - connection, - id, -}: { - href: string; - connection: AsyncDuckDBConnection; - id: string; -}) { - const hivePartitioning = useStore((store) => store.hivePartitioning); - return useQuery({ - queryKey: ["stac-geoparquet-item", href, id], - queryFn: async () => - fetchStacGeoparquetItem({ href, connection, hivePartitioning, id }), - }); -} - export function useGeoTIFF(href: string | undefined) { return useQuery({ queryKey: ["geotiff", href], diff --git a/src/utils/upload.ts b/src/utils/upload.ts index e6f07f8..cefb990 100644 --- a/src/utils/upload.ts +++ b/src/utils/upload.ts @@ -1,15 +1,14 @@ -import type { AsyncDuckDB } from "duckdb-wasm-kit"; - export async function uploadFile({ file, setUploadedFile, - db, + registerParquet, }: { file: File; setUploadedFile: (file: File) => void; - db: AsyncDuckDB | undefined; + registerParquet?: (file: File) => Promise; }) { - if (db && file.name.endsWith(".parquet")) - db.registerFileBuffer(file.name, new Uint8Array(await file.arrayBuffer())); + if (registerParquet && file.name.endsWith(".parquet")) { + await registerParquet(file); + } setUploadedFile(file); } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 6b68e47..ea99adb 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,7 @@ interface ImportMetaEnv { readonly VITE_AUTH_AUTHORITY?: string; readonly VITE_AUTH_CLIENT_ID?: string; readonly VITE_STAC_BROWSER_URL?: string; + readonly VITE_STAC_GEOPARQUET?: string; } interface ImportMeta { diff --git a/tests/config.spec.ts b/tests/config.spec.ts new file mode 100644 index 0000000..6a2c912 --- /dev/null +++ b/tests/config.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { STAC_GEOPARQUET_ENABLED } from "../src/config"; + +describe("STAC_GEOPARQUET_ENABLED", () => { + it("is true when VITE_STAC_GEOPARQUET is unset (test default)", () => { + expect(STAC_GEOPARQUET_ENABLED).toBe(true); + }); + + it("is a boolean", () => { + expect(typeof STAC_GEOPARQUET_ENABLED).toBe("boolean"); + }); +}); diff --git a/tests/utils/stac-wasm.spec.ts b/tests/stac-geoparquet/stac-wasm-loader.spec.ts similarity index 91% rename from tests/utils/stac-wasm.spec.ts rename to tests/stac-geoparquet/stac-wasm-loader.spec.ts index f1f0ef5..ffd97fb 100644 --- a/tests/utils/stac-wasm.spec.ts +++ b/tests/stac-geoparquet/stac-wasm-loader.spec.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { loadStacWasm, warmStacWasm } from "../../src/utils/stac-wasm"; +import { + loadStacWasm, + warmStacWasm, +} from "../../src/components/stac-geoparquet/stac-wasm-loader"; describe("loadStacWasm", () => { it("returns the same promise on repeated calls", () => {