feat: implement automated data scraping and history generation pipeline for PVL reservoir levels
This commit is contained in:
@@ -6,8 +6,10 @@ interface KpiData {
|
||||
level: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
fullness: number;
|
||||
storageDiff?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -105,12 +107,12 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
{language === 'cs' ? "Odhad vypočítaný z aktuální výšky hladiny (mezi min. a max. kótou)." : "Estimate calculated from current water level (between min and max levels)."}
|
||||
{language === 'cs' ? "Rozdíl mezi aktuální hladinou a hladinou zásobního prostoru (důležité pro jachtaře a rekreaci)." : "Difference between current water level and storage space level (important for sailing and recreation)."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem' }}>
|
||||
{data.fullness > 0 ? `${data.fullness.toFixed(1)}%` : 'N/A'}
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)' }}>
|
||||
{data.storageDiff !== undefined && data.storageDiff !== 0 ? (data.storageDiff > 0 ? `+${data.storageDiff.toFixed(2)} m` : `${data.storageDiff.toFixed(2)} m`) : (data.fullness > 0 ? `${data.fullness.toFixed(1)}%` : 'N/A')}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
{dict.volume}: {data.volume.toFixed(1)} mil. m³
|
||||
|
||||
+102
-14
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine } from 'recharts';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine, Bar } from 'recharts';
|
||||
import { type Language, t } from '../translations';
|
||||
import KpiCards from './KpiCards';
|
||||
|
||||
@@ -11,6 +11,8 @@ interface LipnoData {
|
||||
outflow: number;
|
||||
volume: number;
|
||||
fullness: number;
|
||||
temperature?: number | null;
|
||||
precipitation?: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -18,14 +20,42 @@ interface Props {
|
||||
lakeId: string | null;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, language }: any) => {
|
||||
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>
|
||||
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.level}: <span style={{ fontWeight: 'bold' }}>{payload[0].value.toFixed(2)} m n. m.</span></p>
|
||||
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.outflow}: <span style={{ fontWeight: 'bold' }}>{payload[1].value.toFixed(1)} m³/s</span></p>
|
||||
{payload.map((entry: any, index: number) => {
|
||||
let labelStr = '';
|
||||
let unit = '';
|
||||
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; }
|
||||
else if (entry.dataKey === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; }
|
||||
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; }
|
||||
|
||||
if (!labelStr || (entry.dataKey === 'inflow' && entry.value === 0)) return null;
|
||||
|
||||
return (
|
||||
<p key={index} style={{ margin: 0, color: 'var(--text-main)' }}>
|
||||
{labelStr}: <span style={{ fontWeight: 'bold' }}>{entry.value.toFixed(entry.dataKey === 'level' ? 2 : 1)} {unit}</span>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +67,7 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
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;
|
||||
|
||||
@@ -67,7 +98,9 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
outflow: outflow,
|
||||
inflow: item.inflow || 0,
|
||||
volume: item.volume || 0,
|
||||
fullness: 0
|
||||
fullness: 0,
|
||||
temperature: item.temperature,
|
||||
precipitation: item.precipitation
|
||||
};
|
||||
});
|
||||
setData(formattedData);
|
||||
@@ -93,12 +126,37 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
// 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 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;
|
||||
|
||||
const kpiData = {
|
||||
level: latestData.level,
|
||||
inflow: lastValidFlowData.inflow,
|
||||
outflow: lastValidFlowData.outflow,
|
||||
volume: lakeInfo?.volume || 0,
|
||||
fullness: lakeInfo?.capacity || 0
|
||||
fullness: lakeInfo?.capacity || 0,
|
||||
storageDiff: lakeInfo?.storageDiff
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -115,17 +173,17 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
<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="active">24h</button>
|
||||
<button>7d</button>
|
||||
<button>30d</button>
|
||||
<button>{dict.year}</button>
|
||||
<button>{dict.all}</button>
|
||||
<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={data} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
|
||||
<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}/>
|
||||
@@ -140,8 +198,9 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
<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)" />
|
||||
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-orange)" strokeWidth={2} dot={false} />
|
||||
<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>
|
||||
@@ -150,6 +209,7 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
<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>
|
||||
|
||||
{/* Smoothed Toggle Control */}
|
||||
@@ -166,6 +226,34 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WEATHER CHART SECTION */}
|
||||
<div className="chart-card" style={{ marginTop: '1.5rem' }}>
|
||||
<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)' }}>Počasí (Teplota a Srážky)</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: '250px', width: '100%', marginTop: '1rem' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 20, 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> Teplota vzduchu [°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> Srážky (24h) [mm]</span>
|
||||
</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>
|
||||
|
||||
@@ -259,4 +259,19 @@ body {
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Fix Recharts focus outlines when clicking the chart */
|
||||
.recharts-wrapper,
|
||||
.recharts-wrapper *,
|
||||
.recharts-surface,
|
||||
.recharts-surface *,
|
||||
.recharts-responsive-container,
|
||||
.recharts-responsive-container * {
|
||||
outline: none !important;
|
||||
}
|
||||
.recharts-wrapper:focus,
|
||||
.recharts-surface:focus,
|
||||
.recharts-responsive-container:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
+2
-2
@@ -17,7 +17,7 @@ export const t = {
|
||||
flow: 'FLOW RATE',
|
||||
inflow: 'Inflow',
|
||||
outflow: 'Outflow',
|
||||
fullness: 'CAPACITY',
|
||||
fullness: 'STORAGE LEVEL',
|
||||
volume: 'Volume'
|
||||
},
|
||||
chart: {
|
||||
@@ -65,7 +65,7 @@ export const t = {
|
||||
flow: 'PRŮTOK',
|
||||
inflow: 'Přítok',
|
||||
outflow: 'Odtok',
|
||||
fullness: 'NAPLNĚNOST',
|
||||
fullness: 'ZÁSOBNÍ PROSTOR',
|
||||
volume: 'Objem'
|
||||
},
|
||||
chart: {
|
||||
|
||||
Reference in New Issue
Block a user