chore: update lake datasets, add new monitoring locations, and introduce docker-compose infrastructure
This commit is contained in:
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Documentation / Ideas
|
||||
docs/
|
||||
|
||||
@@ -65,6 +65,26 @@ Tento příkaz provede okamžitou aktualizaci a poté automaticky spouští stah
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Spuštění v Dockeru (Produkce a vlastní server)
|
||||
|
||||
Pokud chceš aplikaci nasadit na vlastní server a rovnou s ní spustit i databázi PostgreSQL (TimescaleDB) pro budoucí ukládání dat, je připravena konfigurace pro Docker Compose.
|
||||
|
||||
### Požadavky:
|
||||
- Nainstalovaný **Docker** a **Docker Compose**.
|
||||
|
||||
### Spuštění:
|
||||
1. Spusť sestavení a start kontejnerů na pozadí:
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
2. Docker Compose spustí dva kontejnery:
|
||||
- **`hladinator-db`**: Kontejner s databází PostgreSQL (TimescaleDB) běžící na portu `5432` se svazkem `pgdata` pro persistenci.
|
||||
- **`hladinator-web`**: Webový server Apache servírující zkompilovanou React aplikaci na portu `80`.
|
||||
|
||||
3. Web je následně dostupný na portu `80` vašeho serveru.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Oprava chyb v historii (Zuby / Nuly v grafu)
|
||||
|
||||
Pokud ti aplikace delší dobu neběžela (např. při vypnutém počítači) a následně došlo k doplnění dat z historie, mohly se v grafech přítoku a objemu objevit falešné propady k nule (zuby). Pro vyčištění celé historie a dopočítání těchto bodů z posledních známých hodnot spusť:
|
||||
|
||||
@@ -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: Dockerfile
|
||||
container_name: hladinator-web
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
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
+3187
-1
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3043
-1
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4831
-2
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4708
-1
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
+4699
-1
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
+4753
-1
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4726
-1
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
+4744
-1
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4713
-1
File diff suppressed because it is too large
Load Diff
+4683
-3
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
+4753
-1
File diff suppressed because it is too large
Load Diff
+1277
-812
File diff suppressed because it is too large
Load Diff
@@ -83,12 +83,16 @@ function main() {
|
||||
maxLevel?: number;
|
||||
storageLevel?: number;
|
||||
navigationForbidden?: boolean;
|
||||
type?: 'lake' | 'river';
|
||||
country?: string;
|
||||
area?: number;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export const lakesConfig: LakeConfig[] = [
|
||||
`;
|
||||
updated.forEach((l, idx) => {
|
||||
newContent += ` { id: "${l.id}", text: "${l.text}", ${l.priority ? 'priority: true, ' : ''}coords: [${l.coords[0].toFixed(4)}, ${l.coords[1].toFixed(4)}], maxVolume: ${l.maxVolume}, minLevel: ${l.minLevel}, maxLevel: ${l.maxLevel}, storageLevel: ${l.storageLevel}, navigationForbidden: ${l.navigationForbidden} }${idx === updated.length - 1 ? '' : ','}\n`;
|
||||
newContent += ` { id: "${l.id}", text: "${l.text}", ${l.priority ? 'priority: true, ' : ''}coords: [${l.coords[0].toFixed(4)}, ${l.coords[1].toFixed(4)}], maxVolume: ${l.maxVolume}, minLevel: ${l.minLevel}, maxLevel: ${l.maxLevel}, storageLevel: ${l.storageLevel}, navigationForbidden: ${l.navigationForbidden}${l.type ? `, type: '${l.type}'` : ''}${l.country ? `, country: '${l.country}'` : ''}${l.area ? `, area: ${l.area}` : ''}${l.depth ? `, depth: ${l.depth}` : ''} }${idx === updated.length - 1 ? '' : ','}\n`;
|
||||
});
|
||||
newContent += `];\n`;
|
||||
|
||||
|
||||
@@ -83,7 +83,10 @@ const lakes = lakesConfig.map(lake => {
|
||||
lat: lake.coords[0],
|
||||
lng: lake.coords[1],
|
||||
sparkline,
|
||||
type: lake.type || 'lake'
|
||||
type: lake.type || 'lake',
|
||||
country: lake.country || 'CZ',
|
||||
area: lake.area || 0,
|
||||
depth: lake.depth || 0
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+24
-10
@@ -9,19 +9,22 @@ export interface LakeConfig {
|
||||
storageLevel?: number;
|
||||
navigationForbidden?: boolean;
|
||||
type?: 'lake' | 'river';
|
||||
country?: string;
|
||||
area?: number;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export const lakesConfig: LakeConfig[] = [
|
||||
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306, minLevel: 716.1, maxLevel: 725.6, storageLevel: 724.9, navigationForbidden: false },
|
||||
{ id: "VLL2|1", text: "VD Lipno II - Vltava", priority: true, coords: [48.6250, 14.3180], maxVolume: 1.6, minLevel: 557.6, maxLevel: 563.35, storageLevel: 562.7, navigationForbidden: false },
|
||||
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 364.6, maxLevel: 370.1, storageLevel: 370.1, navigationForbidden: false },
|
||||
{ id: "VLKO|1", text: "VD Kořensko - Vltava", priority: true, coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 347.8, maxLevel: 353.6, storageLevel: 352.6, navigationForbidden: false },
|
||||
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 329.6, maxLevel: 353.6, storageLevel: 349.9, navigationForbidden: false },
|
||||
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 246.6, maxLevel: 270.6, storageLevel: 270.6, navigationForbidden: false },
|
||||
{ id: "VLST|2", text: "VD Štěchovice - Vltava", priority: true, coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 215.8, maxLevel: 219.4, storageLevel: 219.4, navigationForbidden: false },
|
||||
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 442.5, maxLevel: 471.48, storageLevel: 470.65, navigationForbidden: true },
|
||||
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 339.6, maxLevel: 357.97, storageLevel: 354.1, navigationForbidden: false },
|
||||
{ id: "ZESV|2", text: "VD Švihov (Želivka)", priority: true, coords: [49.7040, 15.1150], maxVolume: 266.6, minLevel: 343.1, maxLevel: 379.8, storageLevel: 377, navigationForbidden: true },
|
||||
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true, coords: [48.6322, 14.2215], maxVolume: 306, minLevel: 716.1, maxLevel: 725.6, storageLevel: 724.9, navigationForbidden: false, area: 48.7, depth: 25 },
|
||||
{ id: "VLL2|1", text: "VD Lipno II - Vltava", priority: true, coords: [48.6250, 14.3180], maxVolume: 1.6, minLevel: 557.6, maxLevel: 563.35, storageLevel: 562.7, navigationForbidden: false, area: 0.32, depth: 12 },
|
||||
{ id: "VLHN|1", text: "VD Hněvkovice - Vltava", priority: true, coords: [49.1830, 14.4440], maxVolume: 21.1, minLevel: 364.6, maxLevel: 370.1, storageLevel: 370.1, navigationForbidden: false, area: 3.21, depth: 17 },
|
||||
{ id: "VLKO|1", text: "VD Kořensko - Vltava", priority: true, coords: [49.2550, 14.3980], maxVolume: 2.8, minLevel: 347.8, maxLevel: 353.6, storageLevel: 352.6, navigationForbidden: false, area: 0.72, depth: 9 },
|
||||
{ id: "VLOR|2", text: "VD Orlík - Vltava", priority: true, coords: [49.6060, 14.1700], maxVolume: 716.5, minLevel: 329.6, maxLevel: 353.6, storageLevel: 349.9, navigationForbidden: false, area: 27.3, depth: 74 },
|
||||
{ id: "VLSL|2", text: "VD Slapy - Vltava", priority: true, coords: [49.8220, 14.4360], maxVolume: 269.3, minLevel: 246.6, maxLevel: 270.6, storageLevel: 270.6, navigationForbidden: false, area: 13.9, depth: 58 },
|
||||
{ id: "VLST|2", text: "VD Štěchovice - Vltava", priority: true, coords: [49.8450, 14.4120], maxVolume: 11.2, minLevel: 215.8, maxLevel: 219.4, storageLevel: 219.4, navigationForbidden: false, area: 0.96, depth: 22 },
|
||||
{ id: "MARI|1", text: "VD Římov - Malše", priority: true, coords: [48.8470, 14.4870], maxVolume: 33.8, minLevel: 442.5, maxLevel: 471.48, storageLevel: 470.65, navigationForbidden: true, area: 2.11, depth: 43 },
|
||||
{ id: "MZHR|3", text: "VD Hracholusky - Mže", priority: true, coords: [49.7890, 13.1550], maxVolume: 56.7, minLevel: 339.6, maxLevel: 357.97, storageLevel: 354.1, navigationForbidden: false, area: 4.9, depth: 31 },
|
||||
{ id: "ZESV|2", text: "VD Švihov (Želivka)", priority: true, coords: [49.7040, 15.1150], maxVolume: 266.6, minLevel: 343.1, maxLevel: 379.8, storageLevel: 377, navigationForbidden: true, area: 16.0, depth: 56 },
|
||||
{ id: "VLKA|2", text: "VD Kamýk", coords: [49.6380, 14.2580], maxVolume: 12.8, minLevel: 282.1, maxLevel: 284.6, storageLevel: 284.6, navigationForbidden: false },
|
||||
{ id: "VLVE|2", text: "VD Vrané", coords: [49.9390, 14.3910], maxVolume: 11.1, minLevel: 199.1, maxLevel: 200.1, storageLevel: 200.1, navigationForbidden: false },
|
||||
{ id: "BLHU|1", text: "VD Husinec", coords: [49.0270, 13.9870], maxVolume: 5.7, minLevel: 515.33, maxLevel: 529.88, storageLevel: 522.33, navigationForbidden: true },
|
||||
@@ -52,6 +55,17 @@ export const lakesConfig: LakeConfig[] = [
|
||||
{ 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 },
|
||||
|
||||
// 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' },
|
||||
|
||||
+118
-1
@@ -226,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() {
|
||||
console.log(`Starting bulk scraper for ${lakesConfig.length} lakes...`);
|
||||
|
||||
for (const lake of lakesConfig) {
|
||||
// ID format: VLL1|1 -> internalId=VLL1, oid=1
|
||||
const [internalId, oid] = lake.id.split('|');
|
||||
if (lake.country && lake.country !== 'CZ') {
|
||||
await getOrSimulateInternationalLake(lake);
|
||||
} else {
|
||||
await scrapeLake(lake.id, oid, internalId);
|
||||
}
|
||||
// Add small delay to not hammer the server
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
.app-footer {
|
||||
display: flex;
|
||||
justifyContent: space-between;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -25,6 +25,7 @@ interface Lake {
|
||||
maxVolume: number;
|
||||
navigationForbidden: boolean;
|
||||
sparkline: number[];
|
||||
country?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -32,6 +33,15 @@ interface Props {
|
||||
windUnit?: 'kmh' | 'ms';
|
||||
}
|
||||
|
||||
const getFlagEmoji = (countryCode?: string) => {
|
||||
const code = countryCode || 'CZ';
|
||||
const codePoints = code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
const FavoritesOverview = ({ language }: Props) => {
|
||||
const [lakes, setLakes] = useState<Lake[]>([]);
|
||||
const { isFavorite, toggleFavorite } = useFavorites();
|
||||
@@ -124,8 +134,9 @@ const FavoritesOverview = ({ language }: Props) => {
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingRight: '2rem' }}>
|
||||
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
|
||||
{lake.name} {lake.river ? `- ${lake.river}` : ''}
|
||||
<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')}>
|
||||
|
||||
+110
-13
@@ -30,6 +30,13 @@ interface LakeInfo {
|
||||
name: string;
|
||||
river: string;
|
||||
navigationForbidden?: boolean;
|
||||
type?: string;
|
||||
maxVolume?: number;
|
||||
volume?: number;
|
||||
capacity?: number;
|
||||
storageDiff?: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}
|
||||
|
||||
interface TooltipPayloadItem {
|
||||
@@ -195,8 +202,8 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
.then(json => {
|
||||
let lastValidLevel: number | null = null;
|
||||
const formattedData = json.map((item: { timestamp: string, level?: number, flow?: number, inflow?: number, volume?: number, temperature?: number, precipitation?: number, qn?: string }) => {
|
||||
const outflow = item.flow === null || isNaN(item.flow) ? 0 : item.flow;
|
||||
let level = item.level === null || isNaN(item.level) ? 0 : item.level;
|
||||
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) {
|
||||
@@ -240,8 +247,8 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
inflow: item.inflow || 0,
|
||||
volume: item.volume || 0,
|
||||
fullness: 0,
|
||||
temperature: item.temperature,
|
||||
precipitation: item.precipitation === null ? undefined : item.precipitation,
|
||||
temperature: item.temperature === undefined ? null : item.temperature,
|
||||
precipitation: (item.precipitation === null || item.precipitation === undefined) ? null : item.precipitation,
|
||||
qn: item.qn || ''
|
||||
};
|
||||
});
|
||||
@@ -287,14 +294,102 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
const cutoff = getCutoff();
|
||||
const filteredData = data.filter(d => new Date(d.timestamp).getTime() >= cutoff);
|
||||
|
||||
// Downsample data for large time ranges to prevent stuttering
|
||||
let chartData = filteredData;
|
||||
if (timeRange === '30d' && filteredData.length > 200) {
|
||||
chartData = filteredData.filter((_, i) => i % 4 === 0 || i === filteredData.length - 1);
|
||||
} else if ((timeRange === '1y' || timeRange === 'all') && filteredData.length > 200) {
|
||||
chartData = filteredData.filter((_, i) => i % 24 === 0 || i === filteredData.length - 1);
|
||||
// Resample data to constant time intervals to prevent X-axis time distortion
|
||||
const resampleData = (rawPoints: LipnoData[], range: typeof timeRange): LipnoData[] => {
|
||||
if (rawPoints.length === 0) return [];
|
||||
|
||||
let bucketSizeMs: number;
|
||||
switch (range) {
|
||||
case '24h':
|
||||
return rawPoints; // No aggregation needed for 24h
|
||||
case '7d':
|
||||
bucketSizeMs = 60 * 60 * 1000; // 1 hour
|
||||
break;
|
||||
case '30d':
|
||||
bucketSizeMs = 4 * 60 * 60 * 1000; // 4 hours
|
||||
break;
|
||||
case '1y':
|
||||
case 'all':
|
||||
default:
|
||||
bucketSizeMs = 24 * 60 * 60 * 1000; // 24 hours
|
||||
break;
|
||||
}
|
||||
|
||||
const buckets: { [key: number]: LipnoData[] } = {};
|
||||
|
||||
rawPoints.forEach(p => {
|
||||
const time = new Date(p.timestamp).getTime();
|
||||
const bucketIndex = Math.floor(time / bucketSizeMs) * bucketSizeMs;
|
||||
if (!buckets[bucketIndex]) {
|
||||
buckets[bucketIndex] = [];
|
||||
}
|
||||
buckets[bucketIndex].push(p);
|
||||
});
|
||||
|
||||
return Object.keys(buckets)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(bucketTime => {
|
||||
const pts = buckets[bucketTime];
|
||||
|
||||
let sumLevel = 0, countLevel = 0;
|
||||
let sumOutflow = 0, countOutflow = 0;
|
||||
let sumInflow = 0, countInflow = 0;
|
||||
let sumVolume = 0, countVolume = 0;
|
||||
let sumTemp = 0, countTemp = 0;
|
||||
let sumPrecip = 0, countPrecip = 0;
|
||||
let qn = '';
|
||||
|
||||
pts.forEach(p => {
|
||||
if (p.level !== null && p.level !== undefined && !isNaN(p.level)) {
|
||||
sumLevel += p.level;
|
||||
countLevel++;
|
||||
}
|
||||
if (p.outflow !== null && p.outflow !== undefined && !isNaN(p.outflow)) {
|
||||
sumOutflow += p.outflow;
|
||||
countOutflow++;
|
||||
}
|
||||
if (p.inflow !== null && p.inflow !== undefined && !isNaN(p.inflow)) {
|
||||
sumInflow += p.inflow;
|
||||
countInflow++;
|
||||
}
|
||||
if (p.volume !== null && p.volume !== undefined && !isNaN(p.volume)) {
|
||||
sumVolume += p.volume;
|
||||
countVolume++;
|
||||
}
|
||||
if (p.temperature !== null && p.temperature !== undefined && !isNaN(p.temperature)) {
|
||||
sumTemp += p.temperature;
|
||||
countTemp++;
|
||||
}
|
||||
if (p.precipitation !== null && p.precipitation !== undefined && !isNaN(p.precipitation)) {
|
||||
sumPrecip += p.precipitation;
|
||||
countPrecip++;
|
||||
}
|
||||
if (p.qn) qn = p.qn;
|
||||
});
|
||||
|
||||
const dateObj = new Date(bucketTime);
|
||||
|
||||
return {
|
||||
timestamp: dateObj.toISOString(),
|
||||
date: dateObj.toLocaleString(language === 'cs' ? 'cs-CZ' : 'en-GB', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}),
|
||||
level: countLevel > 0 ? sumLevel / countLevel : 0,
|
||||
outflow: countOutflow > 0 ? sumOutflow / countOutflow : 0,
|
||||
inflow: countInflow > 0 ? sumInflow / countInflow : 0,
|
||||
volume: countVolume > 0 ? sumVolume / countVolume : 0,
|
||||
fullness: 0,
|
||||
temperature: countTemp > 0 ? sumTemp / countTemp : undefined,
|
||||
precipitation: countPrecip > 0 ? sumPrecip : undefined,
|
||||
qn
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const chartData = resampleData(filteredData, timeRange);
|
||||
|
||||
const animate = chartData.length < 150;
|
||||
|
||||
// Find record from 24h, 7d, 30d ago
|
||||
@@ -384,6 +479,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
return [Math.max(0, Math.floor(dataMin - 10)), Math.ceil(dataMax + 10)];
|
||||
} else {
|
||||
let min = dataMin;
|
||||
if (staticConfig?.minLevel && staticConfig.minLevel < min) min = staticConfig.minLevel;
|
||||
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
|
||||
let max = dataMax;
|
||||
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
|
||||
@@ -508,6 +604,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
: [
|
||||
(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;
|
||||
},
|
||||
@@ -666,7 +763,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: 10, bottom: 0 }}
|
||||
onMouseMove={(state: { chartY?: number } | null | undefined) => {
|
||||
onMouseMove={(state: any) => {
|
||||
if (state && state.chartY !== undefined) {
|
||||
const isBottomHalf = state.chartY > 150;
|
||||
const targetY = isBottomHalf ? 5 : 180;
|
||||
@@ -682,7 +779,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 12}} minTickGap={50} />
|
||||
<YAxis yAxisId="left" domain={leftCustomDomain || (leftYAxisDomain as [number, number] | ['auto', 'auto'])} 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="left" domain={leftCustomDomain || (leftYAxisDomain as any)} stroke={visibleSeries.level ? "var(--text-muted)" : "transparent"} tick={{fill: visibleSeries.level ? 'var(--text-muted)' : 'transparent', fontSize: isMobile ? 10 : 12}} tickLine={visibleSeries.level ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} axisLine={visibleSeries.level ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} width={isMobile ? 42 : 60} tickFormatter={(v) => v.toFixed(isRiver ? 0 : 2)} />
|
||||
<YAxis yAxisId="right" orientation="right" domain={rightCustomDomain || [0, (dataMax: number) => Math.max(dataMax, 1)]} stroke={(visibleSeries.outflow || visibleSeries.inflow) ? "var(--text-muted)" : "transparent"} tick={{fill: (visibleSeries.outflow || visibleSeries.inflow) ? 'var(--text-muted)' : 'transparent', fontSize: isMobile ? 10 : 12}} tickLine={(visibleSeries.outflow || visibleSeries.inflow) ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} axisLine={(visibleSeries.outflow || visibleSeries.inflow) ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} width={isMobile ? 35 : 60} tickFormatter={(v) => v.toFixed(1)} />
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
@@ -823,7 +920,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: 10, bottom: 0 }}
|
||||
onMouseMove={(state: { chartY?: number } | null | undefined) => {
|
||||
onMouseMove={(state: any) => {
|
||||
if (state && state.chartY !== undefined) {
|
||||
const isBottomHalf = state.chartY > 100;
|
||||
const targetY = isBottomHalf ? 5 : 110;
|
||||
|
||||
@@ -24,12 +24,19 @@ interface Lake {
|
||||
maxVolume: number;
|
||||
navigationForbidden: boolean;
|
||||
sparkline: number[];
|
||||
country?: string;
|
||||
area?: number;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
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 LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -76,9 +83,22 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
|
||||
<FiStar size={18} fill={isFav ? '#f59e0b' : 'none'} />
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingRight: '2rem' }}>
|
||||
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
|
||||
{lake.name} {lake.river ? `- ${lake.river}` : ''}
|
||||
{/* 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')}>
|
||||
@@ -109,6 +129,16 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{((lake.area !== undefined && lake.area > 0) || (lake.depth !== undefined && lake.depth > 0)) && (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.35rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
{lake.area !== undefined && lake.area > 0 && (
|
||||
<span>{language === 'cs' ? 'Rozloha:' : 'Area:'} <strong style={{ color: 'var(--text-main)' }}>{lake.area} km²</strong></span>
|
||||
)}
|
||||
{lake.depth !== undefined && lake.depth > 0 && (
|
||||
<span>{language === 'cs' ? 'Hloubka:' : 'Depth:'} <strong style={{ color: 'var(--text-main)' }}>{lake.depth} m</strong></span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,10 +173,25 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
windUnit?: 'kmh' | 'ms';
|
||||
}
|
||||
|
||||
const LakesOverview = ({ language }: Props) => {
|
||||
const [lakes, setLakes] = useState<Lake[]>([]);
|
||||
const [selectedCountry, setSelectedCountry] = useState<string>(() => sessionStorage.getItem('lakes_selectedCountry') || 'ALL');
|
||||
const [sortBy, setSortBy] = useState<string>(() => sessionStorage.getItem('lakes_sortBy') || 'name-asc');
|
||||
const { isFavorite, toggleFavorite } = useFavorites();
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('lakes_selectedCountry', selectedCountry);
|
||||
}, [selectedCountry]);
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('lakes_sortBy', sortBy);
|
||||
}, [sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = () => {
|
||||
fetch(`/data/lakes_index.json?t=${Date.now()}`)
|
||||
@@ -160,11 +205,55 @@ const LakesOverview = ({ language }: Props) => {
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
|
||||
const priorityLakes = lakes.filter(l => l.priority && !isFavorite(l.id));
|
||||
const otherLakes = lakes.filter(l => !l.priority && !isFavorite(l.id));
|
||||
const countries = Array.from(new Set(lakes.map(l => l.country || 'CZ'))).filter(Boolean).sort();
|
||||
|
||||
otherLakes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sortLakes = (list: Lake[]) => {
|
||||
return [...list].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'volume-desc':
|
||||
return (b.maxVolume || 0) - (a.maxVolume || 0);
|
||||
case 'area-desc':
|
||||
return (b.area || 0) - (a.area || 0);
|
||||
case 'depth-desc':
|
||||
return (b.depth || 0) - (a.depth || 0);
|
||||
case 'name-desc':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'capacity-desc':
|
||||
return b.capacity - a.capacity;
|
||||
case 'inflow-desc':
|
||||
return parseFloat(b.inflow as any) - parseFloat(a.inflow as any);
|
||||
case 'outflow-desc':
|
||||
return parseFloat(b.outflow as any) - parseFloat(a.outflow as any);
|
||||
case 'name-asc':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Filter based on country
|
||||
const countryFiltered = lakes.filter(l => selectedCountry === 'ALL' || (l.country || 'CZ') === selectedCountry);
|
||||
|
||||
// Filter based on sorting preset requirements if needed
|
||||
const preFiltered = (() => {
|
||||
if (sortBy === 'area-desc') {
|
||||
return countryFiltered.filter(l => l.area !== undefined && l.area > 0);
|
||||
}
|
||||
if (sortBy === 'depth-desc') {
|
||||
return countryFiltered.filter(l => l.depth !== undefined && l.depth > 0);
|
||||
}
|
||||
if (sortBy === 'volume-desc') {
|
||||
return countryFiltered.filter(l => l.maxVolume !== undefined && l.maxVolume > 0);
|
||||
}
|
||||
return countryFiltered;
|
||||
})();
|
||||
|
||||
const sortedLakes = sortLakes(preFiltered);
|
||||
|
||||
const isPhysicalRank = ['volume-desc', 'area-desc', 'depth-desc'].includes(sortBy);
|
||||
const priorityLakes = !isPhysicalRank ? sortedLakes.filter(l => l.priority) : [];
|
||||
const otherLakes = !isPhysicalRank ? sortedLakes.filter(l => !l.priority) : [];
|
||||
const rankedLakes = isPhysicalRank ? sortedLakes : [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
@@ -182,46 +271,166 @@ const LakesOverview = ({ language }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Favorites section */}
|
||||
{favoriteLakes.length > 0 && (
|
||||
{/* FILTER PANEL */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '1.5rem',
|
||||
flexWrap: 'wrap',
|
||||
padding: '1.25rem',
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid var(--border-color)',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{/* SORT BY FILTER (MAIN / FIRST) */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
{language === 'cs' ? 'Seřadit podle' : 'Sort by'}
|
||||
</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-dark)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 2rem 0.5rem 0.75rem',
|
||||
fontSize: '0.9rem',
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
minWidth: '220px'
|
||||
}}
|
||||
>
|
||||
<option value="volume-desc">{language === 'cs' ? 'Největší objem' : 'Largest volume'}</option>
|
||||
<option value="area-desc">{language === 'cs' ? 'Největší rozloha' : 'Largest area'}</option>
|
||||
<option value="depth-desc">{language === 'cs' ? 'Největší hloubka' : 'Largest depth'}</option>
|
||||
<option value="name-asc">{language === 'cs' ? 'Název (A-Z)' : 'Name (A-Z)'}</option>
|
||||
<option value="name-desc">{language === 'cs' ? 'Název (Z-A)' : 'Name (Z-A)'}</option>
|
||||
<option value="inflow-desc">{language === 'cs' ? 'Přítok (nejvyšší)' : 'Inflow (highest)'}</option>
|
||||
<option value="outflow-desc">{language === 'cs' ? 'Odtok (nejvyšší)' : 'Outflow (highest)'}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* COUNTRY FILTER (SECOND) */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
{language === 'cs' ? 'Země' : 'Country'}
|
||||
</label>
|
||||
<select
|
||||
value={selectedCountry}
|
||||
onChange={(e) => setSelectedCountry(e.target.value)}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-dark)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 2rem 0.5rem 0.75rem',
|
||||
fontSize: '0.9rem',
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
minWidth: '150px'
|
||||
}}
|
||||
>
|
||||
<option value="ALL">{language === 'cs' ? 'Všechny země' : 'All countries'}</option>
|
||||
{countries.map(c => {
|
||||
const czNames: Record<string, string> = {
|
||||
CZ: 'Česko',
|
||||
US: 'USA',
|
||||
CA: 'Kanada',
|
||||
CN: 'Čína',
|
||||
BR: 'Brazílie',
|
||||
RU: 'Rusko',
|
||||
CH: 'Švýcarsko'
|
||||
};
|
||||
const enNames: Record<string, string> = {
|
||||
CZ: 'Czechia',
|
||||
US: 'USA',
|
||||
CA: 'Canada',
|
||||
CN: 'China',
|
||||
BR: 'Brazil',
|
||||
RU: 'Russia',
|
||||
CH: 'Switzerland'
|
||||
};
|
||||
const fullName = language === 'cs' ? (czNames[c] || c) : (enNames[c] || c);
|
||||
return (
|
||||
<option key={c} value={c}>{fullName} ({c})</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RENDER RANKED / SINGLE LIST */}
|
||||
{isPhysicalRank && rankedLakes.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<FiStar size={16} fill="#f59e0b" color="#f59e0b" /> {language === 'cs' ? 'Oblíbená' : 'Favorites'} ({favoriteLakes.length})
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||
{sortBy === 'area-desc' && (language === 'cs' ? 'Žebříček: Největší jezera a nádrže podle rozlohy' : 'Ranking: Largest Lakes & Reservoirs by Area')}
|
||||
{sortBy === 'depth-desc' && (language === 'cs' ? 'Žebříček: Nejhlubší jezera a nádrže' : 'Ranking: Deepest Lakes & Reservoirs')}
|
||||
{sortBy === 'volume-desc' && (language === 'cs' ? 'Žebříček: Největší jezera a nádrže podle objemu' : 'Ranking: Largest Lakes & Reservoirs by Volume')}
|
||||
{` (${rankedLakes.length})`}
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: '1.5rem'
|
||||
}}>
|
||||
{favoriteLakes.map(lake => (
|
||||
<LakeCard key={lake.id} lake={lake} language={language} isFav={true} onToggleFav={toggleFavorite} />
|
||||
{rankedLakes.map((lake, index) => (
|
||||
<div key={lake.id} style={{ position: 'relative' }}>
|
||||
{/* Ranking Badge */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-0.5rem',
|
||||
left: '-0.5rem',
|
||||
backgroundColor: index === 0 ? 'var(--color-gold, #f59e0b)' : index === 1 ? '#94a3b8' : index === 2 ? '#b45309' : 'var(--bg-card)',
|
||||
color: index < 3 ? '#fff' : 'var(--text-muted)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '50%',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 3,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<LakeCard lake={lake} language={language} isFav={isFavorite(lake.id)} onToggleFav={toggleFavorite} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{priorityLakes.length > 0 && (
|
||||
{/* RENDER CZ DEFAULT SPLIT LISTS */}
|
||||
{!isPhysicalRank && priorityLakes.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>{language === 'cs' ? 'Jezera a nádrže' : 'Lakes and Reservoirs'}</h2>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>{language === 'cs' ? 'Jezera a nádrže' : 'Lakes and Reservoirs'} ({priorityLakes.length})</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: '1.5rem'
|
||||
}}>
|
||||
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={false} onToggleFav={toggleFavorite} />)}
|
||||
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={isFavorite(lake.id)} onToggleFav={toggleFavorite} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{otherLakes.length > 0 && (
|
||||
{!isPhysicalRank && otherLakes.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', marginTop: '1rem' }}>{language === 'cs' ? 'Ostatní' : 'Other'}</h2>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', marginTop: '1rem' }}>{language === 'cs' ? 'Ostatní' : 'Other'} ({otherLakes.length})</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: '1.5rem'
|
||||
}}>
|
||||
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={false} onToggleFav={toggleFavorite} />)}
|
||||
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={isFavorite(lake.id)} onToggleFav={toggleFavorite} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface River {
|
||||
navigationForbidden: boolean;
|
||||
sparkline: number[];
|
||||
type: 'lake' | 'river';
|
||||
country?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -31,6 +32,15 @@ interface Props {
|
||||
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 }));
|
||||
@@ -76,9 +86,22 @@ const RiverCard = ({ river, language, isFav, onToggleFav }: { river: River, lang
|
||||
<FiStar size={18} fill={isFav ? '#f59e0b' : 'none'} />
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingRight: '2rem' }}>
|
||||
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
|
||||
{river.name} {river.river ? `- ${river.river}` : ''}
|
||||
{/* 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')}>
|
||||
@@ -159,8 +182,18 @@ const RiverCard = ({ river, language, isFav, onToggleFav }: { river: River, lang
|
||||
|
||||
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()}`)
|
||||
@@ -178,9 +211,27 @@ export const RiversOverview = ({ language }: Props) => {
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const favoriteRivers = rivers.filter(r => isFavorite(r.id));
|
||||
const activeRivers = rivers.filter(r => !isFavorite(r.id));
|
||||
activeRivers.sort((a, b) => a.name.localeCompare(b.name));
|
||||
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'
|
||||
@@ -205,29 +256,98 @@ export const RiversOverview = ({ language }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Favorites section */}
|
||||
{favoriteRivers.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<FiStar size={16} fill="#f59e0b" color="#f59e0b" /> {language === 'cs' ? 'Oblíbené' : 'Favorites'} ({favoriteRivers.length})
|
||||
</h2>
|
||||
{/* FILTER PANEL */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: '1.5rem'
|
||||
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'
|
||||
}}>
|
||||
{favoriteRivers.map(river => (
|
||||
<RiverCard key={river.id} river={river} language={language} isFav={true} onToggleFav={toggleFavorite} />
|
||||
))}
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* All Rivers section */}
|
||||
{activeRivers.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||
{language === 'cs' ? 'Sledované profily' : 'Monitored Profiles'}
|
||||
{language === 'cs' ? 'Sledované profily' : 'Monitored Profiles'} ({activeRivers.length})
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
@@ -235,7 +355,7 @@ export const RiversOverview = ({ language }: Props) => {
|
||||
gap: '1.5rem'
|
||||
}}>
|
||||
{activeRivers.map(river => (
|
||||
<RiverCard key={river.id} river={river} language={language} isFav={false} onToggleFav={toggleFavorite} />
|
||||
<RiverCard key={river.id} river={river} language={language} isFav={isFavorite(river.id)} onToggleFav={toggleFavorite} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -19,13 +19,6 @@ interface WeatherData {
|
||||
time?: string;
|
||||
}
|
||||
|
||||
const getCompassDirection = (degrees: number, language: 'cs' | 'en') => {
|
||||
const directionsEn = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
const directionsCs = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
|
||||
const directions = language === 'cs' ? directionsCs : directionsEn;
|
||||
const index = Math.round(((degrees %= 360) < 0 ? degrees + 360 : degrees) / 45) % 8;
|
||||
return directions[index];
|
||||
};
|
||||
|
||||
const formatTime = (isoString: string) => {
|
||||
if (!isoString) return '--:--';
|
||||
|
||||
@@ -42,7 +42,7 @@ const CustomWindTooltip = ({ active, payload, label, language, windUnit = 'kmh',
|
||||
const isLeft = coordinate && viewBox && coordinate.x > viewBox.width / 2;
|
||||
const tooltipClass = `chart-tooltip ${isLeft ? 'tooltip-left' : 'tooltip-right'}`;
|
||||
const data = payload[0].payload;
|
||||
const date = new Date(label);
|
||||
const date = new Date(label || '');
|
||||
const dateStr = date.toLocaleDateString(language === 'cs' ? 'cs-CZ' : 'en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const timeStr = date.toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
@@ -83,7 +83,7 @@ const CustomWindTooltip = ({ active, payload, label, language, windUnit = 'kmh',
|
||||
const CustomWindDot = (props: { cx?: number; cy?: number; payload?: WindDataPoint }) => {
|
||||
const { cx, cy, payload } = props;
|
||||
|
||||
if (!cx || !cy || payload.dir === undefined) return null;
|
||||
if (!cx || !cy || !payload || payload.dir === undefined) return null;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${cx},${cy}) rotate(${payload.dir + 180}) scale(1.5)`}>
|
||||
@@ -258,7 +258,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '24h', windUnit = 'k
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: -20, bottom: 0 }}
|
||||
onMouseMove={(state: { chartY?: number } | null | undefined) => {
|
||||
onMouseMove={(state: any) => {
|
||||
if (state && state.chartY !== undefined) {
|
||||
const isBottomHalf = state.chartY > 140;
|
||||
const targetY = isBottomHalf ? 5 : 160;
|
||||
|
||||
Reference in New Issue
Block a user