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 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 {
id: lake.id,
name: lake.text.replace('VD ', '').split('-')[0].trim(),
river: lake.text.includes('-') ? lake.text.split('-')[1].trim() : '',
name,
river,
priority: lake.priority || false,
level: currentLevel.toFixed(2),
capacity: metrics.capacity,
@@ -71,7 +82,8 @@ const lakes = lakesConfig.map(lake => {
navigationForbidden: lake.navigationForbidden || false,
lat: lake.coords[0],
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;
storageLevel?: number;
navigationForbidden?: boolean;
type?: 'lake' | 'river';
}
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: "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: "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) {
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`);
try {
@@ -80,7 +84,8 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const records: DataRecord[] = [];
let dataTable = null;
$('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);
}
});
@@ -102,9 +107,7 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
records.push({
timestamp: parsedDateStr,
level: parseFloat(levelStr) || 0,
flow: parseFloat(flowStr) || 0,
inflow: 0,
volume: 0
flow: parseFloat(flowStr) || 0
});
}
}
@@ -143,7 +146,19 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const dataMap = new Map<string, DataRecord>();
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) => {
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)
let lastKnownTemp: number | null = null;
let lastKnownPrecip: number | null = null;
let lastKnownInflow: number | undefined = undefined;
let lastKnownVolume: number | undefined = undefined;
mergedData.forEach(item => {
if (item.temperature !== undefined && item.temperature !== null) {
lastKnownTemp = item.temperature;
@@ -164,6 +182,18 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
} else if (lastKnownPrecip !== null) {
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 });
+18 -2
View File
@@ -13,8 +13,12 @@
display: flex;
flex-direction: column;
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;
flex-shrink: 0;
will-change: width;
z-index: 100;
}
.sidebar.collapsed {
@@ -22,8 +26,19 @@
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 {
display: none;
opacity: 0;
max-width: 0;
pointer-events: none;
}
.sidebar.collapsed .sidebar-logo {
@@ -358,6 +373,7 @@
}
.main-content {
margin-left: 0 !important;
padding: 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 LakeDetail from './components/LakeDetail';
import LakesOverview from './components/LakesOverview';
import { RiversOverview } from './components/RiversOverview';
import LakeMap from './components/LakeMap';
import FavoritesOverview from './components/FavoritesOverview';
import WeatherRadar from './components/WeatherRadar';
@@ -82,6 +83,7 @@ function App() {
<Routes>
<Route path="/" element={<LakesOverview 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="/radar" element={<WeatherRadar language={language} />} />
<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 { TbRipple } from 'react-icons/tb';
import { type Language, t } from '../translations';
import { useState, useEffect } from 'react';
import { CircularProgress } from './CircularProgress';
@@ -22,9 +23,10 @@ interface Props {
data: KpiData;
language: Language;
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 dict = t[language].kpi;
const flowDiff = data.inflow - data.outflow;
@@ -38,6 +40,70 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
}
}, [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 (
<>
{/* CARD 1: WATER LEVEL */}
+66 -29
View File
@@ -30,7 +30,7 @@ interface Props {
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) {
const dict = t[language as Language].chart;
if (isWeather) {
@@ -60,18 +60,38 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
let labelStr = '';
let unit = '';
let color = '';
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; color = 'var(--color-cyan)'; }
else if (entry.dataKey === 'outflow') { labelStr = 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 (entry.dataKey === 'level') {
labelStr = isRiver ? (language === 'cs' ? 'Vodní stav' : 'Water level') : dict.level;
unit = isRiver ? 'cm' : 'm n. m.';
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;
return (
<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>
{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>
);
})}
@@ -233,6 +253,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
const isRiver = lakeInfo?.type === 'river';
const kpiData = {
level: latestData.level,
@@ -249,19 +270,24 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
avgOutflow24h
};
const leftYAxisDomain = [
(dataMin: number) => {
let min = dataMin;
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
return min - 0.5;
},
(dataMax: number) => {
let max = dataMax;
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
return max + 0.5;
}
];
const leftYAxisDomain = isRiver
? [
(dataMin: number) => Math.max(0, Math.floor(dataMin - 10)),
(dataMax: number) => Math.ceil(dataMax + 10)
]
: [
(dataMin: number) => {
let min = dataMin;
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
return min - 0.5;
},
(dataMax: number) => {
let max = dataMax;
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
return max + 0.5;
}
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
@@ -308,7 +334,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
</div>
<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 && (
<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>
</defs>
<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}} />
<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 */}
{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 }} />
))}
{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 }} />
)}
{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 }} />
)}
<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="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>
</ResponsiveContainer>
</div>
{/* 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-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>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div>
{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>
{/* WEATHER CHART SECTION */}
+24 -5
View File
@@ -1,5 +1,5 @@
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 'leaflet/dist/leaflet.css';
import { FiX, FiSearch } from 'react-icons/fi';
@@ -20,6 +20,7 @@ interface LakeData {
volume: string;
lat: number;
lng: number;
type?: 'lake' | 'river';
}
interface Props {
@@ -27,7 +28,23 @@ interface Props {
}
// 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({
className: 'custom-div-icon',
html: `
@@ -58,8 +75,6 @@ const LakeMap = ({ language }: Props) => {
lake.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const customIcon = createCustomIcon();
return (
<div className="map-view-container">
<Helmet>
@@ -85,11 +100,15 @@ const LakeMap = ({ language }: Props) => {
<Marker
key={lake.id}
position={[lake.lat, lake.lng]}
icon={customIcon}
icon={createCustomIcon(lake.type)}
eventHandlers={{
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>
<strong>{lake.name}</strong><br/>
{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 { useNavigate, useLocation } from 'react-router-dom';
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 { useFavorites } from '../hooks/useFavorites';
@@ -20,6 +21,7 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
const isOverview = location.pathname === '/';
const isFavoritesPage = location.pathname === '/favorites';
const isRiversPage = location.pathname === '/rivers';
const isMap = location.pathname === '/map';
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-logo" style={{ alignItems: 'center', gap: '0.4rem' }}>
<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>
<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>
{/* 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
onClick={() => setIsCollapsed(!isCollapsed)}
style={{
@@ -88,6 +90,12 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
<span className="sidebar-text">{dict.lakes}</span>
</div>
{/* Rivers & Streams */}
<div className={`nav-item ${isRiversPage ? 'active' : ''}`} onClick={() => handleNavigate('/rivers')}>
<TbRipple size={18} />
<span className="sidebar-text">{dict.rivers}</span>
</div>
{/* Map */}
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
<FiMap />
+28
View File
@@ -162,6 +162,34 @@
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) {
.kpi-grid-container {
grid-template-columns: repeat(2, 1fr);
+8 -2
View File
@@ -5,6 +5,7 @@ export const t = {
sidebar: {
favorites: 'Favorites',
lakes: 'Lakes & Reservoirs',
rivers: 'Rivers & Streams',
map: 'Map',
radar: 'Weather Radar',
settings: 'Settings'
@@ -29,7 +30,9 @@ export const t = {
inflow: 'Inflow',
outflow: 'Outflow',
fullness: 'STORAGE LEVEL',
volume: 'Volume'
volume: 'Volume',
waterLevel: 'WATER LEVEL',
currentFlow: 'CURRENT FLOW'
},
chart: {
title: 'Long-term development',
@@ -78,6 +81,7 @@ export const t = {
sidebar: {
favorites: 'Oblíbené',
lakes: 'Jezera a nádrže',
rivers: 'Řeky a toky',
map: 'Mapa',
radar: 'Meteoradar',
settings: 'Nastavení'
@@ -102,7 +106,9 @@ export const t = {
inflow: 'Přítok',
outflow: 'Odtok',
fullness: 'ZÁSOBNÍ PROSTOR',
volume: 'Objem'
volume: 'Objem',
waterLevel: 'VODNÍ STAV',
currentFlow: 'AKTUÁLNÍ PRŮTOK'
},
chart: {
title: 'Dlouhodobý vývoj',
+5 -5
View File
@@ -1,9 +1,9 @@
export const slugify = (text: string) => {
return text
.split(' - ')[0] // "VD Lipno 1 - Vltava" -> "VD Lipno 1"
.replace(/^VD\s+/i, '') // Remove "VD " prefix -> "Lipno 1"
.normalize('NFD') // Decompose diacritics
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.split(' - ')[0]
.replace(/^(VD|LG)\s+/i, '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/\s+/g, '-'); // Replace spaces with dashes -> "lipno-1"
.replace(/\s+/g, '-');
};