chore: update lake datasets, add new monitoring locations, and introduce docker-compose infrastructure

This commit is contained in:
David Fencl
2026-06-13 13:09:26 +02:00
parent c8fe97078d
commit 62d69fbb1e
77 changed files with 365882 additions and 916 deletions
+232 -23
View File
@@ -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>
)}