Spaces:
Sleeping
Sleeping
| 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<any[]>([]); | |
| const [statusMsg, setStatusMsg] = useState<string | null>(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<string | null>(null); | |
| // Measure state | |
| const [measureMode, setMeasureMode] = useState(false); | |
| const [measurePoints, setMeasurePoints] = useState<[number, number][]>([]); | |
| const [measureResult, setMeasureResult] = useState<string | null>(null); | |
| // Subprocess map view toggles | |
| const [subprocessMapExpanded, setSubprocessMapExpanded] = useState< | |
| Record<number, boolean> | |
| >({}); | |
| const [showTutorial, setShowTutorial] = useState(false); | |
| const expandedGroups = useUIStore((s) => s.expandedGroups); | |
| const setExpandedGroups = (val: Set<number>) => 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 ( | |
| <div className="dc-page"> | |
| {/* Action bar */} | |
| <ActionBar | |
| uiMode={uiMode} | |
| statusMsg={ | |
| placementTarget | |
| ? `Click on map to place ${placementTarget}` | |
| : measureMode | |
| ? `Click ${2 - measurePoints.length} point(s) to measure` | |
| : measureResult || statusMsg | |
| } | |
| onMeasureToggle={() => { | |
| 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)} | |
| /> | |
| <div className="dc-layout"> | |
| {/* Left panel — process editor */} | |
| <div className="dc-left" style={{ width: sidebarWidth, flex: 'none' }}> | |
| {uiMode === 'select' ? ( | |
| <div className="dc-select-controls"> | |
| <p className="dc-hint"> | |
| Pan and zoom the map, then click <strong>Lock map</strong> to | |
| begin editing processes. | |
| </p> | |
| </div> | |
| ) : ( | |
| <> | |
| <ProcessGroupList | |
| processes={state.processes} | |
| groups={state.proc_groups} | |
| groupNames={state.proc_group_names} | |
| groupCoordinates={state.proc_group_coordinates} | |
| onProcessesChange={setProcesses} | |
| onGroupsChange={setGroups} | |
| onGroupNamesChange={setGroupNames} | |
| onGroupCoordinatesChange={(c) => | |
| 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], | |
| })) | |
| } | |
| /> | |
| </> | |
| )} | |
| </div> | |
| {/* Resizer handle */} | |
| <div className="dc-resizer" onMouseDown={startResizing} /> | |
| {/* Right panel — map */} | |
| <div className="dc-right"> | |
| {/* Removed redundant search bar — integrated into ActionBar */} | |
| <MapViewer | |
| height="750px" | |
| center={ | |
| state.map_center?.length === 2 | |
| ? (state.map_center as [number, number]) | |
| : [51.707937580921694, 8.772205607882668] | |
| } | |
| zoom={state.map_zoom || 17} | |
| locked={uiMode === 'analyze'} | |
| processes={state.processes} | |
| groups={state.proc_groups} | |
| groupNames={state.proc_group_names} | |
| groupCoordinates={state.proc_group_coordinates} | |
| baseTile={state.current_base || 'OpenStreetMap'} | |
| onClick={handleMapClick} | |
| onMoveEnd={(center, zoom) => { | |
| 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)) && ( | |
| <div style={{ padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 15, flexWrap: 'wrap', borderBottom: '1px solid var(--border)', background: 'var(--surface)' }}> | |
| <span style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: 'var(--text-muted)' }}>Legend</span> | |
| {/* Cold scale */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> | |
| <span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Cold</span> | |
| <div style={{ width: 40, height: 8, borderRadius: 4, background: 'linear-gradient(to right, rgb(100,150,255), rgb(0,30,180))' }} /> | |
| </div> | |
| {/* Hot scale */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> | |
| <span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Hot</span> | |
| <div style={{ width: 40, height: 8, borderRadius: 4, background: 'linear-gradient(to right, rgb(255,120,120), rgb(180,0,0))' }} /> | |
| </div> | |
| {/* Size */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> | |
| {[3, 5, 8].map((r) => ( | |
| <svg key={r} width={r * 2 + 2} height={r * 2 + 2} style={{ display: 'block', flexShrink: 0 }}> | |
| <circle cx={r + 1} cy={r + 1} r={r} fill="rgba(150,150,150,0.45)" stroke="var(--border-strong)" strokeWidth={1} /> | |
| </svg> | |
| ))} | |
| <span style={{ fontSize: 9, color: 'var(--text-muted)' }}>= Q power (kW)</span> | |
| </div> | |
| </div> | |
| )} | |
| {/* Notes */} | |
| <div className="card mt-md"> | |
| <label>Project Notes</label> | |
| <textarea | |
| value={state.project_notes || ''} | |
| onChange={(e) => | |
| useProjectStore.getState().setProjectNotes(e.target.value) | |
| } | |
| rows={4} | |
| style={{ width: '100%', marginTop: 4 }} | |
| /> | |
| </div> | |
| {/* Stream data table — below project notes so it gets all remaining space */} | |
| <StreamDataTable | |
| processes={state.processes} | |
| groups={state.proc_groups} | |
| groupNames={state.proc_group_names} | |
| groupCoordinates={state.proc_group_coordinates} | |
| /> | |
| <TutorialPanel isOpen={showTutorial} onClose={() => setShowTutorial(false)} /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |