Compare commits
31 Commits
main
...
5894c51256
| Author | SHA1 | Date | |
|---|---|---|---|
| 5894c51256 | |||
| a1a1685ae3 | |||
| 62d69fbb1e | |||
| c8fe97078d | |||
| c4cad149ea | |||
| 4939d1c5dc | |||
| 8fe39b7ab0 | |||
| cdb653d660 | |||
| 48b44cd642 | |||
| 7a7abdd3e5 | |||
| f8a7be7fa3 | |||
| 62c861e610 | |||
| ec540e056d | |||
| 231961da19 | |||
| a67a2247c3 | |||
| cf05e844d8 | |||
| 6395df1992 | |||
| 66021e001e | |||
| db1aadcc8d | |||
| dbb22e7972 | |||
| 6d77c20c84 | |||
| a3b3d40769 | |||
| 27551f9183 | |||
| b660f0f6c3 | |||
| 57e9bf12ca | |||
| 8193ce818a | |||
| 0030dca448 | |||
| 8d1fb5b28e | |||
| 5411bd16ff | |||
| 61a8af109c | |||
| a5bd4985d1 |
+27
-1
@@ -38,4 +38,30 @@ steps:
|
||||
image: curlimages/curl
|
||||
commands:
|
||||
- curl -u 'howard:Papadopolus0' -X POST 'https://portainer.martinfencl.eu/api/stacks/webhooks/72df3f63-b271-4aef-9325-772a2ccbaeca'
|
||||
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: scrape-cron
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- cron
|
||||
cron:
|
||||
- lipno-scraper
|
||||
|
||||
steps:
|
||||
- name: scrape-and-commit
|
||||
image: node:18-alpine
|
||||
environment:
|
||||
GIT_AUTHOR_NAME: drone
|
||||
GIT_AUTHOR_EMAIL: drone@internet-master.cz
|
||||
GIT_COMMITTER_NAME: drone
|
||||
GIT_COMMITTER_EMAIL: drone@internet-master.cz
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- npm ci
|
||||
- node scripts/scrapeLipno.js
|
||||
- git add public/data/lipno.json
|
||||
- git commit -m "chore: update lipno reservoir data [CI SKIP]" || true
|
||||
- git push origin main || true
|
||||
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Documentation / Ideas
|
||||
docs/
|
||||
|
||||
@@ -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
|
||||
@@ -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,4 +1,4 @@
|
||||
# File: .docker/apache/vhost.conf
|
||||
# File: Docker/vhost.conf
|
||||
LoadModule headers_module /usr/lib/apache2/modules/mod_headers.so
|
||||
|
||||
<VirtualHost *:80>
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+2921
-22
File diff suppressed because it is too large
Load Diff
+27
-3
@@ -7,26 +7,50 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"mock": "tsx scripts/generateMockLakes.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-router-dom": "^7.9.6"
|
||||
"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
@@ -0,0 +1 @@
|
||||
[]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+12638
File diff suppressed because it is too large
Load Diff
+15455
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
File diff suppressed because it is too large
Load Diff
+15248
File diff suppressed because it is too large
Load Diff
+12287
File diff suppressed because it is too large
Load Diff
+15500
File diff suppressed because it is too large
Load Diff
+15656
File diff suppressed because it is too large
Load Diff
+15167
File diff suppressed because it is too large
Load Diff
+12665
File diff suppressed because it is too large
Load Diff
+12665
File diff suppressed because it is too large
Load Diff
+15437
File diff suppressed because it is too large
Load Diff
+15545
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
File diff suppressed because it is too large
Load Diff
+15887
File diff suppressed because it is too large
Load Diff
+15473
File diff suppressed because it is too large
Load Diff
+15212
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
File diff suppressed because it is too large
Load Diff
+15473
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
@@ -0,0 +1 @@
|
||||
[]
|
||||
File diff suppressed because it is too large
Load Diff
+15518
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
File diff suppressed because it is too large
Load Diff
+15446
File diff suppressed because it is too large
Load Diff
+15554
File diff suppressed because it is too large
Load Diff
+15257
File diff suppressed because it is too large
Load Diff
+15545
File diff suppressed because it is too large
Load Diff
+15563
File diff suppressed because it is too large
Load Diff
+15527
File diff suppressed because it is too large
Load Diff
+15509
File diff suppressed because it is too large
Load Diff
+15527
File diff suppressed because it is too large
Load Diff
+15545
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
File diff suppressed because it is too large
Load Diff
+15509
File diff suppressed because it is too large
Load Diff
+15779
File diff suppressed because it is too large
Load Diff
+15803
File diff suppressed because it is too large
Load Diff
+15788
File diff suppressed because it is too large
Load Diff
+15878
File diff suppressed because it is too large
Load Diff
+15824
File diff suppressed because it is too large
Load Diff
+15851
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+15617
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
|
||||
}
|
||||
]
|
||||
+15509
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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 |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://hladinator.cz/sitemap.xml
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+637
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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}¤t=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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user