feat: add rivers overview component and sync lake volume data across the dataset
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+2868
-798
File diff suppressed because it is too large
Load Diff
+2850
-798
File diff suppressed because it is too large
Load Diff
+2880
-864
File diff suppressed because it is too large
Load Diff
+2061
-792
File diff suppressed because it is too large
Load Diff
+2885
-797
File diff suppressed because it is too large
Load Diff
+2919
-813
File diff suppressed because it is too large
Load Diff
+2870
-800
File diff suppressed because it is too large
Load Diff
+1433
-38
File diff suppressed because it is too large
Load Diff
+1488
-93
File diff suppressed because it is too large
Load Diff
+2860
-799
File diff suppressed because it is too large
Load Diff
+2904
-798
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
File diff suppressed because it is too large
Load Diff
+2977
-871
File diff suppressed because it is too large
Load Diff
+2869
-799
File diff suppressed because it is too large
Load Diff
+2714
-797
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
File diff suppressed because it is too large
Load Diff
+2924
-854
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
[]
|
||||
File diff suppressed because it is too large
Load Diff
+2887
-799
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
File diff suppressed because it is too large
Load Diff
+2866
-796
File diff suppressed because it is too large
Load Diff
+2928
-849
File diff suppressed because it is too large
Load Diff
+2621
-830
File diff suppressed because it is too large
Load Diff
+2883
-813
File diff suppressed because it is too large
Load Diff
+2981
-857
File diff suppressed because it is too large
Load Diff
+2883
-804
File diff suppressed because it is too large
Load Diff
+2878
-799
File diff suppressed because it is too large
Load Diff
+2900
-812
File diff suppressed because it is too large
Load Diff
+2929
-832
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
File diff suppressed because it is too large
Load Diff
+2882
-803
File diff suppressed because it is too large
Load Diff
+2945
-857
File diff suppressed because it is too large
Load Diff
+2928
-858
File diff suppressed because it is too large
Load Diff
+2954
-857
File diff suppressed because it is too large
Load Diff
+2996
-908
File diff suppressed because it is too large
Load Diff
+2939
-860
File diff suppressed because it is too large
Load Diff
+2945
-857
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2920
-805
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+1037
-564
File diff suppressed because it is too large
Load Diff
+15
-3
@@ -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'
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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} />} />
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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, '-');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user