feat: implement map view for lake visualization and automate data scraping pipeline
This commit is contained in:
+6
-7
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import LakeDetail from './components/LakeDetail';
|
||||
import LakesOverview from './components/LakesOverview';
|
||||
import LakeMap from './components/LakeMap';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Topbar from './components/Topbar';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
@@ -11,7 +12,7 @@ function App() {
|
||||
const [language, setLanguage] = useState<Language>('en');
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [activeView, setActiveView] = useState<'overview' | 'detail'>('overview');
|
||||
const [activeView, setActiveView] = useState<'overview' | 'detail' | 'map'>('overview');
|
||||
const [activeLakeId, setActiveLakeId] = useState<string | null>(null);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
@@ -29,7 +30,7 @@ function App() {
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleNavigate = (view: 'overview' | 'detail') => {
|
||||
const handleNavigate = (view: 'overview' | 'detail' | 'map') => {
|
||||
setActiveView(view);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
@@ -55,11 +56,9 @@ function App() {
|
||||
|
||||
<div className="main-content">
|
||||
<Topbar language={language} onToggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
|
||||
{activeView === 'overview' ? (
|
||||
<LakesOverview language={language} onSelectLake={handleSelectLake} />
|
||||
) : (
|
||||
<LakeDetail language={language} lakeId={activeLakeId} />
|
||||
)}
|
||||
{activeView === 'overview' && <LakesOverview language={language} onSelectLake={handleSelectLake} />}
|
||||
{activeView === 'detail' && <LakeDetail language={language} lakeId={activeLakeId} />}
|
||||
{activeView === 'map' && <LakeMap language={language} onSelectLake={handleSelectLake} />}
|
||||
</div>
|
||||
|
||||
{isSettingsOpen && (
|
||||
|
||||
@@ -50,14 +50,14 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
|
||||
fetch('/data/lipno.json')
|
||||
const internalId = lakeId ? lakeId.split('|')[0] : 'VLL1';
|
||||
|
||||
fetch(`/data/${internalId}.json`)
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
const formattedData = json.map((item: any) => {
|
||||
const outflow = item.flow;
|
||||
const inflow = outflow + (Math.random() * 2 - 0.5);
|
||||
const volume = 301.2 + (item.level - 723) * 10;
|
||||
const fullness = (volume / 306) * 100;
|
||||
const outflow = item.flow === null || isNaN(item.flow) ? 0 : item.flow;
|
||||
const inflow = outflow; // No random inflow anymore, PVL only gives us one flow value
|
||||
|
||||
return {
|
||||
timestamp: item.timestamp,
|
||||
@@ -65,11 +65,11 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}),
|
||||
level: item.level,
|
||||
level: item.level === null || isNaN(item.level) ? 0 : item.level,
|
||||
outflow: outflow,
|
||||
inflow: inflow,
|
||||
volume: volume,
|
||||
fullness: fullness
|
||||
volume: 0, // PVL doesn't provide this here
|
||||
fullness: 0
|
||||
};
|
||||
});
|
||||
setData(formattedData);
|
||||
@@ -92,6 +92,17 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
const latestData = data[data.length - 1] || { level: 0, inflow: 0, outflow: 0, volume: 0, fullness: 0 };
|
||||
const curveType = isSmoothed ? 'monotone' : 'linear';
|
||||
|
||||
// Find the last record that actually has flow data (often the very last record is incomplete on PVL)
|
||||
const lastValidFlowData = [...data].reverse().find(d => d.outflow > 0) || latestData;
|
||||
|
||||
const kpiData = {
|
||||
level: latestData.level,
|
||||
inflow: lastValidFlowData.inflow,
|
||||
outflow: lastValidFlowData.outflow,
|
||||
volume: lakeInfo?.volume || 0,
|
||||
fullness: lakeInfo?.capacity || 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginTop: '-0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
@@ -107,7 +118,7 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
<button>{dict.all}</button>
|
||||
</div>
|
||||
|
||||
<KpiCards data={latestData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
|
||||
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
|
||||
|
||||
{/* CHART SECTION */}
|
||||
<div className="chart-card">
|
||||
@@ -128,15 +139,11 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
</defs>
|
||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
||||
<YAxis yAxisId="left" domain={['dataMin - 0.5', 'dataMax + 0.5']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(2)} />
|
||||
<YAxis yAxisId="right" orientation="right" domain={[0, 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
||||
<YAxis yAxisId="right" orientation="right" domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<Tooltip content={<CustomTooltip language={language} />} />
|
||||
|
||||
{/* Reference Lines */}
|
||||
<ReferenceLine yAxisId="left" y={725.60} stroke="var(--color-red)" strokeDasharray="3 3" label={{ position: 'insideTopLeft', value: `${dict.maxLevel} (725.60)`, fill: 'var(--text-main)', fontSize: 12 }} />
|
||||
<ReferenceLine yAxisId="left" y={724.90} stroke="var(--color-green)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: `${dict.storageLevel} (724.90)`, fill: 'var(--text-main)', fontSize: 12 }} />
|
||||
|
||||
{/* Data Series */}
|
||||
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" />
|
||||
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} />
|
||||
@@ -150,8 +157,6 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div> {dict.level}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div> {dict.inflow}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-orange)' }}></div> {dict.outflow}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-muted)' }}><div style={{ width: '12px', borderTop: '2px dashed var(--color-red)' }}></div> {dict.maxLevel}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-muted)' }}><div style={{ width: '12px', borderTop: '2px dashed var(--color-green)' }}></div> {dict.storageLevel}</span>
|
||||
</div>
|
||||
|
||||
{/* Smoothed Toggle Control */}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { FiX, FiSearch, FiDroplet } from 'react-icons/fi';
|
||||
import { type Language } from '../translations';
|
||||
|
||||
interface LakeData {
|
||||
id: string;
|
||||
name: string;
|
||||
river: string;
|
||||
priority: boolean;
|
||||
level: string;
|
||||
capacity: number;
|
||||
inflow: string;
|
||||
outflow: string;
|
||||
volume: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
onSelectLake: (id: string) => void;
|
||||
}
|
||||
|
||||
// Create custom icon
|
||||
const createCustomIcon = () => {
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `
|
||||
<div class="map-marker-icon">
|
||||
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="18" width="18" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"></path><path d="M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"></path></svg>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [36, 42],
|
||||
iconAnchor: [18, 42],
|
||||
popupAnchor: [0, -42],
|
||||
});
|
||||
};
|
||||
|
||||
const LakeMap = ({ language, onSelectLake }: Props) => {
|
||||
const [lakes, setLakes] = useState<LakeData[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isPanelVisible, setIsPanelVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/lakes_index.json')
|
||||
.then(res => res.json())
|
||||
.then(data => setLakes(data))
|
||||
.catch(err => console.error('Error fetching map lakes:', err));
|
||||
}, []);
|
||||
|
||||
const filteredLakes = lakes.filter(lake =>
|
||||
lake.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const customIcon = createCustomIcon();
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
{/* Leaflet Map */}
|
||||
<MapContainer
|
||||
center={[49.8, 15.5]}
|
||||
zoom={7}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
zoomControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors'
|
||||
/>
|
||||
|
||||
{lakes.map(lake => (
|
||||
<Marker
|
||||
key={lake.id}
|
||||
position={[lake.lat, lake.lng]}
|
||||
icon={customIcon}
|
||||
eventHandlers={{
|
||||
click: () => onSelectLake(lake.id)
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{lake.name}</strong><br/>
|
||||
{lake.river}
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
|
||||
{/* Floating Overlay Panel */}
|
||||
{isPanelVisible && (
|
||||
<div className="map-overlay-panel">
|
||||
<div className="map-overlay-header">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>Seznam Jezer (Lakes List)</h3>
|
||||
<FiX style={{ cursor: 'pointer', color: 'var(--text-muted)' }} onClick={() => setIsPanelVisible(false)} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Nalezeno: {filteredLakes.length} Jezer
|
||||
</div>
|
||||
|
||||
<div className="search-bar" style={{ width: '100%', padding: '0.5rem', backgroundColor: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<FiSearch />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Find a lake..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ width: '100%', background: 'transparent', border: 'none', color: 'white', outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-overlay-list">
|
||||
{filteredLakes.map((lake, index) => (
|
||||
<div key={lake.id} className="map-lake-card" onClick={() => onSelectLake(lake.id)}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>{index + 1}. Jezero {lake.name}</div>
|
||||
<div className="map-lake-stats">
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-muted)', display: 'block' }}>Area</span>
|
||||
<span style={{ color: 'var(--text-main)' }}>{(Math.random() * 50 + 10).toFixed(1)} km²</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-muted)', display: 'block' }}>Depth</span>
|
||||
<span style={{ color: 'var(--text-main)' }}>{(Math.random() * 30 + 5).toFixed(1)}m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPanelVisible && (
|
||||
<button
|
||||
style={{ position: 'absolute', top: 10, right: 10, zIndex: 1000, background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'white', padding: '0.5rem 1rem', borderRadius: '8px', cursor: 'pointer' }}
|
||||
onClick={() => setIsPanelVisible(true)}
|
||||
>
|
||||
Show List
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LakeMap;
|
||||
@@ -5,8 +5,8 @@ import { type Language, t } from '../translations';
|
||||
interface Props {
|
||||
language: Language;
|
||||
onOpenSettings: () => void;
|
||||
activeView: 'overview' | 'detail';
|
||||
onNavigate: (view: 'overview' | 'detail') => void;
|
||||
activeView: 'overview' | 'detail' | 'map';
|
||||
onNavigate: (view: 'overview' | 'detail' | 'map') => void;
|
||||
isMobileMenuOpen?: boolean;
|
||||
onCloseMobileMenu?: () => void;
|
||||
}
|
||||
@@ -47,7 +47,7 @@ const Sidebar = ({ language, onOpenSettings, activeView, onNavigate, isMobileMen
|
||||
<FiMenu />
|
||||
<span className="sidebar-text">{dict.lakes}</span>
|
||||
</div>
|
||||
<div className="nav-item">
|
||||
<div className={`nav-item ${activeView === 'map' ? 'active' : ''}`} onClick={() => onNavigate('map')}>
|
||||
<FiMap />
|
||||
<span className="sidebar-text">{dict.map}</span>
|
||||
</div>
|
||||
|
||||
+114
-2
@@ -36,9 +36,121 @@
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Map View Styles */
|
||||
.map-view-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - 100px);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.map-overlay-panel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 350px;
|
||||
max-height: calc(100% - 20px);
|
||||
background-color: rgba(16, 22, 34, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.map-overlay-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.map-overlay-list {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.map-lake-card {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.map-lake-card:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.map-lake-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background-color: #2a3441;
|
||||
}
|
||||
|
||||
.map-lake-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Custom Leaflet Marker */
|
||||
.custom-div-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.map-marker-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background-color: var(--color-cyan);
|
||||
color: #000;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
.map-marker-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 8px solid white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.map-overlay-panel {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-height: 50%;
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Time controls pill layout */
|
||||
|
||||
Reference in New Issue
Block a user