refactor: remove coverage report and add weather widget and navigation utility files
This commit is contained in:
@@ -2,6 +2,9 @@ import { useState, useEffect } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine, Bar } from 'recharts';
|
||||
import { type Language, t } from '../translations';
|
||||
import KpiCards from './KpiCards';
|
||||
import { WeatherWidget } from './WeatherWidget';
|
||||
import { NAVIGATION_LIMITS } from '../utils/navigationLimits';
|
||||
import { FiAlertCircle } from 'react-icons/fi';
|
||||
|
||||
interface LipnoData {
|
||||
timestamp: string;
|
||||
@@ -211,6 +214,8 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
storageDiff: lakeInfo?.storageDiff
|
||||
};
|
||||
|
||||
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginTop: '-0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
@@ -220,6 +225,31 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
|
||||
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
|
||||
|
||||
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
|
||||
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} sensorTemp={latestData.temperature} />
|
||||
)}
|
||||
|
||||
{limits && limits.map((limit, idx) => {
|
||||
const diff = latestData.level - limit.level;
|
||||
if (diff < 1.0) {
|
||||
const isBelow = diff < 0;
|
||||
return (
|
||||
<div key={idx} style={{ padding: '1rem', borderRadius: '8px', backgroundColor: isBelow ? 'rgba(248, 113, 113, 0.1)' : 'rgba(245, 158, 11, 0.1)', border: `1px solid ${isBelow ? 'var(--color-red)' : '#f59e0b'}`, color: isBelow ? 'var(--color-red)' : '#f59e0b', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<FiAlertCircle style={{ flexShrink: 0, fontSize: '1.5rem' }} />
|
||||
<div>
|
||||
<strong>{language === 'cs' ? limit.labelCs : limit.labelEn} ({limit.level.toFixed(2)} m n.m.):</strong>
|
||||
<br/>
|
||||
{isBelow
|
||||
? (language === 'cs' ? `Hladina je ${Math.abs(diff).toFixed(2)} m POD limitem! Přerušení provozu.` : `Level is ${Math.abs(diff).toFixed(2)} m BELOW limit! Operations suspended.`)
|
||||
: (language === 'cs' ? `Hladina se blíží k limitu (zbývá ${diff.toFixed(2)} m).` : `Level is approaching limit (${diff.toFixed(2)} m remaining).`)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* CHART SECTION */}
|
||||
<div className="chart-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
@@ -250,6 +280,9 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
<Tooltip content={<CustomTooltip language={language} />} />
|
||||
|
||||
{/* 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: 'insideBottomRight', value: language === 'cs' ? limit.labelCs : limit.labelEn, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', 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-orange)" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
||||
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="#8b5cf6" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
||||
|
||||
@@ -230,7 +230,7 @@ const LakesOverview = ({ language }: Props) => {
|
||||
|
||||
{priorityLakes.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Priority Reservoirs</h2>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>{language === 'cs' ? 'Jezera a nádrže' : 'Lakes and Reservoirs'}</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
@@ -240,19 +240,6 @@ const LakesOverview = ({ language }: Props) => {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Other Reservoirs ({otherLakes.length})</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{otherLakes.map(lake => (
|
||||
<SmallLakeCard key={lake.id} lake={lake} isFav={isFavorite(lake.id)} onToggleFav={toggleFavorite} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FiWind, FiSunrise, FiSunset, FiThermometer, FiAlertCircle } from 'react-icons/fi';
|
||||
|
||||
interface WeatherProps {
|
||||
lat: number;
|
||||
lng: number;
|
||||
language: 'cs' | 'en';
|
||||
sensorTemp?: number;
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
temp: number;
|
||||
windSpeed: number; // m/s
|
||||
windGusts: number; // m/s
|
||||
windDir: number; // degrees
|
||||
sunrise: string;
|
||||
sunset: 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 '--:--';
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
export const WeatherWidget = ({ lat, lng, language, sensorTemp }: WeatherProps) => {
|
||||
const [data, setData] = useState<WeatherData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lat || !lng) {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m&daily=sunrise,sunset&timezone=auto&wind_speed_unit=ms`);
|
||||
if (!res.ok) throw new Error('Failed to fetch weather');
|
||||
|
||||
const json = await res.json();
|
||||
setData({
|
||||
temp: json.current.temperature_2m,
|
||||
windSpeed: json.current.wind_speed_10m,
|
||||
windGusts: json.current.wind_gusts_10m,
|
||||
windDir: json.current.wind_direction_10m,
|
||||
sunrise: json.daily.sunrise[0],
|
||||
sunset: json.daily.sunset[0]
|
||||
});
|
||||
setError(false);
|
||||
} catch (err) {
|
||||
console.error('Weather fetch error:', err);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWeather();
|
||||
|
||||
// Refresh weather every 15 minutes
|
||||
const interval = setInterval(fetchWeather, 15 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [lat, lng]);
|
||||
|
||||
const dict = {
|
||||
cs: { title: 'Počasí a Vítr (Aktuálně)', error: 'Data nedostupná', wind: 'Vítr', gusts: 'Nárazy', temp: 'Teplota' },
|
||||
en: { title: 'Weather & Wind (Current)', error: 'Data unavailable', wind: 'Wind', gusts: 'Gusts', temp: 'Temp' }
|
||||
}[language];
|
||||
|
||||
if (loading) {
|
||||
return <div className="kpi-card" style={{ minHeight: '120px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>Loading weather...</div>;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="kpi-card" style={{ opacity: 0.7 }}>
|
||||
<h3 style={{ fontSize: '1rem', color: 'var(--text-muted)', margin: '0 0 0.5rem 0' }}>{dict.title}</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--color-red)' }}>
|
||||
<FiAlertCircle /> {dict.error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kpi-card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<h3 style={{ fontSize: '1rem', color: 'var(--text-muted)', margin: '0 0 1rem 0' }}>{dict.title}</h3>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
||||
|
||||
{/* Left Column: Wind */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
|
||||
<div style={{
|
||||
width: '40px', height: '40px', borderRadius: '50%', backgroundColor: 'rgba(0, 195, 255, 0.1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--color-cyan)', fontSize: '1.2rem',
|
||||
transform: `rotate(${data.windDir}deg)`
|
||||
}} title={`Wind direction: ${data.windDir}°`}>
|
||||
<FiWind style={{ transform: 'rotate(-90deg)' }} /> {/* Assume icon points UP by default, wind from south (180) should point UP. Arrow should point where wind is GOING. */}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', lineHeight: 1.1, color: 'var(--text-main)' }}>
|
||||
{data.windSpeed.toFixed(1)} <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m/s</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '4px' }}>
|
||||
{getCompassDirection(data.windDir, language)} • {dict.gusts}: <span style={{ color: data.windGusts > 10 ? 'var(--color-red)' : 'var(--text-main)' }}>{data.windGusts.toFixed(1)} m/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Other Info */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', borderLeft: '1px solid var(--border-color)', paddingLeft: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem' }} title={sensorTemp !== undefined ? (language === 'cs' ? 'Měřeno přímo senzorem na hrázi' : 'Measured by sensor at the dam') : 'OpenMeteo API'}>
|
||||
<FiThermometer color="var(--color-orange)" />
|
||||
<span style={{ fontWeight: 'bold' }}>{sensorTemp !== undefined ? sensorTemp.toFixed(1) : data.temp.toFixed(1)} °C</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||
<FiSunrise color="#f59e0b" />
|
||||
<span>{formatTime(data.sunrise)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||
<FiSunset color="#f59e0b" />
|
||||
<span>{formatTime(data.sunset)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user