Compare commits

..

17 Commits

Author SHA1 Message Date
David Fencl a67a2247c3 feat: import new reservoir data, add lake management scripts, and update overview UI components
continuous-integration/drone/push Build encountered an error
2026-06-06 20:14:36 +02:00
David Fencl cf05e844d8 feat: update water level metrics and optimize sidebar UI layout 2026-06-06 18:38:18 +02:00
David Fencl 6395df1992 feat: implement multilingual SEO support and enhance map UI with data synchronization updates 2026-06-06 17:24:30 +02:00
David Fencl 66021e001e refactor: remove unused lake JSON files and truncate excessive historical data in index files 2026-06-06 12:41:42 +02:00
David Fencl db1aadcc8d feat: add automatic data polling, conditional search visibility, and extended scraper functionality for monthly lake records 2026-06-06 12:34:20 +02:00
David Fencl dbb22e7972 refactor: centralize lake metrics calculations into a utility module with comprehensive unit tests 2026-06-06 11:45:56 +02:00
David Fencl 6d77c20c84 refactor: remove coverage report and add weather widget and navigation utility files 2026-06-06 11:41:13 +02:00
David Fencl a3b3d40769 feat: add circular progress component and update historical lake data indices 2026-06-06 10:38:43 +02:00
David Fencl 27551f9183 feat: implement Favorites feature with persistent storage and sidebar integration and update lake data. 2026-06-05 23:57:17 +02:00
David Fencl b660f0f6c3 feat: add contact link to settings, update lake labels and sidebar icons, and enhance KPI flow visualization 2026-06-05 23:40:56 +02:00
David Fencl 57e9bf12ca feat: implement Open-Meteo weather integration with backfill scripts and updated lake data models.
continuous-integration/drone/push Build encountered an error
2026-06-05 23:34:13 +02:00
David Fencl 8193ce818a feat: implement tests and coverage reports for KpiCards and scrapeLakes functionality 2026-06-05 23:08:44 +02:00
David Fencl 0030dca448 feat: add color-coded indicators to KpiCards and LakeDetail legends 2026-06-05 22:59:19 +02:00
David Fencl 8d1fb5b28e feat: implement automated data scraping and history generation pipeline for PVL reservoir levels 2026-06-05 22:58:21 +02:00
David Fencl 5411bd16ff feat: update lake index, sync scraping scripts, and prune unused data files 2026-06-05 22:24:47 +02:00
David Fencl 61a8af109c feat: implement map view for lake visualization and automate data scraping pipeline 2026-06-05 22:03:38 +02:00
David Fencl a5bd4985d1 feat: Initial commit - Hladinator (Water Reservoir Dashboard)
continuous-integration/drone/push Build encountered an error
- Setup React project with Vite and TypeScript
- Built dynamic UI supporting Dark/Light mode and CS/EN localization
- Added Lakes Overview grid with mock data for 40+ reservoirs
- Created interactive Recharts charts for water levels and flow rates
- Designed fully responsive premium mobile layout with custom SVG KPIs
- Developed TypeScript scraper scripts to fetch reservoir data
2026-06-05 21:36:38 +02:00
102 changed files with 279580 additions and 1012 deletions
+31 -11
View File
@@ -3,8 +3,8 @@ type: docker
name: default
trigger:
branch: [main]
event: [push]
branch: [ main ]
event: [ push ]
# Kill the default clone entirely
clone:
@@ -14,7 +14,7 @@ steps:
- name: manual clone
image: alpine/git
environment:
GIT_ASKPASS: "echo" # prevent interactive prompts
GIT_ASKPASS: 'echo' # prevent interactive prompts
commands:
- git clone --depth=50 "https://fencl:5ece5d37cd3a9988a78983b10cede284e25717f8@git.internet-master.cz/fencl/davisfe.cz.git" .
# - git fetch origin +refs/heads/deployment:refs/remotes/origin/deployment
@@ -36,12 +36,32 @@ steps:
- name: call-portainer-webhook
image: curlimages/curl
environment:
PORTAINER_USER:
from_secret: PORTAINER_USER
PORTAINER_PASSWORD:
from_secret: PORTAINER_PASSWORD
PORTAINER_WEBHOOK:
from_secret: PORTAINER_WEBHOOK
commands:
- curl -fsS -u "$PORTAINER_USER:$PORTAINER_PASSWORD" -X POST "$PORTAINER_WEBHOOK"
- 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
+57 -58
View File
@@ -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
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
## 🔄 Jak aktualizovat 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 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**.
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
Pro ruční stažení těch nejnovějších dat z webu povodí spusť v terminálu:
```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:
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([
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...
},
},
])
## ⏰ Automatické stahování dat (Cron / Spouštěč)
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`.
Zde jsou nejběžnější možnosti, jak si to můžeš nastavit ty sám:
### Možnost A: Přes Crontab na Macu / Linuxu (Lokálně)
Pokud ti běží počítač (nebo domácí server/Raspberry Pi) nepřetržitě, můžeš využít systémový `cron`.
1. Otevři terminál a napiš: `crontab -e`
2. Na konec souboru vlož následující řádek (uprav cestu ke svému projektu a Node.js):
```bash
# Spustit scraping každých 15 minut
*/15 * * * * cd /Users/davis/WebstormProjects/davisfe.cz && /usr/local/bin/npm run data:update >> scraper.log 2>&1
```
3. Ulož a zavři editor. Od této chvíle se systém postará o automatický sběr dat!
### 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).
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import fs from 'fs';
import https from 'https';
async function compare() {
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
const agent = new https.Agent({ rejectUnauthorized: false });
const response = await axios.get(URL, { httpsAgent: agent });
const $ = cheerio.load(response.data);
let tblFound = null;
$('table').each((i, tbl) => {
if ($(tbl).text().includes('Datum') && $(tbl).text().includes('Odtok')) {
tblFound = $(tbl);
}
});
const pvlRows = [];
if (tblFound) {
tblFound.find('tr').each((i, row) => {
if (i === 0) return;
const cols = $(row).find('td');
if (cols.length >= 3) {
const rawDate = $(cols[0]).text().trim();
const levelStr = $(cols[1]).text().trim().replace(',', '.');
let flowStr = $(cols[2]).text().trim().replace(',', '.');
if (flowStr === '' && cols.length >= 4) {
flowStr = $(cols[3]).text().trim().replace(',', '.');
}
pvlRows.push({
date: rawDate,
level: parseFloat(levelStr),
flow: parseFloat(flowStr)
});
}
});
}
const localData = JSON.parse(fs.readFileSync('public/data/VLL1.json', 'utf-8'));
// Sort local data descending (newest first) to match PVL which is newest first
const sortedLocal = localData.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
console.log('--- POROVNÁNÍ DAT: LIPNO 1 ---');
console.log(String('PVL.CZ').padEnd(40) + ' | ' + 'NAŠE LOKÁLNÍ DATABÁZE');
console.log('-'.repeat(85));
for (let i = 0; i < Math.min(10, pvlRows.length); i++) {
const p = pvlRows[i];
const l = sortedLocal[i];
// Format our local UTC timestamp back to something readable
const d = new Date(l.timestamp);
const localDateStr = `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth()+1).toString().padStart(2, '0')}.${d.getFullYear()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
const pvlStr = `[${p.date}] H: ${p.level} m, O: ${p.flow} m3/s`.padEnd(40);
const locStr = `[${localDateStr}] H: ${l.level} m, O: ${l.flow} m3/s, P: ${l.inflow} m3/s`;
console.log(`${pvlStr} | ${locStr}`);
}
}
compare().catch(console.error);
+44
View File
@@ -0,0 +1,44 @@
# Analýza dostupných dat z Povodí Vltavy (PVL.cz)
Tento dokument sumarizuje všechna data, která jsme schopni strojově získat z webových stránek Povodí Vltavy pro jednotlivé vodní nádrže (např. z adresy `Mereni.aspx?oid=1&id=VLL1`).
Data jsou na zdrojovém backendu rozdělena do několika logických celků (tabulek), které můžeme libovolně vytěžovat.
## 1. Technické parametry nádrže (Základní údaje)
Tato data jsou statická a definují fyzické a inženýrské limity přehrady.
* **Tok (River):** Na jaké řece se nádrž nachází (např. Vltava).
* **Koruna hráze:** Absolutní nadmořská výška nejvyššího bodu hráze [m n.m.].
* **Kóta přelivu:** Výška přelivových hran [m n.m.].
* **Maximální retenční hladina:** Krizová úroveň nadržení při povodních [m n.m.].
* **Hladina zásobního prostoru:** Maximální běžná hladina, pro kterou je určen zásobní objem [m n.m.].
* **Hladina stálého nadržení:** Minimální hladina nutná pro zachování ekologických a technických funkcí [m n.m.] (lze využít pro přesný výpočet procentuální naplněnosti).
* **Výškový systém:** Zpravidla "Balt p.v." (Baltský po vyrovnání).
## 2. Aktuální hodnoty (Real-time data)
Tato tabulka obsahuje nejčerstvější data z měřicích stanic s přesným časovým razítkem. Ne všechny hodnoty musí být vždy u všech přehrad dostupné.
* **Časové razítko:** Přesný čas posledního měření (např. *05.06.2026 22:10*).
* **Hladina vody v nádrži:** Aktuální výška [m n.m.].
* **Objem:** Skutečný aktuální zadržovaný objem vody [mil. m³].
* **Přítok (Inflow):** Odhadovaný/měřený přítok do přehrady [m³/s].
* **Odtok (Outflow):** Skutečný odtok z přehrady [m³/s].
* **Srážky (24h):** Úhrn srážek za posledních 24 hodin [mm] *(k dispozici pouze u vybraných stanic)*.
* **Teplota vzduchu:** Aktuální teplota vzduchu [°C] *(k dispozici pouze u vybraných stanic)*.
## 3. Historická časová řada (Tabulka měření)
Tyto tabulky obsahují historický vývoj po jednotlivých hodinách za posledních několik dnů, což využíváme pro vykreslování grafů.
* **Datum a čas:** Hodinové intervaly (např. *05.06.2026 22:00*).
* **Hladina:** Měřená výška hladiny [m n.m.].
* **Odtok:** Odtok přes hráz [m³/s].
* *(Poznámka: Přítok a Objem se do historické tabulky u většiny přehrad ze strany PVL neukládají, zveřejňují pouze hladinu a odtok).*
* **QN:** Indikátor kvality dat (ověřená/neověřená).
---
### Možnosti budoucího rozšíření HLADINATORu
Na základě výše zmíněných dostupných bodů můžeme do aplikace snadno přidat:
1. **Srážky a teplotu** - Pokud je pro danou přehradu údaj dostupný, můžeme přidat widget pro zobrazení úhrnu srážek za 24h a aktuální teploty vzduchu.
2. **Přesnější výpočet %** - Pomocí limitů "Maximální retenční hladina" a "Hladina stálého nadržení" můžeme přesně indikovat blížící se povodňový stav.
3. **Výstražný systém (Alerts)** - Vizuální varování (např. změna barvy panelu na červenou), pokud se aktuální hladina nebezpečně blíží kótě přelivu.
+6 -1
View File
@@ -4,7 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
<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" />
</head>
<body>
<div id="root"></div>
+2921 -22
View File
File diff suppressed because it is too large Load Diff
+26 -3
View File
@@ -7,26 +7,49 @@
"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",
"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
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
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
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
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
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
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
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://hladinator.cz/sitemap.xml
+1449
View File
File diff suppressed because one or more lines are too long
+629
View File
File diff suppressed because one or more lines are too long
+637
View File
File diff suppressed because one or more lines are too long
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
import { lakesConfig } from './scripts/lakesConfig';
async function run() {
const agent = new https.Agent({ rejectUnauthorized: false });
for (const lake of lakesConfig) {
const [internalId, oid] = lake.id.split('|');
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
try {
const res = await axios.get(URL, { httpsAgent: agent, headers: { 'User-Agent': 'Mozilla/5.0' } });
const $ = cheerio.load(res.data);
let temp = null;
let precip = null;
$('table').each((i, tbl) => {
const text = $(tbl).text();
if (text.includes('Aktuální hodnoty')) {
const tempMatch = text.match(/Teplota vzduchu \[°C\]\s*([\d,]+)/);
if (tempMatch) temp = tempMatch[1];
const precipMatch = text.match(/Srážky \(24h\) \[mm\]\s*([\d,]+)/);
if (precipMatch) precip = precipMatch[1];
}
});
console.log(`[${internalId}] Temp: ${temp}, Precip: ${precip}`);
} catch (e) {
console.error(e.message);
}
}
}
run();
+28
View File
@@ -0,0 +1,28 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
import { lakesConfig } from './scripts/lakesConfig';
async function run() {
const agent = new https.Agent({ rejectUnauthorized: false });
for (const lake of lakesConfig) {
const [internalId, oid] = lake.id.split('|');
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
try {
const res = await axios.get(URL, { httpsAgent: agent, headers: { 'User-Agent': 'Mozilla/5.0' } });
const $ = cheerio.load(res.data);
let storage = 0;
$('table').each((i, tbl) => {
const text = $(tbl).text();
const match = text.match(/Hladina z[aá]sobn[ií]ho prostoru:\s*([\d,]+)/i);
if (match) {
storage = parseFloat(match[1].replace(',', '.'));
}
});
console.log(`{ id: "${lake.id}", storageLevel: ${storage} },`);
} catch (e) {
console.error(e.message);
}
}
}
run();
+19
View File
@@ -0,0 +1,19 @@
import axios from 'axios';
import { lakesConfig } from './scripts/lakesConfig';
async function testOpenMeteo() {
const lipno = lakesConfig.find(l => l.id.startsWith('VLL1'));
if (!lipno) return;
const lat = lipno.coords[0];
const lon = lipno.coords[1];
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,precipitation`;
console.log('Fetching from:', url);
try {
const response = await axios.get(url);
console.log(response.data.current);
} catch (e) {
console.error(e.message);
}
}
testOpenMeteo();
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios';
import { lakesConfig } from './scripts/lakesConfig';
async function testHistory() {
const lipno = lakesConfig.find(l => l.id.startsWith('VLL1'));
if (!lipno) return;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lipno.coords[0]}&longitude=${lipno.coords[1]}&past_days=7&hourly=temperature_2m,precipitation&timezone=GMT`;
console.log('Fetching from:', url);
try {
const res = await axios.get(url);
const hourly = res.data.hourly;
console.log(`Received ${hourly.time.length} hourly records.`);
console.log('Sample record at index 100:');
console.log('Time:', hourly.time[100]);
console.log('Temp:', hourly.temperature_2m[100]);
} catch (e) {
console.error(e.message);
}
}
testHistory();
+46
View File
@@ -0,0 +1,46 @@
import axios from 'axios';
import fs from 'fs';
import https from 'https';
import * as cheerio from 'cheerio';
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
async function testPostback() {
const agent = new https.Agent({ rejectUnauthorized: false });
const res = await axios.get(URL, { httpsAgent: agent, timeout: 10000 });
const $ = cheerio.load(res.data);
const viewstate = $('#__VIEWSTATE').val();
const viewstategenerator = $('#__VIEWSTATEGENERATOR').val();
const eventvalidation = $('#__EVENTVALIDATION').val();
// Try to POST for monthly data
const postData = new URLSearchParams();
postData.append('__EVENTTARGET', 'ctl00$ObsahCPH$PrechodNaBilancniData');
postData.append('__EVENTARGUMENT', '');
postData.append('__VIEWSTATE', viewstate as string);
postData.append('__VIEWSTATEGENERATOR', viewstategenerator as string);
postData.append('__EVENTVALIDATION', eventvalidation as string);
const postRes = await axios.post(URL, postData.toString(), {
httpsAgent: agent,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
fs.writeFileSync('pvl_raw_month.html', postRes.data);
console.log('Saved monthly data to pvl_raw_month.html');
const $post = cheerio.load(postRes.data);
const rows = $post('table.tabulka-seznam tr:not(:first-child)');
console.log(`Found ${rows.length} rows in the table.`);
if (rows.length > 0) {
const firstRow = rows.first().find('td').first().text().trim();
const lastRow = rows.last().find('td').first().text().trim();
console.log(`Date range: ${firstRow} to ${lastRow}`);
}
}
testPostback().catch(console.error);
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { calculateLakeMetrics, LakeCalculationConfig } from '../utils/calculations';
describe('calculateLakeMetrics', () => {
const config: LakeCalculationConfig = {
minLevel: 100,
maxLevel: 110,
storageLevel: 108,
maxVolume: 50,
};
it('should calculate capacity based on reported volume when available', () => {
// 25 / 50 = 50%
const result = calculateLakeMetrics(105, 25, config);
expect(result.capacity).toBe(50);
expect(result.volume).toBe(25);
});
it('should cap capacity at 100% when volume exceeds maxVolume', () => {
const result = calculateLakeMetrics(111, 55, config);
expect(result.capacity).toBe(100);
expect(result.volume).toBe(55);
});
it('should floor capacity at 0% when volume is negative', () => {
const result = calculateLakeMetrics(99, -5, config);
expect(result.capacity).toBe(0);
expect(result.volume).toBe(-5);
});
it('should estimate capacity and volume from level when reported volume is 0', () => {
// Level 105 is exactly halfway between 100 and 110 -> 50%
// 50% of 50 maxVolume = 25
const result = calculateLakeMetrics(105, 0, config);
expect(result.capacity).toBe(50);
expect(result.volume).toBe(25);
});
it('should cap estimated capacity at 100% when level exceeds maxLevel', () => {
const result = calculateLakeMetrics(115, 0, config);
expect(result.capacity).toBe(100);
expect(result.volume).toBe(50); // 100% of 50
});
it('should floor estimated capacity at 0% when level is below minLevel', () => {
const result = calculateLakeMetrics(90, 0, config);
expect(result.capacity).toBe(0);
expect(result.volume).toBe(0); // 0% of 50
});
it('should correctly calculate storageDiff', () => {
const result = calculateLakeMetrics(106, 25, config);
// 106 - 108 = -2.00
expect(result.storageDiff).toBe(-2);
});
it('should calculate positive storageDiff when above storageLevel', () => {
const result = calculateLakeMetrics(109, 25, config);
// 109 - 108 = 1.00
expect(result.storageDiff).toBe(1);
});
it('should handle missing config gracefully', () => {
const emptyConfig: LakeCalculationConfig = {};
const result = calculateLakeMetrics(105, 0, emptyConfig);
expect(result.capacity).toBe(0);
expect(result.volume).toBe(0);
expect(result.storageDiff).toBe(0);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { parseDateString } from '../scrapeLakes';
describe('scrapeLakes - parseDateString', () => {
it('should parse valid date strings correctly', () => {
// Note: JS Date parsing uses local timezone, so the output ISO string depends on where the test runs.
// To make it deterministic, we just check if it returns a string and is not null.
const result = parseDateString('05.06.2026 22:30');
expect(result).not.toBeNull();
// Assuming standard parsing, it should contain 2026
expect(result).toContain('2026-06-05');
});
it('should return null for invalid formats', () => {
expect(parseDateString('')).toBeNull();
expect(parseDateString('invalid date string')).toBeNull();
expect(parseDateString('05.06.2026')).toBeNull(); // Missing time
expect(parseDateString('22:30')).toBeNull(); // Missing date
});
it('should return null for malformed parts', () => {
expect(parseDateString('99.99.9999 99:99')).toBeNull(); // JS Date might parse this as valid overflow, but let's check
expect(parseDateString('abc def ghi')).toBeNull();
});
});
+99
View File
@@ -0,0 +1,99 @@
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;
}
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} }${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();
+76
View File
@@ -0,0 +1,76 @@
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];
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&past_days=7&hourly=temperature_2m,precipitation&timezone=GMT`;
const res = await axios.get(url, { timeout: 10000 });
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:00:00.000Z"
// Open-Meteo time is like "2026-06-02T04:00"
const hourKey = record.timestamp.substring(0, 16); // Extract up to minutes
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();
+79
View File
@@ -0,0 +1,79 @@
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);
return {
id: lake.id,
name: lake.text.replace('VD ', '').split('-')[0].trim(),
river: lake.text.includes('-') ? lake.text.split('-')[1].trim() : '',
priority: lake.priority || false,
level: currentLevel.toFixed(2),
capacity: metrics.capacity,
storageDiff: metrics.storageDiff,
inflow: inflow.toFixed(1),
outflow: currentFlow.toFixed(1),
volume: metrics.volume,
maxVolume: lake.maxVolume || 0,
lat: lake.coords[0],
lng: lake.coords[1],
sparkline
};
});
const outputPath = path.resolve(process.cwd(), 'public/data/lakes_index.json');
fs.writeFileSync(outputPath, JSON.stringify(lakes, null, 2));
console.log('Real lakes index generated:', lakes.length);
+79
View File
@@ -0,0 +1,79 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
const ALL_LAKES = [
{"href": "Mereni.aspx?id=BIBI&oid=1", "text": "VD Bílsko"},
{"href": "Mereni.aspx?id=RACU&oid=3", "text": "VD České Údolí"},
{"href": "Mereni.aspx?id=KLDP&oid=3", "text": "VD Dolejší Padrťský rybník"},
{"href": "Mereni.aspx?id=CPDR&oid=3", "text": "VD Dráteník"},
{"href": "Mereni.aspx?id=KLHP&oid=3", "text": "VD Hořejší Padrťský rybník"},
{"href": "Mereni.aspx?id=SCHU&oid=1", "text": "VD Humenice"},
{"href": "Mereni.aspx?id=BLHU&oid=1", "text": "VD Husinec"},
{"href": "Mereni.aspx?id=VLKA&oid=2", "text": "VD Kamýk"},
{"href": "Mereni.aspx?id=SPKA&oid=1", "text": "VD Karhof"},
{"href": "Mereni.aspx?id=KLKL&oid=3", "text": "VD Klabava"},
{"href": "Mereni.aspx?id=KCKC&oid=3", "text": "VD Klíčava"},
{"href": "Mereni.aspx?id=LILA&oid=3", "text": "VD Láz"},
{"href": "Mereni.aspx?id=MZLU&oid=3", "text": "VD Lučina"},
{"href": "Mereni.aspx?id=SPNE&oid=2", "text": "VD Němčice"},
{"href": "Mereni.aspx?id=UHNY&oid=3", "text": "VD Nýrsko"},
{"href": "Mereni.aspx?id=OPOB&oid=3", "text": "VD Obecnice"},
{"href": "Mereni.aspx?id=PPPI&oid=3", "text": "VD Pilská (u Příbramě)"},
{"href": "Mereni.aspx?id=SAPI&oid=2", "text": "VD Pilská u Žďáru"},
{"href": "Mereni.aspx?id=HESE&oid=2", "text": "VD Sedlice"},
{"href": "Mereni.aspx?id=CRSO&oid=1", "text": "VD Soběnov"},
{"href": "Mereni.aspx?id=SVSV&oid=2", "text": "VD Staviště"},
{"href": "Mereni.aspx?id=STST&oid=2", "text": "VD Strž"},
{"href": "Mereni.aspx?id=SMSM&oid=3", "text": "VD Suchomasty"},
{"href": "Mereni.aspx?id=ZESV&oid=2", "text": "VD Švihov (Želivka)"},
{"href": "Mereni.aspx?id=TRTR&oid=2", "text": "VD Trnávka"},
{"href": "Mereni.aspx?id=VLVE&oid=2", "text": "VD Vrané"},
{"href": "Mereni.aspx?id=HEVR&oid=2", "text": "VD Vřesník"},
{"href": "Mereni.aspx?id=CPZA&oid=3", "text": "VD Záskalská"},
{"href": "Mereni.aspx?id=SPZH&oid=1", "text": "VD Zhejral"},
{"href": "Mereni.aspx?id=STZL&oid=3", "text": "VD Žlutice"}
];
async function checkLakes() {
const agent = new https.Agent({ rejectUnauthorized: false });
const validLakes: any[] = [];
for (const lake of ALL_LAKES) {
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/${lake.href}`;
try {
const response = await axios.get(url, {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
let hasHistory = false;
let hasInflow = false;
$('table').each((i, tbl) => {
const text = $(tbl).text();
if (text.includes('Aktuální hodnoty') && text.includes('Přítok')) {
hasInflow = true;
}
if (text.includes('Datum') && text.includes('Odtok')) {
const rows = $(tbl).find('tr').length;
if (rows > 2) hasHistory = true;
}
});
if (hasHistory && hasInflow) {
validLakes.push(lake);
console.log(`[VALID] ${lake.text}`);
} else {
console.log(`[INVALID] ${lake.text} (Hist:${hasHistory}, In:${hasInflow})`);
}
} catch (err: any) {
console.error(`[ERROR] ${lake.text}: ${err.message}`);
}
}
console.log('\\n--- SUMMARY OF VALID LAKES ---');
console.log(JSON.stringify(validLakes, null, 2));
}
checkLakes();
+29
View File
@@ -0,0 +1,29 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
async function checkMap() {
const agent = new https.Agent({ rejectUnauthorized: false });
try {
const response = await axios.get('https://www.pvl.cz/portal/nadrze/cz/pc/Prehled.aspx', {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const html = response.data;
// Look for variables or inline JSON with coordinates
const scriptMatches = html.match(/<script\\b[^>]*>([\\s\\S]*?)<\\/script>/gi);
if (scriptMatches) {
scriptMatches.forEach((m: string, i: number) => {
if (m.includes('lat') || m.includes('Lng') || m.includes('Points') || m.includes('Markers')) {
console.log("Found something in script " + i);
console.log(m.substring(0, 500)); // preview
}
});
}
} catch (e: any) {
console.error(e.message);
}
}
checkMap();
+33
View File
@@ -0,0 +1,33 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
async function fetchLakes() {
const agent = new https.Agent({ rejectUnauthorized: false });
try {
const response = await axios.get('https://www.pvl.cz/portal/nadrze/cz/pc/Prehled.aspx', {
httpsAgent: agent,
headers: {
'User-Agent': 'Mozilla/5.0'
}
});
const $ = cheerio.load(response.data);
const lakes: any[] = [];
// Links to lakes usually look like Mereni.aspx?oid=xxx&id=yyy
$('a[href^="Mereni.aspx"]').each((i, el) => {
const href = $(el).attr('href');
const text = $(el).text().trim();
if (href && text) {
lakes.push({ href, text });
}
});
console.log(JSON.stringify(lakes, null, 2));
} catch (err: any) {
console.error('Error:', err.message);
}
}
fetchLakes();
+103
View File
@@ -0,0 +1,103 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
import fs from 'fs';
import path from 'path';
import { lakesConfig } from './lakesConfig';
async function fixLevels() {
const agent = new https.Agent({ rejectUnauthorized: false });
const updatedConfig = [...lakesConfig];
for (let i = 0; i < updatedConfig.length; i++) {
const lake = updatedConfig[i];
// id is like SPKA|1 -> internalId is SPKA, oid is 1
const parts = lake.id.split('|');
if (parts.length !== 2) continue;
const internalId = parts[0];
const oid = parts[1];
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?id=${internalId}&oid=${oid}`;
try {
const response = await axios.get(url, {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
let maxRet: number | null = null;
let minStale: number | null = null;
let maxVol: number | null = null;
$('table').each((_, tbl) => {
const text = $(tbl).text();
// Parse levels
if (text.includes('Maximální retenční hladina:')) {
$(tbl).find('tr').each((_, row) => {
const rowText = $(row).text().replace(/\\s+/g, ' ');
if (rowText.includes('Maximální retenční hladina:')) {
const val = parseFloat(rowText.replace('Maximální retenční hladina:', '').replace('[m n.m.]', '').replace(',', '.').trim());
if (!isNaN(val)) maxRet = val;
}
if (rowText.includes('Hladina stálého nadržení:')) {
const val = parseFloat(rowText.replace('Hladina stálého nadržení:', '').replace('[m n.m.]', '').replace(',', '.').trim());
if (!isNaN(val)) minStale = val;
}
});
}
// Parse volume (this is current volume, wait, does PVL show max volume? Usually no, but current volume might be bigger than our guessed maxVolume)
if (text.includes('Aktuální hodnoty') && text.includes('Objem')) {
$(tbl).find('tr').each((_, row) => {
const rowText = $(row).text().replace(/\\s+/g, ' ');
if (rowText.includes('Objem [mil. m3]')) {
const valStr = $(row).find('td').eq(1).text().trim().replace(',', '.');
const val = parseFloat(valStr);
if (!isNaN(val)) maxVol = val; // We will just use the current volume as a baseline if it's bigger than our maxVolume
}
});
}
});
if (maxRet) updatedConfig[i].maxLevel = maxRet;
if (minStale) updatedConfig[i].minLevel = minStale;
// For volume, if the current volume is larger than the configured maxVolume, increase maxVolume
if (maxVol && updatedConfig[i].maxVolume && maxVol > updatedConfig[i].maxVolume!) {
updatedConfig[i].maxVolume = Math.ceil(maxVol * 1.2 * 10) / 10; // add 20% buffer
} else if (maxVol && !updatedConfig[i].maxVolume) {
updatedConfig[i].maxVolume = Math.ceil(maxVol * 1.5 * 10) / 10;
}
console.log(`Updated ${lake.text}: min=${minStale}, max=${maxRet}, vol=${maxVol} -> newMaxVol=${updatedConfig[i].maxVolume}`);
} catch (err: any) {
console.error(`Failed for ${lake.text}: ${err.message}`);
}
}
// Generate new file content
let newContent = `export interface LakeConfig {
id: string;
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
}
export const lakesConfig: LakeConfig[] = [
`;
updatedConfig.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} }${idx === updatedConfig.length - 1 ? '' : ','}\\n`;
});
newContent += `];\\n`;
fs.writeFileSync(path.resolve('./scripts/lakesConfig.ts'), newContent);
console.log("lakesConfig.ts updated!");
}
fixLevels();
+101
View File
@@ -0,0 +1,101 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
import fs from 'fs';
import path from 'path';
import { lakesConfig } from './lakesConfig';
async function fixStorageLevels() {
const agent = new https.Agent({ rejectUnauthorized: false });
const updatedConfig = [...lakesConfig];
for (let i = 0; i < updatedConfig.length; i++) {
const lake = updatedConfig[i];
const parts = lake.id.split('|');
if (parts.length !== 2) continue;
const internalId = parts[0];
const oid = parts[1];
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?id=${internalId}&oid=${oid}`;
try {
const response = await axios.get(url, {
httpsAgent: agent,
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
let storageLevelFound: number | null = null;
let maxVol: number | null = null;
$('table').each((_, tbl) => {
const text = $(tbl).text();
// Parse storage level
if (text.includes('Hladina zásobního prostoru:')) {
$(tbl).find('tr').each((_, row) => {
const rowText = $(row).text().replace(/\\s+/g, ' ');
if (rowText.includes('Hladina zásobního prostoru:')) {
const val = parseFloat(rowText.replace('Hladina zásobního prostoru:', '').replace('[m n.m.]', '').replace(',', '.').trim());
if (!isNaN(val)) storageLevelFound = val;
}
});
}
// Parse current volume
if (text.includes('Aktuální hodnoty') && text.includes('Objem')) {
$(tbl).find('tr').each((_, row) => {
const rowText = $(row).text().replace(/\\s+/g, ' ');
if (rowText.includes('Objem [mil. m3]')) {
const valStr = $(row).find('td').eq(1).text().trim().replace(',', '.');
const val = parseFloat(valStr);
if (!isNaN(val)) maxVol = val;
}
});
}
});
if (storageLevelFound !== null) {
updatedConfig[i].storageLevel = storageLevelFound;
} else {
// if PVL doesn't have it, remove our fake guess so we fallback to maxLevel
delete updatedConfig[i].storageLevel;
}
// Fix maxVolume if current volume exceeds it
if (maxVol && updatedConfig[i].maxVolume && maxVol > updatedConfig[i].maxVolume!) {
updatedConfig[i].maxVolume = Math.ceil(maxVol * 1.05 * 10) / 10;
} else if (maxVol && !updatedConfig[i].maxVolume) {
updatedConfig[i].maxVolume = Math.ceil(maxVol * 1.05 * 10) / 10;
}
console.log(`Updated ${lake.text}: storageLevel=${storageLevelFound}, currVol=${maxVol} -> newMaxVol=${updatedConfig[i].maxVolume}`);
} catch (err: any) {
console.error(`Failed for ${lake.text}: ${err.message}`);
}
}
let newContent = `export interface LakeConfig {
id: string;
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
}
export const lakesConfig: LakeConfig[] = [
`;
updatedConfig.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}, ${l.storageLevel ? 'storageLevel: ' + l.storageLevel + ', ' : ''}navigationForbidden: ${l.navigationForbidden} }${idx === updatedConfig.length - 1 ? '' : ','}\n`;
});
newContent += `];\n`;
fs.writeFileSync(path.resolve('./scripts/lakesConfig.ts'), newContent);
console.log("lakesConfig.ts updated with precise storage levels!");
}
fixStorageLevels();
+64
View File
@@ -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}`);
});
+53
View File
@@ -0,0 +1,53 @@
export interface LakeConfig {
id: string;
text: string;
priority?: boolean;
coords: [number, number];
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
}
export const lakesConfig: LakeConfig[] = [
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306, minLevel: 716.1, maxLevel: 725.6, storageLevel: 724.9, navigationForbidden: false },
{ id: "VLL2|1", text: "VD Lipno II - Vltava", priority: true, coords: [48.6250, 14.3180], maxVolume: 1.6, minLevel: 557.6, maxLevel: 563.35, storageLevel: 562.7, navigationForbidden: false },
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 364.6, maxLevel: 370.1, storageLevel: 370.1, navigationForbidden: false },
{ id: "VLKO|1", text: "VD Kořensko - Vltava", priority: true, coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 347.8, maxLevel: 353.6, storageLevel: 352.6, navigationForbidden: false },
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 329.6, maxLevel: 353.6, storageLevel: 349.9, navigationForbidden: false },
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 246.6, maxLevel: 270.6, storageLevel: 270.6, navigationForbidden: false },
{ id: "VLST|2", text: "VD Štěchovice - Vltava", priority: true, coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 215.8, maxLevel: 219.4, storageLevel: 219.4, navigationForbidden: false },
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 442.5, maxLevel: 471.48, storageLevel: 470.65, navigationForbidden: true },
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 339.6, maxLevel: 357.97, storageLevel: 354.1, navigationForbidden: false },
{ id: "ZESV|2", text: "VD Švihov (Želivka)", priority: true, coords: [49.7040, 15.1150], maxVolume: 266.6, minLevel: 343.1, maxLevel: 379.8, storageLevel: 377, navigationForbidden: true },
{ id: "VLKA|2", text: "VD Kamýk", coords: [49.6380, 14.2580], maxVolume: 12.8, minLevel: 282.1, maxLevel: 284.6, storageLevel: 284.6, navigationForbidden: false },
{ id: "VLVE|2", text: "VD Vrané", coords: [49.9390, 14.3910], maxVolume: 11.1, minLevel: 199.1, maxLevel: 200.1, storageLevel: 200.1, navigationForbidden: false },
{ id: "BLHU|1", text: "VD Husinec", coords: [49.0270, 13.9870], maxVolume: 5.7, minLevel: 515.33, maxLevel: 529.88, storageLevel: 522.33, navigationForbidden: true },
{ id: "UHNY|3", text: "VD Nýrsko", coords: [49.2610, 13.1230], maxVolume: 16, minLevel: 501.2, maxLevel: 524.25, storageLevel: 521.55, navigationForbidden: true },
{ id: "KCKC|3", text: "VD Klíčava", coords: [50.0630, 13.9310], maxVolume: 9.3, minLevel: 267.6, maxLevel: 296.91, storageLevel: 293.7, navigationForbidden: true },
{ id: "KLKL|3", text: "VD Klabava", coords: [49.7540, 13.5640], maxVolume: 1.5, minLevel: 344.4, maxLevel: 351.1, storageLevel: 345.7, navigationForbidden: false },
{ id: "RACU|3", text: "VD České Údolí", coords: [49.7150, 13.3640], maxVolume: 5.5, minLevel: 310.6, maxLevel: 315.2, storageLevel: 313.6, navigationForbidden: false },
{ id: "TRTR|2", text: "VD Trnávka", coords: [49.5260, 15.1950], maxVolume: 5.6, minLevel: 412, maxLevel: 414.5, storageLevel: 413.2, navigationForbidden: false },
{ id: "HESE|2", text: "VD Sedlice", coords: [49.5070, 15.2630], maxVolume: 1.9, minLevel: 443.9, maxLevel: 448.64, storageLevel: 447.4, navigationForbidden: false },
{ id: "MZLU|3", text: "VD Lučina", coords: [49.8050, 12.6390], maxVolume: 2.3, minLevel: 523, maxLevel: 534.68, storageLevel: 532.1, navigationForbidden: true },
{ id: "STZL|3", text: "VD Žlutice", coords: [50.0930, 13.1360], maxVolume: 14.5, minLevel: 493.6, maxLevel: 509.72, storageLevel: 507.05, navigationForbidden: true },
{ id: "PPPI|3", text: "VD Pilská (u Příbramě)", coords: [49.6910, 13.9570], maxVolume: 1.6, minLevel: 661.7, maxLevel: 672.7, storageLevel: 671.4, navigationForbidden: true },
{ id: "LILA|3", text: "VD Láz", coords: [49.6640, 13.8820], maxVolume: 0.8, minLevel: 630, maxLevel: 642.15, storageLevel: 641.35, navigationForbidden: true },
{ id: "OPOB|3", text: "VD Obecnice", coords: [49.7110, 13.9370], maxVolume: 0.6, minLevel: 555.65, maxLevel: 565.87, storageLevel: 564.55, navigationForbidden: true },
{ id: "STST|2", text: "VD Strž", coords: [49.7910, 14.0040], maxVolume: 1, minLevel: 586.6, maxLevel: 589.2, storageLevel: 588.6, navigationForbidden: false },
{ id: "HEVR|2", text: "VD Vřesník", coords: [49.5070, 15.2440], maxVolume: 0.5, minLevel: 406.85, maxLevel: 409.08, storageLevel: 407.6, navigationForbidden: false },
{ id: "CRSO|1", text: "VD Soběnov", coords: [48.7750, 14.5360], maxVolume: 1.4, minLevel: 579.81, maxLevel: 583.26, storageLevel: 582.21, navigationForbidden: false },
{ id: "SCHU|1", text: "VD Humenice", coords: [48.7840, 14.7350], maxVolume: 0.8, minLevel: 531, maxLevel: 544, storageLevel: 536, navigationForbidden: false },
{ id: "SVSV|2", text: "VD Staviště", coords: [49.5750, 15.9520], maxVolume: 1.2, minLevel: 574.6, maxLevel: 581.6, storageLevel: 580.6, navigationForbidden: true },
{ id: "SAPI|2", text: "VD Pilská u Žďáru", coords: [49.5930, 15.9320], maxVolume: 1.5, minLevel: 571.8, maxLevel: 577.3, storageLevel: 576.6, navigationForbidden: false },
{ id: "SMSM|3", text: "VD Suchomasty", coords: [49.8970, 14.0580], maxVolume: 0.7, minLevel: 249.8, maxLevel: 260.9, storageLevel: 260.1, navigationForbidden: false },
{ id: "CPZA|3", text: "VD Záskalská", coords: [49.8050, 13.8510], maxVolume: 0.5, minLevel: 440.54, maxLevel: 449.39, storageLevel: 448.79, navigationForbidden: false },
{ id: "BIBI|1", text: "VD Bílsko", coords: [49.1670, 14.0410], maxVolume: 0.3, minLevel: 463.03, maxLevel: 471.6, storageLevel: 464.03, navigationForbidden: false },
{ id: "SPKA|1", text: "VD Karhof", coords: [48.9740, 14.5450], maxVolume: 0.3, minLevel: 666.8, maxLevel: 669.1, storageLevel: 668.4, navigationForbidden: false },
{ id: "SPNE|2", text: "VD Němčice", coords: [49.7710, 15.1760], maxVolume: 0.4, minLevel: 384.5, maxLevel: 386.4, storageLevel: 385, navigationForbidden: false },
{ id: "SPZH|1", text: "VD Zhejral", coords: [49.2310, 15.3120], maxVolume: 0.2, minLevel: 675.2, maxLevel: 679.7, storageLevel: 678.6, navigationForbidden: true },
{ id: "KLDP|3", text: "VD Dolejší Padrťský rybník", coords: [49.6640, 13.7530], maxVolume: 0.5, minLevel: 632.69, maxLevel: 634.29, storageLevel: 632.89, navigationForbidden: true },
{ id: "KLHP|3", text: "VD Hořejší Padrťský rybník", coords: [49.6550, 13.7610], maxVolume: 0.7, minLevel: 635.76, maxLevel: 637.56, storageLevel: 636.36, navigationForbidden: true },
{ id: "CPDR|3", text: "VD Dráteník", coords: [49.8050, 13.8550], maxVolume: 0.1, minLevel: 413.75, maxLevel: 417.91, storageLevel: 416.68, navigationForbidden: false }
];
+193
View File
@@ -0,0 +1,193 @@
import * as fs from 'fs';
import * as path from 'path';
import * as cheerio from 'cheerio';
import axios from 'axios';
import https from 'https';
import { lakesConfig } from './lakesConfig';
interface DataRecord {
timestamp: string;
level: number;
flow: number;
inflow?: number;
volume?: number;
temperature?: number | null;
precipitation?: number | null;
}
// Parse date from DD.MM.YYYY HH:MM to ISO
export function parseDateString(dateStr: string): string | null {
try {
if (!dateStr || !dateStr.includes(' ')) return null;
const [datePart, timePart] = dateStr.trim().split(' ');
const [day, month, year] = datePart.split('.');
const [hours, minutes] = timePart.split(':');
if (!year || !hours) return null;
const y = parseInt(year);
const m = parseInt(month) - 1;
const dDay = parseInt(day);
const d = new Date(y, m, dDay, parseInt(hours), parseInt(minutes));
if (isNaN(d.getTime())) return null;
if (d.getFullYear() !== y || d.getMonth() !== m || d.getDate() !== dDay) return null;
return d.toISOString();
} catch (e) {
return null;
}
}
async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
try {
const agent = new https.Agent({ rejectUnauthorized: false });
const response = await axios.get(URL, {
httpsAgent: agent,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
});
const $ = cheerio.load(response.data);
let currentInflow = 0;
let currentVolume = 0;
let currentTemp: number | null = null;
let currentPrecip: number | null = null;
$('table').each((i, tbl) => {
const text = $(tbl).text();
if (text.includes('Aktuální hodnoty') && text.includes('Přítok')) {
$(tbl).find('tr').each((j, r) => {
const label = $(r).find('td').eq(0).text().trim();
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('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;
}
});
}
});
const records: DataRecord[] = [];
let dataTable = null;
$('table').each((i, tbl) => {
if ($(tbl).text().includes('Datum') && $(tbl).text().includes('Odtok')) {
dataTable = $(tbl);
}
});
if (dataTable) {
dataTable.find('tr').each((i, row) => {
if (i === 0) return; // skip header
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(',', '.');
}
const parsedDateStr = parseDateString(rawDate);
if (parsedDateStr) {
records.push({
timestamp: parsedDateStr,
level: parseFloat(levelStr) || 0,
flow: parseFloat(flowStr) || 0,
inflow: 0,
volume: 0
});
}
}
});
}
if (records.length > 0) {
records[0].inflow = currentInflow;
records[0].volume = currentVolume;
// Override weather from PVL completely using Open-Meteo
const config = lakesConfig.find(l => l.id.split('|')[0] === internalId);
if (config && config.coords) {
try {
const lat = config.coords[0];
const lon = config.coords[1];
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,precipitation`;
const weatherRes = await axios.get(url, { timeout: 5000 });
if (weatherRes.data && weatherRes.data.current) {
records[0].temperature = weatherRes.data.current.temperature_2m;
records[0].precipitation = weatherRes.data.current.precipitation;
}
// Small delay to prevent API rate limits
await new Promise(resolve => setTimeout(resolve, 200));
} catch (err: any) {
console.error(`Failed to fetch weather for ${internalId}:`, err.message);
}
}
}
let existingData: DataRecord[] = [];
if (fs.existsSync(DATA_FILE)) {
const fileContent = fs.readFileSync(DATA_FILE, 'utf-8');
existingData = JSON.parse(fileContent);
}
const dataMap = new Map<string, DataRecord>();
existingData.forEach(item => dataMap.set(item.timestamp, item));
records.forEach(item => dataMap.set(item.timestamp, item));
const mergedData = Array.from(dataMap.values()).sort((a, b) => {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
// 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.writeFileSync(DATA_FILE, JSON.stringify(mergedData, null, 2), 'utf-8');
console.log(`[${internalId}] Scraped ${records.length} records. DB total: ${mergedData.length}`);
} catch (error: any) {
console.error(`[${internalId}] Error scraping data:`, error.message);
}
}
async function runScraper() {
console.log(`Starting bulk scraper for ${lakesConfig.length} lakes...`);
for (const lake of lakesConfig) {
// ID format: VLL1|1 -> internalId=VLL1, oid=1
const [internalId, oid] = lake.id.split('|');
await scrapeLake(lake.id, oid, internalId);
// Add small delay to not hammer the server
await new Promise(resolve => setTimeout(resolve, 500));
}
console.log('Bulk scraping finished.');
}
runScraper();
+32
View File
@@ -0,0 +1,32 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import https from 'https';
async function checkLake() {
const agent = new https.Agent({ rejectUnauthorized: false });
// Check Lipno 1
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?id=VLL1&oid=1`;
try {
const response = await axios.get(url, {
httpsAgent: agent,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
});
const $ = cheerio.load(response.data);
let hasData = false;
$('table').each((i, tbl) => {
const firstRowText = $(tbl).find('tr').first().text();
console.log(`Table ${i} first row:`, firstRowText.trim().replace(/\\s+/g, ' '));
if (firstRowText.includes('Datum a čas') || firstRowText.includes('Hladina')) {
hasData = true;
}
});
console.log(`hasData=${hasData}`);
} catch (err: any) {
console.error(err.message);
}
}
checkLake();
+52
View File
@@ -0,0 +1,52 @@
export interface LakeCalculationConfig {
maxVolume?: number;
minLevel?: number;
maxLevel?: number;
storageLevel?: number;
}
export interface LakeMetrics {
capacity: number; // 0-100 percentage
volume: number; // in mil. m3
storageDiff: number; // in meters
}
export function calculateLakeMetrics(
currentLevel: number,
reportedVolume: number,
config: LakeCalculationConfig
): LakeMetrics {
let capacity = 0;
let volume = reportedVolume;
let storageDiff = 0;
// 1. Calculate capacity and volume
if (volume > 0 && config.maxVolume && config.maxVolume > 0) {
// If real volume is available, calculate capacity from volume
capacity = Math.max(0, Math.min(100, Math.round((volume / config.maxVolume) * 1000) / 10));
} else if (config.minLevel && config.maxLevel && currentLevel > 0) {
// Fallback: estimate capacity and volume from level difference
const percentage = ((currentLevel - config.minLevel) / (config.maxLevel - config.minLevel)) * 100;
capacity = Math.max(0, Math.min(100, Math.round(percentage * 10) / 10)); // Round to 1 decimal place
if (volume === 0) {
volume = Number(((capacity / 100) * (config.maxVolume || 0)).toFixed(1));
}
} else {
// Missing required config data or bad level
if (volume === 0) {
volume = config.maxVolume || 0;
}
}
// 2. Calculate storage difference
if (config.storageLevel && currentLevel > 0) {
storageDiff = Number((currentLevel - config.storageLevel).toFixed(2));
}
return {
capacity,
volume,
storageDiff
};
}
+52
View File
@@ -0,0 +1,52 @@
import { execSync } from 'child_process';
// How many minutes after the 10-minute mark should we run the scraper?
// The basin authority (PVL) generates data at HH:00, HH:10, HH:20... but it takes time to publish.
// 5 minutes (HH:05, HH:15...) is a safe buffer to avoid fetching outdated data.
const offsetMinutes = 5;
console.log(`\n⏱️ HLADINATOR Watcher spuštěn!`);
console.log(`Budu automaticky stahovat nová data vždy v časech končících na ${offsetMinutes} (např. 10:05, 10:15, 10:25...).\nTo zajistí, že má Povodí dostatek času data vygenerovat a nahrát.\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.\n`);
} catch (error: any) {
console.error(`[${new Date().toLocaleTimeString('cs-CZ')}] ❌ Chyba při aktualizaci:`, error.message);
}
scheduleNextRun();
}
function scheduleNextRun() {
const now = new Date();
const currentMinute = now.getMinutes();
// Find the next target minute (ending in 5)
// E.g. if it's 12, next will be 15. If it's 26, next will be 35.
let nextMinute = Math.floor(currentMinute / 10) * 10 + offsetMinutes;
if (nextMinute <= currentMinute) {
nextMinute += 10;
}
const targetTime = new Date(now);
if (nextMinute >= 60) {
targetTime.setHours(targetTime.getHours() + 1);
targetTime.setMinutes(nextMinute % 60);
} else {
targetTime.setMinutes(nextMinute);
}
targetTime.setSeconds(0);
targetTime.setMilliseconds(0);
const waitMs = targetTime.getTime() - now.getTime();
console.log(`[${new Date().toLocaleTimeString('cs-CZ')}] ⏳ Další stahování naplánováno na: ${targetTime.toLocaleTimeString('cs-CZ')} (za ${(waitMs / 60000).toFixed(1)} minut)\n`);
setTimeout(runUpdate, waitMs);
}
// Run update immediately on first launch and then set the timer
runUpdate();
+449 -33
View File
@@ -1,42 +1,458 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
.dashboard-container {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: var(--bg-dark);
}
.sidebar {
width: 190px;
background-color: var(--bg-card);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 1.5rem 0.75rem;
transition: width 0.3s ease;
overflow: hidden;
}
.sidebar.collapsed {
width: 72px;
padding: 1.5rem 0.5rem;
}
.sidebar.collapsed .sidebar-text {
display: none;
}
.sidebar.collapsed .sidebar-logo {
justify-content: center;
padding-left: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 2rem;
padding-left: 0.5rem;
}
.sidebar-logo svg {
color: var(--color-cyan);
font-size: 2rem;
}
.sidebar-logo div {
display: flex;
flex-direction: column;
}
.sidebar-logo span {
font-weight: 700;
letter-spacing: 0.5px;
font-size: 1.1rem;
line-height: 1.1;
}
.sidebar-logo small {
color: var(--text-muted);
font-size: 0.7rem;
}
.nav-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.5rem;
border-radius: 0.5rem;
color: var(--text-muted);
font-size: 0.95rem;
font-weight: 500;
transition: all 0.2s;
cursor: pointer;
white-space: nowrap;
}
.nav-item svg {
flex-shrink: 0;
min-width: 20px;
}
.nav-item:hover {
background-color: rgba(255, 255, 255, 0.03);
color: var(--text-main);
}
.nav-item.active {
background: linear-gradient(135deg, var(--color-cyan) 0%, #0284c7 100%);
color: #ffffff;
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);
}
.nav-item.active svg {
color: #ffffff;
}
.nav-item svg {
font-size: 1.25rem;
}
.sidebar-footer {
margin-top: auto;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 1.5rem 2rem;
gap: 1.5rem;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
}
.search-bar {
display: flex;
align-items: center;
background-color: var(--bg-card);
padding: 0.5rem 1rem;
border-radius: 0.5rem;
width: 400px;
border: 1px solid var(--border-color);
}
.search-bar svg {
color: var(--text-muted);
margin-right: 0.75rem;
}
.search-bar input {
background: transparent;
border: none;
color: var(--text-main);
outline: none;
width: 100%;
font-size: 0.9rem;
}
.search-bar input::placeholder {
color: var(--text-muted);
}
.topbar-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text-main);
}
.status-dot {
width: 8px;
height: 8px;
background-color: var(--color-green);
border-radius: 50%;
box-shadow: 0 0 8px var(--color-green);
}
.kpi-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.kpi-card {
background-color: var(--bg-card);
border-radius: 0.75rem;
padding: 1.25rem 1.5rem;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kpi-title {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
font-weight: 600;
}
.kpi-value {
font-size: 2.25rem;
font-weight: 700;
}
.kpi-subtitle {
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-muted);
}
.kpi-trend.positive { color: var(--color-green); }
.kpi-trend.negative { color: var(--color-red); }
.chart-card {
background-color: var(--bg-card);
border-radius: 0.75rem;
border: 1px solid var(--border-color);
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.chart-title {
font-size: 1.25rem;
font-weight: 600;
}
.chart-controls {
display: flex;
gap: 2rem;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-label {
font-size: 0.75rem;
color: var(--text-muted);
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
.button-group {
display: flex;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
overflow: hidden;
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
.control-btn {
background: transparent;
border: none;
color: var(--text-muted);
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.control-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.control-btn.active {
background-color: rgba(255, 255, 255, 0.15);
color: var(--text-main);
font-weight: 500;
}
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 10px;
cursor: pointer;
margin: 0 0.5rem;
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background-color: white;
border-radius: 50%;
transition: 0.2s;
}
.toggle-switch.on {
background-color: var(--color-cyan);
}
.toggle-switch.on::after {
left: calc(100% - 18px);
}
.dashboard-footer {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
padding-top: 1rem;
}
.dashboard-footer a {
text-decoration: underline;
}
/* Mobile Responsiveness */
.mobile-only {
display: none !important;
}
@media (max-width: 768px) {
.mobile-only {
display: flex !important;
}
to {
transform: rotate(360deg);
.desktop-only {
display: none !important;
}
.dashboard-container {
flex-direction: column;
}
.sidebar {
display: none;
}
.sidebar.mobile-open {
display: flex;
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 1000;
}
.main-content {
padding: 1rem;
gap: 1rem;
}
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.topbar-mobile-header {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
.search-bar {
width: 100%;
}
.search-bar input {
display: none;
}
.search-bar {
width: auto;
background: transparent;
border: none;
padding: 0;
}
.search-bar svg {
margin: 0;
font-size: 1.5rem;
color: var(--text-main);
}
.kpi-container {
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.kpi-card {
padding: 1rem;
}
.kpi-value {
font-size: 1.75rem;
}
.kpi-subtitle {
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
}
.chart-card {
padding: 1rem;
}
.chart-header {
flex-direction: column;
gap: 1rem;
}
.chart-title {
font-size: 1.1rem;
line-height: 1.3;
}
.chart-controls {
width: 100%;
flex-direction: column;
gap: 1rem;
}
.control-group {
width: 100%;
}
.button-group {
width: 100%;
justify-content: space-between;
}
.control-btn {
padding: 0.5rem;
flex: 1;
text-align: center;
}
.chart-legend-container {
flex-wrap: wrap;
gap: 1rem !important;
justify-content: flex-start !important;
}
.chart-legend-container > span {
flex: 0 0 calc(50% - 0.5rem);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+108 -24
View File
@@ -1,31 +1,115 @@
import { useState } from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import LoadingScreen from './components/LoadingScreen'
import Home from './components/Home'
import Navbar from './components/Navbar'
import TimeBreaker from './components/TimeBreaker'
import { type Language } from './translations'
import './App.css'
import { useState, useEffect } from 'react';
import { Routes, Route, useParams, Navigate } from 'react-router-dom';
import LakeDetail from './components/LakeDetail';
import LakesOverview from './components/LakesOverview';
import LakeMap from './components/LakeMap';
import FavoritesOverview from './components/FavoritesOverview';
import Sidebar from './components/Sidebar';
import Topbar from './components/Topbar';
import SettingsModal from './components/SettingsModal';
import { type Language, t } from './translations';
import { lakesConfig } from '../scripts/lakesConfig';
import { slugify } from './utils/slugify';
import './App.css';
const LakeDetailWrapper = ({ language, windUnit }: { language: Language, windUnit: 'kmh' | 'ms' }) => {
const { slug } = useParams();
const lake = lakesConfig.find(l => slugify(l.text) === slug);
if (!lake) return <Navigate to="/" replace />;
return <LakeDetail language={language} lakeId={lake.id} windUnit={windUnit} />;
};
function App() {
const [isLoading, setIsLoading] = useState(true)
const [language, setLanguage] = useState<Language>('en')
const [language, setLanguage] = useState<Language>(() => {
return (localStorage.getItem('hladinator_lang') as Language) || 'en';
});
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
return (localStorage.getItem('hladinator_theme') as 'dark' | 'light') || 'dark';
});
const [windUnit, setWindUnit] = useState<'kmh' | 'ms'>(() => {
return (localStorage.getItem('hladinator_windUnit') as 'kmh' | 'ms') || 'kmh';
});
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
useEffect(() => {
if (theme === 'light') {
document.body.classList.add('light-mode');
} else {
document.body.classList.remove('light-mode');
}
localStorage.setItem('hladinator_theme', theme);
// Clean up empty hash from URL (e.g. if the user clicked an empty anchor)
if (window.location.href.endsWith('#')) {
window.history.replaceState(null, '', window.location.href.slice(0, -1));
}
}, [theme]);
useEffect(() => {
localStorage.setItem('hladinator_lang', language);
}, [language]);
useEffect(() => {
localStorage.setItem('hladinator_windUnit', windUnit);
}, [windUnit]);
return (
<>
{isLoading ? (
<LoadingScreen onLoaded={() => setIsLoading(false)} />
) : (
<Router>
<Navbar language={language} setLanguage={setLanguage} />
<Routes>
<Route path="/" element={<Home language={language} />} />
<Route path="/time-breaker" element={<TimeBreaker language={language} />} />
</Routes>
</Router>
<div className="dashboard-container">
{/* Mobile overlay */}
{isMobileMenuOpen && (
<div
style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999 }}
onClick={() => setIsMobileMenuOpen(false)}
></div>
)}
</>
)
<Sidebar
language={language}
onOpenSettings={() => setIsSettingsOpen(true)}
isMobileMenuOpen={isMobileMenuOpen}
onCloseMobileMenu={() => setIsMobileMenuOpen(false)}
/>
<div className="main-content" style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Topbar language={language} onToggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Routes>
<Route path="/" element={<LakesOverview language={language} />} />
<Route path="/favorites" element={<FavoritesOverview language={language} />} />
<Route path="/map" element={<LakeMap language={language} />} />
<Route path="/:slug" element={<LakeDetailWrapper language={language} windUnit={windUnit} />} />
</Routes>
</div>
<footer style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1.5rem',
fontSize: '0.8rem',
color: 'var(--text-muted)',
marginTop: 'auto'
}}>
<span>{t[language].chart.dataSources} pvl.cz, open-meteo.com</span>
<span>{t[language].chart.createdIn}</span>
</footer>
</div>
{isSettingsOpen && (
<SettingsModal
language={language}
setLanguage={setLanguage}
theme={theme}
setTheme={setTheme}
windUnit={windUnit}
setWindUnit={setWindUnit}
onClose={() => setIsSettingsOpen(false)}
/>
)}
</div>
);
}
export default App
export default App;
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { t } from '../translations';
describe('Translations', () => {
it('should have exactly the same keys in English and Czech', () => {
const enKeys = Object.keys(t.en).sort();
const csKeys = Object.keys(t.cs).sort();
expect(enKeys).toEqual(csKeys);
// Deep check for nested keys
for (const key of enKeys) {
const enSubKeys = Object.keys((t.en as any)[key]).sort();
const csSubKeys = Object.keys((t.cs as any)[key]).sort();
expect(enSubKeys).toEqual(csSubKeys);
}
});
it('should not have empty translation strings', () => {
const checkEmpty = (obj: any) => {
for (const val of Object.values(obj)) {
if (typeof val === 'string') {
expect(val.length).toBeGreaterThan(0);
} else if (typeof val === 'object') {
checkEmpty(val);
}
}
};
checkEmpty(t.en);
checkEmpty(t.cs);
});
});
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
interface Props {
value: number;
size?: number;
strokeWidth?: number;
}
export const CircularProgress: React.FC<Props> = ({ value, size = 60, strokeWidth = 6 }) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (value / 100) * circumference;
return (
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size}>
<circle
stroke="rgba(255,255,255,0.1)"
fill="transparent"
strokeWidth={strokeWidth}
r={radius}
cx={size / 2}
cy={size / 2}
/>
<circle
stroke="var(--color-cyan)"
fill="transparent"
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ strokeDasharray: circumference, strokeDashoffset: offset, transition: 'stroke-dashoffset 0.5s ease-in-out' }}
r={radius}
cx={size / 2}
cy={size / 2}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
{value > 0 ? `${value.toFixed(1)}%` : 'N/A'}
</div>
</div>
);
};
+185
View File
@@ -0,0 +1,185 @@
import { useState, useEffect } from 'react';
import { FiStar } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { useFavorites } from '../hooks/useFavorites';
import { Helmet } from 'react-helmet-async';
import { CircularProgress } from './CircularProgress';
import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
interface Lake {
id: string;
name: string;
river: string;
priority: boolean;
level: number;
capacity: number;
storageDiff?: number;
inflow: number;
outflow: number;
volume: number;
maxVolume: number;
sparkline: number[];
}
interface Props {
language: Language;
}
const FavoritesOverview = ({ language }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const { isFavorite, toggleFavorite } = useFavorites();
const navigate = useNavigate();
useEffect(() => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error(err));
}, []);
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<Helmet>
<title>{t[language].seo.favoritesTitle}</title>
<meta name="description" content={t[language].seo.favoritesDesc} />
<meta property="og:title" content={t[language].seo.favoritesTitle} />
<meta property="og:description" content={t[language].seo.favoritesDesc} />
</Helmet>
<div>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0 0 0.5rem 0', display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<FiStar size={24} fill="#f59e0b" color="#f59e0b" />
{language === 'cs' ? 'Oblíbená' : 'Favourites'}
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
{language === 'cs'
? 'Jezera připnutá v přehledu. Připnout nebo odepnout lze ikonou hvězdičky.'
: 'Lakes you pinned in the overview. Use the star icon to pin or unpin.'}
</p>
</div>
{favoriteLakes.length === 0 ? (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: '1rem', padding: '4rem 2rem', color: 'var(--text-muted)', textAlign: 'center'
}}>
<FiStar size={48} strokeWidth={1.2} color="var(--text-muted)" />
<p style={{ margin: 0, fontSize: '1.1rem' }}>
{language === 'cs' ? 'Zatím žádná oblíbená jezera.' : 'No favourites yet.'}
</p>
<p style={{ margin: 0, fontSize: '0.85rem' }}>
{language === 'cs'
? 'Přejdi na Jezera a nádrže a klikni na ⭐ u jezera, které tě zajímá.'
: 'Go to Lakes & Reservoirs and click the ⭐ on any lake to pin it here.'}
</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
{favoriteLakes.map(lake => {
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
const minVal = Math.min(...lake.sparkline);
const maxVal = Math.max(...lake.sparkline);
const diff = maxVal - minVal;
const padding = diff === 0 ? 0.1 : diff * 0.1;
const yDomain = [minVal - padding, maxVal + padding];
const firstVal = lake.sparkline[0] || 0;
const lastVal = lake.sparkline[lake.sparkline.length - 1] || 0;
const trendDiff = lastVal - firstVal;
let trendColor = 'var(--color-cyan)';
if (trendDiff > 0.01) trendColor = 'var(--color-green)';
else if (trendDiff < -0.01) trendColor = 'var(--color-red)';
return (
<div
key={lake.id}
className="kpi-card priority-lake-card"
onClick={() => navigate(`/${slugify(lake.name)}`)}
style={{ cursor: 'pointer', padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
>
{/* Unpin button */}
<button
onClick={(e) => { e.stopPropagation(); toggleFavorite(lake.id); }}
title={language === 'cs' ? 'Odepnout' : 'Unpin'}
style={{
position: 'absolute', top: '1rem', right: '1rem',
background: 'none', border: 'none', cursor: 'pointer',
color: '#f59e0b', transition: 'transform 0.15s',
padding: '4px', display: 'flex', alignItems: 'center', zIndex: 2,
}}
onMouseOver={(e) => { e.currentTarget.style.transform = 'scale(1.2)'; }}
onMouseOut={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
>
<FiStar size={18} fill="#f59e0b" />
</button>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>
{lake.name} {lake.river ? `- ${lake.river}` : ''}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{t[language].kpi.level}</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', display: 'flex', alignItems: 'baseline', gap: '0.25rem', lineHeight: '1.1' }}>
{lake.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>m n.m.</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '0.25rem' }}>
{lake.storageDiff !== undefined && (
<div style={{ fontSize: '1rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold', whiteSpace: 'nowrap' }}>
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
</div>
)}
{lake.maxVolume > 0 && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{lake.volume.toFixed(1)} / {lake.maxVolume.toFixed(1)} mil. m³
</div>
)}
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1, height: '40px', marginRight: '2rem' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id={`colorSparkFav-${lake.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={trendColor} stopOpacity={0.8} />
<stop offset="95%" stopColor={trendColor} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis domain={yDomain} hide />
<Area type="monotone" dataKey="value" stroke={trendColor} fillOpacity={1} fill={`url(#colorSparkFav-${lake.id})`} baseValue={yDomain[0]} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingUp color="var(--color-green)" />
<span style={{ color: 'var(--text-muted)' }}>{t[language].kpi.inflow} <span style={{ color: 'var(--color-green)' }}>{lake.inflow} m³/s</span></span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingDown color="var(--color-red)" />
<span style={{ color: 'var(--text-muted)' }}>{t[language].kpi.outflow} <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default FavoritesOverview;
-71
View File
@@ -1,71 +0,0 @@
import React from 'react';
import { FaFacebookF, FaInstagram, FaLinkedinIn } from 'react-icons/fa';
import { SiTypescript, SiReact, SiJavascript } from 'react-icons/si';
import { translations, type Language } from '../translations';
interface HomeProps {
language: Language;
}
const Home: React.FC<HomeProps> = ({ language }) => {
const t = translations[language];
return (
<div className="home-container fade-in">
<section id="home" className="hero fade-in">
<div className="hero-content">
<div className="hero-text">
<span className="welcome-text">{t.welcome}</span>
<h1>{t.hello} <span className="highlight">Davis</span></h1>
<h2 className="job-title">{t.job}</h2>
<p className="description">
{t.desc}
</p>
<div className="hero-footer">
<div className="socials">
<span className="footer-label">{t.findMe}</span>
<div className="icon-group">
<button className="icon-btn"><FaFacebookF /></button>
<button className="icon-btn"><FaInstagram /></button>
<button className="icon-btn"><FaLinkedinIn /></button>
</div>
</div>
<div className="skills">
<span className="footer-label">{t.bestSkill}</span>
<div className="icon-group">
<button className="icon-btn"><SiTypescript /></button>
<button className="icon-btn"><SiReact /></button>
<button className="icon-btn"><SiJavascript /></button>
</div>
</div>
</div>
</div>
<div className="hero-image-container">
<div className="hero-image-placeholder">
<span>Photo</span>
</div>
</div>
</div>
</section>
<section id="about" className="section about fade-in">
<h2>{t.aboutMe}</h2>
<p>
{t.aboutDesc}
</p>
</section>
<section id="contact" className="section contact fade-in">
<h2>{t.getInTouch}</h2>
<p>
{t.contactDesc} <a href="mailto:hello@example.com">{t.emailMe}</a>.
</p>
</section>
</div>
);
};
export default Home;
+182
View File
@@ -0,0 +1,182 @@
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { useState, useEffect } from 'react';
import { CircularProgress } from './CircularProgress';
interface KpiData {
level: number;
levelDiff24h?: number;
levelDiff7d?: number;
levelDiff30d?: number;
inflow: number;
outflow: number;
volume: number;
fullness: number;
storageDiff?: number;
minDiff?: number;
avgInflow24h?: number;
avgOutflow24h?: number;
}
interface Props {
data: KpiData;
language: Language;
lakeName?: string;
}
const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
const [showTooltip, setShowTooltip] = useState(false);
const dict = t[language].kpi;
const flowDiff = data.inflow - data.outflow;
useEffect(() => {
if (showTooltip) {
const timer = setTimeout(() => {
setShowTooltip(false);
}, 3500);
return () => clearTimeout(timer);
}
}, [showTooltip]);
return (
<>
{/* CARD 1: WATER LEVEL */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{dict.level} {lakeName}
</div>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, whiteSpace: 'nowrap' }}>
{data.level.toFixed(2)} <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n. m.</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', alignContent: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', background: 'rgba(0,0,0,0.15)', padding: '0.2rem 0.4rem', borderRadius: '6px' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 'bold' }}>1D</span>
<span style={{ fontSize: '0.85rem', fontWeight: 'bold', color: (data.levelDiff24h ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{(data.levelDiff24h ?? 0) > 0 ? '+' : ''}{((data.levelDiff24h ?? 0) * 100).toFixed(1)} cm
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', background: 'rgba(0,0,0,0.15)', padding: '0.2rem 0.4rem', borderRadius: '6px' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 'bold' }}>7D</span>
<span style={{ fontSize: '0.85rem', fontWeight: 'bold', color: (data.levelDiff7d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{(data.levelDiff7d ?? 0) > 0 ? '+' : ''}{((data.levelDiff7d ?? 0) * 100).toFixed(1)} cm
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', background: 'rgba(0,0,0,0.15)', padding: '0.2rem 0.4rem', borderRadius: '6px' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 'bold' }}>30D</span>
<span style={{ fontSize: '0.85rem', fontWeight: 'bold', color: (data.levelDiff30d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
{(data.levelDiff30d ?? 0) > 0 ? '+' : ''}{((data.levelDiff30d ?? 0) * 100).toFixed(1)} cm
</span>
</div>
</div>
</div>
{/* CARD 2: FLOW */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{dict.flow}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-green)', marginRight: '6px', flexShrink: 0 }}></span>
{dict.inflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)', marginLeft: '4px' }}>{data.inflow.toFixed(1)} m³/s</span>
</div>
{data.avgInflow24h !== undefined && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', paddingLeft: '14px', marginTop: '-2px' }}>
Ø 24h: {data.avgInflow24h.toFixed(1)} m³/s
</div>
)}
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.25rem', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: 'var(--color-red)', marginRight: '2px', flexShrink: 0 }}></span>
{dict.outflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{data.outflow.toFixed(1)} m³/s</span>
{flowDiff > 0 ? <FiArrowUp color="var(--color-green)" /> : flowDiff < 0 ? <FiArrowDown color="var(--color-red)" /> : null}
</div>
{data.avgOutflow24h !== undefined && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', paddingLeft: '14px', marginTop: '-2px' }}>
Ø 24h: {data.avgOutflow24h.toFixed(1)} m³/s
</div>
)}
</div>
{/* Flow Circle */}
<div style={{
width: '70px',
height: '70px',
borderRadius: '50%',
border: `4px solid ${flowDiff >= 0 ? 'rgba(74, 222, 128, 0.2)' : 'rgba(248, 113, 113, 0.2)'}`,
borderTopColor: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)',
borderRightColor: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: 'rotate(-45deg)',
flexShrink: 0
}}>
<span style={{ transform: 'rotate(45deg)', color: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold', textAlign: 'center', lineHeight: 1.2 }}>
<div style={{ fontSize: '0.8rem' }}>{flowDiff > 0 ? '+' : ''}{flowDiff.toFixed(1)}</div>
<div style={{ fontSize: '0.6rem', opacity: 0.8 }}>m³/s</div>
</span>
</div>
</div>
</div>
{/* CARD 3: CAPACITY */}
<div className="kpi-card">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}>
{dict.fullness}
<span
onClick={() => setShowTooltip(!showTooltip)}
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.6, padding: '0 4px' }}
>
</span>
{showTooltip && (
<div
onClick={() => setShowTooltip(false)}
style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginBottom: '8px',
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
padding: '0.75rem',
borderRadius: '8px',
width: '250px',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
color: 'var(--text-main)',
fontSize: '0.85rem',
lineHeight: 1.4,
cursor: 'pointer'
}}>
{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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1, minWidth: 0, paddingRight: '0.5rem' }}>
<div style={{ fontSize: '1.7rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', whiteSpace: 'nowrap', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)' }}>
{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 style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{dict.volume}: {data.volume.toFixed(1)} <span style={{ fontSize: '0.7rem' }}>mil. m³</span>
</div>
{data.minDiff !== undefined && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '4px', whiteSpace: 'nowrap' }}>
{language === 'cs' ? 'K minimu:' : 'To min:'} <span style={{ color: data.minDiff < 0.5 ? 'var(--color-red)' : 'var(--color-green)' }}>{data.minDiff.toFixed(2)} m</span>
</div>
)}
</div>
<div style={{ flexShrink: 0 }}>
<CircularProgress value={data.fullness} size={80} strokeWidth={8} />
</div>
</div>
</div>
</>
);
};
export default KpiCards;
+429
View File
@@ -0,0 +1,429 @@
import { useState, useEffect } from 'react';
import { ComposedChart, Area, Line, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
import { Helmet } from 'react-helmet-async';
import { type Language, t } from '../translations';
import KpiCards from './KpiCards';
import { WeatherWidget } from './WeatherWidget';
import { WindChart } from './WindChart';
import { NAVIGATION_LIMITS } from '../utils/navigationLimits';
import { lakesConfig } from '../../scripts/lakesConfig';
import { FiAlertCircle, FiStar } from 'react-icons/fi';
import { useFavorites } from '../hooks/useFavorites';
interface LipnoData {
timestamp: string;
date: string;
level: number;
inflow: number;
outflow: number;
volume: number;
fullness: number;
temperature?: number | null;
precipitation?: number | null;
}
interface Props {
language: Language;
lakeId: string | null;
windUnit?: 'kmh' | 'ms';
}
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
if (active && payload && payload.length) {
const dict = t[language as 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 ? (language === 'cs' ? 'Teplota' : 'Temperature') : (language === 'cs' ? 'Srážky' : 'Precipitation')}: <span style={{ fontWeight: 'bold' }}>{entry.value !== undefined && entry.value !== null ? entry.value.toFixed(1) : 'N/A'} {isTemp ? '°C' : 'mm'}</span>
</p>
);
})}
</div>
);
}
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].sort((a: any, b: any) => {
const order = ['level', 'inflow', 'outflow', 'temperature', 'precipitation'];
const indexA = order.indexOf(a.dataKey);
const indexB = order.indexOf(b.dataKey);
return (indexA === -1 ? 99 : indexA) - (indexB === -1 ? 99 : indexB);
}).map((entry: any, index: number) => {
let labelStr = '';
let unit = '';
let color = '';
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; color = 'var(--color-cyan)'; }
else if (entry.dataKey === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; color = 'var(--color-red)'; }
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; color = 'var(--color-green)'; }
else if (entry.dataKey === 'temperature') { labelStr = language === 'cs' ? 'Teplota' : 'Temperature'; unit = '°C'; color = 'var(--color-red)'; }
else if (entry.dataKey === 'precipitation') { labelStr = language === 'cs' ? 'Srážky' : 'Precipitation'; unit = 'mm'; color = 'var(--color-cyan)'; }
if (!labelStr || entry.value === null || entry.value === undefined) return null;
return (
<div key={index} style={{ margin: 0, color: 'var(--text-main)', display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color, marginRight: '8px' }}></span>
{labelStr}: <span style={{ fontWeight: 'bold', marginLeft: '4px' }}>{entry.value.toFixed(entry.dataKey === 'level' ? 2 : 1)} {unit}</span>
</div>
);
})}
</div>
);
}
return null;
};
const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
const [data, setData] = useState<LipnoData[]>([]);
const [loading, setLoading] = useState(true);
const [lakeInfo, setLakeInfo] = useState<any>(null);
const [isSmoothed, setIsSmoothed] = useState(true);
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d' | '1y' | 'all'>('7d');
const dict = t[language].chart;
const topbarDict = t[language].topbar;
useEffect(() => {
const loadData = () => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(indexData => {
const found = indexData.find((l: any) => l.id === lakeId);
setLakeInfo(found || { name: 'Lipno 1', river: 'Vltava' });
})
.catch(err => console.error(err));
const internalId = lakeId ? lakeId.split('|')[0] : 'VLL1';
fetch(`/data/${internalId}.json?t=${Date.now()}`)
.then(res => res.json())
.then(json => {
const formattedData = json.map((item: any) => {
const outflow = item.flow === null || isNaN(item.flow) ? 0 : item.flow;
return {
timestamp: item.timestamp,
date: new Date(item.timestamp).toLocaleString(language === 'cs' ? 'cs-CZ' : 'en-GB', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
}),
level: item.level === null || isNaN(item.level) ? 0 : item.level,
outflow: outflow,
inflow: item.inflow || 0,
volume: item.volume || 0,
fullness: 0,
temperature: item.temperature,
precipitation: item.precipitation === null ? undefined : item.precipitation
};
});
setData(formattedData);
setLoading(false);
})
.catch(err => {
console.error('Failed to load data', err);
setLoading(false);
});
};
loadData();
const intervalId = setInterval(loadData, 60 * 1000); // Poll every 1 minute
return () => clearInterval(intervalId);
}, [language, lakeId]);
if (loading) {
return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'var(--bg-dark)', color: 'var(--text-main)' }}>
<div style={{ fontSize: '1.25rem' }}>Loading HLADINATOR...</div>
</div>
);
}
const latestData = data[data.length - 1] || { level: 0, inflow: 0, outflow: 0, volume: 0, fullness: 0 };
const curveType = isSmoothed ? 'monotone' : 'linear';
// Find last valid values for KPIs, including 0
const lastValidFlowData = [...data].reverse().find(d => d.outflow !== null && !isNaN(d.outflow) && 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;
// Find record from 24h, 7d, 30d ago
const nowMs = new Date(latestData.timestamp).getTime();
const targetMs24h = nowMs - 24 * 60 * 60 * 1000;
const targetMs7d = nowMs - 7 * 24 * 60 * 60 * 1000;
const targetMs30d = nowMs - 30 * 24 * 60 * 60 * 1000;
const { isFavorite, toggleFavorite } = useFavorites();
const isFav = lakeId ? isFavorite(lakeId) : false;
let level24hAgo = latestData.level;
let level7dAgo = latestData.level;
let level30dAgo = latestData.level;
let minDiff24h = Infinity;
let minDiff7d = Infinity;
let minDiff30d = Infinity;
let inflowSum24h = 0;
let outflowSum24h = 0;
let flowCount24h = 0;
for (const d of data) {
const t = new Date(d.timestamp).getTime();
const diff24h = Math.abs(t - targetMs24h);
if (diff24h < minDiff24h) {
minDiff24h = diff24h;
level24hAgo = d.level;
}
const diff7d = Math.abs(t - targetMs7d);
if (diff7d < minDiff7d) {
minDiff7d = diff7d;
level7dAgo = d.level;
}
const diff30d = Math.abs(t - targetMs30d);
if (diff30d < minDiff30d) {
minDiff30d = diff30d;
level30dAgo = d.level;
}
if (t >= targetMs24h && d.inflow !== undefined && d.outflow !== undefined) {
inflowSum24h += d.inflow;
outflowSum24h += d.outflow;
flowCount24h++;
}
}
const levelDiff24h = latestData.level - level24hAgo;
const levelDiff7d = latestData.level - level7dAgo;
const levelDiff30d = latestData.level - level30dAgo;
const avgInflow24h = flowCount24h > 0 ? inflowSum24h / flowCount24h : undefined;
const avgOutflow24h = flowCount24h > 0 ? outflowSum24h / flowCount24h : undefined;
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
const kpiData = {
level: latestData.level,
levelDiff24h,
levelDiff7d,
levelDiff30d,
inflow: lastValidFlowData.inflow,
outflow: lastValidFlowData.outflow,
volume: lakeInfo?.volume || 0,
fullness: lakeInfo?.capacity || 0,
storageDiff: lakeInfo?.storageDiff,
minDiff: staticConfig?.minLevel ? latestData.level - staticConfig.minLevel : undefined,
avgInflow24h,
avgOutflow24h
};
const leftYAxisDomain = [
(dataMin: number) => {
let min = dataMin;
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
return min - 0.5;
},
(dataMax: number) => {
let max = dataMax;
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
return max + 0.5;
}
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
{lakeInfo && (
<>
<Helmet>
<title>{t[language].seo.lakeTitle.replace('{name}', lakeInfo.name)}</title>
<meta name="description" content={t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)} />
<meta property="og:title" content={t[language].seo.lakeTitle.replace('{name}', lakeInfo.name)} />
<meta property="og:description" content={t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)} />
</Helmet>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '0 0 0.5rem 0' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold', margin: 0, color: 'var(--text-main)' }}>
{lakeInfo.name}
</h1>
<button
onClick={(e) => { e.preventDefault(); if (lakeId) toggleFavorite(lakeId); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', padding: '0.25rem' }}
title={isFav ? (language === 'cs' ? "Odebrat z oblíbených" : "Remove from favorites") : (language === 'cs' ? "Přidat do oblíbených" : "Add to favorites")}
>
<FiStar size={24} fill={isFav ? '#f59e0b' : 'none'} color={isFav ? '#f59e0b' : 'var(--text-muted)'} />
</button>
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
{t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)}
</p>
</div>
</>
)}
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginTop: '-0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span>{topbarDict.updated} {new Date().toLocaleDateString(language === 'cs' ? 'cs-CZ' : 'en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}, {new Date().toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' })} UTC</span>
<div className="status-dot"></div>
</div>
<div className="kpi-grid-container">
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature ?? undefined} />
)}
</div>
{limits && limits.map((limit, idx) => {
const diff = latestData.level - limit.level;
if (diff < 0.3) {
const isBelow = diff < 0;
return (
<div key={idx} style={{ padding: '1rem', borderRadius: '8px', backgroundColor: isBelow ? 'rgba(248, 113, 113, 0.1)' : 'rgba(245, 158, 11, 0.1)', border: `1px solid ${isBelow ? 'var(--color-red)' : '#f59e0b'}`, color: isBelow ? 'var(--color-red)' : '#f59e0b', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FiAlertCircle style={{ flexShrink: 0, fontSize: '1.5rem' }} />
<div>
<strong>{language === 'cs' ? limit.labelCs : limit.labelEn} ({limit.level.toFixed(2)} m n.m.):</strong>
<br/>
{isBelow
? (language === 'cs' ? `Hladina je ${Math.abs(diff).toFixed(2)} m POD limitem! Přerušení provozu.` : `Level is ${Math.abs(diff).toFixed(2)} m BELOW limit! Operations suspended.`)
: (language === 'cs' ? `Hladina se blíží k limitu (zbývá ${diff.toFixed(2)} m).` : `Level is approaching limit (${diff.toFixed(2)} m remaining).`)
}
</div>
</div>
);
}
return null;
})}
{/* CHART SECTION */}
<div className="chart-card">
<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>
<div className="top-time-controls" style={{ margin: 0 }}>
<button className={timeRange === '24h' ? 'active' : ''} onClick={() => setTimeRange('24h')}>24h</button>
<button className={timeRange === '7d' ? 'active' : ''} onClick={() => setTimeRange('7d')}>7d</button>
<button className={timeRange === '30d' ? 'active' : ''} onClick={() => setTimeRange('30d')}>30d</button>
<button className={timeRange === '1y' ? 'active' : ''} onClick={() => setTimeRange('1y')}>{dict.year}</button>
<button className={timeRange === 'all' ? 'active' : ''} onClick={() => setTimeRange('all')}>{dict.all}</button>
</div>
</div>
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '1rem' }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorLevel" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
<YAxis yAxisId="left" domain={leftYAxisDomain as any} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(2)} />
<YAxis yAxisId="right" orientation="right" domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<Tooltip content={<CustomTooltip language={language} />} />
{/* Data Series */}
{limits && limits.map((limit, idx) => (
<ReferenceLine key={idx} yAxisId="left" y={limit.level} stroke={limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b'} strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `${limit.labelCs} (${limit.level.toFixed(2)} m n. m.)` : `${limit.labelEn} (${limit.level.toFixed(2)} m a.s.l.)`, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', fontSize: 12 }} />
))}
{staticConfig?.maxLevel && (
<ReferenceLine yAxisId="left" y={staticConfig.maxLevel} stroke="var(--color-orange)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Maximální retenční hladina (${staticConfig.maxLevel.toFixed(2)} m n. m.)` : `Max retention level (${staticConfig.maxLevel.toFixed(2)} m a.s.l.)`, fill: 'var(--color-orange)', fontSize: 12 }} />
)}
{staticConfig?.storageLevel && (
<ReferenceLine yAxisId="left" y={staticConfig.storageLevel} stroke="#a855f7" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Hladina zásobního prostoru (${staticConfig.storageLevel.toFixed(2)} m n. m.)` : `Storage space level (${staticConfig.storageLevel.toFixed(2)} m a.s.l.)`, fill: '#a855f7', fontSize: 12 }} />
)}
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} />
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-red)" strokeWidth={2} dot={false} isAnimationActive={animate} />
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} />
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Chart Legend */}
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div> {dict.level}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> {dict.outflow}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div> {dict.inflow}</span>
</div>
{/* WEATHER CHART SECTION */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', marginTop: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{language === 'cs' ? 'Počasí (Teplota a Srážky)' : 'Weather (Temperature & Precipitation)'}</h3>
</div>
<div style={{ flex: 1, minHeight: '200px', width: '100%', marginTop: '0.5rem' }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 10, 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> {language === 'cs' ? 'Teplota vzduchu' : 'Temperature'} [°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> {language === 'cs' ? 'Srážky' : 'Precipitation'} [mm]</span>
</div>
{/* Wind Chart placed inside the main card below the weather graph */}
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WindChart lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} timeRange={timeRange} windUnit={windUnit} />
)}
{/* Smoothed Toggle Control */}
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '3rem', marginBottom: '1rem' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{dict.view}</span>
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.9rem' }}>
<span style={{ color: !isSmoothed ? 'var(--text-main)' : 'var(--text-muted)', transition: '0.2s', marginRight: '0.5rem' }}>{dict.raw}</span>
<div
className={`toggle-switch ${isSmoothed ? 'on' : ''}`}
onClick={() => setIsSmoothed(!isSmoothed)}
></div>
<span style={{ color: isSmoothed ? 'var(--text-main)' : 'var(--text-muted)', transition: '0.2s', marginLeft: '0.5rem' }}>{dict.smoothed}</span>
</div>
</div>
</div>
</div>
);
};
export default LakeDetail;
+157
View File
@@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { FiX, FiSearch } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { slugify } from '../utils/slugify';
import { useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';
interface LakeData {
id: string;
name: string;
river: string;
priority: boolean;
level: string;
capacity: number;
inflow: string;
outflow: string;
volume: string;
lat: number;
lng: number;
}
interface Props {
language: Language;
}
// Create custom icon
const createCustomIcon = () => {
return L.divIcon({
className: 'custom-div-icon',
html: `
<div class="map-marker-icon">
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="18" width="18" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"></path><path d="M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"></path></svg>
</div>
`,
iconSize: [36, 42],
iconAnchor: [18, 42],
popupAnchor: [0, -42],
});
};
const LakeMap = ({ language }: Props) => {
const [lakes, setLakes] = useState<LakeData[]>([]);
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [isPanelVisible, setIsPanelVisible] = useState(true);
useEffect(() => {
fetch('/data/lakes_index.json')
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error('Error fetching map lakes:', err));
}, []);
const filteredLakes = lakes.filter(lake =>
lake.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const customIcon = createCustomIcon();
return (
<div className="map-view-container">
<Helmet>
<title>{t[language].seo.mapTitle}</title>
<meta name="description" content={t[language].seo.mapDesc} />
<meta property="og:title" content={t[language].seo.mapTitle} />
<meta property="og:description" content={t[language].seo.mapDesc} />
</Helmet>
{/* Leaflet Map */}
<MapContainer
center={[49.8, 15.5]}
zoom={7}
style={{ width: '100%', height: '100%' }}
zoomControl={false}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors'
/>
{lakes.map(lake => (
<Marker
key={lake.id}
position={[lake.lat, lake.lng]}
icon={customIcon}
eventHandlers={{
click: () => navigate(`/${slugify(lake.name)}`)
}}
>
<Popup>
<strong>{lake.name}</strong><br/>
{lake.river}
</Popup>
</Marker>
))}
</MapContainer>
{/* Floating Overlay Panel */}
{isPanelVisible && (
<div className="map-overlay-panel">
<div className="map-overlay-header">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>{language === 'cs' ? 'Seznam jezer a nádrží' : 'Lakes and Reservoirs List'}</h3>
<FiX style={{ cursor: 'pointer', color: 'var(--text-muted)' }} onClick={() => setIsPanelVisible(false)} />
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{language === 'cs' ? `Nalezeno: ${filteredLakes.length} záznamů` : `Found: ${filteredLakes.length} records`}
</div>
<div className="search-bar" style={{ width: '100%', padding: '0.5rem', backgroundColor: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)' }}>
<FiSearch />
<input
type="text"
placeholder={language === 'cs' ? 'Najít jezero...' : 'Find a lake...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ width: '100%', background: 'transparent', border: 'none', color: 'white', outline: 'none' }}
/>
</div>
</div>
<div className="map-overlay-list">
{filteredLakes.map((lake, index) => (
<div key={lake.id} className="map-lake-card" onClick={() => navigate(`/${slugify(lake.name)}`)}>
<div style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>{index + 1}. {lake.name}</div>
<div className="map-lake-stats">
<div>
<span style={{ color: 'var(--text-muted)', display: 'block' }}>{language === 'cs' ? 'Rozloha' : 'Area'}</span>
<span style={{ color: 'var(--text-main)' }}>{(Math.random() * 50 + 10).toFixed(1)} km²</span>
</div>
<div>
<span style={{ color: 'var(--text-muted)', display: 'block' }}>{language === 'cs' ? 'Hloubka' : 'Depth'}</span>
<span style={{ color: 'var(--text-main)' }}>{(Math.random() * 30 + 5).toFixed(1)}m</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{!isPanelVisible && (
<button
style={{ position: 'absolute', top: 10, right: 10, zIndex: 1000, background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'white', padding: '0.5rem 1rem', borderRadius: '8px', cursor: 'pointer' }}
onClick={() => setIsPanelVisible(true)}
>
{language === 'cs' ? 'Zobrazit seznam' : 'Show List'}
</button>
)}
</div>
);
};
export default LakeMap;
+216
View File
@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet-async';
import { FiTrendingUp, FiTrendingDown, FiStar } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { useFavorites } from '../hooks/useFavorites';
import { CircularProgress } from './CircularProgress';
interface Lake {
id: string;
name: string;
river: string;
priority: boolean;
level: number;
capacity: number;
storageDiff?: number;
inflow: number;
outflow: number;
volume: number;
maxVolume: number;
sparkline: number[];
}
interface Props {
language: Language;
}
const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
const navigate = useNavigate();
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
const minVal = Math.min(...lake.sparkline);
const maxVal = Math.max(...lake.sparkline);
const diff = maxVal - minVal;
const padding = diff === 0 ? 0.1 : diff * 0.1; // dynamic 10% padding
const yDomain = [minVal - padding, maxVal + padding];
const firstVal = lake.sparkline[0] || 0;
const lastVal = lake.sparkline[lake.sparkline.length - 1] || 0;
const trendDiff = lastVal - firstVal;
// Dynamic color based on trend direction: stable=cyan, rising=green, falling=red
let trendColor = 'var(--color-cyan)';
if (trendDiff > 0.01) trendColor = 'var(--color-green)';
else if (trendDiff < -0.01) trendColor = 'var(--color-red)';
return (
<div
className="kpi-card priority-lake-card"
onClick={() => navigate(`/${slugify(lake.name)}`)}
style={{ cursor: 'pointer', flex: 1, padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
>
{/* Star / Favorite button */}
<button
onClick={(e) => { e.stopPropagation(); onToggleFav(lake.id); }}
title={isFav ? (language === 'cs' ? 'Odepnout' : 'Unpin') : (language === 'cs' ? 'Připnout jako oblíbené' : 'Pin to favorites')}
style={{
position: 'absolute', top: '1rem', right: '1rem',
background: 'none', border: 'none', cursor: 'pointer',
color: isFav ? '#f59e0b' : 'var(--text-muted)',
opacity: isFav ? 1 : 0.4,
transition: 'color 0.2s, opacity 0.2s, transform 0.15s',
padding: '4px',
display: 'flex', alignItems: 'center',
zIndex: 2,
}}
onMouseOver={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'scale(1.2)'; }}
onMouseOut={(e) => { e.currentTarget.style.opacity = isFav ? '1' : '0.4'; e.currentTarget.style.transform = 'scale(1)'; }}
>
<FiStar size={18} fill={isFav ? '#f59e0b' : 'none'} />
</button>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>{lake.name} {lake.river ? `- ${lake.river}` : ''}</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Water level</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', display: 'flex', alignItems: 'baseline', gap: '0.25rem', lineHeight: '1.1' }}>
{lake.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>m n.m.</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '0.25rem' }}>
{lake.storageDiff !== undefined && (
<div style={{ fontSize: '1rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold', whiteSpace: 'nowrap' }}>
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
</div>
)}
{lake.maxVolume > 0 && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{lake.volume.toFixed(1)} / {lake.maxVolume.toFixed(1)} mil. m³
</div>
)}
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1, height: '40px', marginRight: '2rem' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id={`colorSpark-${lake.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={trendColor} stopOpacity={0.8} />
<stop offset="95%" stopColor={trendColor} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis domain={yDomain} hide />
<Area type="monotone" dataKey="value" stroke={trendColor} fillOpacity={1} fill={`url(#colorSpark-${lake.id})`} baseValue={yDomain[0]} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingUp color="var(--color-green)" />
<span style={{ color: 'var(--text-muted)' }}>Inflow <span style={{ color: 'var(--color-green)' }}>{lake.inflow} m³/s</span></span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingDown color="var(--color-red)" />
<span style={{ color: 'var(--text-muted)' }}>Outflow <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
</div>
</div>
</div>
</div>
);
};
const LakesOverview = ({ language }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const { isFavorite, toggleFavorite } = useFavorites();
useEffect(() => {
const loadData = () => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error(err));
};
loadData();
const intervalId = setInterval(loadData, 60 * 1000); // Poll every 1 minute
return () => clearInterval(intervalId);
}, []);
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
const priorityLakes = lakes.filter(l => l.priority && !isFavorite(l.id));
const otherLakes = lakes.filter(l => !l.priority && !isFavorite(l.id));
otherLakes.sort((a, b) => a.name.localeCompare(b.name));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<Helmet>
<title>{t[language].seo.homeTitle}</title>
<meta name="description" content={t[language].seo.homeDesc} />
<meta property="og:title" content={t[language].seo.homeTitle} />
<meta property="og:description" content={t[language].seo.homeDesc} />
</Helmet>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0', color: 'var(--text-main)' }}>{t[language].sidebar.lakes} ({lakes.length})</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
{t[language].seo.homeDesc}
</p>
</div>
{/* Favorites section */}
{favoriteLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FiStar size={16} fill="#f59e0b" color="#f59e0b" /> {language === 'cs' ? 'Oblíbená' : 'Favorites'} ({favoriteLakes.length})
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{favoriteLakes.map(lake => (
<LakeCard key={lake.id} lake={lake} language={language} isFav={true} onToggleFav={toggleFavorite} />
))}
</div>
</section>
)}
{priorityLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>{language === 'cs' ? 'Jezera a nádrže' : 'Lakes and Reservoirs'}</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={false} onToggleFav={toggleFavorite} />)}
</div>
</section>
)}
{otherLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', marginTop: '1rem' }}>{language === 'cs' ? 'Ostatní' : 'Other'}</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={false} onToggleFav={toggleFavorite} />)}
</div>
</section>
)}
</div>
);
};
export default LakesOverview;
-30
View File
@@ -1,30 +0,0 @@
import React, { useEffect, useState } from 'react';
import '../index.css';
interface LoadingScreenProps {
onLoaded: () => void;
}
const LoadingScreen: React.FC<LoadingScreenProps> = ({ onLoaded }) => {
const [fading, setFading] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setFading(true);
setTimeout(onLoaded, 500); // Wait for fade out animation
}, 2500); // Show loading screen for 2.5 seconds
return () => clearTimeout(timer);
}, [onLoaded]);
return (
<div className={`loading-screen ${fading ? 'fade-out' : ''}`}>
<div className="loader-content">
<div className="spinner"></div>
<h1 className="loading-text">Welcome</h1>
</div>
</div>
);
};
export default LoadingScreen;
-81
View File
@@ -1,81 +0,0 @@
import React from 'react';
import logo from '../assets/logo.jpg';
import { translations, type Language } from '../translations';
import { Link, useLocation, useNavigate } from 'react-router-dom';
interface NavbarProps {
language: Language;
setLanguage: (lang: Language) => void;
}
const Navbar: React.FC<NavbarProps> = ({ language, setLanguage }) => {
const t = translations[language];
const location = useLocation();
const navigate = useNavigate();
const handleNavClick = (id: string, e: React.MouseEvent) => {
e.preventDefault();
if (location.pathname !== '/') {
navigate('/');
// Wait for navigation then scroll
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
} else {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
};
const handleHomeClick = (e: React.MouseEvent) => {
e.preventDefault();
if (location.pathname !== '/') {
navigate('/');
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
return (
<nav className="navbar">
<div className="nav-content">
<div
className="logo-container"
onClick={handleHomeClick}
style={{ cursor: 'pointer' }}
>
<img src={logo} alt="David Fencl Logo" className="nav-logo" />
</div>
<div className="nav-right">
<div className="language-switcher">
<button
className={`lang-btn ${language === 'en' ? 'active' : ''}`}
onClick={() => setLanguage('en')}
>
EN
</button>
<span className="lang-separator">/</span>
<button
className={`lang-btn ${language === 'cs' ? 'active' : ''}`}
onClick={() => setLanguage('cs')}
>
CZ
</button>
</div>
<div className="links">
<Link to="/time-breaker">{t.timeBreaker}</Link>
<a href="#about" onClick={(e) => handleNavClick('about', e)}>{t.about}</a>
<a href="#contact" onClick={(e) => handleNavClick('contact', e)}>{t.contact}</a>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;
+204
View File
@@ -0,0 +1,204 @@
import { FiX, FiMoon, FiSun, FiCoffee, FiWind } from 'react-icons/fi';
import { type Language, t } from '../translations';
interface Props {
language: Language;
setLanguage: (lang: Language) => void;
theme: 'dark' | 'light';
setTheme: (theme: 'dark' | 'light') => void;
windUnit: 'kmh' | 'ms';
setWindUnit: (unit: 'kmh' | 'ms') => void;
onClose: () => void;
}
const SettingsModal = ({ language, setLanguage, theme, setTheme, windUnit, setWindUnit, onClose }: Props) => {
const dict = t[language].settings;
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000,
backdropFilter: 'blur(4px)'
}}>
<div style={{
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '1rem',
padding: '2rem',
width: '90%',
maxWidth: '400px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)'
}}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', margin: 0 }}>{dict.title}</h2>
<button
onClick={onClose}
style={{
background: 'transparent', border: 'none', color: 'var(--text-muted)',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0.5rem', borderRadius: '50%'
}}
>
<FiX size={24} />
</button>
</div>
{/* Theme Setting */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
{dict.theme}
</label>
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={() => setTheme('dark')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: theme === 'dark' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: theme === 'dark' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: theme === 'dark' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiMoon /> {dict.dark}
</button>
<button
onClick={() => setTheme('light')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: theme === 'light' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: theme === 'light' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: theme === 'light' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiSun /> {dict.light}
</button>
</div>
</div>
{/* Language Setting */}
<div style={{ marginBottom: '2rem' }}>
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
{dict.language}
</label>
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={() => setLanguage('en')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: language === 'en' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: language === 'en' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: language === 'en' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<span style={{ fontSize: '1.2rem', lineHeight: 1 }}>🇬🇧</span> {dict.english}
</button>
<button
onClick={() => setLanguage('cs')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: language === 'cs' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: language === 'cs' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: language === 'cs' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<span style={{ fontSize: '1.2rem', lineHeight: 1 }}>🇨🇿</span> {dict.czech}
</button>
</div>
</div>
{/* Wind Units Setting */}
<div style={{ marginBottom: '2rem' }}>
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
{dict.windUnits}
</label>
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={() => setWindUnit('kmh')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: windUnit === 'kmh' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: windUnit === 'kmh' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: windUnit === 'kmh' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiWind /> {dict.windUnitKmh}
</button>
<button
onClick={() => setWindUnit('ms')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: windUnit === 'ms' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: windUnit === 'ms' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: windUnit === 'ms' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiWind /> {dict.windUnitMs}
</button>
</div>
</div>
{/* Contact */}
<div style={{ marginBottom: '2rem' }}>
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
{dict.contact}
</label>
<a
href="mailto:info@hladinator.cz"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
color: 'var(--color-cyan)',
fontSize: '0.95rem',
textDecoration: 'none',
fontWeight: 500,
opacity: 0.9,
transition: 'opacity 0.2s'
}}
onMouseOver={(e) => e.currentTarget.style.opacity = '1'}
onMouseOut={(e) => e.currentTarget.style.opacity = '0.9'}
>
info@hladinator.cz
</a>
</div>
{/* Buy me a coffee */}
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '1.5rem', textAlign: 'center' }}>
<a
href="https://buymeacoffee.com/"
target="_blank"
rel="noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
padding: '0.75rem 1.5rem', borderRadius: '2rem',
backgroundColor: '#FFDD00', color: '#000000', fontWeight: 'bold',
textDecoration: 'none', transition: 'transform 0.2s'
}}
onMouseOver={(e) => e.currentTarget.style.transform = 'scale(1.05)'}
onMouseOut={(e) => e.currentTarget.style.transform = 'scale(1)'}
>
<FiCoffee size={20} />
{dict.buyCoffee}
</a>
</div>
</div>
</div>
);
};
export default SettingsModal;
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { FiDroplet, FiStar, FiMap, FiSettings, FiChevronLeft, FiChevronRight, FiDatabase } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { useFavorites } from '../hooks/useFavorites';
interface Props {
language: Language;
onOpenSettings: () => void;
isMobileMenuOpen?: boolean;
onCloseMobileMenu?: () => void;
}
const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu }: Props) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const dict = t[language].sidebar;
const { favorites } = useFavorites();
const isOverview = location.pathname === '/';
const isFavoritesPage = location.pathname === '/favorites';
const isMap = location.pathname === '/map';
const handleNavigate = (path: string) => {
navigate(path);
if (onCloseMobileMenu) onCloseMobileMenu();
};
return (
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
<div className="sidebar-logo">
<FiDroplet size={28} color="var(--color-cyan)" />
<div className="sidebar-text">
<span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.1rem' }}>HLADINATOR</span>
<small>v1.0</small>
</div>
</div>
{/* Toggle Button */}
<div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '1.5rem', marginTop: isCollapsed ? '1rem' : '-0.5rem' }}>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
style={{
background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'var(--text-main)',
borderRadius: '50%', width: '32px', height: '32px', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', zIndex: 10, boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
}}
>
{isCollapsed ? <FiChevronRight size={18} /> : <FiChevronLeft size={18} />}
</button>
</div>
<div className="nav-links">
{/* Favourites */}
<div className={`nav-item ${isFavoritesPage ? 'active' : ''}`} onClick={() => handleNavigate('/favorites')} style={{ position: 'relative' }}>
<div style={{ position: 'relative', display: 'flex' }}>
<FiStar fill={favorites.length > 0 ? '#f59e0b' : 'none'} color={favorites.length > 0 ? '#f59e0b' : 'currentColor'} />
{favorites.length > 0 && (
<span style={{
position: 'absolute',
top: '-8px',
right: '-12px',
backgroundColor: '#f59e0b',
color: '#000',
borderRadius: '999px',
fontSize: '0.65rem',
fontWeight: 'bold',
minWidth: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
border: '2px solid var(--bg-card)'
}}>
{favorites.length}
</span>
)}
</div>
<span className="sidebar-text">{dict.favorites}</span>
</div>
{/* Lakes & Reservoirs */}
<div className={`nav-item ${isOverview ? 'active' : ''}`} onClick={() => handleNavigate('/')}>
<FiDatabase />
<span className="sidebar-text">{dict.lakes}</span>
</div>
{/* Map */}
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
<FiMap />
<span className="sidebar-text">{dict.map}</span>
</div>
</div>
<div className="sidebar-footer">
<div className="nav-item" onClick={onOpenSettings}>
<FiSettings />
<span className="sidebar-text">{dict.settings}</span>
</div>
</div>
</div>
);
};
export default Sidebar;
-162
View File
@@ -1,162 +0,0 @@
.timer-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 500px;
margin: 0 auto;
padding: 2rem;
background-color: rgba(30, 32, 36, 0.5);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
}
.timer-display {
font-size: 5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin: 2rem 0;
padding: 1rem 2rem;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.2);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.timer-display.time-up {
background-color: rgba(244, 67, 54, 0.2);
color: #f44336;
animation: flash 1s infinite;
border: 1px solid rgba(244, 67, 54, 0.5);
}
@keyframes flash {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.controls-section {
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
}
.slider-container {
width: 100%;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.time-slider {
-webkit-appearance: none;
width: 100%;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
outline: none;
cursor: pointer;
}
.time-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent-color);
cursor: pointer;
transition: transform 0.2s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.time-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
background: #747bff;
}
.time-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent-color);
cursor: pointer;
border: none;
transition: transform 0.2s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.time-slider::-moz-range-thumb:hover {
transform: scale(1.1);
background: #747bff;
}
.slider-labels {
display: flex;
justify-content: space-between;
color: var(--secondary-color);
font-size: 0.8rem;
font-weight: 500;
}
.main-controls {
display: flex;
justify-content: center;
gap: 1.5rem;
}
.control-btn {
min-width: 120px;
font-size: 1.1rem;
padding: 0.8rem 2rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.start-btn {
background-color: var(--accent-color);
color: white;
box-shadow: 0 4px 15px rgba(100, 108, 255, 0.3);
}
.start-btn:hover {
background-color: #747bff;
box-shadow: 0 6px 20px rgba(100, 108, 255, 0.4);
transform: translateY(-2px);
}
.reset-btn {
background-color: transparent;
border: 1px solid var(--secondary-color);
color: var(--secondary-color);
}
.reset-btn:hover {
border-color: var(--text-color);
color: var(--text-color);
background-color: rgba(255, 255, 255, 0.05);
}
@media (max-width: 480px) {
.timer-display {
font-size: 3.5rem;
}
.control-btn {
min-width: 100px;
padding: 0.7rem 1.5rem;
font-size: 1rem;
}
}
-163
View File
@@ -1,163 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { translations, type Language } from '../translations';
import './TimeBreaker.css';
interface TimeBreakerProps {
language: Language;
}
const DEFAULT_TIME_MINUTES = 22;
const MAX_TIME_MINUTES = 180;
const SECONDS_PER_MINUTE = 60;
const TimeBreaker: React.FC<TimeBreakerProps> = ({ language }) => {
const t = translations[language];
const [timeLeft, setTimeLeft] = useState(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
const [isRunning, setIsRunning] = useState(false);
const [isTimeUp, setIsTimeUp] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
const intervalRef = useRef<number | null>(null);
// Format time as MM:SS
const formatTime = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
// Update document title
useEffect(() => {
document.title = `${formatTime(timeLeft)} - ${t.timeBreaker}`;
return () => {
document.title = 'David Fencl - IT Consulting';
};
}, [timeLeft, t.timeBreaker]);
// Timer logic
useEffect(() => {
if (isRunning && timeLeft > 0) {
intervalRef.current = window.setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
handleTimeUp();
return 0;
}
return prev - 1;
});
}, 1000);
} else if (timeLeft === 0 && isRunning) {
handleTimeUp();
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isRunning]);
const handleTimeUp = () => {
setIsRunning(false);
setIsTimeUp(true);
playAlarm();
// Play alarm 3 times
setTimeout(playAlarm, 1000);
setTimeout(playAlarm, 2000);
};
const playAlarm = () => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const ctx = audioContextRef.current;
if (!ctx) return;
// Create oscillator
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.5); // Drop to A4
gainNode.gain.setValueAtTime(0.5, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.start();
oscillator.stop(ctx.currentTime + 0.5);
};
const handleStart = () => {
if (timeLeft === 0) return;
setIsRunning(true);
setIsTimeUp(false);
};
const handlePause = () => {
setIsRunning(false);
};
const handleReset = () => {
setIsRunning(false);
setIsTimeUp(false);
setTimeLeft(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
};
return (
<div className="home-container fade-in">
<section className="hero">
<div className="timer-container">
<h1 style={{ marginBottom: '1rem' }}>{t.timeBreaker}</h1>
<div className={`timer-display ${isTimeUp ? 'time-up' : ''}`}>
{formatTime(timeLeft)}
</div>
<div className="controls-section">
<div className="slider-container">
<input
type="range"
min="0"
max={MAX_TIME_MINUTES}
value={Math.ceil(timeLeft / 60)}
onChange={(e) => {
const minutes = parseInt(e.target.value, 10);
setTimeLeft(minutes * 60);
if (minutes > 0) setIsTimeUp(false);
}}
className="time-slider"
/>
<div className="slider-labels">
<span>0m</span>
<span>{MAX_TIME_MINUTES}m</span>
</div>
</div>
<div className="main-controls">
{!isRunning ? (
<button className="btn control-btn start-btn" onClick={handleStart}>
{timeLeft > 0 && timeLeft < DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE ? 'Resume' : 'Start'}
</button>
) : (
<button className="btn control-btn start-btn" onClick={handlePause}>
Pause
</button>
)}
<button className="btn control-btn reset-btn" onClick={handleReset}>
Reset
</button>
</div>
</div>
</div>
</section>
</div>
);
};
export default TimeBreaker;
+36
View File
@@ -0,0 +1,36 @@
import { FiSearch, FiMenu, FiDroplet } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { useLocation } from 'react-router-dom';
interface Props {
language: Language;
onToggleMobileMenu?: () => void;
}
const Topbar = ({ language, onToggleMobileMenu }: Props) => {
const dict = t[language].topbar;
const location = useLocation();
const showSearch = location.pathname === '/' || location.pathname === '/favorites';
return (
<div className="topbar">
<div className="topbar-mobile-header">
<FiMenu onClick={onToggleMobileMenu} className="mobile-only" style={{ fontSize: '1.5rem', cursor: 'pointer' }} />
<div className="mobile-only" style={{ alignItems: 'center', gap: '0.5rem', fontWeight: 'bold', fontSize: '1.25rem' }}>
<FiDroplet color="var(--color-cyan)" />
<span>Hladinator</span>
</div>
{showSearch && (
<div className="search-bar">
<FiSearch />
<input type="text" placeholder={dict.search} />
</div>
)}
</div>
</div>
);
};
export default Topbar;
+144
View File
@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react';
import { FiWind, FiSunrise, FiSunset, FiThermometer, FiAlertCircle } from 'react-icons/fi';
interface WeatherProps {
lat: number;
lng: number;
language: 'cs' | 'en';
sensorTemp?: number;
windUnit?: 'kmh' | 'ms';
}
interface WeatherData {
temp: number;
windSpeed: number; // m/s
windGusts: number; // m/s
windDir: number; // degrees
sunrise: string;
sunset: string;
}
const getCompassDirection = (degrees: number, language: 'cs' | 'en') => {
const directionsEn = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const directionsCs = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const directions = language === 'cs' ? directionsCs : directionsEn;
const index = Math.round(((degrees %= 360) < 0 ? degrees + 360 : degrees) / 45) % 8;
return directions[index];
};
const formatTime = (isoString: string) => {
if (!isoString) return '--:--';
const date = new Date(isoString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh' }: WeatherProps) => {
const [data, setData] = useState<WeatherData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
if (!lat || !lng) {
setLoading(false);
setError(true);
return;
}
const fetchWeather = async () => {
try {
setLoading(true);
const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m&daily=sunrise,sunset&timezone=auto&wind_speed_unit=${windUnit}`);
if (!res.ok) throw new Error('Failed to fetch weather');
const json = await res.json();
setData({
temp: json.current.temperature_2m,
windSpeed: json.current.wind_speed_10m,
windGusts: json.current.wind_gusts_10m,
windDir: json.current.wind_direction_10m,
sunrise: json.daily.sunrise[0],
sunset: json.daily.sunset[0]
});
setError(false);
} catch (err) {
console.error('Weather fetch error:', err);
setError(true);
} finally {
setLoading(false);
}
};
fetchWeather();
// Refresh weather every 15 minutes
const interval = setInterval(fetchWeather, 15 * 60 * 1000);
return () => clearInterval(interval);
}, [lat, lng]);
const dict = {
cs: { title: 'Počasí a Vítr (Aktuálně)', error: 'Data nedostupná', wind: 'Vítr', gusts: 'Nárazy', temp: 'Teplota' },
en: { title: 'Weather & Wind (Current)', error: 'Data unavailable', wind: 'Wind', gusts: 'Gusts', temp: 'Temp' }
}[language];
if (loading) {
return <div className="kpi-card" style={{ minHeight: '120px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>Loading weather...</div>;
}
if (error || !data) {
return (
<div className="kpi-card" style={{ opacity: 0.7 }}>
<h3 style={{ fontSize: '1rem', color: 'var(--text-muted)', margin: '0 0 0.5rem 0' }}>{dict.title}</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--color-red)' }}>
<FiAlertCircle /> {dict.error}
</div>
</div>
);
}
return (
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>{dict.title}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '1rem', alignItems: 'center' }}>
{/* Left Column: Wind */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
<div style={{
width: '40px', height: '40px', borderRadius: '50%', backgroundColor: 'rgba(0, 195, 255, 0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--color-cyan)', fontSize: '1.2rem',
transform: `rotate(${data.windDir}deg)`
}} title={`Wind direction: ${data.windDir}°`}>
<FiWind style={{ transform: 'rotate(-90deg)' }} /> {/* Assume icon points UP by default, wind from south (180) should point UP. Arrow should point where wind is GOING. */}
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', lineHeight: 1.1, color: 'var(--text-main)', whiteSpace: 'nowrap' }}>
{data.windSpeed.toFixed(1)} <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'} {getCompassDirection(data.windDir, language)}</span>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '4px', whiteSpace: 'nowrap' }}>
{dict.gusts}: <span style={{ color: data.windGusts > (windUnit === 'kmh' ? 50 : 13.8) ? 'var(--color-red)' : 'var(--text-main)' }}>{data.windGusts.toFixed(1)} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</span>
</div>
</div>
</div>
{/* Right Column: Other Info */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', borderLeft: '1px solid var(--border-color)', paddingLeft: '1rem', whiteSpace: 'nowrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem' }} title={sensorTemp !== undefined ? (language === 'cs' ? 'Měřeno přímo senzorem na hrázi' : 'Measured by sensor at the dam') : 'OpenMeteo API'}>
<FiThermometer color="var(--color-orange)" />
<span style={{ fontWeight: 'bold' }}>{sensorTemp !== undefined ? sensorTemp.toFixed(1) : data.temp.toFixed(1)} °C</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
<FiSunrise color="#f59e0b" />
<span>{formatTime(data.sunrise)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
<FiSunset color="#f59e0b" />
<span>{formatTime(data.sunset)}</span>
</div>
</div>
</div>
</div>
);
};
+273
View File
@@ -0,0 +1,273 @@
import { useState, useEffect } from 'react';
import { ComposedChart, Line, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { FiWind } from 'react-icons/fi';
import { type Language } from '../translations';
interface WindChartProps {
lat: number;
lng: number;
language: Language;
timeRange?: '24h' | '7d' | '30d' | '1y' | 'all';
windUnit?: 'kmh' | 'ms';
}
interface WindDataPoint {
time: string;
speed: number;
gusts: number;
dir: number;
dirStr: string;
}
const getCompassDirection = (degrees: number, language: 'cs' | 'en') => {
const directionsEn = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const directionsCs = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const directions = language === 'cs' ? directionsCs : directionsEn;
const index = Math.round(((degrees %= 360) < 0 ? degrees + 360 : degrees) / 45) % 8;
return directions[index];
};
const CustomWindTooltip = ({ active, payload, label, language, windUnit = 'kmh' }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const date = new Date(label);
const dateStr = date.toLocaleDateString(language === 'cs' ? 'cs-CZ' : 'en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
const timeStr = date.toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' });
return (
<div style={{ backgroundColor: 'rgba(30, 41, 59, 0.95)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '12px', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.5)', color: 'var(--text-main)', fontSize: '0.9rem', zIndex: 100 }}>
<div style={{ fontWeight: 'bold', marginBottom: '8px', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '4px' }}>
{dateStr} {timeStr}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: 'var(--color-cyan)', fontSize: '1.2rem' }}></span>
<span>{language === 'cs' ? 'Rychlost větru' : 'Wind Speed'}: <strong>{data.speed} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</strong></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: 'var(--color-purple)', fontSize: '1.2rem' }}></span>
<span>{language === 'cs' ? 'Nárazy větru' : 'Wind Gusts'}: <strong>{data.gusts} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</strong></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '4px', color: 'var(--text-muted)' }}>
<FiWind />
<span>{language === 'cs' ? 'Směr' : 'Direction'}: <strong>{data.dirStr} ({data.dir}°)</strong></span>
</div>
</div>
</div>
);
}
return null;
};
const CustomWindDot = (props: any) => {
const { cx, cy, payload } = props;
if (!cx || !cy || payload.dir === undefined) return null;
return (
<g transform={`translate(${cx},${cy}) rotate(${payload.dir}) scale(1.5)`}>
<path
d="M0,-6 L-4,4 L0,2 L4,4 Z"
fill="var(--color-cyan)"
stroke="#1e293b"
strokeWidth={1}
/>
</g>
);
};
export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'kmh' }: WindChartProps) => {
const [data, setData] = useState<WindDataPoint[]>([]);
const [loading, setLoading] = useState(true);
const [currentSpeed, setCurrentSpeed] = useState(0);
const [maxGust, setMaxGust] = useState(0);
useEffect(() => {
const fetchWind = async () => {
try {
setLoading(true);
let url = '';
let isDaily = false;
if (timeRange === '1y' || timeRange === 'all') {
isDaily = true;
const end = new Date();
end.setDate(end.getDate() - 1);
const endStr = end.toISOString().split('T')[0];
const start = new Date();
if (timeRange === '1y') {
start.setDate(start.getDate() - 365);
} else {
start.setFullYear(2020);
}
const startStr = start.toISOString().split('T')[0];
url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=wind_speed_10m_max,wind_gusts_10m_max,wind_direction_10m_dominant&wind_speed_unit=${windUnit}&timezone=auto`;
} else {
let pastDays = 7;
if (timeRange === '24h') pastDays = 1;
if (timeRange === '30d') pastDays = 30;
url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&hourly=wind_speed_10m,wind_gusts_10m,wind_direction_10m&past_days=${pastDays}&forecast_days=1&wind_speed_unit=${windUnit}&timezone=auto`;
}
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch wind data');
const json = await res.json();
const times = isDaily ? json.daily.time : json.hourly.time;
const speeds = isDaily ? json.daily.wind_speed_10m_max : json.hourly.wind_speed_10m;
const gusts = isDaily ? json.daily.wind_gusts_10m_max : json.hourly.wind_gusts_10m;
const dirs = isDaily ? json.daily.wind_direction_10m_dominant : json.hourly.wind_direction_10m;
const chartData: WindDataPoint[] = [];
let maxG = 0;
const now = new Date();
let closestIdx = 0;
let minDiff = Infinity;
for (let i = 0; i < times.length; i++) {
const t = new Date(times[i]);
const diff = Math.abs(t.getTime() - now.getTime());
if (diff < minDiff) {
minDiff = diff;
closestIdx = i;
}
if (t.getTime() <= now.getTime() || isDaily) {
if (gusts[i] > maxG) maxG = gusts[i];
chartData.push({
time: times[i],
speed: speeds[i] || 0,
gusts: gusts[i] || 0,
dir: dirs[i] || 0,
dirStr: getCompassDirection(dirs[i] || 0, language)
});
}
}
let downsampleFactor = 1;
if (timeRange === '7d') downsampleFactor = 3;
if (timeRange === '30d') downsampleFactor = 12;
if (timeRange === '1y') downsampleFactor = 3;
if (timeRange === 'all') downsampleFactor = 14;
const downsampled = chartData.filter((_, i) => i % downsampleFactor === 0 || i === chartData.length - 1);
setData(downsampled);
setMaxGust(maxG);
setCurrentSpeed(speeds[closestIdx] || speeds[speeds.length - 1] || 0);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
if (lat && lng) {
fetchWind();
}
}, [lat, lng, language, timeRange]);
if (loading) {
return (
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '200px', color: 'var(--text-muted)' }}>
{language === 'cs' ? 'Načítám data o větru...' : 'Loading wind data...'}
</div>
);
}
if (data.length === 0) return null;
return (
<div style={{ marginTop: '3rem', paddingTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FiWind style={{ color: 'var(--color-cyan)' }} />
{language === 'cs' ? `Aktivita větru (${timeRange === '1y' || timeRange === 'all' ? 'denní maxima' : timeRange})` : `Wind Activity (${timeRange === '1y' || timeRange === 'all' ? 'daily max' : timeRange})`}
</h3>
<div style={{ display: 'flex', gap: '1.5rem' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{language === 'cs' ? 'Aktuální rychlost' : 'Current Speed'}</span>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.25rem' }}>
<span style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--text-main)' }}>{currentSpeed.toFixed(1)}</span>
<span style={{ fontSize: '0.9rem', color: 'var(--color-cyan)' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{language === 'cs' ? 'Max. nárazy' : 'Peak Gusts'}</span>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.25rem' }}>
<span style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--text-main)' }}>{maxGust.toFixed(1)}</span>
<span style={{ fontSize: '0.9rem', color: 'var(--color-purple)' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'}</span>
</div>
</div>
</div>
</div>
<div style={{ flex: 1, minHeight: '280px', width: '100%', marginTop: '0.5rem' }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 20, right: 0, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorWind" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.4}/>
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis
dataKey="time"
stroke="var(--text-muted)"
tick={{fill: 'var(--text-muted)', fontSize: 11}}
minTickGap={60}
tickFormatter={(v) => {
const d = new Date(v);
return `${d.getDate()}.${d.getMonth()+1}.`;
}}
/>
<YAxis
stroke="var(--text-muted)"
tick={{fill: 'var(--text-muted)', fontSize: 11}}
/>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<Tooltip content={<CustomWindTooltip language={language} windUnit={windUnit} />} />
<Area
type="monotone"
dataKey="speed"
stroke="var(--color-cyan)"
strokeWidth={2}
fillOpacity={1}
fill="url(#colorWind)"
isAnimationActive={true}
dot={<CustomWindDot />}
activeDot={{ r: 6, fill: 'var(--color-cyan)', stroke: '#1e293b', strokeWidth: 2 }}
/>
<Line
type="monotone"
dataKey="gusts"
stroke="var(--color-purple)"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
isAnimationActive={true}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1.5rem', fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ display: 'inline-block', width: '12px', height: '3px', backgroundColor: 'var(--color-cyan)' }}></span>
<span>{language === 'cs' ? 'Rychlost větru' : 'Wind Speed'}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ display: 'inline-block', width: '12px', height: '3px', borderTop: '2px dashed var(--color-purple)' }}></span>
<span>{language === 'cs' ? 'Nárazy větru' : 'Wind Gusts'}</span>
</div>
</div>
</div>
);
};
@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import KpiCards from '../KpiCards';
describe('KpiCards Component', () => {
const mockData = {
level: 723.10,
inflow: 5.5,
outflow: 2.5,
volume: 150,
fullness: 80,
storageDiff: -1.81
};
it('renders correctly with negative storageDiff (red)', () => {
render(<KpiCards data={mockData} language="cs" />);
// STORAGE SPACE card should show -1.81 m
expect(screen.getByText('-1.81 m')).toBeInTheDocument();
// Because it is negative, it should have the red color style applied
const diffElement = screen.getByText('-1.81 m');
expect(diffElement.parentElement?.outerHTML).toContain('var(--color-red)');
});
it('renders correctly with positive storageDiff (cyan)', () => {
const positiveData = { ...mockData, storageDiff: 0.5 };
render(<KpiCards data={positiveData} language="cs" />);
expect(screen.getByText('+0.50 m')).toBeInTheDocument();
const diffElement = screen.getByText('+0.50 m');
expect(diffElement.parentElement?.outerHTML).toContain('var(--color-cyan)');
});
it('falls back to percentage fullness if storageDiff is zero/undefined', () => {
const noDiffData = { ...mockData, storageDiff: 0, fullness: 85.5 };
render(<KpiCards data={noDiffData} language="cs" />);
const elements = screen.getAllByText('85.5%');
expect(elements.length).toBeGreaterThan(0);
expect(elements[0]).toBeInTheDocument();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
const STORAGE_KEY = 'hladinator_favorites';
const loadFavorites = (): string[] => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
interface FavoritesContextType {
favorites: string[];
toggleFavorite: (id: string) => void;
isFavorite: (id: string) => boolean;
}
const FavoritesContext = createContext<FavoritesContextType>({
favorites: [],
toggleFavorite: () => {},
isFavorite: () => false,
});
export const FavoritesProvider = ({ children }: { children: ReactNode }) => {
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
const toggleFavorite = useCallback((id: string) => {
setFavorites(prev => {
const next = prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id];
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
return next;
});
}, []);
const isFavorite = useCallback((id: string) => favorites.includes(id), [favorites]);
return (
<FavoritesContext.Provider value={{ favorites, toggleFavorite, isFavorite }}>
{children}
</FavoritesContext.Provider>
);
};
export const useFavorites = () => useContext(FavoritesContext);
+235 -317
View File
@@ -1,366 +1,284 @@
:root {
--bg-color: #0f0f0f;
--text-color: #f0f0f0;
--accent-color: #646cff;
--secondary-color: #a0a0a0;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: var(--text-color);
background-color: var(--bg-color);
scroll-behavior: smooth;
}
/* Colors based on HLADINATOR design */
--bg-dark: #1e293b; /* Unified lighter navy background */
--bg-card: #1e293b; /* Card/Panel background */
--bg-card-hover: #334155;
--text-main: #f8fafc; /* White text */
--text-muted: #94a3b8; /* Gray text */
html {
scroll-padding-top: 100px;
/* Offset for sticky header */
}
--color-cyan: #06b6d4; /* Water level / Primary */
--color-green: #22c55e; /* Inflow / Positive trend */
--color-red: #ef4444; /* Outflow / Negative trend */
--color-orange: #f97316; /* Outflow line chart color */
--color-purple: #a855f7; /* Wind gusts line color */
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
.kpi-grid-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
width: 100%;
margin: 0 auto;
text-align: center;
}
/* Loading Screen */
.loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-color);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
transition: opacity 0.5s ease-out;
}
.loading-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.loader-content {
display: flex;
flex-direction: column;
align-items: center;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-left-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
.loading-text {
font-size: 1.5rem;
letter-spacing: 2px;
text-transform: uppercase;
animation: pulse 2s infinite;
}
/* Home */
.home-container {
width: 100%;
min-height: 100vh;
.kpi-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
position: relative;
display: flex;
flex-direction: column;
}
.fade-in {
animation: fadeIn 1s ease-in;
}
.navbar {
position: sticky;
top: 0;
z-index: 100;
background-color: rgba(15, 15, 15, 0.85);
backdrop-filter: blur(10px);
display: flex;
justify-content: center;
padding: 0.8rem 8rem;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.nav-content {
width: 100%;
max-width: 1200px;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-logo {
height: 120px;
width: auto;
border-radius: 4px;
}
.links a {
margin-left: 2rem;
text-decoration: none;
color: var(--secondary-color);
transition: color 0.3s;
}
.links a:hover {
color: var(--accent-color);
}
.hero {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 4rem 8rem;
padding-top: 10vh;
text-align: left;
}
.hero-content {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
width: 100%;
max-width: 1200px;
gap: 4rem;
}
.hero-text {
flex: 1;
/* Hide old styles but keep them in case they are used elsewhere */
.kpi-container-mobile {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.welcome-text {
font-size: 0.9rem;
letter-spacing: 3px;
color: var(--secondary-color);
text-transform: uppercase;
margin-bottom: 1rem;
.kpi-card-full {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 1.5rem;
width: 100%;
}
.hero h1 {
font-size: 3.5rem;
margin: 0;
line-height: 1.2;
font-weight: 700;
}
.highlight {
color: var(--accent-color);
}
.job-title {
font-size: 3.5rem;
margin: 0;
line-height: 1.2;
font-weight: 700;
color: var(--text-color);
}
.description {
font-size: 1rem;
line-height: 1.8;
color: var(--secondary-color);
max-width: 600px;
margin-top: 1rem;
}
.hero-footer {
.kpi-row-half {
display: flex;
justify-content: space-between;
margin-top: 4rem;
gap: 2rem;
flex-wrap: wrap;
gap: 1rem;
width: 100%;
}
.footer-label {
display: block;
font-size: 0.8rem;
letter-spacing: 2px;
color: var(--secondary-color);
margin-bottom: 1rem;
text-transform: uppercase;
.kpi-card-half {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 1.5rem;
flex: 1;
}
.icon-group {
/* Map View Styles */
.map-view-container {
position: relative;
width: 100%;
height: calc(100vh - 100px);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.map-overlay-panel {
position: absolute;
top: 10px;
right: 10px;
width: 350px;
max-height: calc(100% - 20px);
background-color: rgba(16, 22, 34, 0.95);
backdrop-filter: blur(10px);
border: 1px solid var(--border-color);
border-radius: 12px;
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.map-overlay-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.map-overlay-list {
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.icon-btn {
width: 54px;
height: 54px;
background: linear-gradient(145deg, #1e2024, #23272b);
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
border: none;
border-radius: 6px;
color: var(--text-color);
font-weight: bold;
.map-lake-card {
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.3rem;
transition: all 0.2s;
}
.icon-btn:hover {
background: linear-gradient(145deg, #23272b, #1e2024);
.map-lake-card:hover {
background-color: rgba(255, 255, 255, 0.08);
transform: translateY(-2px);
color: var(--accent-color);
}
.hero-image-container {
flex: 0.8;
display: flex;
justify-content: center;
align-items: center;
}
.hero-image-placeholder {
.map-lake-image {
width: 100%;
aspect-ratio: 3/4;
background: linear-gradient(145deg, #1e2024, #23272b);
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
border-radius: 20px;
height: 120px;
object-fit: cover;
border-radius: 6px;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
background-color: #2a3441;
}
.map-lake-stats {
display: flex;
justify-content: center;
align-items: center;
color: var(--secondary-color);
font-size: 1.2rem;
border: 1px solid rgba(255, 255, 255, 0.05);
justify-content: space-between;
font-size: 0.85rem;
color: var(--text-muted);
}
.nav-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.language-switcher {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
color: var(--secondary-color);
}
.lang-separator {
opacity: 0.5;
}
.lang-btn {
background: none;
/* Custom Leaflet Marker */
.custom-div-icon {
background: transparent;
border: none;
font-size: 0.9rem;
font-weight: 600;
}
.map-marker-icon {
width: 36px;
height: 36px;
background-color: var(--color-cyan);
color: #000;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid white;
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
cursor: pointer;
opacity: 0.5;
transition: opacity 0.3s, color 0.3s;
padding: 0;
color: var(--secondary-color);
}
.lang-btn:hover {
opacity: 1;
color: var(--accent-color);
}
.lang-btn.active {
opacity: 1;
color: var(--text-color);
.map-marker-icon::after {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid white;
}
@media (max-width: 1024px) {
.hero-content {
flex-direction: column-reverse;
align-items: center;
text-align: center;
}
.hero-text {
align-items: center;
}
.hero-footer {
justify-content: center;
}
.hero-image-container {
width: 80%;
margin-bottom: 2rem;
}
}
.section {
padding: 4rem;
}
.about {
background-color: #1a1a1a;
}
/* Animations */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0% {
opacity: 0.5;
}
50% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
.kpi-grid-container {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.navbar {
padding: 1rem;
.map-overlay-panel {
top: auto;
bottom: 0;
right: 0;
width: 100%;
max-height: 50%;
border-radius: 20px 20px 0 0;
}
.hero h1 {
font-size: 2.5rem;
.kpi-grid-container {
grid-template-columns: 1fr;
}
}
/* Time controls pill layout */
.top-time-controls {
display: flex;
background-color: var(--bg-card);
border-radius: 0.5rem;
border: 1px solid var(--border-color);
overflow: hidden;
width: auto;
}
.top-time-controls button {
flex: 1;
background: transparent;
border: none;
border-right: 1px solid var(--border-color);
color: var(--text-muted);
padding: 0.4rem 1rem;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
font-size: 0.85rem;
}
.top-time-controls button:last-child {
border-right: none;
}
.top-time-controls button.active {
background-color: var(--color-cyan);
color: white;
}
--border-color: rgba(255, 255, 255, 0.05);
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
}
body.light-mode {
--bg-dark: #f1f5f9; /* Unified light background */
--bg-card: #f1f5f9; /* Card/Panel background */
--bg-card-hover: #e2e8f0;
--text-main: #0f172a; /* Dark navy text */
--text-muted: #64748b; /* Muted gray text */
--border-color: rgba(0, 0, 0, 0.1);
/* Slightly darker graph colors for white background */
--color-cyan: #0891b2;
--color-green: #16a34a;
--color-red: #dc2626;
--color-orange: #ea580c;
background-color: var(--bg-dark);
color: var(--text-main);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-weight: 400;
color-scheme: dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background-color: var(--bg-dark);
}
a {
color: inherit;
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;
}
+10 -1
View File
@@ -1,10 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { FavoritesProvider } from './hooks/useFavorites'
import { HelmetProvider } from 'react-helmet-async'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<HelmetProvider>
<BrowserRouter>
<FavoritesProvider>
<App />
</FavoritesProvider>
</BrowserRouter>
</HelmetProvider>
</StrictMode>,
)
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+125 -33
View File
@@ -1,38 +1,130 @@
export type Language = 'en' | 'cs';
export const translations = {
en: {
home: 'Home',
about: 'About',
contact: 'Contact',
timeBreaker: 'Time-Breaker',
welcome: 'WELCOME TO MY WORLD',
hello: "Hello, I'm",
job: 'a Developer.',
desc: "I use animation as a third dimension by which to simplify experiences and guiding through each and every interaction. I'm not adding motion just to spruce things up, but doing it in ways that matter.",
findMe: 'FIND WITH ME',
bestSkill: 'BEST SKILL ON',
aboutMe: 'About Me',
aboutDesc: 'I build accessible, pixel-perfect, and performant web experiences. Passionate about technology and design.',
getInTouch: 'Get In Touch',
contactDesc: 'Interested in working together?',
emailMe: 'Email me',
export const t = {
en: {
sidebar: {
favorites: 'Favorites',
lakes: 'Lakes & Reservoirs',
map: 'Map',
settings: 'Settings'
},
cs: {
home: 'Domů',
about: 'O mně',
contact: 'Kontakt',
timeBreaker: 'Time-Breaker',
welcome: 'VÍTEJTE V MÉM SVĚTĚ',
hello: "Ahoj, jsem",
job: 'Vývojář.',
desc: "Používám animaci jako třetí rozměr, kterým zjednodušuji zážitky a provázím každou interakcí. Nepřidávám pohyb jen pro efekt, ale dělám to způsoby, které mají smysl.",
findMe: 'NAJDETE MĚ NA',
bestSkill: 'DOVEDNOSTI',
aboutMe: 'O mně',
aboutDesc: 'Tvořím přístupné, pixel-perfect a výkonné webové zážitky. Vášnivý pro technologie a design.',
getInTouch: 'Napište mi',
contactDesc: 'Máte zájem o spolupráci?',
emailMe: 'Napište mi',
topbar: {
search: 'Search river or reservoir (e.g. Lipno)...',
updated: 'Last updated:'
},
seo: {
homeTitle: 'Hladinátor - Water levels and flow rates of reservoirs',
homeDesc: 'Track current water levels, flow rates, inflow, and weather development on major Czech dams and reservoirs in real time. Data sourced from official river basin authorities.',
lakeTitle: '{name} - Water level and flow | Hladinátor',
lakeDesc: 'Current water level and statistics for the {name} reservoir. Track water level, flow rate, wind strength, and storage capacity in real time.',
favoritesTitle: 'Favorites | Hladinátor',
favoritesDesc: 'Your pinned lakes and reservoirs. Track water level, flow rate, and weather development on major Czech dams and reservoirs.',
mapTitle: 'Map | Hladinátor',
mapDesc: 'Interactive map of all monitored lakes and reservoirs in the Czech Republic.'
},
kpi: {
level: 'WATER LEVEL',
flow: 'FLOW RATE',
inflow: 'Inflow',
outflow: 'Outflow',
fullness: 'STORAGE LEVEL',
volume: 'Volume'
},
chart: {
title: 'Long-term development',
timeframe: 'Timeframe',
timeframeMobile: 'Time',
view: 'View',
raw: 'Raw data',
smoothed: 'Smoothed',
calendar: 'Calendar',
all: 'All',
year: 'Year',
level: 'Water level',
inflow: 'Inflow',
outflow: 'Outflow',
maxLevel: 'Max retention level',
storageLevel: 'Storage space level',
dataSources: 'Data sources:',
createdIn: 'Created with ♥ in the Czech Republic'
},
settings: {
title: 'Settings',
theme: 'Theme',
dark: 'Dark',
light: 'Light',
language: 'Language',
english: 'English',
czech: 'Čeština',
windUnits: 'Wind units',
windUnitKmh: 'km/h',
windUnitMs: 'm/s',
contact: 'Contact',
contactPlaceholder: 'Your email address',
buyCoffee: 'Buy Me a Coffee'
}
},
cs: {
sidebar: {
favorites: 'Oblíbené',
lakes: 'Jezera a nádrže',
map: 'Mapa',
settings: 'Nastavení'
},
topbar: {
search: 'Hledat tok nebo nádrž (např. Lipno)...',
updated: 'Aktualizováno:'
},
seo: {
homeTitle: 'Hladinátor - Aktuální stav přehrad a nádrží',
homeDesc: 'Sledujte aktuální vodní stav, průtok, přítok a vývoj počasí na nejvýznamnějších českých přehradách v reálném čase. Oficiální data z povodí.',
lakeTitle: '{name} - Stav hladiny a průtok | Hladinátor',
lakeDesc: 'Aktuální vodní stav a statistiky pro vodní dílo {name}. Sledujte vývoj hladiny, sílu větru a kapacitu zásobního prostoru v reálném čase.',
favoritesTitle: 'Oblíbené | Hladinátor',
favoritesDesc: 'Vaše připnuté přehrady a nádrže. Sledujte aktuální vodní stav, průtok a vývoj počasí na vybraných českých přehradách.',
mapTitle: 'Mapa | Hladinátor',
mapDesc: 'Interaktivní mapa všech sledovaných přehrad a nádrží v České republice.'
},
kpi: {
level: 'HLADINA',
flow: 'PRŮTOK',
inflow: 'Přítok',
outflow: 'Odtok',
fullness: 'ZÁSOBNÍ PROSTOR',
volume: 'Objem'
},
chart: {
title: 'Dlouhodobý vývoj',
timeframe: 'Časové období',
timeframeMobile: 'Časové',
view: 'Zobrazení',
raw: 'Syrová data',
smoothed: 'Vyhlazená',
calendar: 'Kalendář',
all: 'Vše',
year: 'Rok',
level: 'Hladina',
inflow: 'Přítok',
outflow: 'Odtok',
maxLevel: 'Max. retenční hladina',
storageLevel: 'Hladina zásobního prostoru',
dataSources: 'Zdroje dat:',
createdIn: 'Vytvořeno s ♥ v České republice'
},
settings: {
title: 'Nastavení',
theme: 'Vzhled',
dark: 'Tmavý',
light: 'Světlý',
language: 'Jazyk',
english: 'English',
czech: 'Čeština',
windUnits: 'Jednotky větru',
windUnitKmh: 'km/h',
windUnitMs: 'm/s',
contact: 'Kontakt',
contactPlaceholder: 'Vaše e-mailová adresa',
buyCoffee: 'Kup mi kávu'
}
}
};
+90
View File
@@ -0,0 +1,90 @@
export interface NavigationLimit {
level: number;
labelCs: string;
labelEn: string;
type: 'danger' | 'warning';
}
export const NAVIGATION_LIMITS: Record<string, NavigationLimit[]> = {
// Orlík
'VLOR|2': [
{
level: 342.50,
labelCs: 'Minimální hladina pro lodní výtah Orlík',
labelEn: 'Minimum level for Orlík boat lift',
type: 'danger'
}
],
// Slapy
'VLSL|2': [
{
level: 266.50,
labelCs: 'Minimální hladina pro převoz lodí Slapy',
labelEn: 'Minimum level for Slapy boat transport',
type: 'danger'
}
],
// Lipno 1
'VLL1|1': [
{
level: 719.60,
labelCs: 'Ukončení značení plavební dráhy',
labelEn: 'End of navigation channel marking',
type: 'danger'
}
],
// Hněvkovice
'VLHN|1': [
{
level: 368.90,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
],
// Hracholusky
'MZHR|3': [
{
level: 351.10,
labelCs: 'Zkrácení zaručené plavební dráhy',
labelEn: 'Shortened guaranteed navigation channel',
type: 'warning'
}
],
// Kamýk
'VLKA|2': [
{
level: 283.60,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
],
// Vrané
'VLVE|2': [
{
level: 199.30,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
],
// Štěchovice
'VLST|2': [
{
level: 217.20,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
],
// Kořensko
'VLKO|1': [
{
level: 352.00,
labelCs: 'Minimální plavební hladina',
labelEn: 'Minimum navigation level',
type: 'danger'
}
]
};
+9
View File
@@ -0,0 +1,9 @@
export const slugify = (text: string) => {
return text
.split(' - ')[0] // "VD Lipno 1 - Vltava" -> "VD Lipno 1"
.replace(/^VD\s+/i, '') // Remove "VD " prefix -> "Lipno 1"
.normalize('NFD') // Decompose diacritics
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.toLowerCase()
.replace(/\s+/g, '-'); // Replace spaces with dashes -> "lipno-1"
};

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