feat: update historical lake sensor data and improve wind chart component rendering
This commit is contained in:
+270
-46
@@ -36,7 +36,7 @@ const CustomTooltip = ({ active, payload, label, language, isWeather, isRiver }:
|
||||
const dict = t[language as Language].chart;
|
||||
if (isWeather) {
|
||||
return (
|
||||
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
||||
<div className="chart-tooltip" style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
||||
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
|
||||
{payload.map((entry: any, index: number) => {
|
||||
const isTemp = entry.name === 'temperature' || entry.dataKey === 'temperature';
|
||||
@@ -50,7 +50,7 @@ const CustomTooltip = ({ active, payload, label, language, isWeather, isRiver }:
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
||||
<div className="chart-tooltip" style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
|
||||
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
|
||||
{[...payload].sort((a: any, b: any) => {
|
||||
const order = ['level', 'inflow', 'outflow', 'temperature', 'precipitation'];
|
||||
@@ -98,11 +98,11 @@ const CustomTooltip = ({ active, payload, label, language, isWeather, isRiver }:
|
||||
})}
|
||||
{payload[0]?.payload?.qn ? (
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid var(--border-color)', fontSize: '0.8rem', color: '#f59e0b', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
⚠️ {language === 'cs' ? `Neověřené měření (QN: ${payload[0].payload.qn})` : `Unverified measurement (QN: ${payload[0].payload.qn})`}
|
||||
⚠️ {language === 'cs' ? 'Neověřené měření' : 'Unverified measurement'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid var(--border-color)', fontSize: '0.8rem', color: 'var(--color-green)', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
✓ {language === 'cs' ? 'Měření ověřeno dispečinkem' : 'Measurement verified'}
|
||||
✓ {language === 'cs' ? 'Měření ověřeno' : 'Measurement verified'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -117,6 +117,31 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
const [lakeInfo, setLakeInfo] = useState<any>(null);
|
||||
const [isSmoothed, setIsSmoothed] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d' | '1y' | 'all'>('7d');
|
||||
const [visibleSeries, setVisibleSeries] = useState({
|
||||
level: true,
|
||||
outflow: true,
|
||||
inflow: true
|
||||
});
|
||||
const [visibleWeatherSeries, setVisibleWeatherSeries] = useState({
|
||||
temp: true,
|
||||
precip: true
|
||||
});
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [leftCustomDomain, setLeftCustomDomain] = useState<[number, number] | null>(null);
|
||||
const [rightCustomDomain, setRightCustomDomain] = useState<[number, number] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth <= 768);
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLeftCustomDomain(null);
|
||||
setRightCustomDomain(null);
|
||||
}, [timeRange, lakeId]);
|
||||
|
||||
const dict = t[language].chart;
|
||||
const topbarDict = t[language].topbar;
|
||||
|
||||
@@ -303,6 +328,124 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
avgOutflow24h
|
||||
};
|
||||
|
||||
const getDefaultLeftDomain = (): [number, number] => {
|
||||
const levels = chartData.map(d => d.level).filter(v => v !== null && v !== undefined && !isNaN(v));
|
||||
if (levels.length === 0) return [0, 100];
|
||||
const dataMin = Math.min(...levels);
|
||||
const dataMax = Math.max(...levels);
|
||||
|
||||
if (isRiver) {
|
||||
return [Math.max(0, Math.floor(dataMin - 10)), Math.ceil(dataMax + 10)];
|
||||
} else {
|
||||
let min = dataMin;
|
||||
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
|
||||
let max = dataMax;
|
||||
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
|
||||
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
|
||||
return [min - 0.5, max + 0.5];
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultRightDomain = (): [number, number] => {
|
||||
const flows = chartData.flatMap(d => [d.outflow, d.inflow]).filter(v => v !== null && v !== undefined && !isNaN(v));
|
||||
if (flows.length === 0) return [0, 10];
|
||||
const dataMax = Math.max(...flows);
|
||||
return [0, Math.max(dataMax, 1)];
|
||||
};
|
||||
|
||||
const handleAxisDragStart = (
|
||||
e: React.MouseEvent | React.TouchEvent,
|
||||
axis: 'left' | 'right'
|
||||
) => {
|
||||
e.preventDefault();
|
||||
const isTouchEvent = 'touches' in e;
|
||||
|
||||
if (isTouchEvent && e.touches.length === 2) {
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
const dist = Math.abs(touch1.clientY - touch2.clientY);
|
||||
|
||||
const currentDomain = axis === 'left'
|
||||
? (leftCustomDomain || getDefaultLeftDomain())
|
||||
: (rightCustomDomain || getDefaultRightDomain());
|
||||
|
||||
const onTouchMove = (moveEvent: TouchEvent) => {
|
||||
if (moveEvent.touches.length === 2) {
|
||||
moveEvent.preventDefault(); // Stop native page zooming
|
||||
const mTouch1 = moveEvent.touches[0];
|
||||
const mTouch2 = moveEvent.touches[1];
|
||||
const currentDist = Math.abs(mTouch1.clientY - mTouch2.clientY);
|
||||
if (currentDist > 5) {
|
||||
const factor = dist / currentDist;
|
||||
const center = (currentDomain[0] + currentDomain[1]) / 2;
|
||||
const range = currentDomain[1] - currentDomain[0];
|
||||
const newMin = center - (range * factor) / 2;
|
||||
const newMax = center + (range * factor) / 2;
|
||||
|
||||
if (axis === 'left') {
|
||||
setLeftCustomDomain([newMin, newMax]);
|
||||
} else {
|
||||
setRightCustomDomain([Math.max(0, newMin), newMax]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
window.addEventListener('touchend', onTouchEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
const startY = isTouchEvent ? e.touches[0].clientY : e.clientY;
|
||||
const currentDomain = axis === 'left'
|
||||
? (leftCustomDomain || getDefaultLeftDomain())
|
||||
: (rightCustomDomain || getDefaultRightDomain());
|
||||
|
||||
const initialRange = currentDomain[1] - currentDomain[0];
|
||||
const center = (currentDomain[1] + currentDomain[0]) / 2;
|
||||
|
||||
const onMove = (moveEvent: MouseEvent | TouchEvent) => {
|
||||
if ('touches' in moveEvent) {
|
||||
moveEvent.preventDefault(); // Stop native page scrolling
|
||||
}
|
||||
const clientY = 'touches' in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY;
|
||||
const deltaY = clientY - startY;
|
||||
|
||||
const factor = Math.pow(2.5, deltaY / 150);
|
||||
const newMin = center - (initialRange * factor) / 2;
|
||||
const newMax = center + (initialRange * factor) / 2;
|
||||
|
||||
if (axis === 'left') {
|
||||
setLeftCustomDomain([newMin, newMax]);
|
||||
} else {
|
||||
setRightCustomDomain([Math.max(0, newMin), newMax]);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (isTouchEvent) {
|
||||
window.removeEventListener('touchmove', onMove);
|
||||
window.removeEventListener('touchend', onEnd);
|
||||
} else {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onEnd);
|
||||
}
|
||||
};
|
||||
|
||||
if (isTouchEvent) {
|
||||
window.addEventListener('touchmove', onMove, { passive: false });
|
||||
window.addEventListener('touchend', onEnd);
|
||||
} else {
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onEnd);
|
||||
}
|
||||
};
|
||||
|
||||
const leftYAxisDomain = isRiver
|
||||
? [
|
||||
(dataMin: number) => Math.max(0, Math.floor(dataMin - 10)),
|
||||
@@ -361,36 +504,45 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginTop: '-0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<div style={{
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: isMobile ? '0.75rem' : '0.9rem',
|
||||
marginTop: isMobile ? '-1.1rem' : '-0.5rem',
|
||||
marginBottom: isMobile ? '-0.5rem' : '0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: isMobile ? '0.4rem' : '0.75rem',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<span>{topbarDict.updated} {new Date().toLocaleDateString(language === 'cs' ? 'cs-CZ' : 'en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}, {new Date().toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' })} UTC</span>
|
||||
<div className="status-dot"></div>
|
||||
{latestData.qn ? (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: isMobile ? '0.65rem' : '0.75rem',
|
||||
padding: isMobile ? '0.1rem 0.3rem' : '0.15rem 0.4rem',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#f59e0b',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem'
|
||||
gap: '0.2rem'
|
||||
}}>
|
||||
⚠️ {language === 'cs' ? `Neověřená data (QN: ${latestData.qn})` : `Unverified data (QN: ${latestData.qn})`}
|
||||
⚠️ {language === 'cs' ? 'Neověřená data' : 'Unverified data'}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: isMobile ? '0.65rem' : '0.75rem',
|
||||
padding: isMobile ? '0.1rem 0.3rem' : '0.15rem 0.4rem',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
color: 'var(--color-green)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.2)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem'
|
||||
gap: '0.2rem'
|
||||
}}>
|
||||
✓ {language === 'cs' ? 'Ověřená data dispečinkem' : 'Data verified by dispatch'}
|
||||
✓ {language === 'cs' ? 'Měření ověřeno' : 'Data verified'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -426,38 +578,56 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
|
||||
{/* CHART SECTION */}
|
||||
<div className="chart-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem', flexWrap: 'wrap', gap: '1rem', padding: isMobile ? '0 0.75rem' : '0' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{dict.title} {lakeInfo ? `${lakeInfo.name} ${lakeInfo.river ? `- ${lakeInfo.river}` : ''}` : 'Lipno 1 - Vltava'}</h3>
|
||||
<div className="top-time-controls" style={{ margin: 0 }}>
|
||||
<div className="top-time-controls" style={{ margin: 0, display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<button className={timeRange === '24h' ? 'active' : ''} onClick={() => setTimeRange('24h')}>24h</button>
|
||||
<button className={timeRange === '7d' ? 'active' : ''} onClick={() => setTimeRange('7d')}>7d</button>
|
||||
<button className={timeRange === '30d' ? 'active' : ''} onClick={() => setTimeRange('30d')}>30d</button>
|
||||
<button className={timeRange === '1y' ? 'active' : ''} onClick={() => setTimeRange('1y')}>{dict.year}</button>
|
||||
<button className={timeRange === 'all' ? 'active' : ''} onClick={() => setTimeRange('all')}>{dict.all}</button>
|
||||
{(leftCustomDomain !== null || rightCustomDomain !== null) && (
|
||||
<button
|
||||
onClick={() => { setLeftCustomDomain(null); setRightCustomDomain(null); }}
|
||||
style={{
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
||||
color: '#ef4444',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.4rem 0.75rem',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '1rem' }}>
|
||||
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '0', position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
|
||||
<ComposedChart data={chartData} margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorLevel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
|
||||
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
||||
<YAxis yAxisId="left" domain={leftYAxisDomain as any} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(isRiver ? 0 : 2)} />
|
||||
<YAxis yAxisId="right" orientation="right" domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
||||
<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 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} />
|
||||
<Tooltip content={<CustomTooltip language={language} isRiver={isRiver} />} />
|
||||
|
||||
{/* 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: 'insideBottomLeft', value: language === 'cs' ? `${limit.labelCs} (${limit.level.toFixed(2)} m n. m.)` : `${limit.labelEn} (${limit.level.toFixed(2)} m a.s.l.)`, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', fontSize: 11 }} />
|
||||
{visibleSeries.level && 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: 'insideBottomLeft', value: language === 'cs' ? `${limit.labelCs} (${limit.level.toFixed(2)} m n. m.)` : `${limit.labelEn} (${limit.level.toFixed(2)} m a.s.l.)`, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', fontSize: 11, className: 'chart-ref-label' }} />
|
||||
))}
|
||||
{!isRiver && staticConfig?.maxLevel && staticConfig?.storageLevel && Math.abs(staticConfig.maxLevel - staticConfig.storageLevel) < 0.05 ? (
|
||||
{visibleSeries.level && !isRiver && staticConfig?.maxLevel && staticConfig?.storageLevel && Math.abs(staticConfig.maxLevel - staticConfig.storageLevel) < 0.05 ? (
|
||||
<ReferenceLine
|
||||
yAxisId="left"
|
||||
y={staticConfig.maxLevel}
|
||||
@@ -469,12 +639,13 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
? `Max. retenční / zásobní hladina (${staticConfig.maxLevel.toFixed(2)} m n. m.)`
|
||||
: `Max retention / storage level (${staticConfig.maxLevel.toFixed(2)} m a.s.l.)`,
|
||||
fill: 'var(--color-orange)',
|
||||
fontSize: 11
|
||||
fontSize: 11,
|
||||
className: 'chart-ref-label'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!isRiver && staticConfig?.maxLevel && (
|
||||
{visibleSeries.level && !isRiver && staticConfig?.maxLevel && (
|
||||
<ReferenceLine
|
||||
yAxisId="left"
|
||||
y={staticConfig.maxLevel}
|
||||
@@ -486,11 +657,12 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
? `Maximální retenční hladina (${staticConfig.maxLevel.toFixed(2)} m n. m.)`
|
||||
: `Max retention level (${staticConfig.maxLevel.toFixed(2)} m a.s.l.)`,
|
||||
fill: 'var(--color-orange)',
|
||||
fontSize: 11
|
||||
fontSize: 11,
|
||||
className: 'chart-ref-label'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isRiver && staticConfig?.storageLevel && (
|
||||
{visibleSeries.level && !isRiver && staticConfig?.storageLevel && (
|
||||
<ReferenceLine
|
||||
yAxisId="left"
|
||||
y={staticConfig.storageLevel}
|
||||
@@ -502,31 +674,71 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
? `Hladina zásobního prostoru (${staticConfig.storageLevel.toFixed(2)} m n. m.)`
|
||||
: `Storage space level (${staticConfig.storageLevel.toFixed(2)} m a.s.l.)`,
|
||||
fill: '#a855f7',
|
||||
fontSize: 11
|
||||
fontSize: 11,
|
||||
className: 'chart-ref-label'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<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-red)" strokeWidth={2} dot={false} isAnimationActive={animate} />
|
||||
{!isRiver && <Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} />}
|
||||
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} hide={!visibleSeries.level} />
|
||||
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-red)" strokeWidth={2} dot={false} isAnimationActive={animate} hide={!visibleSeries.outflow} />
|
||||
{!isRiver && <Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} isAnimationActive={animate} hide={!visibleSeries.inflow} />}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div
|
||||
onMouseDown={(e) => handleAxisDragStart(e, 'left')}
|
||||
onTouchStart={(e) => handleAxisDragStart(e, 'left')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: isMobile ? 25 : 35,
|
||||
width: isMobile ? 42 : 60,
|
||||
cursor: 'ns-resize',
|
||||
zIndex: 10,
|
||||
background: 'transparent'
|
||||
}}
|
||||
title={language === 'cs' ? 'Táhněte pro změnu měřítka osy hladiny' : 'Drag to scale level axis'}
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(e) => handleAxisDragStart(e, 'right')}
|
||||
onTouchStart={(e) => handleAxisDragStart(e, 'right')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: isMobile ? 25 : 35,
|
||||
width: isMobile ? 35 : 60,
|
||||
cursor: 'ns-resize',
|
||||
zIndex: 10,
|
||||
background: 'transparent'
|
||||
}}
|
||||
title={language === 'cs' ? 'Táhněte pro změnu měřítka osy průtoku' : 'Drag to scale flow axis'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart Legend */}
|
||||
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1.5rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)', padding: isMobile ? '0 0.75rem' : '0' }}>
|
||||
<span
|
||||
onClick={() => setVisibleSeries(prev => ({ ...prev, level: !prev.level }))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleSeries.level ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
|
||||
>
|
||||
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div>
|
||||
{isRiver ? (language === 'cs' ? 'Vodní stav' : 'Water level') : dict.level}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span
|
||||
onClick={() => setVisibleSeries(prev => ({ ...prev, outflow: !prev.outflow }))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleSeries.outflow ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
|
||||
>
|
||||
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div>
|
||||
{isRiver ? (language === 'cs' ? 'Průtok' : 'Flow') : dict.outflow}
|
||||
</span>
|
||||
{!isRiver && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span
|
||||
onClick={() => setVisibleSeries(prev => ({ ...prev, inflow: !prev.inflow }))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleSeries.inflow ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
|
||||
>
|
||||
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div>
|
||||
{dict.inflow}
|
||||
</span>
|
||||
@@ -534,29 +746,41 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
|
||||
</div>
|
||||
|
||||
{/* WEATHER CHART SECTION */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', marginTop: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem', marginTop: '1.5rem', flexWrap: 'wrap', gap: '1rem', padding: isMobile ? '0 0.75rem' : '0' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{language === 'cs' ? 'Počasí (Teplota a Srážky)' : 'Weather (Temperature & Precipitation)'}</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: '200px', width: '100%', marginTop: '0.5rem' }}>
|
||||
<div style={{ flex: 1, minHeight: '200px', width: '100%', marginTop: '0' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 0, left: 10, bottom: 0 }}>
|
||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
||||
<YAxis yAxisId="temp" domain={['auto', 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(1)} />
|
||||
<YAxis yAxisId="precip" orientation="right" domain={[0, 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
||||
<ComposedChart data={chartData} margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: 10, bottom: 0 }}>
|
||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 12}} minTickGap={50} />
|
||||
<YAxis yAxisId="temp" domain={['auto', 'auto']} stroke={visibleWeatherSeries.temp ? "var(--text-muted)" : "transparent"} tick={{fill: visibleWeatherSeries.temp ? 'var(--text-muted)' : 'transparent', fontSize: isMobile ? 10 : 12}} tickLine={visibleWeatherSeries.temp ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} axisLine={visibleWeatherSeries.temp ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} width={isMobile ? 42 : 60} tickFormatter={(v) => v.toFixed(1)} />
|
||||
<YAxis yAxisId="precip" orientation="right" domain={[0, 'auto']} stroke={visibleWeatherSeries.precip ? "var(--text-muted)" : "transparent"} tick={{fill: visibleWeatherSeries.precip ? 'var(--text-muted)' : 'transparent', fontSize: isMobile ? 10 : 12}} tickLine={visibleWeatherSeries.precip ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} axisLine={visibleWeatherSeries.precip ? { stroke: 'var(--text-muted)' } : { stroke: 'transparent' }} width={isMobile ? 35 : 60} />
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<Tooltip content={<CustomTooltip language={language} isWeather={true} />} />
|
||||
|
||||
<Bar yAxisId="precip" dataKey="precipitation" fill="var(--color-cyan)" fillOpacity={0.6} isAnimationActive={animate} />
|
||||
<Line yAxisId="temp" type={curveType} dataKey="temperature" stroke="var(--color-red)" strokeWidth={2} dot={true} isAnimationActive={animate} />
|
||||
<Bar yAxisId="precip" dataKey="precipitation" fill="var(--color-cyan)" fillOpacity={0.6} isAnimationActive={animate} hide={!visibleWeatherSeries.precip} />
|
||||
<Line yAxisId="temp" type={curveType} dataKey="temperature" stroke="var(--color-red)" strokeWidth={2} dot={true} isAnimationActive={animate} hide={!visibleWeatherSeries.temp} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> {language === 'cs' ? 'Teplota vzduchu' : 'Temperature'} [°C]</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '12px', backgroundColor: 'var(--color-cyan)', opacity: 0.6 }}></div> {language === 'cs' ? 'Srážky' : 'Precipitation'} [mm]</span>
|
||||
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)', padding: isMobile ? '0 0.75rem' : '0' }}>
|
||||
<span
|
||||
onClick={() => setVisibleWeatherSeries(prev => ({ ...prev, temp: !prev.temp }))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleWeatherSeries.temp ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
|
||||
>
|
||||
<div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div>
|
||||
{language === 'cs' ? 'Teplota vzduchu' : 'Temperature'} [°C]
|
||||
</span>
|
||||
<span
|
||||
onClick={() => setVisibleWeatherSeries(prev => ({ ...prev, precip: !prev.precip }))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: visibleWeatherSeries.precip ? 1 : 0.4, transition: 'opacity 0.2s', userSelect: 'none' }}
|
||||
>
|
||||
<div style={{ width: '12px', height: '12px', backgroundColor: 'var(--color-cyan)', opacity: 0.6 }}></div>
|
||||
{language === 'cs' ? 'Srážky' : 'Precipitation'} [mm]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Wind Chart placed inside the main card below the weather graph */}
|
||||
|
||||
@@ -81,6 +81,14 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentSpeed, setCurrentSpeed] = useState(0);
|
||||
const [maxGust, setMaxGust] = useState(0);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth <= 768);
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWind = async () => {
|
||||
@@ -182,15 +190,21 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '3rem', paddingTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ marginTop: '1.5rem', paddingTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem', padding: isMobile ? '0 0.75rem' : '0', marginBottom: '0.25rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<FiWind style={{ color: 'var(--color-cyan)' }} />
|
||||
{language === 'cs' ? `Aktivita větru (${timeRange === '1y' || timeRange === 'all' ? 'denní maxima' : timeRange})` : `Wind Activity (${timeRange === '1y' || timeRange === 'all' ? 'daily max' : timeRange})`}
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: isMobile ? '0' : '1.5rem',
|
||||
justifyContent: isMobile ? 'space-around' : 'flex-end',
|
||||
width: isMobile ? '100%' : 'auto',
|
||||
marginTop: isMobile ? '0.5rem' : '0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{language === 'cs' ? 'Aktuální rychlost' : 'Current Speed'}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.25rem' }}>
|
||||
<span style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--text-main)' }}>{currentSpeed.toFixed(1)}</span>
|
||||
@@ -198,7 +212,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{language === 'cs' ? 'Max. nárazy' : 'Peak Gusts'}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.25rem' }}>
|
||||
<span style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--text-main)' }}>{maxGust.toFixed(1)}</span>
|
||||
@@ -208,9 +222,9 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: '280px', width: '100%', marginTop: '0.5rem' }}>
|
||||
<div style={{ flex: 1, minHeight: '280px', width: '100%', marginTop: '0' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data} margin={{ top: 20, right: 0, left: -20, bottom: 0 }}>
|
||||
<ComposedChart data={data} margin={isMobile ? { top: 5, right: 5, left: 5, bottom: 0 } : { top: 5, right: 0, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorWind" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.4}/>
|
||||
@@ -220,7 +234,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="var(--text-muted)"
|
||||
tick={{fill: 'var(--text-muted)', fontSize: 11}}
|
||||
tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 11}}
|
||||
minTickGap={60}
|
||||
tickFormatter={(v) => {
|
||||
const d = new Date(v);
|
||||
@@ -229,7 +243,9 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--text-muted)"
|
||||
tick={{fill: 'var(--text-muted)', fontSize: 11}}
|
||||
tick={{fill: 'var(--text-muted)', fontSize: isMobile ? 10 : 11}}
|
||||
width={isMobile ? 35 : 60}
|
||||
tickFormatter={(v) => v.toFixed(1)}
|
||||
/>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<Tooltip content={<CustomWindTooltip language={language} windUnit={windUnit} />} />
|
||||
@@ -258,7 +274,7 @@ export const WindChart = ({ lat, lng, language, timeRange = '7d', windUnit = 'km
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1.5rem', fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1.5rem', fontSize: '0.85rem', color: 'var(--text-muted)', marginTop: '0.5rem', padding: isMobile ? '0 0.75rem' : '0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ display: 'inline-block', width: '12px', height: '3px', backgroundColor: 'var(--color-cyan)' }}></span>
|
||||
<span>{language === 'cs' ? 'Rychlost větru' : 'Wind Speed'}</span>
|
||||
|
||||
Reference in New Issue
Block a user