/* ============================================================
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) && }
onTool(t.id)} aria-label={t.tip}>{t.glyph}
{t.tip}
))}
{PCI.close}
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 (
setLayerOpen(o => !o)}>{PCI.layers}Layers
{layerOpen && }
setBm('aerial')}>Aerial
setBm('topo')}>Topo
{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;