Compare commits

...

30 Commits

Author SHA1 Message Date
David Fencl 5894c51256 chore: update water data sets and migrate docker configuration to directory-based structure
continuous-integration/drone/push Build encountered an error
2026-06-13 22:51:47 +02:00
David Fencl a1a1685ae3 feat: update water monitoring datasets with recent sensor data points 2026-06-13 15:14:13 +02:00
David Fencl 62d69fbb1e chore: update lake datasets, add new monitoring locations, and introduce docker-compose infrastructure 2026-06-13 13:09:26 +02:00
David Fencl c8fe97078d commit 2026-06-09 20:45:08 +02:00
David Fencl c4cad149ea feat: update lake data and optimize weather widget rendering 2026-06-09 20:27:07 +02:00
David Fencl 4939d1c5dc feat: update lake water data and optimize visual components for real-time monitoring 2026-06-08 22:32:10 +02:00
David Fencl 8fe39b7ab0 feat: update water level datasets and improve Tooltip component responsiveness 2026-06-08 21:30:34 +02:00
David Fencl cdb653d660 feat: update lake water data and refine WindChart component functionality 2026-06-08 21:10:29 +02:00
David Fencl 48b44cd642 feat: update historical lake sensor data and improve wind chart component rendering 2026-06-08 20:49:01 +02:00
David Fencl 7a7abdd3e5 feat: update lake water data, implement service worker and manifest, and add favicon 2026-06-08 20:06:23 +02:00
David Fencl f8a7be7fa3 feat: implement sensor glitch detection for water levels and update data cleaning logic 2026-06-08 19:45:37 +02:00
David Fencl 62c861e610 feat: add rivers overview component and sync lake volume data across the dataset 2026-06-08 19:36:54 +02:00
David Fencl ec540e056d feat: implement weather radar component and update water resource data records. before river 2026-06-06 21:04:19 +02:00
David Fencl 231961da19 feat: add disclaimer modal, update lake data, and improve component interactions 2026-06-06 20:35:47 +02:00
David Fencl a67a2247c3 feat: import new reservoir data, add lake management scripts, and update overview UI components
continuous-integration/drone/push Build encountered an error
2026-06-06 20:14:36 +02:00
David Fencl cf05e844d8 feat: update water level metrics and optimize sidebar UI layout 2026-06-06 18:38:18 +02:00
David Fencl 6395df1992 feat: implement multilingual SEO support and enhance map UI with data synchronization updates 2026-06-06 17:24:30 +02:00
David Fencl 66021e001e refactor: remove unused lake JSON files and truncate excessive historical data in index files 2026-06-06 12:41:42 +02:00
David Fencl db1aadcc8d feat: add automatic data polling, conditional search visibility, and extended scraper functionality for monthly lake records 2026-06-06 12:34:20 +02:00
David Fencl dbb22e7972 refactor: centralize lake metrics calculations into a utility module with comprehensive unit tests 2026-06-06 11:45:56 +02:00
David Fencl 6d77c20c84 refactor: remove coverage report and add weather widget and navigation utility files 2026-06-06 11:41:13 +02:00
David Fencl a3b3d40769 feat: add circular progress component and update historical lake data indices 2026-06-06 10:38:43 +02:00
David Fencl 27551f9183 feat: implement Favorites feature with persistent storage and sidebar integration and update lake data. 2026-06-05 23:57:17 +02:00
David Fencl b660f0f6c3 feat: add contact link to settings, update lake labels and sidebar icons, and enhance KPI flow visualization 2026-06-05 23:40:56 +02:00
David Fencl 57e9bf12ca feat: implement Open-Meteo weather integration with backfill scripts and updated lake data models.
continuous-integration/drone/push Build encountered an error
2026-06-05 23:34:13 +02:00
David Fencl 8193ce818a feat: implement tests and coverage reports for KpiCards and scrapeLakes functionality 2026-06-05 23:08:44 +02:00
David Fencl 0030dca448 feat: add color-coded indicators to KpiCards and LakeDetail legends 2026-06-05 22:59:19 +02:00
David Fencl 8d1fb5b28e feat: implement automated data scraping and history generation pipeline for PVL reservoir levels 2026-06-05 22:58:21 +02:00
David Fencl 5411bd16ff feat: update lake index, sync scraping scripts, and prune unused data files 2026-06-05 22:24:47 +02:00
David Fencl 61a8af109c feat: implement map view for lake visualization and automate data scraping pipeline 2026-06-05 22:03:38 +02:00
138 changed files with 849430 additions and 1732 deletions
+3
View File
@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Documentation / Ideas
docs/
+1 -1
View File
@@ -13,7 +13,7 @@ WORKDIR /var/www/html
# Enable necessary Apache modules
RUN a2enmod rewrite headers
COPY vhost.conf /etc/apache2/sites-available/000-default.conf
COPY Docker/vhost.conf /etc/apache2/sites-available/000-default.conf
# Copy the built application from the build stage
COPY --from=build /app/dist /app/dist
+30
View File
@@ -0,0 +1,30 @@
version: '3.8'
services:
db:
image: timescale/timescaledb:latest-pg16
container_name: hladinator-db
restart: always
environment:
POSTGRES_DB: hladinator
POSTGRES_USER: hladinator_user
POSTGRES_PASSWORD: hladinator_db_password_change_me
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
web:
build:
context: ..
dockerfile: Docker/Dockerfile
container_name: hladinator-web
restart: always
ports:
- "80:80"
depends_on:
- db
volumes:
pgdata:
driver: local
+1 -1
View File
@@ -1,4 +1,4 @@
# File: .docker/apache/vhost.conf
# File: Docker/vhost.conf
LoadModule headers_module /usr/lib/apache2/modules/mod_headers.so
<VirtualHost *:80>
+87 -58
View File
@@ -1,73 +1,102 @@
# React + TypeScript + Vite
# 🌊 HLADINATOR
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
HLADINATOR is an interactive and visually engaging web application for monitoring the current status and history of Czech reservoirs. The application provides precise data on water level, outflow, inflow, current volume, and additionally collects weather history (temperature and precipitation) directly from the source.
Currently, two official plugins are available:
Data source: **Povodí Vltavy (pvl.cz)** and other river basin administrators in the Czech Republic.
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
---
## React Compiler
## 🚀 How to Run the Application Locally
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
The application is built on a modern stack (React, Vite, TypeScript, Recharts, Leaflet). You don't need any complex backend to run it locally; the data is read directly from pre-generated static JSON files.
## Expanding the ESLint configuration
1. Install dependencies (if you haven't already):
```bash
npm install
```
2. Start the local development server:
```bash
npm run dev
```
3. Open your browser at `http://localhost:5173`.
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
---
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
## 🔄 How to Update Data (Scraping)
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
Povodí Vltavy does not provide a standard API for weather history, nor does it support direct requests from client browsers (due to CORS and security restrictions). Therefore, we use our own custom **scraper**.
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
To manually fetch the latest data from the river basin websites, run in your terminal:
```bash
npm run data:update
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
This command performs two actions:
1. `npm run scrape`: Scrapes the website for all **53 reservoirs and river stations**, parses historical measurement tables, extracts precise **inflow, outflow, volume, precipitation, and temperature**. It then merges this data intelligently with your local database (`public/data/*.json`) and automatically backfills missing values from previous steps to avoid zero-drop anomalies in the charts.
2. `npm run build-index`: Updates the main index file `lakes_index.json`, which the app uses to render fast previews (e.g., in the side menu or on the map).
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
---
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
## ⏰ Automated Data Updates (Cron / Scheduler)
To automatically accumulate weather and precipitation history even when your machine is off or you are sleeping, we recommend automating the execution of the `npm run data:update` script.
Here are the most common deployment methods:
### Option A: Using Crontab on macOS / Linux (Local)
If you have a computer or home server (like a Raspberry Pi) running continuously:
1. Open the terminal and type: `crontab -e`
2. Add the following line at the end of the file (adjust the paths to your project and Node.js installation):
```bash
# Run scraping every 15 minutes
*/15 * * * * cd /Users/davis/WebstormProjects/davisfe.cz && /usr/local/bin/npm run data:update >> scraper.log 2>&1
```
3. Save and close the editor. The system scheduler will take care of the rest.
### Option B: Using GitHub Actions (For Production Hosting)
Once you push the project to GitHub, you can create a workflow file (e.g., `.github/workflows/scrape.yml`) to run the scraping script every hour on GitHub runners for free, and automatically commit and publish the updated `.json` data files back to the repository.
### Option C: Built-in Simple Scheduler (Recommended for Development)
If you do not want to set up system cron, the project has a built-in scheduler. Open another terminal tab/window and run:
```bash
npm run data:watch
```
This command triggers an immediate update and then automatically schedules updates at 7 minutes past every 10-minute step (e.g., 18:07, 18:17, 18:27...). This delay ensures that the river basin web page has updated its data, preventing duplicate/empty requests.
---
## 🐳 Running in Docker (Production & Own Server)
If you want to deploy the application on your own server and run a PostgreSQL (TimescaleDB) database alongside it for future data collection, a Docker Compose configuration is prepared inside the `Docker` directory.
### Requirements:
- Installed **Docker** and **Docker Compose**.
### Deployment:
1. Go to the `Docker` directory and build/run the containers in the background:
```bash
cd Docker
docker-compose up -d --build
```
2. Docker Compose will launch two containers:
- **`hladinator-db`**: PostgreSQL (TimescaleDB) database running on port `5432` with a `pgdata` volume for data persistence.
- **`hladinator-web`**: Apache web server serving the built React static application on port `80`.
3. The web application is then accessible on port `80` of your server.
---
## 🛠️ Fixing Anomalies in History (Zero Drops / Teeth in Graphs)
If the scraper hasn't run for a while (e.g., when your computer was turned off) and data was filled in subsequently, anomalies or drops to zero (teeth) might appear in the inflow and volume graphs. To clean up the entire history and interpolate these points from the last known state, run:
```bash
npm run data:fix
```
This script scans all data JSON files, detects anomalies/zeros, and repairs them.
## 📁 Key File and Folder Structure
* `/scripts/lakesConfig.ts` - Contains configuration definitions for all **53 monitored reservoirs and rivers** (including their river basin ID, GPS coordinates, maximum capacity limits, and elevation heights). You can add new stations here.
* `/public/data/` - Static storage location for generated JSON files. In production, these must be exposed as static assets.
* `/src/components/` - Holds user interface components, the Leaflet map, and `LakeDetail.tsx` (renders historical hydrology and weather charts via Recharts with automatic anomaly filtering).
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import fs from 'fs';
import https from 'https';
async function compare() {
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
const agent = new https.Agent({ rejectUnauthorized: false });
const response = await axios.get(URL, { httpsAgent: agent });
const $ = cheerio.load(response.data);
let tblFound = null;
$('table').each((i, tbl) => {
if ($(tbl).text().includes('Datum') && $(tbl).text().includes('Odtok')) {
tblFound = $(tbl);
}
});
const pvlRows = [];
if (tblFound) {
tblFound.find('tr').each((i, row) => {
if (i === 0) return;
const cols = $(row).find('td');
if (cols.length >= 3) {
const rawDate = $(cols[0]).text().trim();
const levelStr = $(cols[1]).text().trim().replace(',', '.');
let flowStr = $(cols[2]).text().trim().replace(',', '.');
if (flowStr === '' && cols.length >= 4) {
flowStr = $(cols[3]).text().trim().replace(',', '.');
}
pvlRows.push({
date: rawDate,
level: parseFloat(levelStr),
flow: parseFloat(flowStr)
});
}
});
}
const localData = JSON.parse(fs.readFileSync('public/data/VLL1.json', 'utf-8'));
// Sort local data descending (newest first) to match PVL which is newest first
const sortedLocal = localData.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
console.log('--- POROVNÁNÍ DAT: LIPNO 1 ---');
console.log(String('PVL.CZ').padEnd(40) + ' | ' + 'NAŠE LOKÁLNÍ DATABÁZE');
console.log('-'.repeat(85));
for (let i = 0; i < Math.min(10, pvlRows.length); i++) {
const p = pvlRows[i];
const l = sortedLocal[i];
// Format our local UTC timestamp back to something readable
const d = new Date(l.timestamp);
const localDateStr = `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth()+1).toString().padStart(2, '0')}.${d.getFullYear()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
const pvlStr = `[${p.date}] H: ${p.level} m, O: ${p.flow} m3/s`.padEnd(40);
const locStr = `[${localDateStr}] H: ${l.level} m, O: ${l.flow} m3/s, P: ${l.inflow} m3/s`;
console.log(`${pvlStr} | ${locStr}`);
}
}
compare().catch(console.error);
+44
View File
@@ -0,0 +1,44 @@
# Analýza dostupných dat z Povodí Vltavy (PVL.cz)
Tento dokument sumarizuje všechna data, která jsme schopni strojově získat z webových stránek Povodí Vltavy pro jednotlivé vodní nádrže (např. z adresy `Mereni.aspx?oid=1&id=VLL1`).
Data jsou na zdrojovém backendu rozdělena do několika logických celků (tabulek), které můžeme libovolně vytěžovat.
## 1. Technické parametry nádrže (Základní údaje)
Tato data jsou statická a definují fyzické a inženýrské limity přehrady.
* **Tok (River):** Na jaké řece se nádrž nachází (např. Vltava).
* **Koruna hráze:** Absolutní nadmořská výška nejvyššího bodu hráze [m n.m.].
* **Kóta přelivu:** Výška přelivových hran [m n.m.].
* **Maximální retenční hladina:** Krizová úroveň nadržení při povodních [m n.m.].
* **Hladina zásobního prostoru:** Maximální běžná hladina, pro kterou je určen zásobní objem [m n.m.].
* **Hladina stálého nadržení:** Minimální hladina nutná pro zachování ekologických a technických funkcí [m n.m.] (lze využít pro přesný výpočet procentuální naplněnosti).
* **Výškový systém:** Zpravidla "Balt p.v." (Baltský po vyrovnání).
## 2. Aktuální hodnoty (Real-time data)
Tato tabulka obsahuje nejčerstvější data z měřicích stanic s přesným časovým razítkem. Ne všechny hodnoty musí být vždy u všech přehrad dostupné.
* **Časové razítko:** Přesný čas posledního měření (např. *05.06.2026 22:10*).
* **Hladina vody v nádrži:** Aktuální výška [m n.m.].
* **Objem:** Skutečný aktuální zadržovaný objem vody [mil. m³].
* **Přítok (Inflow):** Odhadovaný/měřený přítok do přehrady [m³/s].
* **Odtok (Outflow):** Skutečný odtok z přehrady [m³/s].
* **Srážky (24h):** Úhrn srážek za posledních 24 hodin [mm] *(k dispozici pouze u vybraných stanic)*.
* **Teplota vzduchu:** Aktuální teplota vzduchu [°C] *(k dispozici pouze u vybraných stanic)*.
## 3. Historická časová řada (Tabulka měření)
Tyto tabulky obsahují historický vývoj po jednotlivých hodinách za posledních několik dnů, což využíváme pro vykreslování grafů.
* **Datum a čas:** Hodinové intervaly (např. *05.06.2026 22:00*).
* **Hladina:** Měřená výška hladiny [m n.m.].
* **Odtok:** Odtok přes hráz [m³/s].
* *(Poznámka: Přítok a Objem se do historické tabulky u většiny přehrad ze strany PVL neukládají, zveřejňují pouze hladinu a odtok).*
* **QN:** Indikátor kvality dat (ověřená/neověřená).
---
### Možnosti budoucího rozšíření HLADINATORu
Na základě výše zmíněných dostupných bodů můžeme do aplikace snadno přidat:
1. **Srážky a teplotu** - Pokud je pro danou přehradu údaj dostupný, můžeme přidat widget pro zobrazení úhrnu srážek za 24h a aktuální teploty vzduchu.
2. **Přesnější výpočet %** - Pomocí limitů "Maximální retenční hladina" a "Hladina stálého nadržení" můžeme přesně indikovat blížící se povodňový stav.
3. **Výstražný systém (Alerts)** - Vizuální varování (např. změna barvy panelu na červenou), pokud se aktuální hladina nebezpečně blíží kótě přelivu.
+22 -2
View File
@@ -2,12 +2,32 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Davis Fencl</title>
<title>Hladinátor - Aktuální stav přehrad a nádrží</title>
<meta name="description" content="Sledujte aktuální vodní stav, průtok a vývoj počasí na českých přehradách a nádržích v reálném čase." />
<meta property="og:title" content="Hladinátor - Aktuální stav přehrad a nádrží" />
<meta property="og:description" content="Sledujte aktuální vodní stav, průtok a vývoj počasí na českých přehradách a nádržích v reálném čase." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://hladinator.cz" />
<!-- PWA Settings -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1e293b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/favicon.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('Service Worker registered:', reg.scope))
.catch(err => console.error('Service Worker failed:', err));
});
}
</script>
</body>
</html>
+1389 -17
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -9,31 +9,48 @@
"lint": "eslint .",
"preview": "vite preview",
"mock": "tsx scripts/generateMockLakes.ts",
"scrape": "tsx scripts/scrapeLipno.ts"
"scrape": "tsx scripts/scrapeLakes.ts",
"build-index": "tsx scripts/buildIndex.ts",
"data:update": "npm run scrape && npm run build-index",
"data:watch": "tsx scripts/watchData.ts",
"data:fix": "tsx scripts/fix_lake_inflows.ts",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"axios": "^1.17.0",
"cheerio": "^1.2.0",
"date-fns": "^4.4.0",
"leaflet": "^1.9.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-helmet-async": "^3.0.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.9.6",
"recharts": "^3.8.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.8",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^29.1.1",
"tsx": "^4.22.4",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.1.8"
}
}
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
+12638
View File
File diff suppressed because it is too large Load Diff
+15455
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
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
+15482
View File
File diff suppressed because it is too large Load Diff
+15248
View File
File diff suppressed because it is too large Load Diff
+12287
View File
File diff suppressed because it is too large Load Diff
+15500
View File
File diff suppressed because it is too large Load Diff
+15656
View File
File diff suppressed because it is too large Load Diff
+15167
View File
File diff suppressed because it is too large Load Diff
+12665
View File
File diff suppressed because it is too large Load Diff
+12665
View File
File diff suppressed because it is too large Load Diff
+15437
View File
File diff suppressed because it is too large Load Diff
+15545
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
+15806
View File
File diff suppressed because it is too large Load Diff
+15887
View File
File diff suppressed because it is too large Load Diff
+15473
View File
File diff suppressed because it is too large Load Diff
+15212
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
+13943
View File
File diff suppressed because it is too large Load Diff
+15473
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
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
+15518
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
+15509
View File
File diff suppressed because it is too large Load Diff
+15446
View File
File diff suppressed because it is too large Load Diff
+15554
View File
File diff suppressed because it is too large Load Diff
+15257
View File
File diff suppressed because it is too large Load Diff
+15545
View File
File diff suppressed because it is too large Load Diff
+15563
View File
File diff suppressed because it is too large Load Diff
+15527
View File
File diff suppressed because it is too large Load Diff
+15509
View File
File diff suppressed because it is too large Load Diff
+15527
View File
File diff suppressed because it is too large Load Diff
+15545
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15743
View File
File diff suppressed because it is too large Load Diff
+15509
View File
File diff suppressed because it is too large Load Diff
+15779
View File
File diff suppressed because it is too large Load Diff
+15803
View File
File diff suppressed because it is too large Load Diff
+15788
View File
File diff suppressed because it is too large Load Diff
+15878
View File
File diff suppressed because it is too large Load Diff
+15824
View File
File diff suppressed because it is too large Load Diff
+15851
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15617
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
}
]
+15509
View File
File diff suppressed because it is too large Load Diff
+2023 -915
View File
File diff suppressed because it is too large Load Diff
-172
View File
@@ -1,172 +0,0 @@
[
{
"timestamp": "2026-05-30T05:00:00.000Z",
"level": 723.04,
"flow": 1.03
},
{
"timestamp": "2026-05-31T05:00:00.000Z",
"level": 723.06,
"flow": 1.03
},
{
"timestamp": "2026-06-01T05:00:00.000Z",
"level": 723.08,
"flow": 30.94
},
{
"timestamp": "2026-06-02T05:00:00.000Z",
"level": 723.08,
"flow": 1.51
},
{
"timestamp": "2026-06-03T05:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-04T05:00:00.000Z",
"level": 723.08,
"flow": 1.49
},
{
"timestamp": "2026-06-04T18:00:00.000Z",
"level": 723.08,
"flow": 1.49
},
{
"timestamp": "2026-06-04T19:00:00.000Z",
"level": 723.08,
"flow": 1.49
},
{
"timestamp": "2026-06-04T20:00:00.000Z",
"level": 723.08,
"flow": 1.49
},
{
"timestamp": "2026-06-04T21:00:00.000Z",
"level": 723.08,
"flow": 1.49
},
{
"timestamp": "2026-06-04T22:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-04T23:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T00:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T01:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T02:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T03:00:00.000Z",
"level": 723.08,
"flow": 1.49
},
{
"timestamp": "2026-06-05T04:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T05:00:00.000Z",
"level": 723.08,
"flow": 1.49
},
{
"timestamp": "2026-06-05T06:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T07:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T08:00:00.000Z",
"level": 723.1,
"flow": 1.49
},
{
"timestamp": "2026-06-05T09:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T10:00:00.000Z",
"level": 723.1,
"flow": 1.49
},
{
"timestamp": "2026-06-05T11:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T12:00:00.000Z",
"level": 723.1,
"flow": 1.49
},
{
"timestamp": "2026-06-05T13:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T14:00:00.000Z",
"level": 723.1,
"flow": 1.49
},
{
"timestamp": "2026-06-05T15:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T16:00:00.000Z",
"level": 723.1,
"flow": 1.49
},
{
"timestamp": "2026-06-05T17:00:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T17:10:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T17:20:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T17:30:00.000Z",
"level": 723.09,
"flow": 1.49
},
{
"timestamp": "2026-06-05T17:40:00.000Z",
"level": 723.09,
"flow": 1.49
}
]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"short_name": "Hladinátor",
"name": "Hladinátor - Stav přehrad a nádrží",
"icons": [
{
"src": "/favicon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"start_url": "/",
"background_color": "#1e293b",
"theme_color": "#1e293b",
"display": "standalone",
"orientation": "portrait"
}
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://hladinator.cz/sitemap.xml
+66
View File
@@ -0,0 +1,66 @@
const CACHE_NAME = 'hladinator-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/favicon.png',
'/manifest.json'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS_TO_CACHE);
})
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
})
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
// Only handle same-origin HTTP/HTTPS requests
if (!event.request.url.startsWith(self.location.origin)) return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
// Fetch new version in background to update cache (stale-while-revalidate)
fetch(event.request).then((networkResponse) => {
if (networkResponse.status === 200) {
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, networkResponse));
}
}).catch(() => {/* ignore network failures */});
return cachedResponse;
}
return fetch(event.request).then((networkResponse) => {
// Cache static files and JSON data on the fly
if (networkResponse.status === 200 && (
event.request.url.includes('.json') ||
event.request.url.includes('.css') ||
event.request.url.includes('.js')
)) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseToCache));
}
return networkResponse;
}).catch(() => {
// Offline fallback
});
})
);
});
+1449
View File
File diff suppressed because one or more lines are too long
+629
View File
File diff suppressed because one or more lines are too long
+637
View File
File diff suppressed because one or more lines are too long
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
import { lakesConfig } from './scripts/lakesConfig';
async function run() {
const agent = new https.Agent({ rejectUnauthorized: false });
for (const lake of lakesConfig) {
const [internalId, oid] = lake.id.split('|');
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
try {
const res = await axios.get(URL, { httpsAgent: agent, headers: { 'User-Agent': 'Mozilla/5.0' } });
const $ = cheerio.load(res.data);
let temp = null;
let precip = null;
$('table').each((i, tbl) => {
const text = $(tbl).text();
if (text.includes('Aktuální hodnoty')) {
const tempMatch = text.match(/Teplota vzduchu \[°C\]\s*([\d,]+)/);
if (tempMatch) temp = tempMatch[1];
const precipMatch = text.match(/Srážky \(24h\) \[mm\]\s*([\d,]+)/);
if (precipMatch) precip = precipMatch[1];
}
});
console.log(`[${internalId}] Temp: ${temp}, Precip: ${precip}`);
} catch (e) {
console.error(e.message);
}
}
}
run();
+28
View File
@@ -0,0 +1,28 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
import { lakesConfig } from './scripts/lakesConfig';
async function run() {
const agent = new https.Agent({ rejectUnauthorized: false });
for (const lake of lakesConfig) {
const [internalId, oid] = lake.id.split('|');
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
try {
const res = await axios.get(URL, { httpsAgent: agent, headers: { 'User-Agent': 'Mozilla/5.0' } });
const $ = cheerio.load(res.data);
let storage = 0;
$('table').each((i, tbl) => {
const text = $(tbl).text();
const match = text.match(/Hladina z[aá]sobn[ií]ho prostoru:\s*([\d,]+)/i);
if (match) {
storage = parseFloat(match[1].replace(',', '.'));
}
});
console.log(`{ id: "${lake.id}", storageLevel: ${storage} },`);
} catch (e) {
console.error(e.message);
}
}
}
run();
+19
View File
@@ -0,0 +1,19 @@
import axios from 'axios';
import { lakesConfig } from './scripts/lakesConfig';
async function testOpenMeteo() {
const lipno = lakesConfig.find(l => l.id.startsWith('VLL1'));
if (!lipno) return;
const lat = lipno.coords[0];
const lon = lipno.coords[1];
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,precipitation`;
console.log('Fetching from:', url);
try {
const response = await axios.get(url);
console.log(response.data.current);
} catch (e) {
console.error(e.message);
}
}
testOpenMeteo();
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios';
import { lakesConfig } from './scripts/lakesConfig';
async function testHistory() {
const lipno = lakesConfig.find(l => l.id.startsWith('VLL1'));
if (!lipno) return;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lipno.coords[0]}&longitude=${lipno.coords[1]}&past_days=7&hourly=temperature_2m,precipitation&timezone=GMT`;
console.log('Fetching from:', url);
try {
const res = await axios.get(url);
const hourly = res.data.hourly;
console.log(`Received ${hourly.time.length} hourly records.`);
console.log('Sample record at index 100:');
console.log('Time:', hourly.time[100]);
console.log('Temp:', hourly.temperature_2m[100]);
} catch (e) {
console.error(e.message);
}
}
testHistory();
+46
View File
@@ -0,0 +1,46 @@
import axios from 'axios';
import fs from 'fs';
import https from 'https';
import * as cheerio from 'cheerio';
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
async function testPostback() {
const agent = new https.Agent({ rejectUnauthorized: false });
const res = await axios.get(URL, { httpsAgent: agent, timeout: 10000 });
const $ = cheerio.load(res.data);
const viewstate = $('#__VIEWSTATE').val();
const viewstategenerator = $('#__VIEWSTATEGENERATOR').val();
const eventvalidation = $('#__EVENTVALIDATION').val();
// Try to POST for monthly data
const postData = new URLSearchParams();
postData.append('__EVENTTARGET', 'ctl00$ObsahCPH$PrechodNaBilancniData');
postData.append('__EVENTARGUMENT', '');
postData.append('__VIEWSTATE', viewstate as string);
postData.append('__VIEWSTATEGENERATOR', viewstategenerator as string);
postData.append('__EVENTVALIDATION', eventvalidation as string);
const postRes = await axios.post(URL, postData.toString(), {
httpsAgent: agent,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
fs.writeFileSync('pvl_raw_month.html', postRes.data);
console.log('Saved monthly data to pvl_raw_month.html');
const $post = cheerio.load(postRes.data);
const rows = $post('table.tabulka-seznam tr:not(:first-child)');
console.log(`Found ${rows.length} rows in the table.`);
if (rows.length > 0) {
const firstRow = rows.first().find('td').first().text().trim();
const lastRow = rows.last().find('td').first().text().trim();
console.log(`Date range: ${firstRow} to ${lastRow}`);
}
}
testPostback().catch(console.error);
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { calculateLakeMetrics, LakeCalculationConfig } from '../utils/calculations';
describe('calculateLakeMetrics', () => {
const config: LakeCalculationConfig = {
minLevel: 100,
maxLevel: 110,
storageLevel: 108,
maxVolume: 50,
};
it('should calculate capacity based on reported volume when available', () => {
// 25 / 50 = 50%
const result = calculateLakeMetrics(105, 25, config);
expect(result.capacity).toBe(50);
expect(result.volume).toBe(25);
});
it('should cap capacity at 100% when volume exceeds maxVolume', () => {
const result = calculateLakeMetrics(111, 55, config);
expect(result.capacity).toBe(100);
expect(result.volume).toBe(55);
});
it('should floor capacity at 0% when volume is negative', () => {
const result = calculateLakeMetrics(99, -5, config);
expect(result.capacity).toBe(0);
expect(result.volume).toBe(-5);
});
it('should estimate capacity and volume from level when reported volume is 0', () => {
// Level 105 is exactly halfway between 100 and 110 -> 50%
// 50% of 50 maxVolume = 25
const result = calculateLakeMetrics(105, 0, config);
expect(result.capacity).toBe(50);
expect(result.volume).toBe(25);
});
it('should cap estimated capacity at 100% when level exceeds maxLevel', () => {
const result = calculateLakeMetrics(115, 0, config);
expect(result.capacity).toBe(100);
expect(result.volume).toBe(50); // 100% of 50
});
it('should floor estimated capacity at 0% when level is below minLevel', () => {
const result = calculateLakeMetrics(90, 0, config);
expect(result.capacity).toBe(0);
expect(result.volume).toBe(0); // 0% of 50
});
it('should correctly calculate storageDiff', () => {
const result = calculateLakeMetrics(106, 25, config);
// 106 - 108 = -2.00
expect(result.storageDiff).toBe(-2);
});
it('should calculate positive storageDiff when above storageLevel', () => {
const result = calculateLakeMetrics(109, 25, config);
// 109 - 108 = 1.00
expect(result.storageDiff).toBe(1);
});
it('should handle missing config gracefully', () => {
const emptyConfig: LakeCalculationConfig = {};
const result = calculateLakeMetrics(105, 0, emptyConfig);
expect(result.capacity).toBe(0);
expect(result.volume).toBe(0);
expect(result.storageDiff).toBe(0);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { parseDateString } from '../scrapeLakes';
describe('scrapeLakes - parseDateString', () => {
it('should parse valid date strings correctly', () => {
// Note: JS Date parsing uses local timezone, so the output ISO string depends on where the test runs.
// To make it deterministic, we just check if it returns a string and is not null.
const result = parseDateString('05.06.2026 22:30');
expect(result).not.toBeNull();
// Assuming standard parsing, it should contain 2026
expect(result).toContain('2026-06-05');
});
it('should return null for invalid formats', () => {
expect(parseDateString('')).toBeNull();
expect(parseDateString('invalid date string')).toBeNull();
expect(parseDateString('05.06.2026')).toBeNull(); // Missing time
expect(parseDateString('22:30')).toBeNull(); // Missing date
});
it('should return null for malformed parts', () => {
expect(parseDateString('99.99.9999 99:99')).toBeNull(); // JS Date might parse this as valid overflow, but let's check
expect(parseDateString('abc def ghi')).toBeNull();
});
});
+103
View File
@@ -0,0 +1,103 @@
import fs from 'fs';
import path from 'path';
export interface LakeConfig {
id: string;
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
}
// Preserve existing minLevel, maxLevel, storageLevel that were scraped from PVL.
// Only update maxVolume, coords, and navigationForbidden.
import { lakesConfig as oldConfig } from './lakesConfig';
const exactData: Record<string, Partial<LakeConfig>> = {
"VLL1|1": { maxVolume: 306.0, coords: [48.6322, 14.2215], navigationForbidden: false },
"VLL2|1": { maxVolume: 1.6, coords: [48.6250, 14.3180], navigationForbidden: false },
"VLHN|1": { maxVolume: 21.1, coords: [49.1830, 14.4440], navigationForbidden: false },
"VLKO|1": { maxVolume: 2.8, coords: [49.2550, 14.3980], navigationForbidden: false },
"VLOR|2": { maxVolume: 716.5, coords: [49.6060, 14.1700], navigationForbidden: false },
"VLSL|2": { maxVolume: 269.3, coords: [49.8220, 14.4360], navigationForbidden: false },
"VLST|2": { maxVolume: 11.2, coords: [49.8450, 14.4120], navigationForbidden: false },
"MARI|1": { maxVolume: 33.8, coords: [48.8470, 14.4870], navigationForbidden: true },
"MZHR|3": { maxVolume: 56.7, coords: [49.7890, 13.1550], navigationForbidden: false },
"ZESV|2": { maxVolume: 266.6, coords: [49.7040, 15.1150], navigationForbidden: true },
"VLKA|2": { maxVolume: 12.8, coords: [49.6380, 14.2580], navigationForbidden: false },
"VLVE|2": { maxVolume: 11.1, coords: [49.9390, 14.3910], navigationForbidden: false },
"BLHU|1": { maxVolume: 5.7, coords: [49.0270, 13.9870], navigationForbidden: true },
"UHNY|3": { maxVolume: 16.0, coords: [49.2610, 13.1230], navigationForbidden: true },
"KCKC|3": { maxVolume: 9.3, coords: [50.0630, 13.9310], navigationForbidden: true },
"KLKL|3": { maxVolume: 1.5, coords: [49.7540, 13.5640], navigationForbidden: false },
"RACU|3": { maxVolume: 5.5, coords: [49.7150, 13.3640], navigationForbidden: false },
"TRTR|2": { maxVolume: 4.1, coords: [49.5260, 15.1950], navigationForbidden: false },
"HESE|2": { maxVolume: 1.9, coords: [49.5070, 15.2630], navigationForbidden: false },
"MZLU|3": { maxVolume: 2.3, coords: [49.8050, 12.6390], navigationForbidden: true },
"STZL|3": { maxVolume: 14.5, coords: [50.0930, 13.1360], navigationForbidden: true },
"PPPI|3": { maxVolume: 1.6, coords: [49.6910, 13.9570], navigationForbidden: true },
"LILA|3": { maxVolume: 0.8, coords: [49.6640, 13.8820], navigationForbidden: true },
"OPOB|3": { maxVolume: 0.6, coords: [49.7110, 13.9370], navigationForbidden: true },
"STST|2": { maxVolume: 1.0, coords: [49.7910, 14.0040], navigationForbidden: false },
"HEVR|2": { maxVolume: 0.5, coords: [49.5070, 15.2440], navigationForbidden: false },
"CRSO|1": { maxVolume: 1.4, coords: [48.7750, 14.5360], navigationForbidden: false },
"SCHU|1": { maxVolume: 0.8, coords: [48.7840, 14.7350], navigationForbidden: false },
"SVSV|2": { maxVolume: 1.2, coords: [49.5750, 15.9520], navigationForbidden: true },
"SAPI|2": { maxVolume: 1.5, coords: [49.5930, 15.9320], navigationForbidden: false },
"SMSM|3": { maxVolume: 0.7, coords: [49.8970, 14.0580], navigationForbidden: false },
"CPZA|3": { maxVolume: 0.5, coords: [49.8050, 13.8510], navigationForbidden: false },
"BIBI|1": { maxVolume: 0.3, coords: [49.1670, 14.0410], navigationForbidden: false },
"SPKA|1": { maxVolume: 0.3, coords: [48.9740, 14.5450], navigationForbidden: false },
"SPNE|2": { maxVolume: 0.4, coords: [49.7710, 15.1760], navigationForbidden: false },
"SPZH|1": { maxVolume: 0.2, coords: [49.2310, 15.3120], navigationForbidden: true },
"KLDP|3": { maxVolume: 0.5, coords: [49.6640, 13.7530], navigationForbidden: true },
"KLHP|3": { maxVolume: 0.7, coords: [49.6550, 13.7610], navigationForbidden: true },
"CPDR|3": { maxVolume: 0.1, coords: [49.8050, 13.8550], navigationForbidden: false },
};
function main() {
const updated = oldConfig.map(lake => {
const fresh = exactData[lake.id];
if (fresh) {
return {
...lake,
maxVolume: fresh.maxVolume,
coords: fresh.coords,
navigationForbidden: fresh.navigationForbidden
};
}
return lake;
});
let newContent = `export interface LakeConfig {
id: string;
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
type?: 'lake' | 'river';
country?: string;
area?: number;
depth?: number;
}
export const lakesConfig: LakeConfig[] = [
`;
updated.forEach((l, idx) => {
newContent += ` { id: "${l.id}", text: "${l.text}", ${l.priority ? 'priority: true, ' : ''}coords: [${l.coords[0].toFixed(4)}, ${l.coords[1].toFixed(4)}], maxVolume: ${l.maxVolume}, minLevel: ${l.minLevel}, maxLevel: ${l.maxLevel}, storageLevel: ${l.storageLevel}, navigationForbidden: ${l.navigationForbidden}${l.type ? `, type: '${l.type}'` : ''}${l.country ? `, country: '${l.country}'` : ''}${l.area ? `, area: ${l.area}` : ''}${l.depth ? `, depth: ${l.depth}` : ''} }${idx === updated.length - 1 ? '' : ','}\n`;
});
newContent += `];\n`;
fs.writeFileSync(path.resolve('./scripts/lakesConfig.ts'), newContent);
console.log("lakesConfig.ts updated with precise static data and navigation limits!");
}
main();
+78
View File
@@ -0,0 +1,78 @@
import fs from 'fs';
import path from 'path';
import axios from 'axios';
import { lakesConfig } from './lakesConfig';
const DATA_DIR = path.resolve(process.cwd(), 'public/data');
async function backfill() {
console.log('Starting weather backfill for past 7 days...');
for (const lake of lakesConfig) {
const internalId = lake.id.split('|')[0];
const filePath = path.join(DATA_DIR, `${internalId}.json`);
if (!fs.existsSync(filePath)) {
console.log(`Skipping ${internalId}, no data file.`);
continue;
}
if (!lake.coords) {
console.log(`Skipping ${internalId}, no coordinates.`);
continue;
}
try {
const lat = lake.coords[0];
const lon = lake.coords[1];
// Fetch maximum past days supported by the forecast API (92 days)
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&past_days=92&hourly=temperature_2m,precipitation&timezone=GMT`;
const res = await axios.get(url, { timeout: 15000 });
const hourly = res.data.hourly;
// Build lookup map for O(1) matching: '2026-06-02T04:00' -> { temp, precip }
const weatherMap = new Map();
for (let i = 0; i < hourly.time.length; i++) {
weatherMap.set(hourly.time[i], {
temperature: hourly.temperature_2m[i],
precipitation: hourly.precipitation[i]
});
}
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
let updatedCount = 0;
for (const record of data) {
// record.timestamp is like "2026-06-02T04:10:00.000Z"
// Open-Meteo time is like "2026-06-02T04:00"
// Convert to hourly key to match weatherMap
const hourKey = record.timestamp.substring(0, 13) + ':00';
if (weatherMap.has(hourKey)) {
const w = weatherMap.get(hourKey);
if (w.temperature !== null && w.temperature !== undefined) {
record.temperature = w.temperature;
updatedCount++;
}
if (w.precipitation !== null && w.precipitation !== undefined) {
record.precipitation = w.precipitation;
}
}
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
console.log(`[${internalId}] Backfilled ${updatedCount} records with historical Open-Meteo data.`);
// small delay to prevent rate limit
await new Promise(r => setTimeout(r, 200));
} catch (e: any) {
console.error(`Error processing ${internalId}:`, e.message);
}
}
console.log('Backfill complete!');
}
backfill();
+95
View File
@@ -0,0 +1,95 @@
import * as fs from 'fs';
import * as path from 'path';
import { lakesConfig } from './lakesConfig';
import { calculateLakeMetrics } from './utils/calculations';
interface DataRecord {
timestamp: string;
level: number;
flow: number;
}
const lakes = lakesConfig.map(lake => {
const [internalId, oid] = lake.id.split('|');
const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
let currentLevel = 0;
let currentFlow = 0;
let sparkline: number[] = Array(12).fill(0);
let capacity = 0;
let volume = 0;
let inflow = 0;
if (fs.existsSync(DATA_FILE)) {
try {
const data: any[] = JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
if (data.length > 0) {
// Find latest valid record or just the last record
const lastValidLevelData = [...data].reverse().find(d => d.level !== null && !isNaN(d.level));
const lastValidFlowData = [...data].reverse().find(d => d.flow !== null && !isNaN(d.flow) && d.flow >= 0);
currentLevel = lastValidLevelData ? lastValidLevelData.level : 0;
currentFlow = lastValidFlowData ? lastValidFlowData.flow : 0;
// Take up to 12 last records for sparkline
const recentData = data.slice(-12);
sparkline = recentData.map(d => (d.level === null || isNaN(d.level) ? 0 : d.level));
// Pad with zeros if less than 12
while (sparkline.length < 12) {
sparkline.unshift(0);
}
const latest = data[data.length - 1];
if (latest.volume && latest.volume > 0) {
volume = latest.volume;
}
if (latest.inflow !== undefined) {
inflow = latest.inflow;
}
}
} catch (e) {
console.error(`Error reading data for ${internalId}`, e);
}
}
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,
river,
priority: lake.priority || false,
level: currentLevel.toFixed(2),
capacity: metrics.capacity,
storageDiff: metrics.storageDiff,
inflow: inflow.toFixed(1),
outflow: currentFlow.toFixed(1),
volume: metrics.volume,
maxVolume: lake.maxVolume || 0,
navigationForbidden: lake.navigationForbidden || false,
lat: lake.coords[0],
lng: lake.coords[1],
sparkline,
type: lake.type || 'lake',
country: lake.country || 'CZ',
area: lake.area || 0,
depth: lake.depth || 0
};
});
const outputPath = path.resolve(process.cwd(), 'public/data/lakes_index.json');
fs.writeFileSync(outputPath, JSON.stringify(lakes, null, 2));
console.log('Real lakes index generated:', lakes.length);
+79
View File
@@ -0,0 +1,79 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
const ALL_LAKES = [
{"href": "Mereni.aspx?id=BIBI&oid=1", "text": "VD Bílsko"},
{"href": "Mereni.aspx?id=RACU&oid=3", "text": "VD České Údolí"},
{"href": "Mereni.aspx?id=KLDP&oid=3", "text": "VD Dolejší Padrťský rybník"},
{"href": "Mereni.aspx?id=CPDR&oid=3", "text": "VD Dráteník"},
{"href": "Mereni.aspx?id=KLHP&oid=3", "text": "VD Hořejší Padrťský rybník"},
{"href": "Mereni.aspx?id=SCHU&oid=1", "text": "VD Humenice"},
{"href": "Mereni.aspx?id=BLHU&oid=1", "text": "VD Husinec"},
{"href": "Mereni.aspx?id=VLKA&oid=2", "text": "VD Kamýk"},
{"href": "Mereni.aspx?id=SPKA&oid=1", "text": "VD Karhof"},
{"href": "Mereni.aspx?id=KLKL&oid=3", "text": "VD Klabava"},
{"href": "Mereni.aspx?id=KCKC&oid=3", "text": "VD Klíčava"},
{"href": "Mereni.aspx?id=LILA&oid=3", "text": "VD Láz"},
{"href": "Mereni.aspx?id=MZLU&oid=3", "text": "VD Lučina"},
{"href": "Mereni.aspx?id=SPNE&oid=2", "text": "VD Němčice"},
{"href": "Mereni.aspx?id=UHNY&oid=3", "text": "VD Nýrsko"},
{"href": "Mereni.aspx?id=OPOB&oid=3", "text": "VD Obecnice"},
{"href": "Mereni.aspx?id=PPPI&oid=3", "text": "VD Pilská (u Příbramě)"},
{"href": "Mereni.aspx?id=SAPI&oid=2", "text": "VD Pilská u Žďáru"},
{"href": "Mereni.aspx?id=HESE&oid=2", "text": "VD Sedlice"},
{"href": "Mereni.aspx?id=CRSO&oid=1", "text": "VD Soběnov"},
{"href": "Mereni.aspx?id=SVSV&oid=2", "text": "VD Staviště"},
{"href": "Mereni.aspx?id=STST&oid=2", "text": "VD Strž"},
{"href": "Mereni.aspx?id=SMSM&oid=3", "text": "VD Suchomasty"},
{"href": "Mereni.aspx?id=ZESV&oid=2", "text": "VD Švihov (Želivka)"},
{"href": "Mereni.aspx?id=TRTR&oid=2", "text": "VD Trnávka"},
{"href": "Mereni.aspx?id=VLVE&oid=2", "text": "VD Vrané"},
{"href": "Mereni.aspx?id=HEVR&oid=2", "text": "VD Vřesník"},
{"href": "Mereni.aspx?id=CPZA&oid=3", "text": "VD Záskalská"},
{"href": "Mereni.aspx?id=SPZH&oid=1", "text": "VD Zhejral"},
{"href": "Mereni.aspx?id=STZL&oid=3", "text": "VD Žlutice"}
];
async function checkLakes() {
const agent = new https.Agent({ rejectUnauthorized: false });
const validLakes: any[] = [];
for (const lake of ALL_LAKES) {
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/${lake.href}`;
try {
const response = await axios.get(url, {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
let hasHistory = false;
let hasInflow = false;
$('table').each((i, tbl) => {
const text = $(tbl).text();
if (text.includes('Aktuální hodnoty') && text.includes('Přítok')) {
hasInflow = true;
}
if (text.includes('Datum') && text.includes('Odtok')) {
const rows = $(tbl).find('tr').length;
if (rows > 2) hasHistory = true;
}
});
if (hasHistory && hasInflow) {
validLakes.push(lake);
console.log(`[VALID] ${lake.text}`);
} else {
console.log(`[INVALID] ${lake.text} (Hist:${hasHistory}, In:${hasInflow})`);
}
} catch (err: any) {
console.error(`[ERROR] ${lake.text}: ${err.message}`);
}
}
console.log('\\n--- SUMMARY OF VALID LAKES ---');
console.log(JSON.stringify(validLakes, null, 2));
}
checkLakes();
+29
View File
@@ -0,0 +1,29 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
async function checkMap() {
const agent = new https.Agent({ rejectUnauthorized: false });
try {
const response = await axios.get('https://www.pvl.cz/portal/nadrze/cz/pc/Prehled.aspx', {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const html = response.data;
// Look for variables or inline JSON with coordinates
const scriptMatches = html.match(/<script\\b[^>]*>([\\s\\S]*?)<\\/script>/gi);
if (scriptMatches) {
scriptMatches.forEach((m: string, i: number) => {
if (m.includes('lat') || m.includes('Lng') || m.includes('Points') || m.includes('Markers')) {
console.log("Found something in script " + i);
console.log(m.substring(0, 500)); // preview
}
});
}
} catch (e: any) {
console.error(e.message);
}
}
checkMap();
+33
View File
@@ -0,0 +1,33 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
async function fetchLakes() {
const agent = new https.Agent({ rejectUnauthorized: false });
try {
const response = await axios.get('https://www.pvl.cz/portal/nadrze/cz/pc/Prehled.aspx', {
httpsAgent: agent,
headers: {
'User-Agent': 'Mozilla/5.0'
}
});
const $ = cheerio.load(response.data);
const lakes: any[] = [];
// Links to lakes usually look like Mereni.aspx?oid=xxx&id=yyy
$('a[href^="Mereni.aspx"]').each((i, el) => {
const href = $(el).attr('href');
const text = $(el).text().trim();
if (href && text) {
lakes.push({ href, text });
}
});
console.log(JSON.stringify(lakes, null, 2));
} catch (err: any) {
console.error('Error:', err.message);
}
}
fetchLakes();
+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();

Some files were not shown because too many files have changed in this diff Show More