242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Helmet } from 'react-helmet-async';
|
|
import { FiTrendingUp, FiTrendingDown, FiStar } from 'react-icons/fi';
|
|
import { type Language, t } from '../translations';
|
|
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { slugify } from '../utils/slugify';
|
|
import { useFavorites } from '../hooks/useFavorites';
|
|
import { CircularProgress } from './CircularProgress';
|
|
|
|
interface Lake {
|
|
id: string;
|
|
name: string;
|
|
river: string;
|
|
priority: boolean;
|
|
level: number;
|
|
capacity: number;
|
|
storageDiff?: number;
|
|
inflow: number;
|
|
outflow: number;
|
|
volume: number;
|
|
sparkline: number[];
|
|
}
|
|
|
|
interface Props {
|
|
language: Language;
|
|
}
|
|
|
|
const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
|
|
const navigate = useNavigate();
|
|
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
|
|
|
|
const minVal = Math.min(...lake.sparkline);
|
|
const maxVal = Math.max(...lake.sparkline);
|
|
const diff = maxVal - minVal;
|
|
// Enforce a minimum visual span of 0.5 meters so tiny fluctuations don't look like mountains
|
|
const padding = diff < 0.5 ? (0.5 - diff) / 2 : 0;
|
|
const yDomain = [minVal - padding, maxVal + padding];
|
|
|
|
return (
|
|
<div
|
|
className="kpi-card priority-lake-card"
|
|
onClick={() => navigate(`/${slugify(lake.name)}`)}
|
|
style={{ cursor: 'pointer', flex: 1, padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
|
|
>
|
|
{/* Star / Favorite button */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onToggleFav(lake.id); }}
|
|
title={isFav ? (language === 'cs' ? 'Odepnout' : 'Unpin') : (language === 'cs' ? 'Připnout jako oblíbené' : 'Pin to favorites')}
|
|
style={{
|
|
position: 'absolute', top: '1rem', right: '1rem',
|
|
background: 'none', border: 'none', cursor: 'pointer',
|
|
color: isFav ? '#f59e0b' : 'var(--text-muted)',
|
|
opacity: isFav ? 1 : 0.4,
|
|
transition: 'color 0.2s, opacity 0.2s, transform 0.15s',
|
|
padding: '4px',
|
|
display: 'flex', alignItems: 'center',
|
|
zIndex: 2,
|
|
}}
|
|
onMouseOver={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'scale(1.2)'; }}
|
|
onMouseOut={(e) => { e.currentTarget.style.opacity = isFav ? '1' : '0.4'; e.currentTarget.style.transform = 'scale(1)'; }}
|
|
>
|
|
<FiStar size={18} fill={isFav ? '#f59e0b' : 'none'} />
|
|
</button>
|
|
|
|
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>{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>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
|
|
<div>
|
|
{lake.storageDiff !== undefined && (
|
|
<div style={{ fontSize: '1.25rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold' }}>
|
|
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
|
|
</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>
|
|
<YAxis domain={yDomain} hide />
|
|
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" baseValue={yDomain[0]} />
|
|
</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>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
<FiTrendingDown color="var(--color-red)" />
|
|
<span style={{ color: 'var(--text-muted)' }}>Outflow <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SmallLakeCard = ({ lake, isFav, onToggleFav }: { lake: Lake, isFav: boolean, onToggleFav: (id: string) => void }) => {
|
|
const navigate = useNavigate();
|
|
|
|
return (
|
|
<div
|
|
className="kpi-card"
|
|
onClick={() => navigate(`/${slugify(lake.name)}`)}
|
|
style={{ cursor: 'pointer', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', position: 'relative' }}
|
|
>
|
|
{/* Star button */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onToggleFav(lake.id); }}
|
|
title={isFav ? (language === 'cs' ? 'Odepnout' : 'Unpin') : (language === 'cs' ? 'Připnout jako oblíbené' : 'Pin to favorites')}
|
|
style={{
|
|
position: 'absolute', top: '0.6rem', right: '0.6rem',
|
|
background: 'none', border: 'none', cursor: 'pointer',
|
|
color: isFav ? '#f59e0b' : 'var(--text-muted)',
|
|
opacity: isFav ? 1 : 0.4,
|
|
transition: 'color 0.2s, opacity 0.2s, transform 0.15s',
|
|
padding: '2px',
|
|
display: 'flex', alignItems: 'center',
|
|
zIndex: 2,
|
|
}}
|
|
onMouseOver={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'scale(1.2)'; }}
|
|
onMouseOut={(e) => { e.currentTarget.style.opacity = isFav ? '1' : '0.4'; e.currentTarget.style.transform = 'scale(1)'; }}
|
|
>
|
|
<FiStar size={14} fill={isFav ? '#f59e0b' : 'none'} />
|
|
</button>
|
|
|
|
<div style={{ fontSize: '0.85rem', fontWeight: 'bold', paddingRight: '1.5rem', lineHeight: 1.2 }}>{lake.name}</div>
|
|
<div style={{ fontSize: '1.1rem', fontWeight: 'bold', color: 'var(--color-cyan)' }}>{lake.level} <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n.m.</span></div>
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
|
<span style={{ color: lake.capacity >= 80 ? 'var(--color-green)' : lake.capacity < 40 ? 'var(--color-red)' : 'var(--text-muted)', fontWeight: 600 }}>
|
|
{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'}
|
|
</span>
|
|
{lake.storageDiff !== undefined && (
|
|
<span style={{ color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', marginLeft: '4px' }}>
|
|
({lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const LakesOverview = ({ language }: Props) => {
|
|
const [lakes, setLakes] = useState<Lake[]>([]);
|
|
const { isFavorite, toggleFavorite, favorites } = useFavorites();
|
|
|
|
useEffect(() => {
|
|
const loadData = () => {
|
|
fetch(`/data/lakes_index.json?t=${Date.now()}`)
|
|
.then(res => res.json())
|
|
.then(data => setLakes(data))
|
|
.catch(err => console.error(err));
|
|
};
|
|
|
|
loadData();
|
|
const intervalId = setInterval(loadData, 60 * 1000); // Poll every 1 minute
|
|
return () => clearInterval(intervalId);
|
|
}, []);
|
|
|
|
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
|
|
const priorityLakes = lakes.filter(l => l.priority && !isFavorite(l.id));
|
|
const otherLakes = lakes.filter(l => !l.priority && !isFavorite(l.id));
|
|
|
|
otherLakes.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
|
<Helmet>
|
|
<title>{t[language].seo.homeTitle}</title>
|
|
<meta name="description" content={t[language].seo.homeDesc} />
|
|
<meta property="og:title" content={t[language].seo.homeTitle} />
|
|
<meta property="og:description" content={t[language].seo.homeDesc} />
|
|
</Helmet>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0', color: 'var(--text-main)' }}>{t[language].sidebar.lakes} ({lakes.length})</h1>
|
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
|
{t[language].seo.homeDesc}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Favorites section */}
|
|
{favoriteLakes.length > 0 && (
|
|
<section>
|
|
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<FiStar size={16} fill="#f59e0b" color="#f59e0b" /> {language === 'cs' ? 'Oblíbená' : 'Favorites'} ({favoriteLakes.length})
|
|
</h2>
|
|
<div style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
|
gap: '1.5rem'
|
|
}}>
|
|
{favoriteLakes.map(lake => (
|
|
<LakeCard key={lake.id} lake={lake} language={language} isFav={true} onToggleFav={toggleFavorite} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{priorityLakes.length > 0 && (
|
|
<section>
|
|
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>{language === 'cs' ? 'Jezera a nádrže' : 'Lakes and Reservoirs'}</h2>
|
|
<div style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
|
gap: '1.5rem'
|
|
}}>
|
|
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={false} onToggleFav={toggleFavorite} />)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LakesOverview;
|