feat: add rivers overview component and sync lake volume data across the dataset
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
|
||||
import { TbRipple } from 'react-icons/tb';
|
||||
import { type Language, t } from '../translations';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CircularProgress } from './CircularProgress';
|
||||
@@ -22,9 +23,10 @@ interface Props {
|
||||
data: KpiData;
|
||||
language: Language;
|
||||
lakeName?: string;
|
||||
isRiver?: boolean;
|
||||
}
|
||||
|
||||
const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
||||
const KpiCards = ({ data, language, lakeName = 'Lipno 1', isRiver = false }: Props) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const dict = t[language].kpi;
|
||||
const flowDiff = data.inflow - data.outflow;
|
||||
@@ -38,6 +40,70 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
||||
}
|
||||
}, [showTooltip]);
|
||||
|
||||
if (isRiver) {
|
||||
return (
|
||||
<>
|
||||
{/* CARD 1: WATER LEVEL */}
|
||||
<div className="kpi-card">
|
||||
<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', alignContent: 'flex-start', 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">
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
{dict.currentFlow}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, whiteSpace: 'nowrap', marginBottom: '0.5rem' }}>
|
||||
{data.outflow.toFixed(1)} <span style={{ fontSize: '1.25rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m³/s</span>
|
||||
</div>
|
||||
{data.avgOutflow24h !== undefined && (
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
Ø 24h: <span style={{ fontWeight: 'bold', color: 'var(--text-main)' }}>{data.avgOutflow24h.toFixed(1)} m³/s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
width: '70px', height: '70px', borderRadius: '50%',
|
||||
backgroundColor: 'rgba(6, 182, 212, 0.1)',
|
||||
border: '2px dashed var(--color-cyan)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--color-cyan)', flexShrink: 0
|
||||
}}>
|
||||
<TbRipple size={36} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* CARD 1: WATER LEVEL */}
|
||||
|
||||
@@ -30,7 +30,7 @@ interface Props {
|
||||
windUnit?: 'kmh' | 'ms';
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
|
||||
const CustomTooltip = ({ active, payload, label, language, isWeather, isRiver }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const dict = t[language as Language].chart;
|
||||
if (isWeather) {
|
||||
@@ -60,18 +60,38 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
|
||||
let labelStr = '';
|
||||
let unit = '';
|
||||
let color = '';
|
||||
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; color = 'var(--color-cyan)'; }
|
||||
else if (entry.dataKey === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; color = 'var(--color-red)'; }
|
||||
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; color = 'var(--color-green)'; }
|
||||
else if (entry.dataKey === 'temperature') { labelStr = language === 'cs' ? 'Teplota' : 'Temperature'; unit = '°C'; color = 'var(--color-red)'; }
|
||||
else if (entry.dataKey === 'precipitation') { labelStr = language === 'cs' ? 'Srážky' : 'Precipitation'; unit = 'mm'; color = 'var(--color-cyan)'; }
|
||||
if (entry.dataKey === 'level') {
|
||||
labelStr = isRiver ? (language === 'cs' ? 'Vodní stav' : 'Water level') : dict.level;
|
||||
unit = isRiver ? 'cm' : 'm n. m.';
|
||||
color = 'var(--color-cyan)';
|
||||
}
|
||||
else if (entry.dataKey === 'outflow') {
|
||||
labelStr = isRiver ? (language === 'cs' ? 'Průtok' : 'Flow') : dict.outflow;
|
||||
unit = 'm³/s';
|
||||
color = 'var(--color-red)';
|
||||
}
|
||||
else if (entry.dataKey === 'inflow') {
|
||||
labelStr = dict.inflow;
|
||||
unit = 'm³/s';
|
||||
color = 'var(--color-green)';
|
||||
}
|
||||
else if (entry.dataKey === 'temperature') {
|
||||
labelStr = language === 'cs' ? 'Teplota' : 'Temperature';
|
||||
unit = '°C';
|
||||
color = 'var(--color-red)';
|
||||
}
|
||||
else if (entry.dataKey === 'precipitation') {
|
||||
labelStr = language === 'cs' ? 'Srážky' : 'Precipitation';
|
||||
unit = 'mm';
|
||||
color = 'var(--color-cyan)';
|
||||
}
|
||||
|
||||
if (!labelStr || entry.value === null || entry.value === undefined) return null;
|
||||
|
||||
return (
|
||||
<div key={index} style={{ margin: 0, color: 'var(--text-main)', display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color, marginRight: '8px' }}></span>
|
||||
{labelStr}: <span style={{ fontWeight: 'bold', marginLeft: '4px' }}>{entry.value.toFixed(entry.dataKey === 'level' ? 2 : 1)} {unit}</span>
|
||||
{labelStr}: <span style={{ fontWeight: 'bold', marginLeft: '4px' }}>{entry.value.toFixed(entry.dataKey === 'level' ? (isRiver ? 0 : 2) : 1)} {unit}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -233,6 +253,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
|
||||
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
|
||||
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
|
||||
const isRiver = lakeInfo?.type === 'river';
|
||||
|
||||
const kpiData = {
|
||||
level: latestData.level,
|
||||
@@ -249,19 +270,24 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
avgOutflow24h
|
||||
};
|
||||
|
||||
const leftYAxisDomain = [
|
||||
(dataMin: number) => {
|
||||
let min = dataMin;
|
||||
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
|
||||
return min - 0.5;
|
||||
},
|
||||
(dataMax: number) => {
|
||||
let max = dataMax;
|
||||
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
|
||||
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
|
||||
return max + 0.5;
|
||||
}
|
||||
];
|
||||
const leftYAxisDomain = isRiver
|
||||
? [
|
||||
(dataMin: number) => Math.max(0, Math.floor(dataMin - 10)),
|
||||
(dataMax: number) => Math.ceil(dataMax + 10)
|
||||
]
|
||||
: [
|
||||
(dataMin: number) => {
|
||||
let min = dataMin;
|
||||
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
|
||||
return min - 0.5;
|
||||
},
|
||||
(dataMax: number) => {
|
||||
let max = dataMax;
|
||||
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
|
||||
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
|
||||
return max + 0.5;
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
|
||||
@@ -308,7 +334,7 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="kpi-grid-container">
|
||||
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
|
||||
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} isRiver={isRiver} />
|
||||
|
||||
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
|
||||
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature ?? undefined} />
|
||||
@@ -359,34 +385,45 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
||||
<YAxis yAxisId="left" domain={leftYAxisDomain as any} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(2)} />
|
||||
<YAxis yAxisId="left" domain={leftYAxisDomain as any} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} 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}} />
|
||||
|
||||
<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} />} />
|
||||
|
||||
{/* Data Series */}
|
||||
{limits && limits.map((limit, idx) => (
|
||||
<ReferenceLine key={idx} yAxisId="left" y={limit.level} stroke={limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b'} strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `${limit.labelCs} (${limit.level.toFixed(2)} m n. m.)` : `${limit.labelEn} (${limit.level.toFixed(2)} m a.s.l.)`, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', fontSize: 12 }} />
|
||||
))}
|
||||
{staticConfig?.maxLevel && (
|
||||
{!isRiver && staticConfig?.maxLevel && (
|
||||
<ReferenceLine yAxisId="left" y={staticConfig.maxLevel} stroke="var(--color-orange)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Maximální retenční hladina (${staticConfig.maxLevel.toFixed(2)} m n. m.)` : `Max retention level (${staticConfig.maxLevel.toFixed(2)} m a.s.l.)`, fill: 'var(--color-orange)', fontSize: 12 }} />
|
||||
)}
|
||||
{staticConfig?.storageLevel && (
|
||||
{!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: 12 }} />
|
||||
)}
|
||||
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} />
|
||||
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-red)" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
||||
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
||||
{!isRiver && <Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} />}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Chart Legend */}
|
||||
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div> {dict.level}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> {dict.outflow}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div> {dict.inflow}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div>
|
||||
{isRiver ? (language === 'cs' ? 'Vodní stav' : 'Water level') : dict.level}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div>
|
||||
{isRiver ? (language === 'cs' ? 'Průtok' : 'Flow') : dict.outflow}
|
||||
</span>
|
||||
{!isRiver && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div>
|
||||
{dict.inflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WEATHER CHART SECTION */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Tooltip } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { FiX, FiSearch } from 'react-icons/fi';
|
||||
@@ -20,6 +20,7 @@ interface LakeData {
|
||||
volume: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
type?: 'lake' | 'river';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -27,7 +28,23 @@ interface Props {
|
||||
}
|
||||
|
||||
// Create custom icon
|
||||
const createCustomIcon = () => {
|
||||
const createCustomIcon = (type?: 'lake' | 'river') => {
|
||||
if (type === 'river') {
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `
|
||||
<div class="river-marker-icon">
|
||||
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" height="20" width="20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12h18M3 8h18M3 16h18" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [36, 42],
|
||||
iconAnchor: [18, 42],
|
||||
popupAnchor: [0, -42],
|
||||
});
|
||||
}
|
||||
|
||||
return L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `
|
||||
@@ -58,8 +75,6 @@ const LakeMap = ({ language }: Props) => {
|
||||
lake.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const customIcon = createCustomIcon();
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
<Helmet>
|
||||
@@ -85,11 +100,15 @@ const LakeMap = ({ language }: Props) => {
|
||||
<Marker
|
||||
key={lake.id}
|
||||
position={[lake.lat, lake.lng]}
|
||||
icon={customIcon}
|
||||
icon={createCustomIcon(lake.type)}
|
||||
eventHandlers={{
|
||||
click: () => navigate(`/${slugify(lake.name)}`)
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -38]} opacity={0.9}>
|
||||
<span style={{ fontWeight: 'bold' }}>{lake.name}</span>
|
||||
{lake.river && <span style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginLeft: '0.4rem' }}>({lake.river})</span>}
|
||||
</Tooltip>
|
||||
<Popup>
|
||||
<strong>{lake.name}</strong><br/>
|
||||
{lake.river}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
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';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
windUnit?: 'kmh' | 'ms';
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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}` : ''}
|
||||
</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 { isFavorite, toggleFavorite } = useFavorites();
|
||||
|
||||
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: any) => 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 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 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>
|
||||
|
||||
{/* 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>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: '1.5rem'
|
||||
}}>
|
||||
{favoriteRivers.map(river => (
|
||||
<RiverCard key={river.id} river={river} language={language} isFav={true} onToggleFav={toggleFavorite} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* All Rivers section */}
|
||||
{activeRivers.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||
{language === 'cs' ? 'Sledované profily' : 'Monitored Profiles'}
|
||||
</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={false} onToggleFav={toggleFavorite} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { FiDroplet, FiStar, FiMap, FiSettings, FiChevronLeft, FiChevronRight, FiDatabase, FiCloudRain } from 'react-icons/fi';
|
||||
import { TbRipple } from 'react-icons/tb';
|
||||
import { type Language, t } from '../translations';
|
||||
import { useFavorites } from '../hooks/useFavorites';
|
||||
|
||||
@@ -20,6 +21,7 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
|
||||
|
||||
const isOverview = location.pathname === '/';
|
||||
const isFavoritesPage = location.pathname === '/favorites';
|
||||
const isRiversPage = location.pathname === '/rivers';
|
||||
const isMap = location.pathname === '/map';
|
||||
const isRadar = location.pathname === '/radar';
|
||||
|
||||
@@ -32,14 +34,14 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
|
||||
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||
<div className="sidebar-logo" style={{ alignItems: 'center', gap: '0.4rem' }}>
|
||||
<FiDroplet size={34} color="var(--color-cyan)" style={{ marginLeft: '-4px', flexShrink: 0 }} />
|
||||
<div className="sidebar-text" style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<div className="sidebar-text" style={{ position: 'relative', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 'bold', letterSpacing: '0.5px', fontSize: '1.15rem', lineHeight: 1 }}>HLADINATOR</span>
|
||||
<small style={{ position: 'absolute', top: '100%', left: '2px', marginTop: '6px', lineHeight: 1, fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--text-muted)' }}>v1.0</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Button */}
|
||||
<div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '0.5rem', marginTop: isCollapsed ? '0.5rem' : '-1.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', marginBottom: '0.5rem', marginTop: '-1.5rem' }}>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
style={{
|
||||
@@ -88,6 +90,12 @@ const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu
|
||||
<span className="sidebar-text">{dict.lakes}</span>
|
||||
</div>
|
||||
|
||||
{/* Rivers & Streams */}
|
||||
<div className={`nav-item ${isRiversPage ? 'active' : ''}`} onClick={() => handleNavigate('/rivers')}>
|
||||
<TbRipple size={18} />
|
||||
<span className="sidebar-text">{dict.rivers}</span>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
|
||||
<FiMap />
|
||||
|
||||
Reference in New Issue
Block a user