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",
"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,
"inflow": 2.24,
"volume": 26.54,
"temperature": 22,
"temperature": 21.8,
"precipitation": 0
}
]
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z",
"level": 352.83,
"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,
"volume": 32.31,
"temperature": 22.7,
"temperature": 22.5,
"precipitation": 0
}
]
+27
View File
@@ -476,6 +476,33 @@
"level": 369.83,
"flow": 14.23,
"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,
"temperature": 22.6,
"precipitation": 0
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z",
"level": 352.43,
"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,
"volume": 2.74,
"temperature": 21.9,
"temperature": 22.1,
"precipitation": 0
}
]
+28 -1
View File
@@ -474,10 +474,37 @@
{
"timestamp": "2026-06-06T15:10:00.000Z",
"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,
"inflow": 9.25,
"volume": 199.67,
"temperature": 20.7,
"temperature": 20.6,
"precipitation": 0
}
]
+29 -2
View File
@@ -465,7 +465,7 @@
{
"timestamp": "2026-06-06T15:00:00.000Z",
"level": 558.44,
"flow": 0,
"flow": 7.51,
"inflow": 0,
"volume": 0,
"temperature": 21.8,
@@ -474,10 +474,37 @@
{
"timestamp": "2026-06-06T15:10:00.000Z",
"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,
"inflow": 5.37,
"volume": 0.35,
"temperature": 21.7,
"temperature": 21.6,
"precipitation": 0
}
]
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z",
"level": 345.29,
"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,
"volume": 522.72,
"temperature": 22.6,
"temperature": 22.3,
"precipitation": 0
}
]
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z",
"level": 269.88,
"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,
"volume": 261.1,
"temperature": 23.5,
"temperature": 23.3,
"precipitation": 0
}
]
+28 -1
View File
@@ -475,9 +475,36 @@
"timestamp": "2026-06-06T15:10:00.000Z",
"level": 216.98,
"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,
"volume": 8.14,
"temperature": 23.2,
"temperature": 22.9,
"precipitation": 0
}
]
+31 -31
View File
@@ -33,9 +33,9 @@
"name": "Lipno II",
"river": "Vltava",
"priority": true,
"level": "558.43",
"level": "558.37",
"capacity": 23.3,
"storageDiff": -2.07,
"storageDiff": -2.13,
"inflow": "5.4",
"outflow": "0.0",
"volume": 0.35,
@@ -43,9 +43,6 @@
"lat": 48.625,
"lng": 14.318,
"sparkline": [
558.63,
558.62,
558.6,
558.58,
558.56,
558.54,
@@ -54,7 +51,10 @@
558.49,
558.47,
558.44,
558.43
558.43,
558.41,
558.38,
558.37
]
},
{
@@ -72,9 +72,9 @@
"lat": 49.183,
"lng": 14.444,
"sparkline": [
369.84,
369.84,
369.84,
369.83,
369.83,
369.83,
369.83,
369.83,
369.83,
@@ -101,9 +101,6 @@
"lat": 49.255,
"lng": 14.398,
"sparkline": [
352.44,
352.43,
352.43,
352.44,
352.44,
352.44,
@@ -112,6 +109,9 @@
352.43,
352.44,
352.44,
352.43,
352.43,
352.43,
352.43
]
},
@@ -149,9 +149,9 @@
"name": "Slapy",
"river": "Vltava",
"priority": true,
"level": "269.88",
"level": "269.89",
"capacity": 97,
"storageDiff": -0.72,
"storageDiff": -0.71,
"inflow": "81.1",
"outflow": "0.0",
"volume": 261.1,
@@ -159,9 +159,6 @@
"lat": 49.822,
"lng": 14.436,
"sparkline": [
269.88,
269.89,
269.89,
269.89,
269.88,
269.88,
@@ -170,7 +167,10 @@
269.88,
269.88,
269.88,
269.88
269.88,
269.88,
269.88,
269.89
]
},
{
@@ -178,9 +178,9 @@
"name": "Štěchovice",
"river": "Vltava",
"priority": true,
"level": "216.98",
"level": "216.95",
"capacity": 72.7,
"storageDiff": -2.42,
"storageDiff": -2.45,
"inflow": "48.3",
"outflow": "0.0",
"volume": 8.14,
@@ -188,9 +188,6 @@
"lat": 49.845,
"lng": 14.412,
"sparkline": [
217,
216.97,
216.99,
216.98,
216.95,
216.98,
@@ -199,7 +196,10 @@
216.98,
216.97,
216.95,
216.98
216.98,
216.96,
216.94,
216.95
]
},
{
@@ -236,19 +236,16 @@
"name": "Hracholusky",
"river": "Mže",
"priority": true,
"level": "352.83",
"level": "352.84",
"capacity": 57,
"storageDiff": -16.67,
"storageDiff": -16.66,
"inflow": "1.5",
"outflow": "2.5",
"outflow": "0.0",
"volume": 32.31,
"maxVolume": 56.7,
"lat": 49.789,
"lng": 13.155,
"sparkline": [
352.84,
352.84,
352.83,
352.84,
352.84,
352.84,
@@ -257,7 +254,10 @@
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';
const args = process.argv.slice(2);
const minutes = parseInt(args[0], 10) || 10;
const intervalMs = minutes * 60 * 1000;
// How many minutes after the 10-minute mark should we run the scraper?
// The basin authority (PVL) generates data at HH:00, HH:10, HH:20... but it takes time to publish.
// 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(`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() {
const now = new Date().toLocaleTimeString('cs-CZ');
console.log(`[${now}] 🔄 Spouštím npm run data:update...`);
try {
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) {
console.error(`[${new Date().toLocaleTimeString('cs-CZ')}] ❌ Chyba při aktualizaci:`, error.message);
}
scheduleNextRun();
}
// Spustit ihned po zapnutí
runUpdate();
function scheduleNextRun() {
const now = new Date();
const currentMinute = now.getMinutes();
// A pak periodicky v zadaném intervalu
setInterval(runUpdate, intervalMs);
// 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);
}
// Run update immediately on first launch and then set the timer
runUpdate();
+9 -4
View File
@@ -7,12 +7,12 @@
}
.sidebar {
width: 250px;
width: 190px;
background-color: var(--bg-card);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 1.5rem 1rem;
padding: 1.5rem 0.75rem;
transition: width 0.3s ease;
overflow: hidden;
}
@@ -75,8 +75,8 @@
.nav-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
gap: 0.75rem;
padding: 0.75rem 0.5rem;
border-radius: 0.5rem;
color: var(--text-muted);
font-size: 0.95rem;
@@ -86,6 +86,11 @@
white-space: nowrap;
}
.nav-item svg {
flex-shrink: 0;
min-width: 20px;
}
.nav-item:hover {
background-color: rgba(255, 255, 255, 0.03);
color: var(--text-main);
+1 -1
View File
@@ -1,5 +1,5 @@
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 LakesOverview from './components/LakesOverview';
import LakeMap from './components/LakeMap';
+40 -9
View File
@@ -6,7 +6,7 @@ import { Helmet } from 'react-helmet-async';
import { CircularProgress } from './CircularProgress';
import { useNavigate } from 'react-router-dom';
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';
interface Lake {
@@ -80,7 +80,21 @@ const FavoritesOverview = ({ language }: Props) => {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
{favoriteLakes.map(lake => {
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 (
<div
key={lake.id}
@@ -127,14 +141,31 @@ const FavoritesOverview = ({ language }: Props) => {
</div>
</div>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem' }}>
<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 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={`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 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 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)' }}>{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>
+51 -22
View File
@@ -13,6 +13,9 @@ interface KpiData {
volume: number;
fullness: number;
storageDiff?: number;
minDiff?: number;
avgInflow24h?: number;
avgOutflow24h?: number;
}
interface Props {
@@ -37,43 +40,62 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
return (
<>
{/* CARD 1: HLADINA */}
{/* CARD 1: WATER LEVEL */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{dict.level} {lakeName}
</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>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<div style={{ fontSize: '0.85rem', color: (data.levelDiff24h ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
({(data.levelDiff24h ?? 0) > 0 ? '+' : ''}{((data.levelDiff24h ?? 0) * 100).toFixed(1)} cm / 24h)
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: 'rgba(0,0,0,0.15)', padding: '0.3rem 0.6rem', borderRadius: '6px' }}>
<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 style={{ fontSize: '0.85rem', color: (data.levelDiff7d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
({(data.levelDiff7d ?? 0) > 0 ? '+' : ''}{((data.levelDiff7d ?? 0) * 100).toFixed(1)} cm / 7d)
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: 'rgba(0,0,0,0.15)', padding: '0.3rem 0.6rem', borderRadius: '6px' }}>
<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 style={{ fontSize: '0.85rem', color: (data.levelDiff30d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
({(data.levelDiff30d ?? 0) > 0 ? '+' : ''}{((data.levelDiff30d ?? 0) * 100).toFixed(1)} cm / 30d)
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: 'rgba(0,0,0,0.15)', padding: '0.3rem 0.6rem', borderRadius: '6px' }}>
<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>
{/* CARD 2: PRŮTOK */}
{/* CARD 2: FLOW */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{dict.flow}
</div>
<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={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#8b5cf6', marginRight: '6px' }}></span>
<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', flexShrink: 0 }}></span>
{dict.inflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)', marginLeft: '4px' }}>{data.inflow.toFixed(1)} m³/s</span>
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-orange)', marginRight: '2px' }}></span>
{data.avgInflow24h !== undefined && (
<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>
{flowDiff > 0 ? <FiArrowUp color="var(--color-green)" /> : flowDiff < 0 ? <FiArrowDown color="var(--color-red)" /> : null}
</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>
{/* Flow Circle */}
@@ -98,7 +120,7 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
</div>
</div>
{/* CARD 3: NAPLNĚNOST */}
{/* CARD 3: CAPACITY */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}>
{dict.fullness}
@@ -133,16 +155,23 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem', marginTop: '0.5rem' }}>
<CircularProgress value={data.fullness} size={80} strokeWidth={8} />
<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)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.5rem' }}>
<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)' }}>
{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³
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{dict.volume}: {data.volume.toFixed(1)} <span style={{ fontSize: '0.7rem' }}>mil. m³</span>
</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>
+30 -14
View File
@@ -1,5 +1,5 @@
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 { type Language, t } from '../translations';
import KpiCards from './KpiCards';
@@ -30,7 +30,7 @@ interface Props {
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
if (active && payload && payload.length) {
const dict = t[language].chart;
const dict = t[language as 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)' }}>
@@ -118,7 +118,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
volume: item.volume || 0,
fullness: 0,
temperature: item.temperature,
precipitation: item.precipitation
precipitation: item.precipitation === null ? undefined : item.precipitation
};
});
setData(formattedData);
@@ -190,6 +190,10 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
let minDiff7d = Infinity;
let minDiff30d = Infinity;
let inflowSum24h = 0;
let outflowSum24h = 0;
let flowCount24h = 0;
for (const d of data) {
const t = new Date(d.timestamp).getTime();
@@ -210,12 +214,24 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
minDiff30d = diff30d;
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 levelDiff7d = latestData.level - level7dAgo;
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 = {
level: latestData.level,
levelDiff24h,
@@ -225,12 +241,12 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
outflow: lastValidFlowData.outflow,
volume: lakeInfo?.volume || 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 = [
(dataMin: number) => {
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'} />
{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>
@@ -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 }} />
))}
{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 && (
<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} />
<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} />
<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="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} />
</ComposedChart>
</ResponsiveContainer>
</div>
@@ -358,8 +374,8 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
{/* 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>
<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: 'var(--color-green)' }}></div> {dict.inflow}</span>
</div>
{/* 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 L from 'leaflet';
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 { slugify } from '../utils/slugify';
import { useNavigate } from 'react-router-dom';
+28 -52
View File
@@ -33,10 +33,18 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
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 padding = diff === 0 ? 0.1 : diff * 0.1; // dynamic 10% 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 (
<div
className="kpi-card priority-lake-card"
@@ -94,13 +102,13 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
<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 id={`colorSpark-${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="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>
</ResponsiveContainer>
</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 [lakes, setLakes] = useState<Lake[]>([]);
const { isFavorite, toggleFavorite, favorites } = useFavorites();
const { isFavorite, toggleFavorite } = useFavorites();
useEffect(() => {
const loadData = () => {
@@ -234,6 +197,19 @@ const LakesOverview = ({ language }: Props) => {
</div>
</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>
);
};
+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';
interface Props {
+6 -5
View File
@@ -29,18 +29,19 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
return (
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
<div className="sidebar-logo" style={{ position: 'relative' }}>
<FiDroplet />
<div className="sidebar-logo">
<FiDroplet size={28} color="var(--color-cyan)" />
<div className="sidebar-text">
<span>HLADINATOR</span>
<span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.1rem' }}>HLADINATOR</span>
<small>v1.0</small>
</div>
</div>
{/* Toggle Button */}
{/* Toggle Button */}
<div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '1.5rem', marginTop: isCollapsed ? '1rem' : '-0.5rem' }}>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
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)',
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)'
+7 -7
View File
@@ -96,10 +96,10 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
}
return (
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<h3 style={{ fontSize: '1rem', color: 'var(--text-muted)', margin: '0 0 1rem 0' }}>{dict.title}</h3>
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column' }}>
<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 */}
<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={{ 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 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '4px' }}>
{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>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '4px', whiteSpace: 'nowrap' }}>
{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>
{/* 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'}>
<FiThermometer color="var(--color-orange)" />
<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 { 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 { type Language } from '../translations';
+2 -3
View File
@@ -13,9 +13,8 @@ describe('KpiCards Component', () => {
};
it('renders correctly with negative storageDiff (red)', () => {
const { container } = render(<KpiCards data={mockData} language="cs" />);
// ZÁSOBNÍ PROSTOR card should show -1.81 m
render(<KpiCards data={mockData} language="cs" />);
// STORAGE SPACE card should show -1.81 m
expect(screen.getByText('-1.81 m')).toBeInTheDocument();
// 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-muted: #94a3b8; /* Gray text */
--color-cyan: #06b6d4; /* Hladina / Primary */
--color-green: #22c55e; /* Přítok / Positive trend */
--color-red: #ef4444; /* Odtok / Negative trend */
--color-orange: #f97316; /* Odtok line chart color */
--color-cyan: #06b6d4; /* Water level / Primary */
--color-green: #22c55e; /* Inflow / Positive trend */
--color-red: #ef4444; /* Outflow / Negative trend */
--color-orange: #f97316; /* Outflow line chart color */
--color-purple: #a855f7; /* Wind gusts line color */
.kpi-grid-container {
+2 -1
View File
@@ -5,9 +5,10 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
// @ts-ignore
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
setupFiles: ['./src/setupTests.ts'],
},
})