Files
SPipe/src/TimeSeriesChart.tsx
T
bjkim1 21ebb16fb7 SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2026-10-30 11:00:46 +09:00

224 lines
6.2 KiB
TypeScript

import React from 'react';
import { timeWeightedAverage } from './resultSync';
interface TimeSeriesChartProps {
title: string;
unit: string;
values: number[];
timeSteps: number[]; // in seconds
currentIndex: number;
onSelectTimeIndex?: (index: number) => void;
color?: string;
}
export const TimeSeriesChart: React.FC<TimeSeriesChartProps> = ({
title,
unit,
values,
timeSteps,
currentIndex,
onSelectTimeIndex,
color = 'var(--accent)',
}) => {
if (!values || values.length === 0) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const avg = timeWeightedAverage(values, timeSteps);
const width = 320;
const height = 90;
const padLeft = 32;
const padRight = 12;
const padTop = 10;
const padBottom = 18;
const plotWidth = width - padLeft - padRight;
const plotHeight = height - padTop - padBottom;
const points = values.map((v, i) => {
const x = padLeft + (i / (values.length - 1 || 1)) * plotWidth;
const y = padTop + plotHeight - ((v - min) / range) * plotHeight;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const pathD = `M ${points.join(' L ')}`;
// Current time marker X
const currentX =
padLeft + (currentIndex / (values.length - 1 || 1)) * plotWidth;
const currentY =
padTop +
plotHeight -
(((values[currentIndex] ?? min) - min) / range) * plotHeight;
// Format time (seconds -> HH:00)
const formatTime = (secs: number) => {
const hours = Math.floor(secs / 3600);
return `${String(hours).padStart(2, '0')}:00`;
};
return (
<div
style={{
background: 'var(--bg-panel)',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '8px',
marginTop: '10px',
boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
marginBottom: '4px',
}}
>
<span style={{ fontWeight: 600, fontSize: '12px', color: 'var(--text)' }}>
24h 시계열: {title}
</span>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>
현재:{' '}
<strong style={{ color }}>
{values[currentIndex]?.toFixed(2)} {unit}
</strong>
</span>
</div>
<div style={{ position: 'relative', width: '100%', height: `${height}px` }}>
<svg
viewBox={`0 0 ${width} ${height}`}
style={{ width: '100%', height: '100%', overflow: 'visible' }}
onClick={(e) => {
if (!onSelectTimeIndex) return;
const rect = e.currentTarget.getBoundingClientRect();
const clickRatio = (e.clientX - rect.left - (padLeft / width) * rect.width) / ((plotWidth / width) * rect.width);
const clamped = Math.max(0, Math.min(1, clickRatio));
const targetIdx = Math.round(clamped * (values.length - 1));
onSelectTimeIndex(targetIdx);
}}
>
{/* Background grid lines */}
<line
x1={padLeft}
y1={padTop}
x2={padLeft + plotWidth}
y2={padTop}
stroke="#f1f5f9"
strokeWidth="1"
/>
<line
x1={padLeft}
y1={padTop + plotHeight / 2}
x2={padLeft + plotWidth}
y2={padTop + plotHeight / 2}
stroke="#f1f5f9"
strokeWidth="1"
/>
<line
x1={padLeft}
y1={padTop + plotHeight}
x2={padLeft + plotWidth}
y2={padTop + plotHeight}
stroke="#e2e8f0"
strokeWidth="1"
/>
{/* Min / Max Labels */}
<text
x={padLeft - 4}
y={padTop + 4}
textAnchor="end"
fontSize="8"
fill="#94a3b8"
>
{max.toFixed(1)}
</text>
<text
x={padLeft - 4}
y={padTop + plotHeight}
textAnchor="end"
fontSize="8"
fill="#94a3b8"
>
{min.toFixed(1)}
</text>
{/* Trend line */}
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Current time marker line */}
<line
x1={currentX}
y1={padTop}
x2={currentX}
y2={padTop + plotHeight}
stroke="#ef4444"
strokeWidth="1.5"
strokeDasharray="2,2"
/>
{/* Current value circle */}
<circle cx={currentX} cy={currentY} r="3.5" fill="#ef4444" />
{/* Time axis labels */}
<text
x={padLeft}
y={height - 2}
textAnchor="start"
fontSize="8"
fill="#94a3b8"
>
{formatTime(timeSteps[0] ?? 0)}
</text>
<text
x={padLeft + plotWidth / 2}
y={height - 2}
textAnchor="middle"
fontSize="8"
fill="#94a3b8"
>
{formatTime(timeSteps[Math.floor(timeSteps.length / 2)] ?? 43200)}
</text>
<text
x={padLeft + plotWidth}
y={height - 2}
textAnchor="end"
fontSize="8"
fill="#94a3b8"
>
{formatTime(timeSteps[timeSteps.length - 1] ?? 86400)}
</text>
</svg>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '10px',
color: 'var(--text-secondary)',
marginTop: '4px',
borderTop: '1px solid #f8fafc',
paddingTop: '3px',
}}
>
<span>최소: {min.toFixed(2)}</span>
<span>평균: {avg.toFixed(2)}</span>
<span>최대: {max.toFixed(2)}</span>
</div>
</div>
);
};