feat: add circular progress component and update historical lake data indices
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user