/* ============================================================ Prospecting — app shell, routing, assessment flow, tweaks The backend (FastAPI on :8766) holds the authoritative site list and runs every assessor. This file just orchestrates UI state. ============================================================ */ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "density": "comfortable", "showConfidence": true, "clickThrough": true }/*EDITMODE-END*/; /* Grid overlay radius for the "show grid on map" toggle (metres). */ const PC_GRID_OVERLAY_RADIUS_M = 5000; const Rail = ({ view, onView, onNew }) => ( ); /* Toast is defined in ReportPanel.jsx so MapWorkspace can use it too (Babel script order is icons → ReportPanel → … → MapWorkspace → App). */ function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [view, setView] = React.useState('workspace'); const [sites, setSites] = React.useState([]); // backend-fetched list const [activeSite, setActiveSite] = React.useState(null); const [pinLocation, setPinLocation] = React.useState(null); // [lng, lat] | null const [gridOverlayOn, setGridOverlayOn] = React.useState(false); const [gridOverlay, setGridOverlay] = React.useState(null); // {lines, substations} const [panelState, setPanelState] = React.useState('empty'); // empty|loading|report|error const [loadingStep, setLoadingStep] = React.useState(0); const [loadingError, setLoadingError] = React.useState(null); const [drawer, setDrawer] = React.useState({ open: false, assessor: null }); const [toast, setToast] = React.useState(null); const [panelW, setPanelW] = React.useState(() => Math.round(Math.min(Math.max((window.innerWidth || 1280) * 0.3, 420), 620))); const timers = React.useRef([]); // Focal point used for the grid overlay query AND the always-on pin marker. // Active site wins over a dropped pin. const focalPoint = activeSite?.centroid || pinLocation || null; const focalLabel = activeSite?.name || (pinLocation ? 'Dropped pin' : null); const clearTimers = () => { timers.current.forEach(clearTimeout); timers.current = []; }; // Initial site list from the backend, with a sticky banner if it's down so // the user notices before they try to draw/save/edit. React.useEffect(() => { let cancelled = false; PCApi.listSites() .then(list => { if (!cancelled) setSites(list); }) .catch(err => { console.warn('Could not reach backend at', PCApi.BASE, err); setToast({ kind: 'error', msg: 'Backend not reachable. Run Install-Prospecting.ps1 once, or restart the ProspectingBackend scheduled task.', actions: [{ label: 'Retry', primary: true, onClick: () => window.location.reload() }], }); // no auto-dismiss — they need to act on it }); return () => { cancelled = true; }; }, []); // run the backend assessment with a brief loading-cascade animation overlaid const runAssessment = async ({ geometry, name, address }) => { clearTimers(); setActiveSite(null); setLoadingError(null); setPanelState('loading'); setLoadingStep(0); const cascadeStepMs = 360; const steps = window.PC_LOADING_STEPS.length; for (let i = 1; i <= steps; i++) { timers.current.push(setTimeout(() => setLoadingStep(i), i * cascadeStepMs)); } const minWaitPromise = new Promise(r => setTimeout(r, steps * cascadeStepMs + 240)); try { const [site] = await Promise.all([ PCApi.assessNow({ geometry, name, address }), minWaitPromise, ]); // Cache the freshly assessed full record so re-opening it skips the fetch. fullSiteCache.current.set(site.id, site); // Replace its summary in the list (or insert at the front for new IDs). const summary = { id: site.id, name: site.name, address: site.address, region: site.region, area_ha: site.area_ha, shape_descriptor: site.shape_descriptor, overall_score: site.overall_score, overall_status: site.overall_status, assessed_at: site.assessed_at, centroid: site.centroid, }; setSites(prev => [summary, ...prev.filter(s => s.id !== site.id)]); setActiveSite(site); setPanelState('report'); } catch (err) { console.error('Assessment failed:', err); setLoadingError(err.message || String(err)); setPanelState('error'); } }; const onDrawComplete = (info) => { runAssessment({ geometry: info.geometry, name: null, address: `Drawn boundary · ${Math.abs(info.centroid[1]).toFixed(3)}°S, ${Math.abs(info.centroid[0]).toFixed(3)}°E`, }); }; // Bumped by Discard to force MapWorkspace to re-show the active site's // *stored* geometry, throwing away the edits the user dragged in. const [revertNonce, setRevertNonce] = React.useState(0); // Latest edited geometry while the user is mid-edit. Updated on every // vertex-drag release so Save commits the most recent shape. const pendingEditGeom = React.useRef(null); const saveActiveEdit = async () => { const geometry = pendingEditGeom.current; if (!activeSite || !geometry) return; const siteId = activeSite.id; setToast({ msg: 'Saving boundary and re-running assessors…' }); try { const updated = await PCApi.updateSiteGeometry(siteId, geometry); fullSiteCache.current.set(updated.id, updated); setActiveSite(updated); const summary = { id: updated.id, name: updated.name, address: updated.address, region: updated.region, area_ha: updated.area_ha, shape_descriptor: updated.shape_descriptor, overall_score: updated.overall_score, overall_status: updated.overall_status, assessed_at: updated.assessed_at, centroid: updated.centroid, }; setSites(prev => prev.map(s => s.id === updated.id ? summary : s)); pendingEditGeom.current = null; setToast({ msg: 'Boundary updated.', kind: 'ok' }); setTimeout(() => setToast(null), 2400); } catch (err) { console.error('save failed', err); // Distinguish 'backend unreachable' (the common case if uvicorn is // down) from a real server-side error so the user knows what to fix. const isNetworkFailure = err && (err.message === 'Failed to fetch' || /NetworkError|TypeError: Load failed/.test(String(err.message || err))); const msg = isNetworkFailure ? 'Save failed — backend not reachable. Is Start-Prospecting.ps1 running?' : `Save failed: ${err.message || err}`; setToast({ msg, kind: 'error', actions: [ { label: 'Retry', primary: true, onClick: saveActiveEdit }, { label: 'Dismiss', onClick: () => setToast(null) }, ], }); } }; const discardActiveEdit = () => { pendingEditGeom.current = null; setToast(null); setRevertNonce(n => n + 1); // re-show stored geometry on the map }; // Fires after every vertex-drag release while in edit mode. Stash the new // geometry and show the Save / Discard toast (replaces the edit hint). const onEditChange = ({ geometry }) => { if (!activeSite) return; pendingEditGeom.current = geometry; setToast({ msg: 'Boundary edited', actions: [ { label: 'Discard', onClick: discardActiveEdit }, { label: 'Save', primary: true, onClick: saveActiveEdit }, ], }); }; // Legacy onEditComplete (fires when leaving edit mode). With onEditChange // already showing the toast on first drag, this is a no-op — kept so the // mapController's existing call site doesn't need to be removed. const onEditComplete = () => {}; // Pin drop is a non-destructive marker. Doesn't create a site — the user // chooses to draw a boundary around it (or run a grid lookup via the toggle). const onPinDrop = (point, label) => { setPinLocation(point); // pin is the new focal point; if grid overlay is on, App effect will refetch }; // Fetch the nearby grid whenever the focal geometry or toggle changes. // We send the site polygon when a site is active (so buffer rings hug the // boundary shape); otherwise the dropped pin's Point (concentric circles). const focalGeometry = activeSite?.geometry || (pinLocation ? { type: 'Point', coordinates: pinLocation } : null); React.useEffect(() => { if (!gridOverlayOn || !focalGeometry) { setGridOverlay(null); return; } let cancelled = false; PCApi.gridNearby({ geometry: focalGeometry, search_radius_m: PC_GRID_OVERLAY_RADIUS_M, buffer_radii_m: [1000, 2500, 5000], }) .then(r => { if (!cancelled) setGridOverlay({ lines: r.lines, substations: r.substations, buffers: r.buffers }); }) .catch(err => { console.warn('grid-nearby fetch failed', err); setToast('Could not load nearby grid infrastructure.'); setTimeout(() => setToast(null), 3200); }); return () => { cancelled = true; }; }, [gridOverlayOn, JSON.stringify(focalGeometry)]); // Cache of fully-loaded site records by id, so re-opening a site doesn't // re-fetch its geometry + assessor results. const fullSiteCache = React.useRef(new Map()); const openSite = async (id) => { if (id == null) { setActiveSite(null); setPanelState('empty'); setView('workspace'); return; } clearTimers(); setView('workspace'); // If we already have the full record cached, render immediately. const cached = fullSiteCache.current.get(id); if (cached) { setActiveSite(cached); setPanelState('report'); return; } // Otherwise show the loading panel and fetch the full record. setActiveSite(null); setPanelState('loading'); setLoadingStep(window.PC_LOADING_STEPS.length); // mid-cascade so it doesn't feel sluggish try { const full = await PCApi.getSite(id); fullSiteCache.current.set(id, full); setActiveSite(full); setPanelState('report'); } catch (err) { console.error('failed to load site', id, err); setLoadingError(err.message || String(err)); setPanelState('error'); } }; const onCloseReport = () => { clearTimers(); setActiveSite(null); setPanelState('empty'); }; const onExport = () => { setToast('Export queued — the PDF report builder is wired to a placeholder for now.'); setTimeout(() => setToast(null), 3200); }; const openAssessor = (a) => setDrawer({ open: true, assessor: a }); const closeDrawer = () => setDrawer(d => ({ ...d, open: false })); React.useEffect(() => () => clearTimers(), []); const cls = `pc-app ${t.density === 'compact' ? 'compact' : ''} ${t.showConfidence ? '' : 'no-conf'}`; return (