Files
davisfe.cz/src/components/LakeDetail.tsx
T
2026-06-05 23:34:13 +02:00

316 lines
16 KiB
TypeScript

import { useState, useEffect } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine, Bar } from 'recharts';
import { type Language, t } from '../translations';
import KpiCards from './KpiCards';
interface LipnoData {
timestamp: string;
date: string;
level: number;
inflow: number;
outflow: number;
volume: number;
fullness: number;
temperature?: number | null;
precipitation?: number | null;
}
interface Props {
language: Language;
lakeId: string | null;
}
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
if (active && payload && payload.length) {
const dict = t[language].chart;
if (isWeather) {
return (
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
{payload.map((entry: any, index: number) => {
const isTemp = entry.name === 'temperature' || entry.dataKey === 'temperature';
return (
<p key={index} style={{ margin: 0, color: 'var(--text-main)' }}>
{isTemp ? 'Teplota' : 'Srážky'}: <span style={{ fontWeight: 'bold' }}>{entry.value !== undefined && entry.value !== null ? entry.value.toFixed(1) : 'N/A'} {isTemp ? '°C' : 'mm'}</span>
</p>
);
})}
</div>
);
}
return (
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
{[...payload].sort((a: any, b: any) => {
const order = ['level', 'inflow', 'outflow', 'temperature', 'precipitation'];
const indexA = order.indexOf(a.dataKey);
const indexB = order.indexOf(b.dataKey);
return (indexA === -1 ? 99 : indexA) - (indexB === -1 ? 99 : indexB);
}).map((entry: any, index: number) => {
let labelStr = '';
let unit = '';
let color = '';
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; color = 'var(--color-cyan)'; }
else if (entry.dataKey === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; color = 'var(--color-orange)'; }
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; color = '#8b5cf6'; }
else if (entry.dataKey === 'temperature') { labelStr = language === 'cs' ? 'Teplota' : 'Temperature'; unit = '°C'; color = 'var(--color-red)'; }
else if (entry.dataKey === 'precipitation') { labelStr = language === 'cs' ? 'Srážky' : 'Precipitation'; unit = 'mm'; color = 'var(--color-cyan)'; }
if (!labelStr || entry.value === null || entry.value === undefined) return null;
return (
<div key={index} style={{ margin: 0, color: 'var(--text-main)', display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color, marginRight: '8px' }}></span>
{labelStr}: <span style={{ fontWeight: 'bold', marginLeft: '4px' }}>{entry.value.toFixed(entry.dataKey === 'level' ? 2 : 1)} {unit}</span>
</div>
);
})}
</div>
);
}
return null;
};
const LakeDetail = ({ language, lakeId }: Props) => {
const [data, setData] = useState<LipnoData[]>([]);
const [loading, setLoading] = useState(true);
const [lakeInfo, setLakeInfo] = useState<any>(null);
const [isSmoothed, setIsSmoothed] = useState(true);
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d' | '1y' | 'all'>('7d');
const dict = t[language].chart;
const topbarDict = t[language].topbar;
useEffect(() => {
fetch('/data/lakes_index.json')
.then(res => res.json())
.then(indexData => {
const found = indexData.find((l: any) => l.id === lakeId);
setLakeInfo(found || { name: 'Lipno 1', river: 'Vltava' });
})
.catch(err => console.error(err));
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 === null || isNaN(item.flow) ? 0 : item.flow;
return {
timestamp: item.timestamp,
date: new Date(item.timestamp).toLocaleString(language === 'cs' ? 'cs-CZ' : 'en-GB', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
}),
level: item.level === null || isNaN(item.level) ? 0 : item.level,
outflow: outflow,
inflow: item.inflow || 0,
volume: item.volume || 0,
fullness: 0,
temperature: item.temperature,
precipitation: item.precipitation
};
});
setData(formattedData);
setLoading(false);
})
.catch(err => {
console.error('Failed to load data', err);
setLoading(false);
});
}, [language, lakeId]);
if (loading) {
return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'var(--bg-dark)', color: 'var(--text-main)' }}>
<div style={{ fontSize: '1.25rem' }}>Loading HLADINATOR...</div>
</div>
);
}
const latestData = data[data.length - 1] || { level: 0, inflow: 0, outflow: 0, volume: 0, fullness: 0 };
const curveType = isSmoothed ? 'monotone' : 'linear';
// Find last valid values for KPIs, including 0
const lastValidFlowData = [...data].reverse().find(d => d.outflow !== null && !isNaN(d.outflow) && d.outflow >= 0) || latestData;
const now = new Date().getTime();
const getCutoff = () => {
switch (timeRange) {
case '24h': return now - 24 * 60 * 60 * 1000;
case '7d': return now - 7 * 24 * 60 * 60 * 1000;
case '30d': return now - 30 * 24 * 60 * 60 * 1000;
case '1y': return now - 365 * 24 * 60 * 60 * 1000;
default: return 0;
}
};
const cutoff = getCutoff();
const filteredData = data.filter(d => new Date(d.timestamp).getTime() >= cutoff);
// Downsample data for large time ranges to prevent stuttering
let chartData = filteredData;
if (timeRange === '30d' && filteredData.length > 200) {
chartData = filteredData.filter((_, i) => i % 4 === 0 || i === filteredData.length - 1);
} else if ((timeRange === '1y' || timeRange === 'all') && filteredData.length > 200) {
chartData = filteredData.filter((_, i) => i % 24 === 0 || i === filteredData.length - 1);
}
const animate = chartData.length < 150;
// Find record from 24h, 7d, 30d ago
const nowMs = new Date(latestData.timestamp).getTime();
const targetMs24h = nowMs - 24 * 60 * 60 * 1000;
const targetMs7d = nowMs - 7 * 24 * 60 * 60 * 1000;
const targetMs30d = nowMs - 30 * 24 * 60 * 60 * 1000;
let level24hAgo = latestData.level;
let level7dAgo = latestData.level;
let level30dAgo = latestData.level;
let minDiff24h = Infinity;
let minDiff7d = Infinity;
let minDiff30d = Infinity;
for (const d of data) {
const t = new Date(d.timestamp).getTime();
const diff24h = Math.abs(t - targetMs24h);
if (diff24h < minDiff24h) {
minDiff24h = diff24h;
level24hAgo = d.level;
}
const diff7d = Math.abs(t - targetMs7d);
if (diff7d < minDiff7d) {
minDiff7d = diff7d;
level7dAgo = d.level;
}
const diff30d = Math.abs(t - targetMs30d);
if (diff30d < minDiff30d) {
minDiff30d = diff30d;
level30dAgo = d.level;
}
}
const levelDiff24h = latestData.level - level24hAgo;
const levelDiff7d = latestData.level - level7dAgo;
const levelDiff30d = latestData.level - level30dAgo;
const kpiData = {
level: latestData.level,
levelDiff24h,
levelDiff7d,
levelDiff30d,
inflow: lastValidFlowData.inflow,
outflow: lastValidFlowData.outflow,
volume: lakeInfo?.volume || 0,
fullness: lakeInfo?.capacity || 0,
storageDiff: lakeInfo?.storageDiff
};
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' }}>
<span>{topbarDict.updated} {new Date().toLocaleDateString(language === 'cs' ? 'cs-CZ' : 'en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}, {new Date().toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' })} UTC</span>
<div className="status-dot"></div>
</div>
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
{/* CHART SECTION */}
<div className="chart-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{dict.title} {lakeInfo ? `${lakeInfo.name} ${lakeInfo.river ? `- ${lakeInfo.river}` : ''}` : 'Lipno 1 - Vltava'}</h3>
<div className="top-time-controls" style={{ margin: 0 }}>
<button className={timeRange === '24h' ? 'active' : ''} onClick={() => setTimeRange('24h')}>24h</button>
<button className={timeRange === '7d' ? 'active' : ''} onClick={() => setTimeRange('7d')}>7d</button>
<button className={timeRange === '30d' ? 'active' : ''} onClick={() => setTimeRange('30d')}>30d</button>
<button className={timeRange === '1y' ? 'active' : ''} onClick={() => setTimeRange('1y')}>{dict.year}</button>
<button className={timeRange === 'all' ? 'active' : ''} onClick={() => setTimeRange('all')}>{dict.all}</button>
</div>
</div>
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '1rem' }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorLevel" 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>
<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, (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} />} />
{/* Data Series */}
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} />
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-orange)" strokeWidth={2} dot={false} isAnimationActive={animate} />
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="#8b5cf6" strokeWidth={2} dot={false} isAnimationActive={animate} />
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Chart Legend */}
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
<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-orange)' }}></div> {dict.outflow}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: '#8b5cf6' }}></div> {dict.inflow}</span>
</div>
{/* WEATHER CHART SECTION */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', marginTop: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{language === 'cs' ? 'Počasí (Teplota a Srážky)' : 'Weather (Temperature & Precipitation)'}</h3>
</div>
<div style={{ flex: 1, minHeight: '200px', width: '100%', marginTop: '0.5rem' }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 10, right: 0, left: 10, bottom: 0 }}>
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
<YAxis yAxisId="temp" domain={['auto', 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(1)} />
<YAxis yAxisId="precip" orientation="right" domain={[0, 'auto']} 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} isWeather={true} />} />
<Bar yAxisId="precip" dataKey="precipitation" fill="var(--color-cyan)" fillOpacity={0.6} isAnimationActive={animate} />
<Line yAxisId="temp" type={curveType} dataKey="temperature" stroke="var(--color-red)" strokeWidth={2} dot={true} isAnimationActive={animate} />
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> {language === 'cs' ? 'Teplota vzduchu' : 'Temperature'} [°C]</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '12px', backgroundColor: 'var(--color-cyan)', opacity: 0.6 }}></div> {language === 'cs' ? 'Srážky' : 'Precipitation'} [mm]</span>
</div>
{/* Smoothed Toggle Control */}
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '3rem', marginBottom: '1rem' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{dict.view}</span>
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.9rem' }}>
<span style={{ color: !isSmoothed ? 'var(--text-main)' : 'var(--text-muted)', transition: '0.2s', marginRight: '0.5rem' }}>{dict.raw}</span>
<div
className={`toggle-switch ${isSmoothed ? 'on' : ''}`}
onClick={() => setIsSmoothed(!isSmoothed)}
></div>
<span style={{ color: isSmoothed ? 'var(--text-main)' : 'var(--text-muted)', transition: '0.2s', marginLeft: '0.5rem' }}>{dict.smoothed}</span>
</div>
</div>
</div>
<div className="dashboard-footer" style={{ marginTop: '0' }}>
<span>{dict.dataSources} <a href="https://www.chmi.cz/" target="_blank" rel="noreferrer">ČHMÚ</a>, <a href="https://www.pvl.cz/" target="_blank" rel="noreferrer">pvl.cz</a></span>
<span>{dict.createdIn}</span>
</div>
</div>
);
};
export default LakeDetail;