chore: update lake datasets, add new monitoring locations, and introduce docker-compose infrastructure
This commit is contained in:
@@ -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')}>
|
||||
|
||||
+111
-14
@@ -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,13 +294,101 @@ 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;
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
)}
|
||||
{/* 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'}
|
||||
{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