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