feat: Initial commit - Hladinator (Water Reservoir Dashboard)
continuous-integration/drone/push Build encountered an error

- Setup React project with Vite and TypeScript
- Built dynamic UI supporting Dark/Light mode and CS/EN localization
- Added Lakes Overview grid with mock data for 40+ reservoirs
- Created interactive Recharts charts for water levels and flow rates
- Designed fully responsive premium mobile layout with custom SVG KPIs
- Developed TypeScript scraper scripts to fetch reservoir data
This commit is contained in:
David Fencl
2026-06-05 21:36:38 +02:00
parent ac43c24f20
commit a5bd4985d1
22 changed files with 4369 additions and 958 deletions
+55
View File
@@ -0,0 +1,55 @@
import * as fs from 'fs';
import * as path from 'path';
interface LakeRaw {
id: string;
text: string;
priority?: boolean;
}
const lakesRaw: LakeRaw[] = [
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true },
{ id: "VLOR|1", text: "VD Orlík - Vltava", priority: true },
{ id: "VLSL|1", text: "VD Slapy - Vltava", priority: false },
{ id: "BLHU|1", text: "VD Husinec - Blanice (PI)" },
{ id: "BIBI|1", text: "VD Bílsko - Bílský potok" },
{ id: "KLDP|3", text: "VD Dolejší Padrťský rybník" },
{ id: "KLHP|3", text: "VD Hořejší Padrťský rybník" },
{ id: "KLKL|3", text: "VD Klabava - Klabava" },
{ id: "KCKC|3", "text": "VD Klíčava - Klíčava" },
{ id: "LILA|3", "text": "VD Láz - Litavka" },
{ id: "MARI|1", "text": "VD Římov - Malše" },
{ id: "MZHR|3", "text": "VD Hracholusky - Mže" },
{ id: "MZLU|3", "text": "VD Lučina - Mže" },
{ id: "MZSS|3", "text": "VD Plzeň-Štruncovy sady" },
{ id: "OPOB|3", "text": "VD Obecnice - Obecnický potok" },
{ id: "PPPI|3", "text": "VD Pilská - Pilský potok" },
{ id: "RACU|3", "text": "VD České Údolí - Radbuza" },
{ id: "SPNE|2", "text": "VD Němčice - Sedlický potok" },
{ id: "SVKR|1", "text": "VD Švihov - Želivka" },
{ id: "UHKA|1", "text": "VD Kamýk - Vltava" },
{ id: "VRSN|1", "text": "VD Vrané - Vltava" },
{ id: "ZLUT|3", "text": "VD Žlutice - Střela" },
// Adding dummies to reach ~40
...Array.from({length: 18}).map((_, i) => ({ id: `DUMMY${i}`, text: `VD Dummy Lake ${i+1}` }))
];
const lakes = lakesRaw.map(lake => {
const sparkline = Array.from({length: 12}).map(() => 50 + Math.random() * 20);
return {
id: lake.id,
name: lake.text.replace('VD ', '').split('-')[0].trim(),
river: lake.text.includes('-') ? lake.text.split('-')[1].trim() : '',
priority: lake.priority || false,
level: (200 + Math.random() * 500).toFixed(2),
capacity: Math.floor(20 + Math.random() * 80), // 20% to 100%
inflow: (Math.random() * 20).toFixed(1),
outflow: (Math.random() * 20).toFixed(1),
volume: (Math.random() * 300).toFixed(1),
sparkline
};
});
const outputPath = path.resolve(process.cwd(), 'public/data/lakes_index.json');
fs.writeFileSync(outputPath, JSON.stringify(lakes, null, 2));
console.log('Mock lakes generated:', lakes.length);
+93
View File
@@ -0,0 +1,93 @@
import * as fs from 'fs';
import * as path from 'path';
import * as cheerio from 'cheerio';
import axios from 'axios';
import https from 'https';
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
const DATA_FILE = path.resolve('public/data/lipno.json');
interface DataRecord {
timestamp: string;
level: number;
flow: number;
}
// Parse date from DD.MM.YYYY HH:MM to ISO
function parseDateString(dateStr: string): string {
const [datePart, timePart] = dateStr.trim().split(' ');
const [day, month, year] = datePart.split('.');
const [hours, minutes] = timePart.split(':');
const d = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hours), parseInt(minutes));
return d.toISOString();
}
async function scrape(): Promise<void> {
try {
const agent = new https.Agent({
rejectUnauthorized: false
});
const response = await axios.get(URL, {
httpsAgent: agent,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
});
const html = response.data;
const $ = cheerio.load(html);
const rows = $('table tr');
const newData: DataRecord[] = [];
rows.each((i, row) => {
const tds = $(row).find('td');
if (tds.length >= 3) {
const datetimeText = $(tds[0]).text().trim();
// Check if it's a valid date string matching DD.MM.YYYY HH:MM
if (/^\d{2}\.\d{2}\.\d{4}\s\d{2}:\d{2}$/.test(datetimeText)) {
const timestamp = parseDateString(datetimeText);
const levelText = $(tds[1]).text().trim().replace(',', '.');
const flowText = $(tds[2]).text().trim().replace(',', '.');
newData.push({
timestamp,
level: parseFloat(levelText),
flow: parseFloat(flowText)
});
}
}
});
// Load existing data
let existingData: DataRecord[] = [];
if (fs.existsSync(DATA_FILE)) {
const fileContent = fs.readFileSync(DATA_FILE, 'utf-8');
existingData = JSON.parse(fileContent);
}
// Merge and deduplicate by timestamp
const dataMap = new Map<string, DataRecord>();
existingData.forEach(item => dataMap.set(item.timestamp, item));
newData.forEach(item => dataMap.set(item.timestamp, item));
// Sort chronologically
const mergedData = Array.from(dataMap.values()).sort((a, b) => {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
// Save back
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(mergedData, null, 2), 'utf-8');
console.log(`Scraped ${newData.length} records. Total records in DB: ${mergedData.length}`);
} catch (error: any) {
console.error('Error scraping data:', error.message);
process.exit(1);
}
}
scrape();