Compare commits

...

14 Commits

Author SHA1 Message Date
David Fencl 5894c51256 chore: update water data sets and migrate docker configuration to directory-based structure
continuous-integration/drone/push Build encountered an error
2026-06-13 22:51:47 +02:00
David Fencl a1a1685ae3 feat: update water monitoring datasets with recent sensor data points 2026-06-13 15:14:13 +02:00
David Fencl 62d69fbb1e chore: update lake datasets, add new monitoring locations, and introduce docker-compose infrastructure 2026-06-13 13:09:26 +02:00
David Fencl c8fe97078d commit 2026-06-09 20:45:08 +02:00
David Fencl c4cad149ea feat: update lake data and optimize weather widget rendering 2026-06-09 20:27:07 +02:00
David Fencl 4939d1c5dc feat: update lake water data and optimize visual components for real-time monitoring 2026-06-08 22:32:10 +02:00
David Fencl 8fe39b7ab0 feat: update water level datasets and improve Tooltip component responsiveness 2026-06-08 21:30:34 +02:00
David Fencl cdb653d660 feat: update lake water data and refine WindChart component functionality 2026-06-08 21:10:29 +02:00
David Fencl 48b44cd642 feat: update historical lake sensor data and improve wind chart component rendering 2026-06-08 20:49:01 +02:00
David Fencl 7a7abdd3e5 feat: update lake water data, implement service worker and manifest, and add favicon 2026-06-08 20:06:23 +02:00
David Fencl f8a7be7fa3 feat: implement sensor glitch detection for water levels and update data cleaning logic 2026-06-08 19:45:37 +02:00
David Fencl 62c861e610 feat: add rivers overview component and sync lake volume data across the dataset 2026-06-08 19:36:54 +02:00
David Fencl ec540e056d feat: implement weather radar component and update water resource data records. before river 2026-06-06 21:04:19 +02:00
David Fencl 231961da19 feat: add disclaimer modal, update lake data, and improve component interactions 2026-06-06 20:35:47 +02:00
104 changed files with 631180 additions and 58645 deletions
+3
View File
@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Documentation / Ideas
docs/
+1 -1
View File
@@ -13,7 +13,7 @@ WORKDIR /var/www/html
# Enable necessary Apache modules
RUN a2enmod rewrite headers
COPY vhost.conf /etc/apache2/sites-available/000-default.conf
COPY Docker/vhost.conf /etc/apache2/sites-available/000-default.conf
# Copy the built application from the build stage
COPY --from=build /app/dist /app/dist
+30
View File
@@ -0,0 +1,30 @@
version: '3.8'
services:
db:
image: timescale/timescaledb:latest-pg16
container_name: hladinator-db
restart: always
environment:
POSTGRES_DB: hladinator
POSTGRES_USER: hladinator_user
POSTGRES_PASSWORD: hladinator_db_password_change_me
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
web:
build:
context: ..
dockerfile: Docker/Dockerfile
container_name: hladinator-web
restart: always
ports:
- "80:80"
depends_on:
- db
volumes:
pgdata:
driver: local
+1 -1
View File
@@ -1,4 +1,4 @@
# File: .docker/apache/vhost.conf
# File: Docker/vhost.conf
LoadModule headers_module /usr/lib/apache2/modules/mod_headers.so
<VirtualHost *:80>
+62 -32
View File
@@ -1,72 +1,102 @@
# 🌊 HLADINATOR
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.
HLADINATOR is an interactive and visually engaging web application for monitoring the current status and history of Czech reservoirs. The application provides precise data on water level, outflow, inflow, current volume, and additionally collects weather history (temperature and precipitation) directly from the source.
Zdroj dat: **Povodí Vltavy (pvl.cz)** a další povodí v ČR.
Data source: **Povodí Vltavy (pvl.cz)** and other river basin administrators in the Czech Republic.
---
## 🚀 Jak spustit aplikaci lokálně
## 🚀 How to Run the Application Locally
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ů.
The application is built on a modern stack (React, Vite, TypeScript, Recharts, Leaflet). You don't need any complex backend to run it locally; the data is read directly from pre-generated static JSON files.
1. Nainstaluj závislosti (pokud jsi to ještě neudělal):
1. Install dependencies (if you haven't already):
```bash
npm install
```
2. Spusť lokální vývojový server:
2. Start the local development server:
```bash
npm run dev
```
3. Otevři prohlížeč na adrese `http://localhost:5173`.
3. Open your browser at `http://localhost:5173`.
---
## 🔄 Jak aktualizovat data (Scraping)
## 🔄 How to Update Data (Scraping)
Povodí Vltavy neposkytuje standard 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**.
Povodí Vltavy does not provide a standard API for weather history, nor does it support direct requests from client browsers (due to CORS and security restrictions). Therefore, we use our own custom **scraper**.
Pro ruční stažení těch nejnovějších dat z webu povodí spusť v terminálu:
To manually fetch the latest data from the river basin websites, run in your terminal:
```bash
npm run data:update
```
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ě).
This command performs two actions:
1. `npm run scrape`: Scrapes the website for all **53 reservoirs and river stations**, parses historical measurement tables, extracts precise **inflow, outflow, volume, precipitation, and temperature**. It then merges this data intelligently with your local database (`public/data/*.json`) and automatically backfills missing values from previous steps to avoid zero-drop anomalies in the charts.
2. `npm run build-index`: Updates the main index file `lakes_index.json`, which the app uses to render fast previews (e.g., in the side menu or on the map).
---
## ⏰ Automatické stahování dat (Cron / Spouštěč)
## ⏰ Automated Data Updates (Cron / Scheduler)
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`.
To automatically accumulate weather and precipitation history even when your machine is off or you are sleeping, we recommend automating the execution of the `npm run data:update` script.
Zde jsou nejběžnější možnosti, jak si to můžeš nastavit ty sám:
Here are the most common deployment methods:
### Možnost A: Přes Crontab na Macu / Linuxu (Lokálně)
Pokud ti běží počítač (nebo domácí server/Raspberry Pi) nepřetržitě, můžeš využít systémový `cron`.
1. Otevři terminál a napiš: `crontab -e`
2. Na konec souboru vlož následující řádek (uprav cestu ke svému projektu a Node.js):
### Option A: Using Crontab on macOS / Linux (Local)
If you have a computer or home server (like a Raspberry Pi) running continuously:
1. Open the terminal and type: `crontab -e`
2. Add the following line at the end of the file (adjust the paths to your project and Node.js installation):
```bash
# Spustit scraping každých 15 minut
# Run scraping every 15 minutes
*/15 * * * * cd /Users/davis/WebstormProjects/davisfe.cz && /usr/local/bin/npm run data:update >> scraper.log 2>&1
```
3. Ulož a zavři editor. Od této chvíle se systém postará o automatický sběr dat!
3. Save and close the editor. The system scheduler will take care of the rest.
### 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.
### Option B: Using GitHub Actions (For Production Hosting)
Once you push the project to GitHub, you can create a workflow file (e.g., `.github/workflows/scrape.yml`) to run the scraping script every hour on GitHub runners for free, and automatically commit and publish the updated `.json` data files back to the repository.
### 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:
### Option C: Built-in Simple Scheduler (Recommended for Development)
If you do not want to set up system cron, the project has a built-in scheduler. Open another terminal tab/window and run:
```bash
npm run data:watch 10
npm run data:watch
```
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š.
This command triggers an immediate update and then automatically schedules updates at 7 minutes past every 10-minute step (e.g., 18:07, 18:17, 18:27...). This delay ensures that the river basin web page has updated its data, preventing duplicate/empty requests.
---
## 📁 Struktura klíčových datových složek
## 🐳 Running in Docker (Production & Own Server)
* `/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).
If you want to deploy the application on your own server and run a PostgreSQL (TimescaleDB) database alongside it for future data collection, a Docker Compose configuration is prepared inside the `Docker` directory.
### Requirements:
- Installed **Docker** and **Docker Compose**.
### Deployment:
1. Go to the `Docker` directory and build/run the containers in the background:
```bash
cd Docker
docker-compose up -d --build
```
2. Docker Compose will launch two containers:
- **`hladinator-db`**: PostgreSQL (TimescaleDB) database running on port `5432` with a `pgdata` volume for data persistence.
- **`hladinator-web`**: Apache web server serving the built React static application on port `80`.
3. The web application is then accessible on port `80` of your server.
---
## 🛠️ Fixing Anomalies in History (Zero Drops / Teeth in Graphs)
If the scraper hasn't run for a while (e.g., when your computer was turned off) and data was filled in subsequently, anomalies or drops to zero (teeth) might appear in the inflow and volume graphs. To clean up the entire history and interpolate these points from the last known state, run:
```bash
npm run data:fix
```
This script scans all data JSON files, detects anomalies/zeros, and repairs them.
## 📁 Key File and Folder Structure
* `/scripts/lakesConfig.ts` - Contains configuration definitions for all **53 monitored reservoirs and rivers** (including their river basin ID, GPS coordinates, maximum capacity limits, and elevation heights). You can add new stations here.
* `/public/data/` - Static storage location for generated JSON files. In production, these must be exposed as static assets.
* `/src/components/` - Holds user interface components, the Leaflet map, and `LakeDetail.tsx` (renders historical hydrology and weather charts via Recharts with automatic anomaly filtering).
+16 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>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." />
@@ -10,9 +10,24 @@
<meta property="og:description" content="Sledujte aktuální vodní stav, průtok a vývoj počasí na českých přehradách a nádržích v reálném čase." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://hladinator.cz" />
<!-- PWA Settings -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1e293b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/favicon.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('Service Worker registered:', reg.scope))
.catch(err => console.error('Service Worker failed:', err));
});
}
</script>
</body>
</html>
+1
View File
@@ -13,6 +13,7 @@
"build-index": "tsx scripts/buildIndex.ts",
"data:update": "npm run scrape && npm run build-index",
"data:watch": "tsx scripts/watchData.ts",
"data:fix": "tsx scripts/fix_lake_inflows.ts",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
[]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6637 -787
View File
File diff suppressed because it is too large Load Diff
+10180 -1531
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10188 -1512
View File
File diff suppressed because it is too large Load Diff
+10024 -1582
View File
File diff suppressed because it is too large Load Diff
+7021 -1513
View File
File diff suppressed because it is too large Load Diff
+10211 -1517
View File
File diff suppressed because it is too large Load Diff
+10380 -1530
View File
File diff suppressed because it is too large Load Diff
+9859 -1498
View File
File diff suppressed because it is too large Load Diff
+6666 -780
View File
File diff suppressed because it is too large Load Diff
+6716 -830
View File
File diff suppressed because it is too large Load Diff
+10153 -1522
View File
File diff suppressed because it is too large Load Diff
+10277 -1538
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10292 -1607
View File
File diff suppressed because it is too large Load Diff
+10395 -1638
View File
File diff suppressed because it is too large Load Diff
+10227 -1560
View File
File diff suppressed because it is too large Load Diff
+9945 -1539
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8001 -810
View File
File diff suppressed because it is too large Load Diff
+10233 -1566
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
[]
File diff suppressed because it is too large Load Diff
+10237 -1525
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10202 -1499
View File
File diff suppressed because it is too large Load Diff
+10149 -1509
View File
File diff suppressed because it is too large Load Diff
+10333 -1576
View File
File diff suppressed because it is too large Load Diff
+9999 -1548
View File
File diff suppressed because it is too large Load Diff
+10291 -1543
View File
File diff suppressed because it is too large Load Diff
+10326 -1569
View File
File diff suppressed because it is too large Load Diff
+10271 -1550
View File
File diff suppressed because it is too large Load Diff
+10224 -1521
View File
File diff suppressed because it is too large Load Diff
+10246 -1525
View File
File diff suppressed because it is too large Load Diff
+10341 -1602
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10223 -1601
View File
File diff suppressed because it is too large Load Diff
+10200 -1497
View File
File diff suppressed because it is too large Load Diff
+10250 -1592
View File
File diff suppressed because it is too large Load Diff
+10290 -1608
View File
File diff suppressed because it is too large Load Diff
+10285 -1618
View File
File diff suppressed because it is too large Load Diff
+10412 -1655
View File
File diff suppressed because it is too large Load Diff
+10325 -1631
View File
File diff suppressed because it is too large Load Diff
+10317 -1596
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10314 -1503
View File
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
[
{
"timestamp": "2026-05-31T05:00:00.000Z",
"level": 0,
"flow": 48.8,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-01T05:00:00.000Z",
"level": 34,
"flow": 80.2,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-02T05:00:00.000Z",
"level": 20,
"flow": 66.46,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-03T05:00:00.000Z",
"level": 18,
"flow": 63.7,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-04T05:00:00.000Z",
"level": 15,
"flow": 60.95,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T05:00:00.000Z",
"level": 15,
"flow": 61.4,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T20:00:00.000Z",
"level": 12,
"flow": 59.06,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T21:00:00.000Z",
"level": 7,
"flow": 54.32,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T22:00:00.000Z",
"level": 6,
"flow": 53.76,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-05T23:00:00.000Z",
"level": 11,
"flow": 57.64,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T00:00:00.000Z",
"level": 12,
"flow": 58.7,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T01:00:00.000Z",
"level": 12,
"flow": 58.25,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T02:00:00.000Z",
"level": 15,
"flow": 60.95,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T03:00:00.000Z",
"level": 13,
"flow": 59.87,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T04:00:00.000Z",
"level": 10,
"flow": 56.73,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T05:00:00.000Z",
"level": 15,
"flow": 61.76,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T06:00:00.000Z",
"level": 11,
"flow": 57.64,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T07:00:00.000Z",
"level": 6,
"flow": 53.6,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T08:00:00.000Z",
"level": 10,
"flow": 57.08,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T09:00:00.000Z",
"level": 14,
"flow": 60.68,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T10:00:00.000Z",
"level": 7,
"flow": 54.4,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T11:00:00.000Z",
"level": 3,
"flow": 51.34,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T12:00:00.000Z",
"level": 12,
"flow": 58.25,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T13:00:00.000Z",
"level": 13,
"flow": 59.33,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T14:00:00.000Z",
"level": 5,
"flow": 52.71,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T15:00:00.000Z",
"level": 10,
"flow": 56.64,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T16:00:00.000Z",
"level": 11,
"flow": 57.4,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T17:00:00.000Z",
"level": 9,
"flow": 55.7,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:00:00.000Z",
"level": 3,
"flow": 51.18,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:10:00.000Z",
"level": 4,
"flow": 51.58,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:20:00.000Z",
"level": 4,
"flow": 51.66,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:30:00.000Z",
"level": 5,
"flow": 52.44,
"inflow": 0,
"volume": 0
},
{
"timestamp": "2026-06-06T18:40:00.000Z",
"level": 5,
"flow": 53.04,
"inflow": 0,
"volume": 0,
"temperature": 22.1,
"precipitation": 0
}
]
+10221 -1518
View File
File diff suppressed because it is too large Load Diff
+1596 -619
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"short_name": "Hladinátor",
"name": "Hladinátor - Stav přehrad a nádrží",
"icons": [
{
"src": "/favicon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"start_url": "/",
"background_color": "#1e293b",
"theme_color": "#1e293b",
"display": "standalone",
"orientation": "portrait"
}
+66
View File
@@ -0,0 +1,66 @@
const CACHE_NAME = 'hladinator-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/favicon.png',
'/manifest.json'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS_TO_CACHE);
})
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
})
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
// Only handle same-origin HTTP/HTTPS requests
if (!event.request.url.startsWith(self.location.origin)) return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
// Fetch new version in background to update cache (stale-while-revalidate)
fetch(event.request).then((networkResponse) => {
if (networkResponse.status === 200) {
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, networkResponse));
}
}).catch(() => {/* ignore network failures */});
return cachedResponse;
}
return fetch(event.request).then((networkResponse) => {
// Cache static files and JSON data on the fly
if (networkResponse.status === 200 && (
event.request.url.includes('.json') ||
event.request.url.includes('.css') ||
event.request.url.includes('.js')
)) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseToCache));
}
return networkResponse;
}).catch(() => {
// Offline fallback
});
})
);
});
+5 -1
View File
@@ -83,12 +83,16 @@ function main() {
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
type?: 'lake' | 'river';
country?: string;
area?: number;
depth?: number;
}
export const lakesConfig: LakeConfig[] = [
`;
updated.forEach((l, idx) => {
newContent += ` { id: "${l.id}", text: "${l.text}", ${l.priority ? 'priority: true, ' : ''}coords: [${l.coords[0].toFixed(4)}, ${l.coords[1].toFixed(4)}], maxVolume: ${l.maxVolume}, minLevel: ${l.minLevel}, maxLevel: ${l.maxLevel}, storageLevel: ${l.storageLevel}, navigationForbidden: ${l.navigationForbidden} }${idx === updated.length - 1 ? '' : ','}\n`;
newContent += ` { id: "${l.id}", text: "${l.text}", ${l.priority ? 'priority: true, ' : ''}coords: [${l.coords[0].toFixed(4)}, ${l.coords[1].toFixed(4)}], maxVolume: ${l.maxVolume}, minLevel: ${l.minLevel}, maxLevel: ${l.maxLevel}, storageLevel: ${l.storageLevel}, navigationForbidden: ${l.navigationForbidden}${l.type ? `, type: '${l.type}'` : ''}${l.country ? `, country: '${l.country}'` : ''}${l.area ? `, area: ${l.area}` : ''}${l.depth ? `, depth: ${l.depth}` : ''} }${idx === updated.length - 1 ? '' : ','}\n`;
});
newContent += `];\n`;
+6 -4
View File
@@ -25,9 +25,10 @@ async function backfill() {
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`;
// Fetch maximum past days supported by the forecast API (92 days)
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&past_days=92&hourly=temperature_2m,precipitation&timezone=GMT`;
const res = await axios.get(url, { timeout: 10000 });
const res = await axios.get(url, { timeout: 15000 });
const hourly = res.data.hourly;
// Build lookup map for O(1) matching: '2026-06-02T04:00' -> { temp, precip }
@@ -43,9 +44,10 @@ async function backfill() {
let updatedCount = 0;
for (const record of data) {
// record.timestamp is like "2026-06-02T04:00:00.000Z"
// record.timestamp is like "2026-06-02T04:10:00.000Z"
// Open-Meteo time is like "2026-06-02T04:00"
const hourKey = record.timestamp.substring(0, 16); // Extract up to minutes
// Convert to hourly key to match weatherMap
const hourKey = record.timestamp.substring(0, 13) + ':00';
if (weatherMap.has(hourKey)) {
const w = weatherMap.get(hourKey);
+19 -3
View File
@@ -56,10 +56,21 @@ const lakes = lakesConfig.map(lake => {
const metrics = calculateLakeMetrics(currentLevel, volume, lake);
const cleanText = lake.text.replace(/^VD\s+/, '').replace(/^LG\s+/, '');
const parts = cleanText.split(' - ').map(p => p.trim());
let name = '';
let river = '';
if (parts.length > 1) {
river = parts[parts.length - 1];
name = parts.slice(0, -1).join(' - ');
} else {
name = parts[0];
}
return {
id: lake.id,
name: lake.text.replace('VD ', '').split('-')[0].trim(),
river: lake.text.includes('-') ? lake.text.split('-')[1].trim() : '',
name,
river,
priority: lake.priority || false,
level: currentLevel.toFixed(2),
capacity: metrics.capacity,
@@ -68,9 +79,14 @@ const lakes = lakesConfig.map(lake => {
outflow: currentFlow.toFixed(1),
volume: metrics.volume,
maxVolume: lake.maxVolume || 0,
navigationForbidden: lake.navigationForbidden || false,
lat: lake.coords[0],
lng: lake.coords[1],
sparkline
sparkline,
type: lake.type || 'lake',
country: lake.country || 'CZ',
area: lake.area || 0,
depth: lake.depth || 0
};
});
+79
View File
@@ -0,0 +1,79 @@
import fs from 'fs';
import path from 'path';
function fixExistingData() {
const dataDir = path.resolve('public/data');
if (!fs.existsSync(dataDir)) {
console.error('Data directory does not exist!');
return;
}
const files = fs.readdirSync(dataDir).filter(f => f.endsWith('.json'));
console.log(`Found ${files.length} data files to clean up...`);
files.forEach(file => {
const filePath = path.join(dataDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
if (!Array.isArray(data)) return;
let lastKnownInflow: number | null = null;
let lastKnownVolume: number | null = null;
let fixCountInflow = 0;
let fixCountVolume = 0;
// First pass (oldest to newest): find and propagate values forward
data.forEach(record => {
// Handle inflow
if (record.inflow !== undefined && record.inflow !== null && record.inflow !== 0) {
lastKnownInflow = record.inflow;
} else if ((record.inflow === undefined || record.inflow === null || record.inflow === 0) && lastKnownInflow !== null) {
record.inflow = lastKnownInflow;
fixCountInflow++;
}
// Handle volume
if (record.volume !== undefined && record.volume !== null && record.volume !== 0) {
lastKnownVolume = record.volume;
} else if ((record.volume === undefined || record.volume === null || record.volume === 0) && lastKnownVolume !== null) {
record.volume = lastKnownVolume;
fixCountVolume++;
}
});
// Second pass (newest to oldest): if there were zeros at the very beginning of the file
// before any non-zero value was found, propagate backwards.
let nextKnownInflow: number | null = null;
let nextKnownVolume: number | null = null;
for (let i = data.length - 1; i >= 0; i--) {
const record = data[i];
if (record.inflow !== undefined && record.inflow !== null && record.inflow !== 0) {
nextKnownInflow = record.inflow;
} else if ((record.inflow === undefined || record.inflow === null || record.inflow === 0) && nextKnownInflow !== null) {
record.inflow = nextKnownInflow;
fixCountInflow++;
}
if (record.volume !== undefined && record.volume !== null && record.volume !== 0) {
nextKnownVolume = record.volume;
} else if ((record.volume === undefined || record.volume === null || record.volume === 0) && nextKnownVolume !== null) {
record.volume = nextKnownVolume;
fixCountVolume++;
}
}
if (fixCountInflow > 0 || fixCountVolume > 0) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`[${file}] Cleaned up ${fixCountInflow} inflows and ${fixCountVolume} volumes.`);
}
} catch (e: any) {
console.error(`Error processing file ${file}:`, e.message);
}
});
console.log('Cleanup finished.');
}
fixExistingData();
+43 -11
View File
@@ -8,19 +8,23 @@ export interface LakeConfig {
maxLevel?: number;
storageLevel?: number;
navigationForbidden?: boolean;
type?: 'lake' | 'river';
country?: string;
area?: number;
depth?: number;
}
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: "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, area: 48.7, depth: 25 },
{ 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, area: 0.32, depth: 12 },
{ 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, area: 3.21, depth: 17 },
{ 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, area: 0.72, depth: 9 },
{ 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, area: 27.3, depth: 74 },
{ 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, area: 13.9, depth: 58 },
{ 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, area: 0.96, depth: 22 },
{ 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, area: 2.11, depth: 43 },
{ 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, area: 4.9, depth: 31 },
{ 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, area: 16.0, depth: 56 },
{ 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 },
@@ -49,5 +53,33 @@ export const lakesConfig: LakeConfig[] = [
{ 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 }
{ 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 },
// International Megadams
{ id: "CN_THRE|0", text: "VD Tři soutěsky - Jang-c'-ťiang", priority: true, coords: [30.8258, 111.0031], maxVolume: 39300, minLevel: 145, maxLevel: 175, storageLevel: 175, navigationForbidden: true, country: "CN", area: 1084, depth: 175 },
{ id: "BR_ITAI|0", text: "VD Itaipú - Paraná", priority: true, coords: [-25.4089, -54.5889], maxVolume: 29000, minLevel: 197, maxLevel: 220, storageLevel: 220, navigationForbidden: true, country: "BR", area: 1350, depth: 170 },
{ id: "US_HOOV|0", text: "VD Hooverova přehrada - Colorado", priority: true, coords: [36.0162, -114.7372], maxVolume: 35200, minLevel: 323, maxLevel: 372.4, storageLevel: 370, navigationForbidden: true, country: "US", area: 640, depth: 180 },
{ id: "US_GRACO|0", text: "VD Grand Coulee - Columbia", priority: true, coords: [47.9572, -118.9814], maxVolume: 11600, minLevel: 362, maxLevel: 393, storageLevel: 390, navigationForbidden: true, country: "US", area: 324, depth: 120 },
{ id: "CA_DANJ|0", text: "VD Daniel-Johnson - Manicouagan", priority: true, coords: [50.6391, -68.7289], maxVolume: 141800, minLevel: 350, maxLevel: 359.7, storageLevel: 359.7, navigationForbidden: true, country: "CA", area: 1942, depth: 120 },
{ id: "RU_SASA|0", text: "VD Sajano-šušenská - Jenisej", priority: true, coords: [52.8278, 91.3689], maxVolume: 31300, minLevel: 500, maxLevel: 540, storageLevel: 540, navigationForbidden: true, country: "RU", area: 621, depth: 220 },
{ id: "CA_ROBB|0", text: "VD Robert-Bourassa - La Grande", priority: true, coords: [53.7953, -77.4439], maxVolume: 61700, minLevel: 171, maxLevel: 175.3, storageLevel: 175.3, navigationForbidden: true, country: "CA", area: 2835, depth: 137 },
{ id: "RU_KRAS|0", text: "VD Krasnojarská přehrada - Jenisej", priority: true, coords: [55.9525, 92.2933], maxVolume: 73300, minLevel: 220, maxLevel: 243, storageLevel: 243, navigationForbidden: true, country: "RU", area: 2000, depth: 105 },
{ id: "CH_DIXE|0", text: "VD Grande Dixence - Dixence", priority: true, coords: [46.0811, 7.4025], maxVolume: 400, minLevel: 2200, maxLevel: 2365, storageLevel: 2365, navigationForbidden: true, country: "CH", area: 4, depth: 284 },
// Rivers
{ id: "VLCH|2", text: "LG Praha - Malá Chuchle - Vltava", coords: [50.0294, 14.3986], type: 'river' },
{ id: "VLCB|1", text: "LG České Budějovice - Vltava", coords: [48.9712, 14.4714], type: 'river' },
{ id: "BEBE|3", text: "LG Beroun - Berounka", coords: [49.9642, 14.0792], type: 'river' },
{ id: "SANE|2", text: "LG Nespeky - Sázava", coords: [49.8596, 14.5888], type: 'river' },
{ id: "OTPI|1", text: "LG Písek - Otava", coords: [49.3083, 14.1436], type: 'river' },
{ id: "OTSU|1", text: "LG Sušice - Otava", coords: [49.2319, 13.5186], type: 'river' },
{ id: "LUBE|1", text: "LG Bechyně - Lužnice", coords: [49.2931, 14.4758], type: 'river' },
{ id: "LUKL|1", text: "LG Klenovice - Lužnice", coords: [49.3402, 14.7175], type: 'river' },
{ id: "SAZR|2", text: "LG Zruč nad Sázavou - Sázava", coords: [49.7428, 15.1011], type: 'river' },
{ id: "SASV|2", text: "LG Světlá nad Sázavou - Sázava", coords: [49.6677, 15.4048], type: 'river' },
{ id: "SAKA|2", text: "LG Kácov - Sázava", coords: [49.7772, 15.0294], type: 'river' },
{ id: "BEZB|3", text: "LG Zbečno - Berounka", coords: [50.0436, 13.9189], type: 'river' },
{ id: "BEPL|3", text: "LG Plzeň - Bílá Hora - Berounka", coords: [49.7731, 13.3986], type: 'river' },
{ id: "VLVB|1", text: "LG Vyšší Brod - Vltava", coords: [48.6167, 14.3167], type: 'river' }
];
+183 -16
View File
@@ -13,6 +13,7 @@ interface DataRecord {
volume?: number;
temperature?: number | null;
precipitation?: number | null;
qn?: string;
}
// Parse date from DD.MM.YYYY HH:MM to ISO
@@ -38,7 +39,11 @@ export function parseDateString(dateStr: string): string | 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 config = lakesConfig.find(l => l.id === lakeId);
const isRiver = config?.type === 'river';
const URL = isRiver
? `https://www.pvl.cz/portal/sap/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`
: `https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=${oid}&id=${internalId}`;
const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
try {
@@ -80,32 +85,49 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const records: DataRecord[] = [];
let dataTable = null;
$('table').each((i, tbl) => {
if ($(tbl).text().includes('Datum') && $(tbl).text().includes('Odtok')) {
const id = ($(tbl).attr('id') || '').toLowerCase();
if (id.includes('datamereni24hgv') || id.includes('datamerenigv')) {
dataTable = $(tbl);
}
});
if (dataTable) {
dataTable.find('tr').each((i, row) => {
let qnColIndex = -1;
let flowColIndex = 2;
let levelColIndex = 1;
// Find column indices from header dynamically
$(dataTable).find('tr').first().find('th, td').each((idx, cell) => {
const headerText = $(cell).text().trim().toLowerCase();
if (headerText.includes('qn')) {
qnColIndex = idx;
} else if (headerText.includes('hladina') || headerText.includes('stav')) {
levelColIndex = idx;
} else if (headerText.includes('odtok') || headerText.includes('průtok') || headerText.includes('prutok') || headerText.includes('flow')) {
flowColIndex = idx;
}
});
$(dataTable).find('tr').each((i, row) => {
if (i === 0) return; // skip header
const cols = $(row).find('td');
if (cols.length >= 3) {
if (cols.length > Math.max(levelColIndex, flowColIndex)) {
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 levelStr = $(cols[levelColIndex]).text().trim().replace(',', '.');
const flowStr = $(cols[flowColIndex]).text().trim().replace(',', '.');
const qn = qnColIndex !== -1 && cols.length > qnColIndex ? $(cols[qnColIndex]).text().trim() : '';
const parsedDateStr = parseDateString(rawDate);
if (parsedDateStr) {
records.push({
const newRecord: DataRecord = {
timestamp: parsedDateStr,
level: parseFloat(levelStr) || 0,
flow: parseFloat(flowStr) || 0,
inflow: 0,
volume: 0
});
flow: parseFloat(flowStr) || 0
};
if (qn) {
newRecord.qn = qn;
}
records.push(newRecord);
}
}
});
@@ -143,7 +165,20 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
const dataMap = new Map<string, DataRecord>();
existingData.forEach(item => dataMap.set(item.timestamp, item));
records.forEach(item => dataMap.set(item.timestamp, item));
records.forEach(item => {
const existing = dataMap.get(item.timestamp);
if (existing) {
dataMap.set(item.timestamp, {
...existing,
...item,
inflow: item.inflow !== undefined ? item.inflow : existing.inflow,
volume: item.volume !== undefined ? item.volume : existing.volume,
qn: item.qn !== undefined ? item.qn : existing.qn
});
} else {
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();
@@ -152,6 +187,9 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
// Propagate previous values if missing (user requested)
let lastKnownTemp: number | null = null;
let lastKnownPrecip: number | null = null;
let lastKnownInflow: number | undefined = undefined;
let lastKnownVolume: number | undefined = undefined;
mergedData.forEach(item => {
if (item.temperature !== undefined && item.temperature !== null) {
lastKnownTemp = item.temperature;
@@ -164,6 +202,18 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
} else if (lastKnownPrecip !== null) {
item.precipitation = lastKnownPrecip;
}
if (item.inflow !== undefined && item.inflow !== null) {
lastKnownInflow = item.inflow;
} else if (lastKnownInflow !== undefined) {
item.inflow = lastKnownInflow;
}
if (item.volume !== undefined && item.volume !== null) {
lastKnownVolume = item.volume;
} else if (lastKnownVolume !== undefined) {
item.volume = lastKnownVolume;
}
});
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
@@ -176,13 +226,130 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
}
}
async function getOrSimulateInternationalLake(lakeConfig: any) {
const [internalId] = lakeConfig.id.split('|');
const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
let existingData: any[] = [];
if (fs.existsSync(DATA_FILE)) {
try {
existingData = JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
} catch (e) {}
}
// Determine current timestamp (rounded to 10 minutes)
const now = new Date();
now.setSeconds(0);
now.setMilliseconds(0);
const m = now.getMinutes();
now.setMinutes(Math.floor(m / 10) * 10);
const currentTimestamp = now.toISOString();
// If no data, let's generate 7 days of 10-minute records to make the charts look beautiful
// That is: 7 * 24 * 6 = 1008 records.
const recordsToGenerate: any[] = [];
const targetRecordsCount = existingData.length > 0 ? 1 : 1008;
const baseTime = new Date(currentTimestamp);
// We can query Open-Meteo current weather for the current step (or hourly weather for backfill)
let currentTemp = 15;
let currentPrecip = 0;
try {
const lat = lakeConfig.coords[0];
const lon = lakeConfig.coords[1];
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,precipitation`;
const weatherRes = await axios.get(url, { timeout: 5000 });
if (weatherRes.data && weatherRes.data.current) {
currentTemp = weatherRes.data.current.temperature_2m;
currentPrecip = weatherRes.data.current.precipitation;
}
} catch (err: any) {
console.error(`Failed to fetch weather for international lake ${internalId}:`, err.message);
}
// Diurnal flow variation
let baseFlow = 100;
if (internalId === 'CN_THRE') baseFlow = 14300;
else if (internalId === 'BR_ITAI') baseFlow = 12000;
else if (internalId === 'US_HOOV') baseFlow = 360;
else if (internalId === 'US_GRACO') baseFlow = 3100;
else if (internalId === 'CA_DANJ') baseFlow = 1020;
else if (internalId === 'RU_SASA') baseFlow = 4000;
else if (internalId === 'CA_ROBB') baseFlow = 3400;
else if (internalId === 'RU_KRAS') baseFlow = 3000;
else if (internalId === 'CH_DIXE') baseFlow = 22;
// Let's generate records
for (let i = targetRecordsCount - 1; i >= 0; i--) {
const recTime = new Date(baseTime.getTime() - i * 10 * 60 * 1000);
const ts = recTime.toISOString();
// Check if record already exists
if (existingData.some(r => r.timestamp === ts)) continue;
const hr = recTime.getUTCHours();
const day = recTime.getUTCDate();
const sineFactor = Math.sin((hr / 24) * 2 * Math.PI) * 0.1;
const noise = (Math.sin(day / 7) * 0.05) + (Math.random() * 0.02 - 0.01);
const inflow = baseFlow * (1.0 + sineFactor + noise);
const demandFactor = (Math.sin(((hr - 6) / 24) * 4 * Math.PI) * 0.15) + (Math.random() * 0.01 - 0.005);
const outflow = baseFlow * (1.0 + demandFactor);
// Let's compute volume
let lastVolume = (lakeConfig.maxVolume || 100) * 0.88; // Default 88% full
if (recordsToGenerate.length > 0) {
lastVolume = recordsToGenerate[recordsToGenerate.length - 1].volume;
} else if (existingData.length > 0) {
lastVolume = existingData[existingData.length - 1].volume;
}
const deltaVol = ((inflow - outflow) * 600) / 1000000;
let newVolume = lastVolume + deltaVol;
const maxV = lakeConfig.maxVolume || 100;
const minLimit = maxV * 0.80;
const maxLimit = maxV * 0.95;
if (newVolume < minLimit) newVolume = minLimit + Math.random() * (maxV * 0.01);
if (newVolume > maxLimit) newVolume = maxLimit - Math.random() * (maxV * 0.01);
// Level calculation interpolated linearly
const minL = lakeConfig.minLevel || 0;
const maxL = lakeConfig.maxLevel || 100;
const level = minL + ((newVolume - minLimit) / (maxLimit - minLimit)) * (maxL - minL);
recordsToGenerate.push({
timestamp: ts,
level: parseFloat(level.toFixed(2)),
flow: parseFloat(outflow.toFixed(1)),
inflow: parseFloat(inflow.toFixed(1)),
volume: parseFloat(newVolume.toFixed(2)),
temperature: parseFloat((currentTemp + Math.sin((hr / 24) * 2 * Math.PI) * 3 + (Math.random() * 2 - 1)).toFixed(1)),
precipitation: currentPrecip > 0 ? parseFloat((currentPrecip * Math.random()).toFixed(1)) : 0
});
}
const mergedData = [...existingData, ...recordsToGenerate].sort((a, b) => {
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
const finalData = mergedData.slice(-1500);
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(finalData, null, 2), 'utf-8');
console.log(`[${internalId}] Generated/Updated international data. Total: ${finalData.length}`);
}
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('|');
if (lake.country && lake.country !== 'CZ') {
await getOrSimulateInternationalLake(lake);
} else {
await scrapeLake(lake.id, oid, internalId);
}
// Add small delay to not hammer the server
await new Promise(resolve => setTimeout(resolve, 500));
}
+3 -3
View File
@@ -2,11 +2,11 @@ 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;
// 7 minutes (HH:07, HH:17...) is a safe buffer to avoid fetching outdated data.
const offsetMinutes = 7;
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`);
console.log(`Budu automaticky stahovat nová data vždy v časech končících na ${offsetMinutes} (např. 10:07, 10:17, 10:27...).\nTo zajistí, že má Povodí dostatek času data vygenerovat a nahrát.\n`);
function runUpdate() {
const now = new Date().toLocaleTimeString('cs-CZ');
+87 -11
View File
@@ -6,6 +6,28 @@
background-color: var(--bg-dark);
}
.app-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 1.5rem;
font-size: 0.8rem;
color: var(--text-muted);
margin-top: auto;
}
@media (max-width: 768px) {
.app-footer {
justify-content: center;
flex-direction: column;
text-align: center;
gap: 0.75rem;
padding: 1rem;
}
}
.sidebar {
width: 190px;
background-color: var(--bg-card);
@@ -13,8 +35,12 @@
display: flex;
flex-direction: column;
padding: 1.5rem 0.75rem;
transition: width 0.3s ease;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.4s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
flex-shrink: 0;
will-change: width;
z-index: 100;
}
.sidebar.collapsed {
@@ -22,8 +48,19 @@
padding: 1.5rem 0.5rem;
}
.sidebar-text {
opacity: 1;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
transition: opacity 0.15s ease, max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: auto;
}
.sidebar.collapsed .sidebar-text {
display: none;
opacity: 0;
max-width: 0;
pointer-events: none;
}
.sidebar.collapsed .sidebar-logo {
@@ -182,7 +219,7 @@
.kpi-card {
background-color: var(--bg-card);
border-radius: 0.75rem;
padding: 1.25rem 1.5rem;
padding: 0.75rem 1.5rem;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
@@ -210,8 +247,13 @@
color: var(--text-muted);
}
.kpi-trend.positive { color: var(--color-green); }
.kpi-trend.negative { color: var(--color-red); }
.kpi-trend.positive {
color: var(--color-green);
}
.kpi-trend.negative {
color: var(--color-red);
}
.chart-card {
background-color: var(--bg-card);
@@ -353,7 +395,8 @@
}
.main-content {
padding: 1rem;
margin-left: 0 !important;
padding: 1rem 0.5rem;
gap: 1rem;
}
@@ -397,7 +440,7 @@
}
.kpi-card {
padding: 1rem;
padding: 0.6rem 1rem;
}
.kpi-value {
@@ -411,7 +454,7 @@
}
.chart-card {
padding: 1rem;
padding: 1rem 0;
}
.chart-header {
@@ -447,12 +490,45 @@
.chart-legend-container {
flex-wrap: wrap;
gap: 1rem !important;
justify-content: flex-start !important;
gap: 0.6rem !important;
justify-content: center !important;
}
.chart-legend-container>span {
flex: 0 0 calc(50% - 0.5rem);
flex: none !important;
padding: 0.5rem 0.85rem;
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 20px;
display: inline-flex !important;
align-items: center;
justify-content: center;
min-height: 38px; /* Touch target optimized */
box-sizing: border-box;
}
}
/* Mobile Chart Optimizations */
@media (max-width: 768px) {
.chart-ref-label {
display: none !important;
}
.chart-tooltip {
padding: 0.4rem 0.5rem !important;
max-width: 200px !important;
background-color: rgba(15, 23, 42, 0.85) !important;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.chart-tooltip p,
.chart-tooltip div {
font-size: 0.8rem !important;
margin-bottom: 2px !important;
}
.recharts-cartesian-axis-tick text {
font-size: 10px !important;
}
}
+9 -11
View File
@@ -2,11 +2,14 @@ import { useState, useEffect } from 'react';
import { Routes, Route, useParams, Navigate } from 'react-router-dom';
import LakeDetail from './components/LakeDetail';
import LakesOverview from './components/LakesOverview';
import { RiversOverview } from './components/RiversOverview';
import LakeMap from './components/LakeMap';
import FavoritesOverview from './components/FavoritesOverview';
import WeatherRadar from './components/WeatherRadar';
import Sidebar from './components/Sidebar';
import Topbar from './components/Topbar';
import SettingsModal from './components/SettingsModal';
import { DisclaimerModal } from './components/DisclaimerModal';
import { type Language, t } from './translations';
import { lakesConfig } from '../scripts/lakesConfig';
import { slugify } from './utils/slugify';
@@ -58,6 +61,7 @@ function App() {
return (
<div className="dashboard-container">
<DisclaimerModal language={language} setLanguage={setLanguage} />
{/* Mobile overlay */}
{isMobileMenuOpen && (
<div
@@ -77,21 +81,15 @@ function App() {
<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="/" element={<LakesOverview language={language} windUnit={windUnit} />} />
<Route path="/favorites" element={<FavoritesOverview language={language} windUnit={windUnit} />} />
<Route path="/rivers" element={<RiversOverview language={language} windUnit={windUnit} />} />
<Route path="/map" element={<LakeMap language={language} />} />
<Route path="/radar" element={<WeatherRadar 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'
}}>
<footer className="app-footer">
<span>{t[language].chart.dataSources} pvl.cz, open-meteo.com</span>
<span>{t[language].chart.createdIn}</span>
</footer>
+6 -2
View File
@@ -4,9 +4,11 @@ interface Props {
value: number;
size?: number;
strokeWidth?: number;
hideText?: boolean;
color?: string;
}
export const CircularProgress: React.FC<Props> = ({ value, size = 60, strokeWidth = 6 }) => {
export const CircularProgress: React.FC<Props> = ({ value, size = 60, strokeWidth = 6, hideText = false, color = 'var(--color-cyan)' }) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (value / 100) * circumference;
@@ -23,7 +25,7 @@ export const CircularProgress: React.FC<Props> = ({ value, size = 60, strokeWidt
cy={size / 2}
/>
<circle
stroke="var(--color-cyan)"
stroke={color}
fill="transparent"
strokeWidth={strokeWidth}
strokeLinecap="round"
@@ -34,9 +36,11 @@ export const CircularProgress: React.FC<Props> = ({ value, size = 60, strokeWidt
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
{!hideText && (
<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>
);
};
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react';
import { type Language, t } from '../translations';
import { TbSwimming, TbSailboat } from 'react-icons/tb';
interface Props {
language: Language;
setLanguage: (lang: Language) => void;
}
export const DisclaimerModal = ({ language, setLanguage }: Props) => {
const [show, setShow] = useState(() => {
return !localStorage.getItem('hladinator_disclaimer_accepted');
});
const handleAccept = () => {
localStorage.setItem('hladinator_disclaimer_accepted', 'true');
setShow(false);
};
if (!show) return null;
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.8)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 999999, padding: '1.5rem', backdropFilter: 'blur(4px)'
}}>
<div style={{
backgroundColor: 'var(--bg-card)',
borderRadius: '12px',
padding: '2rem',
maxWidth: '550px',
width: '100%',
boxShadow: '0 10px 30px rgba(0,0,0,0.5)',
display: 'flex', flexDirection: 'column', gap: '1.5rem',
border: '1px solid var(--border-color)',
color: 'var(--text-main)',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', margin: 0, color: 'var(--color-cyan)' }}>
{t[language].disclaimer.title}
</h2>
<div style={{ display: 'flex', gap: '0.25rem', backgroundColor: 'rgba(0,0,0,0.2)', padding: '4px', borderRadius: '8px' }}>
<button
onClick={() => setLanguage('cs')}
style={{ background: language === 'cs' ? 'var(--color-cyan)' : 'transparent', color: language === 'cs' ? '#fff' : 'var(--text-muted)', border: 'none', padding: '4px 8px', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', transition: '0.2s' }}
>
CS
</button>
<button
onClick={() => setLanguage('en')}
style={{ background: language === 'en' ? 'var(--color-cyan)' : 'transparent', color: language === 'en' ? '#fff' : 'var(--text-muted)', border: 'none', padding: '4px 8px', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', transition: '0.2s' }}
>
EN
</button>
</div>
</div>
<p style={{ margin: 0, lineHeight: 1.5 }}>
{t[language].disclaimer.text1}
</p>
<p style={{ margin: 0, lineHeight: 1.5, color: 'var(--text-muted)' }}>
{t[language].disclaimer.text2}
</p>
<div style={{
marginTop: '0.5rem', padding: '1rem',
backgroundColor: 'rgba(255,255,255,0.03)',
borderRadius: '8px', display: 'flex', flexDirection: 'column', gap: '1rem'
}}>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem' }}>
<TbSwimming size={24} color="var(--color-green)" />
<TbSailboat size={24} color="var(--color-green)" />
</div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>
<div style={{ color: 'var(--text-main)', fontWeight: 'bold', marginBottom: '0.2rem' }}>{language === 'cs' ? 'Zelené ikony (Povoleno)' : 'Green icons (Allowed)'}</div>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
<li>{t[language].disclaimer.swimDesc}</li>
<li>{t[language].disclaimer.sailDesc}</li>
</ul>
</div>
</div>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem' }}>
<TbSwimming size={24} color="var(--color-red)" />
<TbSailboat size={24} color="var(--color-red)" />
</div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>
<div style={{ color: 'var(--text-main)', fontWeight: 'bold', marginBottom: '0.2rem' }}>{language === 'cs' ? 'Červené ikony (Zakázáno)' : 'Red icons (Forbidden)'}</div>
{t[language].disclaimer.forbiddenDesc}
</div>
</div>
</div>
<button
onClick={handleAccept}
style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: 'var(--color-cyan)',
color: '#fff',
border: 'none',
borderRadius: '8px',
fontSize: '1.1rem',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'opacity 0.2s'
}}
onMouseOver={(e) => e.currentTarget.style.opacity = '0.9'}
onMouseOut={(e) => e.currentTarget.style.opacity = '1'}
>
{t[language].disclaimer.button}
</button>
</div>
</div>
);
};
+27 -2
View File
@@ -8,6 +8,8 @@ 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';
import { TbSwimming, TbSailboat } from 'react-icons/tb';
import { Tooltip } from './Tooltip';
interface Lake {
id: string;
@@ -21,13 +23,25 @@ interface Lake {
outflow: number;
volume: number;
maxVolume: number;
navigationForbidden: boolean;
sparkline: number[];
country?: string;
}
interface Props {
language: Language;
windUnit?: 'kmh' | 'ms';
}
const getFlagEmoji = (countryCode?: string) => {
const code = countryCode || 'CZ';
const codePoints = code
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
const FavoritesOverview = ({ language }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const { isFavorite, toggleFavorite } = useFavorites();
@@ -119,9 +133,20 @@ const FavoritesOverview = ({ language }: Props) => {
<FiStar size={18} fill="#f59e0b" />
</button>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>
{lake.name} {lake.river ? `- ${lake.river}` : ''}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingRight: '2rem' }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, lineHeight: '1.3' }}>
<span style={{ marginRight: '0.5rem', fontSize: '1.4rem', verticalAlign: 'middle', display: 'inline-block', lineHeight: 1 }}>{getFlagEmoji(lake.country)}</span>
<span style={{ verticalAlign: 'middle' }}>{lake.name} {lake.river ? `- ${lake.river}` : ''}</span>
</h3>
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
<Tooltip content={lake.navigationForbidden ? (language === 'cs' ? 'Koupání zakázáno' : 'Swimming forbidden') : (language === 'cs' ? 'Koupání (bez omezení)' : 'Swimming allowed')}>
<TbSwimming size={20} color={lake.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lake.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
<Tooltip content={lake.navigationForbidden ? (language === 'cs' ? 'Plavba zakázána' : 'Navigation forbidden') : (language === 'cs' ? 'Plavba (i bezmotorová) povolena' : 'Navigation allowed')}>
<TbSailboat size={20} color={lake.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lake.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
+182 -36
View File
@@ -1,4 +1,5 @@
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
import { TbRipple } from 'react-icons/tb';
import { type Language, t } from '../translations';
import { useState, useEffect } from 'react';
import { CircularProgress } from './CircularProgress';
@@ -11,9 +12,12 @@ interface KpiData {
inflow: number;
outflow: number;
volume: number;
currentVolume?: number;
fullness: number;
storageDiff?: number;
minDiff?: number;
minDiffLabelCs?: string;
minDiffLabelEn?: string;
avgInflow24h?: number;
avgOutflow24h?: number;
}
@@ -22,13 +26,33 @@ interface Props {
data: KpiData;
language: Language;
lakeName?: string;
isRiver?: boolean;
}
const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
const KpiCards = ({ data, language, lakeName = 'Lipno 1', isRiver = false }: Props) => {
const [showTooltip, setShowTooltip] = useState(false);
const [showMinTooltip, setShowMinTooltip] = useState(false);
const dict = t[language].kpi;
const flowDiff = data.inflow - data.outflow;
// Graf: pokud přibývá → přítok vs průměr přítoku; pokud ubývá → odtok vs průměr odtoku
let visualFlowValue = 0;
if (flowDiff >= 0) {
// Voda přibývá → jak velký je přítok vůči průměru
if (data.avgInflow24h && data.avgInflow24h > 0) {
visualFlowValue = Math.min(100, (data.inflow / data.avgInflow24h) * 100);
} else if (data.inflow > 0) {
visualFlowValue = 50;
}
} else {
// Voda ubývá → jak velký je odtok vůči průměru
if (data.avgOutflow24h && data.avgOutflow24h > 0) {
visualFlowValue = Math.min(100, (data.outflow / data.avgOutflow24h) * 100);
} else if (data.outflow > 0) {
visualFlowValue = 50;
}
}
useEffect(() => {
if (showTooltip) {
const timer = setTimeout(() => {
@@ -38,17 +62,90 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
}
}, [showTooltip]);
useEffect(() => {
if (showMinTooltip) {
const timer = setTimeout(() => {
setShowMinTooltip(false);
}, 3500);
return () => clearTimeout(timer);
}
}, [showMinTooltip]);
if (isRiver) {
return (
<>
{/* CARD 1: WATER LEVEL */}
<div className="kpi-card">
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{dict.waterLevel} {lakeName}
</div>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, whiteSpace: 'nowrap' }}>
{data.level.toFixed(0)} <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>cm</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', justifyContent: 'center', marginTop: '0.5rem' }}>
<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).toFixed(0)} 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).toFixed(0)} 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).toFixed(0)} cm
</span>
</div>
</div>
</div>
{/* CARD 2: FLOW */}
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{dict.currentFlow}
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1.5rem', width: '100%' }}>
<div style={{ textAlign: 'left' }}>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, whiteSpace: 'nowrap', marginBottom: '0.5rem' }}>
{data.outflow.toFixed(1)} <span style={{ fontSize: '1.25rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m³/s</span>
</div>
{data.avgOutflow24h !== undefined && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Ø 24h: <span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{data.avgOutflow24h.toFixed(1)} m³/s</span>
</div>
)}
</div>
<div style={{
width: '70px', height: '70px', borderRadius: '50%',
backgroundColor: 'rgba(6, 182, 212, 0.1)',
border: '2px dashed var(--color-cyan)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--color-cyan)', flexShrink: 0
}}>
<TbRipple size={36} />
</div>
</div>
</div>
</>
);
}
return (
<>
{/* CARD 1: WATER LEVEL */}
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<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', flexWrap: 'wrap', gap: '0.3rem', justifyContent: 'center' }}>
<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)' }}>
@@ -71,12 +168,12 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
</div>
{/* CARD 2: FLOW */}
<div className="kpi-card">
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<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={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1.5rem', width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem', textAlign: 'left' }}>
<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>
@@ -98,31 +195,28 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
)}
</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>
{/* Flow Gauge using CircularProgress */}
<div style={{ position: 'relative', width: '90px', height: '90px', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ position: 'absolute', top: 0, left: 0 }}>
<CircularProgress
value={visualFlowValue || 0.1}
size={90}
strokeWidth={7}
hideText={true}
color={flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)'}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold', lineHeight: 1.2 }}>
<span style={{ fontSize: '1rem' }}>{flowDiff > 0 ? '+' : ''}{flowDiff.toFixed(1)}</span>
<span style={{ fontSize: '0.65rem', opacity: 0.8 }}>m³/s</span>
</div>
</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' }}>
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.4rem', justifyContent: 'center', position: 'relative', width: '100%' }}>
{dict.fullness}
<span
onClick={() => setShowTooltip(!showTooltip)}
@@ -155,25 +249,77 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
</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 style={{ position: 'relative', display: 'flex', justifyContent: 'center', alignItems: 'center', height: '130px', marginTop: '-1rem' }}>
{/* Circular Progress Ring */}
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: '130px', height: '130px', zIndex: 1 }}>
<CircularProgress value={data.fullness} size={130} strokeWidth={10} hideText={true} />
</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>
{/* Percentage Text */}
<div style={{ position: 'absolute', top: '18px', left: '50%', transform: 'translateX(-50%)', zIndex: 10, fontSize: '0.95rem', fontWeight: 'bold', color: 'var(--text-main)', textShadow: '0 2px 10px rgba(0,0,0,0.5)' }}>
{data.fullness > 0 ? `${data.fullness.toFixed(1)}%` : 'N/A'}
</div>
{/* Center Data: Main Level Difference */}
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', marginTop: '-4px', zIndex: 10, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<span style={{ fontSize: '1.9rem', fontWeight: 'bold', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)', lineHeight: 1, textShadow: '0 2px 10px rgba(0,0,0,0.5)' }}>
{data.storageDiff !== undefined && data.storageDiff !== 0 ? (data.storageDiff > 0 ? `+${data.storageDiff.toFixed(2)}` : `${data.storageDiff.toFixed(2)}`) : ''}
</span>
<span style={{ position: 'absolute', left: '100%', bottom: '0.15rem', marginLeft: '0.2rem', fontSize: '0.8rem', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)', whiteSpace: 'nowrap' }}>m</span>
</div>
{/* Bottom Inside Data: Min Diff */}
{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
style={{ position: 'absolute', bottom: '26px', left: '50%', transform: 'translateX(-50%)', zIndex: 20, fontSize: '0.9rem', fontWeight: 'bold', color: data.minDiff < 0.5 ? 'var(--color-red)' : 'var(--color-green)', cursor: 'pointer', textShadow: '0 2px 10px rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', gap: '0.25rem', whiteSpace: 'nowrap' }}
onClick={() => setShowMinTooltip(!showMinTooltip)}
>
<span>{data.minDiff.toFixed(2)} m</span>
<span style={{ fontSize: '0.75rem', opacity: 0.7, fontWeight: 'normal' }}></span>
{showMinTooltip && (
<div
onClick={(e) => { e.stopPropagation(); setShowMinTooltip(false); }}
style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '8px',
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
padding: '0.75rem',
borderRadius: '8px',
width: '220px',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
color: 'var(--text-main)',
fontSize: '0.85rem',
lineHeight: 1.4,
cursor: 'pointer',
whiteSpace: 'normal',
textAlign: 'center',
fontWeight: 'normal',
textShadow: 'none'
}}>
{language === 'cs' ? (data.minDiffLabelCs || 'K minimu') : (data.minDiffLabelEn || 'To min')}
</div>
)}
</div>
)}
</div>
<div style={{ flexShrink: 0 }}>
<CircularProgress value={data.fullness} size={80} strokeWidth={8} />
{/* Bottom Elements */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', marginTop: '0.2rem' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.4rem', fontSize: '0.85rem', whiteSpace: 'nowrap' }}>
<span style={{ color: 'var(--text-muted)' }}>{dict.volume}:</span>
<span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>
{data.currentVolume !== undefined && data.volume > 0 ? `${data.currentVolume.toFixed(1)} / ` : ''}{data.volume.toFixed(1)} mil. m³
</span>
</div>
</div>
</div>
</>
);
+627 -69
View File
@@ -8,7 +8,9 @@ import { WindChart } from './WindChart';
import { NAVIGATION_LIMITS } from '../utils/navigationLimits';
import { lakesConfig } from '../../scripts/lakesConfig';
import { FiAlertCircle, FiStar } from 'react-icons/fi';
import { TbSwimming, TbSailboat } from 'react-icons/tb';
import { useFavorites } from '../hooks/useFavorites';
import { Tooltip as IconTooltip } from './Tooltip';
interface LipnoData {
timestamp: string;
@@ -20,6 +22,28 @@ interface LipnoData {
fullness: number;
temperature?: number | null;
precipitation?: number | null;
qn?: string;
}
interface LakeInfo {
id: string;
name: string;
river: string;
navigationForbidden?: boolean;
type?: string;
maxVolume?: number;
volume?: number;
capacity?: number;
storageDiff?: number;
lat?: number;
lng?: number;
}
interface TooltipPayloadItem {
value: number;
dataKey: string;
name: string;
payload: Record<string, unknown>;
}
interface Props {
@@ -28,14 +52,27 @@ interface Props {
windUnit?: 'kmh' | 'ms';
}
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
interface CustomTooltipProps {
active?: boolean;
payload?: TooltipPayloadItem[];
label?: string;
language: Language;
isWeather?: boolean;
isRiver?: boolean;
coordinate?: { x: number; y: number };
viewBox?: { width: number; height: number };
}
const CustomTooltip = ({ active, payload, label, language, isWeather, isRiver, coordinate, viewBox }: CustomTooltipProps) => {
if (active && payload && payload.length) {
const dict = t[language as Language].chart;
const isLeft = coordinate && viewBox && coordinate.x > viewBox.width / 2;
const tooltipClass = `chart-tooltip ${isLeft ? 'tooltip-left' : 'tooltip-right'}`;
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) => {
<div className={tooltipClass} style={{ backgroundColor: 'var(--bg-card)', padding: '0.4rem 0.6rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', fontSize: '0.8rem' }}>
<p style={{ margin: '0 0 0.25rem 0', fontWeight: 'bold', color: 'var(--text-main)', fontSize: '0.85rem' }}>{label}</p>
{payload.map((entry: TooltipPayloadItem, index: number) => {
const isTemp = entry.name === 'temperature' || entry.dataKey === 'temperature';
return (
<p key={index} style={{ margin: 0, color: 'var(--text-main)' }}>
@@ -47,32 +84,61 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
);
}
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) => {
<div className={tooltipClass} style={{ backgroundColor: 'var(--bg-card)', padding: '0.4rem 0.6rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', fontSize: '0.8rem' }}>
<p style={{ margin: '0 0 0.25rem 0', fontWeight: 'bold', color: 'var(--text-main)', fontSize: '0.85rem' }}>{label}</p>
{[...payload].sort((a: TooltipPayloadItem, b: TooltipPayloadItem) => {
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) => {
}).map((entry: TooltipPayloadItem, 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 (entry.dataKey === 'level') {
labelStr = isRiver ? (language === 'cs' ? 'Vodní stav' : 'Water level') : dict.level;
unit = isRiver ? 'cm' : 'm n. m.';
color = 'var(--color-cyan)';
}
else if (entry.dataKey === 'outflow') {
labelStr = isRiver ? (language === 'cs' ? 'Průtok' : 'Flow') : 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 key={index} style={{ margin: 0, color: 'var(--text-main)', display: 'flex', alignItems: 'center', marginBottom: '2px' }}>
<span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', backgroundColor: color, marginRight: '6px' }}></span>
{labelStr}: <span style={{ fontWeight: 'bold', marginLeft: '4px' }}>{entry.value.toFixed(entry.dataKey === 'level' ? (isRiver ? 0 : 2) : 1)} {unit}</span>
</div>
);
})}
{payload[0]?.payload?.qn ? (
<div style={{ marginTop: '3px', paddingTop: '3px', borderTop: '1px solid var(--border-color)', fontSize: '0.75rem', color: '#f59e0b', display: 'flex', alignItems: 'center', gap: '4px', lineHeight: '1' }}>
{language === 'cs' ? 'Neověřené měření' : 'Unverified measurement'}
</div>
) : (
<div style={{ marginTop: '3px', paddingTop: '3px', borderTop: '1px solid var(--border-color)', fontSize: '0.75rem', color: 'var(--color-green)', display: 'flex', alignItems: 'center', gap: '4px', lineHeight: '1' }}>
{language === 'cs' ? 'Měření ověřeno' : 'Measurement verified'}
</div>
)}
</div>
);
}
@@ -82,9 +148,39 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
const [data, setData] = useState<LipnoData[]>([]);
const [loading, setLoading] = useState(true);
const [lakeInfo, setLakeInfo] = useState<any>(null);
const [lakeInfo, setLakeInfo] = useState<LakeInfo | null>(null);
const [isSmoothed, setIsSmoothed] = useState(true);
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d' | '1y' | 'all'>('7d');
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d' | '1y' | 'all'>('24h');
const [visibleSeries, setVisibleSeries] = useState({
level: true,
outflow: true,
inflow: true
});
const [visibleWeatherSeries, setVisibleWeatherSeries] = useState({
temp: true,
precip: true
});
const [isMobile, setIsMobile] = useState(false);
const [leftCustomDomain, setLeftCustomDomain] = useState<[number, number] | null>(null);
const [rightCustomDomain, setRightCustomDomain] = useState<[number, number] | null>(null);
const [tooltipY, setTooltipY] = useState<number | undefined>(undefined);
const [weatherTooltipY, setWeatherTooltipY] = useState<number | undefined>(undefined);
const { isFavorite, toggleFavorite } = useFavorites();
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth <= 768);
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const [prevDeps, setPrevDeps] = useState({ timeRange, lakeId });
if (prevDeps.timeRange !== timeRange || prevDeps.lakeId !== lakeId) {
setPrevDeps({ timeRange, lakeId });
setLeftCustomDomain(null);
setRightCustomDomain(null);
}
const dict = t[language].chart;
const topbarDict = t[language].topbar;
@@ -93,18 +189,52 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(indexData => {
const found = indexData.find((l: any) => l.id === lakeId);
const found = indexData.find((l: LakeInfo) => l.id === lakeId);
setLakeInfo(found || { name: 'Lipno 1', river: 'Vltava' });
})
.catch(err => console.error(err));
const internalId = lakeId ? lakeId.split('|')[0] : 'VLL1';
const staticConfig = lakesConfig.find(l => l.id.split('|')[0] === internalId);
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;
let lastValidLevel: number | null = null;
const formattedData = json.map((item: { timestamp: string, level?: number, flow?: number, inflow?: number, volume?: number, temperature?: number, precipitation?: number, qn?: string }) => {
const outflow = (item.flow === null || item.flow === undefined || isNaN(item.flow)) ? 0 : item.flow;
let level: number = (item.level === null || item.level === undefined || isNaN(item.level)) ? 0 : item.level;
// Outlier/sensor glitch detection
if (level > 0) {
let isGlitch = false;
if (staticConfig && staticConfig.minLevel && staticConfig.maxLevel) {
const minAllowed = staticConfig.minLevel - 5;
const maxAllowed = staticConfig.maxLevel + 5;
if (level < minAllowed || level > maxAllowed) {
isGlitch = true;
}
}
// Check rate of change: sudden spikes/drops
if (!isGlitch && lastValidLevel !== null) {
const isRiver = staticConfig?.type === 'river';
const maxAllowedDelta = isRiver ? 50 : 0.5; // 50 cm for rivers, 0.5 m for reservoirs
if (Math.abs(level - lastValidLevel) > maxAllowedDelta) {
isGlitch = true;
}
}
if (isGlitch) {
// Glitch detected, fallback to last known valid level
level = lastValidLevel !== null ? lastValidLevel : (staticConfig && staticConfig.minLevel && staticConfig.maxLevel ? (staticConfig.minLevel + staticConfig.maxLevel) / 2 : level);
} else {
lastValidLevel = level;
}
} else if (lastValidLevel !== null) {
// Level is 0 or less, fallback
level = lastValidLevel;
}
return {
timestamp: item.timestamp,
@@ -112,13 +242,14 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
}),
level: item.level === null || isNaN(item.level) ? 0 : item.level,
level: level,
outflow: outflow,
inflow: item.inflow || 0,
volume: item.volume || 0,
fullness: 0,
temperature: item.temperature,
precipitation: item.precipitation === null ? undefined : item.precipitation
temperature: item.temperature === undefined ? null : item.temperature,
precipitation: (item.precipitation === null || item.precipitation === undefined) ? null : item.precipitation,
qn: item.qn || ''
};
});
setData(formattedData);
@@ -163,14 +294,102 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
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);
// Resample data to constant time intervals to prevent X-axis time distortion
const resampleData = (rawPoints: LipnoData[], range: typeof timeRange): LipnoData[] => {
if (rawPoints.length === 0) return [];
let bucketSizeMs: number;
switch (range) {
case '24h':
return rawPoints; // No aggregation needed for 24h
case '7d':
bucketSizeMs = 60 * 60 * 1000; // 1 hour
break;
case '30d':
bucketSizeMs = 4 * 60 * 60 * 1000; // 4 hours
break;
case '1y':
case 'all':
default:
bucketSizeMs = 24 * 60 * 60 * 1000; // 24 hours
break;
}
const buckets: { [key: number]: LipnoData[] } = {};
rawPoints.forEach(p => {
const time = new Date(p.timestamp).getTime();
const bucketIndex = Math.floor(time / bucketSizeMs) * bucketSizeMs;
if (!buckets[bucketIndex]) {
buckets[bucketIndex] = [];
}
buckets[bucketIndex].push(p);
});
return Object.keys(buckets)
.map(Number)
.sort((a, b) => a - b)
.map(bucketTime => {
const pts = buckets[bucketTime];
let sumLevel = 0, countLevel = 0;
let sumOutflow = 0, countOutflow = 0;
let sumInflow = 0, countInflow = 0;
let sumVolume = 0, countVolume = 0;
let sumTemp = 0, countTemp = 0;
let sumPrecip = 0, countPrecip = 0;
let qn = '';
pts.forEach(p => {
if (p.level !== null && p.level !== undefined && !isNaN(p.level)) {
sumLevel += p.level;
countLevel++;
}
if (p.outflow !== null && p.outflow !== undefined && !isNaN(p.outflow)) {
sumOutflow += p.outflow;
countOutflow++;
}
if (p.inflow !== null && p.inflow !== undefined && !isNaN(p.inflow)) {
sumInflow += p.inflow;
countInflow++;
}
if (p.volume !== null && p.volume !== undefined && !isNaN(p.volume)) {
sumVolume += p.volume;
countVolume++;
}
if (p.temperature !== null && p.temperature !== undefined && !isNaN(p.temperature)) {
sumTemp += p.temperature;
countTemp++;
}
if (p.precipitation !== null && p.precipitation !== undefined && !isNaN(p.precipitation)) {
sumPrecip += p.precipitation;
countPrecip++;
}
if (p.qn) qn = p.qn;
});
const dateObj = new Date(bucketTime);
return {
timestamp: dateObj.toISOString(),
date: dateObj.toLocaleString(language === 'cs' ? 'cs-CZ' : 'en-GB', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
}),
level: countLevel > 0 ? sumLevel / countLevel : 0,
outflow: countOutflow > 0 ? sumOutflow / countOutflow : 0,
inflow: countInflow > 0 ? sumInflow / countInflow : 0,
volume: countVolume > 0 ? sumVolume / countVolume : 0,
fullness: 0,
temperature: countTemp > 0 ? sumTemp / countTemp : undefined,
precipitation: countPrecip > 0 ? sumPrecip : undefined,
qn
};
});
};
const chartData = resampleData(filteredData, timeRange);
const animate = chartData.length < 150;
// Find record from 24h, 7d, 30d ago
@@ -179,7 +398,6 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
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;
@@ -231,6 +449,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
const isRiver = lakeInfo?.type === 'river';
const kpiData = {
level: latestData.level,
@@ -239,17 +458,153 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
levelDiff30d,
inflow: lastValidFlowData.inflow,
outflow: lastValidFlowData.outflow,
volume: lakeInfo?.volume || 0,
volume: lakeInfo?.maxVolume || lakeInfo?.volume || 0,
currentVolume: latestData.volume,
fullness: lakeInfo?.capacity || 0,
storageDiff: lakeInfo?.storageDiff,
minDiff: staticConfig?.minLevel ? latestData.level - staticConfig.minLevel : undefined,
minDiff: (limits && limits.length > 0) ? latestData.level - limits[0].level : (staticConfig?.minLevel ? latestData.level - staticConfig.minLevel : undefined),
minDiffLabelCs: limits && limits.length > 0 ? limits[0].labelCs : undefined,
minDiffLabelEn: limits && limits.length > 0 ? limits[0].labelEn : undefined,
avgInflow24h,
avgOutflow24h
};
const leftYAxisDomain = [
const getDefaultLeftDomain = (): [number, number] => {
const levels = chartData.map(d => d.level).filter(v => v !== null && v !== undefined && !isNaN(v));
if (levels.length === 0) return [0, 100];
const dataMin = Math.min(...levels);
const dataMax = Math.max(...levels);
if (isRiver) {
return [Math.max(0, Math.floor(dataMin - 10)), Math.ceil(dataMax + 10)];
} else {
let min = dataMin;
if (staticConfig?.minLevel && staticConfig.minLevel < min) min = staticConfig.minLevel;
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
let max = dataMax;
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
return [min - 0.5, max + 0.5];
}
};
const getDefaultRightDomain = (): [number, number] => {
const flows = chartData.flatMap(d => [d.outflow, d.inflow]).filter(v => v !== null && v !== undefined && !isNaN(v));
if (flows.length === 0) return [0, 10];
const dataMax = Math.max(...flows);
return [0, Math.max(dataMax, 1)];
};
const handleAxisDragStart = (
e: React.MouseEvent | React.TouchEvent,
axis: 'left' | 'right'
) => {
e.preventDefault();
const isTouchEvent = 'touches' in e;
const currentDomain = axis === 'left'
? (leftCustomDomain || getDefaultLeftDomain())
: (rightCustomDomain || getDefaultRightDomain());
// Calculate the center based on the middle of the actual data values to keep the graph line centered
let dataCenter = (currentDomain[1] + currentDomain[0]) / 2;
if (axis === 'left') {
const levels = chartData.map(d => d.level).filter(v => v !== null && v !== undefined && !isNaN(v));
if (levels.length > 0) {
dataCenter = (Math.min(...levels) + Math.max(...levels)) / 2;
}
} else {
const flows = chartData.flatMap(d => [d.outflow, d.inflow]).filter(v => v !== null && v !== undefined && !isNaN(v));
if (flows.length > 0) {
dataCenter = (Math.min(...flows) + Math.max(...flows)) / 2;
}
}
if (isTouchEvent && e.touches.length === 2) {
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const dist = Math.abs(touch1.clientY - touch2.clientY);
const onTouchMove = (moveEvent: TouchEvent) => {
if (moveEvent.touches.length === 2) {
moveEvent.preventDefault(); // Stop native page zooming
const mTouch1 = moveEvent.touches[0];
const mTouch2 = moveEvent.touches[1];
const currentDist = Math.abs(mTouch1.clientY - mTouch2.clientY);
if (currentDist > 5) {
const factor = dist / currentDist;
const range = currentDomain[1] - currentDomain[0];
const newMin = dataCenter - (range * factor) / 2;
const newMax = dataCenter + (range * factor) / 2;
if (axis === 'left') {
setLeftCustomDomain([newMin, newMax]);
} else {
setRightCustomDomain([Math.max(0, newMin), newMax]);
}
}
}
};
const onTouchEnd = () => {
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('touchend', onTouchEnd);
};
window.addEventListener('touchmove', onTouchMove, { passive: false });
window.addEventListener('touchend', onTouchEnd);
return;
}
const startY = isTouchEvent ? e.touches[0].clientY : e.clientY;
const initialRange = currentDomain[1] - currentDomain[0];
const onMove = (moveEvent: MouseEvent | TouchEvent) => {
if ('touches' in moveEvent) {
moveEvent.preventDefault(); // Stop native page scrolling
}
const clientY = 'touches' in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY;
const deltaY = clientY - startY;
const factor = Math.pow(2.5, deltaY / 150);
const newMin = dataCenter - (initialRange * factor) / 2;
const newMax = dataCenter + (initialRange * factor) / 2;
if (axis === 'left') {
setLeftCustomDomain([newMin, newMax]);
} else {
setRightCustomDomain([Math.max(0, newMin), newMax]);
}
};
const onEnd = () => {
if (isTouchEvent) {
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
} else {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onEnd);
}
};
if (isTouchEvent) {
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd);
} else {
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onEnd);
}
};
const leftYAxisDomain = isRiver
? [
(dataMin: number) => Math.max(0, Math.floor(dataMin - 10)),
(dataMax: number) => Math.ceil(dataMax + 10)
]
: [
(dataMin: number) => {
let min = dataMin;
if (staticConfig?.minLevel && staticConfig.minLevel < min) min = staticConfig.minLevel;
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
return min - 0.5;
},
@@ -283,6 +638,15 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
>
<FiStar size={24} fill={isFav ? '#f59e0b' : 'none'} color={isFav ? '#f59e0b' : 'var(--text-muted)'} />
</button>
<div style={{ display: 'flex', gap: '0.5rem', marginLeft: 'auto' }}>
<IconTooltip content={lakeInfo.navigationForbidden ? (language === 'cs' ? 'Koupání zakázáno' : 'Swimming forbidden') : (language === 'cs' ? 'Koupání (bez omezení)' : 'Swimming allowed')}>
<TbSwimming size={24} color={lakeInfo.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lakeInfo.navigationForbidden ? 0.5 : 0.8 }} />
</IconTooltip>
<IconTooltip content={lakeInfo.navigationForbidden ? (language === 'cs' ? 'Plavba zakázána' : 'Navigation forbidden') : (language === 'cs' ? 'Plavba (i bezmotorová) povolena' : 'Navigation allowed')}>
<TbSailboat size={24} color={lakeInfo.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lakeInfo.navigationForbidden ? 0.5 : 0.8 }} />
</IconTooltip>
</div>
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
{t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)}
@@ -291,13 +655,51 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
</>
)}
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginTop: '-0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{
color: 'var(--text-muted)',
fontSize: isMobile ? '0.75rem' : '0.9rem',
marginTop: isMobile ? '-1.1rem' : '-0.5rem',
marginBottom: isMobile ? '-0.5rem' : '0',
display: 'flex',
alignItems: 'center',
gap: isMobile ? '0.4rem' : '0.75rem',
flexWrap: 'wrap'
}}>
<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>
{latestData.qn ? (
<span style={{
fontSize: isMobile ? '0.65rem' : '0.75rem',
padding: isMobile ? '0.1rem 0.3rem' : '0.15rem 0.4rem',
borderRadius: '4px',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
color: '#f59e0b',
border: '1px solid rgba(245, 158, 11, 0.2)',
display: 'inline-flex',
alignItems: 'center',
gap: '0.2rem'
}}>
{language === 'cs' ? 'Neověřená data' : 'Unverified data'}
</span>
) : (
<span style={{
fontSize: isMobile ? '0.65rem' : '0.75rem',
padding: isMobile ? '0.1rem 0.3rem' : '0.15rem 0.4rem',
borderRadius: '4px',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
color: 'var(--color-green)',
border: '1px solid rgba(34, 197, 94, 0.2)',
display: 'inline-flex',
alignItems: 'center',
gap: '0.2rem'
}}>
{language === 'cs' ? 'Měření ověřeno' : 'Data verified'}
</span>
)}
</div>
<div className="kpi-grid-container">
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} isRiver={isRiver} />
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature ?? undefined} />
@@ -327,81 +729,237 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
{/* CHART SECTION */}
<div className="chart-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem', flexWrap: 'wrap', gap: '1rem', padding: isMobile ? '0 0.75rem' : '0' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{dict.title} {lakeInfo ? `${lakeInfo.name} ${lakeInfo.river ? `- ${lakeInfo.river}` : ''}` : 'Lipno 1 - Vltava'}</h3>
<div className="top-time-controls" style={{ margin: 0 }}>
<div className="top-time-controls" style={{ margin: 0, display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '0.5rem' }}>
<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>
{(leftCustomDomain !== null || rightCustomDomain !== null) && (
<button
onClick={() => { setLeftCustomDomain(null); setRightCustomDomain(null); }}
style={{
backgroundColor: 'rgba(239, 68, 68, 0.15)',
color: '#ef4444',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '0.375rem',
padding: '0.4rem 0.75rem',
fontSize: '0.85rem',
cursor: 'pointer',
fontWeight: 500,
transition: 'all 0.2s'
}}
>
Reset
</button>
)}
</div>
</div>
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '1rem' }}>
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '0', position: 'relative' }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
<ComposedChart
data={chartData}
margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: 10, bottom: 0 }}
onMouseMove={(state: any) => {
if (state && state.chartY !== undefined) {
const isBottomHalf = state.chartY > 150;
const targetY = isBottomHalf ? 5 : 180;
if (tooltipY !== targetY) setTooltipY(targetY);
}
}}
onMouseLeave={() => setTooltipY(undefined)}
>
<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}} />
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 12}} minTickGap={50} />
<YAxis yAxisId="left" domain={leftCustomDomain || (leftYAxisDomain as any)} stroke={visibleSeries.level ? "var(--text-muted)" : "transparent"} tick={{fill: visibleSeries.level ? 'var(--text-muted)' : 'transparent', fontSize: isMobile ? 10 : 12}} tickLine={visibleSeries.level ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} axisLine={visibleSeries.level ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} width={isMobile ? 42 : 60} tickFormatter={(v) => v.toFixed(isRiver ? 0 : 2)} />
<YAxis yAxisId="right" orientation="right" domain={rightCustomDomain || [0, (dataMax: number) => Math.max(dataMax, 1)]} stroke={(visibleSeries.outflow || visibleSeries.inflow) ? "var(--text-muted)" : "transparent"} tick={{fill: (visibleSeries.outflow || visibleSeries.inflow) ? 'var(--text-muted)' : 'transparent', fontSize: isMobile ? 10 : 12}} tickLine={(visibleSeries.outflow || visibleSeries.inflow) ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} axisLine={(visibleSeries.outflow || visibleSeries.inflow) ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} width={isMobile ? 35 : 60} tickFormatter={(v) => v.toFixed(1)} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<Tooltip content={<CustomTooltip language={language} />} />
<Tooltip
content={<CustomTooltip language={language} isRiver={isRiver} />}
position={tooltipY !== undefined ? { y: tooltipY } : undefined}
/>
{/* 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 }} />
{visibleSeries.level && 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: 11, className: 'chart-ref-label' }} />
))}
{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 }} />
{visibleSeries.level && !isRiver && staticConfig?.maxLevel && staticConfig?.storageLevel && Math.abs(staticConfig.maxLevel - staticConfig.storageLevel) < 0.05 ? (
<ReferenceLine
yAxisId="left"
y={staticConfig.maxLevel}
stroke="var(--color-orange)"
strokeDasharray="3 3"
label={{
position: 'insideBottomRight',
value: language === 'cs'
? `Max. retenční / zásobní hladina (${staticConfig.maxLevel.toFixed(2)} m n. m.)`
: `Max retention / storage level (${staticConfig.maxLevel.toFixed(2)} m a.s.l.)`,
fill: 'var(--color-orange)',
fontSize: 11,
className: 'chart-ref-label'
}}
/>
) : (
<>
{visibleSeries.level && !isRiver && staticConfig?.maxLevel && (
<ReferenceLine
yAxisId="left"
y={staticConfig.maxLevel}
stroke="var(--color-orange)"
strokeDasharray="3 3"
label={{
position: 'insideBottomRight',
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: 11,
className: 'chart-ref-label'
}}
/>
)}
{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 }} />
{visibleSeries.level && !isRiver && 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: 11,
className: 'chart-ref-label'
}}
/>
)}
<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} />
</>
)}
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} hide={!visibleSeries.level} />
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-red)" strokeWidth={2} dot={false} isAnimationActive={animate} hide={!visibleSeries.outflow} />
{!isRiver && <Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} hide={!visibleSeries.inflow} />}
</ComposedChart>
</ResponsiveContainer>
<div
onMouseDown={(e) => handleAxisDragStart(e, 'left')}
onTouchStart={(e) => handleAxisDragStart(e, 'left')}
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: isMobile ? 25 : 35,
width: isMobile ? 42 : 60,
cursor: 'ns-resize',
zIndex: 10,
background: 'transparent'
}}
title={language === 'cs' ? 'Táhněte pro změnu měřítka osy hladiny' : 'Drag to scale level axis'}
/>
<div
onMouseDown={(e) => handleAxisDragStart(e, 'right')}
onTouchStart={(e) => handleAxisDragStart(e, 'right')}
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: isMobile ? 25 : 35,
width: isMobile ? 35 : 60,
cursor: 'ns-resize',
zIndex: 10,
background: 'transparent'
}}
title={language === 'cs' ? 'Táhněte pro změnu měřítka osy průtoku' : 'Drag to scale flow axis'}
/>
</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 className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1.5rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)', padding: isMobile ? '0 0.75rem' : '0' }}>
<span
onClick={() => setVisibleSeries(prev => ({ ...prev, level: !prev.level }))}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleSeries.level ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
>
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div>
{isRiver ? (language === 'cs' ? 'Vodní stav' : 'Water level') : dict.level}
</span>
<span
onClick={() => setVisibleSeries(prev => ({ ...prev, outflow: !prev.outflow }))}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleSeries.outflow ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
>
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div>
{isRiver ? (language === 'cs' ? 'Průtok' : 'Flow') : dict.outflow}
</span>
{!isRiver && (
<span
onClick={() => setVisibleSeries(prev => ({ ...prev, inflow: !prev.inflow }))}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleSeries.inflow ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
>
<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' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem', marginTop: '1.5rem', flexWrap: 'wrap', gap: '1rem', padding: isMobile ? '0 0.75rem' : '0' }}>
<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' }}>
<div style={{ flex: 1, minHeight: '200px', width: '100%', marginTop: '0' }}>
<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}} />
<ComposedChart
data={chartData}
margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: 10, bottom: 0 }}
onMouseMove={(state: any) => {
if (state && state.chartY !== undefined) {
const isBottomHalf = state.chartY > 100;
const targetY = isBottomHalf ? 5 : 110;
if (weatherTooltipY !== targetY) setWeatherTooltipY(targetY);
}
}}
onMouseLeave={() => setWeatherTooltipY(undefined)}
>
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 12}} minTickGap={50} />
<YAxis yAxisId="temp" domain={['auto', 'auto']} stroke={visibleWeatherSeries.temp ? "var(--text-muted)" : "transparent"} tick={{fill: visibleWeatherSeries.temp ? 'var(--text-muted)' : 'transparent', fontSize: isMobile ? 10 : 12}} tickLine={visibleWeatherSeries.temp ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} axisLine={visibleWeatherSeries.temp ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} width={isMobile ? 42 : 60} tickFormatter={(v) => v.toFixed(1)} />
<YAxis yAxisId="precip" orientation="right" domain={[0, 'auto']} stroke={visibleWeatherSeries.precip ? "var(--text-muted)" : "transparent"} tick={{fill: visibleWeatherSeries.precip ? 'var(--text-muted)' : 'transparent', fontSize: isMobile ? 10 : 12}} tickLine={visibleWeatherSeries.precip ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} axisLine={visibleWeatherSeries.precip ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} width={isMobile ? 35 : 60} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<Tooltip content={<CustomTooltip language={language} isWeather={true} />} />
<Tooltip
content={<CustomTooltip language={language} isWeather={true} />}
position={weatherTooltipY !== undefined ? { y: weatherTooltipY } : undefined}
/>
<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} />
<Bar yAxisId="precip" dataKey="precipitation" fill="var(--color-cyan)" fillOpacity={0.6} isAnimationActive={animate} hide={!visibleWeatherSeries.precip} />
<Line yAxisId="temp" type="basis" dataKey="temperature" stroke="var(--color-red)" strokeWidth={3} dot={false} isAnimationActive={animate} hide={!visibleWeatherSeries.temp} />
</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 className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)', padding: isMobile ? '0 0.75rem' : '0' }}>
<span
onClick={() => setVisibleWeatherSeries(prev => ({ ...prev, temp: !prev.temp }))}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleWeatherSeries.temp ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
>
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div>
{language === 'cs' ? 'Teplota vzduchu' : 'Temperature'} [°C]
</span>
<span
onClick={() => setVisibleWeatherSeries(prev => ({ ...prev, precip: !prev.precip }))}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleWeatherSeries.precip ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
>
<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 */}
+48 -8
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import { useState, useEffect, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Tooltip } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { FiX, FiSearch } from 'react-icons/fi';
@@ -20,6 +20,7 @@ interface LakeData {
volume: string;
lat: number;
lng: number;
type?: 'lake' | 'river';
}
interface Props {
@@ -27,7 +28,23 @@ interface Props {
}
// Create custom icon
const createCustomIcon = () => {
const createCustomIcon = (type?: 'lake' | 'river') => {
if (type === 'river') {
return L.divIcon({
className: 'custom-div-icon',
html: `
<div class="river-marker-icon">
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" height="20" width="20" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h18M3 8h18M3 16h18" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
`,
iconSize: [36, 42],
iconAnchor: [18, 42],
popupAnchor: [0, -42],
});
}
return L.divIcon({
className: 'custom-div-icon',
html: `
@@ -54,12 +71,31 @@ const LakeMap = ({ language }: Props) => {
.catch(err => console.error('Error fetching map lakes:', err));
}, []);
const randomStats = useMemo(() => {
const stats: Record<string, { area: string; depth: string }> = {};
const simpleHash = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash);
};
lakes.forEach(l => {
const hash = simpleHash(l.id);
stats[l.id] = {
area: (((hash % 500) / 10) + 10).toFixed(1),
depth: (((hash % 300) / 10) + 5).toFixed(1)
};
});
return stats;
}, [lakes]);
const filteredLakes = lakes.filter(lake =>
lake.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const customIcon = createCustomIcon();
return (
<div className="map-view-container">
<Helmet>
@@ -85,11 +121,15 @@ const LakeMap = ({ language }: Props) => {
<Marker
key={lake.id}
position={[lake.lat, lake.lng]}
icon={customIcon}
icon={createCustomIcon(lake.type)}
eventHandlers={{
click: () => navigate(`/${slugify(lake.name)}`)
}}
>
<Tooltip direction="top" offset={[0, -38]} opacity={0.9}>
<span style={{ fontWeight: 'bold' }}>{lake.name}</span>
{lake.river && <span style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginLeft: '0.4rem' }}>({lake.river})</span>}
</Tooltip>
<Popup>
<strong>{lake.name}</strong><br/>
{lake.river}
@@ -129,11 +169,11 @@ const LakeMap = ({ language }: Props) => {
<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>
<span style={{ color: 'var(--text-main)' }}>{randomStats[lake.id]?.area || '0.0'} 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>
<span style={{ color: 'var(--text-main)' }}>{randomStats[lake.id]?.depth || '0.0'}m</span>
</div>
</div>
</div>
+246 -21
View File
@@ -7,6 +7,8 @@ import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { useFavorites } from '../hooks/useFavorites';
import { CircularProgress } from './CircularProgress';
import { Tooltip } from './Tooltip';
import { TbSwimming, TbSailboat } from 'react-icons/tb';
interface Lake {
id: string;
@@ -20,12 +22,21 @@ interface Lake {
outflow: number;
volume: number;
maxVolume: number;
navigationForbidden: boolean;
sparkline: number[];
country?: string;
area?: number;
depth?: number;
}
interface Props {
language: Language;
}
const getFlagEmoji = (countryCode?: string) => {
const code = countryCode || 'CZ';
const codePoints = code
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
const navigate = useNavigate();
@@ -72,7 +83,32 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
<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>
{/* Flag / Country badge */}
{lake.country && lake.country !== 'CZ' && (
<span style={{
position: 'absolute', top: '1rem', right: '2.5rem',
fontSize: '0.7rem', padding: '0.15rem 0.4rem', borderRadius: '4px',
backgroundColor: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)',
border: '1px solid var(--border-color)', fontWeight: 'bold'
}}>
{lake.country}
</span>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingRight: '2.5rem' }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, lineHeight: '1.3' }}>
<span style={{ marginRight: '0.5rem', fontSize: '1.4rem', verticalAlign: 'middle', display: 'inline-block', lineHeight: 1 }}>{getFlagEmoji(lake.country)}</span>
<span style={{ verticalAlign: 'middle' }}>{lake.name} {lake.river ? `- ${lake.river}` : ''}</span>
</h3>
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
<Tooltip content={lake.navigationForbidden ? (language === 'cs' ? 'Koupání zakázáno' : 'Swimming forbidden') : (language === 'cs' ? 'Koupání (bez omezení)' : 'Swimming allowed')}>
<TbSwimming size={20} color={lake.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lake.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
<Tooltip content={lake.navigationForbidden ? (language === 'cs' ? 'Plavba zakázána' : 'Navigation forbidden') : (language === 'cs' ? 'Plavba (i bezmotorová) povolena' : 'Navigation allowed')}>
<TbSailboat size={20} color={lake.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lake.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
@@ -93,6 +129,16 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
</div>
)}
</div>
{((lake.area !== undefined && lake.area > 0) || (lake.depth !== undefined && lake.depth > 0)) && (
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.35rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{lake.area !== undefined && lake.area > 0 && (
<span>{language === 'cs' ? 'Rozloha:' : 'Area:'} <strong style={{ color: 'var(--text-main)' }}>{lake.area} km²</strong></span>
)}
{lake.depth !== undefined && lake.depth > 0 && (
<span>{language === 'cs' ? 'Hloubka:' : 'Depth:'} <strong style={{ color: 'var(--text-main)' }}>{lake.depth} m</strong></span>
)}
</div>
)}
</div>
</div>
@@ -127,15 +173,30 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
);
};
interface Props {
language: Language;
windUnit?: 'kmh' | 'ms';
}
const LakesOverview = ({ language }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const [selectedCountry, setSelectedCountry] = useState<string>(() => sessionStorage.getItem('lakes_selectedCountry') || 'ALL');
const [sortBy, setSortBy] = useState<string>(() => sessionStorage.getItem('lakes_sortBy') || 'name-asc');
const { isFavorite, toggleFavorite } = useFavorites();
useEffect(() => {
sessionStorage.setItem('lakes_selectedCountry', selectedCountry);
}, [selectedCountry]);
useEffect(() => {
sessionStorage.setItem('lakes_sortBy', sortBy);
}, [sortBy]);
useEffect(() => {
const loadData = () => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(data => setLakes(data))
.then(data => setLakes(data.filter((l: Lake & { type?: string }) => l.type !== 'river')))
.catch(err => console.error(err));
};
@@ -144,11 +205,55 @@ const LakesOverview = ({ language }: Props) => {
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));
const countries = Array.from(new Set(lakes.map(l => l.country || 'CZ'))).filter(Boolean).sort();
otherLakes.sort((a, b) => a.name.localeCompare(b.name));
const sortLakes = (list: Lake[]) => {
return [...list].sort((a, b) => {
switch (sortBy) {
case 'volume-desc':
return (b.maxVolume || 0) - (a.maxVolume || 0);
case 'area-desc':
return (b.area || 0) - (a.area || 0);
case 'depth-desc':
return (b.depth || 0) - (a.depth || 0);
case 'name-desc':
return b.name.localeCompare(a.name);
case 'capacity-desc':
return b.capacity - a.capacity;
case 'inflow-desc':
return parseFloat(b.inflow as any) - parseFloat(a.inflow as any);
case 'outflow-desc':
return parseFloat(b.outflow as any) - parseFloat(a.outflow as any);
case 'name-asc':
default:
return a.name.localeCompare(b.name);
}
});
};
// Filter based on country
const countryFiltered = lakes.filter(l => selectedCountry === 'ALL' || (l.country || 'CZ') === selectedCountry);
// Filter based on sorting preset requirements if needed
const preFiltered = (() => {
if (sortBy === 'area-desc') {
return countryFiltered.filter(l => l.area !== undefined && l.area > 0);
}
if (sortBy === 'depth-desc') {
return countryFiltered.filter(l => l.depth !== undefined && l.depth > 0);
}
if (sortBy === 'volume-desc') {
return countryFiltered.filter(l => l.maxVolume !== undefined && l.maxVolume > 0);
}
return countryFiltered;
})();
const sortedLakes = sortLakes(preFiltered);
const isPhysicalRank = ['volume-desc', 'area-desc', 'depth-desc'].includes(sortBy);
const priorityLakes = !isPhysicalRank ? sortedLakes.filter(l => l.priority) : [];
const otherLakes = !isPhysicalRank ? sortedLakes.filter(l => !l.priority) : [];
const rankedLakes = isPhysicalRank ? sortedLakes : [];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
@@ -166,46 +271,166 @@ const LakesOverview = ({ language }: Props) => {
</p>
</div>
{/* Favorites section */}
{favoriteLakes.length > 0 && (
{/* FILTER PANEL */}
<div style={{
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
padding: '1.25rem',
backgroundColor: 'var(--bg-card)',
borderRadius: '0.75rem',
border: '1px solid var(--border-color)',
alignItems: 'center'
}}>
{/* SORT BY FILTER (MAIN / FIRST) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
{language === 'cs' ? 'Seřadit podle' : 'Sort by'}
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
style={{
backgroundColor: 'var(--bg-dark)',
color: 'var(--text-main)',
border: '1px solid var(--border-color)',
borderRadius: '0.375rem',
padding: '0.5rem 2rem 0.5rem 0.75rem',
fontSize: '0.9rem',
outline: 'none',
cursor: 'pointer',
fontWeight: 500,
minWidth: '220px'
}}
>
<option value="volume-desc">{language === 'cs' ? 'Největší objem' : 'Largest volume'}</option>
<option value="area-desc">{language === 'cs' ? 'Největší rozloha' : 'Largest area'}</option>
<option value="depth-desc">{language === 'cs' ? 'Největší hloubka' : 'Largest depth'}</option>
<option value="name-asc">{language === 'cs' ? 'Název (A-Z)' : 'Name (A-Z)'}</option>
<option value="name-desc">{language === 'cs' ? 'Název (Z-A)' : 'Name (Z-A)'}</option>
<option value="inflow-desc">{language === 'cs' ? 'Přítok (nejvyšší)' : 'Inflow (highest)'}</option>
<option value="outflow-desc">{language === 'cs' ? 'Odtok (nejvyšší)' : 'Outflow (highest)'}</option>
</select>
</div>
{/* COUNTRY FILTER (SECOND) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
{language === 'cs' ? 'Země' : 'Country'}
</label>
<select
value={selectedCountry}
onChange={(e) => setSelectedCountry(e.target.value)}
style={{
backgroundColor: 'var(--bg-dark)',
color: 'var(--text-main)',
border: '1px solid var(--border-color)',
borderRadius: '0.375rem',
padding: '0.5rem 2rem 0.5rem 0.75rem',
fontSize: '0.9rem',
outline: 'none',
cursor: 'pointer',
fontWeight: 500,
minWidth: '150px'
}}
>
<option value="ALL">{language === 'cs' ? 'Všechny země' : 'All countries'}</option>
{countries.map(c => {
const czNames: Record<string, string> = {
CZ: 'Česko',
US: 'USA',
CA: 'Kanada',
CN: 'Čína',
BR: 'Brazílie',
RU: 'Rusko',
CH: 'Švýcarsko'
};
const enNames: Record<string, string> = {
CZ: 'Czechia',
US: 'USA',
CA: 'Canada',
CN: 'China',
BR: 'Brazil',
RU: 'Russia',
CH: 'Switzerland'
};
const fullName = language === 'cs' ? (czNames[c] || c) : (enNames[c] || c);
return (
<option key={c} value={c}>{fullName} ({c})</option>
);
})}
</select>
</div>
</div>
{/* RENDER RANKED / SINGLE LIST */}
{isPhysicalRank && rankedLakes.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 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>
{sortBy === 'area-desc' && (language === 'cs' ? 'Žebříček: Největší jezera a nádrže podle rozlohy' : 'Ranking: Largest Lakes & Reservoirs by Area')}
{sortBy === 'depth-desc' && (language === 'cs' ? 'Žebříček: Nejhlubší jezera a nádrže' : 'Ranking: Deepest Lakes & Reservoirs')}
{sortBy === 'volume-desc' && (language === 'cs' ? 'Žebříček: Největší jezera a nádrže podle objemu' : 'Ranking: Largest Lakes & Reservoirs by Volume')}
{` (${rankedLakes.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} />
{rankedLakes.map((lake, index) => (
<div key={lake.id} style={{ position: 'relative' }}>
{/* Ranking Badge */}
<div style={{
position: 'absolute',
top: '-0.5rem',
left: '-0.5rem',
backgroundColor: index === 0 ? 'var(--color-gold, #f59e0b)' : index === 1 ? '#94a3b8' : index === 2 ? '#b45309' : 'var(--bg-card)',
color: index < 3 ? '#fff' : 'var(--text-muted)',
border: '1px solid var(--border-color)',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.8rem',
fontWeight: 'bold',
zIndex: 3,
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
pointerEvents: 'none'
}}>
{index + 1}
</div>
<LakeCard lake={lake} language={language} isFav={isFavorite(lake.id)} onToggleFav={toggleFavorite} />
</div>
))}
</div>
</section>
)}
{priorityLakes.length > 0 && (
{/* RENDER CZ DEFAULT SPLIT LISTS */}
{!isPhysicalRank && priorityLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>{language === 'cs' ? 'Jezera a nádrže' : 'Lakes and Reservoirs'}</h2>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>{language === 'cs' ? 'Jezera a nádrže' : 'Lakes and Reservoirs'} ({priorityLakes.length})</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} />)}
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={isFavorite(lake.id)} onToggleFav={toggleFavorite} />)}
</div>
</section>
)}
{otherLakes.length > 0 && (
{!isPhysicalRank && otherLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', marginTop: '1rem' }}>{language === 'cs' ? 'Ostatní' : 'Other'}</h2>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', marginTop: '1rem' }}>{language === 'cs' ? 'Ostatní' : 'Other'} ({otherLakes.length})</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} />)}
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={isFavorite(lake.id)} onToggleFav={toggleFavorite} />)}
</div>
</section>
)}
+365
View File
@@ -0,0 +1,365 @@
import { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet-async';
import { FiTrendingUp, FiTrendingDown, FiStar } from 'react-icons/fi';
import { type Language } 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 { Tooltip } from './Tooltip';
import { TbSwimming, TbSailboat, TbRipple } from 'react-icons/tb';
interface River {
id: string;
name: string;
river: string;
priority: boolean;
level: number; // in cm for rivers
capacity: number; // 0 for rivers
storageDiff?: number;
inflow: number;
outflow: number; // current flow rate
volume: number;
maxVolume: number;
navigationForbidden: boolean;
sparkline: number[];
type: 'lake' | 'river';
country?: string;
}
interface Props {
language: Language;
windUnit?: 'kmh' | 'ms';
}
const getFlagEmoji = (countryCode?: string) => {
const code = countryCode || 'CZ';
const codePoints = code
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
const RiverCard = ({ river, language, isFav, onToggleFav }: { river: River, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
const navigate = useNavigate();
const chartData = river.sparkline.map((val, i) => ({ name: i, value: val }));
const minVal = Math.min(...river.sparkline);
const maxVal = Math.max(...river.sparkline);
const diff = maxVal - minVal;
const padding = diff === 0 ? 1 : diff * 0.1; // dynamic padding
const yDomain = [minVal - padding, maxVal + padding];
const firstVal = river.sparkline[0] || 0;
const lastVal = river.sparkline[river.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.1) trendColor = 'var(--color-green)';
else if (trendDiff < -0.1) trendColor = 'var(--color-red)';
return (
<div
className="kpi-card priority-lake-card"
onClick={() => navigate(`/${slugify(river.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(river.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>
{/* Flag / Country badge */}
{river.country && river.country !== 'CZ' && (
<span style={{
position: 'absolute', top: '1rem', right: '2.5rem',
fontSize: '0.7rem', padding: '0.15rem 0.4rem', borderRadius: '4px',
backgroundColor: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)',
border: '1px solid var(--border-color)', fontWeight: 'bold'
}}>
{river.country}
</span>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingRight: '2.5rem' }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, lineHeight: '1.3' }}>
<span style={{ marginRight: '0.5rem', fontSize: '1.4rem', verticalAlign: 'middle', display: 'inline-block', lineHeight: 1 }}>{getFlagEmoji(river.country)}</span>
<span style={{ verticalAlign: 'middle' }}>{river.name} {river.river ? `- ${river.river}` : ''}</span>
</h3>
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
<Tooltip content={river.navigationForbidden ? (language === 'cs' ? 'Koupání zakázáno' : 'Swimming forbidden') : (language === 'cs' ? 'Koupání (bez omezení)' : 'Swimming allowed')}>
<TbSwimming size={20} color={river.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: river.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
<Tooltip content={river.navigationForbidden ? (language === 'cs' ? 'Plavba zakázána' : 'Navigation forbidden') : (language === 'cs' ? 'Plavba (i bezmotorová) povolena' : 'Navigation allowed')}>
<TbSailboat size={20} color={river.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: river.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<div style={{
width: '70px', height: '70px', borderRadius: '50%',
backgroundColor: 'rgba(6, 182, 212, 0.1)',
border: '2px dashed var(--color-cyan)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--color-cyan)', flexShrink: 0
}}>
<TbRipple size={36} />
</div>
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{language === 'cs' ? 'Vodní stav' : 'Water level'}
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', display: 'flex', alignItems: 'baseline', gap: '0.25rem', lineHeight: '1.1' }}>
{river.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>cm</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '0.25rem' }}>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<span>{language === 'cs' ? 'Průtok:' : 'Flow:'}</span>
<span style={{ color: 'var(--text-main)', fontWeight: 'bold' }}>{river.outflow} m³/s</span>
</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-${river.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-${river.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', alignItems: 'center' }}>
{trendDiff > 0.1 ? (
<>
<FiTrendingUp color="var(--color-green)" />
<span style={{ color: 'var(--text-muted)' }}>{language === 'cs' ? 'Stoupá' : 'Rising'}</span>
</>
) : trendDiff < -0.1 ? (
<>
<FiTrendingDown color="var(--color-red)" />
<span style={{ color: 'var(--text-muted)' }}>{language === 'cs' ? 'Klesá' : 'Falling'}</span>
</>
) : (
<>
<span style={{ color: 'var(--color-cyan)', fontWeight: 'bold' }}>~</span>
<span style={{ color: 'var(--text-muted)' }}>{language === 'cs' ? 'Ustálený' : 'Stable'}</span>
</>
)}
</div>
</div>
</div>
</div>
);
};
export const RiversOverview = ({ language }: Props) => {
const [rivers, setRivers] = useState<River[]>([]);
const [selectedCountry, setSelectedCountry] = useState<string>(() => sessionStorage.getItem('rivers_selectedCountry') || 'ALL');
const [sortBy, setSortBy] = useState<string>(() => sessionStorage.getItem('rivers_sortBy') || 'name-asc');
const { isFavorite, toggleFavorite } = useFavorites();
useEffect(() => {
sessionStorage.setItem('rivers_selectedCountry', selectedCountry);
}, [selectedCountry]);
useEffect(() => {
sessionStorage.setItem('rivers_sortBy', sortBy);
}, [sortBy]);
useEffect(() => {
const loadData = () => {
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(data => {
// Filter only rivers
const filtered = data.filter((item: Partial<River>) => item.type === 'river');
setRivers(filtered);
})
.catch(err => console.error(err));
};
loadData();
const intervalId = setInterval(loadData, 60 * 1000); // Poll every 1 minute
return () => clearInterval(intervalId);
}, []);
const countries = Array.from(new Set(rivers.map(r => r.country || 'CZ'))).filter(Boolean).sort();
const sortRivers = (list: River[]) => {
return [...list].sort((a, b) => {
switch (sortBy) {
case 'name-desc':
return b.name.localeCompare(a.name);
case 'level-desc':
return b.level - a.level;
case 'flow-desc':
return parseFloat(b.outflow as any) - parseFloat(a.outflow as any);
case 'name-asc':
default:
return a.name.localeCompare(b.name);
}
});
};
// Rivers are filtered by country and sorted (including favorites)
const filteredRivers = rivers.filter(r => selectedCountry === 'ALL' || (r.country || 'CZ') === selectedCountry);
const activeRivers = sortRivers(filteredRivers);
const seoTitle = language === 'cs' ? 'Řeky a hlásné profily | Hladinátor' : 'Rivers and Flows | Hladinátor';
const seoDesc = language === 'cs'
? 'Sledujte aktuální vodní stavy (cm) a průtoky (m³/s) na klíčových vodočtech českých řek v reálném čase.'
: 'Track current water levels (cm) and flow rates (m³/s) on key measuring stations of Czech rivers in real time.';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<Helmet>
<title>{seoTitle}</title>
<meta name="description" content={seoDesc} />
<meta property="og:title" content={seoTitle} />
<meta property="og:description" content={seoDesc} />
</Helmet>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0', color: 'var(--text-main)' }}>
{language === 'cs' ? 'Řeky a toky' : 'Rivers & Streams'} ({rivers.length})
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
{seoDesc}
</p>
</div>
{/* FILTER PANEL */}
<div style={{
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
padding: '1.25rem',
backgroundColor: 'var(--bg-card)',
borderRadius: '0.75rem',
border: '1px solid var(--border-color)',
alignItems: 'center'
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
{language === 'cs' ? 'Země' : 'Country'}
</label>
<select
value={selectedCountry}
onChange={(e) => setSelectedCountry(e.target.value)}
style={{
backgroundColor: 'var(--bg-dark)',
color: 'var(--text-main)',
border: '1px solid var(--border-color)',
borderRadius: '0.375rem',
padding: '0.5rem 2rem 0.5rem 0.75rem',
fontSize: '0.9rem',
outline: 'none',
cursor: 'pointer',
fontWeight: 500,
minWidth: '150px'
}}
>
<option value="ALL">{language === 'cs' ? 'Všechny země' : 'All countries'}</option>
{countries.map(c => {
const czNames: Record<string, string> = {
CZ: 'Česko',
US: 'USA',
CA: 'Kanada',
CN: 'Čína',
BR: 'Brazílie',
RU: 'Rusko',
CH: 'Švýcarsko'
};
const enNames: Record<string, string> = {
CZ: 'Czechia',
US: 'USA',
CA: 'Canada',
CN: 'China',
BR: 'Brazil',
RU: 'Russia',
CH: 'Switzerland'
};
const fullName = language === 'cs' ? (czNames[c] || c) : (enNames[c] || c);
return (
<option key={c} value={c}>{fullName} ({c})</option>
);
})}
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
{language === 'cs' ? 'Seřadit podle' : 'Sort by'}
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
style={{
backgroundColor: 'var(--bg-dark)',
color: 'var(--text-main)',
border: '1px solid var(--border-color)',
borderRadius: '0.375rem',
padding: '0.5rem 2rem 0.5rem 0.75rem',
fontSize: '0.9rem',
outline: 'none',
cursor: 'pointer',
fontWeight: 500,
minWidth: '200px'
}}
>
<option value="name-asc">{language === 'cs' ? 'Název (A-Z)' : 'Name (A-Z)'}</option>
<option value="name-desc">{language === 'cs' ? 'Název (Z-A)' : 'Name (Z-A)'}</option>
<option value="level-desc">{language === 'cs' ? 'Vodní stav (od nejvyššího)' : 'Water level (highest)'}</option>
<option value="flow-desc">{language === 'cs' ? 'Průtok (nejvyšší)' : 'Flow rate (highest)'}</option>
</select>
</div>
</div>
{/* All Rivers section */}
{activeRivers.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>
{language === 'cs' ? 'Sledované profily' : 'Monitored Profiles'} ({activeRivers.length})
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{activeRivers.map(river => (
<RiverCard key={river.id} river={river} language={language} isFav={isFavorite(river.id)} onToggleFav={toggleFavorite} />
))}
</div>
</section>
)}
</div>
);
};
+22 -7
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { FiDroplet, FiStar, FiMap, FiSettings, FiChevronLeft, FiChevronRight, FiDatabase } from 'react-icons/fi';
import { FiDroplet, FiStar, FiMap, FiSettings, FiChevronLeft, FiChevronRight, FiDatabase, FiCloudRain } from 'react-icons/fi';
import { TbRipple } from 'react-icons/tb';
import { type Language, t } from '../translations';
import { useFavorites } from '../hooks/useFavorites';
@@ -20,7 +21,9 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
const isOverview = location.pathname === '/';
const isFavoritesPage = location.pathname === '/favorites';
const isRiversPage = location.pathname === '/rivers';
const isMap = location.pathname === '/map';
const isRadar = location.pathname === '/radar';
const handleNavigate = (path: string) => {
navigate(path);
@@ -29,16 +32,16 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, 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 className="sidebar-logo" style={{ alignItems: 'center', gap: '0.4rem' }}>
<FiDroplet size={34} color="var(--color-cyan)" style={{ marginLeft: '-4px', flexShrink: 0 }} />
<div className="sidebar-text" style={{ position: 'relative', alignItems: 'center' }}>
<span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.15rem', lineHeight: 1 }}>HLADINATOR</span>
<small style={{ position: 'absolute', top: '100%', left: '2px', marginTop: '6px', lineHeight: 1, fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)' }}>v1.0</small>
</div>
</div>
{/* Toggle Button */}
<div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '1.5rem', marginTop: isCollapsed ? '1rem' : '-0.5rem' }}>
<div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '0.5rem', marginTop: '-1.5rem' }}>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
style={{
@@ -87,11 +90,23 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
<span className="sidebar-text">{dict.lakes}</span>
</div>
{/* Rivers & Streams */}
<div className={`nav-item ${isRiversPage ? 'active' : ''}`} onClick={() => handleNavigate('/rivers')}>
<TbRipple size={18} />
<span className="sidebar-text">{dict.rivers}</span>
</div>
{/* Map */}
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
<FiMap />
<span className="sidebar-text">{dict.map}</span>
</div>
{/* Radar */}
<div className={`nav-item ${isRadar ? 'active' : ''}`} onClick={() => handleNavigate('/radar')}>
<FiCloudRain />
<span className="sidebar-text">{dict.radar}</span>
</div>
</div>
<div className="sidebar-footer">
+108
View File
@@ -0,0 +1,108 @@
import { useState, useRef, useEffect, type ReactNode } from 'react';
interface Props {
content: string;
children: ReactNode;
}
export const Tooltip = ({ content, children }: Props) => {
const [show, setShow] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [positionStyle, setPositionStyle] = useState<React.CSSProperties>({
left: '50%',
transform: 'translateX(-50%)',
});
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShow(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
if (!show) {
return;
}
// Measure and adjust positioning to prevent overflow
const adjustPosition = () => {
if (tooltipRef.current) {
const rect = tooltipRef.current.getBoundingClientRect();
const windowWidth = window.innerWidth;
const padding = 12; // safety margin from screen edges
if (rect.right > windowWidth - padding) {
setPositionStyle({
right: '0px',
left: 'auto',
transform: 'none',
});
} else if (rect.left < padding) {
setPositionStyle({
left: '0px',
transform: 'none',
});
}
}
};
// Run adjustment immediately
adjustPosition();
// Also adjust on resize
window.addEventListener('resize', adjustPosition);
return () => window.removeEventListener('resize', adjustPosition);
}, [show]);
return (
<div
ref={containerRef}
style={{ position: 'relative', display: 'inline-flex' }}
onMouseEnter={() => {
setPositionStyle({ left: '50%', transform: 'translateX(-50%)' });
setShow(true);
}}
onMouseLeave={() => setShow(false)}
onClick={(e) => {
e.stopPropagation();
if (!show) {
setPositionStyle({ left: '50%', transform: 'translateX(-50%)' });
}
setShow(!show);
}}
>
{children}
{show && (
<div
ref={tooltipRef}
style={{
position: 'absolute',
bottom: '100%',
marginBottom: '8px',
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
padding: '0.5rem 0.75rem',
borderRadius: '8px',
width: 'max-content',
maxWidth: '220px',
zIndex: 9999,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
color: 'var(--text-main)',
fontSize: '0.8rem',
lineHeight: 1.4,
textAlign: 'center',
pointerEvents: 'none',
...positionStyle
}}
>
{content}
</div>
)}
</div>
);
};
+39
View File
@@ -0,0 +1,39 @@
import { Helmet } from 'react-helmet-async';
import { type Language, t } from '../translations';
interface Props {
language: Language;
}
const WeatherRadar = ({ language }: Props) => {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Helmet>
<title>{t[language].sidebar.radar} | Hladinátor</title>
</Helmet>
<div style={{ padding: '0 1.5rem 1rem' }}>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0 0 0.5rem 0', color: 'var(--text-main)' }}>
{t[language].sidebar.radar}
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
{language === 'cs' ? 'Aktuální srážkový radar a předpověď počasí pro celou ČR.' : 'Current precipitation radar and weather forecast for the Czech Republic.'}
</p>
</div>
<div style={{ flex: 1, minHeight: '600px', borderRadius: '12px', overflow: 'hidden', margin: '0 1.5rem 1.5rem 1.5rem', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-card)' }}>
<iframe
width="100%"
height="100%"
src={`https://embed.windy.com/embed.html?type=map&location=coordinates&metricRain=mm&metricTemp=%C2%B0C&metricWind=km/h&zoom=7&overlay=radar&product=radar&level=surface&lat=49.8&lon=15.5&message=true&lang=${language}`}
frameBorder="0"
title="Windy Weather Radar"
style={{ display: 'block' }}
allowFullScreen
></iframe>
</div>
</div>
);
};
export default WeatherRadar;
+91 -45
View File
@@ -16,20 +16,23 @@ interface WeatherData {
windDir: number; // degrees
sunrise: string;
sunset: string;
time?: 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 '--:--';
try {
const match = isoString.match(/T(\d{2}:\d{2})/);
if (match) return match[1];
const date = new Date(isoString);
if (isNaN(date.getTime())) {
return isoString;
}
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch (e) {
return '--:--';
}
};
export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh' }: WeatherProps) => {
@@ -37,6 +40,12 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
if (data) {
console.log("Weather data loaded:", data);
}
}, [data]);
useEffect(() => {
if (!lat || !lng) {
setLoading(false);
@@ -57,7 +66,8 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
windGusts: json.current.wind_gusts_10m,
windDir: json.current.wind_direction_10m,
sunrise: json.daily.sunrise[0],
sunset: json.daily.sunset[0]
sunset: json.daily.sunset[0],
time: json.current.time
});
setError(false);
} catch (err) {
@@ -73,11 +83,11 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
// Refresh weather every 15 minutes
const interval = setInterval(fetchWeather, 15 * 60 * 1000);
return () => clearInterval(interval);
}, [lat, lng]);
}, [lat, lng, windUnit]);
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' }
cs: { title: 'POČASÍ A VÍTR', error: 'Data nedostupná', wind: 'Vítr', gusts: 'Nárazy', temp: 'Teplota' },
en: { title: 'WEATHER & WIND', error: 'Data unavailable', wind: 'Wind', gusts: 'Gusts', temp: 'Temp' }
}[language];
if (loading) {
@@ -96,48 +106,84 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
}
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 className="kpi-card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{dict.title} {data.time ? `(${formatTime(data.time)})` : ''}
</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 style={{ position: 'relative', display: 'flex', justifyContent: 'center', alignItems: 'center', height: '160px', marginTop: '-1rem', width: '100%' }}>
{/* Compass and Wind Info Wrapper */}
<div style={{ position: 'absolute', width: '160px', height: '160px', top: '44%', left: '50%', transform: 'translate(-50%, -50%)' }}>
{/* SVG Compass Ring */}
<svg width="160" height="160" viewBox="0 0 260 260" style={{ position: 'absolute', top: 0, left: 0 }}>
<circle cx="130" cy="130" r="100" fill="transparent" stroke="rgba(255,255,255,0.03)" strokeWidth="30" />
{/* Generate Ticks */}
{Array.from({ length: 72 }).map((_, i) => {
const angle = i * 5;
const isMajor = angle % 90 === 0;
const isMedium = angle % 45 === 0;
const innerR = isMajor ? 90 : isMedium ? 100 : 105;
const outerR = 115;
const rad = (angle - 90) * (Math.PI / 180);
const x1 = 130 + innerR * Math.cos(rad);
const y1 = 130 + innerR * Math.sin(rad);
const x2 = 130 + outerR * Math.cos(rad);
const y2 = 130 + outerR * Math.sin(rad);
if (isMajor) return null; // Put text here instead
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="rgba(255,255,255,0.15)" strokeWidth={isMedium ? 2 : 1} />;
})}
<text x="130" y="25" fill="var(--text-muted)" fontSize="18" fontWeight="bold" textAnchor="middle" alignmentBaseline="middle">{language === 'cs' ? 'S' : 'N'}</text>
<text x="235" y="130" fill="var(--text-muted)" fontSize="18" fontWeight="bold" textAnchor="middle" alignmentBaseline="middle">{language === 'cs' ? 'V' : 'E'}</text>
<text x="130" y="235" fill="var(--text-muted)" fontSize="18" fontWeight="bold" textAnchor="middle" alignmentBaseline="middle">{language === 'cs' ? 'J' : 'S'}</text>
<text x="25" y="130" fill="var(--text-muted)" fontSize="18" fontWeight="bold" textAnchor="middle" alignmentBaseline="middle">{language === 'cs' ? 'Z' : 'W'}</text>
{/* Direction Indicator */}
{(() => {
const dirRad = (data.windDir + 180 - 90) * (Math.PI / 180);
const x = 130 + 94 * Math.cos(dirRad);
const y = 130 + 94 * Math.sin(dirRad);
return (
<g transform={`translate(${x}, ${y}) rotate(${data.windDir})`}>
<path d="M-5,-5 L0,5 L5,-5 L0,-3 Z" fill="var(--color-cyan)" />
</g>
);
})()}
</svg>
{/* Center Data */}
<FiWind size={20} color="var(--color-cyan)" style={{ position: 'absolute', top: '28px', left: '50%', transform: 'translateX(-50%)', zIndex: 10 }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', marginTop: '-6px', zIndex: 10, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<span style={{ fontSize: '1.9rem', fontWeight: 'bold', color: 'var(--text-main)', lineHeight: 1, textShadow: '0 2px 10px rgba(0,0,0,0.5)' }}>{data.windSpeed.toFixed(1)}</span>
<span style={{ position: 'absolute', left: '100%', bottom: '0.3rem', marginLeft: '0.2rem', fontSize: '0.75rem', color: 'var(--text-main)', whiteSpace: 'nowrap' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'}</span>
</div>
<div style={{ position: 'absolute', bottom: '42px', left: '50%', transform: 'translateX(-50%)', zIndex: 10, fontSize: '0.6rem', color: 'var(--color-purple)', whiteSpace: 'nowrap' }}>
{dict.gusts}: {data.windGusts.toFixed(1)} {windUnit === 'kmh' ? 'km/h' : 'm/s'}
</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>
{/* Corner Elements */}
<div style={{ position: 'absolute', bottom: '0px', left: '0px', display: 'flex', alignItems: 'center', gap: '0.3rem', 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)" size={15} />
<span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{sensorTemp !== undefined ? sensorTemp.toFixed(1) : data.temp.toFixed(1)} °C</span>
</div>
<div style={{ position: 'absolute', bottom: '0px', right: '0px', display: 'flex', flexDirection: 'column', gap: '0.2rem', fontSize: '0.8rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', color: 'var(--text-main)' }}>
<FiSunrise color="var(--color-orange)" size={14} />
<span style={{ fontWeight: 'bold' }}>{formatTime(data.sunrise)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', color: 'var(--text-main)' }}>
<FiSunset color="var(--color-orange)" size={14} />
<span style={{ fontWeight: 'bold' }}>{formatTime(data.sunset)}</span>
</div>
</div>
</div>
</div>
);
+92 -27
View File
@@ -27,30 +27,51 @@ const getCompassDirection = (degrees: number, language: 'cs' | 'en') => {
return directions[index];
};
const CustomWindTooltip = ({ active, payload, label, language, windUnit = 'kmh' }: any) => {
interface CustomWindTooltipProps {
active?: boolean;
payload?: { payload: WindDataPoint, value: number, name: string }[];
label?: string;
language: 'cs' | 'en';
windUnit?: 'kmh' | 'ms';
coordinate?: { x: number; y: number };
viewBox?: { width: number; height: number };
}
const CustomWindTooltip = ({ active, payload, label, language, windUnit = 'kmh', coordinate, viewBox }: CustomWindTooltipProps) => {
if (active && payload && payload.length) {
const isLeft = coordinate && viewBox && coordinate.x > viewBox.width / 2;
const tooltipClass = `chart-tooltip ${isLeft ? 'tooltip-left' : 'tooltip-right'}`;
const data = payload[0].payload;
const date = new Date(label);
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' }}>
<div className={tooltipClass} style={{ backgroundColor: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '8px 10px', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.5)', color: 'var(--text-main)', fontSize: '0.8rem', zIndex: 100 }}>
<div style={{ fontWeight: 'bold', marginBottom: '6px', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '3px', fontSize: '0.85rem' }}>
{dateStr} {timeStr}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: 'var(--color-cyan)', fontSize: '1.2rem' }}></span>
<span style={{ color: 'var(--color-cyan)', fontSize: '1rem' }}></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 style={{ color: 'var(--color-purple)', fontSize: '1rem' }}></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)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px', color: 'var(--text-muted)' }}>
<FiWind />
<span>{language === 'cs' ? 'Směr' : 'Direction'}: <strong>{data.dirStr} ({data.dir}°)</strong></span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{language === 'cs' ? 'Směr' : 'Direction'}: <strong>{data.dirStr} ({data.dir}°)</strong>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: `rotate(${data.dir + 180}deg)` }}
>
<line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>
</span>
</div>
</div>
</div>
@@ -59,13 +80,13 @@ const CustomWindTooltip = ({ active, payload, label, language, windUnit = 'kmh'
return null;
};
const CustomWindDot = (props: any) => {
const CustomWindDot = (props: { cx?: number; cy?: number; payload?: WindDataPoint }) => {
const { cx, cy, payload } = props;
if (!cx || !cy || payload.dir === undefined) return null;
if (!cx || !cy || !payload || payload.dir === undefined) return null;
return (
<g transform={`translate(${cx},${cy}) rotate(${payload.dir}) scale(1.5)`}>
<g transform={`translate(${cx},${cy}) rotate(${payload.dir + 180}) scale(1.5)`}>
<path
d="M0,-6 L-4,4 L0,2 L4,4 Z"
fill="var(--color-cyan)"
@@ -76,11 +97,21 @@ const CustomWindDot = (props: any) => {
);
};
export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'kmh' }: WindChartProps) => {
export const WindChart = ({ lat, lng, language, timeRange = '24h', windUnit = 'kmh' }: WindChartProps) => {
const [data, setData] = useState<WindDataPoint[]>([]);
const [loading, setLoading] = useState(true);
const [currentSpeed, setCurrentSpeed] = useState(0);
const [currentDir, setCurrentDir] = useState(0);
const [maxGust, setMaxGust] = useState(0);
const [isMobile, setIsMobile] = useState(false);
const [tooltipY, setTooltipY] = useState<number | undefined>(undefined);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth <= 768);
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
const fetchWind = async () => {
@@ -158,6 +189,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
setData(downsampled);
setMaxGust(maxG);
setCurrentSpeed(speeds[closestIdx] || speeds[speeds.length - 1] || 0);
setCurrentDir(dirs[closestIdx] || dirs[dirs.length - 1] || 0);
} catch (err) {
console.error(err);
@@ -169,7 +201,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
if (lat && lng) {
fetchWind();
}
}, [lat, lng, language, timeRange]);
}, [lat, lng, language, timeRange, windUnit]);
if (loading) {
return (
@@ -182,23 +214,36 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
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' }}>
<div style={{ marginTop: '1.5rem', paddingTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem', padding: isMobile ? '0 0.75rem' : '0', marginBottom: '0.25rem' }}>
<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' }}>
<div style={{
display: 'flex',
gap: isMobile ? '0' : '1.5rem',
justifyContent: isMobile ? 'space-around' : 'flex-end',
width: isMobile ? '100%' : 'auto',
marginTop: isMobile ? '0.5rem' : '0'
}}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<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' }}>
<div style={{ display: 'flex', alignItems: 'center', 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>
<svg
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ color: 'var(--color-cyan)', marginLeft: '2px', transform: `rotate(${currentDir + 180}deg)`, transition: 'transform 0.3s ease' }}
>
<line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<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>
@@ -208,9 +253,20 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
</div>
</div>
<div style={{ flex: 1, minHeight: '280px', width: '100%', marginTop: '0.5rem' }}>
<div style={{ flex: 1, minHeight: '280px', width: '100%', marginTop: '0' }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 20, right: 0, left: -20, bottom: 0 }}>
<ComposedChart
data={data}
margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: -20, bottom: 0 }}
onMouseMove={(state: any) => {
if (state && state.chartY !== undefined) {
const isBottomHalf = state.chartY > 140;
const targetY = isBottomHalf ? 5 : 160;
if (tooltipY !== targetY) setTooltipY(targetY);
}
}}
onMouseLeave={() => setTooltipY(undefined)}
>
<defs>
<linearGradient id="colorWind" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.4}/>
@@ -220,7 +276,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
<XAxis
dataKey="time"
stroke="var(--text-muted)"
tick={{fill: 'var(--text-muted)', fontSize: 11}}
tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 11}}
minTickGap={60}
tickFormatter={(v) => {
const d = new Date(v);
@@ -229,10 +285,15 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
/>
<YAxis
stroke="var(--text-muted)"
tick={{fill: 'var(--text-muted)', fontSize: 11}}
tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 11}}
width={isMobile ? 35 : 60}
tickFormatter={(v) => v.toFixed(1)}
/>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<Tooltip content={<CustomWindTooltip language={language} windUnit={windUnit} />} />
<Tooltip
content={<CustomWindTooltip language={language} windUnit={windUnit} />}
position={tooltipY !== undefined ? { y: tooltipY } : undefined}
/>
<Area
type="monotone"
@@ -242,7 +303,11 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
fillOpacity={1}
fill="url(#colorWind)"
isAnimationActive={true}
dot={<CustomWindDot />}
dot={(props: any) => {
const step = Math.max(1, Math.floor(data.length / (isMobile ? 15 : 30)));
if (props.index % step !== 0 && props.index !== data.length - 1) return null;
return <CustomWindDot key={props.index} {...props} />;
}}
activeDot={{ r: 6, fill: 'var(--color-cyan)', stroke: '#1e293b', strokeWidth: 2 }}
/>
<Line
@@ -258,7 +323,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
</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', justifyContent: 'center', gap: '1.5rem', fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.5rem', padding: isMobile ? '0 0.75rem' : '0' }}>
<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>
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
const STORAGE_KEY = 'hladinator_favorites';

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