import { useState, useCallback, useEffect } from 'react'; import { useProjectStore } from '../store/projectStore'; import { useUIStore } from '../store/uiStore'; import { useAnalysisStore } from '../store/analysisStore'; import MapViewer from '../components/map/MapViewer'; import ProcessGroupList from '../components/process/ProcessGroupList'; import ActionBar from '../components/io/ActionBar'; import StreamDataTable from '../components/analysis/StreamDataTable'; import './DataCollectionPage.css'; import TutorialPanel from '../components/ui/TutorialPanel'; export default function DataCollectionPage() { const state = useProjectStore((s) => s.state); const setMapCenter = useProjectStore((s) => s.setMapCenter); const setMapZoom = useProjectStore((s) => s.setMapZoom); const setMapLocked = useProjectStore((s) => s.setMapLocked); const setCurrentBase = useProjectStore((s) => s.setCurrentBase); const setProcesses = useProjectStore((s) => s.setProcesses); const setGroups = useProjectStore((s) => s.setGroups); const setGroupNames = useProjectStore((s) => s.setGroupNames); const selectedStreams = useAnalysisStore((s) => s.selectedStreams); const setSelectedStreams = useAnalysisStore((s) => s.setSelectedStreams); const [uiMode, setUiMode] = useState<'select' | 'analyze'>( state.map_locked ? 'analyze' : 'select' ); const [addressSearch, setAddressSearch] = useState(''); const [_searchResults, setSearchResults] = useState([]); const [statusMsg, setStatusMsg] = useState(null); // Auto-sync UI mode with map_locked state (e.g., when loading files) useEffect(() => { setUiMode(state.map_locked ? 'analyze' : 'select'); }, [state.map_locked]); // Placement state const [placementTarget, setPlacementTarget] = useState(null); // Measure state const [measureMode, setMeasureMode] = useState(false); const [measurePoints, setMeasurePoints] = useState<[number, number][]>([]); const [measureResult, setMeasureResult] = useState(null); // Subprocess map view toggles const [subprocessMapExpanded, setSubprocessMapExpanded] = useState< Record >({}); const [showTutorial, setShowTutorial] = useState(false); const expandedGroups = useUIStore((s) => s.expandedGroups); const setExpandedGroups = (val: Set) => useUIStore.getState().setGroupsExpanded(val); const handleElementDoubleClick = useCallback((type: 'group' | 'sub' | 'child' | 'stream', id: any, subId?: any) => { let pIdx = -1; let targetGIdx = -1; if (type === 'group') targetGIdx = id; else if (type === 'sub') { pIdx = id; targetGIdx = state.proc_groups.findIndex(g => g.includes(pIdx)); } else if (type === 'child') { pIdx = id.subIdx; targetGIdx = state.proc_groups.findIndex(g => g.includes(pIdx)); } else if (type === 'stream') { pIdx = id; targetGIdx = state.proc_groups.findIndex(g => g.includes(pIdx)); } if (targetGIdx !== -1) { // ONLY expand this group setExpandedGroups(new Set([targetGIdx])); if (pIdx !== -1) { // ONLY expand this subprocess useUIStore.getState().setSubprocessExpanded(pIdx, true); useUIStore.getState().setActiveSection(pIdx, 'streams'); } // Scroll with a slightly longer delay to allow expansion to paint setTimeout(() => { let scrollTargetId = `group-list-item-${targetGIdx}`; if (type === 'stream' && subId !== undefined) { scrollTargetId = `stream-editor-${pIdx}-${subId}`; } else if (pIdx !== -1) { scrollTargetId = `sub-card-${pIdx}`; } const el = document.getElementById(scrollTargetId); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Highlight it briefly? el.style.outline = '2px solid var(--accent-primary)'; setTimeout(() => { el.style.outline = 'none'; }, 2000); } }, 300); } }, [state.proc_groups]); // Resizable sidebar state const [sidebarWidth, setSidebarWidth] = useState(350); const [isResizing, setIsResizing] = useState(false); const startResizing = useCallback(() => { setIsResizing(true); }, []); const stopResizing = useCallback(() => { setIsResizing(false); }, []); const resize = useCallback( (e: any) => { if (isResizing) { // Limit width between 200 and 800 const newWidth = Math.max(200, Math.min(800, e.clientX)); setSidebarWidth(newWidth); } }, [isResizing] ); useEffect(() => { if (isResizing) { window.addEventListener('mousemove', resize); window.addEventListener('mouseup', stopResizing); } return () => { window.removeEventListener('mousemove', resize); window.removeEventListener('mouseup', stopResizing); }; }, [isResizing, resize, stopResizing]); const lockMap = useCallback( (center: [number, number], zoom: number) => { setMapCenter(center); setMapZoom(zoom); setMapLocked(true); setUiMode('analyze'); setStatusMsg('Map locked — click on map to place processes'); }, [setMapCenter, setMapZoom, setMapLocked] ); const unlockMap = useCallback(() => { setMapLocked(false); setUiMode('select'); setPlacementTarget(null); setMeasureMode(false); setStatusMsg(null); }, [setMapLocked]); const addProcessGroup = useCallback(() => { const processes = [...state.processes]; const groups = [...state.proc_groups]; const groupNames = [...state.proc_group_names]; // Create a new empty subprocess (level 0 processes contain subprocesses) const newProcess = { name: `Subprocess ${processes.length + 1}`, lat: '', lon: '', box_scale: 1.0, next: '', hours: '', extra_info: { notes: '' }, streams: [], children: [], }; const newIdx = processes.length; processes.push(newProcess); groups.push([newIdx]); groupNames.push(`Process ${groups.length}`); setProcesses(processes); setGroups(groups); setGroupNames(groupNames); setStatusMsg(`Added Process ${groups.length}`); }, [state, setProcesses, setGroups, setGroupNames]); const handleAddressSearch = useCallback(async () => { if (!addressSearch.trim()) return; try { const resp = await fetch( `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent( addressSearch )}` ); const data = await resp.json(); if (data.length > 0) { setSearchResults(data); const lat = parseFloat(data[0].lat); const lon = parseFloat(data[0].lon); setMapCenter([lat, lon]); setMapZoom(17); setStatusMsg(`Found: ${data[0].display_name}`); } else { setStatusMsg('No results found'); } } catch { setStatusMsg('Search failed'); } }, [addressSearch, setMapCenter, setMapZoom]); // Handle map click (for placement or measurement) const handleMapClick = useCallback( (lat: number, lon: number) => { if (placementTarget) { // Place process at clicked coordinates const processes = [...state.processes]; // Parse target: "group_N", "sub_N", "child_N_M" if (placementTarget.startsWith('group_')) { const gIdx = parseInt(placementTarget.split('_')[1]); const coords = { ...state.proc_group_coordinates }; coords[gIdx] = { ...(coords[gIdx] || {}), lat: lat.toString(), lon: lon.toString(), }; useProjectStore.getState().setGroupCoordinates(coords); } else if (placementTarget.startsWith('child_')) { const parts = placementTarget.split('_'); const subIdx = parseInt(parts[1]); const childIdx = parseInt(parts[2]); processes[subIdx] = { ...processes[subIdx] }; processes[subIdx].children = [...(processes[subIdx].children || [])]; processes[subIdx].children[childIdx] = { ...processes[subIdx].children[childIdx], lat: lat.toString(), lon: lon.toString(), }; setProcesses(processes); } else { // sub_N — place subprocess const subIdx = parseInt(placementTarget.split('_')[1]); processes[subIdx] = { ...processes[subIdx], lat: lat.toString(), lon: lon.toString(), }; setProcesses(processes); } setPlacementTarget(null); setStatusMsg('Placed successfully'); } else if (measureMode) { const newPoints = [...measurePoints, [lat, lon] as [number, number]]; setMeasurePoints(newPoints); if (newPoints.length === 2) { // Haversine distance const [lat1, lon1] = newPoints[0]; const [lat2, lon2] = newPoints[1]; const R = 6371000; const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; const d = R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const distStr = d >= 1000 ? `${(d / 1000).toFixed(2)} km` : `${d.toFixed(1)} m`; setMeasureResult(`Distance: ${distStr}`); setMeasureMode(false); setMeasurePoints([]); } } }, [placementTarget, measureMode, measurePoints, state, setProcesses] ); return (
{/* Action bar */} { setMeasureMode(!measureMode); setMeasurePoints([]); setMeasureResult(null); }} measureActive={measureMode} currentBase={state.current_base || 'OpenStreetMap'} onBaseChange={(b) => setCurrentBase(b)} onLockMap={lockMap} onUnlockMap={unlockMap} onAddProcess={addProcessGroup} addressSearch={addressSearch} onAddressSearchChange={setAddressSearch} onAddressSearchSubmit={handleAddressSearch} showHelp={showTutorial} onHelpToggle={() => setShowTutorial(!showTutorial)} />
{/* Left panel — process editor */}
{uiMode === 'select' ? (

Pan and zoom the map, then click Lock map to begin editing processes.

) : ( <> useProjectStore.getState().setGroupCoordinates(c) } expandedGroups={expandedGroups} onExpandedGroupsChange={setExpandedGroups} onPlaceRequest={(target) => { setPlacementTarget(target); setStatusMsg(`Click on map to place ${target}`); }} placementTarget={placementTarget} subprocessMapExpanded={subprocessMapExpanded} onSubprocessMapToggle={(gIdx) => setSubprocessMapExpanded((prev) => ({ ...prev, [gIdx]: !prev[gIdx], })) } /> )}
{/* Resizer handle */}
{/* Right panel — map */}
{/* Removed redundant search bar — integrated into ActionBar */} { if (uiMode === 'select') { setMapCenter(center); setMapZoom(zoom); } }} subprocessMapExpanded={subprocessMapExpanded} childMapExpanded={{}} onProcessesChange={setProcesses} onGroupCoordinatesChange={(c) => useProjectStore.getState().setGroupCoordinates(c) } onElementDoubleClick={handleElementDoubleClick} selectedStreams={selectedStreams} onProcessesSelect={(idxs, val) => { const next = { ...selectedStreams }; idxs.forEach((pi) => { const p = state.processes[pi]; if (!p || !p.streams) return; p.streams.forEach((_, si) => { next[`stream_${pi}_${si}`] = val; }); }); setSelectedStreams(next); }} allowMultiMove={true} /> {/* Legend — directly below the map — VERY COMPACT */} {state.processes.some(p => (p.streams || []).length > 0 || (p.children || []).some(c => (c.streams || []).length > 0)) && (
Legend {/* Cold scale */}
Cold
{/* Hot scale */}
Hot
{/* Size */}
{[3, 5, 8].map((r) => ( ))} = Q power (kW)
)} {/* Notes */}