Compare commits
17 Commits
main
..
a67a2247c3
| Author | SHA1 | Date | |
|---|---|---|---|
| a67a2247c3 | |||
| cf05e844d8 | |||
| 6395df1992 | |||
| 66021e001e | |||
| db1aadcc8d | |||
| dbb22e7972 | |||
| 6d77c20c84 | |||
| a3b3d40769 | |||
| 27551f9183 | |||
| b660f0f6c3 | |||
| 57e9bf12ca | |||
| 8193ce818a | |||
| 0030dca448 | |||
| 8d1fb5b28e | |||
| 5411bd16ff | |||
| 61a8af109c | |||
| a5bd4985d1 |
+31
-11
@@ -3,8 +3,8 @@ type: docker
|
|||||||
name: default
|
name: default
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
branch: [main]
|
branch: [ main ]
|
||||||
event: [push]
|
event: [ push ]
|
||||||
|
|
||||||
# Kill the default clone entirely
|
# Kill the default clone entirely
|
||||||
clone:
|
clone:
|
||||||
@@ -14,7 +14,7 @@ steps:
|
|||||||
- name: manual clone
|
- name: manual clone
|
||||||
image: alpine/git
|
image: alpine/git
|
||||||
environment:
|
environment:
|
||||||
GIT_ASKPASS: "echo" # prevent interactive prompts
|
GIT_ASKPASS: 'echo' # prevent interactive prompts
|
||||||
commands:
|
commands:
|
||||||
- git clone --depth=50 "https://fencl:5ece5d37cd3a9988a78983b10cede284e25717f8@git.internet-master.cz/fencl/davisfe.cz.git" .
|
- 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
|
# - git fetch origin +refs/heads/deployment:refs/remotes/origin/deployment
|
||||||
@@ -36,12 +36,32 @@ steps:
|
|||||||
|
|
||||||
- name: call-portainer-webhook
|
- name: call-portainer-webhook
|
||||||
image: curlimages/curl
|
image: curlimages/curl
|
||||||
environment:
|
|
||||||
PORTAINER_USER:
|
|
||||||
from_secret: PORTAINER_USER
|
|
||||||
PORTAINER_PASSWORD:
|
|
||||||
from_secret: PORTAINER_PASSWORD
|
|
||||||
PORTAINER_WEBHOOK:
|
|
||||||
from_secret: PORTAINER_WEBHOOK
|
|
||||||
commands:
|
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
|
||||||
@@ -1,73 +1,72 @@
|
|||||||
# React + TypeScript + Vite
|
# 🌊 HLADINATOR
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
HLADINATOR je interaktivní a vizuálně poutavá webová aplikace pro sledování aktuálního stavu a historie českých přehrad. Aplikace poskytuje přesná data o výšce hladiny, odtoku, přítoku, aktuálním objemu a navíc sbírá historii počasí (teploty a srážek) přímo od zdroje.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Zdroj dat: **Povodí Vltavy (pvl.cz)** a další povodí v ČR.
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
---
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
## 🚀 Jak spustit aplikaci lokálně
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
Aplikace je postavena na moderním stacku (React, Vite, TypeScript, Recharts, Leaflet). Pro její spuštění nepotřebuješ žádný složitý backend, data se čtou z předgenerovaných JSON souborů.
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
1. Nainstaluj závislosti (pokud jsi to ještě neudělal):
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
2. Spusť lokální vývojový server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
3. Otevři prohlížeč na adrese `http://localhost:5173`.
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
---
|
||||||
|
|
||||||
```js
|
## 🔄 Jak aktualizovat data (Scraping)
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
Povodí Vltavy neposkytuje standardní API pro historii srážek a teplot, ani nepodporuje přímé dotazy z klientského prohlížeče (kvůli CORS a bezpečnosti). Proto využíváme vlastní **scraper**.
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
Pro ruční stažení těch nejnovějších dat z webu povodí spusť v terminálu:
|
||||||
],
|
```bash
|
||||||
languageOptions: {
|
npm run data:update
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
Tento příkaz provede dvě věci:
|
||||||
|
1. `npm run scrape`: Otevře stránky povodí pro všech 12 přehrad, přečte tabulky s historickými měřeními a najde "Aktuální hodnoty", odkud vytáhne exaktní **přítok, objem, srážky a teplotu**. Tato data inteligentně sloučí s tvojí lokální databází (`public/data/*.json`). Pokud Povodí aktuálně počasí neposkytuje, skript zrecykluje tvou dřívější uloženou hodnotu, aby se graf "nerozbil".
|
||||||
|
2. `npm run build-index`: Zaktualizuje hlavní indexový soubor `lakes_index.json`, který aplikace využívá pro vykreslení rychlých náhledů (např. v levém menu nebo na mapě).
|
||||||
|
|
||||||
```js
|
---
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
## ⏰ Automatické stahování dat (Cron / Spouštěč)
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
Aby se ti automaticky budovala bohatá historie počasí a srážek i ve chvíli, kdy spíš, doporučuji nastavit automatické spouštění skriptu `npm run data:update`.
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
Zde jsou nejběžnější možnosti, jak si to můžeš nastavit ty sám:
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
### Možnost A: Přes Crontab na Macu / Linuxu (Lokálně)
|
||||||
reactX.configs['recommended-typescript'],
|
Pokud ti běží počítač (nebo domácí server/Raspberry Pi) nepřetržitě, můžeš využít systémový `cron`.
|
||||||
// Enable lint rules for React DOM
|
1. Otevři terminál a napiš: `crontab -e`
|
||||||
reactDom.configs.recommended,
|
2. Na konec souboru vlož následující řádek (uprav cestu ke svému projektu a Node.js):
|
||||||
],
|
```bash
|
||||||
languageOptions: {
|
# Spustit scraping každých 15 minut
|
||||||
parserOptions: {
|
*/15 * * * * cd /Users/davis/WebstormProjects/davisfe.cz && /usr/local/bin/npm run data:update >> scraper.log 2>&1
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
```
|
||||||
tsconfigRootDir: import.meta.dirname,
|
3. Ulož a zavři editor. Od této chvíle se systém postará o automatický sběr dat!
|
||||||
},
|
|
||||||
// other options...
|
### Možnost B: Pomocí GitHub Actions (Pro Produkci)
|
||||||
},
|
Až projekt nahraješ na GitHub, můžeš si vytvořit workflow soubor (např. `.github/workflows/scrape.yml`), který bude skript spouštět na serverech GitHubu zdarma každou hodinu, a výsledné `.json` soubory automaticky commitne a publikuje na web.
|
||||||
},
|
|
||||||
])
|
### Možnost C: Jednoduchý integrovaný spouštěč (Nejlehčí)
|
||||||
|
Pokud nechceš řešit složitý systémový crontab, napsal jsem pro tebe přímo do Node.js malý spouštěč. Stačí si otevřít další okno terminálu a napsat:
|
||||||
|
```bash
|
||||||
|
npm run data:watch 10
|
||||||
```
|
```
|
||||||
|
Tento příkaz ihned provede první stažení a následně bude aplikaci automaticky aktualizovat **každých 10 minut** (číslo na konci si můžeš libovolně přepsat podle toho, jak často chceš stahovat). Skript poběží, dokud okno terminálu nezavřeš.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Struktura klíčových datových složek
|
||||||
|
|
||||||
|
* `/scripts/lakesConfig.ts` - Tady najdeš definici všech 12 sledovaných přehrad (včetně jejich ID pro Povodí Vltavy, GPS souřadnic, maximálních objemů a stavebních kót). Sem můžeš přidávat nové přehrady.
|
||||||
|
* `/public/data/` - Zde se ukládají vygenerovaná JSON data. V produkci musí být tyto soubory přístupné jako statické assety.
|
||||||
|
* `/src/components/` - Obsahuje samotné vizuální karty, Leaflet mapu a detailní `LakeDetail.tsx` (kde se vykresluje hydrologický a meteorologický graf přes Recharts).
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import fs from 'fs';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
async function compare() {
|
||||||
|
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
const response = await axios.get(URL, { httpsAgent: agent });
|
||||||
|
const $ = cheerio.load(response.data);
|
||||||
|
|
||||||
|
let tblFound = null;
|
||||||
|
$('table').each((i, tbl) => {
|
||||||
|
if ($(tbl).text().includes('Datum') && $(tbl).text().includes('Odtok')) {
|
||||||
|
tblFound = $(tbl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pvlRows = [];
|
||||||
|
if (tblFound) {
|
||||||
|
tblFound.find('tr').each((i, row) => {
|
||||||
|
if (i === 0) return;
|
||||||
|
const cols = $(row).find('td');
|
||||||
|
if (cols.length >= 3) {
|
||||||
|
const rawDate = $(cols[0]).text().trim();
|
||||||
|
const levelStr = $(cols[1]).text().trim().replace(',', '.');
|
||||||
|
let flowStr = $(cols[2]).text().trim().replace(',', '.');
|
||||||
|
if (flowStr === '' && cols.length >= 4) {
|
||||||
|
flowStr = $(cols[3]).text().trim().replace(',', '.');
|
||||||
|
}
|
||||||
|
pvlRows.push({
|
||||||
|
date: rawDate,
|
||||||
|
level: parseFloat(levelStr),
|
||||||
|
flow: parseFloat(flowStr)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const localData = JSON.parse(fs.readFileSync('public/data/VLL1.json', 'utf-8'));
|
||||||
|
// Sort local data descending (newest first) to match PVL which is newest first
|
||||||
|
const sortedLocal = localData.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
|
||||||
|
console.log('--- POROVNÁNÍ DAT: LIPNO 1 ---');
|
||||||
|
console.log(String('PVL.CZ').padEnd(40) + ' | ' + 'NAŠE LOKÁLNÍ DATABÁZE');
|
||||||
|
console.log('-'.repeat(85));
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(10, pvlRows.length); i++) {
|
||||||
|
const p = pvlRows[i];
|
||||||
|
const l = sortedLocal[i];
|
||||||
|
|
||||||
|
// Format our local UTC timestamp back to something readable
|
||||||
|
const d = new Date(l.timestamp);
|
||||||
|
const localDateStr = `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth()+1).toString().padStart(2, '0')}.${d.getFullYear()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const pvlStr = `[${p.date}] H: ${p.level} m, O: ${p.flow} m3/s`.padEnd(40);
|
||||||
|
const locStr = `[${localDateStr}] H: ${l.level} m, O: ${l.flow} m3/s, P: ${l.inflow} m3/s`;
|
||||||
|
|
||||||
|
console.log(`${pvlStr} | ${locStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compare().catch(console.error);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Analýza dostupných dat z Povodí Vltavy (PVL.cz)
|
||||||
|
|
||||||
|
Tento dokument sumarizuje všechna data, která jsme schopni strojově získat z webových stránek Povodí Vltavy pro jednotlivé vodní nádrže (např. z adresy `Mereni.aspx?oid=1&id=VLL1`).
|
||||||
|
|
||||||
|
Data jsou na zdrojovém backendu rozdělena do několika logických celků (tabulek), které můžeme libovolně vytěžovat.
|
||||||
|
|
||||||
|
## 1. Technické parametry nádrže (Základní údaje)
|
||||||
|
Tato data jsou statická a definují fyzické a inženýrské limity přehrady.
|
||||||
|
|
||||||
|
* **Tok (River):** Na jaké řece se nádrž nachází (např. Vltava).
|
||||||
|
* **Koruna hráze:** Absolutní nadmořská výška nejvyššího bodu hráze [m n.m.].
|
||||||
|
* **Kóta přelivu:** Výška přelivových hran [m n.m.].
|
||||||
|
* **Maximální retenční hladina:** Krizová úroveň nadržení při povodních [m n.m.].
|
||||||
|
* **Hladina zásobního prostoru:** Maximální běžná hladina, pro kterou je určen zásobní objem [m n.m.].
|
||||||
|
* **Hladina stálého nadržení:** Minimální hladina nutná pro zachování ekologických a technických funkcí [m n.m.] (lze využít pro přesný výpočet procentuální naplněnosti).
|
||||||
|
* **Výškový systém:** Zpravidla "Balt p.v." (Baltský po vyrovnání).
|
||||||
|
|
||||||
|
## 2. Aktuální hodnoty (Real-time data)
|
||||||
|
Tato tabulka obsahuje nejčerstvější data z měřicích stanic s přesným časovým razítkem. Ne všechny hodnoty musí být vždy u všech přehrad dostupné.
|
||||||
|
|
||||||
|
* **Časové razítko:** Přesný čas posledního měření (např. *05.06.2026 22:10*).
|
||||||
|
* **Hladina vody v nádrži:** Aktuální výška [m n.m.].
|
||||||
|
* **Objem:** Skutečný aktuální zadržovaný objem vody [mil. m³].
|
||||||
|
* **Přítok (Inflow):** Odhadovaný/měřený přítok do přehrady [m³/s].
|
||||||
|
* **Odtok (Outflow):** Skutečný odtok z přehrady [m³/s].
|
||||||
|
* **Srážky (24h):** Úhrn srážek za posledních 24 hodin [mm] *(k dispozici pouze u vybraných stanic)*.
|
||||||
|
* **Teplota vzduchu:** Aktuální teplota vzduchu [°C] *(k dispozici pouze u vybraných stanic)*.
|
||||||
|
|
||||||
|
## 3. Historická časová řada (Tabulka měření)
|
||||||
|
Tyto tabulky obsahují historický vývoj po jednotlivých hodinách za posledních několik dnů, což využíváme pro vykreslování grafů.
|
||||||
|
|
||||||
|
* **Datum a čas:** Hodinové intervaly (např. *05.06.2026 22:00*).
|
||||||
|
* **Hladina:** Měřená výška hladiny [m n.m.].
|
||||||
|
* **Odtok:** Odtok přes hráz [m³/s].
|
||||||
|
* *(Poznámka: Přítok a Objem se do historické tabulky u většiny přehrad ze strany PVL neukládají, zveřejňují pouze hladinu a odtok).*
|
||||||
|
* **QN:** Indikátor kvality dat (ověřená/neověřená).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Možnosti budoucího rozšíření HLADINATORu
|
||||||
|
Na základě výše zmíněných dostupných bodů můžeme do aplikace snadno přidat:
|
||||||
|
1. **Srážky a teplotu** - Pokud je pro danou přehradu údaj dostupný, můžeme přidat widget pro zobrazení úhrnu srážek za 24h a aktuální teploty vzduchu.
|
||||||
|
2. **Přesnější výpočet %** - Pomocí limitů "Maximální retenční hladina" a "Hladina stálého nadržení" můžeme přesně indikovat blížící se povodňový stav.
|
||||||
|
3. **Výstražný systém (Alerts)** - Vizuální varování (např. změna barvy panelu na červenou), pokud se aktuální hladina nebezpečně blíží kótě přelivu.
|
||||||
+6
-1
@@ -4,7 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
|
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+2921
-22
File diff suppressed because it is too large
Load Diff
+26
-3
@@ -7,26 +7,49 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"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": {
|
"dependencies": {
|
||||||
|
"axios": "^1.17.0",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-helmet-async": "^3.0.0",
|
||||||
"react-icons": "^5.5.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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@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/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"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 |
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://hladinator.cz/sitemap.xml
|
||||||
+1449
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+637
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import https from 'https';
|
||||||
|
import { lakesConfig } from './scripts/lakesConfig';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
for (const lake of lakesConfig) {
|
||||||
|
const [internalId, oid] = lake.id.split('|');
|
||||||
|
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
|
||||||
|
try {
|
||||||
|
const res = await axios.get(URL, { httpsAgent: agent, headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
let temp = null;
|
||||||
|
let precip = null;
|
||||||
|
$('table').each((i, tbl) => {
|
||||||
|
const text = $(tbl).text();
|
||||||
|
if (text.includes('Aktuální hodnoty')) {
|
||||||
|
const tempMatch = text.match(/Teplota vzduchu \[°C\]\s*([\d,]+)/);
|
||||||
|
if (tempMatch) temp = tempMatch[1];
|
||||||
|
const precipMatch = text.match(/Srážky \(24h\) \[mm\]\s*([\d,]+)/);
|
||||||
|
if (precipMatch) precip = precipMatch[1];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`[${internalId}] Temp: ${temp}, Precip: ${precip}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import https from 'https';
|
||||||
|
import { lakesConfig } from './scripts/lakesConfig';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
for (const lake of lakesConfig) {
|
||||||
|
const [internalId, oid] = lake.id.split('|');
|
||||||
|
const URL = `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
|
||||||
|
try {
|
||||||
|
const res = await axios.get(URL, { httpsAgent: agent, headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
let storage = 0;
|
||||||
|
$('table').each((i, tbl) => {
|
||||||
|
const text = $(tbl).text();
|
||||||
|
const match = text.match(/Hladina z[aá]sobn[ií]ho prostoru:\s*([\d,]+)/i);
|
||||||
|
if (match) {
|
||||||
|
storage = parseFloat(match[1].replace(',', '.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`{ id: "${lake.id}", storageLevel: ${storage} },`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { lakesConfig } from './scripts/lakesConfig';
|
||||||
|
|
||||||
|
async function testOpenMeteo() {
|
||||||
|
const lipno = lakesConfig.find(l => l.id.startsWith('VLL1'));
|
||||||
|
if (!lipno) return;
|
||||||
|
const lat = lipno.coords[0];
|
||||||
|
const lon = lipno.coords[1];
|
||||||
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,precipitation`;
|
||||||
|
console.log('Fetching from:', url);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url);
|
||||||
|
console.log(response.data.current);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testOpenMeteo();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { lakesConfig } from './scripts/lakesConfig';
|
||||||
|
|
||||||
|
async function testHistory() {
|
||||||
|
const lipno = lakesConfig.find(l => l.id.startsWith('VLL1'));
|
||||||
|
if (!lipno) return;
|
||||||
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lipno.coords[0]}&longitude=${lipno.coords[1]}&past_days=7&hourly=temperature_2m,precipitation&timezone=GMT`;
|
||||||
|
console.log('Fetching from:', url);
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url);
|
||||||
|
const hourly = res.data.hourly;
|
||||||
|
console.log(`Received ${hourly.time.length} hourly records.`);
|
||||||
|
console.log('Sample record at index 100:');
|
||||||
|
console.log('Time:', hourly.time[100]);
|
||||||
|
console.log('Temp:', hourly.temperature_2m[100]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testHistory();
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import fs from 'fs';
|
||||||
|
import https from 'https';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
|
||||||
|
|
||||||
|
async function testPostback() {
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
const res = await axios.get(URL, { httpsAgent: agent, timeout: 10000 });
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
|
||||||
|
const viewstate = $('#__VIEWSTATE').val();
|
||||||
|
const viewstategenerator = $('#__VIEWSTATEGENERATOR').val();
|
||||||
|
const eventvalidation = $('#__EVENTVALIDATION').val();
|
||||||
|
|
||||||
|
// Try to POST for monthly data
|
||||||
|
const postData = new URLSearchParams();
|
||||||
|
postData.append('__EVENTTARGET', 'ctl00$ObsahCPH$PrechodNaBilancniData');
|
||||||
|
postData.append('__EVENTARGUMENT', '');
|
||||||
|
postData.append('__VIEWSTATE', viewstate as string);
|
||||||
|
postData.append('__VIEWSTATEGENERATOR', viewstategenerator as string);
|
||||||
|
postData.append('__EVENTVALIDATION', eventvalidation as string);
|
||||||
|
|
||||||
|
const postRes = await axios.post(URL, postData.toString(), {
|
||||||
|
httpsAgent: agent,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync('pvl_raw_month.html', postRes.data);
|
||||||
|
console.log('Saved monthly data to pvl_raw_month.html');
|
||||||
|
|
||||||
|
const $post = cheerio.load(postRes.data);
|
||||||
|
const rows = $post('table.tabulka-seznam tr:not(:first-child)');
|
||||||
|
console.log(`Found ${rows.length} rows in the table.`);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const firstRow = rows.first().find('td').first().text().trim();
|
||||||
|
const lastRow = rows.last().find('td').first().text().trim();
|
||||||
|
console.log(`Date range: ${firstRow} to ${lastRow}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
testPostback().catch(console.error);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { calculateLakeMetrics, LakeCalculationConfig } from '../utils/calculations';
|
||||||
|
|
||||||
|
describe('calculateLakeMetrics', () => {
|
||||||
|
const config: LakeCalculationConfig = {
|
||||||
|
minLevel: 100,
|
||||||
|
maxLevel: 110,
|
||||||
|
storageLevel: 108,
|
||||||
|
maxVolume: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should calculate capacity based on reported volume when available', () => {
|
||||||
|
// 25 / 50 = 50%
|
||||||
|
const result = calculateLakeMetrics(105, 25, config);
|
||||||
|
expect(result.capacity).toBe(50);
|
||||||
|
expect(result.volume).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap capacity at 100% when volume exceeds maxVolume', () => {
|
||||||
|
const result = calculateLakeMetrics(111, 55, config);
|
||||||
|
expect(result.capacity).toBe(100);
|
||||||
|
expect(result.volume).toBe(55);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should floor capacity at 0% when volume is negative', () => {
|
||||||
|
const result = calculateLakeMetrics(99, -5, config);
|
||||||
|
expect(result.capacity).toBe(0);
|
||||||
|
expect(result.volume).toBe(-5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should estimate capacity and volume from level when reported volume is 0', () => {
|
||||||
|
// Level 105 is exactly halfway between 100 and 110 -> 50%
|
||||||
|
// 50% of 50 maxVolume = 25
|
||||||
|
const result = calculateLakeMetrics(105, 0, config);
|
||||||
|
expect(result.capacity).toBe(50);
|
||||||
|
expect(result.volume).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap estimated capacity at 100% when level exceeds maxLevel', () => {
|
||||||
|
const result = calculateLakeMetrics(115, 0, config);
|
||||||
|
expect(result.capacity).toBe(100);
|
||||||
|
expect(result.volume).toBe(50); // 100% of 50
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should floor estimated capacity at 0% when level is below minLevel', () => {
|
||||||
|
const result = calculateLakeMetrics(90, 0, config);
|
||||||
|
expect(result.capacity).toBe(0);
|
||||||
|
expect(result.volume).toBe(0); // 0% of 50
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly calculate storageDiff', () => {
|
||||||
|
const result = calculateLakeMetrics(106, 25, config);
|
||||||
|
// 106 - 108 = -2.00
|
||||||
|
expect(result.storageDiff).toBe(-2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate positive storageDiff when above storageLevel', () => {
|
||||||
|
const result = calculateLakeMetrics(109, 25, config);
|
||||||
|
// 109 - 108 = 1.00
|
||||||
|
expect(result.storageDiff).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing config gracefully', () => {
|
||||||
|
const emptyConfig: LakeCalculationConfig = {};
|
||||||
|
const result = calculateLakeMetrics(105, 0, emptyConfig);
|
||||||
|
expect(result.capacity).toBe(0);
|
||||||
|
expect(result.volume).toBe(0);
|
||||||
|
expect(result.storageDiff).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseDateString } from '../scrapeLakes';
|
||||||
|
|
||||||
|
describe('scrapeLakes - parseDateString', () => {
|
||||||
|
it('should parse valid date strings correctly', () => {
|
||||||
|
// Note: JS Date parsing uses local timezone, so the output ISO string depends on where the test runs.
|
||||||
|
// To make it deterministic, we just check if it returns a string and is not null.
|
||||||
|
const result = parseDateString('05.06.2026 22:30');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
// Assuming standard parsing, it should contain 2026
|
||||||
|
expect(result).toContain('2026-06-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid formats', () => {
|
||||||
|
expect(parseDateString('')).toBeNull();
|
||||||
|
expect(parseDateString('invalid date string')).toBeNull();
|
||||||
|
expect(parseDateString('05.06.2026')).toBeNull(); // Missing time
|
||||||
|
expect(parseDateString('22:30')).toBeNull(); // Missing date
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for malformed parts', () => {
|
||||||
|
expect(parseDateString('99.99.9999 99:99')).toBeNull(); // JS Date might parse this as valid overflow, but let's check
|
||||||
|
expect(parseDateString('abc def ghi')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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();
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
const ALL_LAKES = [
|
||||||
|
{"href": "Mereni.aspx?id=BIBI&oid=1", "text": "VD Bílsko"},
|
||||||
|
{"href": "Mereni.aspx?id=RACU&oid=3", "text": "VD České Údolí"},
|
||||||
|
{"href": "Mereni.aspx?id=KLDP&oid=3", "text": "VD Dolejší Padrťský rybník"},
|
||||||
|
{"href": "Mereni.aspx?id=CPDR&oid=3", "text": "VD Dráteník"},
|
||||||
|
{"href": "Mereni.aspx?id=KLHP&oid=3", "text": "VD Hořejší Padrťský rybník"},
|
||||||
|
{"href": "Mereni.aspx?id=SCHU&oid=1", "text": "VD Humenice"},
|
||||||
|
{"href": "Mereni.aspx?id=BLHU&oid=1", "text": "VD Husinec"},
|
||||||
|
{"href": "Mereni.aspx?id=VLKA&oid=2", "text": "VD Kamýk"},
|
||||||
|
{"href": "Mereni.aspx?id=SPKA&oid=1", "text": "VD Karhof"},
|
||||||
|
{"href": "Mereni.aspx?id=KLKL&oid=3", "text": "VD Klabava"},
|
||||||
|
{"href": "Mereni.aspx?id=KCKC&oid=3", "text": "VD Klíčava"},
|
||||||
|
{"href": "Mereni.aspx?id=LILA&oid=3", "text": "VD Láz"},
|
||||||
|
{"href": "Mereni.aspx?id=MZLU&oid=3", "text": "VD Lučina"},
|
||||||
|
{"href": "Mereni.aspx?id=SPNE&oid=2", "text": "VD Němčice"},
|
||||||
|
{"href": "Mereni.aspx?id=UHNY&oid=3", "text": "VD Nýrsko"},
|
||||||
|
{"href": "Mereni.aspx?id=OPOB&oid=3", "text": "VD Obecnice"},
|
||||||
|
{"href": "Mereni.aspx?id=PPPI&oid=3", "text": "VD Pilská (u Příbramě)"},
|
||||||
|
{"href": "Mereni.aspx?id=SAPI&oid=2", "text": "VD Pilská u Žďáru"},
|
||||||
|
{"href": "Mereni.aspx?id=HESE&oid=2", "text": "VD Sedlice"},
|
||||||
|
{"href": "Mereni.aspx?id=CRSO&oid=1", "text": "VD Soběnov"},
|
||||||
|
{"href": "Mereni.aspx?id=SVSV&oid=2", "text": "VD Staviště"},
|
||||||
|
{"href": "Mereni.aspx?id=STST&oid=2", "text": "VD Strž"},
|
||||||
|
{"href": "Mereni.aspx?id=SMSM&oid=3", "text": "VD Suchomasty"},
|
||||||
|
{"href": "Mereni.aspx?id=ZESV&oid=2", "text": "VD Švihov (Želivka)"},
|
||||||
|
{"href": "Mereni.aspx?id=TRTR&oid=2", "text": "VD Trnávka"},
|
||||||
|
{"href": "Mereni.aspx?id=VLVE&oid=2", "text": "VD Vrané"},
|
||||||
|
{"href": "Mereni.aspx?id=HEVR&oid=2", "text": "VD Vřesník"},
|
||||||
|
{"href": "Mereni.aspx?id=CPZA&oid=3", "text": "VD Záskalská"},
|
||||||
|
{"href": "Mereni.aspx?id=SPZH&oid=1", "text": "VD Zhejral"},
|
||||||
|
{"href": "Mereni.aspx?id=STZL&oid=3", "text": "VD Žlutice"}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function checkLakes() {
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
const validLakes: any[] = [];
|
||||||
|
|
||||||
|
for (const lake of ALL_LAKES) {
|
||||||
|
const url = `https://www.pvl.cz/portal/nadrze/cz/pc/${lake.href}`;
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
httpsAgent: agent,
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||||
|
});
|
||||||
|
const $ = cheerio.load(response.data);
|
||||||
|
let hasHistory = false;
|
||||||
|
let hasInflow = false;
|
||||||
|
|
||||||
|
$('table').each((i, tbl) => {
|
||||||
|
const text = $(tbl).text();
|
||||||
|
if (text.includes('Aktuální hodnoty') && text.includes('Přítok')) {
|
||||||
|
hasInflow = true;
|
||||||
|
}
|
||||||
|
if (text.includes('Datum') && text.includes('Odtok')) {
|
||||||
|
const rows = $(tbl).find('tr').length;
|
||||||
|
if (rows > 2) hasHistory = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasHistory && hasInflow) {
|
||||||
|
validLakes.push(lake);
|
||||||
|
console.log(`[VALID] ${lake.text}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[INVALID] ${lake.text} (Hist:${hasHistory}, In:${hasInflow})`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[ERROR] ${lake.text}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\\n--- SUMMARY OF VALID LAKES ---');
|
||||||
|
console.log(JSON.stringify(validLakes, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
checkLakes();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
async function checkMap() {
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
try {
|
||||||
|
const response = await axios.get('https://www.pvl.cz/portal/nadrze/cz/pc/Prehled.aspx', {
|
||||||
|
httpsAgent: agent,
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||||
|
});
|
||||||
|
const html = response.data;
|
||||||
|
|
||||||
|
// Look for variables or inline JSON with coordinates
|
||||||
|
const scriptMatches = html.match(/<script\\b[^>]*>([\\s\\S]*?)<\\/script>/gi);
|
||||||
|
if (scriptMatches) {
|
||||||
|
scriptMatches.forEach((m: string, i: number) => {
|
||||||
|
if (m.includes('lat') || m.includes('Lng') || m.includes('Points') || m.includes('Markers')) {
|
||||||
|
console.log("Found something in script " + i);
|
||||||
|
console.log(m.substring(0, 500)); // preview
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkMap();
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
async function fetchLakes() {
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
try {
|
||||||
|
const response = await axios.get('https://www.pvl.cz/portal/nadrze/cz/pc/Prehled.aspx', {
|
||||||
|
httpsAgent: agent,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data);
|
||||||
|
const lakes: any[] = [];
|
||||||
|
|
||||||
|
// Links to lakes usually look like Mereni.aspx?oid=xxx&id=yyy
|
||||||
|
$('a[href^="Mereni.aspx"]').each((i, el) => {
|
||||||
|
const href = $(el).attr('href');
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
if (href && text) {
|
||||||
|
lakes.push({ href, text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify(lakes, null, 2));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLakes();
|
||||||
@@ -0,0 +1,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();
|
||||||
@@ -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();
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
];
|
||||||
@@ -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}¤t=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();
|
||||||
@@ -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();
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -1,42 +1,458 @@
|
|||||||
#root {
|
.dashboard-container {
|
||||||
max-width: 1280px;
|
display: flex;
|
||||||
margin: 0 auto;
|
height: 100vh;
|
||||||
padding: 2rem;
|
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;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.button-group {
|
||||||
height: 6em;
|
display: flex;
|
||||||
padding: 1.5em;
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
will-change: filter;
|
border-radius: 0.5rem;
|
||||||
transition: filter 300ms;
|
overflow: hidden;
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes logo-spin {
|
.control-btn {
|
||||||
from {
|
background: transparent;
|
||||||
transform: rotate(0deg);
|
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
@@ -1,31 +1,115 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
import { Routes, Route, useParams, Navigate } from 'react-router-dom';
|
||||||
import LoadingScreen from './components/LoadingScreen'
|
import LakeDetail from './components/LakeDetail';
|
||||||
import Home from './components/Home'
|
import LakesOverview from './components/LakesOverview';
|
||||||
import Navbar from './components/Navbar'
|
import LakeMap from './components/LakeMap';
|
||||||
import TimeBreaker from './components/TimeBreaker'
|
import FavoritesOverview from './components/FavoritesOverview';
|
||||||
import { type Language } from './translations'
|
import Sidebar from './components/Sidebar';
|
||||||
import './App.css'
|
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() {
|
function App() {
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [language, setLanguage] = useState<Language>(() => {
|
||||||
const [language, setLanguage] = useState<Language>('en')
|
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 (
|
return (
|
||||||
<>
|
<div className="dashboard-container">
|
||||||
{isLoading ? (
|
{/* Mobile overlay */}
|
||||||
<LoadingScreen onLoaded={() => setIsLoading(false)} />
|
{isMobileMenuOpen && (
|
||||||
) : (
|
<div
|
||||||
<Router>
|
style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999 }}
|
||||||
<Navbar language={language} setLanguage={setLanguage} />
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
<Routes>
|
></div>
|
||||||
<Route path="/" element={<Home language={language} />} />
|
|
||||||
<Route path="/time-breaker" element={<TimeBreaker language={language} />} />
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)
|
<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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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='© <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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
||||||
@@ -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}¤t=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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
+237
-319
@@ -1,366 +1,284 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-color: #0f0f0f;
|
/* Colors based on HLADINATOR design */
|
||||||
--text-color: #f0f0f0;
|
--bg-dark: #1e293b; /* Unified lighter navy background */
|
||||||
--accent-color: #646cff;
|
--bg-card: #1e293b; /* Card/Panel background */
|
||||||
--secondary-color: #a0a0a0;
|
--bg-card-hover: #334155;
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
--text-main: #f8fafc; /* White text */
|
||||||
line-height: 1.5;
|
--text-muted: #94a3b8; /* Gray text */
|
||||||
font-weight: 400;
|
|
||||||
color-scheme: dark;
|
--color-cyan: #06b6d4; /* Water level / Primary */
|
||||||
color: var(--text-color);
|
--color-green: #22c55e; /* Inflow / Positive trend */
|
||||||
background-color: var(--bg-color);
|
--color-red: #ef4444; /* Outflow / Negative trend */
|
||||||
scroll-behavior: smooth;
|
--color-orange: #f97316; /* Outflow line chart color */
|
||||||
}
|
--color-purple: #a855f7; /* Wind gusts line color */
|
||||||
|
|
||||||
html {
|
.kpi-grid-container {
|
||||||
scroll-padding-top: 100px;
|
display: grid;
|
||||||
/* Offset for sticky header */
|
grid-template-columns: repeat(4, 1fr);
|
||||||
}
|
gap: 1.5rem;
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading Screen */
|
.kpi-card {
|
||||||
.loading-screen {
|
background-color: var(--bg-card);
|
||||||
position: fixed;
|
border: 1px solid var(--border-color);
|
||||||
top: 0;
|
border-radius: 12px;
|
||||||
left: 0;
|
padding: 1.5rem;
|
||||||
width: 100%;
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
height: 100%;
|
position: relative;
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in {
|
/* Hide old styles but keep them in case they are used elsewhere */
|
||||||
animation: fadeIn 1s ease-in;
|
.kpi-container-mobile {
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-text {
|
.kpi-card-full {
|
||||||
font-size: 0.9rem;
|
background-color: var(--bg-card);
|
||||||
letter-spacing: 3px;
|
border: 1px solid var(--border-color);
|
||||||
color: var(--secondary-color);
|
border-radius: 0.75rem;
|
||||||
text-transform: uppercase;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.kpi-row-half {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
gap: 1rem;
|
||||||
margin-top: 4rem;
|
width: 100%;
|
||||||
gap: 2rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-label {
|
.kpi-card-half {
|
||||||
display: block;
|
background-color: var(--bg-card);
|
||||||
font-size: 0.8rem;
|
border: 1px solid var(--border-color);
|
||||||
letter-spacing: 2px;
|
border-radius: 0.75rem;
|
||||||
color: var(--secondary-color);
|
padding: 1.5rem;
|
||||||
margin-bottom: 1rem;
|
flex: 1;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
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;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.map-lake-card {
|
||||||
width: 54px;
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
height: 54px;
|
border: 1px solid var(--border-color);
|
||||||
background: linear-gradient(145deg, #1e2024, #23272b);
|
border-radius: 8px;
|
||||||
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
|
padding: 1rem;
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-color);
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.2s;
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn:hover {
|
.map-lake-card:hover {
|
||||||
background: linear-gradient(145deg, #23272b, #1e2024);
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-image-container {
|
.map-lake-image {
|
||||||
flex: 0.8;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-image-placeholder {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 3/4;
|
height: 120px;
|
||||||
background: linear-gradient(145deg, #1e2024, #23272b);
|
object-fit: cover;
|
||||||
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
|
border-radius: 6px;
|
||||||
border-radius: 20px;
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background-color: #2a3441;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-lake-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
align-items: center;
|
font-size: 0.85rem;
|
||||||
color: var(--secondary-color);
|
color: var(--text-muted);
|
||||||
font-size: 1.2rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-right {
|
/* Custom Leaflet Marker */
|
||||||
display: flex;
|
.custom-div-icon {
|
||||||
flex-direction: column;
|
background: transparent;
|
||||||
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;
|
|
||||||
border: none;
|
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;
|
cursor: pointer;
|
||||||
opacity: 0.5;
|
|
||||||
transition: opacity 0.3s, color 0.3s;
|
|
||||||
padding: 0;
|
|
||||||
color: var(--secondary-color);
|
|
||||||
}
|
}
|
||||||
|
.map-marker-icon::after {
|
||||||
.lang-btn:hover {
|
content: '';
|
||||||
opacity: 1;
|
position: absolute;
|
||||||
color: var(--accent-color);
|
bottom: -6px;
|
||||||
}
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
.lang-btn.active {
|
width: 0;
|
||||||
opacity: 1;
|
height: 0;
|
||||||
color: var(--text-color);
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 8px solid white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.hero-content {
|
.kpi-grid-container {
|
||||||
flex-direction: column-reverse;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.navbar {
|
.map-overlay-panel {
|
||||||
padding: 1rem;
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 50%;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
}
|
}
|
||||||
|
.kpi-grid-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hero h1 {
|
/* Time controls pill layout */
|
||||||
font-size: 2.5rem;
|
.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
@@ -1,10 +1,19 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
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 './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<HelmetProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<FavoritesProvider>
|
||||||
|
<App />
|
||||||
|
</FavoritesProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</HelmetProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
+125
-33
@@ -1,38 +1,130 @@
|
|||||||
export type Language = 'en' | 'cs';
|
export type Language = 'en' | 'cs';
|
||||||
|
|
||||||
export const translations = {
|
export const t = {
|
||||||
en: {
|
en: {
|
||||||
home: 'Home',
|
sidebar: {
|
||||||
about: 'About',
|
favorites: 'Favorites',
|
||||||
contact: 'Contact',
|
lakes: 'Lakes & Reservoirs',
|
||||||
timeBreaker: 'Time-Breaker',
|
map: 'Map',
|
||||||
welcome: 'WELCOME TO MY WORLD',
|
settings: 'Settings'
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
cs: {
|
topbar: {
|
||||||
home: 'Domů',
|
search: 'Search river or reservoir (e.g. Lipno)...',
|
||||||
about: 'O mně',
|
updated: 'Last updated:'
|
||||||
contact: 'Kontakt',
|
},
|
||||||
timeBreaker: 'Time-Breaker',
|
seo: {
|
||||||
welcome: 'VÍTEJTE V MÉM SVĚTĚ',
|
homeTitle: 'Hladinátor - Water levels and flow rates of reservoirs',
|
||||||
hello: "Ahoj, jsem",
|
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.',
|
||||||
job: 'Vývojář.',
|
lakeTitle: '{name} - Water level and flow | Hladinátor',
|
||||||
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.",
|
lakeDesc: 'Current water level and statistics for the {name} reservoir. Track water level, flow rate, wind strength, and storage capacity in real time.',
|
||||||
findMe: 'NAJDETE MĚ NA',
|
favoritesTitle: 'Favorites | Hladinátor',
|
||||||
bestSkill: 'DOVEDNOSTI',
|
favoritesDesc: 'Your pinned lakes and reservoirs. Track water level, flow rate, and weather development on major Czech dams and reservoirs.',
|
||||||
aboutMe: 'O mně',
|
mapTitle: 'Map | Hladinátor',
|
||||||
aboutDesc: 'Tvořím přístupné, pixel-perfect a výkonné webové zážitky. Vášnivý pro technologie a design.',
|
mapDesc: 'Interactive map of all monitored lakes and reservoirs in the Czech Republic.'
|
||||||
getInTouch: 'Napište mi',
|
},
|
||||||
contactDesc: 'Máte zájem o spolupráci?',
|
kpi: {
|
||||||
emailMe: 'Napište mi',
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -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
Reference in New Issue
Block a user