- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
224 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
};
|