mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
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:
parent
3ce860c1e8
commit
771d7eb3b3
2 changed files with 88 additions and 2 deletions
18
src/api.js
18
src/api.js
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} />}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue