Files
SPipe/src/CalibrationPanel.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

296 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Upload, FileText, Download } from 'lucide-react';
import type { SimulationResult } from './types';
import {
CALIBRATION_PARAMS,
buildCalibrationReport,
formatCalibrationReport,
parseCalibrationFile,
type CalibrationParam,
type Observation,
} from './calibration';
export interface CalibrationData {
fileName: string;
param: CalibrationParam;
observations: Observation[];
skippedLines: number[];
}
interface CalibrationPanelProps {
result: SimulationResult;
data: CalibrationData | null;
onDataChange: (data: CalibrationData | null) => void;
/** Raw engine value -> display units, per parameter. */
toDisplay: Record<CalibrationParam, (v: number) => number>;
units: Record<CalibrationParam, string>;
onFocusLocation: (type: 'node' | 'link', id: string) => void;
/** 지도에 값 보기: labels for the map (null = hide). */
onAnnotations?: (annotations: import('./types').MapAnnotation[] | null) => void;
}
const W = 320;
const fmt = (v: number) => (Math.abs(v) >= 100 ? v.toFixed(1) : v.toFixed(2));
const hhmm = (sec: number) => `${Math.floor(sec / 3600)}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}`;
function niceRange(values: number[]): [number, number] {
let lo = Math.min(...values);
let hi = Math.max(...values);
if (!Number.isFinite(lo)) return [0, 1];
if (hi === lo) { lo -= 1; hi += 1; }
const pad = (hi - lo) * 0.08;
return [lo - pad, hi + pad];
}
export const CalibrationPanel: React.FC<CalibrationPanelProps> = ({ result, data, onDataChange, toDisplay, units, onFocusLocation, onAnnotations }) => {
const fileRef = useRef<HTMLInputElement>(null);
const [selectedLoc, setSelectedLoc] = useState<string | null>(null);
const [showReport, setShowReport] = useState(false);
const [pickedParam, setPickedParam] = useState<CalibrationParam>(data?.param ?? 'pressure');
const param = data?.param ?? pickedParam;
const paramMeta = CALIBRATION_PARAMS.find((p) => p.id === param)!;
const unit = units[param];
const report = useMemo(
() => (data ? buildCalibrationReport(data.observations, data.param, result, toDisplay[data.param]) : null),
[data, result, toDisplay]
);
const activeLoc = selectedLoc && report?.locations.some((l) => l.location === selectedLoc)
? selectedLoc
: report?.locations[0]?.location ?? null;
const handleFile = async (file: File) => {
const parsed = parseCalibrationFile(await file.text());
onDataChange({ fileName: file.name, param, observations: parsed.observations, skippedLines: parsed.skippedLines });
setSelectedLoc(null);
};
const reportText = report ? formatCalibrationReport(report, paramMeta.label, unit) : '';
const [showOnMap, setShowOnMap] = useState(false);
useEffect(() => {
if (!onAnnotations) return;
if (!showOnMap || !report) {
onAnnotations(null);
return;
}
onAnnotations(
report.locations.map((l) => ({
type: paramMeta.element,
id: l.location,
text: `실측 ${fmt(l.observedMean)} / 계산 ${fmt(l.computedMean)}\n오차 ${fmt(l.computedMean - l.observedMean)} ${unit}`,
color: Math.abs(l.computedMean - l.observedMean) > Math.max(1e-9, 0.1 * Math.abs(l.observedMean)) ? '#b91c1c' : '#0f172a',
}))
);
}, [showOnMap, report, paramMeta.element, unit, onAnnotations]);
useEffect(() => () => onAnnotations?.(null), [onAnnotations]);
const downloadReport = () => {
const blob = new Blob(['' + reportText], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `calibration_${param}.txt`;
a.click();
URL.revokeObjectURL(a.href);
};
// ---- time-series chart for the selected location ----
const series = useMemo(() => {
if (!report || !activeLoc) return null;
const ts = result.timeSteps ?? [];
const raw = paramMeta.element === 'node'
? result.nodes[activeLoc]?.timeSeries?.[param as 'pressure' | 'head' | 'demand' | 'quality']
: result.links[activeLoc]?.timeSeries?.[param as 'flow' | 'velocity'];
const sim = (raw ?? []).map((v) => toDisplay[param](paramMeta.element === 'link' ? Math.abs(v) : v));
const obs = report.points.filter((p) => p.location === activeLoc);
return { ts, sim, obs };
}, [report, activeLoc, result, param, paramMeta.element, toDisplay]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)', fontSize: 'var(--fs-xs)' }}>
<div style={{ display: 'flex', gap: 'var(--s-2)', alignItems: 'center', flexWrap: 'wrap' }}>
<select
value={param}
onChange={(e) => {
const next = e.target.value as CalibrationParam;
setPickedParam(next);
if (data) onDataChange({ ...data, param: next });
}}
style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}
aria-label="보정 항목"
>
{CALIBRATION_PARAMS.map((p) => <option key={p.id} value={p.id}>{p.label} ({units[p.id]})</option>)}
</select>
<button className="btn btn-secondary" onClick={() => fileRef.current?.click()} style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}>
<Upload size={12} /><span>실측 데이터 불러오기</span>
</button>
<input
ref={fileRef}
type="file"
accept=".dat,.csv,.txt"
style={{ display: 'none' }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleFile(f); e.target.value = ''; }}
/>
</div>
{!data ? (
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.6 }}>
실측값 파일(.dat / .csv / .txt)을 불러오면 해석값과 비교합니다. 한 줄에 <b>위치 ID, 시각, 값</b>을 적고,
시각은 시간(6, 6.5) 또는 시:분(6:30)으로 씁니다. 값은 화면에 표시된 단위로 입력합니다.
<pre style={{ margin: '6px 0 0', padding: '6px 8px', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', fontSize: '11px' }}>
{`;위치 시각 값
1265 0 52.3
6 48.1
1270 6:30 44.0`}
</pre>
</div>
) : !report || report.points.length === 0 ? (
<div style={{ color: 'var(--crit)' }}>
{data.fileName}: 비교할 수 있는 관측값이 없습니다.
{report?.unknownLocations.length ? ` 모델에 없는 위치: ${report.unknownLocations.slice(0, 8).join(', ')}` : ''}
{' '}항목({paramMeta.label})이 맞는지 확인하세요.
</div>
) : (
<>
<div style={{ color: 'var(--text-secondary)' }}>
{data.fileName} · 관측값 {report.network.count}개 · 위치 {report.locations.length}곳
{report.unknownLocations.length > 0 && ` · 모델에 없는 위치 ${report.unknownLocations.length}곳 제외`}
{report.outOfRange > 0 && ` · 해석 기간 밖 ${report.outOfRange}개 제외`}
{data.skippedLines.length > 0 && ` · 읽지 못한 줄 ${data.skippedLines.length}개`}
</div>
{/* Report table (EPANET calibration report columns) */}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-mono)', fontSize: '11px' }}>
<thead>
<tr style={{ background: 'var(--bg-subtle)', textAlign: 'right' }}>
{['위치', '수', '관측 평균', '계산 평균', '평균 오차', 'RMS 오차'].map((h, i) => (
<th key={h} style={{ padding: '4px 5px', textAlign: i === 0 ? 'left' : 'right', fontWeight: 600, fontFamily: 'var(--font-sans)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{report.locations.map((l) => (
<tr
key={l.location}
onClick={() => { setSelectedLoc(l.location); onFocusLocation(paramMeta.element, l.location); }}
style={{ cursor: 'pointer', background: l.location === activeLoc ? 'var(--accent-soft, #e0ecff)' : undefined, borderBottom: '1px solid var(--border)' }}
title="클릭하면 그래프와 지도 위치를 보여줍니다"
>
<td style={{ padding: '3px 5px' }}>{l.location}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{l.count}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.observedMean)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.computedMean)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.meanError)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.rmsError)}</td>
</tr>
))}
<tr style={{ fontWeight: 700, background: 'var(--bg-subtle)' }}>
<td style={{ padding: '3px 5px' }}>전체</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{report.network.count}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.observedMean)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.computedMean)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.meanError)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.rmsError)}</td>
</tr>
</tbody>
</table>
<div style={{ marginTop: 4, color: 'var(--text-secondary)' }}>
평균값 상관계수 R = {report.correlationOfMeans === null ? '-' : report.correlationOfMeans.toFixed(3)} ·
전체 상관계수 R = {report.correlationAll === null ? '-' : report.correlationAll.toFixed(3)} · 단위 {unit}
</div>
</div>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={showOnMap} onChange={(e) => setShowOnMap(e.target.checked)} />
지도에 값 보기 (위치별 평균 실측·계산·오차, 오차 10% 초과는 빨간색)
</label>
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
<button className="btn btn-secondary" onClick={() => setShowReport((v) => !v)} style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}>
<FileText size={12} /><span>{showReport ? '리포트 닫기' : '리포트 보기'}</span>
</button>
<button className="btn btn-secondary" onClick={downloadReport} style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}>
<Download size={12} /><span>리포트 저장 (.txt)</span>
</button>
</div>
{showReport && (
<pre style={{ margin: 0, padding: 8, background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', fontSize: '10.5px', overflowX: 'auto', whiteSpace: 'pre' }}>
{reportText}
</pre>
)}
<ScatterChart points={report.points} unit={unit} highlight={activeLoc} />
{series && activeLoc && (
<SeriesChart location={activeLoc} unit={unit} ts={series.ts} sim={series.sim} obs={series.obs} />
)}
</>
)}
</div>
);
};
/** Observed (x) vs computed (y) with the 1:1 line -- EPANET's correlation plot. */
const ScatterChart: React.FC<{ points: { location: string; observed: number; computed: number }[]; unit: string; highlight: string | null }> = ({ points, unit, highlight }) => {
const H = 240, pl = 40, pr = 10, pt = 18, pb = 30;
const [lo, hi] = niceRange(points.flatMap((p) => [p.observed, p.computed]));
const sx = (v: number) => pl + ((v - lo) / (hi - lo)) * (W - pl - pr);
const sy = (v: number) => H - pb - ((v - lo) / (hi - lo)) * (H - pt - pb);
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => lo + f * (hi - lo));
return (
<svg width={W} height={H} role="img" aria-label="실측값과 계산값 상관도" style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)' }}>
<text x={pl} y={12} fontSize={11} fontWeight={600} fill="currentColor">실측 vs 계산 ({unit})</text>
{ticks.map((t) => (
<g key={t} fontSize={9} fill="#64748b">
<line x1={pl} x2={W - pr} y1={sy(t)} y2={sy(t)} stroke="#e2e8f0" />
<text x={pl - 4} y={sy(t) + 3} textAnchor="end">{fmt(t)}</text>
<text x={sx(t)} y={H - pb + 12} textAnchor="middle">{fmt(t)}</text>
</g>
))}
<line x1={sx(lo)} y1={sy(lo)} x2={sx(hi)} y2={sy(hi)} stroke="#94a3b8" strokeDasharray="4 3" />
{points.map((p, i) => (
<circle key={i} cx={sx(p.observed)} cy={sy(p.computed)} r={p.location === highlight ? 4 : 3}
fill={p.location === highlight ? '#dc2626' : '#2563eb'} fillOpacity={0.8} />
))}
<text x={(W + pl) / 2} y={H - 4} fontSize={9} textAnchor="middle" fill="#64748b">실측값</text>
<text x={10} y={(H - pb + pt) / 2} fontSize={9} textAnchor="middle" fill="#64748b" transform={`rotate(-90 10 ${(H - pb + pt) / 2})`}>계산값</text>
</svg>
);
};
/** Simulated series (line) with the measurements (dots) for one location. */
const SeriesChart: React.FC<{ location: string; unit: string; ts: number[]; sim: number[]; obs: { timeSec: number; observed: number }[] }> = ({ location, unit, ts, sim, obs }) => {
const H = 200, pl = 40, pr = 10, pt = 18, pb = 26;
const tMax = Math.max(ts[ts.length - 1] ?? 0, ...obs.map((o) => o.timeSec), 1);
const [lo, hi] = niceRange([...sim, ...obs.map((o) => o.observed)]);
const sx = (t: number) => pl + (t / tMax) * (W - pl - pr);
const sy = (v: number) => H - pb - ((v - lo) / (hi - lo)) * (H - pt - pb);
const path = sim.map((v, i) => `${i ? 'L' : 'M'}${sx(ts[i] ?? 0).toFixed(1)},${sy(v).toFixed(1)}`).join('');
const hourStep = tMax > 48 * 3600 ? 12 : tMax > 24 * 3600 ? 6 : 3;
const hourTicks: number[] = [];
for (let h = 0; h * 3600 <= tMax; h += hourStep) hourTicks.push(h * 3600);
return (
<svg width={W} height={H} role="img" aria-label={`${location} 시간별 실측값과 계산값`} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)' }}>
<text x={pl} y={12} fontSize={11} fontWeight={600} fill="currentColor">{location} 시간별 ({unit})</text>
<g fontSize={9} fill="#64748b">
<line x1={W - 118} x2={W - 104} y1={9} y2={9} stroke="#2563eb" strokeWidth={2} />
<text x={W - 100} y={12}>계산</text>
<circle cx={W - 62} cy={9} r={3} fill="#dc2626" />
<text x={W - 55} y={12}>실측</text>
</g>
{[0, 0.5, 1].map((f) => {
const v = lo + f * (hi - lo);
return (
<g key={f} fontSize={9} fill="#64748b">
<line x1={pl} x2={W - pr} y1={sy(v)} y2={sy(v)} stroke="#e2e8f0" />
<text x={pl - 4} y={sy(v) + 3} textAnchor="end">{fmt(v)}</text>
</g>
);
})}
{hourTicks.map((t) => (
<text key={t} x={sx(t)} y={H - pb + 12} fontSize={9} textAnchor="middle" fill="#64748b">{hhmm(t)}</text>
))}
<path d={path} fill="none" stroke="#2563eb" strokeWidth={1.8} />
{obs.map((o, i) => <circle key={i} cx={sx(o.timeSec)} cy={sy(o.observed)} r={3.2} fill="#dc2626" />)}
</svg>
);
};