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/"
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 && (