feat: add circular progress component and update historical lake data indices

This commit is contained in:
David Fencl
2026-06-06 10:38:43 +02:00
parent 27551f9183
commit a3b3d40769
15 changed files with 2896 additions and 243 deletions
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
interface Props {
value: number;
size?: number;
strokeWidth?: number;
}
export const CircularProgress: React.FC<Props> = ({ value, size = 60, strokeWidth = 6 }) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (value / 100) * circumference;
return (
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size}>
<circle
stroke="rgba(255,255,255,0.1)"
fill="transparent"
strokeWidth={strokeWidth}
r={radius}
cx={size / 2}
cy={size / 2}
/>
<circle
stroke="var(--color-cyan)"
fill="transparent"
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ strokeDasharray: circumference, strokeDashoffset: offset, transition: 'stroke-dashoffset 0.5s ease-in-out' }}
r={radius}
cx={size / 2}
cy={size / 2}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
{value > 0 ? `${value.toFixed(1)}%` : 'N/A'}
</div>
</div>
);
};
+3 -29
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { FiStar } from 'react-icons/fi';
import { type Language } from '../translations';
import { useFavorites } from '../hooks/useFavorites';
import { CircularProgress } from './CircularProgress';
import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
@@ -25,34 +26,13 @@ interface Props {
language: Language;
}
const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number, size?: number, strokeWidth?: number }) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (value / 100) * circumference;
return (
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size}>
<circle stroke="rgba(255,255,255,0.1)" fill="transparent" strokeWidth={strokeWidth} r={radius} cx={size / 2} cy={size / 2} />
<circle
stroke="var(--color-cyan)" fill="transparent" strokeWidth={strokeWidth} strokeLinecap="round"
style={{ strokeDasharray: circumference, strokeDashoffset: offset, transition: 'stroke-dashoffset 0.5s ease-in-out' }}
r={radius} cx={size / 2} cy={size / 2} transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
{value > 0 ? `${value}%` : 'N/A'}
</div>
</div>
);
};
const FavoritesOverview = ({ language }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const { isFavorite, toggleFavorite } = useFavorites();
const navigate = useNavigate();
useEffect(() => {
fetch('/data/lakes_index.json')
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error(err));
@@ -131,17 +111,11 @@ const FavoritesOverview = ({ language }: Props) => {
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
<div>
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
<span style={{ color: lake.capacity >= 80 ? 'var(--color-green)' : lake.capacity < 40 ? 'var(--color-red)' : 'var(--text-main)' }}>
{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'}
</span>
</div>
{lake.storageDiff !== undefined && (
<div style={{ fontSize: '0.8rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 500 }}>
<div style={{ fontSize: '1.25rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold' }}>
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
</div>
)}
</div>
</div>
</div>
+12 -8
View File
@@ -1,6 +1,7 @@
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { useState, useEffect } from 'react';
import { CircularProgress } from './CircularProgress';
interface KpiData {
level: number;
@@ -55,9 +56,6 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
({(data.levelDiff30d ?? 0) > 0 ? '+' : ''}{((data.levelDiff30d ?? 0) * 100).toFixed(1)} cm / 30d)
</div>
</div>
{/* Decorative Circle for Level */}
<div style={{ position: 'absolute', right: '1.5rem', top: '1.5rem', width: '40px', height: '40px', borderRadius: '50%', border: '4px solid rgba(0, 195, 255, 0.2)', borderTopColor: 'var(--color-cyan)', transform: 'rotate(45deg)' }}></div>
</div>
{/* CARD 2: PRŮTOK */}
@@ -135,11 +133,17 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
</div>
)}
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)' }}>
{data.storageDiff !== undefined && data.storageDiff !== 0 ? (data.storageDiff > 0 ? `+${data.storageDiff.toFixed(2)} m` : `${data.storageDiff.toFixed(2)} m`) : (data.fullness > 0 ? `${data.fullness.toFixed(1)}%` : 'N/A')}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{dict.volume}: {data.volume.toFixed(1)} mil. m³
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem', marginTop: '0.5rem' }}>
<CircularProgress value={data.fullness} size={80} strokeWidth={8} />
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem', color: data.storageDiff && data.storageDiff < 0 ? 'var(--color-red)' : 'var(--color-cyan)' }}>
{data.storageDiff !== undefined && data.storageDiff !== 0 ? (data.storageDiff > 0 ? `+${data.storageDiff.toFixed(2)} m` : `${data.storageDiff.toFixed(2)} m`) : (data.fullness > 0 ? `${data.fullness.toFixed(1)}%` : 'N/A')}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{dict.volume}: {data.volume.toFixed(1)} mil. m³
</div>
</div>
</div>
</div>
</div>
+13 -44
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { FiTrendingUp, FiTrendingDown, FiStar } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { useFavorites } from '../hooks/useFavorites';
import { CircularProgress } from './CircularProgress';
interface Lake {
id: string;
@@ -24,44 +25,16 @@ interface Props {
language: Language;
}
const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number, size?: number, strokeWidth?: number }) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (value / 100) * circumference;
return (
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size}>
<circle
stroke="rgba(255,255,255,0.1)"
fill="transparent"
strokeWidth={strokeWidth}
r={radius}
cx={size / 2}
cy={size / 2}
/>
<circle
stroke="var(--color-cyan)"
fill="transparent"
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ strokeDasharray: circumference, strokeDashoffset: offset, transition: 'stroke-dashoffset 0.5s ease-in-out' }}
r={radius}
cx={size / 2}
cy={size / 2}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
{value > 0 ? `${value}%` : 'N/A'}
</div>
</div>
);
};
const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
const navigate = useNavigate();
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
const minVal = Math.min(...lake.sparkline);
const maxVal = Math.max(...lake.sparkline);
const diff = maxVal - minVal;
// Enforce a minimum visual span of 0.5 meters so tiny fluctuations don't look like mountains
const padding = diff < 0.5 ? (0.5 - diff) / 2 : 0;
const yDomain = [minVal - padding, maxVal + padding];
return (
<div
@@ -106,13 +79,8 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
<div>
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
<span style={{ color: lake.capacity >= 80 ? 'var(--color-green)' : lake.capacity < 40 ? 'var(--color-red)' : 'var(--text-main)' }}>
{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'}
</span>
</div>
{lake.storageDiff !== undefined && (
<div style={{ fontSize: '0.8rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 500 }}>
<div style={{ fontSize: '1.25rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 'bold' }}>
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
</div>
)}
@@ -130,7 +98,8 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0} />
</linearGradient>
</defs>
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" />
<YAxis domain={yDomain} hide />
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" baseValue={yDomain[0]} />
</AreaChart>
</ResponsiveContainer>
</div>
@@ -201,7 +170,7 @@ const LakesOverview = ({ language }: Props) => {
const { isFavorite, toggleFavorite, favorites } = useFavorites();
useEffect(() => {
fetch('/data/lakes_index.json')
fetch(`/data/lakes_index.json?t=${Date.now()}`)
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error(err));