/* ============================================================ Map Workspace — toolbar, search, layers, basemap, results panel ============================================================ */ const TOOLS = [ { id: 'draw', mode: 'draw', glyph: PCI.drawPoly, tip: 'Draw polygon' }, { id: 'edit', mode: 'edit', glyph: PCI.editPoly, tip: 'Edit polygon' }, { id: 'address', mode: 'address', glyph: PCI.address, tip: 'Drop / enter address' }, { id: 'measure-distance', mode: 'measure-distance', glyph: PCI.ruler, tip: 'Measure distance' }, { id: 'measure-area', mode: 'measure-area', glyph: PCI.area, tip: 'Measure area' }, ]; const Toolbar = ({ tool, onTool, onClear }) => (
{TOOLS.map((t, i) => ( {(i === 3) && } {t.tip} ))} Clear
); const SearchBox = ({ value, onChange, results, onPick, inputRef }) => (
{PCI.search} onChange(e.target.value)} placeholder="Search an address or locality…" />
{results.length > 0 && (
{results.map((r, i) => (
onPick(r)}> {PCI.pin}{r.label}{r.sub}
))}
)}
); /** Nearby-grid map legend. Lives top-right of the map, below the Layers button. */ const GridLegend = ({ overlay }) => { const lineCount = overlay?.lines?.features?.length || 0; const subCount = overlay?.substations?.features?.length || 0; return (
Nearby grid (5 km)
{lineCount} lines · {subCount} substations
≥110 kV (transmission)
50–66 kV
33 kV
22 kV
11 kV
Transmission sub
Subtransmission sub
1 / 2.5 / 5 km buffer
); }; const LayerPanel = ({ layers, onToggle }) => (

Overlay layers

More layers will activate as their data sources come online. The nearby-grid overlay is toggled from the Grid section in the report panel.

{layers.map(l => (
{ if (!l.stub) onToggle(l.id); }} aria-disabled={l.stub}> {l.label} {l.stub && data pending}
))}
); const MapWorkspace = ({ toast, sites, activeSite, panelState, loadingStep, loadingError, clickThrough, onDrawComplete, onPinDrop, onSiteClickMap, onEditComplete, onEditChange, gridOverlayOn, gridOverlay, gridSectionToggle, focalPoint, focalLabel, revertNonce, onCloseReport, onExport, onOpenAssessor, panelW, setPanelW }) => { const mapDivRef = React.useRef(null); const mapRef = React.useRef(null); const inputRef = React.useRef(null); const handlers = React.useRef({}); handlers.current = { onDrawComplete, onPinDrop, onSiteClickMap, onEditComplete, onEditChange }; const [tool, setTool] = React.useState(null); const [basemap, setBasemap] = React.useState('aerial'); const [layers, setLayers] = React.useState(() => window.PC_LAYERS.map(l => ({ ...l }))); const [layerOpen, setLayerOpen] = React.useState(false); const [query, setQuery] = React.useState(''); const [measure, setMeasure] = React.useState(null); const [draftCount, setDraftCount] = React.useState(0); const [ready, setReady] = React.useState(false); const draggingRef = React.useRef(false); // draggable panel divider React.useEffect(() => { const onMove = e => { if (!draggingRef.current) return; const w = window.innerWidth - e.clientX; const max = Math.min(820, window.innerWidth - 480); setPanelW(Math.max(380, Math.min(max, w))); }; const onUp = () => { if (draggingRef.current) { draggingRef.current = false; document.body.style.userSelect = ''; document.body.style.cursor = ''; } }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, []); React.useEffect(() => { mapRef.current && mapRef.current.resize(); }, [panelW]); // init map once React.useEffect(() => { const m = new window.PCMap(mapDivRef.current, { onReady: () => { setReady(true); layers.forEach(l => m.setLayer(l.id, l.on)); m.setSites(sites); }, onDrawComplete: info => { handlers.current.onDrawComplete(info); setTool(null); m.setMode(null); }, onPinDrop: p => { handlers.current.onPinDrop && handlers.current.onPinDrop(p); setTool(null); m.setMode(null); }, onSiteClick: id => handlers.current.onSiteClickMap(id), onEditComplete: info => { handlers.current.onEditComplete && handlers.current.onEditComplete(info); }, onEditChange: info => { handlers.current.onEditChange && handlers.current.onEditChange(info); }, onMeasure: r => setMeasure(r), }); mapRef.current = m; window.__pcmap = m; return () => { /* keep alive for session */ }; }, []); // push site list changes into the map React.useEffect(() => { if (ready && mapRef.current) mapRef.current.setSites(sites); }, [sites, ready]); // push grid overlay into the map (toggle controlled in App) React.useEffect(() => { if (!ready || !mapRef.current) return; if (gridOverlayOn && gridOverlay) mapRef.current.setGridOverlay(gridOverlay); else mapRef.current.clearGridOverlay(); }, [gridOverlayOn, gridOverlay, ready]); // keep the focal pin in sync with the focal point (site centroid OR dropped pin) React.useEffect(() => { if (!ready || !mapRef.current) return; if (focalPoint) mapRef.current.setPin(focalPoint, focalLabel); else mapRef.current.clearPin(); }, [focalPoint && focalPoint[0], focalPoint && focalPoint[1], focalLabel, ready]); // reflect active site on the map. Re-runs on revertNonce bump so Discard // can throw away in-flight edits by re-showing the *stored* geometry. React.useEffect(() => { if (ready && activeSite && mapRef.current) mapRef.current.showSite(activeSite, true); if (ready && !activeSite && mapRef.current) mapRef.current.clearDraft(); }, [activeSite, ready, revertNonce]); // resize when panel toggles React.useEffect(() => { const t = setTimeout(() => mapRef.current && mapRef.current.resize(), 260); return () => clearTimeout(t); }, [panelState, activeSite]); const pickTool = id => { const next = tool === id ? null : id; setTool(next); const m = mapRef.current; if (!m) return; if (id === 'address') { inputRef.current && inputRef.current.focus(); } if (next === null) { m.setMode(null); return; } m.setMode(TOOLS.find(t => t.id === id).mode); if (next && next !== 'edit' && next !== 'address') setMeasure(null); }; const clearAll = () => { mapRef.current && mapRef.current.clearDraft(); setTool(null); mapRef.current && mapRef.current.setMode(null); setMeasure(null); setDraftCount(0); }; const toggleLayer = id => { setLayers(ls => ls.map(l => { if (l.id !== id) return l; const on = !l.on; mapRef.current && mapRef.current.setLayer(id, on); return { ...l, on }; })); }; const setBm = b => { setBasemap(b); mapRef.current && mapRef.current.setBasemap(b); }; const results = React.useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; return window.PC_GAZETTEER.filter(g => g.label.toLowerCase().includes(q)).slice(0, 6); }, [query]); const pickResult = r => { setQuery(''); if (mapRef.current) { mapRef.current.flyTo(r.center, r.zoom); mapRef.current.setPin(r.center, r.label); handlers.current.onPinDrop && handlers.current.onPinDrop(r.center, r.label); } }; const hasPanel = panelState !== 'hidden'; return (
{layerOpen && }
{tool === 'draw' && (
Click to add points · click the first point or double-click to finish
)} {tool === 'address' && (
Click the map to drop a pin · then use Draw polygon to assess a boundary around it
)} {tool === 'edit' && !toast && (
Drag any vertex to reshape the boundary
)} {measure && (
{measure.type === 'area' ? 'Area' : 'Distance'} {measure.text}
)} {gridOverlayOn && gridOverlay && } {/* Toast is inside .pc-mapwrap so its left:50%/translateX(-50%) centres it on the *map* (not the entire pc-main, which would shift it under the sidebar when the report panel is open). */}
{hasPanel && (
{ draggingRef.current = true; document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; e.preventDefault(); }}>
{panelState === 'empty' && } {panelState === 'loading' && } {panelState === 'error' && } {panelState === 'report' && activeSite && ( )}
)}
); }; window.MapWorkspace = MapWorkspace;