├── .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 |
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 |
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 |
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 |
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 |
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 |
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 | [](https://github.com/developmentseed/stac-map/actions/workflows/ci.yaml)
4 | [](https://github.com/developmentseed/stac-map/deployments/github-pages)
5 | [](https://github.com/developmentseed/stac-map/releases)
6 |
7 | The map-first, single-page, statically-hosted STAC visualizer at .
8 |
9 |
10 |
11 |
12 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------