feat: add rivers overview component and sync lake volume data across the dataset

This commit is contained in:
David Fencl
2026-06-08 19:36:54 +02:00
parent ec540e056d
commit 62c861e610
71 changed files with 139421 additions and 29818 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
[]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1490 -77
View File
File diff suppressed because it is too large Load Diff
+2868 -798
View File
File diff suppressed because it is too large Load Diff
+2850 -798
View File
File diff suppressed because it is too large Load Diff
+2880 -864
View File
File diff suppressed because it is too large Load Diff
+2061 -792
View File
File diff suppressed because it is too large Load Diff
+2885 -797
View File
File diff suppressed because it is too large Load Diff
+2919 -813
View File
File diff suppressed because it is too large Load Diff
+2870 -800
View File
File diff suppressed because it is too large Load Diff
+1433 -38
View File
File diff suppressed because it is too large Load Diff
+1488 -93
View File
File diff suppressed because it is too large Load Diff
+2860 -799
View File
File diff suppressed because it is too large Load Diff
+2904 -798
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2960 -872
View File
File diff suppressed because it is too large Load Diff
+2977 -871
View File
File diff suppressed because it is too large Load Diff
+2869 -799
View File
File diff suppressed because it is too large Load Diff
+2714 -797
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1437 -78
View File
File diff suppressed because it is too large Load Diff
+2924 -854
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
[]
File diff suppressed because it is too large Load Diff
+2887 -799
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2878 -799
View File
File diff suppressed because it is too large Load Diff
+2866 -796
View File
File diff suppressed because it is too large Load Diff
+2928 -849
View File
File diff suppressed because it is too large Load Diff
+2621 -830
View File
File diff suppressed because it is too large Load Diff
+2883 -813
View File
File diff suppressed because it is too large Load Diff
+2981 -857
View File
File diff suppressed because it is too large Load Diff
+2883 -804
View File
File diff suppressed because it is too large Load Diff
+2878 -799
View File
File diff suppressed because it is too large Load Diff
+2900 -812
View File
File diff suppressed because it is too large Load Diff
+2929 -832
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2926 -865
View File
File diff suppressed because it is too large Load Diff
+2882 -803
View File
File diff suppressed because it is too large Load Diff
+2945 -857
View File
File diff suppressed because it is too large Load Diff
+2928 -858
View File
File diff suppressed because it is too large Load Diff
+2954 -857
View File
File diff suppressed because it is too large Load Diff
+2996 -908
View File
File diff suppressed because it is too large Load Diff
+2939 -860
View File
File diff suppressed because it is too large Load Diff
+2945 -857
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2920 -805
View File
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
[
{
"timestamp": "2026-05-31T05:00:00.000Z",
"level": 0,
"flow": 48.8,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-01T05:00:00.000Z",
"level": 34,
"flow": 80.2,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-02T05:00:00.000Z",
"level": 20,
"flow": 66.46,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-03T05:00:00.000Z",
"level": 18,
"flow": 63.7,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-04T05:00:00.000Z",
"level": 15,
"flow": 60.95,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T05:00:00.000Z",
"level": 15,
"flow": 61.4,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T20:00:00.000Z",
"level": 12,
"flow": 59.06,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T21:00:00.000Z",
"level": 7,
"flow": 54.32,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T22:00:00.000Z",
"level": 6,
"flow": 53.76,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T23:00:00.000Z",
"level": 11,
"flow": 57.64,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T00:00:00.000Z",
"level": 12,
"flow": 58.7,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T01:00:00.000Z",
"level": 12,
"flow": 58.25,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T02:00:00.000Z",
"level": 15,
"flow": 60.95,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T03:00:00.000Z",
"level": 13,
"flow": 59.87,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T04:00:00.000Z",
"level": 10,
"flow": 56.73,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T05:00:00.000Z",
"level": 15,
"flow": 61.76,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T06:00:00.000Z",
"level": 11,
"flow": 57.64,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T07:00:00.000Z",
"level": 6,
"flow": 53.6,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T08:00:00.000Z",
"level": 10,
"flow": 57.08,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T09:00:00.000Z",
"level": 14,
"flow": 60.68,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T10:00:00.000Z",
"level": 7,
"flow": 54.4,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T11:00:00.000Z",
"level": 3,
"flow": 51.34,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T12:00:00.000Z",
"level": 12,
"flow": 58.25,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T13:00:00.000Z",
"level": 13,
"flow": 59.33,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T14:00:00.000Z",
"level": 5,
"flow": 52.71,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T15:00:00.000Z",
"level": 10,
"flow": 56.64,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T16:00:00.000Z",
"level": 11,
"flow": 57.4,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T17:00:00.000Z",
"level": 9,
"flow": 55.7,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:00:00.000Z",
"level": 3,
"flow": 51.18,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:10:00.000Z",
"level": 4,
"flow": 51.58,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:20:00.000Z",
"level": 4,
"flow": 51.66,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:30:00.000Z",
"level": 5,
"flow": 52.44,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:40:00.000Z",
"level": 5,
"flow": 53.04,
"inflow": 0,
"volume": 0,
"temperature": 22.1,
"precipitation": 0
}
]
+2852 -800
View File
File diff suppressed because it is too large Load Diff
+1037 -564
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -56,10 +56,21 @@ const lakes = lakesConfig.map(lake => {
const metrics = calculateLakeMetrics(currentLevel, volume, lake); const metrics = calculateLakeMetrics(currentLevel, volume, lake);
const cleanText = lake.text.replace(/^VD\s+/, '').replace(/^LG\s+/, '');
const parts = cleanText.split('-').map(p => p.trim());
let name = '';
let river = '';
if (parts.length > 1) {
river = parts[parts.length - 1];
name = parts.slice(0, -1).join(' - ');
} else {
name = parts[0];
}
return { return {
id: lake.id, id: lake.id,
name: lake.text.replace('VD ', '').split('-')[0].trim(), name,
river: lake.text.includes('-') ? lake.text.split('-')[1].trim() : '', river,
priority: lake.priority || false, priority: lake.priority || false,
level: currentLevel.toFixed(2), level: currentLevel.toFixed(2),
capacity: metrics.capacity, capacity: metrics.capacity,
@@ -71,7 +82,8 @@ const lakes = lakesConfig.map(lake => {
navigationForbidden: lake.navigationForbidden || false, navigationForbidden: lake.navigationForbidden || false,
lat: lake.coords[0], lat: lake.coords[0],
lng: lake.coords[1], lng: lake.coords[1],
sparkline sparkline,
type: lake.type || 'lake'
}; };
}); });
+79
View File
@@ -0,0 +1,79 @@
import fs from 'fs';
import path from 'path';
function fixExistingData() {
const dataDir = path.resolve('public/data');
if (!fs.existsSync(dataDir)) {
console.error('Data directory does not exist!');
return;
}
const files = fs.readdirSync(dataDir).filter(f => f.endsWith('.json'));
console.log(`Found ${files.length} data files to clean up...`);
files.forEach(file => {
const filePath = path.join(dataDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
if (!Array.isArray(data)) return;
let lastKnownInflow: number | null = null;
let lastKnownVolume: number | null = null;
let fixCountInflow = 0;
let fixCountVolume = 0;
// First pass (oldest to newest): find and propagate values forward
data.forEach(record => {
// Handle inflow
if (record.inflow !== undefined && record.inflow !== null && record.inflow !== 0) {
lastKnownInflow = record.inflow;
} else if ((record.inflow === undefined || record.inflow === null || record.inflow === 0) && lastKnownInflow !== null) {
record.inflow = lastKnownInflow;
fixCountInflow++;
}
// Handle volume
if (record.volume !== undefined && record.volume !== null && record.volume !== 0) {
lastKnownVolume = record.volume;
} else if ((record.volume === undefined || record.volume === null || record.volume === 0) && lastKnownVolume !== null) {
record.volume = lastKnownVolume;
fixCountVolume++;
}
});
// Second pass (newest to oldest): if there were zeros at the very beginning of the file
// before any non-zero value was found, propagate backwards.
let nextKnownInflow: number | null = null;
let nextKnownVolume: number | null = null;
for (let i = data.length - 1; i >= 0; i--) {
const record = data[i];
if (record.inflow !== undefined && record.inflow !== null && record.inflow !== 0) {
nextKnownInflow = record.inflow;
} else if ((record.inflow === undefined || record.inflow === null || record.inflow === 0) && nextKnownInflow !== null) {
record.inflow = nextKnownInflow;
fixCountInflow++;
}
if (record.volume !== undefined && record.volume !== null && record.volume !== 0) {
nextKnownVolume = record.volume;
} else if ((record.volume === undefined || record.volume === null || record.volume === 0) && nextKnownVolume !== null) {
record.volume = nextKnownVolume;
fixCountVolume++;
}
}
if (fixCountInflow > 0 || fixCountVolume > 0) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`[${file}] Cleaned up ${fixCountInflow} inflows and ${fixCountVolume} volumes.`);
}
} catch (e: any) {
console.error(`Error processing file ${file}:`, e.message);
}
});
console.log('Cleanup finished.');
}
fixExistingData();
+19 -1
View File
@@ -8,6 +8,7 @@ export interface LakeConfig {
maxLevel?: number; maxLevel?: number;
storageLevel?: number; storageLevel?: number;
navigationForbidden?: boolean; navigationForbidden?: boolean;
type?: 'lake' | 'river';
} }
export const lakesConfig: LakeConfig[] = [ export const lakesConfig: LakeConfig[] = [
@@ -49,5 +50,22 @@ export const lakesConfig: LakeConfig[] = [
{ id: "SPZH|1", text: "VD Zhejral", coords: [49.2310, 15.3120], maxVolume: 0.2, minLevel: 675.2, maxLevel: 679.7, storageLevel: 678.6, navigationForbidden: true }, { id: "SPZH|1", text: "VD Zhejral", coords: [49.2310, 15.3120], maxVolume: 0.2, minLevel: 675.2, maxLevel: 679.7, storageLevel: 678.6, navigationForbidden: true },
{ id: "KLDP|3", text: "VD Dolejší Padrťský rybník", coords: [49.6640, 13.7530], maxVolume: 0.5, minLevel: 632.69, maxLevel: 634.29, storageLevel: 632.89, navigationForbidden: true }, { id: "KLDP|3", text: "VD Dolejší Padrťský rybník", coords: [49.6640, 13.7530], maxVolume: 0.5, minLevel: 632.69, maxLevel: 634.29, storageLevel: 632.89, navigationForbidden: true },
{ id: "KLHP|3", text: "VD Hořejší Padrťský rybník", coords: [49.6550, 13.7610], maxVolume: 0.7, minLevel: 635.76, maxLevel: 637.56, storageLevel: 636.36, navigationForbidden: true }, { id: "KLHP|3", text: "VD Hořejší Padrťský rybník", coords: [49.6550, 13.7610], maxVolume: 0.7, minLevel: 635.76, maxLevel: 637.56, storageLevel: 636.36, navigationForbidden: true },
{ id: "CPDR|3", text: "VD Dráteník", coords: [49.8050, 13.8550], maxVolume: 0.1, minLevel: 413.75, maxLevel: 417.91, storageLevel: 416.68, navigationForbidden: false } { id: "CPDR|3", text: "VD Dráteník", coords: [49.8050, 13.8550], maxVolume: 0.1, minLevel: 413.75, maxLevel: 417.91, storageLevel: 416.68, navigationForbidden: false },
// Rivers
{ id: "VLCH|2", text: "LG Praha - Malá Chuchle - Vltava", coords: [50.0294, 14.3986], type: 'river' },
{ id: "VLCB|1", text: "LG České Budějovice - Vltava", coords: [48.9712, 14.4714], type: 'river' },
{ id: "BEBE|3", text: "LG Beroun - Berounka", coords: [49.9642, 14.0792], type: 'river' },
{ id: "SANE|2", text: "LG Nespeky - Sázava", coords: [49.8596, 14.5888], type: 'river' },
{ id: "OTPI|1", text: "LG Písek - Otava", coords: [49.3083, 14.1436], type: 'river' },
{ id: "OTSU|1", text: "LG Sušice - Otava", coords: [49.2319, 13.5186], type: 'river' },
{ id: "LUBE|1", text: "LG Bechyně - Lužnice", coords: [49.2931, 14.4758], type: 'river' },
{ id: "LUKL|1", text: "LG Klenovice - Lužnice", coords: [49.3402, 14.7175], type: 'river' },
{ id: "SAZR|2", text: "LG Zruč nad Sázavou - Sázava", coords: [49.7428, 15.1011], type: 'river' },
{ id: "SASV|2", text: "LG Světlá nad Sázavou - Sázava", coords: [49.6677, 15.4048], type: 'river' },
{ id: "SAKA|2", text: "LG Kácov - Sázava", coords: [49.7772, 15.0294], type: 'river' },
{ id: "BEZB|3", text: "LG Zbečno - Berounka", coords: [50.0436, 13.9189], type: 'river' },
{ id: "BEPL|3", text: "LG Plzeň - Bílá Hora - Berounka", coords: [49.7731, 13.3986], type: 'river' },
{ id: "VLVB|1", text: "LG Vyšší Brod - Vltava", coords: [48.6167, 14.3167], type: 'river' }
]; ];
+36 -6
View File
@@ -38,7 +38,11 @@ export function parseDateString(dateStr: string): string | null {
} }
async function scrapeLake(lakeId: string, oid: string, internalId: string) { async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`; const config = lakesConfig.find(l => l.id === lakeId);
const isRiver = config?.type === 'river';
const URL = isRiver
? `https://www.pvl.cz/portal/sap/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`
: `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
const DATA_FILE = path.resolve(`public/data/${internalId}.json`); const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
try { try {
@@ -80,7 +84,8 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const records: DataRecord[] = []; const records: DataRecord[] = [];
let dataTable = null; let dataTable = null;
$('table').each((i, tbl) => { $('table').each((i, tbl) => {
if ($(tbl).text().includes('Datum') && $(tbl).text().includes('Odtok')) { const id = ($(tbl).attr('id') || '').toLowerCase();
if (id.includes('datamereni24hgv') || id.includes('datamerenigv')) {
dataTable = $(tbl); dataTable = $(tbl);
} }
}); });
@@ -102,9 +107,7 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
records.push({ records.push({
timestamp: parsedDateStr, timestamp: parsedDateStr,
level: parseFloat(levelStr) || 0, level: parseFloat(levelStr) || 0,
flow: parseFloat(flowStr) || 0, flow: parseFloat(flowStr) || 0
inflow: 0,
volume: 0
}); });
} }
} }
@@ -143,7 +146,19 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const dataMap = new Map<string, DataRecord>(); const dataMap = new Map<string, DataRecord>();
existingData.forEach(item => dataMap.set(item.timestamp, item)); existingData.forEach(item => dataMap.set(item.timestamp, item));
records.forEach(item => dataMap.set(item.timestamp, item)); records.forEach(item => {
const existing = dataMap.get(item.timestamp);
if (existing) {
dataMap.set(item.timestamp, {
...existing,
...item,
inflow: item.inflow !== undefined ? item.inflow : existing.inflow,
volume: item.volume !== undefined ? item.volume : existing.volume
});
} else {
dataMap.set(item.timestamp, item);
}
});
const mergedData = Array.from(dataMap.values()).sort((a, b) => { const mergedData = Array.from(dataMap.values()).sort((a, b) => {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
@@ -152,6 +167,9 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
// Propagate previous values if missing (user requested) // Propagate previous values if missing (user requested)
let lastKnownTemp: number | null = null; let lastKnownTemp: number | null = null;
let lastKnownPrecip: number | null = null; let lastKnownPrecip: number | null = null;
let lastKnownInflow: number | undefined = undefined;
let lastKnownVolume: number | undefined = undefined;
mergedData.forEach(item => { mergedData.forEach(item => {
if (item.temperature !== undefined && item.temperature !== null) { if (item.temperature !== undefined && item.temperature !== null) {
lastKnownTemp = item.temperature; lastKnownTemp = item.temperature;
@@ -164,6 +182,18 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
} else if (lastKnownPrecip !== null) { } else if (lastKnownPrecip !== null) {
item.precipitation = lastKnownPrecip; item.precipitation = lastKnownPrecip;
} }
if (item.inflow !== undefined && item.inflow !== null) {
lastKnownInflow = item.inflow;
} else if (lastKnownInflow !== undefined) {
item.inflow = lastKnownInflow;
}
if (item.volume !== undefined && item.volume !== null) {
lastKnownVolume = item.volume;
} else if (lastKnownVolume !== undefined) {
item.volume = lastKnownVolume;
}
}); });
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true }); fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
+18 -2
View File
@@ -13,8 +13,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 1.5rem 0.75rem; padding: 1.5rem 0.75rem;
transition: width 0.3s ease; transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.4s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden; overflow: hidden;
flex-shrink: 0;
will-change: width;
z-index: 100;
} }
.sidebar.collapsed { .sidebar.collapsed {
@@ -22,8 +26,19 @@
padding: 1.5rem 0.5rem; padding: 1.5rem 0.5rem;
} }
.sidebar-text {
opacity: 1;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
transition: opacity 0.15s ease, max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: auto;
}
.sidebar.collapsed .sidebar-text { .sidebar.collapsed .sidebar-text {
display: none; opacity: 0;
max-width: 0;
pointer-events: none;
} }
.sidebar.collapsed .sidebar-logo { .sidebar.collapsed .sidebar-logo {
@@ -358,6 +373,7 @@
} }
.main-content { .main-content {
margin-left: 0 !important;
padding: 1rem; padding: 1rem;
gap: 1rem; gap: 1rem;
} }
+2
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Routes, Route, useParams, 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 { RiversOverview } from './components/RiversOverview';
import LakeMap from './components/LakeMap'; import LakeMap from './components/LakeMap';
import FavoritesOverview from './components/FavoritesOverview'; import FavoritesOverview from './components/FavoritesOverview';
import WeatherRadar from './components/WeatherRadar'; import WeatherRadar from './components/WeatherRadar';
@@ -82,6 +83,7 @@ function App() {
<Routes> <Routes>
<Route path="/" element={<LakesOverview language={language} windUnit={windUnit} />} /> <Route path="/" element={<LakesOverview language={language} windUnit={windUnit} />} />
<Route path="/favorites" element={<FavoritesOverview language={language} windUnit={windUnit} />} /> <Route path="/favorites" element={<FavoritesOverview language={language} windUnit={windUnit} />} />
<Route path="/rivers" element={<RiversOverview language={language} windUnit={windUnit} />} />
<Route path="/map" element={<LakeMap language={language} />} /> <Route path="/map" element={<LakeMap language={language} />} />
<Route path="/radar" element={<WeatherRadar language={language} />} /> <Route path="/radar" element={<WeatherRadar language={language} />} />
<Route path="/:slug" element={<LakeDetailWrapper language={language} windUnit={windUnit} />} /> <Route path="/:slug" element={<LakeDetailWrapper language={language} windUnit={windUnit} />} />
+67 -1
View File
@@ -1,4 +1,5 @@
import { FiArrowUp, FiArrowDown } from 'react-icons/fi'; import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
import { TbRipple } from 'react-icons/tb';
import { type Language, t } from '../translations'; import { type Language, t } from '../translations';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { CircularProgress } from './CircularProgress'; import { CircularProgress } from './CircularProgress';
@@ -22,9 +23,10 @@ interface Props {
data: KpiData; data: KpiData;
language: Language; language: Language;
lakeName?: string; lakeName?: string;
isRiver?: boolean;
} }
const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => { const KpiCards = ({ data, language, lakeName = 'Lipno 1', isRiver = false }: Props) => {
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const dict = t[language].kpi; const dict = t[language].kpi;
const flowDiff = data.inflow - data.outflow; const flowDiff = data.inflow - data.outflow;
@@ -38,6 +40,70 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
} }
}, [showTooltip]); }, [showTooltip]);
if (isRiver) {
return (
<>
{/* CARD 1: WATER LEVEL */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{dict.waterLevel} {lakeName}
</div>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, whiteSpace: 'nowrap' }}>
{data.level.toFixed(0)} <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>cm</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', alignContent: 'flex-start', marginTop: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', background: 'rgba(0,0,0,0.15)', padding: '0.2rem 0.4rem', 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).toFixed(0)} cm
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', background: 'rgba(0,0,0,0.15)', padding: '0.2rem 0.4rem', 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).toFixed(0)} cm
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', background: 'rgba(0,0,0,0.15)', padding: '0.2rem 0.4rem', 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).toFixed(0)} cm
</span>
</div>
</div>
</div>
{/* CARD 2: FLOW */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{dict.currentFlow}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, whiteSpace: 'nowrap', marginBottom: '0.5rem' }}>
{data.outflow.toFixed(1)} <span style={{ fontSize: '1.25rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m³/s</span>
</div>
{data.avgOutflow24h !== undefined && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Ø 24h: <span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{data.avgOutflow24h.toFixed(1)} m³/s</span>
</div>
)}
</div>
<div style={{
width: '70px', height: '70px', borderRadius: '50%',
backgroundColor: 'rgba(6, 182, 212, 0.1)',
border: '2px dashed var(--color-cyan)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--color-cyan)', flexShrink: 0
}}>
<TbRipple size={36} />
</div>
</div>
</div>
</>
);
}
return ( return (
<> <>
{/* CARD 1: WATER LEVEL */} {/* CARD 1: WATER LEVEL */}
+54 -17
View File
@@ -30,7 +30,7 @@ interface Props {
windUnit?: 'kmh' | 'ms'; windUnit?: 'kmh' | 'ms';
} }
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => { const CustomTooltip = ({ active, payload, label, language, isWeather, isRiver }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const dict = t[language as Language].chart; const dict = t[language as Language].chart;
if (isWeather) { if (isWeather) {
@@ -60,18 +60,38 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
let labelStr = ''; let labelStr = '';
let unit = ''; let unit = '';
let color = ''; let color = '';
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; color = 'var(--color-cyan)'; } if (entry.dataKey === 'level') {
else if (entry.dataKey === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; color = 'var(--color-red)'; } labelStr = isRiver ? (language === 'cs' ? 'Vodní stav' : 'Water level') : dict.level;
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; color = 'var(--color-green)'; } unit = isRiver ? 'cm' : 'm n. m.';
else if (entry.dataKey === 'temperature') { labelStr = language === 'cs' ? 'Teplota' : 'Temperature'; unit = '°C'; color = 'var(--color-red)'; } color = 'var(--color-cyan)';
else if (entry.dataKey === 'precipitation') { labelStr = language === 'cs' ? 'Srážky' : 'Precipitation'; unit = 'mm'; color = 'var(--color-cyan)'; } }
else if (entry.dataKey === 'outflow') {
labelStr = isRiver ? (language === 'cs' ? 'Průtok' : 'Flow') : dict.outflow;
unit = 'm³/s';
color = 'var(--color-red)';
}
else if (entry.dataKey === 'inflow') {
labelStr = dict.inflow;
unit = 'm³/s';
color = 'var(--color-green)';
}
else if (entry.dataKey === 'temperature') {
labelStr = language === 'cs' ? 'Teplota' : 'Temperature';
unit = '°C';
color = 'var(--color-red)';
}
else if (entry.dataKey === 'precipitation') {
labelStr = language === 'cs' ? 'Srážky' : 'Precipitation';
unit = 'mm';
color = 'var(--color-cyan)';
}
if (!labelStr || entry.value === null || entry.value === undefined) return null; if (!labelStr || entry.value === null || entry.value === undefined) return null;
return ( return (
<div key={index} style={{ margin: 0, color: 'var(--text-main)', display: 'flex', alignItems: 'center', marginBottom: '4px' }}> <div key={index} style={{ margin: 0, color: 'var(--text-main)', display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color, marginRight: '8px' }}></span> <span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color, marginRight: '8px' }}></span>
{labelStr}: <span style={{ fontWeight: 'bold', marginLeft: '4px' }}>{entry.value.toFixed(entry.dataKey === 'level' ? 2 : 1)} {unit}</span> {labelStr}: <span style={{ fontWeight: 'bold', marginLeft: '4px' }}>{entry.value.toFixed(entry.dataKey === 'level' ? (isRiver ? 0 : 2) : 1)} {unit}</span>
</div> </div>
); );
})} })}
@@ -233,6 +253,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined; const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined; const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
const isRiver = lakeInfo?.type === 'river';
const kpiData = { const kpiData = {
level: latestData.level, level: latestData.level,
@@ -249,7 +270,12 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
avgOutflow24h avgOutflow24h
}; };
const leftYAxisDomain = [ const leftYAxisDomain = isRiver
? [
(dataMin: number) => Math.max(0, Math.floor(dataMin - 10)),
(dataMax: number) => Math.ceil(dataMax + 10)
]
: [
(dataMin: number) => { (dataMin: number) => {
let min = dataMin; let min = dataMin;
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; }); if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
@@ -308,7 +334,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
</div> </div>
<div className="kpi-grid-container"> <div className="kpi-grid-container">
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} /> <KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} isRiver={isRiver} />
{lakeInfo && lakeInfo.lat && lakeInfo.lng && ( {lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature ?? undefined} /> <WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature ?? undefined} />
@@ -359,34 +385,45 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} /> <XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
<YAxis yAxisId="left" domain={leftYAxisDomain as any} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(2)} /> <YAxis yAxisId="left" domain={leftYAxisDomain as any} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(isRiver ? 0 : 2)} />
<YAxis yAxisId="right" orientation="right" domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} /> <YAxis yAxisId="right" orientation="right" domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<Tooltip content={<CustomTooltip language={language} />} /> <Tooltip content={<CustomTooltip language={language} isRiver={isRiver} />} />
{/* Data Series */} {/* Data Series */}
{limits && limits.map((limit, idx) => ( {limits && limits.map((limit, idx) => (
<ReferenceLine key={idx} yAxisId="left" y={limit.level} stroke={limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b'} strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `${limit.labelCs} (${limit.level.toFixed(2)} m n. m.)` : `${limit.labelEn} (${limit.level.toFixed(2)} m a.s.l.)`, 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: 'insideBottomLeft', value: language === 'cs' ? `${limit.labelCs} (${limit.level.toFixed(2)} m n. m.)` : `${limit.labelEn} (${limit.level.toFixed(2)} m a.s.l.)`, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', fontSize: 12 }} />
))} ))}
{staticConfig?.maxLevel && ( {!isRiver && staticConfig?.maxLevel && (
<ReferenceLine yAxisId="left" y={staticConfig.maxLevel} stroke="var(--color-orange)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Maximální retenční hladina (${staticConfig.maxLevel.toFixed(2)} m n. m.)` : `Max retention level (${staticConfig.maxLevel.toFixed(2)} m a.s.l.)`, fill: 'var(--color-orange)', fontSize: 12 }} /> <ReferenceLine yAxisId="left" y={staticConfig.maxLevel} stroke="var(--color-orange)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Maximální retenční hladina (${staticConfig.maxLevel.toFixed(2)} m n. m.)` : `Max retention level (${staticConfig.maxLevel.toFixed(2)} m a.s.l.)`, fill: 'var(--color-orange)', fontSize: 12 }} />
)} )}
{staticConfig?.storageLevel && ( {!isRiver && staticConfig?.storageLevel && (
<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)} m n. m.)` : `Storage space level (${staticConfig.storageLevel.toFixed(2)} m a.s.l.)`, fill: '#a855f7', 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)} m n. m.)` : `Storage space level (${staticConfig.storageLevel.toFixed(2)} m a.s.l.)`, 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-red)" 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} /> {!isRiver && <Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} />}
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* 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' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> {dict.outflow}</span> <div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div> {dict.inflow}</span> {isRiver ? (language === 'cs' ? 'Vodní stav' : 'Water level') : dict.level}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div>
{isRiver ? (language === 'cs' ? 'Průtok' : 'Flow') : dict.outflow}
</span>
{!isRiver && (
<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 */}
+24 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Popup, Tooltip } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { FiX, FiSearch } from 'react-icons/fi'; import { FiX, FiSearch } from 'react-icons/fi';
@@ -20,6 +20,7 @@ interface LakeData {
volume: string; volume: string;
lat: number; lat: number;
lng: number; lng: number;
type?: 'lake' | 'river';
} }
interface Props { interface Props {
@@ -27,7 +28,23 @@ interface Props {
} }
// Create custom icon // Create custom icon
const createCustomIcon = () => { const createCustomIcon = (type?: 'lake' | 'river') => {
if (type === 'river') {
return L.divIcon({
className: 'custom-div-icon',
html: `
<div class="river-marker-icon">
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" height="20" width="20" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h18M3 8h18M3 16h18" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
`,
iconSize: [36, 42],
iconAnchor: [18, 42],
popupAnchor: [0, -42],
});
}
return L.divIcon({ return L.divIcon({
className: 'custom-div-icon', className: 'custom-div-icon',
html: ` html: `
@@ -58,8 +75,6 @@ const LakeMap = ({ language }: Props) => {
lake.name.toLowerCase().includes(searchTerm.toLowerCase()) lake.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
const customIcon = createCustomIcon();
return ( return (
<div className="map-view-container"> <div className="map-view-container">
<Helmet> <Helmet>
@@ -85,11 +100,15 @@ const LakeMap = ({ language }: Props) => {
<Marker <Marker
key={lake.id} key={lake.id}
position={[lake.lat, lake.lng]} position={[lake.lat, lake.lng]}
icon={customIcon} icon={createCustomIcon(lake.type)}
eventHandlers={{ eventHandlers={{
click: () => navigate(`/${slugify(lake.name)}`) click: () => navigate(`/${slugify(lake.name)}`)
}} }}
> >
<Tooltip direction="top" offset={[0, -38]} opacity={0.9}>
<span style={{ fontWeight: 'bold' }}>{lake.name}</span>
{lake.river && <span style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginLeft: '0.4rem' }}>({lake.river})</span>}
</Tooltip>
<Popup> <Popup>
<strong>{lake.name}</strong><br/> <strong>{lake.name}</strong><br/>
{lake.river} {lake.river}
+245
View File
@@ -0,0 +1,245 @@
import { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet-async';
import { FiTrendingUp, FiTrendingDown, FiStar } from 'react-icons/fi';
import { type Language } from '../translations';
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { useFavorites } from '../hooks/useFavorites';
import { Tooltip } from './Tooltip';
import { TbSwimming, TbSailboat, TbRipple } from 'react-icons/tb';
interface River {
id: string;
name: string;
river: string;
priority: boolean;
level: number; // in cm for rivers
capacity: number; // 0 for rivers
storageDiff?: number;
inflow: number;
outflow: number; // current flow rate
volume: number;
maxVolume: number;
navigationForbidden: boolean;
sparkline: number[];
type: 'lake' | 'river';
}
interface Props {
language: Language;
windUnit?: 'kmh' | 'ms';
}
const RiverCard = ({ river, language, isFav, onToggleFav }: { river: River, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
const navigate = useNavigate();
const chartData = river.sparkline.map((val, i) => ({ name: i, value: val }));
const minVal = Math.min(...river.sparkline);
const maxVal = Math.max(...river.sparkline);
const diff = maxVal - minVal;
const padding = diff === 0 ? 1 : diff * 0.1; // dynamic padding
const yDomain = [minVal - padding, maxVal + padding];
const firstVal = river.sparkline[0] || 0;
const lastVal = river.sparkline[river.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.1) trendColor = 'var(--color-green)';
else if (trendDiff < -0.1) trendColor = 'var(--color-red)';
return (
<div
className="kpi-card priority-lake-card"
onClick={() => navigate(`/${slugify(river.name)}`)}
style={{ cursor: 'pointer', flex: 1, padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
>
{/* Star / Favorite button */}
<button
onClick={(e) => { e.stopPropagation(); onToggleFav(river.id); }}
title={isFav ? (language === 'cs' ? 'Odepnout' : 'Unpin') : (language === 'cs' ? 'Připnout jako oblíbené' : 'Pin to favorites')}
style={{
position: 'absolute', top: '1rem', right: '1rem',
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: '4px',
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={18} fill={isFav ? '#f59e0b' : 'none'} />
</button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingRight: '2rem' }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{river.name} {river.river ? `- ${river.river}` : ''}
</h3>
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
<Tooltip content={river.navigationForbidden ? (language === 'cs' ? 'Koupání zakázáno' : 'Swimming forbidden') : (language === 'cs' ? 'Koupání (bez omezení)' : 'Swimming allowed')}>
<TbSwimming size={20} color={river.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: river.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
<Tooltip content={river.navigationForbidden ? (language === 'cs' ? 'Plavba zakázána' : 'Navigation forbidden') : (language === 'cs' ? 'Plavba (i bezmotorová) povolena' : 'Navigation allowed')}>
<TbSailboat size={20} color={river.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: river.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<div style={{
width: '70px', height: '70px', borderRadius: '50%',
backgroundColor: 'rgba(6, 182, 212, 0.1)',
border: '2px dashed var(--color-cyan)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--color-cyan)', flexShrink: 0
}}>
<TbRipple size={36} />
</div>
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{language === 'cs' ? 'Vodní stav' : 'Water level'}
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', display: 'flex', alignItems: 'baseline', gap: '0.25rem', lineHeight: '1.1' }}>
{river.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>cm</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '0.25rem' }}>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<span>{language === 'cs' ? 'Průtok:' : 'Flow:'}</span>
<span style={{ color: 'var(--text-main)', fontWeight: 'bold' }}>{river.outflow} m³/s</span>
</div>
</div>
</div>
</div>
<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={`colorSpark-${river.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(#colorSpark-${river.id})`} baseValue={yDomain[0]} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{trendDiff > 0.1 ? (
<>
<FiTrendingUp color="var(--color-green)" />
<span style={{ color: 'var(--text-muted)' }}>{language === 'cs' ? 'Stoupá' : 'Rising'}</span>
</>
) : trendDiff < -0.1 ? (
<>
<FiTrendingDown color="var(--color-red)" />
<span style={{ color: 'var(--text-muted)' }}>{language === 'cs' ? 'Klesá' : 'Falling'}</span>
</>
) : (
<>
<span style={{ color: 'var(--color-cyan)', fontWeight: 'bold' }}>~</span>
<span style={{ color: 'var(--text-muted)' }}>{language === 'cs' ? 'Ustálený' : 'Stable'}</span>
</>
)}
</div>
</div>
</div>
</div>
);
};
export const RiversOverview = ({ language }: Props) => {
const [rivers, setRivers] = useState<River[]>([]);
const { isFavorite, toggleFavorite } = useFavorites();
useEffect(() => {
const loadData = () => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(data => {
// Filter only rivers
const filtered = data.filter((item: any) => item.type === 'river');
setRivers(filtered);
})
.catch(err => console.error(err));
};
loadData();
const intervalId = setInterval(loadData, 60 * 1000); // Poll every 1 minute
return () => clearInterval(intervalId);
}, []);
const favoriteRivers = rivers.filter(r => isFavorite(r.id));
const activeRivers = rivers.filter(r => !isFavorite(r.id));
activeRivers.sort((a, b) => a.name.localeCompare(b.name));
const seoTitle = language === 'cs' ? 'Řeky a hlásné profily | Hladinátor' : 'Rivers and Flows | Hladinátor';
const seoDesc = language === 'cs'
? 'Sledujte aktuální vodní stavy (cm) a průtoky (m³/s) na klíčových vodočtech českých řek v reálném čase.'
: 'Track current water levels (cm) and flow rates (m³/s) on key measuring stations of Czech rivers in real time.';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<Helmet>
<title>{seoTitle}</title>
<meta name="description" content={seoDesc} />
<meta property="og:title" content={seoTitle} />
<meta property="og:description" content={seoDesc} />
</Helmet>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0', color: 'var(--text-main)' }}>
{language === 'cs' ? 'Řeky a toky' : 'Rivers & Streams'} ({rivers.length})
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
{seoDesc}
</p>
</div>
{/* Favorites section */}
{favoriteRivers.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FiStar size={16} fill="#f59e0b" color="#f59e0b" /> {language === 'cs' ? 'Oblíbené' : 'Favorites'} ({favoriteRivers.length})
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{favoriteRivers.map(river => (
<RiverCard key={river.id} river={river} language={language} isFav={true} onToggleFav={toggleFavorite} />
))}
</div>
</section>
)}
{/* All Rivers section */}
{activeRivers.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>
{language === 'cs' ? 'Sledované profily' : 'Monitored Profiles'}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{activeRivers.map(river => (
<RiverCard key={river.id} river={river} language={language} isFav={false} onToggleFav={toggleFavorite} />
))}
</div>
</section>
)}
</div>
);
};
+10 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { FiDroplet, FiStar, FiMap, FiSettings, FiChevronLeft, FiChevronRight, FiDatabase, FiCloudRain } from 'react-icons/fi'; import { FiDroplet, FiStar, FiMap, FiSettings, FiChevronLeft, FiChevronRight, FiDatabase, FiCloudRain } from 'react-icons/fi';
import { TbRipple } from 'react-icons/tb';
import { type Language, t } from '../translations'; import { type Language, t } from '../translations';
import { useFavorites } from '../hooks/useFavorites'; import { useFavorites } from '../hooks/useFavorites';
@@ -20,6 +21,7 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
const isOverview = location.pathname === '/'; const isOverview = location.pathname === '/';
const isFavoritesPage = location.pathname === '/favorites'; const isFavoritesPage = location.pathname === '/favorites';
const isRiversPage = location.pathname === '/rivers';
const isMap = location.pathname === '/map'; const isMap = location.pathname === '/map';
const isRadar = location.pathname === '/radar'; const isRadar = location.pathname === '/radar';
@@ -32,14 +34,14 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}> <div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
<div className="sidebar-logo" style={{ alignItems: 'center', gap: '0.4rem' }}> <div className="sidebar-logo" style={{ alignItems: 'center', gap: '0.4rem' }}>
<FiDroplet size={34} color="var(--color-cyan)" style={{ marginLeft: '-4px', flexShrink: 0 }} /> <FiDroplet size={34} color="var(--color-cyan)" style={{ marginLeft: '-4px', flexShrink: 0 }} />
<div className="sidebar-text" style={{ position: 'relative', display: 'flex', alignItems: 'center' }}> <div className="sidebar-text" style={{ position: 'relative', alignItems: 'center' }}>
<span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.15rem', lineHeight: 1 }}>HLADINATOR</span> <span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.15rem', lineHeight: 1 }}>HLADINATOR</span>
<small style={{ position: 'absolute', top: '100%', left: '2px', marginTop: '6px', lineHeight: 1, fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)' }}>v1.0</small> <small style={{ position: 'absolute', top: '100%', left: '2px', marginTop: '6px', lineHeight: 1, fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)' }}>v1.0</small>
</div> </div>
</div> </div>
{/* Toggle Button */} {/* Toggle Button */}
<div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '0.5rem', marginTop: isCollapsed ? '0.5rem' : '-1.5rem' }}> <div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '0.5rem', marginTop: '-1.5rem' }}>
<button <button
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setIsCollapsed(!isCollapsed)}
style={{ style={{
@@ -88,6 +90,12 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
<span className="sidebar-text">{dict.lakes}</span> <span className="sidebar-text">{dict.lakes}</span>
</div> </div>
{/* Rivers & Streams */}
<div className={`nav-item ${isRiversPage ? 'active' : ''}`} onClick={() => handleNavigate('/rivers')}>
<TbRipple size={18} />
<span className="sidebar-text">{dict.rivers}</span>
</div>
{/* Map */} {/* Map */}
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}> <div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
<FiMap /> <FiMap />
+28
View File
@@ -162,6 +162,34 @@
border-top: 8px solid white; border-top: 8px solid white;
} }
.river-marker-icon {
width: 36px;
height: 36px;
background-color: var(--color-blue, #2563eb);
color: white;
border-radius: 8px; /* Rounded square shape */
display: flex;
align-items: center;
justify-content: center;
border: 2px solid white;
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
cursor: pointer;
position: relative;
}
.river-marker-icon::after {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid white;
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.kpi-grid-container { .kpi-grid-container {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
+8 -2
View File
@@ -5,6 +5,7 @@ export const t = {
sidebar: { sidebar: {
favorites: 'Favorites', favorites: 'Favorites',
lakes: 'Lakes & Reservoirs', lakes: 'Lakes & Reservoirs',
rivers: 'Rivers & Streams',
map: 'Map', map: 'Map',
radar: 'Weather Radar', radar: 'Weather Radar',
settings: 'Settings' settings: 'Settings'
@@ -29,7 +30,9 @@ export const t = {
inflow: 'Inflow', inflow: 'Inflow',
outflow: 'Outflow', outflow: 'Outflow',
fullness: 'STORAGE LEVEL', fullness: 'STORAGE LEVEL',
volume: 'Volume' volume: 'Volume',
waterLevel: 'WATER LEVEL',
currentFlow: 'CURRENT FLOW'
}, },
chart: { chart: {
title: 'Long-term development', title: 'Long-term development',
@@ -78,6 +81,7 @@ export const t = {
sidebar: { sidebar: {
favorites: 'Oblíbené', favorites: 'Oblíbené',
lakes: 'Jezera a nádrže', lakes: 'Jezera a nádrže',
rivers: 'Řeky a toky',
map: 'Mapa', map: 'Mapa',
radar: 'Meteoradar', radar: 'Meteoradar',
settings: 'Nastavení' settings: 'Nastavení'
@@ -102,7 +106,9 @@ export const t = {
inflow: 'Přítok', inflow: 'Přítok',
outflow: 'Odtok', outflow: 'Odtok',
fullness: 'ZÁSOBNÍ PROSTOR', fullness: 'ZÁSOBNÍ PROSTOR',
volume: 'Objem' volume: 'Objem',
waterLevel: 'VODNÍ STAV',
currentFlow: 'AKTUÁLNÍ PRŮTOK'
}, },
chart: { chart: {
title: 'Dlouhodobý vývoj', title: 'Dlouhodobý vývoj',
+5 -5
View File
@@ -1,9 +1,9 @@
export const slugify = (text: string) => { export const slugify = (text: string) => {
return text return text
.split(' - ')[0] // "VD Lipno 1 - Vltava" -> "VD Lipno 1" .split(' - ')[0]
.replace(/^VD\s+/i, '') // Remove "VD " prefix -> "Lipno 1" .replace(/^(VD|LG)\s+/i, '')
.normalize('NFD') // Decompose diacritics .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics .replace(/[\u0300-\u036f]/g, '')
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '-'); // Replace spaces with dashes -> "lipno-1" .replace(/\s+/g, '-');
}; };