feat: update water level metrics and optimize sidebar UI layout
This commit is contained in:
+28
-1
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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();
|
||||
|
||||
// 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
|
||||
setInterval(runUpdate, intervalMs);
|
||||
// Run update immediately on first launch and then set the timer
|
||||
runUpdate();
|
||||
|
||||
+9
-4
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,11 +214,23 @@ 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,
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -33,9 +33,17 @@ 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
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* Toggle Button */}
|
||||
</div>
|
||||
|
||||
{/* 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)'
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user