├── .node-version ├── src ├── vite-env.d.ts ├── types │ ├── map.d.ts │ └── stac.d.ts ├── components │ ├── ui │ │ ├── provider.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ ├── toggle-tip.tsx │ │ ├── color-mode.tsx │ │ └── prose.tsx │ ├── sections │ │ ├── properties.tsx │ │ ├── catalogs.tsx │ │ ├── assets.tsx │ │ ├── items.tsx │ │ ├── collections.tsx │ │ ├── links.tsx │ │ ├── filter.tsx │ │ ├── collection-search.tsx │ │ └── item-search.tsx │ ├── extent.tsx │ ├── examples.tsx │ ├── cards │ │ ├── catalog.tsx │ │ ├── collection.tsx │ │ └── asset.tsx │ ├── error.tsx │ ├── panel.tsx │ ├── section.tsx │ ├── introduction.tsx │ ├── properties.tsx │ ├── breadcrumbs.tsx │ └── value.tsx ├── hooks │ ├── stac-collections.ts │ ├── stac-children.ts │ ├── stac-search.ts │ └── stac-value.ts ├── main.tsx ├── constants.ts ├── layers │ ├── overlay.tsx │ └── map.tsx ├── utils │ ├── stac-geoparquet.ts │ └── stac.ts └── app.tsx ├── .env ├── public └── favicon.png ├── img ├── stac-map-dark.png └── stac-map-light.png ├── tsconfig.json ├── docs ├── decisions │ ├── adr-template-bare-minimal.md │ ├── adr-template-bare.md │ ├── .markdownlint.yml │ ├── README.md │ ├── 0002-drill-the-props.md │ ├── adr-template-minimal.md │ ├── 0001-dont-use-stac-react.md │ ├── 0000-use-markdown-architectural-decision-records.md │ └── adr-template.md └── architecture.md ├── .releaserc ├── index.html ├── .gitignore ├── vite.config.ts ├── .github ├── workflows │ ├── pr.yaml │ ├── preview-remove.yml │ ├── ci.yaml │ ├── github-pr-update.cjs │ └── preview-deploy.yaml ├── pull_request_template.md └── dependabot.yml ├── vitest.config.ts ├── tsconfig.node.json ├── .prettierrc.toml ├── tsconfig.app.json ├── .prettierignore ├── eslint.config.js ├── LICENSE ├── tests ├── stac.spec.ts └── app.spec.tsx ├── README.md └── package.json /.node-version: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_STAC_NATURAL_QUERY_API=https://api.stac-semantic-search.k8s.labs.ds.io 2 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-map/HEAD/public/favicon.png -------------------------------------------------------------------------------- /img/stac-map-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-map/HEAD/img/stac-map-dark.png -------------------------------------------------------------------------------- /img/stac-map-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-map/HEAD/img/stac-map-light.png -------------------------------------------------------------------------------- /src/types/map.d.ts: -------------------------------------------------------------------------------- 1 | export type Color = [number, number, number, number]; 2 | export type BBox2D = [number, number, number, number]; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /docs/decisions/adr-template-bare-minimal.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | ## Context and Problem Statement 4 | 5 | ## Considered Options 6 | 7 | ## Decision Outcome 8 | 9 | ### Consequences 10 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | "@semantic-release/release-notes-generator", 8 | "@semantic-release/github" 9 | ], 10 | "preset": "conventionalcommits" 11 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | stac-map 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | tests/__screenshots__/ 26 | codebook.toml 27 | -------------------------------------------------------------------------------- /src/components/ui/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ChakraProvider, defaultSystem } from "@chakra-ui/react"; 4 | import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode"; 5 | 6 | export function Provider(props: ColorModeProviderProps) { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | import topLevelAwait from "vite-plugin-top-level-await"; 4 | import wasm from "vite-plugin-wasm"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | 7 | // https://vite.dev/config/ 8 | export default defineConfig({ 9 | base: "/stac-map/", 10 | plugins: [react(), tsconfigPaths(), wasm(), topLevelAwait()], 11 | }); 12 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - reopened 9 | 10 | jobs: 11 | lint: 12 | name: Lint 13 | runs-on: ubuntu-latest 14 | permissions: 15 | pull-requests: read 16 | steps: 17 | - uses: amannn/action-semantic-pull-request@v6 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /src/components/sections/properties.tsx: -------------------------------------------------------------------------------- 1 | import { LuList } from "react-icons/lu"; 2 | import { Properties, type PropertiesProps } from "../properties"; 3 | import Section from "../section"; 4 | 5 | export default function PropertiesSection({ ...props }: PropertiesProps) { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## Checklist 8 | 9 | - [ ] Code is formatted (`yarn format`) 10 | - [ ] Code is linted (`yarn lint`) 11 | - [ ] Code builds (`yarn build`) 12 | - [ ] Tests pass (`yarn test`) 13 | - [ ] Commit messages and/or this PR's title are formatted per [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | development-dependencies: 9 | dependency-type: "development" 10 | update-types: 11 | - "minor" 12 | - "patch" 13 | production-dependencies: 14 | dependency-type: "production" 15 | update-types: 16 | - "minor" 17 | - "patch" 18 | - package-ecosystem: "github-actions" 19 | directory: "/" 20 | schedule: 21 | interval: "weekly" 22 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { playwright } from "@vitest/browser-playwright"; 3 | import wasm from "vite-plugin-wasm"; 4 | import { defineConfig } from "vitest/config"; 5 | 6 | export default defineConfig({ 7 | plugins: [react(), wasm()], 8 | optimizeDeps: { 9 | include: ["@deck.gl/core", "@duckdb/duckdb-wasm", "react-icons/lib"], 10 | }, 11 | test: { 12 | browser: { 13 | enabled: true, 14 | provider: playwright(), 15 | headless: true, 16 | instances: [ 17 | { 18 | browser: "chromium", 19 | }, 20 | ], 21 | }, 22 | typecheck: { 23 | enabled: true, 24 | }, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /docs/decisions/adr-template-bare.md: -------------------------------------------------------------------------------- 1 | --- 2 | status: 3 | date: 4 | decision-makers: 5 | consulted: 6 | informed: 7 | --- 8 | 9 | # 10 | 11 | ## Context and Problem Statement 12 | 13 | ## Decision Drivers 14 | 15 | - 16 | 17 | ## Considered Options 18 | 19 | - 20 | 21 | ## Decision Outcome 22 | 23 | Chosen option: "", because 24 | 25 | ### Consequences 26 | 27 | - Good, because 28 | - Bad, because 29 | 30 | ### Confirmation 31 | 32 | ## Pros and Cons of the Options 33 | 34 | ### 35 | 36 | - Good, because 37 | - Neutral, because 38 | - Bad, because 39 | 40 | ## More Information 41 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | tabWidth = 2 2 | semi = true 3 | singleQuote = false 4 | trailingComma = "es5" 5 | printWidth = 80 6 | plugins = ["@trivago/prettier-plugin-sort-imports"] 7 | 8 | # Import sorting configuration 9 | importOrder = [ 10 | "^react$", 11 | "^react-dom$", 12 | "^react/", 13 | "^react-", 14 | "^@chakra-ui/", 15 | "^@deck\\.gl/", 16 | "^@geoarrow/", 17 | "^@duckdb/", 18 | "^@tanstack/", 19 | "^@turf/", 20 | "^@types/", 21 | "^maplibre-gl", 22 | "^deck\\.gl", 23 | "^apache-arrow", 24 | "^stac-ts", 25 | "^stac-wasm", 26 | "^next-themes", 27 | "^@", 28 | "^[a-z]", 29 | "^\\./", 30 | "^\\.\\./" 31 | ] 32 | importOrderSeparation = false 33 | importOrderSortSpecifiers = true 34 | importOrderCaseInsensitive = true 35 | -------------------------------------------------------------------------------- /docs/decisions/.markdownlint.yml: -------------------------------------------------------------------------------- 1 | default: true 2 | 3 | # Allow arbitrary line length 4 | # 5 | # Reason: We apply the one-sentence-per-line rule. A sentence may get longer than 80 characters, especially if links are contained. 6 | # 7 | # Details: https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md#md013---line-length 8 | MD013: false 9 | 10 | # Allow duplicate headings 11 | # 12 | # Reasons: 13 | # 14 | # - The chosen option is considerably often used as title of the ADR (e.g., ADR-0015). Thus, that title repeats. 15 | # - We use "Examples" multiple times (e.g., ADR-0010). 16 | # - Markdown lint should support the user and not annoy them. 17 | # 18 | # Details: https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md#md024---multiple-headings-with-the-same-content 19 | MD024: false 20 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "erasableSyntaxOnly": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/extent.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@chakra-ui/react"; 2 | import type { 3 | SpatialExtent as StacSpatialExtent, 4 | TemporalExtent as StacTemporalExtent, 5 | } from "stac-ts"; 6 | 7 | export function SpatialExtent({ bbox }: { bbox: StacSpatialExtent }) { 8 | return [{bbox.map((n) => Number(n.toFixed(4))).join(", ")}]; 9 | } 10 | 11 | export function TemporalExtent({ interval }: { interval: StacTemporalExtent }) { 12 | return ( 13 | 14 | —{" "} 15 | 16 | 17 | ); 18 | } 19 | 20 | function DateString({ datetime }: { datetime: string | null }) { 21 | if (datetime) { 22 | return new Date(datetime).toLocaleDateString(); 23 | } else { 24 | return "unbounded"; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | yarn.lock 4 | package-lock.json 5 | 6 | # Build outputs 7 | dist/ 8 | build/ 9 | .next/ 10 | out/ 11 | 12 | # Generated files 13 | coverage/ 14 | .nyc_output/ 15 | 16 | # Logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Cache directories 29 | .cache/ 30 | .parcel-cache/ 31 | 32 | # IDE files 33 | .vscode/ 34 | .idea/ 35 | 36 | # OS generated files 37 | .DS_Store 38 | .DS_Store? 39 | ._* 40 | .Spotlight-V100 41 | .Trashes 42 | ehthumbs.db 43 | Thumbs.db 44 | 45 | # Temporary files 46 | *.tmp 47 | *.temp 48 | 49 | # Documentation that shouldn't be auto-formatted 50 | CHANGELOG.md 51 | LICENSE 52 | *.min.js 53 | *.min.css 54 | 55 | # Config files that might have specific formatting 56 | .github/ 57 | -------------------------------------------------------------------------------- /docs/decisions/README.md: -------------------------------------------------------------------------------- 1 | # Decisions 2 | 3 | For new Architectural Decision Records (ADRs), please use one of the following templates as a starting point: 4 | 5 | - [adr-template.md](adr-template.md) has all sections, with explanations about them. 6 | - [adr-template-minimal.md](adr-template-minimal.md) only contains mandatory sections, with explanations about them. 7 | - [adr-template-bare.md](adr-template-bare.md) has all sections, which are empty (no explanations). 8 | - [adr-template-bare-minimal.md](adr-template-bare-minimal.md) has the mandatory sections, without explanations. 9 | 10 | The MADR documentation is available at while general information about ADRs is available at . 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import reactHooks from "eslint-plugin-react-hooks"; 3 | import reactRefresh from "eslint-plugin-react-refresh"; 4 | import globals from "globals"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist", "src/components/ui"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2022, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | } 28 | ); 29 | -------------------------------------------------------------------------------- /src/hooks/stac-collections.ts: -------------------------------------------------------------------------------- 1 | import { useInfiniteQuery } from "@tanstack/react-query"; 2 | import type { StacCollections } from "../types/stac"; 3 | 4 | export default function useStacCollections(href: string | undefined) { 5 | return useInfiniteQuery({ 6 | queryKey: ["stac-collections", href], 7 | queryFn: async ({ pageParam }) => { 8 | if (pageParam) { 9 | return await fetch(pageParam).then((response) => { 10 | if (response.ok) return response.json(); 11 | else 12 | throw new Error( 13 | `Error while fetching collections from ${pageParam}` 14 | ); 15 | }); 16 | } else { 17 | return null; 18 | } 19 | }, 20 | initialPageParam: href, 21 | getNextPageParam: (lastPage: StacCollections | null) => 22 | lastPage?.links?.find((link) => link.rel == "next")?.href, 23 | enabled: !!href, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /docs/decisions/0002-drill-the-props.md: -------------------------------------------------------------------------------- 1 | # Drill the props 2 | 3 | ## Context and Problem Statement 4 | 5 | There's a lot of state that we need to synchronize between the map and the rest of the application. 6 | 7 | ## Considered Options 8 | 9 | - Use a custom context and provider 10 | - Use dispatch 11 | - Use a state management framework 12 | - Just [prop drill](https://react.dev/learn/passing-data-deeply-with-context#the-problem-with-passing-props) 13 | 14 | ## Decision Outcome 15 | 16 | We tried both dispatch and a custom context, and while both were fine, they felt complicated to manage as the app changed state. 17 | We rejected a state management framework as "too heavy" for this simple of an app. 18 | As of v0.7, we went back to prop drilling. 19 | 20 | ### Consequences 21 | 22 | - Good, because it encourages us to have a flatter, simpler component hierarchy 23 | - Bad, because it leads to components with _lots_ of props at a high level 24 | -------------------------------------------------------------------------------- /src/components/examples.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from "react"; 2 | import { Badge, Menu, Portal, Span } from "@chakra-ui/react"; 3 | import { EXAMPLES } from "../constants"; 4 | 5 | export function Examples({ 6 | setHref, 7 | children, 8 | }: { 9 | setHref: (href: string | undefined) => void; 10 | children: ReactNode; 11 | }) { 12 | return ( 13 | setHref(details.value)}> 14 | {children} 15 | 16 | 17 | 18 | {EXAMPLES.map(({ title, badge, href }, index) => ( 19 | 20 | {title} 21 | 22 | {badge} 23 | 24 | ))} 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { ErrorBoundary } from "react-error-boundary"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | import App from "./app.tsx"; 6 | import { ErrorComponent } from "./components/error.tsx"; 7 | import { Provider } from "./components/ui/provider.tsx"; 8 | 9 | const queryClient = new QueryClient({ 10 | defaultOptions: { 11 | queries: { 12 | staleTime: Infinity, 13 | retry: 1, 14 | }, 15 | }, 16 | }); 17 | 18 | createRoot(document.getElementById("root")!).render( 19 | 20 | 21 | 22 | history.pushState(null, "", location.pathname)} 25 | > 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | -------------------------------------------------------------------------------- /src/types/stac.d.ts: -------------------------------------------------------------------------------- 1 | import type { StacAsset, StacCatalog, StacCollection, StacItem } from "stac-ts"; 2 | 3 | export interface StacItemCollection { 4 | type: "FeatureCollection"; 5 | features: StacItem[]; 6 | id?: string; 7 | title?: string; 8 | description?: string; 9 | links?: StacLink[]; 10 | numberMatched?: number; 11 | [k: string]: unknown; 12 | } 13 | 14 | export type StacValue = 15 | | StacCatalog 16 | | StacCollection 17 | | StacItem 18 | | StacItemCollection; 19 | 20 | export interface StacCollections { 21 | collections: StacCollection[]; 22 | links?: StacLink[]; 23 | numberMatched?: number; 24 | } 25 | 26 | export interface NaturalLanguageCollectionSearchResult { 27 | collection_id: string; 28 | explanation: string; 29 | } 30 | 31 | export type StacAssets = { [k: string]: StacAsset }; 32 | 33 | export interface StacSearch { 34 | collections?: string[]; 35 | datetime?: string; 36 | bbox?: number[]; 37 | } 38 | 39 | export type DatetimeBounds = { start: Date; end: Date }; 40 | -------------------------------------------------------------------------------- /src/components/sections/catalogs.tsx: -------------------------------------------------------------------------------- 1 | import { LuFolder } from "react-icons/lu"; 2 | import { Stack } from "@chakra-ui/react"; 3 | import type { StacCatalog } from "stac-ts"; 4 | import CatalogCard from "../cards/catalog"; 5 | import Section from "../section"; 6 | 7 | interface CatalogsProps { 8 | catalogs: StacCatalog[]; 9 | setHref: (href: string | undefined) => void; 10 | } 11 | 12 | export default function CatalogsSection({ catalogs, setHref }: CatalogsProps) { 13 | const title = `Catalogs (${catalogs.length})`; 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | 21 | function Catalogs({ catalogs, setHref }: CatalogsProps) { 22 | return ( 23 | 24 | {catalogs.map((catalog) => ( 25 | 30 | ))} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/cards/catalog.tsx: -------------------------------------------------------------------------------- 1 | import { MarkdownHooks } from "react-markdown"; 2 | import { Card, Link, Stack, Text } from "@chakra-ui/react"; 3 | import type { StacCatalog } from "stac-ts"; 4 | 5 | export default function CatalogCard({ 6 | catalog, 7 | setHref, 8 | }: { 9 | catalog: StacCatalog; 10 | setHref: (href: string | undefined) => void; 11 | }) { 12 | const selfHref = catalog.links.find((link) => link.rel === "self")?.href; 13 | return ( 14 | 15 | 16 | 17 | selfHref && setHref(selfHref)}> 18 | {catalog.title || catalog.id} 19 | 20 | 21 | 22 | 23 | 24 | {catalog.description} 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/error.tsx: -------------------------------------------------------------------------------- 1 | import { AbsoluteCenter, Alert, Box, Button, Stack } from "@chakra-ui/react"; 2 | 3 | export function ErrorComponent({ 4 | error, 5 | resetErrorBoundary, 6 | }: { 7 | error: Error; 8 | resetErrorBoundary: () => void; 9 | }) { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | Unhandled application error 17 | 18 | 19 | {error.message} 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Development Seed 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /docs/decisions/adr-template-minimal.md: -------------------------------------------------------------------------------- 1 | # {short title, representative of solved problem and found solution} 2 | 3 | ## Context and Problem Statement 4 | 5 | {Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story. You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.} 6 | 7 | ## Considered Options 8 | 9 | - {title of option 1} 10 | - {title of option 2} 11 | - {title of option 3} 12 | - … 13 | 14 | ## Decision Outcome 15 | 16 | Chosen option: "{title of option 1}", because {justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | … | comes out best (see below)}. 17 | 18 | 19 | 20 | ### Consequences 21 | 22 | - Good, because {positive consequence, e.g., improvement of one or more desired qualities, …} 23 | - Bad, because {negative consequence, e.g., compromising one or more desired qualities, …} 24 | - … 25 | -------------------------------------------------------------------------------- /src/components/sections/assets.tsx: -------------------------------------------------------------------------------- 1 | import { LuFiles } from "react-icons/lu"; 2 | import { DataList } from "@chakra-ui/react"; 3 | import type { StacAssets } from "../../types/stac"; 4 | import AssetCard from "../cards/asset"; 5 | import Section from "../section"; 6 | 7 | interface AssetsProps { 8 | assets: StacAssets; 9 | cogTileHref: string | undefined; 10 | setCogTileHref: (href: string | undefined) => void; 11 | } 12 | 13 | export default function AssetsSection({ ...props }: AssetsProps) { 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | 21 | function Assets({ assets, cogTileHref, setCogTileHref }: AssetsProps) { 22 | return ( 23 | 24 | {Object.keys(assets).map((key) => ( 25 | 26 | {key} 27 | 28 | 33 | 34 | 35 | ))} 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Toaster as ChakraToaster, 5 | createToaster, 6 | Portal, 7 | Spinner, 8 | Stack, 9 | Toast, 10 | } from "@chakra-ui/react"; 11 | 12 | export const toaster = createToaster({ 13 | placement: "bottom-end", 14 | pauseOnPageIdle: true, 15 | }); 16 | 17 | export const Toaster = () => { 18 | return ( 19 | 20 | 21 | {(toast) => ( 22 | 23 | {toast.type === "loading" ? ( 24 | 25 | ) : ( 26 | 27 | )} 28 | 29 | {toast.title && {toast.title}} 30 | {toast.description && ( 31 | {toast.description} 32 | )} 33 | 34 | {toast.action && ( 35 | {toast.action.label} 36 | )} 37 | {toast.closable && } 38 | 39 | )} 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /docs/decisions/0001-dont-use-stac-react.md: -------------------------------------------------------------------------------- 1 | # Don't use **stac-react** 2 | 3 | ## Context and Problem Statement 4 | 5 | Development Seed has headless [STAC](https://stacspec.org) components for [React](https://react.dev/) at [stac-react](https://github.com/developmentseed/stac-react). 6 | **stac-map** needs to fetch STAC values over HTTPS and search STAC APIs, and **stac-react** supports both of these operations. 7 | However, **stac-map** has some additional requirements that are _not_ directly supported by **stac-react**: 8 | 9 | - **stac-map** can have _any_ STAC value as it's "root value", not just a STAC API. **stac-react** requires a STAC API for many of its functions. 10 | - **stac-react** does not do any caching of fetched values 11 | 12 | ## Considered Options 13 | 14 | - Extend **stac-react** with the functionality we need for **stac-map** 15 | - Don't use **stac-react** 16 | 17 | ## Decision Outcome 18 | 19 | We decided to not use **stac-react** for the initial build out of **stac-map**. 20 | 21 | ### Consequences 22 | 23 | - We were able to build more quickly because we didn't have to work around limitations of **stac-react**. 24 | - We did a lot of work that might not be as reusable by other STAC+React projects. 25 | Over the medium-to-long term, we should find parts of **stac-map** that we can move to **stac-react**, and/or refactor **stac-react** to more directly support **stac-map** requirements. 26 | -------------------------------------------------------------------------------- /src/components/panel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | SkeletonText, 4 | type UseFileUploadReturn, 5 | } from "@chakra-ui/react"; 6 | import Introduction from "./introduction"; 7 | import { type SharedValueProps, Value } from "./value"; 8 | import type { StacValue } from "../types/stac"; 9 | 10 | export interface PanelProps extends SharedValueProps { 11 | href: string | undefined; 12 | value: StacValue | undefined; 13 | error: Error | undefined; 14 | fileUpload: UseFileUploadReturn; 15 | } 16 | 17 | export default function Panel({ 18 | href, 19 | setHref, 20 | value, 21 | error, 22 | fileUpload, 23 | ...props 24 | }: PanelProps) { 25 | if (error) 26 | return ( 27 | 28 | 29 | 30 | Error while fetching STAC value 31 | {error.toString()} 32 | 33 | 34 | ); 35 | else if (href) { 36 | if (value) { 37 | return ( 38 | 45 | ); 46 | } else { 47 | return ; 48 | } 49 | } else { 50 | return ; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const EXAMPLES = [ 2 | { title: "eoAPI DevSeed", badge: "API", href: "https://stac.eoapi.dev/" }, 3 | { 4 | title: "Microsoft Planetary Computer", 5 | badge: "API", 6 | href: "https://planetarycomputer.microsoft.com/api/stac/v1", 7 | }, 8 | { 9 | title: "Earth Search by Element 84", 10 | badge: "API", 11 | href: "https://earth-search.aws.element84.com/v1", 12 | }, 13 | { 14 | title: "NASA VEDA", 15 | badge: "API", 16 | href: "https://openveda.cloud/api/stac", 17 | }, 18 | { 19 | title: "HOT OpenAerialMap", 20 | badge: "API", 21 | href: "https://api.imagery.hotosm.org/stac", 22 | }, 23 | { 24 | title: "Maxar Open Data", 25 | badge: "static", 26 | href: "https://maxar-opendata.s3.dualstack.us-west-2.amazonaws.com/events/catalog.json", 27 | }, 28 | { 29 | title: "Colorado NAIP", 30 | badge: "stac-geoparquet", 31 | href: "https://raw.githubusercontent.com/developmentseed/labs-375-stac-geoparquet-backend/refs/heads/main/data/naip.parquet", 32 | }, 33 | { 34 | title: "Kentucky Imagery & Elevation", 35 | badge: "API", 36 | href: "https://spved5ihrl.execute-api.us-west-2.amazonaws.com/", 37 | }, 38 | { 39 | title: "Simple item", 40 | badge: "item", 41 | href: "https://raw.githubusercontent.com/radiantearth/stac-spec/refs/heads/master/examples/simple-item.json", 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /src/hooks/stac-children.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useQueries } from "@tanstack/react-query"; 3 | import type { StacValue } from "../types/stac"; 4 | import { getStacJsonValue } from "../utils/stac"; 5 | 6 | export default function useStacChildren({ 7 | value, 8 | enabled, 9 | }: { 10 | value: StacValue | undefined; 11 | enabled: boolean; 12 | }) { 13 | const results = useQueries({ 14 | queries: 15 | value?.links 16 | ?.filter((link) => link.rel === "child") 17 | .map((link) => { 18 | return { 19 | queryKey: ["stac-value", link.href], 20 | queryFn: () => getStacJsonValue(link.href), 21 | enabled: enabled, 22 | }; 23 | }) || [], 24 | combine: (results) => { 25 | return { 26 | data: results.map((result) => result.data), 27 | }; 28 | }, 29 | }); 30 | 31 | return useMemo(() => { 32 | const collections = []; 33 | const catalogs = []; 34 | for (const value of results.data) { 35 | switch (value?.type) { 36 | case "Catalog": 37 | catalogs.push(value); 38 | break; 39 | case "Collection": 40 | collections.push(value); 41 | break; 42 | } 43 | } 44 | return { 45 | collections: collections.length > 0 ? collections : undefined, 46 | catalogs: catalogs.length > 0 ? catalogs : undefined, 47 | }; 48 | }, [results.data]); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/section.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from "react"; 2 | import { ErrorBoundary } from "react-error-boundary"; 3 | import { type IconType } from "react-icons/lib"; 4 | import { Accordion, Alert, HStack, Icon } from "@chakra-ui/react"; 5 | 6 | export default function Section({ 7 | title, 8 | TitleIcon, 9 | value, 10 | children, 11 | }: { 12 | title: ReactNode; 13 | TitleIcon: IconType; 14 | value: string; 15 | children: ReactNode; 16 | }) { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | {" "} 24 | {title} 25 | 26 | 27 | 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | function FallbackComponent({ error }: { error: Error }) { 40 | return ( 41 | 42 | 43 | 44 | An error occurred during rendering 45 | {error.message} 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"; 3 | 4 | export interface TooltipProps extends ChakraTooltip.RootProps { 5 | showArrow?: boolean; 6 | portalled?: boolean; 7 | portalRef?: React.RefObject; 8 | content: React.ReactNode; 9 | contentProps?: ChakraTooltip.ContentProps; 10 | disabled?: boolean; 11 | } 12 | 13 | export const Tooltip = React.forwardRef( 14 | function Tooltip(props, ref) { 15 | const { 16 | showArrow, 17 | children, 18 | disabled, 19 | portalled = true, 20 | content, 21 | contentProps, 22 | portalRef, 23 | ...rest 24 | } = props; 25 | 26 | if (disabled) return children; 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | 33 | 34 | {showArrow && ( 35 | 36 | 37 | 38 | )} 39 | {content} 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | ); 47 | -------------------------------------------------------------------------------- /src/components/sections/items.tsx: -------------------------------------------------------------------------------- 1 | import { LuFiles } from "react-icons/lu"; 2 | import { Link, List } from "@chakra-ui/react"; 3 | import type { StacItem } from "stac-ts"; 4 | import Section from "../section"; 5 | 6 | interface ItemsProps { 7 | items: StacItem[]; 8 | setHref: (href: string | undefined) => void; 9 | } 10 | 11 | export default function ItemsSection({ 12 | filteredItems, 13 | items, 14 | ...props 15 | }: { filteredItems: StacItem[] | undefined } & ItemsProps) { 16 | const parenthetical = filteredItems 17 | ? `${filteredItems.length}/${items.length}` 18 | : items.length; 19 | const title = `Items (${parenthetical})`; 20 | return ( 21 |
22 | 23 |
24 | ); 25 | } 26 | 27 | function Items({ 28 | items, 29 | setHref, 30 | }: { 31 | items: StacItem[]; 32 | setHref: (href: string | undefined) => void; 33 | }) { 34 | return ( 35 | 36 | {items.map((item, i) => ( 37 | 38 | { 40 | const selfHref = item.links.find( 41 | (link) => link.rel === "self" 42 | )?.href; 43 | if (selfHref) setHref(selfHref); 44 | }} 45 | > 46 | {item.id} 47 | 48 | 49 | ))} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/sections/collections.tsx: -------------------------------------------------------------------------------- 1 | import { LuFolderPlus } from "react-icons/lu"; 2 | import { Stack } from "@chakra-ui/react"; 3 | import type { StacCollection } from "stac-ts"; 4 | import CollectionCard from "../cards/collection"; 5 | import Section from "../section"; 6 | 7 | interface CollectionsProps { 8 | collections: StacCollection[]; 9 | setHref: (href: string | undefined) => void; 10 | } 11 | 12 | export default function CollectionsSection({ 13 | collections, 14 | filteredCollections, 15 | numberOfCollections, 16 | setHref, 17 | }: { 18 | filteredCollections: StacCollection[] | undefined; 19 | numberOfCollections: number | undefined; 20 | } & CollectionsProps) { 21 | const parenthetical = filteredCollections 22 | ? `${filteredCollections.length}/${numberOfCollections || collections.length}` 23 | : collections.length; 24 | const title = `Collections (${parenthetical})`; 25 | return ( 26 |
27 | 31 |
32 | ); 33 | } 34 | 35 | function Collections({ collections, setHref }: CollectionsProps) { 36 | return ( 37 | 38 | {collections.map((collection) => ( 39 | 44 | ))} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /docs/decisions/0000-use-markdown-architectural-decision-records.md: -------------------------------------------------------------------------------- 1 | # Use Markdown Architectural Decision Records 2 | 3 | ## Context and Problem Statement 4 | 5 | We want to record architectural decisions made in this project independent whether decisions concern the architecture ("architectural decision record"), the code, or other fields. 6 | Which format and structure should these records follow? 7 | 8 | ## Considered Options 9 | 10 | - [MADR](https://adr.github.io/madr/) 4.0.0 – The Markdown Architectural Decision Records 11 | - [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" 12 | - [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements 13 | - Other templates listed at 14 | - Formless – No conventions for file format and structure 15 | 16 | ## Decision Outcome 17 | 18 | Chosen option: "MADR 4.0.0", because 19 | 20 | - Implicit assumptions should be made explicit. 21 | Design documentation is important to enable people understanding the decisions later on. 22 | See also ["A rational design process: How and why to fake it"](https://doi.org/10.1109/TSE.1986.6312940). 23 | - MADR allows for structured capturing of any decision. 24 | - The MADR format is lean and fits our development style. 25 | - The MADR structure is comprehensible and facilitates usage & maintenance. 26 | - The MADR project is vivid. 27 | -------------------------------------------------------------------------------- /src/components/introduction.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FileUpload, 3 | Link, 4 | Stack, 5 | type UseFileUploadReturn, 6 | } from "@chakra-ui/react"; 7 | import { Examples } from "./examples"; 8 | 9 | export default function Introduction({ 10 | fileUpload, 11 | setHref, 12 | }: { 13 | fileUpload: UseFileUploadReturn; 14 | setHref: (href: string | undefined) => void; 15 | }) { 16 | return ( 17 | 18 |

19 | stac-map is a map-first visualization tool for{" "} 20 | 21 | STAC 22 | 23 | . 24 |

25 | 26 |

27 | To get started, use the text input,{" "} 28 | 33 | 34 | upload a file 35 | 36 | 37 | , or{" "} 38 | 39 | load an example 40 | 41 | . 42 |

43 |

44 | Questions, issues, or feature requests? Get in touch on{" "} 45 | 46 | 47 | GitHub 48 | 49 | 50 | . 51 |

52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/hooks/stac-search.ts: -------------------------------------------------------------------------------- 1 | import { useInfiniteQuery } from "@tanstack/react-query"; 2 | import type { StacLink } from "stac-ts"; 3 | import type { StacItemCollection, StacSearch } from "../types/stac"; 4 | import { fetchStac } from "../utils/stac"; 5 | 6 | export default function useStacSearch(search: StacSearch, link: StacLink) { 7 | return useInfiniteQuery({ 8 | queryKey: ["search", search, link], 9 | initialPageParam: updateLink(link, search), 10 | getNextPageParam: (lastPage: StacItemCollection) => 11 | lastPage.links?.find((link) => link.rel == "next"), 12 | queryFn: fetchSearch, 13 | }); 14 | } 15 | 16 | async function fetchSearch({ pageParam }: { pageParam: StacLink }) { 17 | return (await fetchStac( 18 | pageParam.href, 19 | pageParam.method as "GET" | "POST" | undefined, 20 | (pageParam.body as StacSearch) && JSON.stringify(pageParam.body) 21 | )) as StacItemCollection; 22 | } 23 | 24 | function updateLink(link: StacLink, search: StacSearch) { 25 | if (!link.method) { 26 | link.method = "GET"; 27 | } 28 | const url = new URL(link.href); 29 | if (link.method == "GET") { 30 | if (search.collections) { 31 | url.searchParams.set("collections", search.collections.join(",")); 32 | } 33 | if (search.bbox) { 34 | url.searchParams.set("bbox", search.bbox.join(",")); 35 | } 36 | if (search.datetime) { 37 | url.searchParams.set("datetime", search.datetime); 38 | } 39 | } else { 40 | link.body = search; 41 | } 42 | link.href = url.toString(); 43 | return link; 44 | } 45 | -------------------------------------------------------------------------------- /src/components/cards/collection.tsx: -------------------------------------------------------------------------------- 1 | import { MarkdownHooks } from "react-markdown"; 2 | import { Card, Link, Stack, Text } from "@chakra-ui/react"; 3 | import type { StacCollection } from "stac-ts"; 4 | import { SpatialExtent, TemporalExtent } from "../extent"; 5 | 6 | export default function CollectionCard({ 7 | collection, 8 | setHref, 9 | footer, 10 | }: { 11 | collection: StacCollection; 12 | setHref: (href: string | undefined) => void; 13 | footer?: string; 14 | }) { 15 | const selfHref = collection.links.find((link) => link.rel === "self")?.href; 16 | return ( 17 | 18 | 19 | 20 | selfHref && setHref(selfHref)}> 21 | {collection.title || collection.id} 22 | 23 | 24 | 25 | 26 | 27 | {collection.description} 28 | 29 | 30 | {collection.extent?.temporal?.interval && ( 31 | 34 | )} 35 | {collection.extent?.spatial?.bbox && ( 36 | 39 | )} 40 | 41 | 42 | 43 | {footer && ( 44 | 45 | {footer} 46 | 47 | )} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/preview-remove.yml: -------------------------------------------------------------------------------- 1 | name: Remove preview 2 | 3 | on: 4 | pull_request_target: 5 | types: [ closed ] 6 | 7 | env: 8 | BUCKET_NAME: ds-preview-stac-map-${{ github.event.number }} 9 | AWS_ROLE_ARN: arn:aws:iam::552819999234:role/stac-map-gh-preview 10 | AWS_REGION: us-west-2 11 | 12 | permissions: 13 | id-token: write 14 | contents: read 15 | issues: write 16 | pull-requests: write 17 | 18 | jobs: 19 | remove: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v6 23 | - uses: aws-actions/configure-aws-credentials@v5 24 | with: 25 | role-to-assume: ${{ env.AWS_ROLE_ARN }} 26 | aws-region: ${{ env.AWS_REGION }} 27 | - name: Check if bucket exists 28 | id: check_bucket 29 | run: | 30 | if aws s3 ls "s3://${{ env.BUCKET_NAME }}" 2>&1 | grep -q 'NoSuchBucket'; then 31 | echo "Bucket does not exist." 32 | echo "exists=false" >> "$GITHUB_OUTPUT" 33 | else 34 | echo "Bucket exists." 35 | echo "exists=true" >> "$GITHUB_OUTPUT" 36 | fi 37 | - name: Empty the bucket 38 | if: steps.check_bucket.outputs.exists == 'true' 39 | run: | 40 | aws s3 rm s3://$BUCKET_NAME --recursive --quiet 41 | - name: Remove the bucket 42 | if: steps.check_bucket.outputs.exists == 'true' 43 | run: | 44 | aws s3 rb s3://$BUCKET_NAME 45 | - name: Remove PR comment 46 | uses: actions/github-script@v8 47 | if: success() 48 | with: 49 | script: | 50 | const { deleteComment } = require('./.github/workflows/github-pr-update.cjs') 51 | await deleteComment({ github, context, core }) 52 | -------------------------------------------------------------------------------- /src/components/sections/links.tsx: -------------------------------------------------------------------------------- 1 | import { LuArrowUpToLine, LuExternalLink, LuLink } from "react-icons/lu"; 2 | import { ButtonGroup, IconButton, Link, List, Span } from "@chakra-ui/react"; 3 | import type { StacLink } from "stac-ts"; 4 | import Section from "../section"; 5 | 6 | const SET_HREF_REL_TYPES = [ 7 | "root", 8 | "parent", 9 | "child", 10 | "collection", 11 | "item", 12 | "search", 13 | "items", 14 | ]; 15 | 16 | interface LinksProps { 17 | links: StacLink[]; 18 | setHref: (href: string | undefined) => void; 19 | } 20 | 21 | export default function LinksSection({ ...props }: LinksProps) { 22 | return ( 23 |
24 | 25 |
26 | ); 27 | } 28 | 29 | function Links({ links, setHref }: LinksProps) { 30 | return ( 31 | 32 | {links.map((link, i) => ( 33 | 34 | 35 | {link.rel + (link.method ? ` (${link.method})` : "")} 36 | 37 | 38 | {SET_HREF_REL_TYPES.includes(link.rel) && 39 | (!link.method || link.method === "GET") && ( 40 | { 42 | e.preventDefault(); 43 | setHref(link.href); 44 | }} 45 | > 46 | 47 | 48 | 49 | 50 | )} 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ))} 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/properties.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { LuFileJson } from "react-icons/lu"; 3 | import { Code, DataList, Dialog, IconButton, Portal } from "@chakra-ui/react"; 4 | 5 | export interface PropertiesProps { 6 | properties: { [k: string]: unknown }; 7 | } 8 | 9 | export function Properties({ properties }: PropertiesProps) { 10 | return ( 11 | 12 | {Object.keys(properties).map((key) => ( 13 | 14 | ))} 15 | 16 | ); 17 | } 18 | 19 | function Property({ 20 | propertyKey, 21 | propertyValue, 22 | }: { 23 | propertyKey: string; 24 | propertyValue: unknown; 25 | }) { 26 | return ( 27 | 28 | {propertyKey} 29 | {getValue(propertyValue)} 30 | 31 | ); 32 | } 33 | 34 | function getValue(value: unknown): ReactNode { 35 | switch (typeof value) { 36 | case "string": 37 | case "number": 38 | case "bigint": 39 | case "boolean": 40 | case "undefined": 41 | return value; 42 | case "object": 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
56 |                     {JSON.stringify(value, null, 2)}
57 |                   
58 |
59 |
60 |
61 |
62 |
63 | ); 64 | case "symbol": 65 | case "function": 66 | return null; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/ui/toggle-tip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { HiOutlineInformationCircle } from "react-icons/hi"; 3 | import { Popover as ChakraPopover, IconButton, Portal } from "@chakra-ui/react"; 4 | 5 | export interface ToggleTipProps extends ChakraPopover.RootProps { 6 | showArrow?: boolean; 7 | portalled?: boolean; 8 | portalRef?: React.RefObject; 9 | content?: React.ReactNode; 10 | } 11 | 12 | export const ToggleTip = React.forwardRef( 13 | function ToggleTip(props, ref) { 14 | const { 15 | showArrow, 16 | children, 17 | portalled = true, 18 | content, 19 | portalRef, 20 | ...rest 21 | } = props; 22 | 23 | return ( 24 | 28 | {children} 29 | 30 | 31 | 39 | {showArrow && ( 40 | 41 | 42 | 43 | )} 44 | {content} 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | ); 52 | 53 | export const InfoTip = React.forwardRef< 54 | HTMLDivElement, 55 | Partial 56 | >(function InfoTip(props, ref) { 57 | const { children, ...rest } = props; 58 | return ( 59 | 60 | 66 | 67 | 68 | 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | What follows is some light documentation on how **stac-map** is built. 4 | 5 | ## Core concepts 6 | 7 | Here's the two core concepts of **stac-map**. 8 | 9 | ### Everything starts with the `href` 10 | 11 | **stac-map** is driven by one (and only one) `href` value, which is a URI to a remote file _or_ the name of an uploaded file. 12 | The `href` is stored in the app state and is synchronized with a URL parameter, which allows the sharing of links to **stac-map** pointed at a specific STAC value. 13 | 14 | ### The value could be (almost) anything 15 | 16 | Once the `href` is set, the data at the `href` is loaded into the app as a single `value`. 17 | The `value` could be: 18 | 19 | - A STAC [Catalog](https://github.com/radiantearth/stac-spec/blob/master/catalog-spec/catalog-spec.md), [Collection](https://github.com/radiantearth/stac-spec/blob/master/collection-spec/collection-spec.md), or [Item](https://github.com/radiantearth/stac-spec/blob/master/item-spec/item-spec.md) 20 | - A STAC [API](https://github.com/radiantearth/stac-api-spec) 21 | - A GeoJSON [FeatureCollection](https://datatracker.ietf.org/doc/html/rfc7946#section-3.3) with STAC Items as its `features` (commonly referred to as an `ItemCollection`, though no such term exists in the STAC specification) 22 | - A [stac-geoparquet](https://github.com/stac-utils/stac-geoparquet) file, which is treated as an `ItemCollection`. 23 | 24 | The behaviors of the app are then driven by the attributes of the `value`. 25 | 26 | ## Concept diagram 27 | 28 | Any values that don't have a parent are set by the user, either directly (e.g. `href`) or by interacting with the app (e.g. `bbox`). 29 | 30 | ```mermaid 31 | flowchart TD 32 | h[href] --> value; 33 | value --> catalogs; 34 | value --> collections; 35 | collections --> filteredCollections; 36 | bbox --> filteredCollections; 37 | datetimeBounds --> filteredCollections; 38 | value --> linkedItems; 39 | linkedItems -- if no user items --> items; 40 | search --> userItems; 41 | userItems --> items; 42 | items --> filteredItems; 43 | bbox --> filteredItems; 44 | datetimeBounds --> filteredItems; 45 | ``` 46 | -------------------------------------------------------------------------------- /tests/stac.spec.ts: -------------------------------------------------------------------------------- 1 | import { StacItem } from "stac-ts"; 2 | import { expect, test } from "vitest"; 3 | import { makeHrefsAbsolute, toAbsoluteUrl } from "../src/utils/stac"; 4 | 5 | test("should preserve UTF8 characters while making URLS absolute", async () => { 6 | expect(toAbsoluteUrl("🦄.tiff", new URL("s3://some-bucket"))).equals( 7 | "s3://some-bucket/🦄.tiff" 8 | ); 9 | expect( 10 | toAbsoluteUrl("https://foo/bar/🦄.tiff", new URL("s3://some-bucket")) 11 | ).equals("https://foo/bar/🦄.tiff"); 12 | expect( 13 | toAbsoluteUrl("../../../🦄.tiff", new URL("s3://some-bucket/🌈/path/a/b/")) 14 | ).equals("s3://some-bucket/🌈/🦄.tiff"); 15 | 16 | expect(toAbsoluteUrl("a+🦄.tiff", new URL("s3://some-bucket/🌈/"))).equals( 17 | "s3://some-bucket/🌈/a+🦄.tiff" 18 | ); 19 | 20 | expect( 21 | toAbsoluteUrl("../../../🦄.tiff", new URL("https://some-url/🌈/path/a/b/")) 22 | ).equals("https://some-url/%F0%9F%8C%88/%F0%9F%A6%84.tiff"); 23 | expect( 24 | toAbsoluteUrl( 25 | "foo/🦄.tiff?width=1024", 26 | new URL("https://user@[2601:195:c381:3560::f42a]:1234/test") 27 | ) 28 | ).equals( 29 | "https://user@[2601:195:c381:3560::f42a]:1234/foo/%F0%9F%A6%84.tiff?width=1024" 30 | ); 31 | }); 32 | 33 | test("should convert relative links to absolute", () => { 34 | expect( 35 | makeHrefsAbsolute( 36 | { 37 | links: [ 38 | { href: "a/b/c", rel: "child" }, 39 | { href: "/d/e/f", rel: "child" }, 40 | ], 41 | } as unknown as StacItem, 42 | "https://example.com/root/item.json" 43 | ).links 44 | ).deep.equals([ 45 | { href: "https://example.com/root/a/b/c", rel: "child" }, 46 | { href: "https://example.com/d/e/f", rel: "child" }, 47 | { href: "https://example.com/root/item.json", rel: "self" }, 48 | ]); 49 | }); 50 | 51 | test("should convert relative assets to absolute", () => { 52 | expect( 53 | makeHrefsAbsolute( 54 | { 55 | assets: { 56 | tiff: { href: "./foo.tiff" }, 57 | thumbnail: { href: "../thumbnails/foo.png" }, 58 | }, 59 | } as unknown as StacItem, 60 | "https://example.com/root/item.json" 61 | ).assets 62 | ).deep.equals({ 63 | tiff: { href: "https://example.com/root/foo.tiff" }, 64 | thumbnail: { href: "https://example.com/thumbnails/foo.png" }, 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | ci: 11 | name: CI 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v6 15 | - uses: actions/setup-node@v6 16 | with: 17 | node-version-file: .node-version 18 | cache: 'yarn' 19 | - name: Install 20 | run: | 21 | yarn install 22 | yarn playwright install 23 | - name: Lint 24 | run: yarn lint 25 | - name: Check formatting 26 | run: yarn format:check 27 | - name: Test 28 | run: yarn test 29 | - name: Build 30 | run: yarn build 31 | build: 32 | name: Build 33 | needs: ci 34 | if: github.event_name == 'push' 35 | runs-on: ubuntu-latest 36 | permissions: 37 | contents: read 38 | pages: write 39 | id-token: write 40 | steps: 41 | - uses: actions/checkout@v6 42 | with: 43 | fetch-depth: 0 44 | - uses: actions/setup-node@v6 45 | with: 46 | node-version-file: .node-version 47 | cache: 'yarn' 48 | - name: Install 49 | run: yarn install 50 | - id: setup_pages 51 | uses: actions/configure-pages@v5 52 | - name: Build 53 | run: yarn build 54 | - name: Upload artifact 55 | uses: actions/upload-pages-artifact@v4 56 | with: 57 | path: ./dist 58 | deploy: 59 | name: Deploy 60 | needs: build 61 | permissions: 62 | contents: read 63 | pages: write 64 | id-token: write 65 | environment: 66 | name: github-pages 67 | url: ${{ steps.deployment.outputs.page_url }} 68 | runs-on: ubuntu-latest 69 | steps: 70 | - id: deployment 71 | uses: actions/deploy-pages@v4 72 | release: 73 | name: Release 74 | runs-on: ubuntu-latest 75 | needs: deploy 76 | permissions: 77 | contents: write 78 | issues: write 79 | pull-requests: write 80 | id-token: write 81 | steps: 82 | - uses: actions/checkout@v6 83 | with: 84 | fetch-depth: 0 85 | - uses: actions/setup-node@v6 86 | with: 87 | node-version-file: .node-version 88 | cache: 'yarn' 89 | - name: Install 90 | run: yarn install 91 | - name: Release 92 | run: yarn run semantic-release 93 | env: 94 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stac-map 2 | 3 | [![CI status](https://img.shields.io/github/actions/workflow/status/developmentseed/stac-map/ci.yaml?style=for-the-badge&label=CI)](https://github.com/developmentseed/stac-map/actions/workflows/ci.yaml) 4 | [![GitHub deployments](https://img.shields.io/github/deployments/developmentseed/stac-map/github-pages?style=for-the-badge&label=Deploy)](https://github.com/developmentseed/stac-map/deployments/github-pages) 5 | [![GitHub Release](https://img.shields.io/github/v/release/developmentseed/stac-map?style=for-the-badge)](https://github.com/developmentseed/stac-map/releases) 6 | 7 | The map-first, single-page, statically-hosted STAC visualizer at . 8 | 9 | 10 | 11 | 12 | stac-map with eoAPI DevSeed loaded in 13 | 14 | 15 | 16 | Includes: 17 | 18 | - Natural language collection search 19 | - **stac-geoparquet** visualization, upload, and download 20 | 21 | > [!WARNING] 22 | > This application is in its infancy :baby: and will change significantly and/or break at any time. 23 | 24 | ## Development 25 | 26 | Get [yarn](https://yarnpkg.com/), then: 27 | 28 | ```shell 29 | git clone git@github.com:developmentseed/stac-map 30 | cd stac-map 31 | yarn install 32 | yarn dev 33 | ``` 34 | 35 | This will open a development server at . 36 | 37 | We have some code quality checks/tools: 38 | 39 | ```shell 40 | yarn lint 41 | yarn format 42 | ``` 43 | 44 | And some simple tests: 45 | 46 | ```shell 47 | yarn playwright install 48 | yarn test 49 | ``` 50 | 51 | ## Contributing 52 | 53 | We have some [architecture documentation](./docs/architecture.md) to help you get the lay of the land. 54 | We use Github [Pull Requests](https://github.com/developmentseed/stac-map/pulls) to propose changes, and [Issues](https://github.com/developmentseed/stac-map/issues) to report bugs and request features. 55 | 56 | We use [semantic-release](https://github.com/semantic-release/semantic-release?tab=readme-ov-file) to create [releases](https://github.com/developmentseed/stac-map/releases). 57 | This requires our commit messages to conform to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). 58 | 59 | ## Deploying 60 | 61 | See [deploy.yaml](./.github/workflows/deploy.yaml) for a (drop-dead simple) example of deploying this application as a static site via Github Pages. 62 | 63 | ## Versioning 64 | 65 | For now, we use a form of [Sentimental Versioning](https://github.com/dominictarr/sentimental-versioning#readme), where we use MAJOR, MINOR, and PATCH versions to communicate the "weight" of changes. 66 | We may formalize our releases into a stricter form of [Semantic Versioning](https://semver.org/) at some point in the future. 67 | -------------------------------------------------------------------------------- /src/components/cards/asset.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { LuDownload } from "react-icons/lu"; 3 | import { 4 | Button, 5 | ButtonGroup, 6 | Card, 7 | Checkbox, 8 | Collapsible, 9 | DataList, 10 | HStack, 11 | Image, 12 | Span, 13 | } from "@chakra-ui/react"; 14 | import type { StacAsset } from "stac-ts"; 15 | import { isCog, isVisual } from "../../utils/stac"; 16 | import Properties from "../sections/properties"; 17 | 18 | export default function AssetCard({ 19 | asset, 20 | cogTileHref, 21 | setCogTileHref, 22 | }: { 23 | asset: StacAsset; 24 | cogTileHref: string | undefined; 25 | setCogTileHref: (href: string | undefined) => void; 26 | }) { 27 | const [imageError, setImageError] = useState(false); 28 | // eslint-disable-next-line 29 | const { href, roles, type, title, ...properties } = asset; 30 | 31 | const checked = cogTileHref === asset.href; 32 | 33 | return ( 34 | 35 | 36 | {asset.title && {asset.title}} 37 | 38 | 39 | {!imageError && ( 40 | setImageError(true)} /> 41 | )} 42 | 43 | {asset.roles && ( 44 | 45 | Roles 46 | {asset.roles?.join(", ")} 47 | 48 | )} 49 | {asset.type && ( 50 | 51 | Type 52 | {asset.type} 53 | 54 | )} 55 | 56 | {Object.keys(properties).length > 0 && ( 57 | 58 | Properties 59 | 60 | 61 | 62 | 63 | )} 64 | 65 | {isCog(asset) && isVisual(asset) && ( 66 | { 69 | if (e.checked) setCogTileHref(asset.href); 70 | else setCogTileHref(undefined); 71 | }} 72 | > 73 | 74 | 75 | Visualize 76 | 77 | )} 78 | 79 | 80 | 81 | 82 | 87 | 88 | 89 | 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stac-map", 3 | "description": "A map-first, single-page, statically-hosted STAC search and visualization tool, with stac-geoparquet support", 4 | "author": { 5 | "name": "Pete Gadomski", 6 | "email": "pete.gadomski@gmail.com" 7 | }, 8 | "contributors": [ 9 | { 10 | "name": "Indraneel Purohit", 11 | "email": "indraneelpurohit@gmail.com" 12 | } 13 | ], 14 | "homepage": "https://github.com/developmentseed/stac-map/", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/developmentseed/stac-map.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/developmentseed/stac-map/issues" 21 | }, 22 | "license": "MIT", 23 | "version": "0.0.1", 24 | "type": "module", 25 | "scripts": { 26 | "dev": "vite", 27 | "build": "tsc -b && vite build", 28 | "build-preview": "tsc -b && vite build --base=/", 29 | "lint": "eslint .", 30 | "format": "prettier . --write", 31 | "format:check": "prettier . --check", 32 | "preview": "vite preview", 33 | "test": "vitest run" 34 | }, 35 | "dependencies": { 36 | "@chakra-ui/react": "^3.30.0", 37 | "@deck.gl/core": "^9.2.4", 38 | "@deck.gl/geo-layers": "^9.2.4", 39 | "@deck.gl/layers": "^9.2.4", 40 | "@deck.gl/mapbox": "^9.2.4", 41 | "@duckdb/duckdb-wasm": "^1.29.0", 42 | "@emotion/react": "^11.13.5", 43 | "@geoarrow/deck.gl-layers": "^0.3.0", 44 | "@geoarrow/geoarrow-js": "github:smohiudd/geoarrow-js#feature/wkb", 45 | "@tanstack/react-query": "^5.90.12", 46 | "@turf/bbox": "^7.3.1", 47 | "@turf/bbox-polygon": "^7.3.1", 48 | "@turf/boolean-valid": "^7.3.1", 49 | "@turf/helpers": "^7.2.0", 50 | "apache-arrow": "^21.1.0", 51 | "deck.gl": "^9.2.4", 52 | "duckdb-wasm-kit": "^0.1.38", 53 | "maplibre-gl": "^5.14.0", 54 | "next-themes": "^0.4.3", 55 | "react": "^19.2.1", 56 | "react-dom": "^19.2.1", 57 | "react-error-boundary": "^6.0.0", 58 | "react-icons": "^5.5.0", 59 | "react-map-gl": "^8.1.0", 60 | "react-markdown": "^10.1.0", 61 | "stac-ts": "^1.0.4", 62 | "stac-wasm": "^0.0.3" 63 | }, 64 | "devDependencies": { 65 | "@eslint/js": "^9.25.0", 66 | "@trivago/prettier-plugin-sort-imports": "^6.0.0", 67 | "@types/geojson": "^7946.0.16", 68 | "@types/react": "^19.2.7", 69 | "@types/react-dom": "^19.1.2", 70 | "@vitejs/plugin-react": "^5.1.2", 71 | "@vitest/browser": "^4.0.15", 72 | "@vitest/browser-playwright": "^4.0.15", 73 | "conventional-changelog-conventionalcommits": "^9.1.0", 74 | "eslint": "^9.25.0", 75 | "eslint-plugin-react-hooks": "^7.0.1", 76 | "eslint-plugin-react-refresh": "^0.4.19", 77 | "globals": "^16.0.0", 78 | "playwright": "^1.57.0", 79 | "prettier": "^3.7.4", 80 | "semantic-release": "^25.0.2", 81 | "typescript": "~5.9.3", 82 | "typescript-eslint": "^8.49.0", 83 | "vite": "^7.2.7", 84 | "vite-plugin-top-level-await": "^1.5.0", 85 | "vite-plugin-wasm": "^3.5.0", 86 | "vite-tsconfig-paths": "^5.1.4", 87 | "vitest": "^4.0.15", 88 | "vitest-browser-react": "^2.0.2" 89 | }, 90 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 91 | } 92 | -------------------------------------------------------------------------------- /src/components/ui/color-mode.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { LuMoon, LuSun } from "react-icons/lu"; 5 | import type { IconButtonProps, SpanProps } from "@chakra-ui/react"; 6 | import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"; 7 | import type { ThemeProviderProps } from "next-themes"; 8 | import { ThemeProvider, useTheme } from "next-themes"; 9 | 10 | export interface ColorModeProviderProps extends ThemeProviderProps {} 11 | 12 | export function ColorModeProvider(props: ColorModeProviderProps) { 13 | return ( 14 | 15 | ); 16 | } 17 | 18 | export type ColorMode = "light" | "dark"; 19 | 20 | export interface UseColorModeReturn { 21 | colorMode: ColorMode; 22 | setColorMode: (colorMode: ColorMode) => void; 23 | toggleColorMode: () => void; 24 | } 25 | 26 | export function useColorMode(): UseColorModeReturn { 27 | const { resolvedTheme, setTheme, forcedTheme } = useTheme(); 28 | const colorMode = forcedTheme || resolvedTheme; 29 | const toggleColorMode = () => { 30 | setTheme(resolvedTheme === "dark" ? "light" : "dark"); 31 | }; 32 | return { 33 | colorMode: colorMode as ColorMode, 34 | setColorMode: setTheme, 35 | toggleColorMode, 36 | }; 37 | } 38 | 39 | export function useColorModeValue(light: T, dark: T) { 40 | const { colorMode } = useColorMode(); 41 | return colorMode === "dark" ? dark : light; 42 | } 43 | 44 | export function ColorModeIcon() { 45 | const { colorMode } = useColorMode(); 46 | return colorMode === "dark" ? : ; 47 | } 48 | 49 | interface ColorModeButtonProps extends Omit {} 50 | 51 | export const ColorModeButton = React.forwardRef< 52 | HTMLButtonElement, 53 | ColorModeButtonProps 54 | >(function ColorModeButton(props, ref) { 55 | const { toggleColorMode } = useColorMode(); 56 | return ( 57 | }> 58 | 71 | 72 | 73 | 74 | ); 75 | }); 76 | 77 | export const LightMode = React.forwardRef( 78 | function LightMode(props, ref) { 79 | return ( 80 | 89 | ); 90 | } 91 | ); 92 | 93 | export const DarkMode = React.forwardRef( 94 | function DarkMode(props, ref) { 95 | return ( 96 | 105 | ); 106 | } 107 | ); 108 | -------------------------------------------------------------------------------- /.github/workflows/github-pr-update.cjs: -------------------------------------------------------------------------------- 1 | const PR_MARKER = ''; 2 | 3 | async function findComment({ github, context, core }) { 4 | const comments = await github.rest.issues.listComments({ 5 | owner: context.repo.owner, 6 | repo: context.repo.repo, 7 | issue_number: context.payload.pull_request.number 8 | }); 9 | const existingComment = comments.data.find((comment) => 10 | comment.body.includes(PR_MARKER) 11 | ); 12 | 13 | return existingComment?.id; 14 | } 15 | 16 | async function setComment({ github, context, commentId, body }) { 17 | if (commentId) { 18 | await github.rest.issues.updateComment({ 19 | owner: context.repo.owner, 20 | repo: context.repo.repo, 21 | comment_id: commentId, 22 | body 23 | }); 24 | } else { 25 | await github.rest.issues.createComment({ 26 | owner: context.repo.owner, 27 | repo: context.repo.repo, 28 | issue_number: context.payload.pull_request.number, 29 | body 30 | }); 31 | } 32 | } 33 | 34 | async function createDeployingComment({ github, context, core }) { 35 | const commentId = await findComment({ github, context, core }); 36 | 37 | const comment = ` 38 | ${PR_MARKER} 39 | ### Website deploying to S3! 40 | 41 | | Name | Link | 42 | |:-:|------------------------| 43 | | Latest commit | ${context.payload.pull_request.head.sha} | 44 | `; 45 | 46 | await setComment({ github, context, commentId, body: comment }); 47 | } 48 | 49 | async function createFailedComment({ github, context, core }) { 50 | const commentId = await findComment({ github, context, core }); 51 | 52 | const comment = ` 53 | ${PR_MARKER} 54 | ### Deployment failed! 55 | 56 | _Check the action logs for more information._ 57 | 58 | | Name | Link | 59 | |:-:|------------------------| 60 | | Latest commit | ${context.payload.pull_request.head.sha} | 61 | `; 62 | 63 | await setComment({ github, context, commentId, body: comment }); 64 | } 65 | 66 | async function createSuccessComment({ github, context, core }) { 67 | const commentId = await findComment({ github, context, core }); 68 | 69 | const websiteUrl = `http://${process.env.BUCKET_NAME}.s3-website-${process.env.AWS_REGION}.amazonaws.com/`; 70 | const comment = ` 71 | ${PR_MARKER} 72 | ### Deploy Preview ready! 73 | 74 | 75 | | Name | Link | 76 | |:-:|------------------------| 77 | | Latest commit | ${context.payload.pull_request.head.sha} | 78 | | Deploy Preview | ${websiteUrl} | 79 | `; 80 | 81 | await setComment({ github, context, commentId, body: comment }); 82 | } 83 | 84 | async function deleteComment({ github, context, core }) { 85 | const commentId = await findComment({ github, context, core }); 86 | 87 | if (commentId) { 88 | await github.rest.issues.deleteComment({ 89 | owner: context.repo.owner, 90 | repo: context.repo.repo, 91 | comment_id: commentId 92 | }); 93 | } 94 | } 95 | 96 | module.exports = { 97 | createDeployingComment, 98 | createFailedComment, 99 | createSuccessComment, 100 | deleteComment 101 | }; 102 | -------------------------------------------------------------------------------- /src/components/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumb } from "@chakra-ui/react"; 2 | import type { StacValue } from "../types/stac"; 3 | 4 | export default function Breadcrumbs({ 5 | value, 6 | picked, 7 | setPicked, 8 | setHref, 9 | }: { 10 | value: StacValue; 11 | picked: StacValue | undefined; 12 | setPicked: (picked: StacValue | undefined) => void; 13 | setHref: (href: string | undefined) => void; 14 | }) { 15 | let selfHref; 16 | let rootHref; 17 | let parentHref; 18 | if (value.links) { 19 | for (const link of value.links) { 20 | switch (link.rel) { 21 | case "self": 22 | selfHref = link.href; 23 | break; 24 | case "parent": 25 | parentHref = link.href; 26 | break; 27 | case "root": 28 | rootHref = link.href; 29 | break; 30 | } 31 | } 32 | } 33 | const breadcrumbs = []; 34 | if (rootHref && selfHref != rootHref) { 35 | breadcrumbs.push( 36 | 42 | ); 43 | } 44 | if (parentHref && selfHref != parentHref && rootHref != parentHref) { 45 | breadcrumbs.push( 46 | 52 | ); 53 | } 54 | if (picked) { 55 | breadcrumbs.push( 56 | 57 | { 60 | e.preventDefault(); 61 | setPicked(undefined); 62 | }} 63 | > 64 | {getStacType(value)} 65 | 66 | 67 | ); 68 | breadcrumbs.push( 69 | 70 | 71 | {"Picked " + getStacType(picked).toLowerCase()} 72 | 73 | 74 | ); 75 | } else { 76 | breadcrumbs.push( 77 | 78 | {getStacType(value)} 79 | 80 | ); 81 | } 82 | return ( 83 | 84 | 85 | {breadcrumbs.flatMap((value, i) => [ 86 | value, 87 | i < breadcrumbs.length - 1 && ( 88 | 89 | ), 90 | ])} 91 | 92 | 93 | ); 94 | } 95 | 96 | function BreadcrumbItem({ 97 | href, 98 | setHref, 99 | text, 100 | }: { 101 | href: string; 102 | setHref: (href: string | undefined) => void; 103 | text: string; 104 | }) { 105 | return ( 106 | 107 | { 110 | e.preventDefault(); 111 | setHref(href); 112 | }} 113 | > 114 | {text} 115 | 116 | 117 | ); 118 | } 119 | 120 | function getStacType(value: StacValue) { 121 | switch (value.type) { 122 | case "Feature": 123 | return "Item"; 124 | case "FeatureCollection": 125 | return "Item collection"; 126 | case "Catalog": 127 | case "Collection": 128 | return value.type; 129 | default: 130 | return "Unknown"; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tests/app.spec.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { describe, expect, test } from "vitest"; 3 | import { render } from "vitest-browser-react"; 4 | import App from "../src/app"; 5 | import { Provider } from "../src/components/ui/provider"; 6 | import { EXAMPLES } from "../src/constants"; 7 | 8 | const queryClient = new QueryClient(); 9 | 10 | async function renderApp() { 11 | return await render( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | describe("app", () => { 21 | test("has a map", async () => { 22 | const app = await renderApp(); 23 | await expect 24 | .element(app.getByRole("region", { name: "Map" })) 25 | .toBeVisible(); 26 | }); 27 | 28 | test("has a input text box", async () => { 29 | const app = await renderApp(); 30 | await expect 31 | .element( 32 | app.getByRole("textbox", { 33 | name: "Enter a url to STAC JSON or GeoParquet", 34 | }) 35 | ) 36 | .toBeVisible(); 37 | }); 38 | 39 | test("has an upload button", async () => { 40 | const app = await renderApp(); 41 | await expect 42 | .element(app.getByRole("button", { name: "upload" })) 43 | .toBeVisible(); 44 | }); 45 | 46 | test("has a color mode button", async () => { 47 | const app = await renderApp(); 48 | await expect 49 | .element(app.getByRole("button", { name: "Toggle color mode" })) 50 | .toBeVisible(); 51 | }); 52 | 53 | describe.for(EXAMPLES)("example $title", ({ title }) => { 54 | test("updates title", async ({ expect }) => { 55 | const app = await renderApp(); 56 | await app.getByRole("button", { name: "Examples" }).click(); 57 | await app.getByRole("menuitem", { name: title }).click(); 58 | expect(document.title !== "stac-map"); 59 | }); 60 | }); 61 | 62 | test("CSDA Planet", async () => { 63 | // https://github.com/developmentseed/stac-map/issues/96 64 | window.history.pushState( 65 | {}, 66 | "", 67 | "?href=https://csdap.earthdata.nasa.gov/stac/collections/planet" 68 | ); 69 | const app = await renderApp(); 70 | await expect 71 | .element(app.getByRole("heading", { name: "Planet" })) 72 | .toBeVisible(); 73 | }); 74 | 75 | test("renders download buttons", async () => { 76 | window.history.pushState( 77 | {}, 78 | "", 79 | "?href=https://stac.eoapi.dev/collections/MAXAR_yellowstone_flooding22" 80 | ); 81 | const app = await renderApp(); 82 | await app.getByRole("button", { name: "Item search" }).click(); 83 | await app.getByRole("button", { name: "Search", exact: true }).click(); 84 | await expect 85 | .element(app.getByRole("button", { name: "JSON" })) 86 | .toBeVisible(); 87 | await expect 88 | .element(app.getByRole("button", { name: "stac-geoparquet" })) 89 | .toBeVisible(); 90 | }); 91 | 92 | test("paginates collections", async () => { 93 | window.history.pushState({}, "", "?href=https://stac.eoapi.dev"); 94 | const app = await renderApp(); 95 | await expect 96 | .element(app.getByRole("button", { name: "Fetch more collections" })) 97 | .toBeVisible(); 98 | await expect 99 | .element(app.getByRole("button", { name: "Fetch all collections" })) 100 | .toBeVisible(); 101 | await app.getByRole("button", { name: "Fetch more collections" }).click(); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /docs/decisions/adr-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | # These are optional metadata elements. Feel free to remove any of them. 3 | status: "{proposed | rejected | accepted | deprecated | … | superseded by ADR-0123}" 4 | date: { YYYY-MM-DD when the decision was last updated } 5 | decision-makers: { list everyone involved in the decision } 6 | consulted: 7 | { 8 | list everyone whose opinions are sought (typically subject-matter experts); and with whom there is a two-way communication, 9 | } 10 | informed: 11 | { 12 | list everyone who is kept up-to-date on progress; and with whom there is a one-way communication, 13 | } 14 | --- 15 | 16 | # {short title, representative of solved problem and found solution} 17 | 18 | ## Context and Problem Statement 19 | 20 | {Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story. You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.} 21 | 22 | 23 | 24 | ## Decision Drivers 25 | 26 | - {decision driver 1, e.g., a force, facing concern, …} 27 | - {decision driver 2, e.g., a force, facing concern, …} 28 | - … 29 | 30 | ## Considered Options 31 | 32 | - {title of option 1} 33 | - {title of option 2} 34 | - {title of option 3} 35 | - … 36 | 37 | ## Decision Outcome 38 | 39 | Chosen option: "{title of option 1}", because {justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | … | comes out best (see below)}. 40 | 41 | 42 | 43 | ### Consequences 44 | 45 | - Good, because {positive consequence, e.g., improvement of one or more desired qualities, …} 46 | - Bad, because {negative consequence, e.g., compromising one or more desired qualities, …} 47 | - … 48 | 49 | 50 | 51 | ### Confirmation 52 | 53 | {Describe how the implementation / compliance of the ADR can/will be confirmed. Is there any automated or manual fitness function? If so, list it and explain how it is applied. Is the chosen design and its implementation in line with the decision? E.g., a design/code review or a test with a library such as ArchUnit can help validate this. Note that although we classify this element as optional, it is included in many ADRs.} 54 | 55 | 56 | 57 | ## Pros and Cons of the Options 58 | 59 | ### {title of option 1} 60 | 61 | 62 | 63 | {example | description | pointer to more information | …} 64 | 65 | - Good, because {argument a} 66 | - Good, because {argument b} 67 | 68 | - Neutral, because {argument c} 69 | - Bad, because {argument d} 70 | - … 71 | 72 | ### {title of other option} 73 | 74 | {example | description | pointer to more information | …} 75 | 76 | - Good, because {argument a} 77 | - Good, because {argument b} 78 | - Neutral, because {argument c} 79 | - Bad, because {argument d} 80 | - … 81 | 82 | 83 | 84 | ## More Information 85 | 86 | {You might want to provide additional evidence/confidence for the decision outcome here and/or document the team agreement on the decision and/or define when/how this decision the decision should be realized and if/when it should be re-visited. Links to other decisions and resources might appear here as well.} 87 | -------------------------------------------------------------------------------- /src/layers/overlay.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { LuUpload } from "react-icons/lu"; 3 | import { 4 | Box, 5 | Button, 6 | FileUpload, 7 | GridItem, 8 | HStack, 9 | IconButton, 10 | Input, 11 | SimpleGrid, 12 | } from "@chakra-ui/react"; 13 | import Breadcrumbs from "../components/breadcrumbs"; 14 | import { Examples } from "../components/examples"; 15 | import Panel, { type PanelProps } from "../components/panel"; 16 | import { ColorModeButton } from "../components/ui/color-mode"; 17 | import type { StacValue } from "../types/stac"; 18 | 19 | export interface OverlayProps extends PanelProps { 20 | picked: StacValue | undefined; 21 | setPicked: (picked: StacValue | undefined) => void; 22 | } 23 | 24 | export default function Overlay({ 25 | href, 26 | setHref, 27 | fileUpload, 28 | value, 29 | picked, 30 | setPicked, 31 | items, 32 | filteredItems, 33 | ...props 34 | }: OverlayProps) { 35 | return ( 36 | 37 | 38 | 44 | 50 | {(value && ( 51 | 57 | )) || stac-map} 58 | 59 | 60 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ); 93 | } 94 | 95 | function HrefInput({ 96 | href, 97 | setHref, 98 | }: { 99 | href: string | undefined; 100 | setHref: (href: string | undefined) => void; 101 | }) { 102 | const [value, setValue] = useState(href || ""); 103 | 104 | return ( 105 | { 108 | e.preventDefault(); 109 | setHref(value); 110 | }} 111 | flex="1" 112 | > 113 | setValue(e.target.value)} 118 | > 119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /src/hooks/stac-value.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import type { UseFileUploadReturn } from "@chakra-ui/react"; 3 | import { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; 4 | import { useQueries, useQuery } from "@tanstack/react-query"; 5 | import type { StacItem } from "stac-ts"; 6 | import { useDuckDb } from "duckdb-wasm-kit"; 7 | import type { DatetimeBounds, StacValue } from "../types/stac"; 8 | import { getStacJsonValue } from "../utils/stac"; 9 | import { 10 | getStacGeoparquet, 11 | getStacGeoparquetItem, 12 | getStacGeoparquetTable, 13 | } from "../utils/stac-geoparquet"; 14 | 15 | export default function useStacValue({ 16 | href, 17 | fileUpload, 18 | datetimeBounds, 19 | stacGeoparquetItemId, 20 | }: { 21 | href: string | undefined; 22 | fileUpload: UseFileUploadReturn; 23 | datetimeBounds: DatetimeBounds | undefined; 24 | stacGeoparquetItemId: string | undefined; 25 | }) { 26 | const { db } = useDuckDb(); 27 | const [connection, setConnection] = useState(); 28 | const enableStacGeoparquet = 29 | (connection && href && href.endsWith(".parquet")) || false; 30 | 31 | useEffect(() => { 32 | if (db && href?.endsWith(".parquet")) { 33 | (async () => { 34 | const connection = await db.connect(); 35 | await connection.query("LOAD spatial;"); 36 | await connection.query("LOAD icu;"); 37 | try { 38 | new URL(href); 39 | } catch { 40 | const file = fileUpload.acceptedFiles[0]; 41 | db.registerFileBuffer(href, new Uint8Array(await file.arrayBuffer())); 42 | } 43 | setConnection(connection); 44 | })(); 45 | } 46 | }, [db, href, fileUpload.acceptedFiles]); 47 | 48 | const jsonResult = useQuery({ 49 | queryKey: ["stac-value", href], 50 | queryFn: () => getStacJsonValue(href || "", fileUpload), 51 | enabled: (href && !href.endsWith(".parquet")) || false, 52 | }); 53 | const stacGeoparquetResult = useQuery({ 54 | queryKey: ["stac-geoparquet", href], 55 | queryFn: () => 56 | (href && connection && getStacGeoparquet(href, connection)) || null, 57 | enabled: enableStacGeoparquet, 58 | }); 59 | const stacGeoparquetTableResult = useQuery({ 60 | queryKey: ["stac-geoparquet-table", href, datetimeBounds], 61 | queryFn: () => 62 | (href && 63 | connection && 64 | getStacGeoparquetTable(href, connection, datetimeBounds)) || 65 | null, 66 | placeholderData: (previousData) => previousData, 67 | enabled: enableStacGeoparquet, 68 | }); 69 | const stacGeoparquetItem = useQuery({ 70 | queryKey: ["stac-geoparquet-item", href, stacGeoparquetItemId], 71 | queryFn: () => 72 | href && 73 | connection && 74 | stacGeoparquetItemId && 75 | getStacGeoparquetItem(href, connection, stacGeoparquetItemId), 76 | enabled: enableStacGeoparquet && !!stacGeoparquetItemId, 77 | }); 78 | const value = jsonResult.data || stacGeoparquetResult.data || undefined; 79 | const table = enableStacGeoparquet 80 | ? stacGeoparquetTableResult.data || undefined 81 | : undefined; 82 | const error = 83 | jsonResult.error || 84 | stacGeoparquetResult.error || 85 | stacGeoparquetTableResult.error || 86 | undefined; 87 | 88 | const itemsResult = useQueries({ 89 | queries: 90 | value?.links 91 | ?.filter((link) => link.rel === "item") 92 | .map((link) => { 93 | return { 94 | queryKey: ["stac-value", link.href], 95 | queryFn: () => getStacJsonValue(link.href) as Promise, 96 | enabled: !!(href && value), 97 | }; 98 | }) || [], 99 | combine: (results) => { 100 | return { 101 | data: results.map((result) => result.data).filter((value) => !!value), 102 | }; 103 | }, 104 | }); 105 | 106 | return { 107 | value, 108 | error, 109 | table, 110 | stacGeoparquetItem: stacGeoparquetItem.data, 111 | items: itemsResult.data.length > 0 ? itemsResult.data : undefined, 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /.github/workflows/preview-deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Preview 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "*" 7 | 8 | permissions: 9 | id-token: write 10 | contents: read 11 | issues: write 12 | pull-requests: write 13 | 14 | env: 15 | BUCKET_NAME: ds-preview-stac-map-${{ github.event.number }} 16 | AWS_ROLE_ARN: arn:aws:iam::552819999234:role/stac-map-gh-preview 17 | AWS_REGION: us-west-2 18 | DIST_DIRECTORY: dist 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v6 25 | - uses: actions/setup-node@v6 26 | with: 27 | node-version-file: ".node-version" 28 | cache: "yarn" 29 | - name: Post building comment 30 | uses: actions/github-script@v8 31 | with: 32 | script: | 33 | const { createDeployingComment } = require('./.github/workflows/github-pr-update.cjs') 34 | await createDeployingComment({ github, context, core }) 35 | - name: Install 36 | run: yarn install --ignore-engines 37 | - name: Build 38 | run: yarn build-preview 39 | - name: Post error comment 40 | uses: actions/github-script@v8 41 | if: failure() 42 | with: 43 | script: | 44 | const { createFailedComment } = require('./.github/workflows/github-pr-update.cjs') 45 | await createFailedComment({ github, context, core }) 46 | - uses: actions/upload-artifact@v5 47 | with: 48 | path: ${{ env.DIST_DIRECTORY }} 49 | deploy: 50 | runs-on: ubuntu-latest 51 | needs: build 52 | steps: 53 | - uses: actions/checkout@v6 54 | - uses: aws-actions/configure-aws-credentials@v5 55 | with: 56 | role-to-assume: ${{ env.AWS_ROLE_ARN }} 57 | aws-region: ${{ env.AWS_REGION }} 58 | - uses: actions/download-artifact@v5 59 | with: 60 | path: ${{ env.DIST_DIRECTORY }} 61 | - name: Check if bucket exists 62 | id: check_bucket 63 | run: | 64 | if aws s3 ls "s3://${{ env.BUCKET_NAME }}" 2>&1 | grep -q 'NoSuchBucket'; then 65 | echo "Bucket does not exist." 66 | echo "exists=false" >> "$GITHUB_OUTPUT" 67 | else 68 | echo "Bucket exists." 69 | echo "exists=true" >> "$GITHUB_OUTPUT" 70 | fi 71 | - name: Create S3 bucket 72 | if: steps.check_bucket.outputs.exists == 'false' 73 | run: | 74 | aws s3 mb s3://${{ env.BUCKET_NAME }} 75 | - name: Enable static website hosting 76 | if: steps.check_bucket.outputs.exists == 'false' 77 | run: | 78 | aws s3 website \ 79 | s3://${{ env.BUCKET_NAME }} \ 80 | --index-document index.html \ 81 | --error-document index.html 82 | - name: Sync files 83 | run: | 84 | aws s3 sync \ 85 | ./${{env.DIST_DIRECTORY}} s3://${{ env.BUCKET_NAME }} \ 86 | --delete \ 87 | --quiet 88 | - name: Make bucket public access 89 | if: steps.check_bucket.outputs.exists == 'false' 90 | run: | 91 | aws s3api delete-public-access-block --bucket ${{ env.BUCKET_NAME }} 92 | - name: Add bucket policy for public access 93 | if: steps.check_bucket.outputs.exists == 'false' 94 | run: | 95 | echo '{ 96 | "Version": "2012-10-17", 97 | "Statement": [{ 98 | "Sid": "PublicReadGetObject", 99 | "Effect": "Allow", 100 | "Principal": "*", 101 | "Action": "s3:GetObject", 102 | "Resource": "arn:aws:s3:::${{ env.BUCKET_NAME }}/*" 103 | }] 104 | }' > bucket-policy.json 105 | aws s3api put-bucket-policy --bucket ${{ env.BUCKET_NAME }} --policy file://bucket-policy.json 106 | - name: Post comment with preview URL 107 | uses: actions/github-script@v8 108 | if: success() 109 | with: 110 | script: | 111 | const { createSuccessComment } = require('./.github/workflows/github-pr-update.cjs') 112 | await createSuccessComment({ github, context, core }) 113 | - name: Post error comment 114 | uses: actions/github-script@v8 115 | if: failure() 116 | with: 117 | script: | 118 | const { createFailedComment } = require('./.github/workflows/github-pr-update.cjs') 119 | await createFailedComment({ github, context, core }) 120 | -------------------------------------------------------------------------------- /src/utils/stac-geoparquet.ts: -------------------------------------------------------------------------------- 1 | import { io } from "@geoarrow/geoarrow-js"; 2 | import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; 3 | import { 4 | Binary, 5 | Data, 6 | makeData, 7 | makeVector, 8 | Table, 9 | vectorFromArray, 10 | } from "apache-arrow"; 11 | import * as stacWasm from "stac-wasm"; 12 | import type { DatetimeBounds, StacItemCollection } from "../types/stac"; 13 | 14 | export async function getStacGeoparquet( 15 | href: string, 16 | connection: AsyncDuckDBConnection 17 | ) { 18 | const { startDatetimeColumnName, endDatetimeColumnName } = 19 | await getStacGeoparquetDatetimeColumns(href, connection); 20 | 21 | const summaryResult = await connection.query( 22 | `SELECT COUNT(*) as count, MIN(bbox.xmin) as xmin, MIN(bbox.ymin) as ymin, MAX(bbox.xmax) as xmax, MAX(bbox.ymax) as ymax, MIN(${startDatetimeColumnName}) as start_datetime, MAX(${endDatetimeColumnName}) as end_datetime FROM read_parquet('${href}')` 23 | ); 24 | const summaryRow = summaryResult.toArray().map((row) => row.toJSON())[0]; 25 | 26 | const kvMetadataResult = await connection.query( 27 | `SELECT key, value FROM parquet_kv_metadata('${href}')` 28 | ); 29 | const decoder = new TextDecoder("utf-8"); 30 | const kvMetadata = Object.fromEntries( 31 | kvMetadataResult.toArray().map((row) => { 32 | const jsonRow = row.toJSON(); 33 | const key = decoder.decode(jsonRow.key); 34 | let value; 35 | try { 36 | value = JSON.parse(decoder.decode(jsonRow.value)); 37 | } catch { 38 | // pass 39 | } 40 | return [key, value]; 41 | }) 42 | ); 43 | 44 | return { 45 | type: "FeatureCollection", 46 | bbox: [summaryRow.xmin, summaryRow.ymin, summaryRow.xmax, summaryRow.ymax], 47 | features: [], 48 | title: href.split("/").slice(-1)[0], 49 | description: `A stac-geoparquet file with ${summaryRow.count} items`, 50 | start_datetime: summaryRow.start_datetime 51 | ? new Date(summaryRow.start_datetime).toLocaleString() 52 | : null, 53 | end_datetime: summaryRow.end_datetime 54 | ? new Date(summaryRow.end_datetime).toLocaleString() 55 | : null, 56 | geoparquet_metadata: kvMetadata, 57 | } as StacItemCollection; 58 | } 59 | 60 | export async function getStacGeoparquetTable( 61 | href: string, 62 | connection: AsyncDuckDBConnection, 63 | datetimeBounds: DatetimeBounds | undefined 64 | ) { 65 | const { startDatetimeColumnName, endDatetimeColumnName } = 66 | await getStacGeoparquetDatetimeColumns(href, connection); 67 | 68 | let query = `SELECT ST_AsWKB(geometry) as geometry, id FROM read_parquet('${href}')`; 69 | if (datetimeBounds) { 70 | query += ` WHERE ${startDatetimeColumnName} >= DATETIME '${datetimeBounds.start.toISOString()}' AND ${endDatetimeColumnName} <= DATETIME '${datetimeBounds.end.toISOString()}'`; 71 | } 72 | const result = await connection.query(query); 73 | const geometry: Uint8Array[] = result.getChildAt(0)?.toArray(); 74 | const wkb = new Uint8Array(geometry?.flatMap((array) => [...array])); 75 | const valueOffsets = new Int32Array(geometry.length + 1); 76 | for (let i = 0, len = geometry.length; i < len; i++) { 77 | const current = valueOffsets[i]; 78 | valueOffsets[i + 1] = current + geometry[i].length; 79 | } 80 | const data: Data = makeData({ 81 | type: new Binary(), 82 | data: wkb, 83 | valueOffsets, 84 | }); 85 | const polygons = io.parseWkb(data, io.WKBType.Polygon, 2); 86 | const table = new Table({ 87 | // @ts-expect-error: 2769 88 | geometry: makeVector(polygons), 89 | id: vectorFromArray(result.getChild("id")?.toArray()), 90 | }); 91 | table.schema.fields[0].metadata.set( 92 | "ARROW:extension:name", 93 | "geoarrow.polygon" 94 | ); 95 | return table; 96 | } 97 | 98 | export async function getStacGeoparquetItem( 99 | href: string, 100 | connection: AsyncDuckDBConnection, 101 | id: string 102 | ) { 103 | const result = await connection.query( 104 | `SELECT * REPLACE ST_AsGeoJSON(geometry) as geometry FROM read_parquet('${href}') WHERE id = '${id}'` 105 | ); 106 | const item = stacWasm.arrowToStacJson(result)[0]; 107 | item.geometry = JSON.parse(item.geometry); 108 | return item; 109 | } 110 | 111 | async function getStacGeoparquetDatetimeColumns( 112 | href: string, 113 | connection: AsyncDuckDBConnection 114 | ) { 115 | const describeResult = await connection.query( 116 | `DESCRIBE SELECT * FROM read_parquet('${href}')` 117 | ); 118 | const describe = describeResult.toArray().map((row) => row.toJSON()); 119 | const columnNames = describe.map((row) => row.column_name); 120 | const startDatetimeColumnName = columnNames.includes("start_datetime") 121 | ? "start_datetime" 122 | : "datetime"; 123 | const endDatetimeColumnName = columnNames.includes("end_datetime") 124 | ? "start_datetime" 125 | : "datetime"; 126 | return { startDatetimeColumnName, endDatetimeColumnName }; 127 | } 128 | -------------------------------------------------------------------------------- /src/components/sections/filter.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { LuFilter, LuFilterX } from "react-icons/lu"; 3 | import { Checkbox, DataList, Slider, Stack, Text } from "@chakra-ui/react"; 4 | import type { StacCollection, StacItem } from "stac-ts"; 5 | import type { BBox2D } from "../../types/map"; 6 | import type { DatetimeBounds, StacValue } from "../../types/stac"; 7 | import { getItemDatetimes } from "../../utils/stac"; 8 | import { SpatialExtent } from "../extent"; 9 | import Section from "../section"; 10 | 11 | interface FilterProps { 12 | filter: boolean; 13 | setFilter: (filter: boolean) => void; 14 | bbox: BBox2D | undefined; 15 | setDatetimeBounds: (bounds: DatetimeBounds | undefined) => void; 16 | value: StacValue; 17 | items: StacItem[] | undefined; 18 | collections: StacCollection[] | undefined; 19 | } 20 | 21 | export default function FilterSection({ filter, ...props }: FilterProps) { 22 | return ( 23 |
28 | 29 |
30 | ); 31 | } 32 | 33 | function Filter({ 34 | filter, 35 | setFilter, 36 | bbox, 37 | setDatetimeBounds, 38 | value, 39 | items, 40 | collections, 41 | }: FilterProps) { 42 | const [filterStart, setFilterStart] = useState(); 43 | const [filterEnd, setFilterEnd] = useState(); 44 | 45 | const datetimes = useMemo(() => { 46 | let start = 47 | value.start_datetime && typeof value.start_datetime === "string" 48 | ? new Date(value.start_datetime as string) 49 | : null; 50 | let end = 51 | value.end_datetime && typeof value.end_datetime === "string" 52 | ? new Date(value.end_datetime as string) 53 | : null; 54 | 55 | if (items) { 56 | for (const item of items) { 57 | const itemDatetimes = getItemDatetimes(item); 58 | if (itemDatetimes.start && (!start || itemDatetimes.start < start)) 59 | start = itemDatetimes.start; 60 | if (itemDatetimes.end && (!end || itemDatetimes.end > end)) 61 | end = itemDatetimes.end; 62 | } 63 | } 64 | 65 | if (collections) { 66 | for (const collection of collections) { 67 | const extents = collection.extent?.temporal?.interval?.[0]; 68 | if (extents) { 69 | const collectionStart = extents[0] ? new Date(extents[0]) : null; 70 | if (collectionStart && (!start || collectionStart < start)) 71 | start = collectionStart; 72 | const collectionEnd = extents[1] ? new Date(extents[1]) : null; 73 | if (collectionEnd && (!end || collectionEnd > end)) 74 | end = collectionEnd; 75 | } 76 | } 77 | } 78 | 79 | return start && end ? { start, end } : null; 80 | }, [value, items, collections]); 81 | 82 | const sliderValue = useMemo(() => { 83 | if (!datetimes) return undefined; 84 | if (filterStart && filterEnd) { 85 | return [filterStart.getTime(), filterEnd.getTime()]; 86 | } 87 | return [datetimes.start.getTime(), datetimes.end.getTime()]; 88 | }, [datetimes, filterStart, filterEnd]); 89 | 90 | useEffect(() => { 91 | if (filterStart && filterEnd) { 92 | setDatetimeBounds({ start: filterStart, end: filterEnd }); 93 | } 94 | }, [filterStart, filterEnd, setDatetimeBounds]); 95 | 96 | return ( 97 | 98 | setFilter(!!e.checked)} 102 | > 103 | 104 | Filter collections and items? 105 | 106 | 107 | 108 | 109 | 110 | Bounding box 111 | 112 | {(bbox && ) || "not set"} 113 | 114 | 115 | {datetimes && ( 116 | 117 | Datetime 118 | 119 | 120 | 121 | {filterStart 122 | ? filterStart.toLocaleDateString() 123 | : datetimes.start.toLocaleDateString()}{" "} 124 | —{" "} 125 | {filterEnd 126 | ? filterEnd.toLocaleDateString() 127 | : datetimes.end.toLocaleDateString()} 128 | 129 | { 134 | setFilterStart(new Date(e.value[0])); 135 | setFilterEnd(new Date(e.value[1])); 136 | }} 137 | disabled={!filter} 138 | > 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | )} 150 | 151 | 152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /src/components/ui/prose.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { chakra } from "@chakra-ui/react"; 4 | 5 | export const Prose = chakra("div", { 6 | base: { 7 | color: "fg.muted", 8 | maxWidth: "65ch", 9 | fontSize: "sm", 10 | lineHeight: "1.7em", 11 | "& p": { 12 | marginTop: "1em", 13 | marginBottom: "1em", 14 | }, 15 | "& blockquote": { 16 | marginTop: "1.285em", 17 | marginBottom: "1.285em", 18 | paddingInline: "1.285em", 19 | borderInlineStartWidth: "0.25em", 20 | }, 21 | "& a": { 22 | color: "fg", 23 | textDecoration: "underline", 24 | textUnderlineOffset: "3px", 25 | textDecorationThickness: "2px", 26 | textDecorationColor: "border.muted", 27 | fontWeight: "500", 28 | }, 29 | "& strong": { 30 | fontWeight: "600", 31 | }, 32 | "& a strong": { 33 | color: "inherit", 34 | }, 35 | "& h1": { 36 | fontSize: "2.15em", 37 | letterSpacing: "-0.02em", 38 | marginTop: "0", 39 | marginBottom: "0.8em", 40 | lineHeight: "1.2em", 41 | }, 42 | "& h2": { 43 | fontSize: "1.4em", 44 | letterSpacing: "-0.02em", 45 | marginTop: "1.6em", 46 | marginBottom: "0.8em", 47 | lineHeight: "1.4em", 48 | }, 49 | "& h3": { 50 | fontSize: "1.285em", 51 | letterSpacing: "-0.01em", 52 | marginTop: "1.5em", 53 | marginBottom: "0.4em", 54 | lineHeight: "1.5em", 55 | }, 56 | "& h4": { 57 | marginTop: "1.4em", 58 | marginBottom: "0.5em", 59 | letterSpacing: "-0.01em", 60 | lineHeight: "1.5em", 61 | }, 62 | "& img": { 63 | marginTop: "1.7em", 64 | marginBottom: "1.7em", 65 | borderRadius: "lg", 66 | boxShadow: "inset", 67 | }, 68 | "& picture": { 69 | marginTop: "1.7em", 70 | marginBottom: "1.7em", 71 | }, 72 | "& picture > img": { 73 | marginTop: "0", 74 | marginBottom: "0", 75 | }, 76 | "& video": { 77 | marginTop: "1.7em", 78 | marginBottom: "1.7em", 79 | }, 80 | "& kbd": { 81 | fontSize: "0.85em", 82 | borderRadius: "xs", 83 | paddingTop: "0.15em", 84 | paddingBottom: "0.15em", 85 | paddingInlineEnd: "0.35em", 86 | paddingInlineStart: "0.35em", 87 | fontFamily: "inherit", 88 | color: "fg.muted", 89 | "--shadow": "colors.border", 90 | boxShadow: "0 0 0 1px var(--shadow),0 1px 0 1px var(--shadow)", 91 | }, 92 | "& code": { 93 | fontSize: "0.925em", 94 | letterSpacing: "-0.01em", 95 | borderRadius: "md", 96 | borderWidth: "1px", 97 | padding: "0.25em", 98 | }, 99 | "& pre code": { 100 | fontSize: "inherit", 101 | letterSpacing: "inherit", 102 | borderWidth: "inherit", 103 | padding: "0", 104 | }, 105 | "& h2 code": { 106 | fontSize: "0.9em", 107 | }, 108 | "& h3 code": { 109 | fontSize: "0.8em", 110 | }, 111 | "& pre": { 112 | backgroundColor: "bg.subtle", 113 | marginTop: "1.6em", 114 | marginBottom: "1.6em", 115 | borderRadius: "md", 116 | fontSize: "0.9em", 117 | paddingTop: "0.65em", 118 | paddingBottom: "0.65em", 119 | paddingInlineEnd: "1em", 120 | paddingInlineStart: "1em", 121 | overflowX: "auto", 122 | fontWeight: "400", 123 | }, 124 | "& ol": { 125 | marginTop: "1em", 126 | marginBottom: "1em", 127 | paddingInlineStart: "1.5em", 128 | }, 129 | "& ul": { 130 | marginTop: "1em", 131 | marginBottom: "1em", 132 | paddingInlineStart: "1.5em", 133 | }, 134 | "& li": { 135 | marginTop: "0.285em", 136 | marginBottom: "0.285em", 137 | }, 138 | "& ol > li": { 139 | paddingInlineStart: "0.4em", 140 | listStyleType: "decimal", 141 | "&::marker": { 142 | color: "fg.muted", 143 | }, 144 | }, 145 | "& ul > li": { 146 | paddingInlineStart: "0.4em", 147 | listStyleType: "disc", 148 | "&::marker": { 149 | color: "fg.muted", 150 | }, 151 | }, 152 | "& > ul > li p": { 153 | marginTop: "0.5em", 154 | marginBottom: "0.5em", 155 | }, 156 | "& > ul > li > p:first-of-type": { 157 | marginTop: "1em", 158 | }, 159 | "& > ul > li > p:last-of-type": { 160 | marginBottom: "1em", 161 | }, 162 | "& > ol > li > p:first-of-type": { 163 | marginTop: "1em", 164 | }, 165 | "& > ol > li > p:last-of-type": { 166 | marginBottom: "1em", 167 | }, 168 | "& ul ul, ul ol, ol ul, ol ol": { 169 | marginTop: "0.5em", 170 | marginBottom: "0.5em", 171 | }, 172 | "& dl": { 173 | marginTop: "1em", 174 | marginBottom: "1em", 175 | }, 176 | "& dt": { 177 | fontWeight: "600", 178 | marginTop: "1em", 179 | }, 180 | "& dd": { 181 | marginTop: "0.285em", 182 | paddingInlineStart: "1.5em", 183 | }, 184 | "& hr": { 185 | marginTop: "2.25em", 186 | marginBottom: "2.25em", 187 | }, 188 | "& :is(h1,h2,h3,h4,h5,hr) + *": { 189 | marginTop: "0", 190 | }, 191 | "& table": { 192 | width: "100%", 193 | tableLayout: "auto", 194 | textAlign: "start", 195 | lineHeight: "1.5em", 196 | marginTop: "2em", 197 | marginBottom: "2em", 198 | }, 199 | "& thead": { 200 | borderBottomWidth: "1px", 201 | color: "fg", 202 | }, 203 | "& tbody tr": { 204 | borderBottomWidth: "1px", 205 | borderBottomColor: "border", 206 | }, 207 | "& thead th": { 208 | paddingInlineEnd: "1em", 209 | paddingBottom: "0.65em", 210 | paddingInlineStart: "1em", 211 | fontWeight: "medium", 212 | textAlign: "start", 213 | }, 214 | "& thead th:first-of-type": { 215 | paddingInlineStart: "0", 216 | }, 217 | "& thead th:last-of-type": { 218 | paddingInlineEnd: "0", 219 | }, 220 | "& tbody td, tfoot td": { 221 | paddingTop: "0.65em", 222 | paddingInlineEnd: "1em", 223 | paddingBottom: "0.65em", 224 | paddingInlineStart: "1em", 225 | }, 226 | "& tbody td:first-of-type, tfoot td:first-of-type": { 227 | paddingInlineStart: "0", 228 | }, 229 | "& tbody td:last-of-type, tfoot td:last-of-type": { 230 | paddingInlineEnd: "0", 231 | }, 232 | "& figure": { 233 | marginTop: "1.625em", 234 | marginBottom: "1.625em", 235 | }, 236 | "& figure > *": { 237 | marginTop: "0", 238 | marginBottom: "0", 239 | }, 240 | "& figcaption": { 241 | fontSize: "0.85em", 242 | lineHeight: "1.25em", 243 | marginTop: "0.85em", 244 | color: "fg.muted", 245 | }, 246 | "& h1, h2, h3, h4": { 247 | color: "fg", 248 | fontWeight: "600", 249 | }, 250 | }, 251 | variants: { 252 | size: { 253 | md: { 254 | fontSize: "sm", 255 | }, 256 | lg: { 257 | fontSize: "md", 258 | }, 259 | }, 260 | }, 261 | defaultVariants: { 262 | size: "md", 263 | }, 264 | }); 265 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { Box, Container, FileUpload, useFileUpload } from "@chakra-ui/react"; 3 | import type { StacCollection, StacItem } from "stac-ts"; 4 | import { Toaster } from "./components/ui/toaster"; 5 | import useStacChildren from "./hooks/stac-children"; 6 | import useStacValue from "./hooks/stac-value"; 7 | import Map from "./layers/map"; 8 | import Overlay from "./layers/overlay"; 9 | import type { BBox2D, Color } from "./types/map"; 10 | import type { DatetimeBounds, StacValue } from "./types/stac"; 11 | import { 12 | isCog, 13 | isCollectionInBbox, 14 | isCollectionInDatetimeBounds, 15 | isItemInBbox, 16 | isItemInDatetimeBounds, 17 | isVisual, 18 | } from "./utils/stac"; 19 | 20 | // TODO make this configurable by the user. 21 | const lineColor: Color = [207, 63, 2, 100]; 22 | const fillColor: Color = [207, 63, 2, 50]; 23 | 24 | export default function App() { 25 | // User state 26 | const [href, setHref] = useState(getInitialHref()); 27 | const fileUpload = useFileUpload({ maxFiles: 1 }); 28 | const [userCollections, setCollections] = useState(); 29 | const [userItems, setItems] = useState(); 30 | const [picked, setPicked] = useState(); 31 | const [bbox, setBbox] = useState(); 32 | const [datetimeBounds, setDatetimeBounds] = useState(); 33 | const [filter, setFilter] = useState(true); 34 | const [stacGeoparquetItemId, setStacGeoparquetItemId] = useState(); 35 | const [cogTileHref, setCogTileHref] = useState(); 36 | 37 | // Derived state 38 | const { 39 | value, 40 | error, 41 | items: linkedItems, 42 | table, 43 | stacGeoparquetItem, 44 | } = useStacValue({ 45 | href, 46 | fileUpload, 47 | datetimeBounds: filter ? datetimeBounds : undefined, 48 | stacGeoparquetItemId, 49 | }); 50 | const collectionsLink = value?.links?.find((link) => link.rel === "data"); 51 | const { catalogs, collections: linkedCollections } = useStacChildren({ 52 | value, 53 | enabled: !!value && !collectionsLink, 54 | }); 55 | const collections = collectionsLink ? userCollections : linkedCollections; 56 | const items = userItems || linkedItems; 57 | const filteredCollections = useMemo(() => { 58 | if (filter && collections) { 59 | return collections.filter( 60 | (collection) => 61 | (!bbox || isCollectionInBbox(collection, bbox)) && 62 | (!datetimeBounds || 63 | isCollectionInDatetimeBounds(collection, datetimeBounds)) 64 | ); 65 | } else { 66 | return undefined; 67 | } 68 | }, [collections, filter, bbox, datetimeBounds]); 69 | const filteredItems = useMemo(() => { 70 | if (filter && items) { 71 | return items.filter( 72 | (item) => 73 | (!bbox || isItemInBbox(item, bbox)) && 74 | (!datetimeBounds || isItemInDatetimeBounds(item, datetimeBounds)) 75 | ); 76 | } else { 77 | return undefined; 78 | } 79 | }, [items, filter, bbox, datetimeBounds]); 80 | 81 | // Effects 82 | useEffect(() => { 83 | function handlePopState() { 84 | setHref(new URLSearchParams(location.search).get("href") ?? ""); 85 | } 86 | window.addEventListener("popstate", handlePopState); 87 | 88 | const href = new URLSearchParams(location.search).get("href"); 89 | if (href) { 90 | try { 91 | new URL(href); 92 | } catch { 93 | history.pushState(null, "", location.pathname); 94 | } 95 | } 96 | 97 | return () => { 98 | window.removeEventListener("popstate", handlePopState); 99 | }; 100 | }, []); 101 | 102 | useEffect(() => { 103 | if (href && new URLSearchParams(location.search).get("href") != href) { 104 | history.pushState(null, "", "?href=" + href); 105 | } else if (href === "") { 106 | history.pushState(null, "", location.pathname); 107 | } 108 | }, [href]); 109 | 110 | useEffect(() => { 111 | // It should never be more than 1. 112 | if (fileUpload.acceptedFiles.length == 1) { 113 | setHref(fileUpload.acceptedFiles[0].name); 114 | } 115 | }, [fileUpload.acceptedFiles]); 116 | 117 | useEffect(() => { 118 | setPicked(undefined); 119 | setItems(undefined); 120 | setDatetimeBounds(undefined); 121 | setCogTileHref(value && getCogTileHref(value)); 122 | 123 | if (value && (value.title || value.id)) { 124 | document.title = "stac-map | " + (value.title || value.id); 125 | } else { 126 | document.title = "stac-map"; 127 | } 128 | }, [value]); 129 | 130 | useEffect(() => { 131 | setCogTileHref(picked && getCogTileHref(picked)); 132 | }, [picked]); 133 | 134 | useEffect(() => { 135 | setPicked(stacGeoparquetItem); 136 | }, [stacGeoparquetItem]); 137 | 138 | return ( 139 | <> 140 | 141 | 142 | 149 | 164 | 165 | 166 | 167 | 177 | 199 | 200 | 201 | 202 | ); 203 | } 204 | 205 | function getInitialHref() { 206 | const href = new URLSearchParams(location.search).get("href") || ""; 207 | try { 208 | new URL(href); 209 | } catch { 210 | return undefined; 211 | } 212 | return href; 213 | } 214 | 215 | function getCogTileHref(value: StacValue) { 216 | let cogTileHref = undefined; 217 | if (value.assets) { 218 | for (const asset of Object.values(value.assets)) { 219 | if (isCog(asset) && isVisual(asset)) { 220 | cogTileHref = asset.href as string; 221 | break; 222 | } 223 | } 224 | } 225 | return cogTileHref; 226 | } 227 | -------------------------------------------------------------------------------- /src/utils/stac.ts: -------------------------------------------------------------------------------- 1 | import type { UseFileUploadReturn } from "@chakra-ui/react"; 2 | import type { StacAsset, StacCollection, StacItem, StacLink } from "stac-ts"; 3 | import type { BBox2D } from "../types/map"; 4 | import type { DatetimeBounds, StacAssets, StacValue } from "../types/stac"; 5 | 6 | export async function getStacJsonValue( 7 | href: string, 8 | fileUpload?: UseFileUploadReturn 9 | ): Promise { 10 | let url; 11 | try { 12 | url = new URL(href); 13 | } catch { 14 | if (fileUpload) { 15 | return getStacJsonValueFromUpload(fileUpload); 16 | } else { 17 | throw new Error( 18 | `Cannot get STAC JSON value from href=${href} without a fileUpload` 19 | ); 20 | } 21 | } 22 | return await fetchStac(url); 23 | } 24 | 25 | async function getStacJsonValueFromUpload(fileUpload: UseFileUploadReturn) { 26 | // We assume there's one and only on file. 27 | const file = fileUpload.acceptedFiles[0]; 28 | return JSON.parse(await file.text()); 29 | } 30 | 31 | export async function fetchStac( 32 | href: string | URL, 33 | method: "GET" | "POST" = "GET", 34 | body?: string 35 | ): Promise { 36 | return await fetch(href, { 37 | method, 38 | headers: { 39 | Accept: "application/json", 40 | }, 41 | body, 42 | }).then(async (response) => { 43 | if (response.ok) { 44 | return response 45 | .json() 46 | .then((json) => makeHrefsAbsolute(json, href.toString())) 47 | .then((json) => maybeAddTypeField(json)); 48 | } else { 49 | throw new Error(`${method} ${href}: ${response.statusText}`); 50 | } 51 | }); 52 | } 53 | 54 | export function makeHrefsAbsolute( 55 | value: T, 56 | baseUrl: string 57 | ): T { 58 | const baseUrlObj = new URL(baseUrl); 59 | 60 | if (value.links != null) { 61 | let hasSelf = false; 62 | for (const link of value.links) { 63 | if (link.rel === "self") hasSelf = true; 64 | if (link.href) { 65 | link.href = toAbsoluteUrl(link.href, baseUrlObj); 66 | } 67 | } 68 | if (hasSelf === false) { 69 | value.links.push({ href: baseUrl, rel: "self" }); 70 | } 71 | } else { 72 | value.links = [{ href: baseUrl, rel: "self" }]; 73 | } 74 | 75 | if (value.assets != null) { 76 | for (const asset of Object.values(value.assets)) { 77 | if (asset.href) { 78 | asset.href = toAbsoluteUrl(asset.href, baseUrlObj); 79 | } 80 | } 81 | } 82 | return value; 83 | } 84 | 85 | export function toAbsoluteUrl(href: string, baseUrl: URL): string { 86 | if (isAbsolute(href)) return href; 87 | 88 | const targetUrl = new URL(href, baseUrl); 89 | 90 | if (targetUrl.protocol === "http:" || targetUrl.protocol === "https:") { 91 | return targetUrl.toString(); 92 | } else if (targetUrl.protocol === "s3:") { 93 | return decodeURI(targetUrl.toString()); 94 | } else { 95 | return targetUrl.toString(); 96 | } 97 | } 98 | 99 | function isAbsolute(url: string) { 100 | try { 101 | new URL(url); 102 | return true; 103 | } catch { 104 | return false; 105 | } 106 | } 107 | 108 | // eslint-disable-next-line 109 | function maybeAddTypeField(value: any) { 110 | if (!value.type) { 111 | if (value.features && Array.isArray(value.features)) { 112 | value.type = "FeatureCollection"; 113 | } else if (value.extent) { 114 | value.type = "Collection"; 115 | } else if (value.geometry && value.properties) { 116 | value.type = "Feature"; 117 | } else if (value.stac_version) { 118 | value.type = "Catalog"; 119 | } 120 | } 121 | return value; 122 | } 123 | 124 | export function getItemDatetimes(item: StacItem) { 125 | const start = item.properties?.start_datetime 126 | ? new Date(item.properties.start_datetime) 127 | : item.properties?.datetime 128 | ? new Date(item.properties.datetime) 129 | : null; 130 | const end = item.properties?.end_datetime 131 | ? new Date(item.properties.end_datetime) 132 | : item.properties?.datetime 133 | ? new Date(item.properties.datetime) 134 | : null; 135 | return { start, end }; 136 | } 137 | 138 | export function isCollectionInBbox(collection: StacCollection, bbox: BBox2D) { 139 | if (bbox[2] - bbox[0] >= 360) { 140 | // A global bbox always contains every collection 141 | return true; 142 | } 143 | const collectionBbox = collection?.extent?.spatial?.bbox?.[0]; 144 | if (collectionBbox) { 145 | return ( 146 | !( 147 | collectionBbox[0] < bbox[0] && 148 | collectionBbox[1] < bbox[1] && 149 | collectionBbox[2] > bbox[2] && 150 | collectionBbox[3] > bbox[3] 151 | ) && 152 | !( 153 | collectionBbox[0] > bbox[2] || 154 | collectionBbox[1] > bbox[3] || 155 | collectionBbox[2] < bbox[0] || 156 | collectionBbox[3] < bbox[1] 157 | ) 158 | ); 159 | } else { 160 | return false; 161 | } 162 | } 163 | 164 | export function isCollectionInDatetimeBounds( 165 | collection: StacCollection, 166 | bounds: DatetimeBounds 167 | ) { 168 | const interval = collection.extent.temporal.interval[0]; 169 | const start = interval[0] ? new Date(interval[0]) : null; 170 | const end = interval[1] ? new Date(interval[1]) : null; 171 | return !((end && end < bounds.start) || (start && start > bounds.end)); 172 | } 173 | 174 | export function isItemInBbox(item: StacItem, bbox: BBox2D) { 175 | if (bbox[2] - bbox[0] >= 360) { 176 | // A global bbox always contains every item 177 | return true; 178 | } 179 | const itemBbox = item.bbox; 180 | if (itemBbox) { 181 | return ( 182 | !( 183 | itemBbox[0] < bbox[0] && 184 | itemBbox[1] < bbox[1] && 185 | itemBbox[2] > bbox[2] && 186 | itemBbox[3] > bbox[3] 187 | ) && 188 | !( 189 | itemBbox[0] > bbox[2] || 190 | itemBbox[1] > bbox[3] || 191 | itemBbox[2] < bbox[0] || 192 | itemBbox[3] < bbox[1] 193 | ) 194 | ); 195 | } else { 196 | return false; 197 | } 198 | } 199 | 200 | export function isItemInDatetimeBounds(item: StacItem, bounds: DatetimeBounds) { 201 | const datetimes = getItemDatetimes(item); 202 | return !( 203 | (datetimes.end && datetimes.end < bounds.start) || 204 | (datetimes.start && datetimes.start > bounds.end) 205 | ); 206 | } 207 | 208 | export function deconstructStac(value: StacValue) { 209 | if (value.type === "Feature") { 210 | return { 211 | links: value.links, 212 | assets: value.assets as StacAssets | undefined, 213 | properties: value.properties, 214 | }; 215 | } else { 216 | const { links, assets, ...properties } = value; 217 | return { 218 | links: links || [], 219 | assets: assets as StacAssets | undefined, 220 | properties, 221 | }; 222 | } 223 | } 224 | 225 | export function getImportantLinks(links: StacLink[]) { 226 | let rootLink: StacLink | undefined = undefined; 227 | let collectionsLink: StacLink | undefined = undefined; 228 | let nextLink: StacLink | undefined = undefined; 229 | let prevLink: StacLink | undefined = undefined; 230 | const filteredLinks = []; 231 | if (links) { 232 | for (const link of links) { 233 | switch (link.rel) { 234 | case "root": 235 | rootLink = link; 236 | break; 237 | case "data": 238 | collectionsLink = link; 239 | break; 240 | case "next": 241 | nextLink = link; 242 | break; 243 | case "previous": 244 | prevLink = link; 245 | break; 246 | } 247 | // We already show children and items in their own pane 248 | if (link.rel !== "child" && link.rel !== "item") filteredLinks.push(link); 249 | } 250 | } 251 | return { rootLink, collectionsLink, nextLink, prevLink, filteredLinks }; 252 | } 253 | 254 | export function isCog(asset: StacAsset) { 255 | return ( 256 | asset.type === "image/tiff; application=geotiff; profile=cloud-optimized" 257 | ); 258 | } 259 | 260 | export function isVisual(asset: StacAsset) { 261 | if (asset.roles) { 262 | for (const role of asset.roles) { 263 | if (role === "visual" || role === "thumbnail") { 264 | return true; 265 | } 266 | } 267 | } 268 | return false; 269 | } 270 | -------------------------------------------------------------------------------- /src/components/sections/collection-search.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef, useState } from "react"; 2 | import { LuFolderSearch, LuSearch } from "react-icons/lu"; 3 | import { 4 | CloseButton, 5 | Combobox, 6 | createListCollection, 7 | Field, 8 | HStack, 9 | Input, 10 | InputGroup, 11 | Portal, 12 | SegmentGroup, 13 | SkeletonText, 14 | Stack, 15 | } from "@chakra-ui/react"; 16 | import { useQuery } from "@tanstack/react-query"; 17 | import type { StacCollection } from "stac-ts"; 18 | import type { NaturalLanguageCollectionSearchResult } from "../../types/stac"; 19 | import CollectionCard from "../cards/collection"; 20 | import Section from "../section"; 21 | 22 | interface CollectionSearchProps { 23 | collections: StacCollection[]; 24 | catalogHref: string | undefined; 25 | setHref: (href: string | undefined) => void; 26 | } 27 | 28 | export default function CollectionSearchSection({ 29 | ...props 30 | }: CollectionSearchProps) { 31 | return ( 32 |
37 | 38 |
39 | ); 40 | } 41 | 42 | function CollectionSearch({ 43 | collections, 44 | catalogHref, 45 | setHref, 46 | }: CollectionSearchProps) { 47 | const [value, setValue] = useState<"Text" | "Natural language">("Text"); 48 | return ( 49 | 50 | 51 | 55 | setValue(e.value as "Text" | "Natural language") 56 | } 57 | > 58 | 59 | 69 | 70 | 71 | {value === "Text" && ( 72 | 76 | )} 77 | {value === "Natural language" && catalogHref && ( 78 | 83 | )} 84 | 85 | ); 86 | } 87 | 88 | function CollectionCombobox({ 89 | collections, 90 | setHref, 91 | }: { 92 | collections: StacCollection[]; 93 | setHref: (href: string | undefined) => void; 94 | }) { 95 | const [searchValue, setSearchValue] = useState(""); 96 | 97 | const filteredCollections = useMemo(() => { 98 | return collections.filter( 99 | (collection) => 100 | collection.title?.toLowerCase().includes(searchValue.toLowerCase()) || 101 | collection.id.toLowerCase().includes(searchValue.toLowerCase()) || 102 | collection.description.toLowerCase().includes(searchValue.toLowerCase()) 103 | ); 104 | }, [searchValue, collections]); 105 | 106 | const collection = useMemo( 107 | () => 108 | createListCollection({ 109 | items: filteredCollections, 110 | itemToString: (collection) => collection.title || collection.id, 111 | itemToValue: (collection) => collection.id, 112 | }), 113 | 114 | [filteredCollections] 115 | ); 116 | 117 | return ( 118 | setSearchValue(details.inputValue)} 123 | onSelect={(details) => { 124 | const collection = collections.find( 125 | (collection) => collection.id == details.itemValue 126 | ); 127 | if (collection) { 128 | const selfHref = collection.links.find( 129 | (link) => link.rel == "self" 130 | )?.href; 131 | if (selfHref) { 132 | setHref(selfHref); 133 | } 134 | } 135 | }} 136 | > 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | {filteredCollections.map((collection) => ( 150 | 151 | {collection.title || collection.id} 152 | 153 | 154 | 155 | ))} 156 | 157 | No collections found 158 | 159 | 160 | 161 | 162 | 163 | ); 164 | } 165 | 166 | function NaturalLanguageCollectionSearch({ 167 | href, 168 | setHref, 169 | collections, 170 | }: { 171 | href: string; 172 | setHref: (href: string | undefined) => void; 173 | collections: StacCollection[]; 174 | }) { 175 | const [query, setQuery] = useState(); 176 | const [value, setValue] = useState(""); 177 | const inputRef = useRef(null); 178 | 179 | const endElement = value ? ( 180 | { 183 | setValue(""); 184 | setQuery(undefined); 185 | inputRef.current?.focus(); 186 | }} 187 | me="-2" 188 | /> 189 | ) : undefined; 190 | 191 | return ( 192 | 193 |
{ 195 | e.preventDefault(); 196 | setQuery(value); 197 | }} 198 | > 199 | 200 | } 202 | endElement={endElement} 203 | > 204 | setValue(e.target.value)} 209 | > 210 | 211 | 212 | Natural language collection search is experimental, and can be 213 | rather slow. 214 | 215 | 216 |
217 | {query && ( 218 | 224 | )} 225 |
226 | ); 227 | } 228 | 229 | function Results({ 230 | query, 231 | href, 232 | setHref, 233 | collections, 234 | }: { 235 | query: string; 236 | href: string; 237 | setHref: (href: string | undefined) => void; 238 | collections: StacCollection[]; 239 | }) { 240 | const { data } = useQuery<{ 241 | results: NaturalLanguageCollectionSearchResult[]; 242 | }>({ 243 | queryKey: [href, query], 244 | queryFn: async () => { 245 | const body = JSON.stringify({ 246 | query, 247 | catalog_url: href, 248 | }); 249 | const url = new URL( 250 | "search", 251 | import.meta.env.VITE_STAC_NATURAL_QUERY_API 252 | ); 253 | return await fetch(url, { 254 | method: "POST", 255 | headers: { 256 | "Content-Type": "application/json", 257 | }, 258 | body, 259 | }).then((response) => { 260 | if (response.ok) { 261 | return response.json(); 262 | } else { 263 | throw new Error( 264 | `Error while doing a natural language search against ${href}: ${response.statusText}` 265 | ); 266 | } 267 | }); 268 | }, 269 | }); 270 | 271 | const results = useMemo(() => { 272 | return data?.results.map( 273 | (result: NaturalLanguageCollectionSearchResult) => { 274 | return { 275 | result, 276 | collection: collections.find( 277 | (collection) => collection.id == result.collection_id 278 | ), 279 | }; 280 | } 281 | ); 282 | }, [data, collections]); 283 | 284 | if (results) { 285 | return ( 286 | 287 | {results.map((result) => { 288 | if (result.collection) { 289 | return ( 290 | 296 | ); 297 | } else { 298 | return null; 299 | } 300 | })} 301 | 302 | ); 303 | } else { 304 | return ; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/layers/map.tsx: -------------------------------------------------------------------------------- 1 | import { type RefObject, useEffect, useMemo, useRef } from "react"; 2 | import { 3 | Map as MaplibreMap, 4 | type MapRef, 5 | useControl, 6 | } from "react-map-gl/maplibre"; 7 | import { type DeckProps, Layer } from "@deck.gl/core"; 8 | import { TileLayer } from "@deck.gl/geo-layers"; 9 | import { BitmapLayer, GeoJsonLayer } from "@deck.gl/layers"; 10 | import { MapboxOverlay } from "@deck.gl/mapbox"; 11 | import { GeoArrowPolygonLayer } from "@geoarrow/deck.gl-layers"; 12 | import bbox from "@turf/bbox"; 13 | import bboxPolygon from "@turf/bbox-polygon"; 14 | import "maplibre-gl/dist/maplibre-gl.css"; 15 | import type { Table } from "apache-arrow"; 16 | import type { SpatialExtent, StacCollection, StacItem } from "stac-ts"; 17 | import type { BBox, Feature, FeatureCollection } from "geojson"; 18 | import { useColorModeValue } from "../components/ui/color-mode"; 19 | import type { BBox2D, Color } from "../types/map"; 20 | import type { StacValue } from "../types/stac"; 21 | 22 | export default function Map({ 23 | value, 24 | collections, 25 | filteredCollections, 26 | items, 27 | filteredItems, 28 | fillColor, 29 | lineColor, 30 | setBbox, 31 | picked, 32 | setPicked, 33 | table, 34 | setStacGeoparquetItemId, 35 | cogTileHref, 36 | }: { 37 | value: StacValue | undefined; 38 | collections: StacCollection[] | undefined; 39 | filteredCollections: StacCollection[] | undefined; 40 | items: StacItem[] | undefined; 41 | filteredItems: StacItem[] | undefined; 42 | fillColor: Color; 43 | lineColor: Color; 44 | setBbox: (bbox: BBox2D | undefined) => void; 45 | picked: StacValue | undefined; 46 | setPicked: (picked: StacValue | undefined) => void; 47 | table: Table | undefined; 48 | setStacGeoparquetItemId: (id: string | undefined) => void; 49 | cogTileHref: string | undefined; 50 | }) { 51 | const mapRef = useRef(null); 52 | const mapStyle = useColorModeValue( 53 | "positron-gl-style", 54 | "dark-matter-gl-style" 55 | ); 56 | const valueGeoJson = useMemo(() => { 57 | if (value) { 58 | return valueToGeoJson(value); 59 | } else { 60 | return undefined; 61 | } 62 | }, [value]); 63 | const pickedGeoJson = useMemo(() => { 64 | if (picked) { 65 | return valueToGeoJson(picked); 66 | } else { 67 | return undefined; 68 | } 69 | }, [picked]); 70 | const collectionsGeoJson = useMemo(() => { 71 | return (filteredCollections || collections) 72 | ?.map( 73 | (collection) => 74 | collection.extent?.spatial?.bbox && 75 | bboxPolygon(getCollectionExtents(collection) as BBox) 76 | ) 77 | .filter((feature) => !!feature); 78 | }, [collections, filteredCollections]); 79 | 80 | const inverseFillColor: Color = [ 81 | 256 - fillColor[0], 82 | 256 - fillColor[1], 83 | 256 - fillColor[2], 84 | fillColor[3], 85 | ]; 86 | const inverseLineColor: Color = [ 87 | 256 - fillColor[0], 88 | 256 - fillColor[1], 89 | 256 - fillColor[2], 90 | fillColor[3], 91 | ]; 92 | 93 | let layers: Layer[] = []; 94 | 95 | if (cogTileHref) 96 | layers.push( 97 | new TileLayer({ 98 | id: "cog-tiles", 99 | extent: value && getBbox(value, collections), 100 | maxRequests: 10, 101 | data: 102 | cogTileHref && 103 | `https://titiler.xyz/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png?url=${cogTileHref}`, 104 | renderSubLayers: (props) => { 105 | const { boundingBox } = props.tile; 106 | const { data, ...otherProps } = props; 107 | 108 | if (data) { 109 | return new BitmapLayer(otherProps, { 110 | image: data, 111 | bounds: [ 112 | boundingBox[0][0], 113 | boundingBox[0][1], 114 | boundingBox[1][0], 115 | boundingBox[1][1], 116 | ], 117 | }); 118 | } else { 119 | return null; 120 | } 121 | }, 122 | }) 123 | ); 124 | 125 | layers = [ 126 | ...layers, 127 | new GeoJsonLayer({ 128 | id: "picked", 129 | data: pickedGeoJson, 130 | filled: true, 131 | getFillColor: inverseFillColor, 132 | getLineColor: inverseLineColor, 133 | getLineWidth: 2, 134 | lineWidthUnits: "pixels", 135 | }), 136 | new GeoJsonLayer({ 137 | id: "items", 138 | data: (filteredItems || items) as Feature[] | undefined, 139 | filled: true, 140 | getFillColor: fillColor, 141 | getLineColor: lineColor, 142 | getLineWidth: 2, 143 | lineWidthUnits: "pixels", 144 | pickable: true, 145 | onClick: (info) => { 146 | setPicked(info.object); 147 | }, 148 | }), 149 | new GeoJsonLayer({ 150 | id: "collections", 151 | data: collectionsGeoJson, 152 | filled: false, 153 | getLineColor: lineColor, 154 | getLineWidth: 2, 155 | lineWidthUnits: "pixels", 156 | }), 157 | new GeoJsonLayer({ 158 | id: "value", 159 | data: valueGeoJson, 160 | filled: !items && !cogTileHref, 161 | getFillColor: collections ? inverseFillColor : fillColor, 162 | getLineColor: collections ? inverseLineColor : lineColor, 163 | getLineWidth: 2, 164 | lineWidthUnits: "pixels", 165 | pickable: value?.type !== "Collection" && value?.type !== "Feature", 166 | onClick: (info) => { 167 | setPicked(info.object); 168 | }, 169 | }), 170 | ]; 171 | 172 | if (table) 173 | layers.push( 174 | new GeoArrowPolygonLayer({ 175 | id: "table", 176 | data: table, 177 | filled: true, 178 | getFillColor: fillColor, 179 | getLineColor: lineColor, 180 | getLineWidth: 2, 181 | lineWidthUnits: "pixels", 182 | pickable: true, 183 | onClick: (info) => { 184 | setStacGeoparquetItemId(table.getChild("id")?.get(info.index)); 185 | }, 186 | }) 187 | ); 188 | 189 | useEffect(() => { 190 | if (value && mapRef.current) { 191 | const padding = { 192 | top: window.innerHeight / 10, 193 | bottom: window.innerHeight / 20, 194 | right: window.innerWidth / 20, 195 | left: window.innerWidth / 20 + window.innerWidth / 3, 196 | }; 197 | const bbox = getBbox(value, collections); 198 | if (bbox) mapRef.current.fitBounds(bbox, { linear: true, padding }); 199 | } 200 | }, [value, collections]); 201 | 202 | return ( 203 | { 214 | if (mapRef.current && !mapRef.current.isMoving()) 215 | setBbox(sanitizeBbox(mapRef.current?.getBounds().toArray().flat())); 216 | }} 217 | > 218 | getCursor(mapRef, props)} 221 | > 222 | 223 | ); 224 | } 225 | 226 | function DeckGLOverlay(props: DeckProps) { 227 | const control = useControl(() => new MapboxOverlay({})); 228 | control.setProps(props); 229 | return <>; 230 | } 231 | 232 | function getCursor( 233 | mapRef: RefObject, 234 | { 235 | isHovering, 236 | isDragging, 237 | }: { 238 | isHovering: boolean; 239 | isDragging: boolean; 240 | } 241 | ) { 242 | let cursor = "grab"; 243 | if (isHovering) { 244 | cursor = "pointer"; 245 | } else if (isDragging) { 246 | cursor = "grabbing"; 247 | } 248 | if (mapRef.current) { 249 | mapRef.current.getCanvas().style.cursor = cursor; 250 | } 251 | return cursor; 252 | } 253 | 254 | function valueToGeoJson(value: StacValue) { 255 | switch (value.type) { 256 | case "Catalog": 257 | return undefined; 258 | case "Collection": 259 | return ( 260 | value.extent?.spatial?.bbox && 261 | bboxPolygon(getCollectionExtents(value) as BBox) 262 | ); 263 | case "Feature": 264 | return value as Feature; 265 | case "FeatureCollection": 266 | return value as FeatureCollection; 267 | } 268 | } 269 | 270 | function getCollectionExtents(collection: StacCollection): SpatialExtent { 271 | const spatialExtent = collection.extent?.spatial; 272 | // check if bbox is a list of lists, otherwise its a single list of nums 273 | return Array.isArray(spatialExtent?.bbox?.[0]) 274 | ? spatialExtent?.bbox[0] 275 | : (spatialExtent?.bbox as unknown as SpatialExtent); 276 | } 277 | 278 | function getBbox( 279 | value: StacValue, 280 | collections: StacCollection[] | undefined 281 | ): BBox2D | undefined { 282 | let valueBbox; 283 | switch (value.type) { 284 | case "Catalog": 285 | valueBbox = 286 | collections && collections.length > 0 287 | ? sanitizeBbox( 288 | collections 289 | .map((collection) => getCollectionExtents(collection)) 290 | .filter((extents) => !!extents) 291 | .reduce((accumulator, currentValue) => { 292 | return [ 293 | Math.min(accumulator[0], currentValue[0]), 294 | Math.min(accumulator[1], currentValue[1]), 295 | Math.max(accumulator[2], currentValue[2]), 296 | Math.max(accumulator[3], currentValue[3]), 297 | ]; 298 | }) 299 | ) 300 | : undefined; 301 | break; 302 | case "Collection": 303 | valueBbox = getCollectionExtents(value); 304 | break; 305 | case "Feature": 306 | valueBbox = value.bbox; 307 | break; 308 | case "FeatureCollection": 309 | valueBbox = bbox(value as FeatureCollection) as BBox2D; 310 | break; 311 | } 312 | return valueBbox ? sanitizeBbox(valueBbox) : undefined; 313 | } 314 | 315 | function sanitizeBbox(bbox: BBox | SpatialExtent): BBox2D { 316 | if (bbox.length === 6) { 317 | return [ 318 | Math.max(bbox[0], -180), 319 | Math.max(bbox[1], -90), 320 | Math.min(bbox[3], 180), 321 | Math.min(bbox[4], 90), 322 | ]; 323 | } else { 324 | return [ 325 | Math.max(bbox[0], -180), 326 | Math.max(bbox[1], -90), 327 | Math.min(bbox[2], 180), 328 | Math.min(bbox[3], 90), 329 | ]; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/components/value.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { 3 | LuArrowLeft, 4 | LuArrowRight, 5 | LuExternalLink, 6 | LuFile, 7 | LuFileQuestion, 8 | LuFiles, 9 | LuFolder, 10 | LuFolderPlus, 11 | LuPause, 12 | LuPlay, 13 | LuStepForward, 14 | } from "react-icons/lu"; 15 | import { MarkdownHooks } from "react-markdown"; 16 | import { 17 | Accordion, 18 | Button, 19 | ButtonGroup, 20 | Card, 21 | Heading, 22 | HStack, 23 | Icon, 24 | Image, 25 | Span, 26 | Stack, 27 | } from "@chakra-ui/react"; 28 | import { useQuery } from "@tanstack/react-query"; 29 | import type { StacCatalog, StacCollection, StacItem } from "stac-ts"; 30 | import AssetsSection from "./sections/assets"; 31 | import CatalogsSection from "./sections/catalogs"; 32 | import CollectionSearchSection from "./sections/collection-search"; 33 | import CollectionsSection from "./sections/collections"; 34 | import FilterSection from "./sections/filter"; 35 | import ItemSearchSection from "./sections/item-search"; 36 | import ItemsSection from "./sections/items"; 37 | import LinksSection from "./sections/links"; 38 | import PropertiesSection from "./sections/properties"; 39 | import { Prose } from "./ui/prose"; 40 | import useStacCollections from "../hooks/stac-collections"; 41 | import type { BBox2D } from "../types/map"; 42 | import type { DatetimeBounds, StacSearch, StacValue } from "../types/stac"; 43 | import { deconstructStac, fetchStac, getImportantLinks } from "../utils/stac"; 44 | 45 | export interface SharedValueProps { 46 | catalogs: StacCatalog[] | undefined; 47 | setCollections: (collections: StacCollection[] | undefined) => void; 48 | collections: StacCollection[] | undefined; 49 | filteredCollections: StacCollection[] | undefined; 50 | items: StacItem[] | undefined; 51 | filteredItems: StacItem[] | undefined; 52 | setHref: (href: string | undefined) => void; 53 | filter: boolean; 54 | setFilter: (filter: boolean) => void; 55 | bbox: BBox2D | undefined; 56 | setItems: (items: StacItem[] | undefined) => void; 57 | setDatetimeBounds: (bounds: DatetimeBounds | undefined) => void; 58 | cogTileHref: string | undefined; 59 | setCogTileHref: (href: string | undefined) => void; 60 | } 61 | 62 | interface ValueProps extends SharedValueProps { 63 | href: string; 64 | value: StacValue; 65 | } 66 | 67 | export function Value({ 68 | href, 69 | setHref, 70 | value, 71 | catalogs, 72 | collections, 73 | filteredCollections, 74 | setCollections, 75 | items, 76 | filteredItems, 77 | setItems, 78 | filter, 79 | setFilter, 80 | bbox, 81 | setDatetimeBounds, 82 | cogTileHref, 83 | setCogTileHref, 84 | }: ValueProps) { 85 | const [search, setSearch] = useState(); 86 | const [fetchAllCollections, setFetchAllCollections] = useState(false); 87 | const [thumbnailError, setThumbnailError] = useState(false); 88 | 89 | const selfHref = value.links?.find((link) => link.rel === "self")?.href; 90 | 91 | const { 92 | links, 93 | assets, 94 | properties: rawProperties, 95 | } = useMemo(() => { 96 | return deconstructStac(value); 97 | }, [value]); 98 | // Description is handled at the top of the panel, so we don't need it down in 99 | // the properties. 100 | const properties = useMemo(() => { 101 | if (!rawProperties) return undefined; 102 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 103 | const { description, ...rest } = rawProperties; 104 | return Object.keys(rest).length > 0 ? rest : undefined; 105 | }, [rawProperties]); 106 | 107 | const { rootLink, collectionsLink, nextLink, prevLink, filteredLinks } = 108 | useMemo(() => { 109 | return getImportantLinks(links); 110 | }, [links]); 111 | 112 | const rootData = useQuery({ 113 | queryKey: ["stac-value", rootLink?.href], 114 | enabled: !!rootLink, 115 | queryFn: () => rootLink && fetchStac(rootLink.href), 116 | }); 117 | 118 | const searchLinks = useMemo(() => { 119 | return rootData.data?.links?.filter((link) => link.rel === "search"); 120 | }, [rootData.data]); 121 | 122 | const collectionsResult = useStacCollections(collectionsLink?.href); 123 | 124 | const thumbnailAsset = useMemo(() => { 125 | return ( 126 | assets && 127 | ((Object.keys(assets).includes("thumbnail") && assets["thumbnail"]) || 128 | Object.values(assets).find((asset) => 129 | asset.roles?.includes("thumbnail") 130 | )) 131 | ); 132 | }, [assets]); 133 | 134 | const numberOfCollections = useMemo(() => { 135 | return collectionsResult.data?.pages.at(0)?.numberMatched; 136 | }, [collectionsResult.data]); 137 | 138 | useEffect(() => { 139 | setCollections( 140 | collectionsResult.data?.pages.flatMap((page) => page?.collections || []) 141 | ); 142 | }, [collectionsResult.data, setCollections]); 143 | 144 | useEffect(() => { 145 | if ( 146 | fetchAllCollections && 147 | !collectionsResult.isFetching && 148 | collectionsResult.hasNextPage 149 | ) 150 | collectionsResult.fetchNextPage(); 151 | }, [fetchAllCollections, collectionsResult]); 152 | 153 | useEffect(() => { 154 | setItems(undefined); 155 | }, [search, setItems]); 156 | 157 | return ( 158 | 159 | 160 | 161 | {getValueIcon(value)} 162 | {(value.title as string) || 163 | value.id || 164 | href.split("/").slice(-1)[0]?.split("?")[0]} 165 | 166 | 167 | 168 | {thumbnailAsset && !thumbnailError && ( 169 | setThumbnailError(true)} 172 | maxH={"200"} 173 | /> 174 | )} 175 | 176 | {!!value.description && ( 177 | 178 | {value.description as string} 179 | 180 | )} 181 | 182 | {selfHref && ( 183 | 184 | 189 | 201 | {value.type === "Feature" && ( 202 | 210 | )} 211 | 212 | )} 213 | 214 | {(prevLink || nextLink) && ( 215 | 216 | {prevLink && ( 217 | 221 | )} 222 | 223 | {nextLink && ( 224 | 228 | )} 229 | 230 | )} 231 | 232 | {collectionsResult.hasNextPage && ( 233 | 234 | 235 | Collection pagination 236 | 237 | 238 | 239 | 251 | 264 | 265 | 266 | 267 | )} 268 | 269 | 270 | {catalogs && catalogs.length > 0 && ( 271 | 272 | )} 273 | 274 | {collections && collections.length && ( 275 | 281 | )} 282 | 283 | {collections && ( 284 | 289 | )} 290 | 291 | {value.type === "Collection" && 292 | searchLinks && 293 | searchLinks.length > 0 && ( 294 | 302 | )} 303 | 304 | {(items || collections || value.type === "FeatureCollection") && ( 305 | 314 | )} 315 | 316 | {items && ( 317 | 322 | )} 323 | 324 | {assets && ( 325 | 330 | )} 331 | 332 | {filteredLinks && filteredLinks.length > 0 && ( 333 | 334 | )} 335 | 336 | {properties && } 337 | 338 | 339 | ); 340 | } 341 | 342 | function getValueIcon(value: StacValue) { 343 | switch (value.type) { 344 | case "Catalog": 345 | return ; 346 | case "Collection": 347 | return ; 348 | case "Feature": 349 | return ; 350 | case "FeatureCollection": 351 | return ; 352 | default: 353 | return ; 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /src/components/sections/item-search.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { 3 | LuDownload, 4 | LuPause, 5 | LuPlay, 6 | LuSearch, 7 | LuStepForward, 8 | LuX, 9 | } from "react-icons/lu"; 10 | import { 11 | Alert, 12 | Button, 13 | ButtonGroup, 14 | createListCollection, 15 | DownloadTrigger, 16 | Field, 17 | Group, 18 | Heading, 19 | HStack, 20 | IconButton, 21 | Input, 22 | Portal, 23 | Progress, 24 | Select, 25 | Span, 26 | Stack, 27 | Switch, 28 | Text, 29 | } from "@chakra-ui/react"; 30 | import type { 31 | StacCollection, 32 | StacItem, 33 | StacLink, 34 | TemporalExtent, 35 | } from "stac-ts"; 36 | import * as stac_wasm from "stac-wasm"; 37 | import useStacSearch from "../../hooks/stac-search"; 38 | import type { BBox2D } from "../../types/map"; 39 | import type { StacSearch } from "../../types/stac"; 40 | import { SpatialExtent } from "../extent"; 41 | import Section from "../section"; 42 | 43 | interface ItemSearchProps { 44 | search: StacSearch | undefined; 45 | setSearch: (search: StacSearch | undefined) => void; 46 | links: StacLink[]; 47 | bbox: BBox2D | undefined; 48 | collection: StacCollection; 49 | setItems: (items: StacItem[] | undefined) => void; 50 | } 51 | 52 | export default function ItemSearchSection({ ...props }: ItemSearchProps) { 53 | return ( 54 |
55 | 56 |
57 | ); 58 | } 59 | 60 | function ItemSearch({ 61 | search, 62 | setSearch, 63 | links, 64 | bbox, 65 | collection, 66 | setItems, 67 | }: { 68 | search: StacSearch | undefined; 69 | setSearch: (search: StacSearch | undefined) => void; 70 | links: StacLink[]; 71 | bbox: BBox2D | undefined; 72 | collection: StacCollection; 73 | setItems: (items: StacItem[] | undefined) => void; 74 | }) { 75 | // We trust that there's at least one link. 76 | const [link, setLink] = useState(links[0]); 77 | const [useViewportBounds, setUseViewportBounds] = useState(true); 78 | const [datetime, setDatetime] = useState( 79 | search?.datetime 80 | ); 81 | const methods = createListCollection({ 82 | items: links.map((link) => { 83 | return { 84 | label: (link.method as string) || "GET", 85 | value: (link.method as string) || "GET", 86 | }; 87 | }), 88 | }); 89 | 90 | return ( 91 | 92 | 93 | setUseViewportBounds(e.checked)} 97 | size={"sm"} 98 | > 99 | 100 | Use viewport bounds 101 | 102 | 103 | {bbox && useViewportBounds && ( 104 | 105 | 106 | 107 | )} 108 | 109 | 110 | 114 | 115 | 116 | { 120 | const link = links.find( 121 | (link) => (link.method || "GET") == e.value 122 | ); 123 | if (link) setLink(link); 124 | }} 125 | maxW={100} 126 | > 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | {methods.items.map((method) => ( 140 | 141 | {method.label} 142 | 143 | 144 | ))} 145 | 146 | 147 | 148 | 149 | 150 | 164 | 165 | 166 | {search && ( 167 | setSearch(undefined)} 171 | setItems={setItems} 172 | /> 173 | )} 174 | 175 | ); 176 | } 177 | 178 | function Search({ 179 | search, 180 | link, 181 | onClear, 182 | setItems, 183 | }: { 184 | search: StacSearch; 185 | link: StacLink; 186 | onClear: () => void; 187 | setItems: (items: StacItem[] | undefined) => void; 188 | }) { 189 | const result = useStacSearch(search, link); 190 | const numberMatched = result.data?.pages[0]?.numberMatched; 191 | const items = useMemo(() => { 192 | return result.data?.pages.flatMap((page) => page.features); 193 | }, [result.data]); 194 | const [autoFetch, setAutoFetch] = useState(false); 195 | 196 | useEffect(() => { 197 | if (autoFetch && !result.isFetching && result.hasNextPage) 198 | result.fetchNextPage(); 199 | }, [result, autoFetch]); 200 | 201 | useEffect(() => { 202 | setItems(items); 203 | }, [items, setItems]); 204 | 205 | const downloadJson = () => { 206 | return JSON.stringify( 207 | items ? { type: "FeatureCollection", features: items } : {} 208 | ); 209 | }; 210 | 211 | const downloadStacGeoparquet = () => { 212 | return new Blob( 213 | items ? [stac_wasm.stacJsonToParquet(items) as BlobPart] : [] 214 | ); 215 | }; 216 | 217 | return ( 218 | 219 | Search results 220 | {(numberMatched && ( 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | )) || 230 | (items && {items.length} item(s) fetched)} 231 | {result.error && ( 232 | 233 | 234 | 235 | Error while searching 236 | {result.error.toString()} 237 | 238 | 239 | )} 240 | 241 | 251 | 267 | 268 | 271 | 272 | {items && items.length > 0 && ( 273 | <> 274 | Download 275 | 276 | 282 | 285 | 286 | 292 | 295 | 296 | 297 | 298 | )} 299 | 300 | ); 301 | } 302 | 303 | function Datetime({ 304 | interval, 305 | setDatetime, 306 | }: { 307 | interval: TemporalExtent | undefined; 308 | setDatetime: (datetime: string | undefined) => void; 309 | }) { 310 | const [startDatetime, setStartDatetime] = useState( 311 | interval?.[0] ? new Date(interval[0]) : undefined 312 | ); 313 | const [endDatetime, setEndDatetime] = useState( 314 | interval?.[1] ? new Date(interval[1]) : undefined 315 | ); 316 | 317 | useEffect(() => { 318 | if (startDatetime || endDatetime) { 319 | setDatetime( 320 | `${startDatetime?.toISOString() || ".."}/${endDatetime?.toISOString() || ".."}` 321 | ); 322 | } else { 323 | setDatetime(undefined); 324 | } 325 | }, [startDatetime, endDatetime, setDatetime]); 326 | 327 | return ( 328 | 329 | 334 | 339 | 349 | 350 | ); 351 | } 352 | 353 | function DatetimeInput({ 354 | label, 355 | datetime, 356 | setDatetime, 357 | }: { 358 | label: string; 359 | datetime: Date | undefined; 360 | setDatetime: (datetime: Date | undefined) => void; 361 | }) { 362 | const [error, setError] = useState(); 363 | const dateValue = datetime?.toISOString().split("T")[0] || ""; 364 | const timeValue = datetime?.toISOString().split("T")[1].slice(0, 8) || ""; 365 | 366 | const setDatetimeChecked = (datetime: Date) => { 367 | try { 368 | datetime.toISOString(); 369 | // eslint-disable-next-line 370 | } catch (e: any) { 371 | setError(e.toString()); 372 | return; 373 | } 374 | setDatetime(datetime); 375 | setError(undefined); 376 | }; 377 | const setDate = (date: string) => { 378 | setDatetimeChecked( 379 | new Date(date + "T" + (timeValue == "" ? "00:00:00" : timeValue) + "Z") 380 | ); 381 | }; 382 | const setTime = (time: string) => { 383 | if (dateValue != "") { 384 | const newDatetime = new Date(dateValue); 385 | const timeParts = time.split(":").map(Number); 386 | newDatetime.setUTCHours(timeParts[0]); 387 | newDatetime.setUTCMinutes(timeParts[1]); 388 | if (timeParts.length == 3) { 389 | newDatetime.setUTCSeconds(timeParts[2]); 390 | } 391 | setDatetimeChecked(newDatetime); 392 | } 393 | }; 394 | 395 | return ( 396 | 397 | {label} 398 | 399 | setDate(e.target.value)} 403 | size={"sm"} 404 | > 405 | setTime(e.target.value)} 409 | size={"sm"} 410 | > 411 | setDatetime(undefined)} 415 | > 416 | 417 | 418 | 419 | {error} 420 | 421 | ); 422 | } 423 | --------------------------------------------------------------------------------