diff --git a/package-lock.json b/package-lock.json index bee02b5..608d1d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,9 @@ "": { "name": "map-explorer", "version": "1.0.1", + "license": "MIT", "dependencies": { + "@geomatico/maplibre-cog-protocol": "^0.9.0", "maplibre-gl": "^4.7.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1082,6 +1084,21 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@geomatico/maplibre-cog-protocol": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@geomatico/maplibre-cog-protocol/-/maplibre-cog-protocol-0.9.0.tgz", + "integrity": "sha512-PHenAjgSm5fJHtk/lff76w5m66drHz8H+1LjeNk4nT72EqjKeWBCKHvKLdACY4a1ay8BrhaB2NkfFNTUVrV84g==", + "license": "MIT", + "dependencies": { + "@mapbox/sphericalmercator": "^2.0.2", + "d3-scale": "^4.0.2", + "geotiff": "^3.0.5", + "quick-lru": "^7.1.0" + }, + "peerDependencies": { + "maplibre-gl": "^4.5.0 || ^5.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1225,6 +1242,11 @@ "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", "license": "ISC" }, + "node_modules/@mapbox/sphericalmercator": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-2.0.2.tgz", + "integrity": "sha512-Ai/S9qXupBzFCM0i4sYDIx1A0pDYMaD3xlhVg05JToYeyORq8aAuHbd6CCPC86HlLSjYmoPeljJ5tMVKtzd4Lg==" + }, "node_modules/@mapbox/tiny-sdf": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", @@ -1281,6 +1303,12 @@ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", "license": "ISC" }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2981,6 +3009,88 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -3662,6 +3772,37 @@ "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==", "license": "ISC" }, + "node_modules/geotiff": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", + "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.9.3", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.5.0", + "xml-utils": "^1.10.2", + "zstddec": "^0.2.0" + }, + "engines": { + "node": ">=10.19" + } + }, + "node_modules/geotiff/node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3970,6 +4111,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -4198,6 +4348,12 @@ "node": ">=0.10.0" } }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", + "license": "Apache-2.0" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4761,6 +4917,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4774,6 +4936,12 @@ "node": ">=6" } }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -4934,6 +5102,18 @@ "node": ">=6" } }, + "node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/quickselect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", @@ -5803,6 +5983,12 @@ "node": ">=18" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -5925,6 +6111,12 @@ "node": ">=18" } }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", + "license": "CC0-1.0" + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -5951,6 +6143,12 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", + "license": "MIT AND BSD-3-Clause" } } } diff --git a/package.json b/package.json index 2f0f847..18f23e7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "map-explorer", "private": true, - "version": "1.0.1", + "version": "1.1.0", "type": "module", "description": "Search portal for ESA data via STAC (Copernicus Data Space Ecosystem)", "license": "MIT", @@ -20,6 +20,7 @@ "test:watch": "vitest" }, "dependencies": { + "@geomatico/maplibre-cog-protocol": "^0.9.0", "maplibre-gl": "^4.7.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index 7b94ba9..9439c2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import FilterPanel, { EMPTY_FORM, type FilterForm } from './components/FilterPan import MapView from './components/MapView' import ResultList from './components/ResultList' import ItemDetail from './components/ItemDetail' +import CodeModal from './components/CodeModal' import { useCollections } from './hooks/useCollections' import { useStacSearch } from './hooks/useStacSearch' import { useSavedSearches } from './hooks/useSavedSearches' @@ -47,6 +48,7 @@ export default function App() { const [selectedIds, setSelectedIds] = useState>(new Set()) const [hoveredId, setHoveredId] = useState(null) const [detailItem, setDetailItem] = useState(null) + const [codeOpen, setCodeOpen] = useState(false) const [drawing, setDrawing] = useState(false) // Collapsible side panels. @@ -168,10 +170,13 @@ export default function App() { onLoadMore={stac.loadMore} onClose={() => setResultsOpen(false)} onClearSelection={clearSelection} + onShowCode={() => setCodeOpen(true)} + canShowCode={stac.lastParams != null} /> {detailItem && setDetailItem(null)} />} + {codeOpen && setCodeOpen(false)} />} ) } diff --git a/src/components/CodeModal.tsx b/src/components/CodeModal.tsx new file mode 100644 index 0000000..804592f --- /dev/null +++ b/src/components/CodeModal.tsx @@ -0,0 +1,90 @@ +import { useMemo, useState } from 'react' +import type { SearchParams } from '../types/stac' +import { toCurl, toPython } from '../utils/codegen' +import { stamp, triggerDownload } from '../utils/export' + +interface Props { + params: SearchParams | null + onClose: () => void +} + +type Tab = 'python' | 'curl' + +const TABS: { key: Tab; label: string; lang: string; file: (s: string) => string; mime: string }[] = [ + { key: 'python', label: 'Python (pystac-client)', lang: 'python', file: (s) => `stac-search-${s}.py`, mime: 'text/x-python' }, + { key: 'curl', label: 'curl', lang: 'bash', file: (s) => `stac-search-${s}.sh`, mime: 'text/x-shellscript' }, +] + +export default function CodeModal({ params, onClose }: Props) { + const [tab, setTab] = useState('python') + const [copied, setCopied] = useState(false) + + // Generate both snippets once per params change. + const snippets = useMemo>( + () => ({ + python: params ? toPython(params) : '', + curl: params ? toCurl(params) : '', + }), + [params], + ) + + const active = TABS.find((t) => t.key === tab)! + const code = snippets[tab] + + const copy = async () => { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + /* clipboard unavailable (e.g. insecure context) */ + } + } + + const tabBtn = (t: Tab) => + `px-2.5 py-1 text-xs ${tab === t ? 'border-accent text-accent' : ''}` + + return ( +
+
e.stopPropagation()} + > + +

Reproduce this search in code

+

+ Generated from the last search you ran, against{' '} + stac.dataspace.copernicus.eu. +

+ +
+ {TABS.map((t) => ( + + ))} + + + +
+ + {params ? ( +
+            {code}
+          
+ ) : ( +
+ Run a search first to generate code. +
+ )} +
+
+ ) +} diff --git a/src/components/ItemDetail.helpers.test.ts b/src/components/ItemDetail.helpers.test.ts new file mode 100644 index 0000000..594dda8 --- /dev/null +++ b/src/components/ItemDetail.helpers.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { cogAssets, itemTime } from './ItemDetail' +import type { StacItem } from '../types/stac' + +function item(partial: Partial): StacItem { + return { + type: 'Feature', + id: 'X', + geometry: null, + properties: {}, + assets: {}, + ...partial, + } +} + +describe('itemTime', () => { + it('parses properties.datetime to epoch ms', () => { + const t = itemTime(item({ properties: { datetime: '2024-01-01T00:00:00Z' } })) + expect(t).toBe(Date.parse('2024-01-01T00:00:00Z')) + }) + + it('falls back to start_datetime', () => { + const t = itemTime(item({ properties: { datetime: null, start_datetime: '2023-06-15T12:00:00Z' } })) + expect(t).toBe(Date.parse('2023-06-15T12:00:00Z')) + }) + + it('returns null when there is no usable date', () => { + expect(itemTime(item({ properties: {} }))).toBeNull() + expect(itemTime(item({ properties: { datetime: 'not-a-date' } }))).toBeNull() + }) +}) + +describe('cogAssets', () => { + it('includes http(s) GeoTIFF assets and excludes thumbnails / non-http', () => { + const it = item({ + assets: { + thumbnail: { href: 'https://example.com/preview.png', type: 'image/png' }, + visual: { href: 'https://example.com/visual.tif', type: 'image/tiff; application=geotiff', title: 'Visual' }, + s3data: { href: 's3://bucket/data.tif', type: 'image/tiff' }, + meta: { href: 'https://example.com/meta.json', type: 'application/json' }, + }, + }) + const cogs = cogAssets(it) + expect(cogs.map((c) => c.key)).toEqual(['visual']) + expect(cogs[0]).toMatchObject({ href: 'https://example.com/visual.tif', title: 'Visual' }) + }) + + it('includes a renderable-role asset with a .tif href even without a type', () => { + const it = item({ + assets: { + data: { href: 'https://example.com/B04.tif', roles: ['data'] }, + other: { href: 'https://example.com/B04.bin', roles: ['data'] }, + }, + }) + expect(cogAssets(it).map((c) => c.key)).toEqual(['data']) + }) + + it('returns an empty list when there are no GeoTIFF assets', () => { + expect(cogAssets(item({ assets: { thumbnail: { href: 'https://x/y.png' } } }))).toEqual([]) + }) +}) diff --git a/src/components/ItemDetail.tsx b/src/components/ItemDetail.tsx index 8d3b5bf..148fd08 100644 --- a/src/components/ItemDetail.tsx +++ b/src/components/ItemDetail.tsx @@ -21,6 +21,32 @@ export function itemDatetime(item: StacItem): string { ) } +/** The item's acquisition time as epoch ms, or null if unparseable. */ +export function itemTime(item: StacItem): number | null { + const raw = (item.properties.datetime as string) || (item.properties['start_datetime'] as string) + if (!raw) return null + const t = Date.parse(raw) + return Number.isNaN(t) ? null : t +} + +/** + * GeoTIFF/COG assets that can be tiled client-side by the cog:// protocol. + * Filters to http(s) assets whose type looks like a (Cloud-Optimized) GeoTIFF or + * whose roles mark them as renderable imagery. Excludes s3://-style hrefs the + * browser can't fetch. + */ +export function cogAssets(item: StacItem): { key: string; href: string; title: string }[] { + return Object.entries(item.assets || {}) + .filter(([, a]) => { + if (!a.href || !/^https?:/i.test(a.href)) return false + const type = (a.type || '').toLowerCase() + const isTiff = /tiff?|geotiff|cog/.test(type) + const renderableRole = a.roles?.some((r) => ['visual', 'data', 'overview'].includes(r)) + return isTiff || (renderableRole && /tif/i.test(a.href)) + }) + .map(([key, a]) => ({ key, href: a.href, title: a.title || key })) +} + interface Props { item: StacItem onClose: () => void diff --git a/src/components/MapView.test.tsx b/src/components/MapView.test.tsx index 4319aa4..55a73f6 100644 --- a/src/components/MapView.test.tsx +++ b/src/components/MapView.test.tsx @@ -43,6 +43,11 @@ vi.mock('react-map-gl/maplibre', async () => { return { default: Map, Source, Layer } }) +// The real maplibre-gl touches WebGL / web workers at import time (and +// URL.createObjectURL, which jsdom lacks). MapView only needs addProtocol. +vi.mock('maplibre-gl', () => ({ default: { addProtocol: vi.fn() } })) +vi.mock('@geomatico/maplibre-cog-protocol', () => ({ cogProtocol: vi.fn() })) + // Imported after the mock is registered (vi.mock is hoisted regardless). import MapView from './MapView' import type { StacItem, BBox } from '../types/stac' @@ -292,6 +297,32 @@ describe('MapView bbox preview source', () => { }) }) +// --------------------------------------------------------------------------- +// Base-map switcher + timeline +// --------------------------------------------------------------------------- +describe('MapView base map + timeline', () => { + it('renders the base-map switcher with the available styles', () => { + renderMapView() + expect(screen.getByLabelText('Base')).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Streets' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Satellite' })).toBeInTheDocument() + }) + + it('toggles the timeline control and filters footprints by acquisition date', () => { + // opticalItem @ 2024-01-01, sarItem @ 2024-01-02 -> two distinct stops. + renderMapView() + fireEvent.click(screen.getByRole('button', { name: /Timeline off/i })) + + // The slider appears and starts with all items visible. + expect(screen.getByRole('slider')).toBeInTheDocument() + expect(geojson('source-footprints').features).toHaveLength(2) + + // Stepping back to the earliest stop leaves only the earliest item. + fireEvent.click(screen.getByRole('button', { name: '◀' })) + expect(geojson('source-footprints').features).toHaveLength(1) + }) +}) + describe('MapView quicklook overlays', () => { it('renders no overlay when nothing is selected', () => { renderMapView() diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 77d5b64..f731efd 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -1,24 +1,69 @@ -import { useCallback, useMemo, useState } from 'react' -import Map, { Layer, Source, type MapLayerMouseEvent } from 'react-map-gl/maplibre' -import type { StyleSpecification } from 'maplibre-gl' +import { useCallback, useEffect, useMemo, useState } from 'react' +import Map, { Layer, Source, type ErrorEvent, type MapLayerMouseEvent } from 'react-map-gl/maplibre' +import maplibregl, { type StyleSpecification } from 'maplibre-gl' +import { cogProtocol } from '@geomatico/maplibre-cog-protocol' import type { BBox, StacItem } from '../types/stac' import { bboxToPolygon, cornersToBBox, overlayCoordinates, type ImageCorners } from '../utils/bbox' -import { thumbnailHref } from './ItemDetail' - -// Base raster map style (OSM), no token or paid services required. -const BASE_STYLE: StyleSpecification = { - version: 8, - sources: { - osm: { - type: 'raster', - tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'], - tileSize: 256, - attribution: '© OpenStreetMap contributors', - }, - }, - layers: [{ id: 'osm', type: 'raster', source: 'osm' }], +import { cogAssets, thumbnailHref } from './ItemDetail' +import TimeSlider from './TimeSlider' + +// Register the cog:// protocol once so raster sources can stream Cloud-Optimized +// GeoTIFFs client-side (geotiff.js). Guarded for HMR / repeated imports. +let cogRegistered = false +if (!cogRegistered) { + cogRegistered = true + try { + maplibregl.addProtocol('cog', cogProtocol) + } catch { + /* already registered */ + } +} + +// Builds a single-raster map style (no token or paid services required). +function rasterStyle(id: string, tiles: string[], attribution: string): StyleSpecification { + return { + version: 8, + sources: { [id]: { type: 'raster', tiles, tileSize: 256, attribution } }, + layers: [{ id, type: 'raster', source: id }], + } } +// Selectable base maps, all key-free. +const BASE_MAPS: { key: string; label: string; style: StyleSpecification }[] = [ + { + key: 'streets', + label: 'Streets', + style: rasterStyle('osm', ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'], '© OpenStreetMap contributors'), + }, + { + key: 'satellite', + label: 'Satellite', + style: rasterStyle( + 'esri', + ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], + 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics', + ), + }, + { + key: 'light', + label: 'Light', + style: rasterStyle( + 'carto-light', + ['a', 'b', 'c'].map((s) => `https://${s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png`), + '© OpenStreetMap contributors © CARTO', + ), + }, + { + key: 'dark', + label: 'Dark', + style: rasterStyle( + 'carto-dark', + ['a', 'b', 'c'].map((s) => `https://${s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png`), + '© OpenStreetMap contributors © CARTO', + ), + }, +] + interface Props { items: StacItem[] bbox?: BBox @@ -80,11 +125,66 @@ export default function MapView({ // Whether to overlay the selected items' quicklooks on the map. const [showImagery, setShowImagery] = useState(true) + // Selected base map. + const [baseMapKey, setBaseMapKey] = useState(BASE_MAPS[0].key) + const baseStyle = useMemo( + () => (BASE_MAPS.find((b) => b.key === baseMapKey) ?? BASE_MAPS[0]).style, + [baseMapKey], + ) + + // Timeline animation: when on, footprints/overlays are restricted to the ids + // acquired up to the current slider position (owned by TimeSlider). + const [timeline, setTimeline] = useState(false) + const [timeWindowIds, setTimeWindowIds] = useState | null>(null) + useEffect(() => { + if (!timeline) setTimeWindowIds(null) + }, [timeline]) + + // Items visible given the (optional) time window. + const visibleItems = useMemo( + () => (timeline && timeWindowIds ? items.filter((it) => timeWindowIds.has(it.id)) : items), + [items, timeline, timeWindowIds], + ) + + // Full-res COG overlay: only offered when exactly one item is selected. + const singleSelectedId = selectedIds.size === 1 ? [...selectedIds][0] : null + const singleSelected = useMemo( + () => (singleSelectedId ? items.find((it) => it.id === singleSelectedId) : undefined), + [singleSelectedId, items], + ) + const cogs = useMemo(() => (singleSelected ? cogAssets(singleSelected) : []), [singleSelected]) + const [cogAssetKey, setCogAssetKey] = useState(null) + const [cogError, setCogError] = useState(false) + // Reset the COG choice when the selection changes or imagery is toggled off. + useEffect(() => { + setCogAssetKey(null) + setCogError(false) + }, [singleSelectedId, showImagery]) + + const activeCogHref = useMemo(() => { + if (!showImagery || !cogAssetKey || cogError) return null + return cogs.find((c) => c.key === cogAssetKey)?.href ?? null + }, [showImagery, cogAssetKey, cogError, cogs]) + + const handleMapError = useCallback( + (e: ErrorEvent) => { + if (!activeCogHref) return + const srcId = (e as unknown as { sourceId?: string }).sourceId + const msg = e.error?.message ?? '' + // The cog:// load failed (commonly auth/CORS) — fall back to the quicklook. + if (srcId === 'cog-overlay' || /cog:|geotiff|tiff/i.test(msg) || msg.includes(activeCogHref)) { + setCogError(true) + setCogAssetKey(null) + } + }, + [activeCogHref], + ) + // Georeferenced quicklook overlays, one per selected item (with a thumbnail - // and a usable footprint/bbox). + // and a usable footprint/bbox). Suppressed while a COG overlay is active. const overlays = useMemo(() => { - if (!showImagery || selectedIds.size === 0) return [] - return items + if (!showImagery || selectedIds.size === 0 || activeCogHref) return [] + return visibleItems .filter((it) => selectedIds.has(it.id)) .map((it) => { const href = thumbnailHref(it) @@ -93,13 +193,13 @@ export default function MapView({ return { id: it.id, url: thumbForMap(href), coordinates: coords } }) .filter(Boolean) as { id: string; url: string; coordinates: ImageCorners }[] - }, [showImagery, selectedIds, items]) + }, [showImagery, selectedIds, visibleItems, activeCogHref]) // FeatureCollection of the result footprints. const footprints = useMemo( () => ({ type: 'FeatureCollection' as const, - features: items + features: visibleItems .filter((it) => it.geometry) .map((it) => ({ type: 'Feature' as const, @@ -108,7 +208,7 @@ export default function MapView({ geometry: it.geometry as GeoJSON.Geometry, })), }), - [items, selectedIds, hoveredId], + [visibleItems, selectedIds, hoveredId], ) // Bbox preview: either confirmed or while drawing. @@ -184,19 +284,68 @@ export default function MapView({ > 🛰 Imagery {showImagery ? 'on' : 'off'} + + {showImagery && cogs.length > 0 && ( + + )} + {cogError && ( + + Couldn't load full-res COG (likely auth/CORS); showing preview. + + )} )} +
+ + +
+ + {timeline && } + + {activeCogHref && ( + + + + )} + {overlays.map((ov) => ( diff --git a/src/components/ResultList.tsx b/src/components/ResultList.tsx index 95437c7..acdd11a 100644 --- a/src/components/ResultList.tsx +++ b/src/components/ResultList.tsx @@ -16,6 +16,8 @@ interface Props { onLoadMore: () => void onClose: () => void onClearSelection: () => void + onShowCode: () => void + canShowCode: boolean } function ResultRow({ @@ -98,6 +100,8 @@ export default function ResultList({ onLoadMore, onClose, onClearSelection, + onShowCode, + canShowCode, }: Props) { return ( <> @@ -130,6 +134,13 @@ export default function ResultList({ > CSV + diff --git a/src/components/TimeSlider.tsx b/src/components/TimeSlider.tsx new file mode 100644 index 0000000..5de8ae9 --- /dev/null +++ b/src/components/TimeSlider.tsx @@ -0,0 +1,121 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { StacItem } from '../types/stac' +import { itemTime } from './ItemDetail' + +interface Props { + items: StacItem[] + // Reports the ids visible at the current time position (cumulative up to the + // slider). MapView filters footprints/overlays to this set. + onWindowChange: (ids: Set | null) => void +} + +const STEP_MS = 600 + +function fmt(t: number): string { + return new Date(t).toISOString().slice(0, 10) +} + +export default function TimeSlider({ items, onWindowChange }: Props) { + // Timed items sorted ascending; the slider stops on each acquisition time so + // every step reveals at least one new item. + const timed = useMemo(() => { + return items + .map((it) => ({ id: it.id, t: itemTime(it) })) + .filter((x): x is { id: string; t: number } => x.t != null) + .sort((a, b) => a.t - b.t) + }, [items]) + + const stops = useMemo(() => [...new Set(timed.map((x) => x.t))], [timed]) + const last = Math.max(0, stops.length - 1) + + const [idx, setIdx] = useState(last) + const [playing, setPlaying] = useState(false) + const timer = useRef | null>(null) + + // Reset to "all visible" whenever the result set changes. + useEffect(() => { + setIdx(last) + setPlaying(false) + }, [last]) + + // Active ids = everything acquired at or before the current stop (coverage builds up). + const activeIds = useMemo(() => { + if (stops.length === 0) return null + const cutoff = stops[Math.min(idx, last)] + return new Set(timed.filter((x) => x.t <= cutoff).map((x) => x.id)) + }, [timed, stops, idx, last]) + + useEffect(() => { + onWindowChange(activeIds) + }, [activeIds, onWindowChange]) + + // Play loop: advance one stop per tick, stop at the end. + useEffect(() => { + if (!playing) return + timer.current = setInterval(() => { + setIdx((i) => { + if (i >= last) { + setPlaying(false) + return i + } + return i + 1 + }) + }, STEP_MS) + return () => { + if (timer.current) clearInterval(timer.current) + } + }, [playing, last]) + + const togglePlay = () => { + if (!playing && idx >= last) setIdx(0) // restart from the beginning + setPlaying((p) => !p) + } + + if (stops.length < 2) { + return ( +
+ Not enough dated results to animate. +
+ ) + } + + return ( +
+ + + + { + setPlaying(false) + setIdx(Number(e.target.value)) + }} + /> + + {fmt(stops[idx])} · {activeIds?.size ?? 0}/{timed.length} + +
+ ) +} diff --git a/src/config.ts b/src/config.ts index 4df885a..d2f6f56 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,11 @@ export const STAC_BASE_URL = import.meta.env.DEV ? '/stac/v1' : 'https://stac.dataspace.copernicus.eu/v1' +// The real, absolute STAC endpoint. Used for generated code snippets (copy as +// code), which must be runnable outside the app — the dev proxy path (/stac/v1) +// would not resolve elsewhere. +export const STAC_PUBLIC_URL = 'https://stac.dataspace.copernicus.eu/v1' + // Number of results requested per page from the API. export const PAGE_LIMIT = 50 diff --git a/src/utils/codegen.test.ts b/src/utils/codegen.test.ts new file mode 100644 index 0000000..9b71d82 --- /dev/null +++ b/src/utils/codegen.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { toPython, toCurl } from './codegen' +import type { SearchParams } from '../types/stac' + +const full: SearchParams = { + collections: ['sentinel-2-l2a'], + bbox: [5, 50, 10, 55], + dateFrom: '2024-01-01T00:00:00Z', + dateTo: '2024-12-31T23:59:59Z', + maxCloudCover: 20, + limit: 50, + sortby: { field: 'datetime', direction: 'desc' }, +} + +const minimal: SearchParams = { + collections: ['sentinel-1-grd'], + limit: 50, +} + +describe('toPython', () => { + it('emits a pystac-client snippet with every active filter', () => { + const py = toPython(full) + expect(py).toContain('from pystac_client import Client') + expect(py).toContain('Client.open("https://stac.dataspace.copernicus.eu/v1")') + expect(py).toContain('collections=[') + expect(py).toContain('"sentinel-2-l2a"') + expect(py).toContain('bbox=[') + expect(py).toContain('datetime="2024-01-01T00:00:00Z/2024-12-31T23:59:59Z"') + expect(py).toContain('filter_lang="cql2-json"') + expect(py).toContain('"eo:cloud_cover"') + expect(py).toContain('max_items=50') + }) + + it('uses the public endpoint, never the dev proxy path', () => { + expect(toPython(full)).not.toContain('/stac/v1') + }) + + it('omits kwargs that are not set', () => { + const py = toPython(minimal) + expect(py).toContain('collections=[') + expect(py).toContain('max_items=50') + expect(py).not.toContain('bbox=') + expect(py).not.toContain('datetime=') + expect(py).not.toContain('filter_lang') + }) +}) + +describe('toCurl', () => { + it('POSTs the exact search body to /search', () => { + const curl = toCurl(full) + expect(curl).toContain('curl -X POST "https://stac.dataspace.copernicus.eu/v1/search"') + expect(curl).toContain('"Content-Type: application/json"') + // The body is valid JSON and round-trips. + const body = curl.slice(curl.indexOf("-d '") + 4, curl.lastIndexOf("'")) + const parsed = JSON.parse(body) + expect(parsed.collections).toEqual(['sentinel-2-l2a']) + expect(parsed.bbox).toEqual([5, 50, 10, 55]) + expect(parsed.datetime).toBe('2024-01-01T00:00:00Z/2024-12-31T23:59:59Z') + expect(parsed['filter-lang']).toBe('cql2-json') + }) +}) diff --git a/src/utils/codegen.ts b/src/utils/codegen.ts new file mode 100644 index 0000000..8a8323d --- /dev/null +++ b/src/utils/codegen.ts @@ -0,0 +1,68 @@ +// Generates runnable code snippets that reproduce the current STAC search. +// Both helpers reuse buildSearchBody() so the snippets always match the exact +// request the app sends. Snippets target the public STAC endpoint (STAC_PUBLIC_URL), +// not the dev proxy, so they run anywhere. +import type { SearchParams } from '../types/stac' +import { buildSearchBody } from '../api/stac' +import { STAC_PUBLIC_URL } from '../config' + +// Renders a JS value as a Python literal (dict / list / str / int / float / bool / None). +function toPy(value: unknown, indent = 1): string { + const pad = ' '.repeat(indent) + const padIn = ' '.repeat(indent + 1) + if (value === null || value === undefined) return 'None' + if (typeof value === 'boolean') return value ? 'True' : 'False' + if (typeof value === 'number') return String(value) + if (typeof value === 'string') return JSON.stringify(value) + if (Array.isArray(value)) { + if (value.length === 0) return '[]' + const items = value.map((v) => padIn + toPy(v, indent + 1)) + return `[\n${items.join(',\n')}\n${pad}]` + } + if (typeof value === 'object') { + const entries = Object.entries(value as Record) + if (entries.length === 0) return '{}' + const items = entries.map(([k, v]) => `${padIn}${JSON.stringify(k)}: ${toPy(v, indent + 1)}`) + return `{\n${items.join(',\n')}\n${pad}}` + } + return 'None' +} + +/** A pystac-client (Python) snippet reproducing the current search. */ +export function toPython(params: SearchParams): string { + const body = buildSearchBody(params) + const kwargs: string[] = [] + if (Array.isArray(body.collections)) kwargs.push(`collections=${toPy(body.collections)}`) + if (Array.isArray(body.bbox)) kwargs.push(`bbox=${toPy(body.bbox)}`) + if (typeof body.datetime === 'string') kwargs.push(`datetime=${JSON.stringify(body.datetime)}`) + if (body.filter) { + kwargs.push(`filter=${toPy(body.filter)}`) + kwargs.push('filter_lang="cql2-json"') + } + if (Array.isArray(body.sortby)) kwargs.push(`sortby=${toPy(body.sortby)}`) + if (typeof body.limit === 'number') kwargs.push(`max_items=${body.limit}`) + + const args = kwargs.map((k) => ` ${k},`).join('\n') + return `# pip install pystac-client +from pystac_client import Client + +client = Client.open(${JSON.stringify(STAC_PUBLIC_URL)}) +search = client.search( +${args} +) +items = list(search.items()) +print(f"{len(items)} item(s) found") +` +} + +/** A curl command POSTing the exact search body to /search. */ +export function toCurl(params: SearchParams): string { + const body = buildSearchBody(params) + const json = JSON.stringify(body, null, 2) + // Wrap the body in single quotes for the shell; escape any embedded single quote. + const escaped = json.replace(/'/g, `'\\''`) + return `curl -X POST "${STAC_PUBLIC_URL}/search" \\ + -H "Content-Type: application/json" \\ + -H "Accept: application/json" \\ + -d '${escaped}'` +} diff --git a/src/utils/export.ts b/src/utils/export.ts index b3dc957..a8acd28 100644 --- a/src/utils/export.ts +++ b/src/utils/export.ts @@ -1,6 +1,6 @@ import type { StacItem } from '../types/stac' -function triggerDownload(content: string, filename: string, mime: string) { +export function triggerDownload(content: string, filename: string, mime: string) { const blob = new Blob([content], { type: mime }) const url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -12,7 +12,7 @@ function triggerDownload(content: string, filename: string, mime: string) { URL.revokeObjectURL(url) } -function stamp(): string { +export function stamp(): string { // YYYYMMDD-HHMMSS return new Date().toISOString().replace(/[:T]/g, '-').replace(/\..+/, '') }