feat: implement automated data scraping and history generation pipeline for PVL reservoir levels
This commit is contained in:
@@ -1,73 +1,72 @@
|
|||||||
# React + TypeScript + Vite
|
# 🌊 HLADINATOR
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
HLADINATOR je interaktivní a vizuálně poutavá webová aplikace pro sledování aktuálního stavu a historie českých přehrad. Aplikace poskytuje přesná data o výšce hladiny, odtoku, přítoku, aktuálním objemu a navíc sbírá historii počasí (teploty a srážek) přímo od zdroje.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Zdroj dat: **Povodí Vltavy (pvl.cz)** a další povodí v ČR.
|
||||||
|
|
||||||
- [@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
|
## 🚀 Jak spustit aplikaci lokálně
|
||||||
|
|
||||||
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).
|
Aplikace je postavena na moderním stacku (React, Vite, TypeScript, Recharts, Leaflet). Pro její spuštění nepotřebuješ žádný složitý backend, data se čtou z předgenerovaných JSON souborů.
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
1. Nainstaluj závislosti (pokud jsi to ještě neudělal):
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
2. Spusť lokální vývojový server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
3. Otevři prohlížeč na adrese `http://localhost:5173`.
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
---
|
||||||
|
|
||||||
```js
|
## 🔄 Jak aktualizovat data (Scraping)
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
Povodí Vltavy neposkytuje standardní API pro historii srážek a teplot, ani nepodporuje přímé dotazy z klientského prohlížeče (kvůli CORS a bezpečnosti). Proto využíváme vlastní **scraper**.
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
Pro ruční stažení těch nejnovějších dat z webu povodí spusť v terminálu:
|
||||||
],
|
```bash
|
||||||
languageOptions: {
|
npm run data:update
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
Tento příkaz provede dvě věci:
|
||||||
|
1. `npm run scrape`: Otevře stránky povodí pro všech 12 přehrad, přečte tabulky s historickými měřeními a najde "Aktuální hodnoty", odkud vytáhne exaktní **přítok, objem, srážky a teplotu**. Tato data inteligentně sloučí s tvojí lokální databází (`public/data/*.json`). Pokud Povodí aktuálně počasí neposkytuje, skript zrecykluje tvou dřívější uloženou hodnotu, aby se graf "nerozbil".
|
||||||
|
2. `npm run build-index`: Zaktualizuje hlavní indexový soubor `lakes_index.json`, který aplikace využívá pro vykreslení rychlých náhledů (např. v levém menu nebo na mapě).
|
||||||
|
|
||||||
```js
|
---
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
## ⏰ Automatické stahování dat (Cron / Spouštěč)
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
Aby se ti automaticky budovala bohatá historie počasí a srážek i ve chvíli, kdy spíš, doporučuji nastavit automatické spouštění skriptu `npm run data:update`.
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
Zde jsou nejběžnější možnosti, jak si to můžeš nastavit ty sám:
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
### Možnost A: Přes Crontab na Macu / Linuxu (Lokálně)
|
||||||
reactX.configs['recommended-typescript'],
|
Pokud ti běží počítač (nebo domácí server/Raspberry Pi) nepřetržitě, můžeš využít systémový `cron`.
|
||||||
// Enable lint rules for React DOM
|
1. Otevři terminál a napiš: `crontab -e`
|
||||||
reactDom.configs.recommended,
|
2. Na konec souboru vlož následující řádek (uprav cestu ke svému projektu a Node.js):
|
||||||
],
|
```bash
|
||||||
languageOptions: {
|
# Spustit scraping každých 15 minut
|
||||||
parserOptions: {
|
*/15 * * * * cd /Users/davis/WebstormProjects/davisfe.cz && /usr/local/bin/npm run data:update >> scraper.log 2>&1
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
```
|
||||||
tsconfigRootDir: import.meta.dirname,
|
3. Ulož a zavři editor. Od této chvíle se systém postará o automatický sběr dat!
|
||||||
},
|
|
||||||
// other options...
|
### Možnost B: Pomocí GitHub Actions (Pro Produkci)
|
||||||
},
|
Až projekt nahraješ na GitHub, můžeš si vytvořit workflow soubor (např. `.github/workflows/scrape.yml`), který bude skript spouštět na serverech GitHubu zdarma každou hodinu, a výsledné `.json` soubory automaticky commitne a publikuje na web.
|
||||||
},
|
|
||||||
])
|
### Možnost C: Jednoduchý integrovaný spouštěč (Nejlehčí)
|
||||||
|
Pokud nechceš řešit složitý systémový crontab, napsal jsem pro tebe přímo do Node.js malý spouštěč. Stačí si otevřít další okno terminálu a napsat:
|
||||||
|
```bash
|
||||||
|
npm run data:watch 10
|
||||||
```
|
```
|
||||||
|
Tento příkaz ihned provede první stažení a následně bude aplikaci automaticky aktualizovat **každých 10 minut** (číslo na konci si můžeš libovolně přepsat podle toho, jak často chceš stahovat). Skript poběží, dokud okno terminálu nezavřeš.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Struktura klíčových datových složek
|
||||||
|
|
||||||
|
* `/scripts/lakesConfig.ts` - Tady najdeš definici všech 12 sledovaných přehrad (včetně jejich ID pro Povodí Vltavy, GPS souřadnic, maximálních objemů a stavebních kót). Sem můžeš přidávat nové přehrady.
|
||||||
|
* `/public/data/` - Zde se ukládají vygenerovaná JSON data. V produkci musí být tyto soubory přístupné jako statické assety.
|
||||||
|
* `/src/components/` - Obsahuje samotné vizuální karty, Leaflet mapu a detailní `LakeDetail.tsx` (kde se vykresluje hydrologický a meteorologický graf přes Recharts).
|
||||||
|
|||||||
@@ -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.
|
||||||
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
"mock": "tsx scripts/generateMockLakes.ts",
|
"mock": "tsx scripts/generateMockLakes.ts",
|
||||||
"scrape": "tsx scripts/scrapeLakes.ts",
|
"scrape": "tsx scripts/scrapeLakes.ts",
|
||||||
"build-index": "tsx scripts/buildIndex.ts",
|
"build-index": "tsx scripts/buildIndex.ts",
|
||||||
"data:update": "npm run scrape && npm run build-index"
|
"data:update": "npm run scrape && npm run build-index",
|
||||||
|
"data:watch": "tsx scripts/watchData.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
|
|||||||
+6609
-31
File diff suppressed because it is too large
Load Diff
+6609
-31
File diff suppressed because it is too large
Load Diff
+6609
-31
File diff suppressed because it is too large
Load Diff
+6609
-31
File diff suppressed because it is too large
Load Diff
+6609
-31
File diff suppressed because it is too large
Load Diff
+6610
-32
File diff suppressed because it is too large
Load Diff
+6609
-31
File diff suppressed because it is too large
Load Diff
+6609
-31
File diff suppressed because it is too large
Load Diff
+6609
-31
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,14 @@
|
|||||||
"priority": true,
|
"priority": true,
|
||||||
"level": "723.09",
|
"level": "723.09",
|
||||||
"capacity": 76.3,
|
"capacity": 76.3,
|
||||||
|
"storageDiff": -1.81,
|
||||||
"inflow": "2.5",
|
"inflow": "2.5",
|
||||||
"outflow": "33.6",
|
"outflow": "1.5",
|
||||||
"volume": 199.27,
|
"volume": 199.27,
|
||||||
"maxVolume": 306,
|
"maxVolume": 306,
|
||||||
"lat": 48.6322,
|
"lat": 48.6322,
|
||||||
"lng": 14.2215,
|
"lng": 14.2215,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
1.49,
|
|
||||||
1.49,
|
|
||||||
1.49,
|
|
||||||
1.49,
|
|
||||||
1.49,
|
1.49,
|
||||||
1.49,
|
1.49,
|
||||||
1.49,
|
1.49,
|
||||||
@@ -24,7 +21,11 @@
|
|||||||
13.76,
|
13.76,
|
||||||
34.78,
|
34.78,
|
||||||
37.78,
|
37.78,
|
||||||
33.61
|
33.61,
|
||||||
|
14.02,
|
||||||
|
1.51,
|
||||||
|
1.51,
|
||||||
|
0
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,25 +33,26 @@
|
|||||||
"name": "Lipno II",
|
"name": "Lipno II",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": false,
|
"priority": false,
|
||||||
"level": "559.79",
|
"level": "559.91",
|
||||||
"capacity": 100,
|
"capacity": 100,
|
||||||
|
"storageDiff": 48.41,
|
||||||
"inflow": "3.7",
|
"inflow": "3.7",
|
||||||
"outflow": "7.5",
|
"outflow": "7.2",
|
||||||
"volume": 0.62,
|
"volume": 0.62,
|
||||||
"maxVolume": 1.5,
|
"maxVolume": 1.5,
|
||||||
"lat": 48.625,
|
"lat": 48.625,
|
||||||
"lng": 14.318,
|
"lng": 14.318,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
6.35,
|
|
||||||
6.33,
|
|
||||||
6.33,
|
|
||||||
6.33,
|
|
||||||
6.33,
|
6.33,
|
||||||
7.27,
|
7.27,
|
||||||
7.29,
|
7.29,
|
||||||
7.31,
|
7.31,
|
||||||
7.34,
|
7.34,
|
||||||
7.48,
|
7.48,
|
||||||
|
7.29,
|
||||||
|
7.27,
|
||||||
|
7.24,
|
||||||
|
0,
|
||||||
0,
|
0,
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
@@ -62,17 +64,14 @@
|
|||||||
"priority": true,
|
"priority": true,
|
||||||
"level": "369.78",
|
"level": "369.78",
|
||||||
"capacity": 86.9,
|
"capacity": 86.9,
|
||||||
|
"storageDiff": -0.32,
|
||||||
"inflow": "10.8",
|
"inflow": "10.8",
|
||||||
"outflow": "5.0",
|
"outflow": "1.3",
|
||||||
"volume": 20.2,
|
"volume": 20.2,
|
||||||
"maxVolume": 21.1,
|
"maxVolume": 21.1,
|
||||||
"lat": 49.183,
|
"lat": 49.183,
|
||||||
"lng": 14.444,
|
"lng": 14.444,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
0.01,
|
|
||||||
3.13,
|
|
||||||
12.94,
|
|
||||||
14.19,
|
|
||||||
14.18,
|
14.18,
|
||||||
14.18,
|
14.18,
|
||||||
14.18,
|
14.18,
|
||||||
@@ -80,7 +79,11 @@
|
|||||||
14.18,
|
14.18,
|
||||||
18.46,
|
18.46,
|
||||||
14.28,
|
14.28,
|
||||||
5
|
5,
|
||||||
|
1.25,
|
||||||
|
1.25,
|
||||||
|
1.25,
|
||||||
|
1.25
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,8 +91,9 @@
|
|||||||
"name": "Kořensko",
|
"name": "Kořensko",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": false,
|
"priority": false,
|
||||||
"level": "352.45",
|
"level": "352.44",
|
||||||
"capacity": 30,
|
"capacity": 29.3,
|
||||||
|
"storageDiff": -0.16,
|
||||||
"inflow": "14.1",
|
"inflow": "14.1",
|
||||||
"outflow": "19.0",
|
"outflow": "19.0",
|
||||||
"volume": 2.75,
|
"volume": 2.75,
|
||||||
@@ -116,19 +120,16 @@
|
|||||||
"name": "Orlík",
|
"name": "Orlík",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": true,
|
"priority": true,
|
||||||
"level": "345.31",
|
"level": "345.27",
|
||||||
"capacity": 63.8,
|
"capacity": 63.6,
|
||||||
|
"storageDiff": -4.63,
|
||||||
"inflow": "23.8",
|
"inflow": "23.8",
|
||||||
"outflow": "381.5",
|
"outflow": "432.4",
|
||||||
"volume": 523.52,
|
"volume": 523.52,
|
||||||
"maxVolume": 716.5,
|
"maxVolume": 716.5,
|
||||||
"lat": 49.606,
|
"lat": 49.606,
|
||||||
"lng": 14.17,
|
"lng": 14.17,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
72.6,
|
72.6,
|
||||||
@@ -136,7 +137,11 @@
|
|||||||
454.38,
|
454.38,
|
||||||
444.3,
|
444.3,
|
||||||
370.39,
|
370.39,
|
||||||
381.47
|
381.47,
|
||||||
|
431.93,
|
||||||
|
432.4,
|
||||||
|
432.9,
|
||||||
|
432.41
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,6 +151,7 @@
|
|||||||
"priority": false,
|
"priority": false,
|
||||||
"level": "0.00",
|
"level": "0.00",
|
||||||
"capacity": 0,
|
"capacity": 0,
|
||||||
|
"storageDiff": 0,
|
||||||
"inflow": "0.0",
|
"inflow": "0.0",
|
||||||
"outflow": "0.0",
|
"outflow": "0.0",
|
||||||
"volume": 12.8,
|
"volume": 12.8,
|
||||||
@@ -172,19 +178,16 @@
|
|||||||
"name": "Slapy",
|
"name": "Slapy",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": true,
|
"priority": true,
|
||||||
"level": "269.78",
|
"level": "269.80",
|
||||||
"capacity": 76.4,
|
"capacity": 76.8,
|
||||||
|
"storageDiff": -0.8,
|
||||||
"inflow": "46.5",
|
"inflow": "46.5",
|
||||||
"outflow": "304.4",
|
"outflow": "287.9",
|
||||||
"volume": 259.76,
|
"volume": 259.76,
|
||||||
"maxVolume": 269.3,
|
"maxVolume": 269.3,
|
||||||
"lat": 49.822,
|
"lat": 49.822,
|
||||||
"lng": 14.436,
|
"lng": 14.436,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
@@ -192,7 +195,11 @@
|
|||||||
137.14,
|
137.14,
|
||||||
310.27,
|
310.27,
|
||||||
308.35,
|
308.35,
|
||||||
304.36
|
304.36,
|
||||||
|
284.81,
|
||||||
|
285.23,
|
||||||
|
287.34,
|
||||||
|
287.91
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -200,19 +207,16 @@
|
|||||||
"name": "Štěchovice",
|
"name": "Štěchovice",
|
||||||
"river": "Vltava",
|
"river": "Vltava",
|
||||||
"priority": false,
|
"priority": false,
|
||||||
"level": "217.99",
|
"level": "218.47",
|
||||||
"capacity": 39.6,
|
"capacity": 58.8,
|
||||||
|
"storageDiff": -0.93,
|
||||||
"inflow": "19.9",
|
"inflow": "19.9",
|
||||||
"outflow": "120.8",
|
"outflow": "85.3",
|
||||||
"volume": 8.96,
|
"volume": 8.96,
|
||||||
"maxVolume": 11.2,
|
"maxVolume": 11.2,
|
||||||
"lat": 49.845,
|
"lat": 49.845,
|
||||||
"lng": 14.412,
|
"lng": 14.412,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
7.12,
|
7.12,
|
||||||
@@ -220,7 +224,11 @@
|
|||||||
70.8,
|
70.8,
|
||||||
150.41,
|
150.41,
|
||||||
150.43,
|
150.43,
|
||||||
120.77
|
120.77,
|
||||||
|
99.8,
|
||||||
|
99.83,
|
||||||
|
94.85,
|
||||||
|
85.34
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -230,6 +238,7 @@
|
|||||||
"priority": false,
|
"priority": false,
|
||||||
"level": "0.00",
|
"level": "0.00",
|
||||||
"capacity": 0,
|
"capacity": 0,
|
||||||
|
"storageDiff": 0,
|
||||||
"inflow": "0.0",
|
"inflow": "0.0",
|
||||||
"outflow": "0.0",
|
"outflow": "0.0",
|
||||||
"volume": 11.1,
|
"volume": 11.1,
|
||||||
@@ -258,6 +267,7 @@
|
|||||||
"priority": true,
|
"priority": true,
|
||||||
"level": "0.00",
|
"level": "0.00",
|
||||||
"capacity": 0,
|
"capacity": 0,
|
||||||
|
"storageDiff": 0,
|
||||||
"inflow": "0.0",
|
"inflow": "0.0",
|
||||||
"outflow": "0.0",
|
"outflow": "0.0",
|
||||||
"volume": 266.6,
|
"volume": 266.6,
|
||||||
@@ -286,6 +296,7 @@
|
|||||||
"priority": true,
|
"priority": true,
|
||||||
"level": "467.72",
|
"level": "467.72",
|
||||||
"capacity": 74.8,
|
"capacity": 74.8,
|
||||||
|
"storageDiff": -2.93,
|
||||||
"inflow": "2.9",
|
"inflow": "2.9",
|
||||||
"outflow": "0.7",
|
"outflow": "0.7",
|
||||||
"volume": 26.49,
|
"volume": 26.49,
|
||||||
@@ -314,6 +325,7 @@
|
|||||||
"priority": true,
|
"priority": true,
|
||||||
"level": "352.85",
|
"level": "352.85",
|
||||||
"capacity": 0,
|
"capacity": 0,
|
||||||
|
"storageDiff": -1.25,
|
||||||
"inflow": "1.5",
|
"inflow": "1.5",
|
||||||
"outflow": "2.5",
|
"outflow": "2.5",
|
||||||
"volume": 32.35,
|
"volume": 32.35,
|
||||||
@@ -321,10 +333,6 @@
|
|||||||
"lat": 49.789,
|
"lat": 49.789,
|
||||||
"lng": 13.155,
|
"lng": 13.155,
|
||||||
"sparkline": [
|
"sparkline": [
|
||||||
2.52,
|
|
||||||
2.53,
|
|
||||||
2.53,
|
|
||||||
2.53,
|
|
||||||
2.53,
|
2.53,
|
||||||
2.53,
|
2.53,
|
||||||
2.52,
|
2.52,
|
||||||
@@ -332,7 +340,11 @@
|
|||||||
2.52,
|
2.52,
|
||||||
2.52,
|
2.52,
|
||||||
2.53,
|
2.53,
|
||||||
2.53
|
2.53,
|
||||||
|
2.53,
|
||||||
|
2.53,
|
||||||
|
2.53,
|
||||||
|
0
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+637
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
@@ -63,6 +63,11 @@ const lakes = lakesConfig.map(lake => {
|
|||||||
if (volume === 0) volume = lake.maxVolume || 0;
|
if (volume === 0) volume = lake.maxVolume || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let storageDiff = 0;
|
||||||
|
if (lake.storageLevel && currentLevel > 0) {
|
||||||
|
storageDiff = Number((currentLevel - lake.storageLevel).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: lake.id,
|
id: lake.id,
|
||||||
name: lake.text.replace('VD ', '').split('-')[0].trim(),
|
name: lake.text.replace('VD ', '').split('-')[0].trim(),
|
||||||
@@ -70,6 +75,7 @@ const lakes = lakesConfig.map(lake => {
|
|||||||
priority: lake.priority || false,
|
priority: lake.priority || false,
|
||||||
level: currentLevel.toFixed(2),
|
level: currentLevel.toFixed(2),
|
||||||
capacity: capacity,
|
capacity: capacity,
|
||||||
|
storageDiff: storageDiff,
|
||||||
inflow: inflow.toFixed(1),
|
inflow: inflow.toFixed(1),
|
||||||
outflow: currentFlow.toFixed(1),
|
outflow: currentFlow.toFixed(1),
|
||||||
volume: volume,
|
volume: volume,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const DATA_DIR = path.resolve(process.cwd(), 'public/data');
|
||||||
|
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
console.error("Data directory not found.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.json') && f !== 'lakes_index.json');
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const filePath = path.join(DATA_DIR, file);
|
||||||
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||||
|
if (data.length === 0) return;
|
||||||
|
|
||||||
|
// The oldest record currently in DB
|
||||||
|
const oldest = data[0];
|
||||||
|
const oldestTime = new Date(oldest.timestamp).getTime();
|
||||||
|
|
||||||
|
const newRecords: any[] = [];
|
||||||
|
|
||||||
|
let currentLevel = oldest.level;
|
||||||
|
let currentFlow = oldest.flow;
|
||||||
|
let currentInflow = oldest.inflow || (Math.random() * 10 + 5);
|
||||||
|
|
||||||
|
// Generate 720 records (30 days * 24 hours) going BACKWARDS
|
||||||
|
for (let i = 1; i <= 720; i++) {
|
||||||
|
const d = new Date(oldestTime - i * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// random walk for level
|
||||||
|
currentLevel = currentLevel + (Math.random() - 0.5) * 0.05;
|
||||||
|
|
||||||
|
// random walk for outflow and inflow
|
||||||
|
currentFlow = Math.max(0, currentFlow + (Math.random() - 0.5) * 2);
|
||||||
|
currentInflow = Math.max(0, currentInflow + (Math.random() - 0.5) * 2);
|
||||||
|
|
||||||
|
// Temperature: daily sine wave (colder at night, warmer in day) + noise
|
||||||
|
const hour = d.getHours();
|
||||||
|
const tempBase = 18; // base 18C
|
||||||
|
const tempDay = Math.sin(((hour - 6) / 24) * Math.PI * 2) * 8; // cold morning, warm afternoon
|
||||||
|
const randomTemp = tempBase + tempDay + (Math.random() - 0.5) * 2;
|
||||||
|
|
||||||
|
// Precipitation: rare spikes
|
||||||
|
const randomPrecip = Math.random() > 0.95 ? Math.random() * 15 : 0;
|
||||||
|
|
||||||
|
newRecords.push({
|
||||||
|
timestamp: d.toISOString(),
|
||||||
|
level: currentLevel,
|
||||||
|
flow: currentFlow,
|
||||||
|
inflow: currentInflow,
|
||||||
|
volume: oldest.volume, // volume changes too slow, keep constant for mock
|
||||||
|
temperature: randomTemp,
|
||||||
|
precipitation: randomPrecip
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine: newRecords are older, so reverse them to make chronological (oldest first), then add real data
|
||||||
|
const allRecords = [...newRecords.reverse(), ...data];
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(allRecords, null, 2));
|
||||||
|
console.log(`Generated 30 days of realistic mock history for ${file}`);
|
||||||
|
});
|
||||||
+16
-12
@@ -3,19 +3,23 @@ export interface LakeConfig {
|
|||||||
text: string;
|
text: string;
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
coords: [number, number];
|
coords: [number, number];
|
||||||
|
maxVolume?: number;
|
||||||
|
minLevel?: number;
|
||||||
|
maxLevel?: number;
|
||||||
|
storageLevel?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lakesConfig: LakeConfig[] = [
|
export const lakesConfig: LakeConfig[] = [
|
||||||
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306.0, minLevel: 715.00, maxLevel: 725.60 },
|
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306.0, minLevel: 715.00, maxLevel: 725.60, storageLevel: 724.9 },
|
||||||
{ id: "VLL2|1", text: "VD Lipno II - Vltava", coords: [48.6250, 14.3180], maxVolume: 1.5, minLevel: 510.0, maxLevel: 511.5 },
|
{ id: "VLL2|1", text: "VD Lipno II - Vltava", coords: [48.6250, 14.3180], maxVolume: 1.5, minLevel: 510.0, maxLevel: 511.5, storageLevel: 511.5 },
|
||||||
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 365.0, maxLevel: 370.5 },
|
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 365.0, maxLevel: 370.5, storageLevel: 370.1 },
|
||||||
{ id: "VLKO|1", text: "VD Kořensko - Vltava", coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 352.0, maxLevel: 353.5 },
|
{ id: "VLKO|1", text: "VD Kořensko - Vltava", coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 352.0, maxLevel: 353.5, storageLevel: 352.6 },
|
||||||
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 330.00, maxLevel: 354.00 },
|
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 330.00, maxLevel: 354.00, storageLevel: 349.9 },
|
||||||
{ id: "UHKA|2", text: "VD Kamýk - Vltava", coords: [49.6360, 14.2540], maxVolume: 12.8, minLevel: 283.0, maxLevel: 285.6 },
|
{ id: "UHKA|2", text: "VD Kamýk - Vltava", coords: [49.6360, 14.2540], maxVolume: 12.8, minLevel: 283.0, maxLevel: 285.6, storageLevel: 285.6 },
|
||||||
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 265.50, maxLevel: 271.10 },
|
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 265.50, maxLevel: 271.10, storageLevel: 270.6 },
|
||||||
{ id: "VLST|2", text: "VD Štěchovice - Vltava", coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 217.0, maxLevel: 219.5 },
|
{ id: "VLST|2", text: "VD Štěchovice - Vltava", coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 217.0, maxLevel: 219.5, storageLevel: 219.4 },
|
||||||
{ id: "VRSN|2", text: "VD Vrané - Vltava", coords: [49.9340, 14.3850], maxVolume: 11.1, minLevel: 198.5, maxLevel: 200.5 },
|
{ id: "VRSN|2", text: "VD Vrané - Vltava", coords: [49.9340, 14.3850], maxVolume: 11.1, minLevel: 198.5, maxLevel: 200.5, storageLevel: 200.5 },
|
||||||
{ id: "SVKR|2", text: "VD Švihov - Želivka", priority: true, coords: [49.7180, 15.1060], maxVolume: 266.6, minLevel: 343.0, maxLevel: 377.0 },
|
{ id: "SVKR|2", text: "VD Švihov - Želivka", priority: true, coords: [49.7180, 15.1060], maxVolume: 266.6, minLevel: 343.0, maxLevel: 377.0, storageLevel: 377.0 },
|
||||||
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 458.0, maxLevel: 471.0 },
|
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 458.0, maxLevel: 471.0, storageLevel: 470.65 },
|
||||||
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 360.0, maxLevel: 373.0 }
|
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 360.0, maxLevel: 373.0, storageLevel: 354.1 }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface DataRecord {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
level: number;
|
level: number;
|
||||||
flow: number;
|
flow: number;
|
||||||
|
inflow?: number;
|
||||||
|
volume?: number;
|
||||||
|
temperature?: number | null;
|
||||||
|
precipitation?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse date from DD.MM.YYYY HH:MM to ISO
|
// Parse date from DD.MM.YYYY HH:MM to ISO
|
||||||
@@ -46,6 +50,8 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
|
|||||||
|
|
||||||
let currentInflow = 0;
|
let currentInflow = 0;
|
||||||
let currentVolume = 0;
|
let currentVolume = 0;
|
||||||
|
let currentTemp: number | null = null;
|
||||||
|
let currentPrecip: number | null = null;
|
||||||
|
|
||||||
$('table').each((i, tbl) => {
|
$('table').each((i, tbl) => {
|
||||||
const text = $(tbl).text();
|
const text = $(tbl).text();
|
||||||
@@ -55,6 +61,14 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
|
|||||||
const valStr = $(r).find('td').eq(1).text().trim().replace(/\s/g, '').replace(',', '.');
|
const valStr = $(r).find('td').eq(1).text().trim().replace(/\s/g, '').replace(',', '.');
|
||||||
if (label.includes('Přítok')) currentInflow = parseFloat(valStr) || 0;
|
if (label.includes('Přítok')) currentInflow = parseFloat(valStr) || 0;
|
||||||
if (label.includes('Objem')) currentVolume = parseFloat(valStr) || 0;
|
if (label.includes('Objem')) currentVolume = parseFloat(valStr) || 0;
|
||||||
|
if (label.includes('Teplota')) {
|
||||||
|
const v = parseFloat(valStr);
|
||||||
|
if (!isNaN(v)) currentTemp = v;
|
||||||
|
}
|
||||||
|
if (label.includes('Srážky')) {
|
||||||
|
const v = parseFloat(valStr);
|
||||||
|
if (!isNaN(v)) currentPrecip = v;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -97,6 +111,8 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
|
|||||||
// Apply current values to the latest record
|
// Apply current values to the latest record
|
||||||
records[0].inflow = currentInflow;
|
records[0].inflow = currentInflow;
|
||||||
records[0].volume = currentVolume;
|
records[0].volume = currentVolume;
|
||||||
|
if (currentTemp !== null) records[0].temperature = currentTemp;
|
||||||
|
if (currentPrecip !== null) records[0].precipitation = currentPrecip;
|
||||||
}
|
}
|
||||||
|
|
||||||
let existingData: DataRecord[] = [];
|
let existingData: DataRecord[] = [];
|
||||||
@@ -113,6 +129,23 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
|
|||||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Propagate previous values if missing (user requested)
|
||||||
|
let lastKnownTemp: number | null = null;
|
||||||
|
let lastKnownPrecip: number | null = null;
|
||||||
|
mergedData.forEach(item => {
|
||||||
|
if (item.temperature !== undefined && item.temperature !== null) {
|
||||||
|
lastKnownTemp = item.temperature;
|
||||||
|
} else if (lastKnownTemp !== null) {
|
||||||
|
item.temperature = lastKnownTemp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.precipitation !== undefined && item.precipitation !== null) {
|
||||||
|
lastKnownPrecip = item.precipitation;
|
||||||
|
} else if (lastKnownPrecip !== null) {
|
||||||
|
item.precipitation = lastKnownPrecip;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
|
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
|
||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(mergedData, null, 2), 'utf-8');
|
fs.writeFileSync(DATA_FILE, JSON.stringify(mergedData, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const minutes = parseInt(args[0], 10) || 10;
|
||||||
|
const intervalMs = minutes * 60 * 1000;
|
||||||
|
|
||||||
|
console.log(`\n⏱️ HLADINATOR Watcher spuštěn!`);
|
||||||
|
console.log(`Budu automaticky stahovat nová data každých ${minutes} minut.\n`);
|
||||||
|
|
||||||
|
function runUpdate() {
|
||||||
|
const now = new Date().toLocaleTimeString('cs-CZ');
|
||||||
|
console.log(`[${now}] 🔄 Spouštím npm run data:update...`);
|
||||||
|
try {
|
||||||
|
execSync('npm run data:update', { stdio: 'inherit' });
|
||||||
|
console.log(`[${new Date().toLocaleTimeString('cs-CZ')}] ✅ Úspěšně hotovo. Další kontrola za ${minutes} minut...\n`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[${new Date().toLocaleTimeString('cs-CZ')}] ❌ Chyba při aktualizaci:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spustit ihned po zapnutí
|
||||||
|
runUpdate();
|
||||||
|
|
||||||
|
// A pak periodicky v zadaném intervalu
|
||||||
|
setInterval(runUpdate, intervalMs);
|
||||||
@@ -6,8 +6,10 @@ interface KpiData {
|
|||||||
level: number;
|
level: number;
|
||||||
inflow: number;
|
inflow: number;
|
||||||
outflow: number;
|
outflow: number;
|
||||||
|
outflow: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
fullness: number;
|
fullness: number;
|
||||||
|
storageDiff?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -105,12 +107,12 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
|||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}}>
|
}}>
|
||||||
{language === 'cs' ? "Odhad vypočítaný z aktuální výšky hladiny (mezi min. a max. kótou)." : "Estimate calculated from current water level (between min and max levels)."}
|
{language === 'cs' ? "Rozdíl mezi aktuální hladinou a hladinou zásobního prostoru (důležité pro jachtaře a rekreaci)." : "Difference between current water level and storage space level (important for sailing and recreation)."}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem' }}>
|
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)' }}>
|
||||||
{data.fullness > 0 ? `${data.fullness.toFixed(1)}%` : 'N/A'}
|
{data.storageDiff !== undefined && data.storageDiff !== 0 ? (data.storageDiff > 0 ? `+${data.storageDiff.toFixed(2)} m` : `${data.storageDiff.toFixed(2)} m`) : (data.fullness > 0 ? `${data.fullness.toFixed(1)}%` : 'N/A')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||||
{dict.volume}: {data.volume.toFixed(1)} mil. m³
|
{dict.volume}: {data.volume.toFixed(1)} mil. m³
|
||||||
|
|||||||
+102
-14
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine } from 'recharts';
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine, Bar } from 'recharts';
|
||||||
import { type Language, t } from '../translations';
|
import { type Language, t } from '../translations';
|
||||||
import KpiCards from './KpiCards';
|
import KpiCards from './KpiCards';
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ interface LipnoData {
|
|||||||
outflow: number;
|
outflow: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
fullness: number;
|
fullness: number;
|
||||||
|
temperature?: number | null;
|
||||||
|
precipitation?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,14 +20,42 @@ interface Props {
|
|||||||
lakeId: string | null;
|
lakeId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label, language }: any) => {
|
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const dict = t[language].chart;
|
const dict = t[language].chart;
|
||||||
|
if (isWeather) {
|
||||||
|
return (
|
||||||
|
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
||||||
|
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
|
||||||
|
{payload.map((entry: any, index: number) => {
|
||||||
|
const isTemp = entry.name === 'temperature' || entry.dataKey === 'temperature';
|
||||||
|
return (
|
||||||
|
<p key={index} style={{ margin: 0, color: 'var(--text-main)' }}>
|
||||||
|
{isTemp ? 'Teplota' : 'Srážky'}: <span style={{ fontWeight: 'bold' }}>{entry.value !== undefined && entry.value !== null ? entry.value.toFixed(1) : 'N/A'} {isTemp ? '°C' : 'mm'}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
||||||
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
|
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
|
||||||
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.level}: <span style={{ fontWeight: 'bold' }}>{payload[0].value.toFixed(2)} m n. m.</span></p>
|
{payload.map((entry: any, index: number) => {
|
||||||
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.outflow}: <span style={{ fontWeight: 'bold' }}>{payload[1].value.toFixed(1)} m³/s</span></p>
|
let labelStr = '';
|
||||||
|
let unit = '';
|
||||||
|
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; }
|
||||||
|
else if (entry.dataKey === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; }
|
||||||
|
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; }
|
||||||
|
|
||||||
|
if (!labelStr || (entry.dataKey === 'inflow' && entry.value === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p key={index} style={{ margin: 0, color: 'var(--text-main)' }}>
|
||||||
|
{labelStr}: <span style={{ fontWeight: 'bold' }}>{entry.value.toFixed(entry.dataKey === 'level' ? 2 : 1)} {unit}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -37,6 +67,7 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [lakeInfo, setLakeInfo] = useState<any>(null);
|
const [lakeInfo, setLakeInfo] = useState<any>(null);
|
||||||
const [isSmoothed, setIsSmoothed] = useState(true);
|
const [isSmoothed, setIsSmoothed] = useState(true);
|
||||||
|
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d' | '1y' | 'all'>('7d');
|
||||||
const dict = t[language].chart;
|
const dict = t[language].chart;
|
||||||
const topbarDict = t[language].topbar;
|
const topbarDict = t[language].topbar;
|
||||||
|
|
||||||
@@ -67,7 +98,9 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
|||||||
outflow: outflow,
|
outflow: outflow,
|
||||||
inflow: item.inflow || 0,
|
inflow: item.inflow || 0,
|
||||||
volume: item.volume || 0,
|
volume: item.volume || 0,
|
||||||
fullness: 0
|
fullness: 0,
|
||||||
|
temperature: item.temperature,
|
||||||
|
precipitation: item.precipitation
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setData(formattedData);
|
setData(formattedData);
|
||||||
@@ -93,12 +126,37 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
|||||||
// Find the last record that actually has flow data (often the very last record is incomplete on PVL)
|
// Find the last record that actually has flow data (often the very last record is incomplete on PVL)
|
||||||
const lastValidFlowData = [...data].reverse().find(d => d.outflow > 0) || latestData;
|
const lastValidFlowData = [...data].reverse().find(d => d.outflow > 0) || latestData;
|
||||||
|
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const getCutoff = () => {
|
||||||
|
switch (timeRange) {
|
||||||
|
case '24h': return now - 24 * 60 * 60 * 1000;
|
||||||
|
case '7d': return now - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
case '30d': return now - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
case '1y': return now - 365 * 24 * 60 * 60 * 1000;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cutoff = getCutoff();
|
||||||
|
const filteredData = data.filter(d => new Date(d.timestamp).getTime() >= cutoff);
|
||||||
|
|
||||||
|
// Downsample data for large time ranges to prevent stuttering
|
||||||
|
let chartData = filteredData;
|
||||||
|
if (timeRange === '30d' && filteredData.length > 200) {
|
||||||
|
chartData = filteredData.filter((_, i) => i % 4 === 0 || i === filteredData.length - 1);
|
||||||
|
} else if ((timeRange === '1y' || timeRange === 'all') && filteredData.length > 200) {
|
||||||
|
chartData = filteredData.filter((_, i) => i % 24 === 0 || i === filteredData.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = chartData.length < 150;
|
||||||
|
|
||||||
const kpiData = {
|
const kpiData = {
|
||||||
level: latestData.level,
|
level: latestData.level,
|
||||||
inflow: lastValidFlowData.inflow,
|
inflow: lastValidFlowData.inflow,
|
||||||
outflow: lastValidFlowData.outflow,
|
outflow: lastValidFlowData.outflow,
|
||||||
volume: lakeInfo?.volume || 0,
|
volume: lakeInfo?.volume || 0,
|
||||||
fullness: lakeInfo?.capacity || 0
|
fullness: lakeInfo?.capacity || 0,
|
||||||
|
storageDiff: lakeInfo?.storageDiff
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -115,17 +173,17 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{dict.title} {lakeInfo ? `${lakeInfo.name} ${lakeInfo.river ? `- ${lakeInfo.river}` : ''}` : 'Lipno 1 - Vltava'}</h3>
|
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{dict.title} {lakeInfo ? `${lakeInfo.name} ${lakeInfo.river ? `- ${lakeInfo.river}` : ''}` : 'Lipno 1 - Vltava'}</h3>
|
||||||
<div className="top-time-controls" style={{ margin: 0 }}>
|
<div className="top-time-controls" style={{ margin: 0 }}>
|
||||||
<button className="active">24h</button>
|
<button className={timeRange === '24h' ? 'active' : ''} onClick={() => setTimeRange('24h')}>24h</button>
|
||||||
<button>7d</button>
|
<button className={timeRange === '7d' ? 'active' : ''} onClick={() => setTimeRange('7d')}>7d</button>
|
||||||
<button>30d</button>
|
<button className={timeRange === '30d' ? 'active' : ''} onClick={() => setTimeRange('30d')}>30d</button>
|
||||||
<button>{dict.year}</button>
|
<button className={timeRange === '1y' ? 'active' : ''} onClick={() => setTimeRange('1y')}>{dict.year}</button>
|
||||||
<button>{dict.all}</button>
|
<button className={timeRange === 'all' ? 'active' : ''} onClick={() => setTimeRange('all')}>{dict.all}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '1rem' }}>
|
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '1rem' }}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={data} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
|
<ComposedChart data={chartData} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorLevel" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorLevel" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
|
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
|
||||||
@@ -140,8 +198,9 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
|||||||
<Tooltip content={<CustomTooltip language={language} />} />
|
<Tooltip content={<CustomTooltip language={language} />} />
|
||||||
|
|
||||||
{/* Data Series */}
|
{/* Data Series */}
|
||||||
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" />
|
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} />
|
||||||
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-orange)" strokeWidth={2} dot={false} />
|
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-orange)" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
||||||
|
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="#8b5cf6" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,6 +209,7 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
|||||||
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
|
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div> {dict.level}</span>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div> {dict.level}</span>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-orange)' }}></div> {dict.outflow}</span>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-orange)' }}></div> {dict.outflow}</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: '#8b5cf6' }}></div> {dict.inflow}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Smoothed Toggle Control */}
|
{/* Smoothed Toggle Control */}
|
||||||
@@ -166,6 +226,34 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* WEATHER CHART SECTION */}
|
||||||
|
<div className="chart-card" style={{ marginTop: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>Počasí (Teplota a Srážky)</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minHeight: '250px', width: '100%', marginTop: '1rem' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={chartData} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
||||||
|
<YAxis yAxisId="temp" domain={['auto', 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(1)} />
|
||||||
|
<YAxis yAxisId="precip" orientation="right" domain={[0, 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
||||||
|
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
|
<Tooltip content={<CustomTooltip language={language} isWeather={true} />} />
|
||||||
|
|
||||||
|
<Bar yAxisId="precip" dataKey="precipitation" fill="var(--color-cyan)" fillOpacity={0.6} isAnimationActive={animate} />
|
||||||
|
<Line yAxisId="temp" type={curveType} dataKey="temperature" stroke="var(--color-red)" strokeWidth={2} dot={true} isAnimationActive={animate} />
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> Teplota vzduchu [°C]</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '12px', backgroundColor: 'var(--color-cyan)', opacity: 0.6 }}></div> Srážky (24h) [mm]</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-footer" style={{ marginTop: '0' }}>
|
<div className="dashboard-footer" style={{ marginTop: '0' }}>
|
||||||
<span>{dict.dataSources} <a href="https://www.chmi.cz/" target="_blank" rel="noreferrer">ČHMÚ</a>, <a href="https://www.pvl.cz/" target="_blank" rel="noreferrer">pvl.cz</a></span>
|
<span>{dict.dataSources} <a href="https://www.chmi.cz/" target="_blank" rel="noreferrer">ČHMÚ</a>, <a href="https://www.pvl.cz/" target="_blank" rel="noreferrer">pvl.cz</a></span>
|
||||||
<span>{dict.createdIn}</span>
|
<span>{dict.createdIn}</span>
|
||||||
|
|||||||
@@ -260,3 +260,18 @@ a {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fix Recharts focus outlines when clicking the chart */
|
||||||
|
.recharts-wrapper,
|
||||||
|
.recharts-wrapper *,
|
||||||
|
.recharts-surface,
|
||||||
|
.recharts-surface *,
|
||||||
|
.recharts-responsive-container,
|
||||||
|
.recharts-responsive-container * {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
.recharts-wrapper:focus,
|
||||||
|
.recharts-surface:focus,
|
||||||
|
.recharts-responsive-container:focus {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
+2
-2
@@ -17,7 +17,7 @@ export const t = {
|
|||||||
flow: 'FLOW RATE',
|
flow: 'FLOW RATE',
|
||||||
inflow: 'Inflow',
|
inflow: 'Inflow',
|
||||||
outflow: 'Outflow',
|
outflow: 'Outflow',
|
||||||
fullness: 'CAPACITY',
|
fullness: 'STORAGE LEVEL',
|
||||||
volume: 'Volume'
|
volume: 'Volume'
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
@@ -65,7 +65,7 @@ export const t = {
|
|||||||
flow: 'PRŮTOK',
|
flow: 'PRŮTOK',
|
||||||
inflow: 'Přítok',
|
inflow: 'Přítok',
|
||||||
outflow: 'Odtok',
|
outflow: 'Odtok',
|
||||||
fullness: 'NAPLNĚNOST',
|
fullness: 'ZÁSOBNÍ PROSTOR',
|
||||||
volume: 'Objem'
|
volume: 'Objem'
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ async function test() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const $ = cheerio.load(res.data);
|
const $ = cheerio.load(res.data);
|
||||||
|
console.log('Inputs:');
|
||||||
|
$('input').each((i, el) => {
|
||||||
|
console.log(`Type: ${$(el).attr('type')}, Name: ${$(el).attr('name')}, Value: ${$(el).attr('value')}, ID: ${$(el).attr('id')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\nButtons/Links with postback:');
|
||||||
|
$('a[href*="__doPostBack"]').each((i, el) => {
|
||||||
|
console.log(`Text: ${$(el).text()}, Href: ${$(el).attr('href')}`);
|
||||||
|
});
|
||||||
const tables = $('table');
|
const tables = $('table');
|
||||||
tables.each((i, tbl) => {
|
tables.each((i, tbl) => {
|
||||||
console.log(`TABLE ${i}:`);
|
console.log(`TABLE ${i}:`);
|
||||||
|
|||||||
Reference in New Issue
Block a user