feat: update water level metrics and optimize sidebar UI layout

This commit is contained in:
David Fencl
2026-06-06 18:38:18 +02:00
parent 6395df1992
commit cf05e844d8
25 changed files with 503 additions and 175 deletions
+28 -1
View File
@@ -465,10 +465,37 @@
{ {
"timestamp": "2026-06-06T15:10:00.000Z", "timestamp": "2026-06-06T15:10:00.000Z",
"level": 467.75, "level": 467.75,
"flow": 0.7,
"inflow": 0,
"volume": 0,
"temperature": 22.2,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:20:00.000Z",
"level": 467.75,
"flow": 0.7,
"inflow": 0,
"volume": 0,
"temperature": 22.2,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:30:00.000Z",
"level": 467.75,
"flow": 0.7,
"inflow": 0,
"volume": 0,
"temperature": 22.2,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:40:00.000Z",
"level": 467.75,
"flow": 0, "flow": 0,
"inflow": 2.24, "inflow": 2.24,
"volume": 26.54, "volume": 26.54,
"temperature": 22, "temperature": 21.8,
"precipitation": 0 "precipitation": 0
} }
] ]
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z", "timestamp": "2026-06-06T15:10:00.000Z",
"level": 352.83, "level": 352.83,
"flow": 2.52, "flow": 2.52,
"inflow": 0,
"volume": 0,
"temperature": 22.9,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:20:00.000Z",
"level": 352.83,
"flow": 2.52,
"inflow": 0,
"volume": 0,
"temperature": 22.9,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:30:00.000Z",
"level": 352.84,
"flow": 2.52,
"inflow": 0,
"volume": 0,
"temperature": 22.9,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:40:00.000Z",
"level": 352.84,
"flow": 0,
"inflow": 1.47, "inflow": 1.47,
"volume": 32.31, "volume": 32.31,
"temperature": 22.7, "temperature": 22.5,
"precipitation": 0 "precipitation": 0
} }
] ]
+27
View File
@@ -476,6 +476,33 @@
"level": 369.83, "level": 369.83,
"flow": 14.23, "flow": 14.23,
"inflow": 0, "inflow": 0,
"volume": 0,
"temperature": 22.5,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:20:00.000Z",
"level": 369.83,
"flow": 14.23,
"inflow": 0,
"volume": 0,
"temperature": 22.5,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:30:00.000Z",
"level": 369.83,
"flow": 14.23,
"inflow": 0,
"volume": 0,
"temperature": 22.5,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:40:00.000Z",
"level": 369.83,
"flow": 14.23,
"inflow": 0,
"volume": 20.37, "volume": 20.37,
"temperature": 22.6, "temperature": 22.6,
"precipitation": 0 "precipitation": 0
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z", "timestamp": "2026-06-06T15:10:00.000Z",
"level": 352.43, "level": 352.43,
"flow": 19.05, "flow": 19.05,
"inflow": 0,
"volume": 0,
"temperature": 21.9,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:20:00.000Z",
"level": 352.43,
"flow": 19.05,
"inflow": 0,
"volume": 0,
"temperature": 21.9,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:30:00.000Z",
"level": 352.43,
"flow": 19.05,
"inflow": 0,
"volume": 0,
"temperature": 21.9,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:40:00.000Z",
"level": 352.43,
"flow": 19.05,
"inflow": 13.43, "inflow": 13.43,
"volume": 2.74, "volume": 2.74,
"temperature": 21.9, "temperature": 22.1,
"precipitation": 0 "precipitation": 0
} }
] ]
+28 -1
View File
@@ -474,10 +474,37 @@
{ {
"timestamp": "2026-06-06T15:10:00.000Z", "timestamp": "2026-06-06T15:10:00.000Z",
"level": 723.09, "level": 723.09,
"flow": 1.51,
"inflow": 0,
"volume": 0,
"temperature": 20.8,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:20:00.000Z",
"level": 723.09,
"flow": 1.51,
"inflow": 0,
"volume": 0,
"temperature": 20.8,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:30:00.000Z",
"level": 723.09,
"flow": 1.51,
"inflow": 0,
"volume": 0,
"temperature": 20.8,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:40:00.000Z",
"level": 723.09,
"flow": 0, "flow": 0,
"inflow": 9.25, "inflow": 9.25,
"volume": 199.67, "volume": 199.67,
"temperature": 20.7, "temperature": 20.6,
"precipitation": 0 "precipitation": 0
} }
] ]
+29 -2
View File
@@ -465,7 +465,7 @@
{ {
"timestamp": "2026-06-06T15:00:00.000Z", "timestamp": "2026-06-06T15:00:00.000Z",
"level": 558.44, "level": 558.44,
"flow": 0, "flow": 7.51,
"inflow": 0, "inflow": 0,
"volume": 0, "volume": 0,
"temperature": 21.8, "temperature": 21.8,
@@ -474,10 +474,37 @@
{ {
"timestamp": "2026-06-06T15:10:00.000Z", "timestamp": "2026-06-06T15:10:00.000Z",
"level": 558.43, "level": 558.43,
"flow": 7.51,
"inflow": 0,
"volume": 0,
"temperature": 21.8,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:20:00.000Z",
"level": 558.41,
"flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 21.8,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:30:00.000Z",
"level": 558.38,
"flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 21.8,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:40:00.000Z",
"level": 558.37,
"flow": 0, "flow": 0,
"inflow": 5.37, "inflow": 5.37,
"volume": 0.35, "volume": 0.35,
"temperature": 21.7, "temperature": 21.6,
"precipitation": 0 "precipitation": 0
} }
] ]
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z", "timestamp": "2026-06-06T15:10:00.000Z",
"level": 345.29, "level": 345.29,
"flow": 0, "flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 22.6,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:20:00.000Z",
"level": 345.29,
"flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 22.6,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:30:00.000Z",
"level": 345.29,
"flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 22.6,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:40:00.000Z",
"level": 345.29,
"flow": 0,
"inflow": 24.39, "inflow": 24.39,
"volume": 522.72, "volume": 522.72,
"temperature": 22.6, "temperature": 22.3,
"precipitation": 0 "precipitation": 0
} }
] ]
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z", "timestamp": "2026-06-06T15:10:00.000Z",
"level": 269.88, "level": 269.88,
"flow": 0, "flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 23.5,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:20:00.000Z",
"level": 269.88,
"flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 23.5,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:30:00.000Z",
"level": 269.88,
"flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 23.5,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:40:00.000Z",
"level": 269.89,
"flow": 0,
"inflow": 81.06, "inflow": 81.06,
"volume": 261.1, "volume": 261.1,
"temperature": 23.5, "temperature": 23.3,
"precipitation": 0 "precipitation": 0
} }
] ]
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z", "timestamp": "2026-06-06T15:10:00.000Z",
"level": 216.98, "level": 216.98,
"flow": 0, "flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 23.2,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:20:00.000Z",
"level": 216.96,
"flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 23.2,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:30:00.000Z",
"level": 216.94,
"flow": 0,
"inflow": 0,
"volume": 0,
"temperature": 23.2,
"precipitation": 0
},
{
"timestamp": "2026-06-06T15:40:00.000Z",
"level": 216.95,
"flow": 0,
"inflow": 48.25, "inflow": 48.25,
"volume": 8.14, "volume": 8.14,
"temperature": 23.2, "temperature": 22.9,
"precipitation": 0 "precipitation": 0
} }
] ]
+31 -31
View File
@@ -33,9 +33,9 @@
"name": "Lipno II", "name": "Lipno II",
"river": "Vltava", "river": "Vltava",
"priority": true, "priority": true,
"level": "558.43", "level": "558.37",
"capacity": 23.3, "capacity": 23.3,
"storageDiff": -2.07, "storageDiff": -2.13,
"inflow": "5.4", "inflow": "5.4",
"outflow": "0.0", "outflow": "0.0",
"volume": 0.35, "volume": 0.35,
@@ -43,9 +43,6 @@
"lat": 48.625, "lat": 48.625,
"lng": 14.318, "lng": 14.318,
"sparkline": [ "sparkline": [
558.63,
558.62,
558.6,
558.58, 558.58,
558.56, 558.56,
558.54, 558.54,
@@ -54,7 +51,10 @@
558.49, 558.49,
558.47, 558.47,
558.44, 558.44,
558.43 558.43,
558.41,
558.38,
558.37
] ]
}, },
{ {
@@ -72,9 +72,9 @@
"lat": 49.183, "lat": 49.183,
"lng": 14.444, "lng": 14.444,
"sparkline": [ "sparkline": [
369.84, 369.83,
369.84, 369.83,
369.84, 369.83,
369.83, 369.83,
369.83, 369.83,
369.83, 369.83,
@@ -101,9 +101,6 @@
"lat": 49.255, "lat": 49.255,
"lng": 14.398, "lng": 14.398,
"sparkline": [ "sparkline": [
352.44,
352.43,
352.43,
352.44, 352.44,
352.44, 352.44,
352.44, 352.44,
@@ -112,6 +109,9 @@
352.43, 352.43,
352.44, 352.44,
352.44, 352.44,
352.43,
352.43,
352.43,
352.43 352.43
] ]
}, },
@@ -149,9 +149,9 @@
"name": "Slapy", "name": "Slapy",
"river": "Vltava", "river": "Vltava",
"priority": true, "priority": true,
"level": "269.88", "level": "269.89",
"capacity": 97, "capacity": 97,
"storageDiff": -0.72, "storageDiff": -0.71,
"inflow": "81.1", "inflow": "81.1",
"outflow": "0.0", "outflow": "0.0",
"volume": 261.1, "volume": 261.1,
@@ -159,9 +159,6 @@
"lat": 49.822, "lat": 49.822,
"lng": 14.436, "lng": 14.436,
"sparkline": [ "sparkline": [
269.88,
269.89,
269.89,
269.89, 269.89,
269.88, 269.88,
269.88, 269.88,
@@ -170,7 +167,10 @@
269.88, 269.88,
269.88, 269.88,
269.88, 269.88,
269.88 269.88,
269.88,
269.88,
269.89
] ]
}, },
{ {
@@ -178,9 +178,9 @@
"name": "Štěchovice", "name": "Štěchovice",
"river": "Vltava", "river": "Vltava",
"priority": true, "priority": true,
"level": "216.98", "level": "216.95",
"capacity": 72.7, "capacity": 72.7,
"storageDiff": -2.42, "storageDiff": -2.45,
"inflow": "48.3", "inflow": "48.3",
"outflow": "0.0", "outflow": "0.0",
"volume": 8.14, "volume": 8.14,
@@ -188,9 +188,6 @@
"lat": 49.845, "lat": 49.845,
"lng": 14.412, "lng": 14.412,
"sparkline": [ "sparkline": [
217,
216.97,
216.99,
216.98, 216.98,
216.95, 216.95,
216.98, 216.98,
@@ -199,7 +196,10 @@
216.98, 216.98,
216.97, 216.97,
216.95, 216.95,
216.98 216.98,
216.96,
216.94,
216.95
] ]
}, },
{ {
@@ -236,19 +236,16 @@
"name": "Hracholusky", "name": "Hracholusky",
"river": "Mže", "river": "Mže",
"priority": true, "priority": true,
"level": "352.83", "level": "352.84",
"capacity": 57, "capacity": 57,
"storageDiff": -16.67, "storageDiff": -16.66,
"inflow": "1.5", "inflow": "1.5",
"outflow": "2.5", "outflow": "0.0",
"volume": 32.31, "volume": 32.31,
"maxVolume": 56.7, "maxVolume": 56.7,
"lat": 49.789, "lat": 49.789,
"lng": 13.155, "lng": 13.155,
"sparkline": [ "sparkline": [
352.84,
352.84,
352.83,
352.84, 352.84,
352.84, 352.84,
352.84, 352.84,
@@ -257,7 +254,10 @@
352.84, 352.84,
352.84, 352.84,
352.84, 352.84,
352.83 352.83,
352.83,
352.84,
352.84
] ]
} }
] ]
+36 -9
View File
@@ -1,25 +1,52 @@
import { execSync } from 'child_process'; import { execSync } from 'child_process';
const args = process.argv.slice(2); // How many minutes after the 10-minute mark should we run the scraper?
const minutes = parseInt(args[0], 10) || 10; // The basin authority (PVL) generates data at HH:00, HH:10, HH:20... but it takes time to publish.
const intervalMs = minutes * 60 * 1000; // 5 minutes (HH:05, HH:15...) is a safe buffer to avoid fetching outdated data.
const offsetMinutes = 5;
console.log(`\n⏱️ HLADINATOR Watcher spuštěn!`); console.log(`\n⏱️ HLADINATOR Watcher spuštěn!`);
console.log(`Budu automaticky stahovat nová data každých ${minutes} minut.\n`); console.log(`Budu automaticky stahovat nová data vždy v časech končících na ${offsetMinutes} (např. 10:05, 10:15, 10:25...).\nTo zajistí, že má Povodí dostatek času data vygenerovat a nahrát.\n`);
function runUpdate() { function runUpdate() {
const now = new Date().toLocaleTimeString('cs-CZ'); const now = new Date().toLocaleTimeString('cs-CZ');
console.log(`[${now}] 🔄 Spouštím npm run data:update...`); console.log(`[${now}] 🔄 Spouštím npm run data:update...`);
try { try {
execSync('npm run data:update', { stdio: 'inherit' }); execSync('npm run data:update', { stdio: 'inherit' });
console.log(`[${new Date().toLocaleTimeString('cs-CZ')}] ✅ Úspěšně hotovo. Další kontrola za ${minutes} minut...\n`); console.log(`[${new Date().toLocaleTimeString('cs-CZ')}] ✅ Úspěšně hotovo.\n`);
} catch (error: any) { } catch (error: any) {
console.error(`[${new Date().toLocaleTimeString('cs-CZ')}] ❌ Chyba při aktualizaci:`, error.message); console.error(`[${new Date().toLocaleTimeString('cs-CZ')}] ❌ Chyba při aktualizaci:`, error.message);
} }
scheduleNextRun();
} }
// Spustit ihned po zapnutí function scheduleNextRun() {
runUpdate(); const now = new Date();
const currentMinute = now.getMinutes();
// Find the next target minute (ending in 5)
// E.g. if it's 12, next will be 15. If it's 26, next will be 35.
let nextMinute = Math.floor(currentMinute / 10) * 10 + offsetMinutes;
if (nextMinute <= currentMinute) {
nextMinute += 10;
}
const targetTime = new Date(now);
if (nextMinute >= 60) {
targetTime.setHours(targetTime.getHours() + 1);
targetTime.setMinutes(nextMinute % 60);
} else {
targetTime.setMinutes(nextMinute);
}
targetTime.setSeconds(0);
targetTime.setMilliseconds(0);
const waitMs = targetTime.getTime() - now.getTime();
console.log(`[${new Date().toLocaleTimeString('cs-CZ')}] ⏳ Další stahování naplánováno na: ${targetTime.toLocaleTimeString('cs-CZ')} (za ${(waitMs / 60000).toFixed(1)} minut)\n`);
setTimeout(runUpdate, waitMs);
}
// A pak periodicky v zadaném intervalu // Run update immediately on first launch and then set the timer
setInterval(runUpdate, intervalMs); runUpdate();
+9 -4
View File
@@ -7,12 +7,12 @@
} }
.sidebar { .sidebar {
width: 250px; width: 190px;
background-color: var(--bg-card); background-color: var(--bg-card);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 1.5rem 1rem; padding: 1.5rem 0.75rem;
transition: width 0.3s ease; transition: width 0.3s ease;
overflow: hidden; overflow: hidden;
} }
@@ -75,8 +75,8 @@
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 0.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.95rem; font-size: 0.95rem;
@@ -86,6 +86,11 @@
white-space: nowrap; white-space: nowrap;
} }
.nav-item svg {
flex-shrink: 0;
min-width: 20px;
}
.nav-item:hover { .nav-item:hover {
background-color: rgba(255, 255, 255, 0.03); background-color: rgba(255, 255, 255, 0.03);
color: var(--text-main); color: var(--text-main);
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Routes, Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom'; import { Routes, Route, useParams, Navigate } from 'react-router-dom';
import LakeDetail from './components/LakeDetail'; import LakeDetail from './components/LakeDetail';
import LakesOverview from './components/LakesOverview'; import LakesOverview from './components/LakesOverview';
import LakeMap from './components/LakeMap'; import LakeMap from './components/LakeMap';
+40 -9
View File
@@ -6,7 +6,7 @@ import { Helmet } from 'react-helmet-async';
import { CircularProgress } from './CircularProgress'; import { CircularProgress } from './CircularProgress';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify'; import { slugify } from '../utils/slugify';
import { AreaChart, Area, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi'; import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
interface Lake { interface Lake {
@@ -80,7 +80,21 @@ const FavoritesOverview = ({ language }: Props) => {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
{favoriteLakes.map(lake => { {favoriteLakes.map(lake => {
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val })); const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
const isFav = isFavorite(lake.id);
const minVal = Math.min(...lake.sparkline);
const maxVal = Math.max(...lake.sparkline);
const diff = maxVal - minVal;
const padding = diff === 0 ? 0.1 : diff * 0.1;
const yDomain = [minVal - padding, maxVal + padding];
const firstVal = lake.sparkline[0] || 0;
const lastVal = lake.sparkline[lake.sparkline.length - 1] || 0;
const trendDiff = lastVal - firstVal;
let trendColor = 'var(--color-cyan)';
if (trendDiff > 0.01) trendColor = 'var(--color-green)';
else if (trendDiff < -0.01) trendColor = 'var(--color-red)';
return ( return (
<div <div
key={lake.id} key={lake.id}
@@ -127,14 +141,31 @@ const FavoritesOverview = ({ language }: Props) => {
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ flex: 1, height: '40px', marginRight: '2rem' }}>
<FiTrendingUp color="var(--color-green)" /> <ResponsiveContainer width="100%" height="100%">
<span style={{ color: 'var(--text-muted)' }}>{t[language].kpi.inflow} <span style={{ color: 'var(--color-green)' }}>{lake.inflow} m³/s</span></span> <AreaChart data={chartData}>
<defs>
<linearGradient id={`colorSparkFav-${lake.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={trendColor} stopOpacity={0.8} />
<stop offset="95%" stopColor={trendColor} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis domain={yDomain} hide />
<Area type="monotone" dataKey="value" stroke={trendColor} fillOpacity={1} fill={`url(#colorSparkFav-${lake.id})`} baseValue={yDomain[0]} />
</AreaChart>
</ResponsiveContainer>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingDown color="var(--color-red)" /> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
<span style={{ color: 'var(--text-muted)' }}>{t[language].kpi.outflow} <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span> <div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingUp color="var(--color-green)" />
<span style={{ color: 'var(--text-muted)' }}>{t[language].kpi.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)' }}>{t[language].kpi.outflow} <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
</div>
</div> </div>
</div> </div>
+51 -22
View File
@@ -13,6 +13,9 @@ interface KpiData {
volume: number; volume: number;
fullness: number; fullness: number;
storageDiff?: number; storageDiff?: number;
minDiff?: number;
avgInflow24h?: number;
avgOutflow24h?: number;
} }
interface Props { interface Props {
@@ -37,43 +40,62 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
return ( return (
<> <>
{/* CARD 1: HLADINA */} {/* CARD 1: WATER LEVEL */}
<div className="kpi-card"> <div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}> <div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{dict.level} {lakeName} {dict.level} {lakeName}
</div> </div>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, marginBottom: '0.5rem' }}> <div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, marginBottom: '0.5rem', whiteSpace: 'nowrap' }}>
{data.level.toFixed(2)} <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n. m.</span> {data.level.toFixed(2)} <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n. m.</span>
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '0.5rem' }}>
<div style={{ fontSize: '0.85rem', color: (data.levelDiff24h ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: 'rgba(0,0,0,0.15)', padding: '0.3rem 0.6rem', borderRadius: '6px' }}>
({(data.levelDiff24h ?? 0) > 0 ? '+' : ''}{((data.levelDiff24h ?? 0) * 100).toFixed(1)} cm / 24h) <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 'bold' }}>1D</span>
<span style={{ fontSize: '0.85rem', fontWeight: 'bold', color: (data.levelDiff24h ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{(data.levelDiff24h ?? 0) > 0 ? '+' : ''}{((data.levelDiff24h ?? 0) * 100).toFixed(1)} cm
</span>
</div> </div>
<div style={{ fontSize: '0.85rem', color: (data.levelDiff7d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: 'rgba(0,0,0,0.15)', padding: '0.3rem 0.6rem', borderRadius: '6px' }}>
({(data.levelDiff7d ?? 0) > 0 ? '+' : ''}{((data.levelDiff7d ?? 0) * 100).toFixed(1)} cm / 7d) <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 'bold' }}>7D</span>
<span style={{ fontSize: '0.85rem', fontWeight: 'bold', color: (data.levelDiff7d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{(data.levelDiff7d ?? 0) > 0 ? '+' : ''}{((data.levelDiff7d ?? 0) * 100).toFixed(1)} cm
</span>
</div> </div>
<div style={{ fontSize: '0.85rem', color: (data.levelDiff30d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: 'rgba(0,0,0,0.15)', padding: '0.3rem 0.6rem', borderRadius: '6px' }}>
({(data.levelDiff30d ?? 0) > 0 ? '+' : ''}{((data.levelDiff30d ?? 0) * 100).toFixed(1)} cm / 30d) <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 'bold' }}>30D</span>
<span style={{ fontSize: '0.85rem', fontWeight: 'bold', color: (data.levelDiff30d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{(data.levelDiff30d ?? 0) > 0 ? '+' : ''}{((data.levelDiff30d ?? 0) * 100).toFixed(1)} cm
</span>
</div> </div>
</div> </div>
</div> </div>
{/* CARD 2: PRŮTOK */} {/* CARD 2: FLOW */}
<div className="kpi-card"> <div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}> <div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{dict.flow} {dict.flow}
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', height: '100%' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', height: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}> <div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#8b5cf6', marginRight: '6px' }}></span> <span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#8b5cf6', marginRight: '6px', flexShrink: 0 }}></span>
{dict.inflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)', marginLeft: '4px' }}>{data.inflow.toFixed(1)} m³/s</span> {dict.inflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)', marginLeft: '4px' }}>{data.inflow.toFixed(1)} m³/s</span>
</div> </div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.25rem' }}> {data.avgInflow24h !== undefined && (
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-orange)', marginRight: '2px' }}></span> <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', paddingLeft: '14px', marginTop: '-2px' }}>
Ø 24h: {data.avgInflow24h.toFixed(1)} m³/s
</div>
)}
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.25rem', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-orange)', marginRight: '2px', flexShrink: 0 }}></span>
{dict.outflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{data.outflow.toFixed(1)} m³/s</span> {dict.outflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{data.outflow.toFixed(1)} m³/s</span>
{flowDiff > 0 ? <FiArrowUp color="var(--color-green)" /> : flowDiff < 0 ? <FiArrowDown color="var(--color-red)" /> : null} {flowDiff > 0 ? <FiArrowUp color="var(--color-green)" /> : flowDiff < 0 ? <FiArrowDown color="var(--color-red)" /> : null}
</div> </div>
{data.avgOutflow24h !== undefined && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', paddingLeft: '14px', marginTop: '-2px' }}>
Ø 24h: {data.avgOutflow24h.toFixed(1)} m³/s
</div>
)}
</div> </div>
{/* Flow Circle */} {/* Flow Circle */}
@@ -98,7 +120,7 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
</div> </div>
</div> </div>
{/* CARD 3: NAPLNĚNOST */} {/* CARD 3: CAPACITY */}
<div className="kpi-card"> <div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}> <div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}>
{dict.fullness} {dict.fullness}
@@ -133,16 +155,23 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
</div> </div>
)} )}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem', marginTop: '0.5rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.5rem' }}>
<CircularProgress value={data.fullness} size={80} strokeWidth={8} /> <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1, minWidth: 0, paddingRight: '0.5rem' }}>
<div style={{ fontSize: '1.7rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', whiteSpace: 'nowrap', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)' }}>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<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')} {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>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}> <div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{dict.volume}: {data.volume.toFixed(1)} mil. m³ {dict.volume}: {data.volume.toFixed(1)} <span style={{ fontSize: '0.7rem' }}>mil. m³</span>
</div> </div>
{data.minDiff !== undefined && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '4px', whiteSpace: 'nowrap' }}>
{language === 'cs' ? 'K minimu:' : 'To min:'} <span style={{ color: data.minDiff < 0.5 ? 'var(--color-red)' : 'var(--color-green)' }}>{data.minDiff.toFixed(2)} m</span>
</div>
)}
</div>
<div style={{ flexShrink: 0 }}>
<CircularProgress value={data.fullness} size={80} strokeWidth={8} />
</div> </div>
</div> </div>
</div> </div>
+30 -14
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine, Bar } from 'recharts'; import { ComposedChart, Area, Line, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { type Language, t } from '../translations'; import { type Language, t } from '../translations';
import KpiCards from './KpiCards'; import KpiCards from './KpiCards';
@@ -30,7 +30,7 @@ interface Props {
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => { const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const dict = t[language].chart; const dict = t[language as Language].chart;
if (isWeather) { if (isWeather) {
return ( 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)' }}> <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)' }}>
@@ -118,7 +118,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
volume: item.volume || 0, volume: item.volume || 0,
fullness: 0, fullness: 0,
temperature: item.temperature, temperature: item.temperature,
precipitation: item.precipitation precipitation: item.precipitation === null ? undefined : item.precipitation
}; };
}); });
setData(formattedData); setData(formattedData);
@@ -190,6 +190,10 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
let minDiff7d = Infinity; let minDiff7d = Infinity;
let minDiff30d = Infinity; let minDiff30d = Infinity;
let inflowSum24h = 0;
let outflowSum24h = 0;
let flowCount24h = 0;
for (const d of data) { for (const d of data) {
const t = new Date(d.timestamp).getTime(); const t = new Date(d.timestamp).getTime();
@@ -210,11 +214,23 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
minDiff30d = diff30d; minDiff30d = diff30d;
level30dAgo = d.level; level30dAgo = d.level;
} }
if (t >= targetMs24h && d.inflow !== undefined && d.outflow !== undefined) {
inflowSum24h += d.inflow;
outflowSum24h += d.outflow;
flowCount24h++;
}
} }
const levelDiff24h = latestData.level - level24hAgo; const levelDiff24h = latestData.level - level24hAgo;
const levelDiff7d = latestData.level - level7dAgo; const levelDiff7d = latestData.level - level7dAgo;
const levelDiff30d = latestData.level - level30dAgo; const levelDiff30d = latestData.level - level30dAgo;
const avgInflow24h = flowCount24h > 0 ? inflowSum24h / flowCount24h : undefined;
const avgOutflow24h = flowCount24h > 0 ? outflowSum24h / flowCount24h : undefined;
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
const kpiData = { const kpiData = {
level: latestData.level, level: latestData.level,
@@ -225,12 +241,12 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
outflow: lastValidFlowData.outflow, outflow: lastValidFlowData.outflow,
volume: lakeInfo?.volume || 0, volume: lakeInfo?.volume || 0,
fullness: lakeInfo?.capacity || 0, fullness: lakeInfo?.capacity || 0,
storageDiff: lakeInfo?.storageDiff storageDiff: lakeInfo?.storageDiff,
minDiff: staticConfig?.minLevel ? latestData.level - staticConfig.minLevel : undefined,
avgInflow24h,
avgOutflow24h
}; };
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
const leftYAxisDomain = [ const leftYAxisDomain = [
(dataMin: number) => { (dataMin: number) => {
let min = dataMin; let min = dataMin;
@@ -284,7 +300,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} /> <KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
{lakeInfo && lakeInfo.lat && lakeInfo.lng && ( {lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature} /> <WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature ?? undefined} />
)} )}
</div> </div>
@@ -343,14 +359,14 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
<ReferenceLine key={idx} yAxisId="left" y={limit.level} stroke={limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b'} strokeDasharray="3 3" label={{ position: 'insideBottomRight', value: language === 'cs' ? limit.labelCs : limit.labelEn, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', fontSize: 12 }} /> <ReferenceLine key={idx} yAxisId="left" y={limit.level} stroke={limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b'} strokeDasharray="3 3" label={{ position: 'insideBottomRight', value: language === 'cs' ? limit.labelCs : limit.labelEn, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', fontSize: 12 }} />
))} ))}
{staticConfig && staticConfig.maxLevel && ( {staticConfig && staticConfig.maxLevel && (
<ReferenceLine yAxisId="left" y={staticConfig.maxLevel} stroke="var(--color-red)" strokeDasharray="3 3" label={{ position: 'insideTopLeft', value: language === 'cs' ? `Maximální retenční hladina (${staticConfig.maxLevel.toFixed(2)})` : `Max retention level (${staticConfig.maxLevel.toFixed(2)})`, fill: 'var(--color-red)', fontSize: 12 }} /> <ReferenceLine yAxisId="left" y={staticConfig.maxLevel} stroke="var(--color-orange)" strokeDasharray="3 3" label={{ position: 'insideTopLeft', value: language === 'cs' ? `Maximální retenční hladina (${staticConfig.maxLevel.toFixed(2)})` : `Max retention level (${staticConfig.maxLevel.toFixed(2)})`, fill: 'var(--color-orange)', fontSize: 12 }} />
)} )}
{staticConfig && staticConfig.storageLevel && ( {staticConfig && staticConfig.storageLevel && (
<ReferenceLine yAxisId="left" y={staticConfig.storageLevel} stroke="var(--color-green)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Hladina zásobního prostoru (${staticConfig.storageLevel.toFixed(2)})` : `Storage space level (${staticConfig.storageLevel.toFixed(2)})`, fill: 'var(--color-green)', fontSize: 12 }} /> <ReferenceLine yAxisId="left" y={staticConfig.storageLevel} stroke="#a855f7" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Hladina zásobního prostoru (${staticConfig.storageLevel.toFixed(2)})` : `Storage space level (${staticConfig.storageLevel.toFixed(2)})`, fill: '#a855f7', fontSize: 12 }} />
)} )}
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} /> <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="outflow" stroke="var(--color-red)" strokeWidth={2} dot={false} isAnimationActive={animate} />
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="#8b5cf6" strokeWidth={2} dot={false} isAnimationActive={animate} /> <Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} />
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -358,8 +374,8 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
{/* Chart Legend */} {/* 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)' }}> <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-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: 'var(--color-red)' }}></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> <span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div> {dict.inflow}</span>
</div> </div>
{/* WEATHER CHART SECTION */} {/* WEATHER CHART SECTION */}
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { FiX, FiSearch, FiDroplet } from 'react-icons/fi'; import { FiX, FiSearch } from 'react-icons/fi';
import { type Language, t } from '../translations'; import { type Language, t } from '../translations';
import { slugify } from '../utils/slugify'; import { slugify } from '../utils/slugify';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
+28 -52
View File
@@ -33,9 +33,17 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
const minVal = Math.min(...lake.sparkline); const minVal = Math.min(...lake.sparkline);
const maxVal = Math.max(...lake.sparkline); const maxVal = Math.max(...lake.sparkline);
const diff = maxVal - minVal; 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 ? 0.1 : diff * 0.1; // dynamic 10% padding
const padding = diff < 0.5 ? (0.5 - diff) / 2 : 0;
const yDomain = [minVal - padding, maxVal + padding]; const yDomain = [minVal - padding, maxVal + padding];
const firstVal = lake.sparkline[0] || 0;
const lastVal = lake.sparkline[lake.sparkline.length - 1] || 0;
const trendDiff = lastVal - firstVal;
// Dynamic color based on trend direction: stable=cyan, rising=green, falling=red
let trendColor = 'var(--color-cyan)';
if (trendDiff > 0.01) trendColor = 'var(--color-green)';
else if (trendDiff < -0.01) trendColor = 'var(--color-red)';
return ( return (
<div <div
@@ -94,13 +102,13 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}> <AreaChart data={chartData}>
<defs> <defs>
<linearGradient id="colorSpark" x1="0" y1="0" x2="0" y2="1"> <linearGradient id={`colorSpark-${lake.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.8} /> <stop offset="5%" stopColor={trendColor} stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0} /> <stop offset="95%" stopColor={trendColor} stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<YAxis domain={yDomain} hide /> <YAxis domain={yDomain} hide />
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" baseValue={yDomain[0]} /> <Area type="monotone" dataKey="value" stroke={trendColor} fillOpacity={1} fill={`url(#colorSpark-${lake.id})`} baseValue={yDomain[0]} />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -120,54 +128,9 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
); );
}; };
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 LakesOverview = ({ language }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]); const [lakes, setLakes] = useState<Lake[]>([]);
const { isFavorite, toggleFavorite, favorites } = useFavorites(); const { isFavorite, toggleFavorite } = useFavorites();
useEffect(() => { useEffect(() => {
const loadData = () => { const loadData = () => {
@@ -234,6 +197,19 @@ const LakesOverview = ({ language }: Props) => {
</div> </div>
</section> </section>
)} )}
{otherLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', marginTop: '1rem' }}>{language === 'cs' ? 'Ostatní' : 'Other'}</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={false} onToggleFav={toggleFavorite} />)}
</div>
</section>
)}
</div> </div>
); );
}; };
+1 -1
View File
@@ -1,4 +1,4 @@
import { FiX, FiMoon, FiSun, FiGlobe, FiCoffee, FiWind } from 'react-icons/fi'; import { FiX, FiMoon, FiSun, FiCoffee, FiWind } from 'react-icons/fi';
import { type Language, t } from '../translations'; import { type Language, t } from '../translations';
interface Props { interface Props {
+7 -6
View File
@@ -29,18 +29,19 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
return ( return (
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}> <div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
<div className="sidebar-logo" style={{ position: 'relative' }}> <div className="sidebar-logo">
<FiDroplet /> <FiDroplet size={28} color="var(--color-cyan)" />
<div className="sidebar-text"> <div className="sidebar-text">
<span>HLADINATOR</span> <span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.1rem' }}>HLADINATOR</span>
<small>v1.0</small> <small>v1.0</small>
</div> </div>
</div>
{/* Toggle Button */}
{/* Toggle Button */}
<div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '1.5rem', marginTop: isCollapsed ? '1rem' : '-0.5rem' }}>
<button <button
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setIsCollapsed(!isCollapsed)}
style={{ style={{
position: 'absolute', right: isCollapsed ? '-16px' : '-16px', top: '50%', transform: 'translateY(-50%)',
background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'var(--text-main)', background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'var(--text-main)',
borderRadius: '50%', width: '32px', height: '32px', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', width: '32px', height: '32px', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', zIndex: 10, boxShadow: '0 2px 5px rgba(0,0,0,0.2)' cursor: 'pointer', zIndex: 10, boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
+7 -7
View File
@@ -96,10 +96,10 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
} }
return ( return (
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> <div className="kpi-card" style={{ display: 'flex', flexDirection: 'column' }}>
<h3 style={{ fontSize: '1rem', color: 'var(--text-muted)', margin: '0 0 1rem 0' }}>{dict.title}</h3> <div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{dict.title}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', alignItems: 'center' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '1rem', alignItems: 'center' }}>
{/* Left Column: Wind */} {/* Left Column: Wind */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
@@ -114,16 +114,16 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', lineHeight: 1.1, color: 'var(--text-main)' }}> <div style={{ fontSize: '1.5rem', fontWeight: 'bold', lineHeight: 1.1, color: 'var(--text-main)' }}>
{data.windSpeed.toFixed(1)} <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'}</span> {data.windSpeed.toFixed(1)} <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'} {getCompassDirection(data.windDir, language)}</span>
</div> </div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '4px' }}> <div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '4px', whiteSpace: 'nowrap' }}>
{getCompassDirection(data.windDir, language)} {dict.gusts}: <span style={{ color: data.windGusts > (windUnit === 'kmh' ? 50 : 13.8) ? 'var(--color-red)' : 'var(--text-main)' }}>{data.windGusts.toFixed(1)} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</span> {dict.gusts}: <span style={{ color: data.windGusts > (windUnit === 'kmh' ? 50 : 13.8) ? 'var(--color-red)' : 'var(--text-main)' }}>{data.windGusts.toFixed(1)} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Right Column: Other Info */} {/* Right Column: Other Info */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', borderLeft: '1px solid var(--border-color)', paddingLeft: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', borderLeft: '1px solid var(--border-color)', paddingLeft: '1rem', whiteSpace: 'nowrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem' }} title={sensorTemp !== undefined ? (language === 'cs' ? 'Měřeno přímo senzorem na hrázi' : 'Measured by sensor at the dam') : 'OpenMeteo API'}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem' }} title={sensorTemp !== undefined ? (language === 'cs' ? 'Měřeno přímo senzorem na hrázi' : 'Measured by sensor at the dam') : 'OpenMeteo API'}>
<FiThermometer color="var(--color-orange)" /> <FiThermometer color="var(--color-orange)" />
<span style={{ fontWeight: 'bold' }}>{sensorTemp !== undefined ? sensorTemp.toFixed(1) : data.temp.toFixed(1)} °C</span> <span style={{ fontWeight: 'bold' }}>{sensorTemp !== undefined ? sensorTemp.toFixed(1) : data.temp.toFixed(1)} °C</span>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart } from 'recharts'; import { ComposedChart, Line, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { FiWind } from 'react-icons/fi'; import { FiWind } from 'react-icons/fi';
import { type Language } from '../translations'; import { type Language } from '../translations';
+2 -3
View File
@@ -13,9 +13,8 @@ describe('KpiCards Component', () => {
}; };
it('renders correctly with negative storageDiff (red)', () => { it('renders correctly with negative storageDiff (red)', () => {
const { container } = render(<KpiCards data={mockData} language="cs" />); render(<KpiCards data={mockData} language="cs" />);
// STORAGE SPACE card should show -1.81 m
// ZÁSOBNÍ PROSTOR card should show -1.81 m
expect(screen.getByText('-1.81 m')).toBeInTheDocument(); expect(screen.getByText('-1.81 m')).toBeInTheDocument();
// Because it is negative, it should have the red color style applied // Because it is negative, it should have the red color style applied
+4 -4
View File
@@ -6,10 +6,10 @@
--text-main: #f8fafc; /* White text */ --text-main: #f8fafc; /* White text */
--text-muted: #94a3b8; /* Gray text */ --text-muted: #94a3b8; /* Gray text */
--color-cyan: #06b6d4; /* Hladina / Primary */ --color-cyan: #06b6d4; /* Water level / Primary */
--color-green: #22c55e; /* Přítok / Positive trend */ --color-green: #22c55e; /* Inflow / Positive trend */
--color-red: #ef4444; /* Odtok / Negative trend */ --color-red: #ef4444; /* Outflow / Negative trend */
--color-orange: #f97316; /* Odtok line chart color */ --color-orange: #f97316; /* Outflow line chart color */
--color-purple: #a855f7; /* Wind gusts line color */ --color-purple: #a855f7; /* Wind gusts line color */
.kpi-grid-container { .kpi-grid-container {
+2 -1
View File
@@ -5,9 +5,10 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// @ts-ignore
test: { test: {
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
setupFiles: './src/test/setup.ts', setupFiles: ['./src/setupTests.ts'],
}, },
}) })