Compare commits
14 Commits
a67a2247c3
...
5894c51256
| Author | SHA1 | Date | |
|---|---|---|---|
| 5894c51256 | |||
| a1a1685ae3 | |||
| 62d69fbb1e | |||
| c8fe97078d | |||
| c4cad149ea | |||
| 4939d1c5dc | |||
| 8fe39b7ab0 | |||
| cdb653d660 | |||
| 48b44cd642 | |||
| 7a7abdd3e5 | |||
| f8a7be7fa3 | |||
| 62c861e610 | |||
| ec540e056d | |||
| 231961da19 |
@@ -22,3 +22,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Documentation / Ideas
|
||||||
|
docs/
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ WORKDIR /var/www/html
|
|||||||
# Enable necessary Apache modules
|
# Enable necessary Apache modules
|
||||||
RUN a2enmod rewrite headers
|
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 the built application from the build stage
|
||||||
COPY --from=build /app/dist /app/dist
|
COPY --from=build /app/dist /app/dist
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: timescale/timescaledb:latest-pg16
|
||||||
|
container_name: hladinator-db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: hladinator
|
||||||
|
POSTGRES_USER: hladinator_user
|
||||||
|
POSTGRES_PASSWORD: hladinator_db_password_change_me
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Docker/Dockerfile
|
||||||
|
container_name: hladinator-web
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
driver: local
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# File: .docker/apache/vhost.conf
|
# File: Docker/vhost.conf
|
||||||
LoadModule headers_module /usr/lib/apache2/modules/mod_headers.so
|
LoadModule headers_module /usr/lib/apache2/modules/mod_headers.so
|
||||||
|
|
||||||
<VirtualHost *:80>
|
<VirtualHost *:80>
|
||||||
@@ -1,72 +1,102 @@
|
|||||||
# 🌊 HLADINATOR
|
# 🌊 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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
2. Spusť lokální vývojový server:
|
2. Start the local development server:
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
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 standardní API pro historii srážek a teplot, ani nepodporuje přímé dotazy z klientského prohlížeče (kvůli CORS a bezpečnosti). Proto využíváme vlastní **scraper**.
|
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
|
```bash
|
||||||
npm run data:update
|
npm run data:update
|
||||||
```
|
```
|
||||||
|
|
||||||
Tento příkaz provede dvě věci:
|
This command performs two actions:
|
||||||
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".
|
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`: 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ě).
|
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ě)
|
### Option A: Using Crontab on macOS / Linux (Local)
|
||||||
Pokud ti běží počítač (nebo domácí server/Raspberry Pi) nepřetržitě, můžeš využít systémový `cron`.
|
If you have a computer or home server (like a Raspberry Pi) running continuously:
|
||||||
1. Otevři terminál a napiš: `crontab -e`
|
1. Open the terminal and type: `crontab -e`
|
||||||
2. Na konec souboru vlož následující řádek (uprav cestu ke svému projektu a Node.js):
|
2. Add the following line at the end of the file (adjust the paths to your project and Node.js installation):
|
||||||
```bash
|
```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
|
*/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)
|
### Option B: Using GitHub Actions (For Production Hosting)
|
||||||
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.
|
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čí)
|
### Option C: Built-in Simple Scheduler (Recommended for Development)
|
||||||
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:
|
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
|
```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.
|
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.
|
||||||
* `/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).
|
### 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
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Hladinátor - Aktuální stav přehrad a nádrží</title>
|
<title>Hladinátor - Aktuální stav přehrad a nádrží</title>
|
||||||
<meta name="description" content="Sledujte aktuální vodní stav, průtok a vývoj počasí na českých přehradách a nádržích v reálném čase." />
|
<meta 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: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:type" content="website" />
|
||||||
<meta property="og:url" content="https://hladinator.cz" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"build-index": "tsx scripts/buildIndex.ts",
|
"build-index": "tsx scripts/buildIndex.ts",
|
||||||
"data:update": "npm run scrape && npm run build-index",
|
"data:update": "npm run scrape && npm run build-index",
|
||||||
"data:watch": "tsx scripts/watchData.ts",
|
"data:watch": "tsx scripts/watchData.ts",
|
||||||
|
"data:fix": "tsx scripts/fix_lake_inflows.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"coverage": "vitest run --coverage"
|
"coverage": "vitest run --coverage"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+6637
-787
File diff suppressed because it is too large
Load Diff
+10180
-1531
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
File diff suppressed because it is too large
Load Diff
+10024
-1582
File diff suppressed because it is too large
Load Diff
+7021
-1513
File diff suppressed because it is too large
Load Diff
+10211
-1517
File diff suppressed because it is too large
Load Diff
+10380
-1530
File diff suppressed because it is too large
Load Diff
+9859
-1498
File diff suppressed because it is too large
Load Diff
+6666
-780
File diff suppressed because it is too large
Load Diff
+6716
-830
File diff suppressed because it is too large
Load Diff
+10153
-1522
File diff suppressed because it is too large
Load Diff
+10277
-1538
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
File diff suppressed because it is too large
Load Diff
+10395
-1638
File diff suppressed because it is too large
Load Diff
+10227
-1560
File diff suppressed because it is too large
Load Diff
+9945
-1539
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
File diff suppressed because it is too large
Load Diff
+10233
-1566
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
File diff suppressed because it is too large
Load Diff
+10237
-1525
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
File diff suppressed because it is too large
Load Diff
+10149
-1509
File diff suppressed because it is too large
Load Diff
+10333
-1576
File diff suppressed because it is too large
Load Diff
+9999
-1548
File diff suppressed because it is too large
Load Diff
+10291
-1543
File diff suppressed because it is too large
Load Diff
+10326
-1569
File diff suppressed because it is too large
Load Diff
+10271
-1550
File diff suppressed because it is too large
Load Diff
+10224
-1521
File diff suppressed because it is too large
Load Diff
+10246
-1525
File diff suppressed because it is too large
Load Diff
+10341
-1602
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
File diff suppressed because it is too large
Load Diff
+10200
-1497
File diff suppressed because it is too large
Load Diff
+10250
-1592
File diff suppressed because it is too large
Load Diff
+10290
-1608
File diff suppressed because it is too large
Load Diff
+10285
-1618
File diff suppressed because it is too large
Load Diff
+10412
-1655
File diff suppressed because it is too large
Load Diff
+10325
-1631
File diff suppressed because it is too large
Load Diff
+10317
-1596
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+10314
-1503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-31T05:00:00.000Z",
|
||||||
|
"level": 0,
|
||||||
|
"flow": 48.8,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-01T05:00:00.000Z",
|
||||||
|
"level": 34,
|
||||||
|
"flow": 80.2,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-02T05:00:00.000Z",
|
||||||
|
"level": 20,
|
||||||
|
"flow": 66.46,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-03T05:00:00.000Z",
|
||||||
|
"level": 18,
|
||||||
|
"flow": 63.7,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-04T05:00:00.000Z",
|
||||||
|
"level": 15,
|
||||||
|
"flow": 60.95,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T05:00:00.000Z",
|
||||||
|
"level": 15,
|
||||||
|
"flow": 61.4,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T20:00:00.000Z",
|
||||||
|
"level": 12,
|
||||||
|
"flow": 59.06,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T21:00:00.000Z",
|
||||||
|
"level": 7,
|
||||||
|
"flow": 54.32,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T22:00:00.000Z",
|
||||||
|
"level": 6,
|
||||||
|
"flow": 53.76,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-05T23:00:00.000Z",
|
||||||
|
"level": 11,
|
||||||
|
"flow": 57.64,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T00:00:00.000Z",
|
||||||
|
"level": 12,
|
||||||
|
"flow": 58.7,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T01:00:00.000Z",
|
||||||
|
"level": 12,
|
||||||
|
"flow": 58.25,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T02:00:00.000Z",
|
||||||
|
"level": 15,
|
||||||
|
"flow": 60.95,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T03:00:00.000Z",
|
||||||
|
"level": 13,
|
||||||
|
"flow": 59.87,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T04:00:00.000Z",
|
||||||
|
"level": 10,
|
||||||
|
"flow": 56.73,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T05:00:00.000Z",
|
||||||
|
"level": 15,
|
||||||
|
"flow": 61.76,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T06:00:00.000Z",
|
||||||
|
"level": 11,
|
||||||
|
"flow": 57.64,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T07:00:00.000Z",
|
||||||
|
"level": 6,
|
||||||
|
"flow": 53.6,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T08:00:00.000Z",
|
||||||
|
"level": 10,
|
||||||
|
"flow": 57.08,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T09:00:00.000Z",
|
||||||
|
"level": 14,
|
||||||
|
"flow": 60.68,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T10:00:00.000Z",
|
||||||
|
"level": 7,
|
||||||
|
"flow": 54.4,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T11:00:00.000Z",
|
||||||
|
"level": 3,
|
||||||
|
"flow": 51.34,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T12:00:00.000Z",
|
||||||
|
"level": 12,
|
||||||
|
"flow": 58.25,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T13:00:00.000Z",
|
||||||
|
"level": 13,
|
||||||
|
"flow": 59.33,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T14:00:00.000Z",
|
||||||
|
"level": 5,
|
||||||
|
"flow": 52.71,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T15:00:00.000Z",
|
||||||
|
"level": 10,
|
||||||
|
"flow": 56.64,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T16:00:00.000Z",
|
||||||
|
"level": 11,
|
||||||
|
"flow": 57.4,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T17:00:00.000Z",
|
||||||
|
"level": 9,
|
||||||
|
"flow": 55.7,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T18:00:00.000Z",
|
||||||
|
"level": 3,
|
||||||
|
"flow": 51.18,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T18:10:00.000Z",
|
||||||
|
"level": 4,
|
||||||
|
"flow": 51.58,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T18:20:00.000Z",
|
||||||
|
"level": 4,
|
||||||
|
"flow": 51.66,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T18:30:00.000Z",
|
||||||
|
"level": 5,
|
||||||
|
"flow": 52.44,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-06-06T18:40:00.000Z",
|
||||||
|
"level": 5,
|
||||||
|
"flow": 53.04,
|
||||||
|
"inflow": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"temperature": 22.1,
|
||||||
|
"precipitation": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
+10221
-1518
File diff suppressed because it is too large
Load Diff
+1596
-619
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 |
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Hladinátor",
|
||||||
|
"name": "Hladinátor - Stav přehrad a nádrží",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": "/",
|
||||||
|
"background_color": "#1e293b",
|
||||||
|
"theme_color": "#1e293b",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait"
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -83,12 +83,16 @@ function main() {
|
|||||||
maxLevel?: number;
|
maxLevel?: number;
|
||||||
storageLevel?: number;
|
storageLevel?: number;
|
||||||
navigationForbidden?: boolean;
|
navigationForbidden?: boolean;
|
||||||
|
type?: 'lake' | 'river';
|
||||||
|
country?: string;
|
||||||
|
area?: number;
|
||||||
|
depth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lakesConfig: LakeConfig[] = [
|
export const lakesConfig: LakeConfig[] = [
|
||||||
`;
|
`;
|
||||||
updated.forEach((l, idx) => {
|
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`;
|
newContent += `];\n`;
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ async function backfill() {
|
|||||||
try {
|
try {
|
||||||
const lat = lake.coords[0];
|
const lat = lake.coords[0];
|
||||||
const lon = lake.coords[1];
|
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;
|
const hourly = res.data.hourly;
|
||||||
|
|
||||||
// Build lookup map for O(1) matching: '2026-06-02T04:00' -> { temp, precip }
|
// Build lookup map for O(1) matching: '2026-06-02T04:00' -> { temp, precip }
|
||||||
@@ -43,9 +44,10 @@ async function backfill() {
|
|||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
|
|
||||||
for (const record of data) {
|
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"
|
// 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)) {
|
if (weatherMap.has(hourKey)) {
|
||||||
const w = weatherMap.get(hourKey);
|
const w = weatherMap.get(hourKey);
|
||||||
|
|||||||
+19
-3
@@ -56,10 +56,21 @@ const lakes = lakesConfig.map(lake => {
|
|||||||
|
|
||||||
const metrics = calculateLakeMetrics(currentLevel, volume, 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 {
|
return {
|
||||||
id: lake.id,
|
id: lake.id,
|
||||||
name: lake.text.replace('VD ', '').split('-')[0].trim(),
|
name,
|
||||||
river: lake.text.includes('-') ? lake.text.split('-')[1].trim() : '',
|
river,
|
||||||
priority: lake.priority || false,
|
priority: lake.priority || false,
|
||||||
level: currentLevel.toFixed(2),
|
level: currentLevel.toFixed(2),
|
||||||
capacity: metrics.capacity,
|
capacity: metrics.capacity,
|
||||||
@@ -68,9 +79,14 @@ const lakes = lakesConfig.map(lake => {
|
|||||||
outflow: currentFlow.toFixed(1),
|
outflow: currentFlow.toFixed(1),
|
||||||
volume: metrics.volume,
|
volume: metrics.volume,
|
||||||
maxVolume: lake.maxVolume || 0,
|
maxVolume: lake.maxVolume || 0,
|
||||||
|
navigationForbidden: lake.navigationForbidden || false,
|
||||||
lat: lake.coords[0],
|
lat: lake.coords[0],
|
||||||
lng: lake.coords[1],
|
lng: lake.coords[1],
|
||||||
sparkline
|
sparkline,
|
||||||
|
type: lake.type || 'lake',
|
||||||
|
country: lake.country || 'CZ',
|
||||||
|
area: lake.area || 0,
|
||||||
|
depth: lake.depth || 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -8,19 +8,23 @@ export interface LakeConfig {
|
|||||||
maxLevel?: number;
|
maxLevel?: number;
|
||||||
storageLevel?: number;
|
storageLevel?: number;
|
||||||
navigationForbidden?: boolean;
|
navigationForbidden?: boolean;
|
||||||
|
type?: 'lake' | 'river';
|
||||||
|
country?: string;
|
||||||
|
area?: number;
|
||||||
|
depth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lakesConfig: LakeConfig[] = [
|
export const lakesConfig: LakeConfig[] = [
|
||||||
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306, minLevel: 716.1, maxLevel: 725.6, storageLevel: 724.9, navigationForbidden: false },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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: "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: "VLVE|2", text: "VD Vrané", coords: [49.9390, 14.3910], maxVolume: 11.1, minLevel: 199.1, maxLevel: 200.1, storageLevel: 200.1, navigationForbidden: false },
|
||||||
{ id: "BLHU|1", text: "VD Husinec", coords: [49.0270, 13.9870], maxVolume: 5.7, minLevel: 515.33, maxLevel: 529.88, storageLevel: 522.33, navigationForbidden: true },
|
{ id: "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: "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: "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: "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' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+184
-17
@@ -13,6 +13,7 @@ interface DataRecord {
|
|||||||
volume?: number;
|
volume?: number;
|
||||||
temperature?: number | null;
|
temperature?: number | null;
|
||||||
precipitation?: number | null;
|
precipitation?: number | null;
|
||||||
|
qn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse date from DD.MM.YYYY HH:MM to ISO
|
// 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) {
|
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`);
|
const DATA_FILE = path.resolve(`public/data/${internalId}.json`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -80,32 +85,49 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
|
|||||||
const records: DataRecord[] = [];
|
const records: DataRecord[] = [];
|
||||||
let dataTable = null;
|
let dataTable = null;
|
||||||
$('table').each((i, tbl) => {
|
$('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);
|
dataTable = $(tbl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dataTable) {
|
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
|
if (i === 0) return; // skip header
|
||||||
const cols = $(row).find('td');
|
const cols = $(row).find('td');
|
||||||
if (cols.length >= 3) {
|
if (cols.length > Math.max(levelColIndex, flowColIndex)) {
|
||||||
const rawDate = $(cols[0]).text().trim();
|
const rawDate = $(cols[0]).text().trim();
|
||||||
const levelStr = $(cols[1]).text().trim().replace(',', '.');
|
const levelStr = $(cols[levelColIndex]).text().trim().replace(',', '.');
|
||||||
let flowStr = $(cols[2]).text().trim().replace(',', '.');
|
const flowStr = $(cols[flowColIndex]).text().trim().replace(',', '.');
|
||||||
if (flowStr === '' && cols.length >= 4) {
|
const qn = qnColIndex !== -1 && cols.length > qnColIndex ? $(cols[qnColIndex]).text().trim() : '';
|
||||||
flowStr = $(cols[3]).text().trim().replace(',', '.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedDateStr = parseDateString(rawDate);
|
const parsedDateStr = parseDateString(rawDate);
|
||||||
if (parsedDateStr) {
|
if (parsedDateStr) {
|
||||||
records.push({
|
const newRecord: DataRecord = {
|
||||||
timestamp: parsedDateStr,
|
timestamp: parsedDateStr,
|
||||||
level: parseFloat(levelStr) || 0,
|
level: parseFloat(levelStr) || 0,
|
||||||
flow: parseFloat(flowStr) || 0,
|
flow: parseFloat(flowStr) || 0
|
||||||
inflow: 0,
|
};
|
||||||
volume: 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>();
|
const dataMap = new Map<string, DataRecord>();
|
||||||
existingData.forEach(item => dataMap.set(item.timestamp, item));
|
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) => {
|
const mergedData = Array.from(dataMap.values()).sort((a, b) => {
|
||||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
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)
|
// Propagate previous values if missing (user requested)
|
||||||
let lastKnownTemp: number | null = null;
|
let lastKnownTemp: number | null = null;
|
||||||
let lastKnownPrecip: number | null = null;
|
let lastKnownPrecip: number | null = null;
|
||||||
|
let lastKnownInflow: number | undefined = undefined;
|
||||||
|
let lastKnownVolume: number | undefined = undefined;
|
||||||
|
|
||||||
mergedData.forEach(item => {
|
mergedData.forEach(item => {
|
||||||
if (item.temperature !== undefined && item.temperature !== null) {
|
if (item.temperature !== undefined && item.temperature !== null) {
|
||||||
lastKnownTemp = item.temperature;
|
lastKnownTemp = item.temperature;
|
||||||
@@ -164,6 +202,18 @@ async function scrapeLake(lakeId: string, oid: string, internalId: string) {
|
|||||||
} else if (lastKnownPrecip !== null) {
|
} else if (lastKnownPrecip !== null) {
|
||||||
item.precipitation = lastKnownPrecip;
|
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 });
|
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}¤t=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() {
|
async function runScraper() {
|
||||||
console.log(`Starting bulk scraper for ${lakesConfig.length} lakes...`);
|
console.log(`Starting bulk scraper for ${lakesConfig.length} lakes...`);
|
||||||
|
|
||||||
for (const lake of lakesConfig) {
|
for (const lake of lakesConfig) {
|
||||||
// ID format: VLL1|1 -> internalId=VLL1, oid=1
|
|
||||||
const [internalId, oid] = lake.id.split('|');
|
const [internalId, oid] = lake.id.split('|');
|
||||||
await scrapeLake(lake.id, oid, internalId);
|
if (lake.country && lake.country !== 'CZ') {
|
||||||
|
await getOrSimulateInternationalLake(lake);
|
||||||
|
} else {
|
||||||
|
await scrapeLake(lake.id, oid, internalId);
|
||||||
|
}
|
||||||
// Add small delay to not hammer the server
|
// Add small delay to not hammer the server
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { execSync } from 'child_process';
|
|||||||
|
|
||||||
// How many minutes after the 10-minute mark should we run the scraper?
|
// 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.
|
// 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.
|
// 7 minutes (HH:07, HH:17...) is a safe buffer to avoid fetching outdated data.
|
||||||
const offsetMinutes = 5;
|
const offsetMinutes = 7;
|
||||||
|
|
||||||
console.log(`\n⏱️ HLADINATOR Watcher spuštěn!`);
|
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() {
|
function runUpdate() {
|
||||||
const now = new Date().toLocaleTimeString('cs-CZ');
|
const now = new Date().toLocaleTimeString('cs-CZ');
|
||||||
|
|||||||
+112
-36
@@ -6,6 +6,28 @@
|
|||||||
background-color: var(--bg-dark);
|
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 {
|
.sidebar {
|
||||||
width: 190px;
|
width: 190px;
|
||||||
background-color: var(--bg-card);
|
background-color: var(--bg-card);
|
||||||
@@ -13,8 +35,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1.5rem 0.75rem;
|
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;
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
will-change: width;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed {
|
.sidebar.collapsed {
|
||||||
@@ -22,8 +48,19 @@
|
|||||||
padding: 1.5rem 0.5rem;
|
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 {
|
.sidebar.collapsed .sidebar-text {
|
||||||
display: none;
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed .sidebar-logo {
|
.sidebar.collapsed .sidebar-logo {
|
||||||
@@ -182,7 +219,7 @@
|
|||||||
.kpi-card {
|
.kpi-card {
|
||||||
background-color: var(--bg-card);
|
background-color: var(--bg-card);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -210,8 +247,13 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-trend.positive { color: var(--color-green); }
|
.kpi-trend.positive {
|
||||||
.kpi-trend.negative { color: var(--color-red); }
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-trend.negative {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
.chart-card {
|
.chart-card {
|
||||||
background-color: var(--bg-card);
|
background-color: var(--bg-card);
|
||||||
@@ -330,19 +372,19 @@
|
|||||||
.mobile-only {
|
.mobile-only {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-only {
|
.desktop-only {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.mobile-open {
|
.sidebar.mobile-open {
|
||||||
display: flex;
|
display: flex;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -351,108 +393,142 @@
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
padding: 1rem;
|
margin-left: 0 !important;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-mobile-header {
|
.topbar-mobile-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar input {
|
.search-bar input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
width: auto;
|
width: auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar svg {
|
.search-bar svg {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-container {
|
.kpi-container {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-card {
|
.kpi-card {
|
||||||
padding: 1rem;
|
padding: 0.6rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-value {
|
.kpi-value {
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-subtitle {
|
.kpi-subtitle {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-card {
|
.chart-card {
|
||||||
padding: 1rem;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-header {
|
.chart-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-title {
|
.chart-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-controls {
|
.chart-controls {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group {
|
.control-group {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-group {
|
.button-group {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-legend-container {
|
.chart-legend-container {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 1rem !important;
|
gap: 0.6rem !important;
|
||||||
justify-content: flex-start !important;
|
justify-content: center !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-legend-container > span {
|
.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
@@ -2,11 +2,14 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Routes, Route, useParams, Navigate } from 'react-router-dom';
|
import { Routes, Route, useParams, Navigate } from 'react-router-dom';
|
||||||
import LakeDetail from './components/LakeDetail';
|
import LakeDetail from './components/LakeDetail';
|
||||||
import LakesOverview from './components/LakesOverview';
|
import LakesOverview from './components/LakesOverview';
|
||||||
|
import { RiversOverview } from './components/RiversOverview';
|
||||||
import LakeMap from './components/LakeMap';
|
import LakeMap from './components/LakeMap';
|
||||||
import FavoritesOverview from './components/FavoritesOverview';
|
import FavoritesOverview from './components/FavoritesOverview';
|
||||||
|
import WeatherRadar from './components/WeatherRadar';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import Topbar from './components/Topbar';
|
import Topbar from './components/Topbar';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
|
import { DisclaimerModal } from './components/DisclaimerModal';
|
||||||
import { type Language, t } from './translations';
|
import { type Language, t } from './translations';
|
||||||
import { lakesConfig } from '../scripts/lakesConfig';
|
import { lakesConfig } from '../scripts/lakesConfig';
|
||||||
import { slugify } from './utils/slugify';
|
import { slugify } from './utils/slugify';
|
||||||
@@ -58,6 +61,7 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-container">
|
<div className="dashboard-container">
|
||||||
|
<DisclaimerModal language={language} setLanguage={setLanguage} />
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -77,21 +81,15 @@ function App() {
|
|||||||
<Topbar language={language} onToggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
|
<Topbar language={language} onToggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LakesOverview language={language} />} />
|
<Route path="/" element={<LakesOverview language={language} windUnit={windUnit} />} />
|
||||||
<Route path="/favorites" element={<FavoritesOverview language={language} />} />
|
<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="/map" element={<LakeMap language={language} />} />
|
||||||
|
<Route path="/radar" element={<WeatherRadar language={language} />} />
|
||||||
<Route path="/:slug" element={<LakeDetailWrapper language={language} windUnit={windUnit} />} />
|
<Route path="/:slug" element={<LakeDetailWrapper language={language} windUnit={windUnit} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
<footer style={{
|
<footer className="app-footer">
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '1.5rem',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
marginTop: 'auto'
|
|
||||||
}}>
|
|
||||||
<span>{t[language].chart.dataSources} pvl.cz, open-meteo.com</span>
|
<span>{t[language].chart.dataSources} pvl.cz, open-meteo.com</span>
|
||||||
<span>{t[language].chart.createdIn}</span>
|
<span>{t[language].chart.createdIn}</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ interface Props {
|
|||||||
value: number;
|
value: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
strokeWidth?: 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 radius = (size - strokeWidth) / 2;
|
||||||
const circumference = radius * 2 * Math.PI;
|
const circumference = radius * 2 * Math.PI;
|
||||||
const offset = circumference - (value / 100) * circumference;
|
const offset = circumference - (value / 100) * circumference;
|
||||||
@@ -23,7 +25,7 @@ export const CircularProgress: React.FC<Props> = ({ value, size = 60, strokeWidt
|
|||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
stroke="var(--color-cyan)"
|
stroke={color}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -34,9 +36,11 @@ export const CircularProgress: React.FC<Props> = ({ value, size = 60, strokeWidt
|
|||||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
|
{!hideText && (
|
||||||
{value > 0 ? `${value.toFixed(1)}%` : 'N/A'}
|
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
|
||||||
</div>
|
{value > 0 ? `${value.toFixed(1)}%` : 'N/A'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,8 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { slugify } from '../utils/slugify';
|
import { slugify } from '../utils/slugify';
|
||||||
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
|
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
|
||||||
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
|
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
|
||||||
|
import { TbSwimming, TbSailboat } from 'react-icons/tb';
|
||||||
|
import { Tooltip } from './Tooltip';
|
||||||
|
|
||||||
interface Lake {
|
interface Lake {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,13 +23,25 @@ interface Lake {
|
|||||||
outflow: number;
|
outflow: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
maxVolume: number;
|
maxVolume: number;
|
||||||
|
navigationForbidden: boolean;
|
||||||
sparkline: number[];
|
sparkline: number[];
|
||||||
|
country?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
language: Language;
|
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 FavoritesOverview = ({ language }: Props) => {
|
||||||
const [lakes, setLakes] = useState<Lake[]>([]);
|
const [lakes, setLakes] = useState<Lake[]>([]);
|
||||||
const { isFavorite, toggleFavorite } = useFavorites();
|
const { isFavorite, toggleFavorite } = useFavorites();
|
||||||
@@ -119,9 +133,20 @@ const FavoritesOverview = ({ language }: Props) => {
|
|||||||
<FiStar size={18} fill="#f59e0b" />
|
<FiStar size={18} fill="#f59e0b" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingRight: '2rem' }}>
|
||||||
{lake.name} {lake.river ? `- ${lake.river}` : ''}
|
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, lineHeight: '1.3' }}>
|
||||||
</h3>
|
<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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
|
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
|
||||||
|
|||||||
+191
-45
@@ -1,4 +1,5 @@
|
|||||||
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
|
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
|
||||||
|
import { TbRipple } from 'react-icons/tb';
|
||||||
import { type Language, t } from '../translations';
|
import { type Language, t } from '../translations';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { CircularProgress } from './CircularProgress';
|
import { CircularProgress } from './CircularProgress';
|
||||||
@@ -11,9 +12,12 @@ interface KpiData {
|
|||||||
inflow: number;
|
inflow: number;
|
||||||
outflow: number;
|
outflow: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
|
currentVolume?: number;
|
||||||
fullness: number;
|
fullness: number;
|
||||||
storageDiff?: number;
|
storageDiff?: number;
|
||||||
minDiff?: number;
|
minDiff?: number;
|
||||||
|
minDiffLabelCs?: string;
|
||||||
|
minDiffLabelEn?: string;
|
||||||
avgInflow24h?: number;
|
avgInflow24h?: number;
|
||||||
avgOutflow24h?: number;
|
avgOutflow24h?: number;
|
||||||
}
|
}
|
||||||
@@ -22,13 +26,33 @@ interface Props {
|
|||||||
data: KpiData;
|
data: KpiData;
|
||||||
language: Language;
|
language: Language;
|
||||||
lakeName?: string;
|
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 [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
const [showMinTooltip, setShowMinTooltip] = useState(false);
|
||||||
const dict = t[language].kpi;
|
const dict = t[language].kpi;
|
||||||
const flowDiff = data.inflow - data.outflow;
|
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(() => {
|
useEffect(() => {
|
||||||
if (showTooltip) {
|
if (showTooltip) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -38,17 +62,90 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
|||||||
}
|
}
|
||||||
}, [showTooltip]);
|
}, [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" 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* CARD 1: WATER LEVEL */}
|
{/* 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' }}>
|
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||||
{dict.level} {lakeName}
|
{dict.level} {lakeName}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, whiteSpace: 'nowrap' }}>
|
<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>
|
{data.level.toFixed(2)} <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n. m.</span>
|
||||||
</div>
|
</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' }}>
|
<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.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)' }}>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* CARD 2: FLOW */}
|
{/* 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' }}>
|
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||||
{dict.flow}
|
{dict.flow}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<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' }}>
|
<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' }}>
|
<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>
|
<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>
|
{dict.inflow}: <span style={{ fontWeight: 'bold', color: 'var(--text-main)', marginLeft: '4px' }}>{data.inflow.toFixed(1)} m³/s</span>
|
||||||
@@ -97,41 +194,38 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flow Circle */}
|
{/* Flow Gauge using CircularProgress */}
|
||||||
<div style={{
|
<div style={{ position: 'relative', width: '90px', height: '90px', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
width: '70px',
|
<div style={{ position: 'absolute', top: 0, left: 0 }}>
|
||||||
height: '70px',
|
<CircularProgress
|
||||||
borderRadius: '50%',
|
value={visualFlowValue || 0.1}
|
||||||
border: `4px solid ${flowDiff >= 0 ? 'rgba(74, 222, 128, 0.2)' : 'rgba(248, 113, 113, 0.2)'}`,
|
size={90}
|
||||||
borderTopColor: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)',
|
strokeWidth={7}
|
||||||
borderRightColor: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)',
|
hideText={true}
|
||||||
display: 'flex',
|
color={flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)'}
|
||||||
alignItems: 'center',
|
/>
|
||||||
justifyContent: 'center',
|
</div>
|
||||||
transform: 'rotate(-45deg)',
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', color: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold', lineHeight: 1.2 }}>
|
||||||
flexShrink: 0
|
<span style={{ fontSize: '1rem' }}>{flowDiff > 0 ? '+' : ''}{flowDiff.toFixed(1)}</span>
|
||||||
}}>
|
<span style={{ fontSize: '0.65rem', opacity: 0.8 }}>m³/s</span>
|
||||||
<span style={{ transform: 'rotate(45deg)', color: flowDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold', textAlign: 'center', lineHeight: 1.2 }}>
|
</div>
|
||||||
<div style={{ fontSize: '0.8rem' }}>{flowDiff > 0 ? '+' : ''}{flowDiff.toFixed(1)}</div>
|
|
||||||
<div style={{ fontSize: '0.6rem', opacity: 0.8 }}>m³/s</div>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CARD 3: CAPACITY */}
|
{/* CARD 3: CAPACITY */}
|
||||||
<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', display: 'flex', alignItems: 'center', gap: '0.4rem', position: 'relative' }}>
|
<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}
|
{dict.fullness}
|
||||||
<span
|
<span
|
||||||
onClick={() => setShowTooltip(!showTooltip)}
|
onClick={() => setShowTooltip(!showTooltip)}
|
||||||
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.6, padding: '0 4px' }}
|
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.6, padding: '0 4px' }}
|
||||||
>
|
>
|
||||||
ⓘ
|
ⓘ
|
||||||
</span>
|
</span>
|
||||||
{showTooltip && (
|
{showTooltip && (
|
||||||
<div
|
<div
|
||||||
onClick={() => setShowTooltip(false)}
|
onClick={() => setShowTooltip(false)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -155,25 +249,77 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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={{ position: 'relative', display: 'flex', justifyContent: 'center', alignItems: 'center', height: '130px', marginTop: '-1rem' }}>
|
||||||
<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')}
|
{/* Circular Progress Ring */}
|
||||||
</div>
|
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: '130px', height: '130px', zIndex: 1 }}>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
<CircularProgress value={data.fullness} size={130} strokeWidth={10} hideText={true} />
|
||||||
{dict.volume}: {data.volume.toFixed(1)} <span style={{ fontSize: '0.7rem' }}>mil. m³</span>
|
|
||||||
</div>
|
|
||||||
{data.minDiff !== undefined && (
|
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '4px', whiteSpace: 'nowrap' }}>
|
|
||||||
{language === 'cs' ? 'K minimu:' : 'To min:'} <span style={{ color: data.minDiff < 0.5 ? 'var(--color-red)' : 'var(--color-green)' }}>{data.minDiff.toFixed(2)} m</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flexShrink: 0 }}>
|
{/* Percentage Text */}
|
||||||
<CircularProgress value={data.fullness} size={80} strokeWidth={8} />
|
<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={{ 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>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+637
-79
@@ -8,7 +8,9 @@ import { WindChart } from './WindChart';
|
|||||||
import { NAVIGATION_LIMITS } from '../utils/navigationLimits';
|
import { NAVIGATION_LIMITS } from '../utils/navigationLimits';
|
||||||
import { lakesConfig } from '../../scripts/lakesConfig';
|
import { lakesConfig } from '../../scripts/lakesConfig';
|
||||||
import { FiAlertCircle, FiStar } from 'react-icons/fi';
|
import { FiAlertCircle, FiStar } from 'react-icons/fi';
|
||||||
|
import { TbSwimming, TbSailboat } from 'react-icons/tb';
|
||||||
import { useFavorites } from '../hooks/useFavorites';
|
import { useFavorites } from '../hooks/useFavorites';
|
||||||
|
import { Tooltip as IconTooltip } from './Tooltip';
|
||||||
|
|
||||||
interface LipnoData {
|
interface LipnoData {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -20,6 +22,28 @@ interface LipnoData {
|
|||||||
fullness: number;
|
fullness: number;
|
||||||
temperature?: number | null;
|
temperature?: number | null;
|
||||||
precipitation?: 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 {
|
interface Props {
|
||||||
@@ -28,14 +52,27 @@ interface Props {
|
|||||||
windUnit?: 'kmh' | 'ms';
|
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) {
|
if (active && payload && payload.length) {
|
||||||
const dict = t[language as Language].chart;
|
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) {
|
if (isWeather) {
|
||||||
return (
|
return (
|
||||||
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
<div 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.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
|
<p style={{ margin: '0 0 0.25rem 0', fontWeight: 'bold', color: 'var(--text-main)', fontSize: '0.85rem' }}>{label}</p>
|
||||||
{payload.map((entry: any, index: number) => {
|
{payload.map((entry: TooltipPayloadItem, index: number) => {
|
||||||
const isTemp = entry.name === 'temperature' || entry.dataKey === 'temperature';
|
const isTemp = entry.name === 'temperature' || entry.dataKey === 'temperature';
|
||||||
return (
|
return (
|
||||||
<p key={index} style={{ margin: 0, color: 'var(--text-main)' }}>
|
<p key={index} style={{ margin: 0, color: 'var(--text-main)' }}>
|
||||||
@@ -47,32 +84,61 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
<div 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.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
|
<p style={{ margin: '0 0 0.25rem 0', fontWeight: 'bold', color: 'var(--text-main)', fontSize: '0.85rem' }}>{label}</p>
|
||||||
{[...payload].sort((a: any, b: any) => {
|
{[...payload].sort((a: TooltipPayloadItem, b: TooltipPayloadItem) => {
|
||||||
const order = ['level', 'inflow', 'outflow', 'temperature', 'precipitation'];
|
const order = ['level', 'inflow', 'outflow', 'temperature', 'precipitation'];
|
||||||
const indexA = order.indexOf(a.dataKey);
|
const indexA = order.indexOf(a.dataKey);
|
||||||
const indexB = order.indexOf(b.dataKey);
|
const indexB = order.indexOf(b.dataKey);
|
||||||
return (indexA === -1 ? 99 : indexA) - (indexB === -1 ? 99 : indexB);
|
return (indexA === -1 ? 99 : indexA) - (indexB === -1 ? 99 : indexB);
|
||||||
}).map((entry: any, index: number) => {
|
}).map((entry: TooltipPayloadItem, index: number) => {
|
||||||
let labelStr = '';
|
let labelStr = '';
|
||||||
let unit = '';
|
let unit = '';
|
||||||
let color = '';
|
let color = '';
|
||||||
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; color = 'var(--color-cyan)'; }
|
if (entry.dataKey === 'level') {
|
||||||
else if (entry.dataKey === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; color = 'var(--color-red)'; }
|
labelStr = isRiver ? (language === 'cs' ? 'Vodní stav' : 'Water level') : dict.level;
|
||||||
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; color = 'var(--color-green)'; }
|
unit = isRiver ? 'cm' : 'm n. m.';
|
||||||
else if (entry.dataKey === 'temperature') { labelStr = language === 'cs' ? 'Teplota' : 'Temperature'; unit = '°C'; color = 'var(--color-red)'; }
|
color = 'var(--color-cyan)';
|
||||||
else if (entry.dataKey === 'precipitation') { labelStr = language === 'cs' ? 'Srážky' : 'Precipitation'; unit = 'mm'; 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;
|
if (!labelStr || entry.value === null || entry.value === undefined) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} style={{ margin: 0, color: 'var(--text-main)', display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
|
<div key={index} style={{ margin: 0, color: 'var(--text-main)', display: 'flex', alignItems: 'center', marginBottom: '2px' }}>
|
||||||
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color, marginRight: '8px' }}></span>
|
<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' ? 2 : 1)} {unit}</span>
|
{labelStr}: <span style={{ fontWeight: 'bold', marginLeft: '4px' }}>{entry.value.toFixed(entry.dataKey === 'level' ? (isRiver ? 0 : 2) : 1)} {unit}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,9 +148,39 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
|
|||||||
const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||||
const [data, setData] = useState<LipnoData[]>([]);
|
const [data, setData] = useState<LipnoData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [lakeInfo, setLakeInfo] = useState<any>(null);
|
const [lakeInfo, setLakeInfo] = useState<LakeInfo | null>(null);
|
||||||
const [isSmoothed, setIsSmoothed] = useState(true);
|
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 dict = t[language].chart;
|
||||||
const topbarDict = t[language].topbar;
|
const topbarDict = t[language].topbar;
|
||||||
|
|
||||||
@@ -93,18 +189,52 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
|||||||
fetch(`/data/lakes_index.json?t=${Date.now()}`)
|
fetch(`/data/lakes_index.json?t=${Date.now()}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(indexData => {
|
.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' });
|
setLakeInfo(found || { name: 'Lipno 1', river: 'Vltava' });
|
||||||
})
|
})
|
||||||
.catch(err => console.error(err));
|
.catch(err => console.error(err));
|
||||||
|
|
||||||
const internalId = lakeId ? lakeId.split('|')[0] : 'VLL1';
|
const internalId = lakeId ? lakeId.split('|')[0] : 'VLL1';
|
||||||
|
const staticConfig = lakesConfig.find(l => l.id.split('|')[0] === internalId);
|
||||||
|
|
||||||
fetch(`/data/${internalId}.json?t=${Date.now()}`)
|
fetch(`/data/${internalId}.json?t=${Date.now()}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(json => {
|
.then(json => {
|
||||||
const formattedData = json.map((item: any) => {
|
let lastValidLevel: number | null = null;
|
||||||
const outflow = item.flow === null || isNaN(item.flow) ? 0 : item.flow;
|
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 {
|
return {
|
||||||
timestamp: item.timestamp,
|
timestamp: item.timestamp,
|
||||||
@@ -112,13 +242,14 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
|||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit'
|
hour: '2-digit', minute: '2-digit'
|
||||||
}),
|
}),
|
||||||
level: item.level === null || isNaN(item.level) ? 0 : item.level,
|
level: level,
|
||||||
outflow: outflow,
|
outflow: outflow,
|
||||||
inflow: item.inflow || 0,
|
inflow: item.inflow || 0,
|
||||||
volume: item.volume || 0,
|
volume: item.volume || 0,
|
||||||
fullness: 0,
|
fullness: 0,
|
||||||
temperature: item.temperature,
|
temperature: item.temperature === undefined ? null : item.temperature,
|
||||||
precipitation: item.precipitation === null ? undefined : item.precipitation
|
precipitation: (item.precipitation === null || item.precipitation === undefined) ? null : item.precipitation,
|
||||||
|
qn: item.qn || ''
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setData(formattedData);
|
setData(formattedData);
|
||||||
@@ -163,13 +294,101 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
|||||||
const cutoff = getCutoff();
|
const cutoff = getCutoff();
|
||||||
const filteredData = data.filter(d => new Date(d.timestamp).getTime() >= cutoff);
|
const filteredData = data.filter(d => new Date(d.timestamp).getTime() >= cutoff);
|
||||||
|
|
||||||
// Downsample data for large time ranges to prevent stuttering
|
// Resample data to constant time intervals to prevent X-axis time distortion
|
||||||
let chartData = filteredData;
|
const resampleData = (rawPoints: LipnoData[], range: typeof timeRange): LipnoData[] => {
|
||||||
if (timeRange === '30d' && filteredData.length > 200) {
|
if (rawPoints.length === 0) return [];
|
||||||
chartData = filteredData.filter((_, i) => i % 4 === 0 || i === filteredData.length - 1);
|
|
||||||
} else if ((timeRange === '1y' || timeRange === 'all') && filteredData.length > 200) {
|
let bucketSizeMs: number;
|
||||||
chartData = filteredData.filter((_, i) => i % 24 === 0 || i === filteredData.length - 1);
|
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;
|
const animate = chartData.length < 150;
|
||||||
|
|
||||||
@@ -179,7 +398,6 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
|||||||
const targetMs7d = nowMs - 7 * 24 * 60 * 60 * 1000;
|
const targetMs7d = nowMs - 7 * 24 * 60 * 60 * 1000;
|
||||||
const targetMs30d = nowMs - 30 * 24 * 60 * 60 * 1000;
|
const targetMs30d = nowMs - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const { isFavorite, toggleFavorite } = useFavorites();
|
|
||||||
const isFav = lakeId ? isFavorite(lakeId) : false;
|
const isFav = lakeId ? isFavorite(lakeId) : false;
|
||||||
|
|
||||||
let level24hAgo = latestData.level;
|
let level24hAgo = latestData.level;
|
||||||
@@ -231,6 +449,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
|||||||
|
|
||||||
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
|
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
|
||||||
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
|
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
|
||||||
|
const isRiver = lakeInfo?.type === 'river';
|
||||||
|
|
||||||
const kpiData = {
|
const kpiData = {
|
||||||
level: latestData.level,
|
level: latestData.level,
|
||||||
@@ -239,27 +458,163 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
|||||||
levelDiff30d,
|
levelDiff30d,
|
||||||
inflow: lastValidFlowData.inflow,
|
inflow: lastValidFlowData.inflow,
|
||||||
outflow: lastValidFlowData.outflow,
|
outflow: lastValidFlowData.outflow,
|
||||||
volume: lakeInfo?.volume || 0,
|
volume: lakeInfo?.maxVolume || lakeInfo?.volume || 0,
|
||||||
|
currentVolume: latestData.volume,
|
||||||
fullness: lakeInfo?.capacity || 0,
|
fullness: lakeInfo?.capacity || 0,
|
||||||
storageDiff: lakeInfo?.storageDiff,
|
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,
|
avgInflow24h,
|
||||||
avgOutflow24h
|
avgOutflow24h
|
||||||
};
|
};
|
||||||
|
|
||||||
const leftYAxisDomain = [
|
const getDefaultLeftDomain = (): [number, number] => {
|
||||||
(dataMin: 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;
|
let min = dataMin;
|
||||||
|
if (staticConfig?.minLevel && staticConfig.minLevel < min) min = staticConfig.minLevel;
|
||||||
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
|
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
|
||||||
return min - 0.5;
|
|
||||||
},
|
|
||||||
(dataMax: number) => {
|
|
||||||
let max = dataMax;
|
let max = dataMax;
|
||||||
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
|
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
|
||||||
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
|
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
|
||||||
return max + 0.5;
|
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;
|
||||||
|
},
|
||||||
|
(dataMax: number) => {
|
||||||
|
let max = dataMax;
|
||||||
|
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
|
||||||
|
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
|
||||||
|
return max + 0.5;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
|
||||||
@@ -283,6 +638,15 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
|||||||
>
|
>
|
||||||
<FiStar size={24} fill={isFav ? '#f59e0b' : 'none'} color={isFav ? '#f59e0b' : 'var(--text-muted)'} />
|
<FiStar size={24} fill={isFav ? '#f59e0b' : 'none'} color={isFav ? '#f59e0b' : 'var(--text-muted)'} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
|
||||||
{t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)}
|
{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>
|
<span>{topbarDict.updated} {new Date().toLocaleDateString(language === 'cs' ? 'cs-CZ' : 'en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}, {new Date().toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' })} UTC</span>
|
||||||
<div className="status-dot"></div>
|
<div 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>
|
||||||
|
|
||||||
<div className="kpi-grid-container">
|
<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 && (
|
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
|
||||||
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature ?? undefined} />
|
<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 */}
|
{/* CHART SECTION */}
|
||||||
<div className="chart-card">
|
<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>
|
<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 === '24h' ? 'active' : ''} onClick={() => setTimeRange('24h')}>24h</button>
|
||||||
<button className={timeRange === '7d' ? 'active' : ''} onClick={() => setTimeRange('7d')}>7d</button>
|
<button className={timeRange === '7d' ? 'active' : ''} onClick={() => setTimeRange('7d')}>7d</button>
|
||||||
<button className={timeRange === '30d' ? 'active' : ''} onClick={() => setTimeRange('30d')}>30d</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 === '1y' ? 'active' : ''} onClick={() => setTimeRange('1y')}>{dict.year}</button>
|
||||||
<button className={timeRange === 'all' ? 'active' : ''} onClick={() => setTimeRange('all')}>{dict.all}</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>
|
</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%">
|
<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>
|
<defs>
|
||||||
<linearGradient id="colorLevel" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorLevel" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
|
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
|
||||||
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
|
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 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="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={[0, (dataMax: number) => Math.max(dataMax, 1)]} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
<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} />
|
<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 */}
|
{/* Data Series */}
|
||||||
{limits && limits.map((limit, idx) => (
|
{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: 12 }} />
|
<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 && (
|
{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: '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 }} />
|
<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'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{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'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{staticConfig?.storageLevel && (
|
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} hide={!visibleSeries.level} />
|
||||||
<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 }} />
|
<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} />}
|
||||||
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} />
|
|
||||||
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-red)" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
|
||||||
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Chart Legend */}
|
{/* 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)' }}>
|
<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 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div> {dict.level}</span>
|
<span
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> {dict.outflow}</span>
|
onClick={() => setVisibleSeries(prev => ({ ...prev, level: !prev.level }))}
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div> {dict.inflow}</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* WEATHER CHART SECTION */}
|
{/* 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>
|
<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>
|
||||||
|
|
||||||
<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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={chartData} margin={{ top: 10, right: 0, left: 10, bottom: 0 }}>
|
<ComposedChart
|
||||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
data={chartData}
|
||||||
<YAxis yAxisId="temp" domain={['auto', 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(1)} />
|
margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: 10, bottom: 0 }}
|
||||||
<YAxis yAxisId="precip" orientation="right" domain={[0, 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
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} />
|
<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} />
|
<Bar yAxisId="precip" dataKey="precipitation" fill="var(--color-cyan)" fillOpacity={0.6} isAnimationActive={animate} hide={!visibleWeatherSeries.precip} />
|
||||||
<Line yAxisId="temp" type={curveType} dataKey="temperature" stroke="var(--color-red)" strokeWidth={2} dot={true} isAnimationActive={animate} />
|
<Line yAxisId="temp" type="basis" dataKey="temperature" stroke="var(--color-red)" strokeWidth={3} dot={false} isAnimationActive={animate} hide={!visibleWeatherSeries.temp} />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
|
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)', padding: isMobile ? '0 0.75rem' : '0' }}>
|
||||||
<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
|
||||||
<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>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Wind Chart placed inside the main card below the weather graph */}
|
{/* Wind Chart placed inside the main card below the weather graph */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, Tooltip } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { FiX, FiSearch } from 'react-icons/fi';
|
import { FiX, FiSearch } from 'react-icons/fi';
|
||||||
@@ -20,6 +20,7 @@ interface LakeData {
|
|||||||
volume: string;
|
volume: string;
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
|
type?: 'lake' | 'river';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,7 +28,23 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create custom icon
|
// 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({
|
return L.divIcon({
|
||||||
className: 'custom-div-icon',
|
className: 'custom-div-icon',
|
||||||
html: `
|
html: `
|
||||||
@@ -54,12 +71,31 @@ const LakeMap = ({ language }: Props) => {
|
|||||||
.catch(err => console.error('Error fetching map lakes:', err));
|
.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 =>
|
const filteredLakes = lakes.filter(lake =>
|
||||||
lake.name.toLowerCase().includes(searchTerm.toLowerCase())
|
lake.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const customIcon = createCustomIcon();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-view-container">
|
<div className="map-view-container">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -85,11 +121,15 @@ const LakeMap = ({ language }: Props) => {
|
|||||||
<Marker
|
<Marker
|
||||||
key={lake.id}
|
key={lake.id}
|
||||||
position={[lake.lat, lake.lng]}
|
position={[lake.lat, lake.lng]}
|
||||||
icon={customIcon}
|
icon={createCustomIcon(lake.type)}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: () => navigate(`/${slugify(lake.name)}`)
|
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>
|
<Popup>
|
||||||
<strong>{lake.name}</strong><br/>
|
<strong>{lake.name}</strong><br/>
|
||||||
{lake.river}
|
{lake.river}
|
||||||
@@ -129,11 +169,11 @@ const LakeMap = ({ language }: Props) => {
|
|||||||
<div className="map-lake-stats">
|
<div className="map-lake-stats">
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--text-muted)', display: 'block' }}>{language === 'cs' ? 'Rozloha' : 'Area'}</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--text-muted)', display: 'block' }}>{language === 'cs' ? 'Hloubka' : 'Depth'}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { slugify } from '../utils/slugify';
|
import { slugify } from '../utils/slugify';
|
||||||
import { useFavorites } from '../hooks/useFavorites';
|
import { useFavorites } from '../hooks/useFavorites';
|
||||||
import { CircularProgress } from './CircularProgress';
|
import { CircularProgress } from './CircularProgress';
|
||||||
|
import { Tooltip } from './Tooltip';
|
||||||
|
import { TbSwimming, TbSailboat } from 'react-icons/tb';
|
||||||
|
|
||||||
interface Lake {
|
interface Lake {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,12 +22,21 @@ interface Lake {
|
|||||||
outflow: number;
|
outflow: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
maxVolume: number;
|
maxVolume: number;
|
||||||
|
navigationForbidden: boolean;
|
||||||
sparkline: number[];
|
sparkline: number[];
|
||||||
|
country?: string;
|
||||||
|
area?: number;
|
||||||
|
depth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
const getFlagEmoji = (countryCode?: string) => {
|
||||||
language: Language;
|
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 LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -72,7 +83,32 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
|
|||||||
<FiStar size={18} fill={isFav ? '#f59e0b' : 'none'} />
|
<FiStar size={18} fill={isFav ? '#f59e0b' : 'none'} />
|
||||||
</button>
|
</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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
|
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
|
||||||
@@ -93,6 +129,16 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</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 LakesOverview = ({ language }: Props) => {
|
||||||
const [lakes, setLakes] = useState<Lake[]>([]);
|
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();
|
const { isFavorite, toggleFavorite } = useFavorites();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('lakes_selectedCountry', selectedCountry);
|
||||||
|
}, [selectedCountry]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('lakes_sortBy', sortBy);
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = () => {
|
const loadData = () => {
|
||||||
fetch(`/data/lakes_index.json?t=${Date.now()}`)
|
fetch(`/data/lakes_index.json?t=${Date.now()}`)
|
||||||
.then(res => res.json())
|
.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));
|
.catch(err => console.error(err));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,11 +205,55 @@ const LakesOverview = ({ language }: Props) => {
|
|||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
|
const countries = Array.from(new Set(lakes.map(l => l.country || 'CZ'))).filter(Boolean).sort();
|
||||||
const priorityLakes = lakes.filter(l => l.priority && !isFavorite(l.id));
|
|
||||||
const otherLakes = lakes.filter(l => !l.priority && !isFavorite(l.id));
|
|
||||||
|
|
||||||
otherLakes.sort((a, b) => a.name.localeCompare(b.name));
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||||
@@ -166,46 +271,166 @@ const LakesOverview = ({ language }: Props) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Favorites section */}
|
{/* FILTER PANEL */}
|
||||||
{favoriteLakes.length > 0 && (
|
<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>
|
<section>
|
||||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||||
<FiStar size={16} fill="#f59e0b" color="#f59e0b" /> {language === 'cs' ? 'Oblíbená' : 'Favorites'} ({favoriteLakes.length})
|
{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>
|
</h2>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||||
gap: '1.5rem'
|
gap: '1.5rem'
|
||||||
}}>
|
}}>
|
||||||
{favoriteLakes.map(lake => (
|
{rankedLakes.map((lake, index) => (
|
||||||
<LakeCard key={lake.id} lake={lake} language={language} isFav={true} onToggleFav={toggleFavorite} />
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{priorityLakes.length > 0 && (
|
{/* RENDER CZ DEFAULT SPLIT LISTS */}
|
||||||
|
{!isPhysicalRank && priorityLakes.length > 0 && (
|
||||||
<section>
|
<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={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||||
gap: '1.5rem'
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{otherLakes.length > 0 && (
|
{!isPhysicalRank && otherLakes.length > 0 && (
|
||||||
<section>
|
<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={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||||
gap: '1.5rem'
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+25
-10
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
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 { type Language, t } from '../translations';
|
||||||
import { useFavorites } from '../hooks/useFavorites';
|
import { useFavorites } from '../hooks/useFavorites';
|
||||||
|
|
||||||
@@ -20,7 +21,9 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
|
|||||||
|
|
||||||
const isOverview = location.pathname === '/';
|
const isOverview = location.pathname === '/';
|
||||||
const isFavoritesPage = location.pathname === '/favorites';
|
const isFavoritesPage = location.pathname === '/favorites';
|
||||||
|
const isRiversPage = location.pathname === '/rivers';
|
||||||
const isMap = location.pathname === '/map';
|
const isMap = location.pathname === '/map';
|
||||||
|
const isRadar = location.pathname === '/radar';
|
||||||
|
|
||||||
const handleNavigate = (path: string) => {
|
const handleNavigate = (path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
@@ -29,17 +32,17 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||||
<div className="sidebar-logo">
|
<div className="sidebar-logo" style={{ alignItems: 'center', gap: '0.4rem' }}>
|
||||||
<FiDroplet size={28} color="var(--color-cyan)" />
|
<FiDroplet size={34} color="var(--color-cyan)" style={{ marginLeft: '-4px', flexShrink: 0 }} />
|
||||||
<div className="sidebar-text">
|
<div className="sidebar-text" style={{ position: 'relative', alignItems: 'center' }}>
|
||||||
<span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.1rem' }}>HLADINATOR</span>
|
<span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.15rem', lineHeight: 1 }}>HLADINATOR</span>
|
||||||
<small>v1.0</small>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle Button */}
|
{/* 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
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'var(--text-main)',
|
background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'var(--text-main)',
|
||||||
@@ -50,7 +53,7 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
|
|||||||
{isCollapsed ? <FiChevronRight size={18} /> : <FiChevronLeft size={18} />}
|
{isCollapsed ? <FiChevronRight size={18} /> : <FiChevronLeft size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
{/* Favourites */}
|
{/* Favourites */}
|
||||||
<div className={`nav-item ${isFavoritesPage ? 'active' : ''}`} onClick={() => handleNavigate('/favorites')} style={{ position: 'relative' }}>
|
<div className={`nav-item ${isFavoritesPage ? 'active' : ''}`} onClick={() => handleNavigate('/favorites')} style={{ position: 'relative' }}>
|
||||||
@@ -87,11 +90,23 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
|
|||||||
<span className="sidebar-text">{dict.lakes}</span>
|
<span className="sidebar-text">{dict.lakes}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rivers & Streams */}
|
||||||
|
<div className={`nav-item ${isRiversPage ? 'active' : ''}`} onClick={() => handleNavigate('/rivers')}>
|
||||||
|
<TbRipple size={18} />
|
||||||
|
<span className="sidebar-text">{dict.rivers}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
|
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
|
||||||
<FiMap />
|
<FiMap />
|
||||||
<span className="sidebar-text">{dict.map}</span>
|
<span className="sidebar-text">{dict.map}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Radar */}
|
||||||
|
<div className={`nav-item ${isRadar ? 'active' : ''}`} onClick={() => handleNavigate('/radar')}>
|
||||||
|
<FiCloudRain />
|
||||||
|
<span className="sidebar-text">{dict.radar}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -16,20 +16,23 @@ interface WeatherData {
|
|||||||
windDir: number; // degrees
|
windDir: number; // degrees
|
||||||
sunrise: string;
|
sunrise: string;
|
||||||
sunset: 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) => {
|
const formatTime = (isoString: string) => {
|
||||||
if (!isoString) return '--:--';
|
if (!isoString) return '--:--';
|
||||||
const date = new Date(isoString);
|
try {
|
||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
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) => {
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
console.log("Weather data loaded:", data);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lat || !lng) {
|
if (!lat || !lng) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -49,7 +58,7 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m&daily=sunrise,sunset&timezone=auto&wind_speed_unit=${windUnit}`);
|
const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m&daily=sunrise,sunset&timezone=auto&wind_speed_unit=${windUnit}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch weather');
|
if (!res.ok) throw new Error('Failed to fetch weather');
|
||||||
|
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setData({
|
setData({
|
||||||
temp: json.current.temperature_2m,
|
temp: json.current.temperature_2m,
|
||||||
@@ -57,7 +66,8 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
|
|||||||
windGusts: json.current.wind_gusts_10m,
|
windGusts: json.current.wind_gusts_10m,
|
||||||
windDir: json.current.wind_direction_10m,
|
windDir: json.current.wind_direction_10m,
|
||||||
sunrise: json.daily.sunrise[0],
|
sunrise: json.daily.sunrise[0],
|
||||||
sunset: json.daily.sunset[0]
|
sunset: json.daily.sunset[0],
|
||||||
|
time: json.current.time
|
||||||
});
|
});
|
||||||
setError(false);
|
setError(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -69,15 +79,15 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchWeather();
|
fetchWeather();
|
||||||
|
|
||||||
// Refresh weather every 15 minutes
|
// Refresh weather every 15 minutes
|
||||||
const interval = setInterval(fetchWeather, 15 * 60 * 1000);
|
const interval = setInterval(fetchWeather, 15 * 60 * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [lat, lng]);
|
}, [lat, lng, windUnit]);
|
||||||
|
|
||||||
const dict = {
|
const dict = {
|
||||||
cs: { title: 'Počasí a Vítr (Aktuálně)', error: 'Data nedostupná', wind: 'Vítr', gusts: 'Nárazy', temp: 'Teplota' },
|
cs: { title: 'POČASÍ A VÍTR', 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' }
|
en: { title: 'WEATHER & WIND', error: 'Data unavailable', wind: 'Wind', gusts: 'Gusts', temp: 'Temp' }
|
||||||
}[language];
|
}[language];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -96,48 +106,84 @@ export const WeatherWidget = ({ lat, lng, language, sensorTemp, windUnit = 'kmh'
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column' }}>
|
<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}</div>
|
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||||
|
{dict.title} {data.time ? `(${formatTime(data.time)})` : ''}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '1rem', alignItems: 'center' }}>
|
</div>
|
||||||
|
|
||||||
{/* Left Column: Wind */}
|
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', alignItems: 'center', height: '160px', marginTop: '-1rem', width: '100%' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
|
|
||||||
<div style={{
|
{/* Compass and Wind Info Wrapper */}
|
||||||
width: '40px', height: '40px', borderRadius: '50%', backgroundColor: 'rgba(0, 195, 255, 0.1)',
|
<div style={{ position: 'absolute', width: '160px', height: '160px', top: '44%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
{/* SVG Compass Ring */}
|
||||||
color: 'var(--color-cyan)', fontSize: '1.2rem',
|
<svg width="160" height="160" viewBox="0 0 260 260" style={{ position: 'absolute', top: 0, left: 0 }}>
|
||||||
transform: `rotate(${data.windDir}deg)`
|
<circle cx="130" cy="130" r="100" fill="transparent" stroke="rgba(255,255,255,0.03)" strokeWidth="30" />
|
||||||
}} 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. */}
|
{/* 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>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<div style={{ position: 'absolute', bottom: '42px', left: '50%', transform: 'translateX(-50%)', zIndex: 10, fontSize: '0.6rem', color: 'var(--color-purple)', whiteSpace: 'nowrap' }}>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', lineHeight: 1.1, color: 'var(--text-main)', whiteSpace: 'nowrap' }}>
|
{dict.gusts}: {data.windGusts.toFixed(1)} {windUnit === 'kmh' ? 'km/h' : 'm/s'}
|
||||||
{data.windSpeed.toFixed(1)} <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>{windUnit === 'kmh' ? 'km/h' : 'm/s'} • {getCompassDirection(data.windDir, language)}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '4px', whiteSpace: 'nowrap' }}>
|
|
||||||
{dict.gusts}: <span style={{ color: data.windGusts > (windUnit === 'kmh' ? 50 : 13.8) ? 'var(--color-red)' : 'var(--text-main)' }}>{data.windGusts.toFixed(1)} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Other Info */}
|
{/* Corner Elements */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', borderLeft: '1px solid var(--border-color)', paddingLeft: '1rem', whiteSpace: 'nowrap' }}>
|
<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'}>
|
||||||
<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)" size={15} />
|
||||||
<FiThermometer color="var(--color-orange)" />
|
<span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{sensorTemp !== undefined ? sensorTemp.toFixed(1) : data.temp.toFixed(1)} °C</span>
|
||||||
<span style={{ fontWeight: 'bold' }}>{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>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', color: 'var(--text-main)' }}>
|
||||||
<FiSunrise color="#f59e0b" />
|
<FiSunset color="var(--color-orange)" size={14} />
|
||||||
<span>{formatTime(data.sunrise)}</span>
|
<span style={{ fontWeight: 'bold' }}>{formatTime(data.sunset)}</span>
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
|
||||||
<FiSunset color="#f59e0b" />
|
|
||||||
<span>{formatTime(data.sunset)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,30 +27,51 @@ const getCompassDirection = (degrees: number, language: 'cs' | 'en') => {
|
|||||||
return directions[index];
|
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) {
|
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 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 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' });
|
const timeStr = date.toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
return (
|
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 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: '8px', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '4px' }}>
|
<div style={{ fontWeight: 'bold', marginBottom: '6px', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '3px', fontSize: '0.85rem' }}>
|
||||||
{dateStr} {timeStr}
|
{dateStr} {timeStr}
|
||||||
</div>
|
</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' }}>
|
<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>
|
<span>{language === 'cs' ? 'Rychlost větru' : 'Wind Speed'}: <strong>{data.speed} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<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>
|
<span>{language === 'cs' ? 'Nárazy větru' : 'Wind Gusts'}: <strong>{data.gusts} {windUnit === 'kmh' ? 'km/h' : 'm/s'}</strong></span>
|
||||||
</div>
|
</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 />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,13 +80,13 @@ const CustomWindTooltip = ({ active, payload, label, language, windUnit = 'kmh'
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CustomWindDot = (props: any) => {
|
const CustomWindDot = (props: { cx?: number; cy?: number; payload?: WindDataPoint }) => {
|
||||||
const { cx, cy, payload } = props;
|
const { cx, cy, payload } = props;
|
||||||
|
|
||||||
if (!cx || !cy || payload.dir === undefined) return null;
|
if (!cx || !cy || !payload || payload.dir === undefined) return null;
|
||||||
|
|
||||||
return (
|
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
|
<path
|
||||||
d="M0,-6 L-4,4 L0,2 L4,4 Z"
|
d="M0,-6 L-4,4 L0,2 L4,4 Z"
|
||||||
fill="var(--color-cyan)"
|
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 [data, setData] = useState<WindDataPoint[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentSpeed, setCurrentSpeed] = useState(0);
|
const [currentSpeed, setCurrentSpeed] = useState(0);
|
||||||
|
const [currentDir, setCurrentDir] = useState(0);
|
||||||
const [maxGust, setMaxGust] = 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(() => {
|
useEffect(() => {
|
||||||
const fetchWind = async () => {
|
const fetchWind = async () => {
|
||||||
@@ -158,6 +189,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
|||||||
setData(downsampled);
|
setData(downsampled);
|
||||||
setMaxGust(maxG);
|
setMaxGust(maxG);
|
||||||
setCurrentSpeed(speeds[closestIdx] || speeds[speeds.length - 1] || 0);
|
setCurrentSpeed(speeds[closestIdx] || speeds[speeds.length - 1] || 0);
|
||||||
|
setCurrentDir(dirs[closestIdx] || dirs[dirs.length - 1] || 0);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -169,7 +201,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
|||||||
if (lat && lng) {
|
if (lat && lng) {
|
||||||
fetchWind();
|
fetchWind();
|
||||||
}
|
}
|
||||||
}, [lat, lng, language, timeRange]);
|
}, [lat, lng, language, timeRange, windUnit]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -182,23 +214,36 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
|||||||
if (data.length === 0) return null;
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: '3rem', paddingTop: '1rem', display: 'flex', flexDirection: 'column', 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' }}>
|
<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' }}>
|
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<FiWind style={{ color: 'var(--color-cyan)' }} />
|
<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})`}
|
{language === 'cs' ? `Aktivita větru (${timeRange === '1y' || timeRange === 'all' ? 'denní maxima' : timeRange})` : `Wind Activity (${timeRange === '1y' || timeRange === 'all' ? 'daily max' : timeRange})`}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '1.5rem' }}>
|
<div style={{
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
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>
|
<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: '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>
|
<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>
|
</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>
|
<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' }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.25rem' }}>
|
||||||
<span style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--text-main)' }}>{maxGust.toFixed(1)}</span>
|
<span style={{ fontSize: '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>
|
</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%">
|
<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>
|
<defs>
|
||||||
<linearGradient id="colorWind" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorWind" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.4}/>
|
<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
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
stroke="var(--text-muted)"
|
stroke="var(--text-muted)"
|
||||||
tick={{fill: 'var(--text-muted)', fontSize: 11}}
|
tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 11}}
|
||||||
minTickGap={60}
|
minTickGap={60}
|
||||||
tickFormatter={(v) => {
|
tickFormatter={(v) => {
|
||||||
const d = new Date(v);
|
const d = new Date(v);
|
||||||
@@ -229,10 +285,15 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
|||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
stroke="var(--text-muted)"
|
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} />
|
<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
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -242,7 +303,11 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
|||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
fill="url(#colorWind)"
|
fill="url(#colorWind)"
|
||||||
isAnimationActive={true}
|
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 }}
|
activeDot={{ r: 6, fill: 'var(--color-cyan)', stroke: '#1e293b', strokeWidth: 2 }}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
@@ -258,7 +323,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<span style={{ display: 'inline-block', width: '12px', height: '3px', backgroundColor: 'var(--color-cyan)' }}></span>
|
<span style={{ display: 'inline-block', width: '12px', height: '3px', backgroundColor: 'var(--color-cyan)' }}></span>
|
||||||
<span>{language === 'cs' ? 'Rychlost větru' : 'Wind Speed'}</span>
|
<span>{language === 'cs' ? 'Rychlost větru' : 'Wind Speed'}</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
|
||||||
const STORAGE_KEY = 'hladinator_favorites';
|
const STORAGE_KEY = 'hladinator_favorites';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user