From 9dfd505c967d8aff0a7ee44e02269bd676af5282 Mon Sep 17 00:00:00 2001 From: Raul Bardaji Date: Sun, 31 May 2026 02:10:07 -0600 Subject: [PATCH 1/2] feat(ui): render dataset spatial extent on a map and group metadata fields Expanded search result metadata previously showed the raw CKAN extras as a flat key: value list, with the spatial geometry dumped as a JSON string. - Add SpatialMap (react-leaflet + OpenStreetMap) to draw the spatial extent. - Add DatasetMetadata to give fields human-friendly labels, group harvest_* provenance into its own subsection, and fall back to plain text when the spatial value is not parseable geometry. - Extract pure geometry parsing/bounds into spatialGeometry for unit testing. Closes #180 --- ui/package-lock.json | 41 +++++- ui/package.json | 2 + ui/src/components/DatasetMetadata.js | 154 ++++++++++++++++++++++ ui/src/components/DatasetMetadata.test.js | 85 ++++++++++++ ui/src/components/SpatialMap.js | 57 ++++++++ ui/src/components/spatialGeometry.js | 65 +++++++++ ui/src/pages/Search.js | 24 +--- 7 files changed, 402 insertions(+), 26 deletions(-) create mode 100644 ui/src/components/DatasetMetadata.js create mode 100644 ui/src/components/DatasetMetadata.test.js create mode 100644 ui/src/components/SpatialMap.js create mode 100644 ui/src/components/spatialGeometry.js diff --git a/ui/package-lock.json b/ui/package-lock.json index 8a2422b..527a160 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,21 +1,23 @@ { - "name": "pop-api-frontend", - "version": "0.2.0", + "name": "ndp-ep-frontend", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pop-api-frontend", - "version": "0.2.0", + "name": "ndp-ep-frontend", + "version": "1.4.0", "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.10.0", + "leaflet": "^1.9.4", "lucide-react": "^0.517.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.6.2", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" @@ -3090,6 +3092,17 @@ } } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -11126,6 +11139,12 @@ "shell-quote": "^1.8.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -13970,6 +13989,20 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", diff --git a/ui/package.json b/ui/package.json index 4ab0cf2..1ce2a19 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,9 +9,11 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.10.0", + "leaflet": "^1.9.4", "lucide-react": "^0.517.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.6.2", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" diff --git a/ui/src/components/DatasetMetadata.js b/ui/src/components/DatasetMetadata.js new file mode 100644 index 0000000..4c7eca2 --- /dev/null +++ b/ui/src/components/DatasetMetadata.js @@ -0,0 +1,154 @@ +import React from 'react'; +import { MapPin } from 'lucide-react'; +import SpatialMap from './SpatialMap'; +import { parseGeometry } from './spatialGeometry'; + +// Renders a dataset's CKAN `extras` in a readable way: the spatial extent is +// drawn on a map, the remaining fields get human-friendly labels, and harvest +// provenance is split into its own muted subsection. All values present in +// `extras` are still surfaced — nothing is hidden. + +// Friendly labels for keys we know about. Anything not listed falls back to a +// humanized version of the raw key. +const LABELS = { + EPSG: 'EPSG', + collection: 'Collection', + data_vintage: 'Data vintage', + encoding: 'Encoding', + resolution: 'Resolution', + service_type: 'Service type', + harvest_object_id: 'Harvest object ID', + harvest_source_id: 'Harvest source ID', + harvest_source_title: 'Harvest source', + ndp_user_id: 'Owner ID', + status: 'Status' +}; + +const humanize = (key) => + key + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); + +const labelFor = (key) => LABELS[key] || humanize(key); + +const formatValue = (value) => + typeof value === 'object' && value !== null + ? JSON.stringify(value) + : String(value); + +// Long opaque values (UUIDs, hashes) read better in a monospace font that can +// wrap, so the rest of the grid stays aligned. +const isOpaque = (value) => + typeof value === 'string' && /^[0-9a-f-]{16,}$/i.test(value); + +const Field = ({ label, value }) => ( +
+
{label}
+
+ {formatValue(value)} +
+
+); + +const FieldGrid = ({ entries }) => ( +
+ {entries.map(([key, value]) => ( + + ))} +
+); + +const DatasetMetadata = ({ extras }) => { + const entries = Object.entries(extras || {}); + if (!entries.length) return null; + + const spatialEntry = entries.find(([key]) => key === 'spatial'); + const spatial = spatialEntry ? spatialEntry[1] : null; + const canMapSpatial = spatial != null && parseGeometry(spatial) != null; + + // Fields shown in the main grid: everything except spatial (handled by the + // map) and harvest provenance (its own subsection). If the spatial value + // cannot be drawn, keep it here as plain text so no information is lost. + const general = entries.filter(([key]) => { + if (key.startsWith('harvest_')) return false; + if (key === 'spatial') return !canMapSpatial; + return true; + }); + const harvest = entries.filter(([key]) => key.startsWith('harvest_')); + + return ( +
+ {canMapSpatial && ( +
+
+ + Spatial extent +
+ +
+ )} + + {general.length > 0 && } + + {harvest.length > 0 && ( +
+
+ Harvest provenance +
+ +
+ )} +
+ ); +}; + +export default DatasetMetadata; diff --git a/ui/src/components/DatasetMetadata.test.js b/ui/src/components/DatasetMetadata.test.js new file mode 100644 index 0000000..efcd3b8 --- /dev/null +++ b/ui/src/components/DatasetMetadata.test.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import DatasetMetadata from './DatasetMetadata'; +import { parseGeometry } from './spatialGeometry'; + +// SpatialMap pulls in Leaflet (ESM, DOM/canvas), which CRA's jest does not +// transform, so the renderer is mocked. The pure geometry logic lives in +// ./spatialGeometry and is imported directly. These tests cover the +// presentation logic: labels, grouping, and the spatial-extent fallback. +jest.mock('./SpatialMap', () => ({ + __esModule: true, + default: () =>
map
+})); + +const POLYGON = JSON.stringify({ + type: 'Polygon', + coordinates: [ + [ + [-124.6, 32.3], + [-113.7, 32.3], + [-113.7, 42.3], + [-124.6, 42.3], + [-124.6, 32.3] + ] + ] +}); + +describe('DatasetMetadata', () => { + it('renders the map and hides the raw spatial string when geometry parses', () => { + render(); + expect(screen.getByTestId('spatial-map')).toBeInTheDocument(); + expect(screen.queryByText('Spatial', { exact: false })).toBeInTheDocument(); + // The raw coordinate JSON must not appear as a plain field value. + expect(screen.queryByText(/coordinates/)).not.toBeInTheDocument(); + }); + + it('applies friendly labels to known keys', () => { + render(); + expect(screen.getByText('Data vintage')).toBeInTheDocument(); + expect(screen.getByText('EPSG')).toBeInTheDocument(); + }); + + it('groups harvest_* fields under a provenance subsection', () => { + render( + + ); + expect(screen.getByText('Harvest provenance')).toBeInTheDocument(); + expect(screen.getByText('Harvest source')).toBeInTheDocument(); + expect(screen.getByText('WIFIRE Commons')).toBeInTheDocument(); + }); + + it('keeps the spatial value as a plain field when it cannot be parsed', () => { + render(); + expect(screen.queryByTestId('spatial-map')).not.toBeInTheDocument(); + expect(screen.getByText('not-geojson')).toBeInTheDocument(); + }); + + it('renders nothing when there are no extras', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('parseGeometry', () => { + it('parses a JSON string polygon', () => { + expect(parseGeometry(POLYGON)).toMatchObject({ type: 'Polygon' }); + }); + + it('unwraps a Feature into its geometry', () => { + const feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 2] } }; + expect(parseGeometry(feature)).toMatchObject({ type: 'Point' }); + }); + + it('returns null for non-geometry input', () => { + expect(parseGeometry('nonsense')).toBeNull(); + expect(parseGeometry(null)).toBeNull(); + expect(parseGeometry({ foo: 'bar' })).toBeNull(); + }); +}); diff --git a/ui/src/components/SpatialMap.js b/ui/src/components/SpatialMap.js new file mode 100644 index 0000000..80e3ed8 --- /dev/null +++ b/ui/src/components/SpatialMap.js @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react'; +import { MapContainer, TileLayer, GeoJSON } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { parseGeometry, boundsFromGeometry } from './spatialGeometry'; + +// Renders the GeoJSON geometry stored in a dataset's `spatial` extra on an +// OpenStreetMap basemap. Returns null when the value cannot be parsed into a +// geometry, so the caller can fall back to plain text. + +const STYLE = { color: '#2563eb', weight: 2, fillColor: '#2563eb', fillOpacity: 0.15 }; + +// Draw point geometries as small circle markers; Leaflet's default marker +// icon relies on image assets that break under the CRA build, and a circle +// reads fine for an extent preview anyway. +const pointToLayer = (_feature, latlng) => + L.circleMarker(latlng, { radius: 6, ...STYLE, fillOpacity: 0.6 }); + +const SpatialMap = ({ spatial, height = 220 }) => { + const geometry = useMemo(() => parseGeometry(spatial), [spatial]); + const bounds = useMemo( + () => (geometry ? boundsFromGeometry(geometry) : null), + [geometry] + ); + + if (!geometry || !bounds) return null; + + // Leaflet bounds are [[south, west], [north, east]]. Pad a degenerate + // (single-point) box slightly so the map has something to fit. + const pad = bounds.minLat === bounds.maxLat ? 0.5 : 0; + const latLngBounds = [ + [bounds.minLat - pad, bounds.minLng - pad], + [bounds.maxLat + pad, bounds.maxLng + pad] + ]; + + return ( + + + + + ); +}; + +export default SpatialMap; diff --git a/ui/src/components/spatialGeometry.js b/ui/src/components/spatialGeometry.js new file mode 100644 index 0000000..c68fa81 --- /dev/null +++ b/ui/src/components/spatialGeometry.js @@ -0,0 +1,65 @@ +// Pure helpers for turning a dataset's `spatial` extra into a GeoJSON geometry +// and a bounding box. Kept free of Leaflet (and any DOM dependency) so the +// logic can be unit-tested and imported without pulling in the map renderer. + +// The `spatial` value can arrive as a GeoJSON object or as a JSON string, and +// may be a bare geometry, a Feature, or a FeatureCollection. Returns a usable +// geometry, or null if nothing parses. +export const parseGeometry = (spatial) => { + let value = spatial; + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + return null; + } + } + if (!value || typeof value !== 'object') return null; + if (value.type === 'Feature') return value.geometry || null; + if (value.type === 'FeatureCollection') { + const geometries = (value.features || []) + .map((f) => f && f.geometry) + .filter(Boolean); + if (!geometries.length) return null; + return { type: 'GeometryCollection', geometries }; + } + if (value.type === 'GeometryCollection' && value.geometries) return value; + if (value.type && value.coordinates) return value; + return null; +}; + +// Walk every coordinate pair in a geometry to build a [lng, lat] bounding box. +// Returns null when no finite coordinate is found. +export const boundsFromGeometry = (geometry) => { + const acc = { + minLng: Infinity, + maxLng: -Infinity, + minLat: Infinity, + maxLat: -Infinity + }; + + const collect = (geom) => { + if (!geom) return; + if (geom.type === 'GeometryCollection') { + (geom.geometries || []).forEach(collect); + return; + } + const walk = (coords) => { + if (!Array.isArray(coords)) return; + if (typeof coords[0] === 'number' && typeof coords[1] === 'number') { + const [lng, lat] = coords; + acc.minLng = Math.min(acc.minLng, lng); + acc.maxLng = Math.max(acc.maxLng, lng); + acc.minLat = Math.min(acc.minLat, lat); + acc.maxLat = Math.max(acc.maxLat, lat); + return; + } + coords.forEach(walk); + }; + walk(geom.coordinates); + }; + + collect(geometry); + if (!Number.isFinite(acc.minLat) || !Number.isFinite(acc.minLng)) return null; + return acc; +}; diff --git a/ui/src/pages/Search.js b/ui/src/pages/Search.js index 1b2c546..d35654e 100644 --- a/ui/src/pages/Search.js +++ b/ui/src/pages/Search.js @@ -18,6 +18,7 @@ import { resourcesAPI, generalDatasetAPI } from '../services/api'; +import DatasetMetadata from '../components/DatasetMetadata'; const MODES = [ { id: 'both', label: 'All' }, @@ -1102,28 +1103,7 @@ const ResultCard = ({ )} - {expanded && hasExtras && ( -
-
- {Object.entries(item.extras).map(([key, value]) => ( -
- {key}: - - {typeof value === 'object' ? JSON.stringify(value) : String(value)} - -
- ))} -
-
- )} + {expanded && hasExtras && } {pendingAction && ( Date: Sun, 31 May 2026 02:15:12 -0600 Subject: [PATCH 2/2] chore(release): bump version to 0.32.0 --- CHANGELOG.md | 11 +++++++++++ api/config/swagger_settings.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9823296..a07767d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.32.0] - 2026-05-31 + +### Added +- **Search results now show a dataset's spatial extent on a map.** When a result card is expanded, the `spatial` GeoJSON geometry is drawn on an OpenStreetMap basemap (via Leaflet) and the view is fitted to its bounding box, so the geographic area a dataset covers is visible at a glance instead of being shown as a raw coordinate string. + +### Changed +- **The expanded metadata section of a search result is easier to read.** CKAN `extras` fields now use human-friendly labels (e.g. `data_vintage` → "Data vintage"), harvest provenance (`harvest_*`) is grouped into its own subsection, and long opaque values such as UUIDs are shown in a monospace font. All fields previously shown are still surfaced; when a `spatial` value is not parseable geometry it falls back to plain text. + +### Backwards compatibility +- UI-only change. No API behavior, request/response shapes, or routes change. + ## [0.31.0] - 2026-05-26 ### Changed diff --git a/api/config/swagger_settings.py b/api/config/swagger_settings.py index 8388808..7eda9a9 100644 --- a/api/config/swagger_settings.py +++ b/api/config/swagger_settings.py @@ -12,7 +12,7 @@ class Settings(BaseSettings): swagger_title: str = "API Documentation" swagger_description: str = "This is the API documentation." - swagger_version: str = "0.31.0" + swagger_version: str = "0.32.0" root_path: str = "" # API root path prefix (e.g., "/test" or "") is_public: bool = True metrics_endpoint: str = "https://federation.ndp.utah.edu/metrics/"