Add public land classification to PlaceDetail

Shows PAD-US land ownership data (unit name, agency hierarchy, access
status) when clicking on map areas. Upgrades "Dropped pin" name to
land unit summary on public land. Private land gets a muted indicator.

- api.js: fetchLandclass() for /api/landclass endpoint
- PlaceDetail.jsx: LandclassSection, PrivateLandIndicator components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-04-22 15:36:44 +00:00
commit 771d7eb3b3
2 changed files with 88 additions and 2 deletions

View file

@ -242,3 +242,21 @@ export async function fetchNearbyContacts(lat, lon, radiusM, signal) {
return [] return []
} }
} }
/**
* Fetch PAD-US land classification for a point.
* @param {number} lat
* @param {number} lon
* @param {AbortSignal} signal
* @returns {Promise<object|null>} Classification data or null on error
*/
export async function fetchLandclass(lat, lon, signal) {
try {
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) })
const resp = await fetch(`/api/landclass?${params}`, { signal })
if (!resp.ok) return null
return resp.json()
} catch {
return null
}
}

View file

@ -1,12 +1,12 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { import {
X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy, X, Navigation, Plus, Bookmark, ChevronDown, ChevronUp, Copy,
Clock, Phone, Globe, Mail, BookOpen, Info, Clock, Phone, Globe, Mail, BookOpen, Info, Trees,
} from 'lucide-react' } from 'lucide-react'
import OpeningHours from 'opening_hours' import OpeningHours from 'opening_hours'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useStore } from '../store' import { useStore } from '../store'
import { fetchElevation, fetchPlaceDetails, fetchDriveTime, fetchNearbyContacts } from '../api' import { fetchElevation, fetchPlaceDetails, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from '../api'
import { hasFeature } from '../config' import { hasFeature } from '../config'
import { buildAddress } from '../utils/place' import { buildAddress } from '../utils/place'
@ -354,6 +354,46 @@ function EnrichmentSections({ details }) {
// Skeleton loader // Skeleton loader
// Land classification display
function LandclassSection({ data }) {
if (!data || data.is_public !== true || !data.classifications?.length) return null
return (
<DetailSection label="Public Land" icon={Trees}>
<div className="flex flex-col gap-2">
{data.classifications.map((c, i) => (
<div key={i} className="flex flex-col gap-0.5">
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
{c.unit_name}
</span>
{(c.owner_type || c.manager_name || c.designation_type) && (
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{[c.owner_type, c.manager_name, c.designation_type].filter(Boolean).join(' \u203a ')}
</span>
)}
{c.public_access && c.public_access !== 'Unknown' && (
<span className="category-badge" style={{ fontSize: '10px', width: 'fit-content' }}>
{c.public_access}
</span>
)}
</div>
))}
</div>
</DetailSection>
)
}
function PrivateLandIndicator({ data }) {
if (!data || data.is_private !== true) return null
return (
<p className="mt-1 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
Private land
</p>
)
}
function EnrichmentSkeleton() { function EnrichmentSkeleton() {
return ( return (
<div className="mt-3 flex flex-col gap-2.5 animate-pulse"> <div className="mt-3 flex flex-col gap-2.5 animate-pulse">
@ -383,6 +423,7 @@ export default function PlaceDetail() {
const [placeDetails, setPlaceDetails] = useState(null) const [placeDetails, setPlaceDetails] = useState(null)
const [driveTime, setDriveTime] = useState(null) const [driveTime, setDriveTime] = useState(null)
const [nearbyLabel, setNearbyLabel] = useState(null) const [nearbyLabel, setNearbyLabel] = useState(null)
const [landclass, setLandclass] = useState(null)
const closeCopy = useCallback(() => setCopyOpen(false), []) const closeCopy = useCallback(() => setCopyOpen(false), [])
@ -481,6 +522,28 @@ export default function PlaceDetail() {
return () => controller.abort() return () => controller.abort()
}, [placeLat, placeLon]) }, [placeLat, placeLon])
// Fetch land classification when place changes (if feature enabled)
useEffect(() => {
if (!hasFeature('has_landclass') || placeLat == null || placeLon == null) {
setLandclass(null)
return
}
const controller = new AbortController()
fetchLandclass(placeLat, placeLon, controller.signal).then((data) => {
if (!controller.signal.aborted && data) {
setLandclass(data)
// Upgrade "Dropped pin" name to land summary if reverse geocode didn't resolve
if (data.summary && useStore.getState().selectedPlace?.name === 'Dropped pin') {
const current = useStore.getState().selectedPlace
useStore.getState().setSelectedPlace({ ...current, name: data.summary })
}
} else if (!controller.signal.aborted) {
setLandclass(null)
}
})
return () => controller.abort()
}, [placeLat, placeLon])
// Derive elevation/loading from comparing result to current place // Derive elevation/loading from comparing result to current place
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon) const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
const elevation = !elevLoading ? elevResult.value : null const elevation = !elevLoading ? elevResult.value : null
@ -598,6 +661,11 @@ export default function PlaceDetail() {
</span> </span>
</div> </div>
{/* OSM enrichment sections */}
{/* Land classification (PAD-US) */}
<LandclassSection data={landclass} />
<PrivateLandIndicator data={landclass} />
{/* OSM enrichment sections */} {/* OSM enrichment sections */}
{placeDetails === 'loading' && <EnrichmentSkeleton />} {placeDetails === 'loading' && <EnrichmentSkeleton />}
{placeDetails && placeDetails !== 'loading' && <EnrichmentSections details={placeDetails} />} {placeDetails && placeDetails !== 'loading' && <EnrichmentSections details={placeDetails} />}