feat: Initial commit - Hladinator (Water Reservoir Dashboard)
continuous-integration/drone/push Build encountered an error

- Setup React project with Vite and TypeScript
- Built dynamic UI supporting Dark/Light mode and CS/EN localization
- Added Lakes Overview grid with mock data for 40+ reservoirs
- Created interactive Recharts charts for water levels and flow rates
- Designed fully responsive premium mobile layout with custom SVG KPIs
- Developed TypeScript scraper scripts to fetch reservoir data
This commit is contained in:
David Fencl
2026-06-05 21:36:38 +02:00
parent ac43c24f20
commit a5bd4985d1
22 changed files with 4369 additions and 958 deletions
+245
View File
@@ -0,0 +1,245 @@
import { useState, useEffect } from 'react';
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
import { type Language, t } from '../translations';
import Topbar from './Topbar';
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
interface Lake {
id: string;
name: string;
river: string;
priority: boolean;
level: number;
capacity: number;
inflow: number;
outflow: number;
volume: number;
sparkline: number[];
}
interface Props {
language: Language;
onSelectLake: (id: string) => void;
}
const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number, size?: number, strokeWidth?: number }) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (value / 100) * circumference;
return (
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size}>
<circle
stroke="rgba(255,255,255,0.1)"
fill="transparent"
strokeWidth={strokeWidth}
r={radius}
cx={size / 2}
cy={size / 2}
/>
<circle
stroke="var(--color-cyan)"
fill="transparent"
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ strokeDasharray: circumference, strokeDashoffset: offset, transition: 'stroke-dashoffset 0.5s ease-in-out' }}
r={radius}
cx={size / 2}
cy={size / 2}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
{value}%
</div>
</div>
);
};
const PriorityCard = ({ lake, onSelectLake }: { lake: Lake, onSelectLake: (id: string) => void }) => {
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
return (
<div className="kpi-card priority-lake-card" style={{ flex: 1, padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>{lake.name} {lake.river ? `- ${lake.river}` : ''}</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div style={{ width: '40px', height: '60px', backgroundColor: 'rgba(255,255,255,0.05)', position: 'relative', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ position: 'absolute', bottom: 0, left: 0, width: '100%', height: `${lake.capacity}%`, backgroundColor: 'var(--color-cyan)', opacity: 0.3 }}></div>
<div style={{ position: 'absolute', bottom: `${lake.capacity}%`, left: 0, width: '100%', height: '2px', backgroundColor: 'var(--color-cyan)' }}></div>
</div>
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Water level</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>{lake.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)' }}>m n.m.</span></div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Depth</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
<div>
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>{lake.capacity}% / <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>{lake.volume} mil. m³</span></div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Volume</div>
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1, height: '40px', marginRight: '2rem' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorSpark" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.8}/>
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
</linearGradient>
</defs>
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingUp color="var(--color-green)" />
<span style={{ color: 'var(--text-muted)' }}>Inflow <span style={{ color: 'var(--color-green)' }}>{lake.inflow} m³/s</span></span>
<span style={{ color: 'var(--text-muted)' }}>/ Outflow <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingDown color="var(--color-red)" />
<span style={{ color: 'var(--text-muted)' }}>Inflow <span style={{ color: 'var(--color-green)' }}>{lake.inflow} m³/s</span></span>
<span style={{ color: 'var(--text-muted)' }}>/ Outflow <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
</div>
</div>
</div>
<button
onClick={() => onSelectLake(lake.id)}
style={{
width: '100%', padding: '0.75rem', borderRadius: '0.5rem',
backgroundColor: 'var(--color-cyan)', color: 'white',
border: 'none', fontWeight: 'bold', cursor: 'pointer',
marginTop: 'auto', transition: 'background-color 0.2s'
}}
onMouseOver={e => e.currentTarget.style.backgroundColor = '#0284c7'}
onMouseOut={e => e.currentTarget.style.backgroundColor = 'var(--color-cyan)'}
>
View Full Details
</button>
</div>
);
};
const SmallCard = ({ lake, onSelectLake }: { lake: Lake, onSelectLake: (id: string) => void }) => {
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
return (
<div
className="kpi-card"
onClick={() => onSelectLake(lake.id)}
style={{ padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', cursor: 'pointer', transition: 'transform 0.2s', minHeight: '120px' }}
onMouseOver={e => e.currentTarget.style.transform = 'translateY(-2px)'}
onMouseOut={e => e.currentTarget.style.transform = 'translateY(0)'}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '120px' }}>
{lake.name}
</div>
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>{lake.level}</div>
</div>
<CircularProgress value={lake.capacity} size={36} strokeWidth={3} />
</div>
<div style={{ flex: 1, minHeight: '30px', marginTop: 'auto' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id={`spark-${lake.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
</linearGradient>
</defs>
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" strokeWidth={1.5} fillOpacity={1} fill={`url(#spark-${lake.id})`} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
};
const LakesOverview = ({ language, onSelectLake }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const [sortBy, setSortBy] = useState<'name' | 'level' | 'capacity' | 'inflow'>('name');
useEffect(() => {
fetch('/data/lakes_index.json')
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error(err));
}, []);
const priorityLakes = lakes.filter(l => l.priority);
const otherLakes = lakes.filter(l => !l.priority);
// Sorting
otherLakes.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'level') return b.level - a.level;
if (sortBy === 'capacity') return b.capacity - a.capacity;
if (sortBy === 'inflow') return b.inflow - a.inflow;
return 0;
});
const sortButtonStyle = (type: string) => ({
background: 'none', border: 'none',
color: sortBy === type ? 'var(--text-main)' : 'var(--text-muted)',
cursor: 'pointer', fontSize: '0.85rem'
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>Overview: Lakes ({lakes.length})</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>Monitoring {lakes.length} reservoirs across the Czech Republic</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
<span>Sort by:</span>
<button style={sortButtonStyle('name')} onClick={() => setSortBy('name')}>Name (A-Z)</button> |
<button style={sortButtonStyle('level')} onClick={() => setSortBy('level')}>Level</button> |
<button style={sortButtonStyle('capacity')} onClick={() => setSortBy('capacity')}>Capacity</button> |
<button style={sortButtonStyle('inflow')} onClick={() => setSortBy('inflow')}>Flow In</button>
</div>
</div>
{priorityLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Priority Reservoirs</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{priorityLakes.map(lake => <PriorityCard key={lake.id} lake={lake} onSelectLake={onSelectLake} />)}
</div>
</section>
)}
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Other Reservoirs ({otherLakes.length})</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
}}>
{otherLakes.map(lake => <SmallCard key={lake.id} lake={lake} onSelectLake={onSelectLake} />)}
</div>
</section>
</div>
);
};
export default LakesOverview;