SPipe 데모 v2 (2026-10-30) 서버 배포본

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

No files matched your search

+402
View File
@@ -0,0 +1,402 @@
import React, { useState } from 'react';
import { X, Plus } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import {
addNode,
addPipe,
addPump,
addValve,
addCurve,
addRule,
addControl,
updatePattern,
getNextNodeId,
getNextPipeId,
getNextPumpId,
getNextValveId,
getNextCurveId,
} from './inpModel';
import type { TableCategory } from './FullAttributeTableModal';
interface AddElementModalProps {
category: TableCategory;
model: NetworkModel;
onAdd: (newModel: NetworkModel) => void;
onClose: () => void;
}
const LABELS: Partial<Record<TableCategory, string>> = {
junctions: '절점 (Junction)',
pipes: '관로 (Pipe)',
pumps: '펌프 (Pump)',
valves: '밸브 (Valve)',
reservoirs: '저수지 (Reservoir)',
tanks: '탱크 (Tank)',
patterns: '패턴 (Pattern)',
controls: '컨트롤 (Control)',
rules: '규칙 (Rule)',
curves: '펌프 곡선 (Curve)',
};
function averageExistingCoords(model: NetworkModel): { x: number; y: number } {
const nodes = Object.values(model.nodes);
if (nodes.length === 0) return { x: 0, y: 0 };
const sum = nodes.reduce((acc, n) => ({ x: acc.x + n.x, y: acc.y + n.y }), { x: 0, y: 0 });
return { x: Math.round((sum.x / nodes.length) * 100) / 100, y: Math.round((sum.y / nodes.length) * 100) / 100 };
}
export const AddElementModal: React.FC<AddElementModalProps> = ({ category, model, onAdd, onClose }) => {
const [error, setError] = useState<string | null>(null);
const nodeIds = Object.keys(model.nodes);
const linkIds = [...Object.keys(model.pipes), ...Object.keys(model.pumps), ...Object.keys(model.valves || {})];
const defaultCoords = averageExistingCoords(model);
// Shared field state (only relevant fields are rendered/used per category)
const [id, setId] = useState('');
const [node1, setNode1] = useState(nodeIds[0] || '');
const [node2, setNode2] = useState(nodeIds[1] || nodeIds[0] || '');
const [elevation, setElevation] = useState('0');
const [demand, setDemand] = useState('0');
const [pattern, setPattern] = useState('');
const [head, setHead] = useState('0');
const [initLevel, setInitLevel] = useState('5');
const [minLevel, setMinLevel] = useState('0');
const [maxLevel, setMaxLevel] = useState('10');
const [diameter, setDiameter] = useState('10');
const [minVol, setMinVol] = useState('0');
const [length, setLength] = useState('100');
const [pipeDiameter, setPipeDiameter] = useState('200');
const [roughness, setRoughness] = useState('100');
const [minorLoss, setMinorLoss] = useState('0');
const [status, setStatus] = useState('Open');
const [parameters, setParameters] = useState('');
const [valveType, setValveType] = useState('PRV');
const [valveSetting, setValveSetting] = useState('0');
const [multipliers, setMultipliers] = useState('1.0 1.0 1.0 1.0 1.0 1.0');
const [controlLinkId, setControlLinkId] = useState(linkIds[0] || '');
const [controlStatus, setControlStatus] = useState('Open');
const [ruleText, setRuleText] = useState('RULE 1\nIF NODE J1 PRESSURE BELOW 10\nTHEN PIPE P1 STATUS = OPEN\nPRIORITY 1');
const [curvePoints, setCurvePoints] = useState('0 50\n50 40\n100 20');
const title = LABELS[category] || category;
const handleSubmit = () => {
setError(null);
try {
if (category === 'junctions') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('절점 ID를 입력하세요.');
if (model.nodes[trimmedId]) throw new Error(`이미 존재하는 절점 ID입니다: ${trimmedId}`);
const next = addNode(model, {
id: trimmedId,
type: 'JUNCTION',
x: defaultCoords.x,
y: defaultCoords.y,
elevation: parseFloat(elevation) || 0,
demand: parseFloat(demand) || 0,
pattern: pattern.trim() || undefined,
});
onAdd(next);
} else if (category === 'reservoirs') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('저수지 ID를 입력하세요.');
if (model.nodes[trimmedId]) throw new Error(`이미 존재하는 절점 ID입니다: ${trimmedId}`);
const next = addNode(model, {
id: trimmedId,
type: 'RESERVOIR',
x: defaultCoords.x,
y: defaultCoords.y,
elevation: parseFloat(head) || 0,
head: parseFloat(head) || 0,
pattern: pattern.trim() || undefined,
});
onAdd(next);
} else if (category === 'tanks') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('탱크 ID를 입력하세요.');
if (model.nodes[trimmedId]) throw new Error(`이미 존재하는 절점 ID입니다: ${trimmedId}`);
const next = addNode(model, {
id: trimmedId,
type: 'TANK',
x: defaultCoords.x,
y: defaultCoords.y,
elevation: parseFloat(elevation) || 0,
initLevel: parseFloat(initLevel) || 0,
minLevel: parseFloat(minLevel) || 0,
maxLevel: parseFloat(maxLevel) || 0,
diameter: parseFloat(diameter) || 0,
minVol: parseFloat(minVol) || 0,
});
onAdd(next);
} else if (category === 'pipes') {
if (!node1 || !node2) throw new Error('시작/끝 절점을 선택하세요.');
if (node1 === node2) throw new Error('시작과 끝 절점은 달라야 합니다.');
const trimmedId = id.trim() || getNextPipeId(model);
if (model.pipes[trimmedId] || model.pumps[trimmedId] || model.valves?.[trimmedId]) {
throw new Error(`이미 존재하는 링크 ID입니다: ${trimmedId}`);
}
const next = addPipe(model, {
id: trimmedId,
node1,
node2,
length: parseFloat(length) || 0,
diameter: parseFloat(pipeDiameter) || 0,
roughness: parseFloat(roughness) || 100,
minorLoss: parseFloat(minorLoss) || 0,
status,
});
onAdd(next);
} else if (category === 'pumps') {
if (!node1 || !node2) throw new Error('상류/하류 절점을 선택하세요.');
if (node1 === node2) throw new Error('상류와 하류 절점은 달라야 합니다.');
const trimmedId = id.trim() || getNextPumpId(model);
if (model.pipes[trimmedId] || model.pumps[trimmedId] || model.valves?.[trimmedId]) {
throw new Error(`이미 존재하는 링크 ID입니다: ${trimmedId}`);
}
if (!parameters.trim()) throw new Error('펌프 특성 (예: HEAD Curve1 또는 POWER 10)을 입력하세요.');
const next = addPump(model, { id: trimmedId, node1, node2, parameters: parameters.trim() });
onAdd(next);
} else if (category === 'valves') {
if (!node1 || !node2) throw new Error('상류/하류 절점을 선택하세요.');
if (node1 === node2) throw new Error('상류와 하류 절점은 달라야 합니다.');
const trimmedId = id.trim() || getNextValveId(model);
if (model.pipes[trimmedId] || model.pumps[trimmedId] || model.valves?.[trimmedId]) {
throw new Error(`이미 존재하는 링크 ID입니다: ${trimmedId}`);
}
const settingNum = parseFloat(valveSetting);
const next = addValve(model, {
id: trimmedId,
node1,
node2,
diameter: parseFloat(diameter) || 0,
valveType,
setting: isNaN(settingNum) ? valveSetting : settingNum,
minorLoss: parseFloat(minorLoss) || 0,
});
onAdd(next);
} else if (category === 'patterns') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('패턴 ID를 입력하세요.');
if (model.patterns[trimmedId]) throw new Error(`이미 존재하는 패턴 ID입니다: ${trimmedId}`);
const values = multipliers
.split(/[\s,]+/)
.map((v) => v.trim())
.filter(Boolean)
.map(Number);
if (values.length === 0 || values.some((v) => isNaN(v))) {
throw new Error('시간별 배율은 공백/쉼표로 구분된 숫자여야 합니다.');
}
const next = updatePattern(model, trimmedId, values);
onAdd(next);
} else if (category === 'controls') {
if (!controlLinkId) throw new Error('대상 링크를 선택하세요.');
const next = addControl(model, { linkId: controlLinkId, status: controlStatus });
onAdd(next);
} else if (category === 'rules') {
const next = addRule(model, ruleText);
onAdd(next);
} else if (category === 'curves') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('곡선 ID를 입력하세요.');
if (model.curves[trimmedId]) throw new Error(`이미 존재하는 곡선 ID입니다: ${trimmedId}`);
const points = curvePoints
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [xStr, yStr] = line.split(/[\s,]+/);
return { x: parseFloat(xStr), y: parseFloat(yStr) };
});
if (points.length === 0 || points.some((p) => isNaN(p.x) || isNaN(p.y))) {
throw new Error('곡선 점은 한 줄당 "X Y" 형식의 숫자 쌍이어야 합니다.');
}
const next = addCurve(model, { id: trimmedId, points });
onAdd(next);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
}
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '5px 8px',
fontSize: '12px',
border: '1px solid var(--border-strong)',
borderRadius: '4px',
};
const labelStyle: React.CSSProperties = { fontSize: '11px', color: 'var(--text-secondary)', display: 'block', marginBottom: '3px' };
const fieldWrap: React.CSSProperties = { marginBottom: '10px' };
const nodeSelect = (value: string, onChange: (v: string) => void, label: string) => (
<div style={fieldWrap}>
<label style={labelStyle}>{label}</label>
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
{nodeIds.length === 0 && <option value="">(절점 없음)</option>}
{nodeIds.map((nid) => (
<option key={nid} value={nid}>{nid}</option>
))}
</select>
</div>
);
return (
<div className="modal-overlay" style={{ zIndex: 1000 }}>
<div className="modal-content" style={{ width: 'min(420px, calc(100vw - 32px))', padding: 'var(--s-6)' }} role="dialog" aria-modal="true">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--s-4)' }}>
<h3 style={{ margin: 0, fontSize: 'var(--fs-lg)', color: 'var(--text)' }}>{title} 추가</h3>
<button onClick={onClose} className="tool-btn" aria-label="닫기" title="닫기">
<X size={16} />
</button>
</div>
{error && (
<div style={{ padding: '8px 10px', marginBottom: '10px', background: 'var(--crit-wash)', color: 'var(--crit)', borderRadius: '4px', fontSize: '11px' }}>
{error}
</div>
)}
{(category === 'junctions' || category === 'reservoirs' || category === 'tanks' || category === 'pipes' || category === 'pumps' || category === 'valves' || category === 'patterns' || category === 'curves') && (
<div style={fieldWrap}>
<label style={labelStyle}>{category === 'pipes' || category === 'pumps' || category === 'valves' ? 'ID (비우면 자동 생성)' : 'ID'}</label>
<input type="text" value={id} onChange={(e) => setId(e.target.value)} style={inputStyle} autoFocus />
</div>
)}
{category === 'junctions' && (
<>
<div style={fieldWrap}><label style={labelStyle}>지반고 (Elevation)</label><input type="number" value={elevation} onChange={(e) => setElevation(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>기본수요량 (Demand)</label><input type="number" value={demand} onChange={(e) => setDemand(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>수요 패턴 ID (선택)</label><input type="text" value={pattern} onChange={(e) => setPattern(e.target.value)} style={inputStyle} /></div>
</>
)}
{category === 'reservoirs' && (
<>
<div style={fieldWrap}><label style={labelStyle}>총수두 (Head)</label><input type="number" value={head} onChange={(e) => setHead(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>Head Pattern ID (선택)</label><input type="text" value={pattern} onChange={(e) => setPattern(e.target.value)} style={inputStyle} /></div>
</>
)}
{category === 'tanks' && (
<>
<div style={fieldWrap}><label style={labelStyle}>바닥 지반고 (Elevation)</label><input type="number" value={elevation} onChange={(e) => setElevation(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>초기 수위 (Initial Level)</label><input type="number" value={initLevel} onChange={(e) => setInitLevel(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>최저 수위 (Minimum Level)</label><input type="number" value={minLevel} onChange={(e) => setMinLevel(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>최고 수위 (Maximum Level)</label><input type="number" value={maxLevel} onChange={(e) => setMaxLevel(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>직경 (Diameter)</label><input type="number" value={diameter} onChange={(e) => setDiameter(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>최저 저수용량 (Min Volume)</label><input type="number" value={minVol} onChange={(e) => setMinVol(e.target.value)} style={inputStyle} /></div>
</>
)}
{category === 'pipes' && (
<>
{nodeSelect(node1, setNode1, '시작절점 (Node1)')}
{nodeSelect(node2, setNode2, '끝절점 (Node2)')}
<div style={fieldWrap}><label style={labelStyle}>관로길이 (Length)</label><input type="number" value={length} onChange={(e) => setLength(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>관경 (Diameter)</label><input type="number" value={pipeDiameter} onChange={(e) => setPipeDiameter(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>조도계수 (Roughness)</label><input type="number" value={roughness} onChange={(e) => setRoughness(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>미소손실계수 (Minor Loss)</label><input type="number" value={minorLoss} onChange={(e) => setMinorLoss(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}>
<label style={labelStyle}>상태 (Status)</label>
<select value={status} onChange={(e) => setStatus(e.target.value)} style={inputStyle}>
<option value="Open">Open</option>
<option value="Closed">Closed</option>
<option value="CV">CV</option>
</select>
</div>
</>
)}
{category === 'pumps' && (
<>
{nodeSelect(node1, setNode1, '상류절점 (Node1)')}
{nodeSelect(node2, setNode2, '하류절점 (Node2)')}
<div style={fieldWrap}>
<label style={labelStyle}>펌프 특성 (예: HEAD Curve1, POWER 10)</label>
<input type="text" value={parameters} onChange={(e) => setParameters(e.target.value)} style={inputStyle} placeholder="HEAD Curve1" />
</div>
</>
)}
{category === 'valves' && (
<>
{nodeSelect(node1, setNode1, '상류절점 (Node1)')}
{nodeSelect(node2, setNode2, '하류절점 (Node2)')}
<div style={fieldWrap}>
<label style={labelStyle}>밸브 유형 (Type)</label>
<select value={valveType} onChange={(e) => setValveType(e.target.value)} style={inputStyle}>
{['PRV', 'PSV', 'PBV', 'FCV', 'TCV', 'GPV', 'PCV'].map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div style={fieldWrap}><label style={labelStyle}>구경 (Diameter)</label><input type="number" value={diameter} onChange={(e) => setDiameter(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>설정값 (Setting)</label><input type="text" value={valveSetting} onChange={(e) => setValveSetting(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>미소손실계수 (Minor Loss)</label><input type="number" value={minorLoss} onChange={(e) => setMinorLoss(e.target.value)} style={inputStyle} /></div>
</>
)}
{category === 'patterns' && (
<div style={fieldWrap}>
<label style={labelStyle}>시간별 배율 (공백 또는 쉼표 구분)</label>
<textarea value={multipliers} onChange={(e) => setMultipliers(e.target.value)} style={{ ...inputStyle, minHeight: '60px', fontFamily: 'var(--font-mono)' }} />
</div>
)}
{category === 'controls' && (
<>
<div style={fieldWrap}>
<label style={labelStyle}>대상 링크</label>
<select value={controlLinkId} onChange={(e) => setControlLinkId(e.target.value)} style={inputStyle}>
{linkIds.length === 0 && <option value="">(링크 없음)</option>}
{linkIds.map((lid) => <option key={lid} value={lid}>{lid}</option>)}
</select>
</div>
<div style={fieldWrap}>
<label style={labelStyle}>상태 (Open / Closed)</label>
<select value={controlStatus} onChange={(e) => setControlStatus(e.target.value)} style={inputStyle}>
<option value="Open">Open</option>
<option value="Closed">Closed</option>
</select>
</div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginBottom: '10px' }}>
단순 컨트롤(LINK 상태)만 생성합니다. 조건부(IF NODE...)나 시간 기반 컨트롤은 검증 탭의 컨트롤 편집기를 이용하세요.
</div>
</>
)}
{category === 'rules' && (
<div style={fieldWrap}>
<label style={labelStyle}>규칙 정의 (EPANET RULE 구문 원문)</label>
<textarea
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
style={{ ...inputStyle, minHeight: '140px', fontFamily: 'var(--font-mono)', whiteSpace: 'pre' }}
/>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '4px' }}>
RULE 이름으로 시작해 IF/THEN/PRIORITY 절을 그대로 입력합니다. 문법은 검증 없이 그대로 저장됩니다.
</div>
</div>
)}
{category === 'curves' && (
<div style={fieldWrap}>
<label style={labelStyle}>곡선 점 (한 줄당 "X Y")</label>
<textarea
value={curvePoints}
onChange={(e) => setCurvePoints(e.target.value)}
style={{ ...inputStyle, minHeight: '90px', fontFamily: 'var(--font-mono)', whiteSpace: 'pre' }}
/>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--s-3)', marginTop: 'var(--s-4)' }}>
<button onClick={onClose} className="btn btn-secondary">취소</button>
<button onClick={handleSubmit} className="btn btn-primary">
<Plus size={13} />
추가
</button>
</div>
</div>
</div>
);
};
+145
View File
@@ -0,0 +1,145 @@
import React, { useState } from 'react';
import { Search } from 'lucide-react';
import {
searchKakaoAddress,
type KakaoAddressResult,
formatMapServiceError,
} from './mapServices';
interface AddressSearchProps {
onSelect: (result: KakaoAddressResult) => void;
}
export function AddressSearch({ onSelect }: AddressSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<KakaoAddressResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasSearched, setHasSearched] = useState(false);
const handleSearch = async (event: React.FormEvent) => {
event.preventDefault();
const trimmed = query.trim();
if (!trimmed || isLoading) return;
setIsLoading(true);
setError(null);
setHasSearched(true);
setResults([]);
try {
setResults(await searchKakaoAddress(trimmed));
} catch (caught) {
setError(formatMapServiceError(caught));
} finally {
setIsLoading(false);
}
};
const handleSelect = (result: KakaoAddressResult) => {
onSelect(result);
setResults([]);
setHasSearched(false);
setQuery(result.roadAddressName || result.parcelAddressName || result.addressName);
};
const showPanel = Boolean(error) || (!isLoading && hasSearched && results.length === 0) || results.length > 0;
return (
<div style={{ width: '340px', position: 'relative', flexShrink: 0 }}>
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '4px' }}>
<div style={{ position: 'relative', flex: 1 }}>
<Search
size={12}
aria-hidden="true"
style={{ position: 'absolute', left: '7px', top: '8px', color: 'var(--text-muted)' }}
/>
<input
type="text"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setError(null);
setHasSearched(false);
}}
placeholder="도로명·지번 주소 검색"
aria-label="카카오 주소 검색"
style={{ width: '100%', height: '28px', paddingLeft: '24px', fontSize: '11px' }}
/>
</div>
<button
type="submit"
className="btn btn-secondary"
disabled={isLoading || !query.trim()}
aria-label="주소 검색 실행"
style={{ height: '28px', padding: '0 9px' }}
>
{isLoading ? '검색 중' : '주소 검색'}
</button>
</form>
{showPanel && (
<div
style={{
position: 'absolute',
top: '31px',
right: 0,
zIndex: 1000,
width: '100%',
maxHeight: '280px',
overflowY: 'auto',
background: 'var(--bg-panel)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--r-sm)',
boxShadow: 'var(--shadow-pop)',
}}
role="listbox"
aria-label="카카오 주소 검색 결과"
>
{error && (
<div role="alert" style={{ padding: '8px', color: 'var(--crit)', fontSize: '11px', lineHeight: 1.4 }}>
{error}
</div>
)}
{!error && !isLoading && hasSearched && results.length === 0 && (
<div style={{ padding: '8px', color: 'var(--text-secondary)', fontSize: '11px' }}>
검색 결과가 없습니다.
</div>
)}
{results.map((result) => (
<button
type="button"
key={result.id}
onClick={() => handleSelect(result)}
role="option"
aria-selected="false"
style={{
display: 'block',
width: '100%',
padding: '7px 9px',
border: 0,
borderBottom: '1px solid var(--border)',
background: 'transparent',
color: 'var(--text)',
cursor: 'pointer',
textAlign: 'left',
}}
>
<strong style={{ display: 'block', fontSize: '11px' }}>
{result.placeName || result.roadAddressName || result.parcelAddressName || result.addressName}
</strong>
{result.roadAddressName && (
<span style={{ display: 'block', marginTop: '2px', fontSize: '10px', color: 'var(--text-secondary)' }}>
도로명: {result.roadAddressName}
</span>
)}
{result.parcelAddressName && (
<span style={{ display: 'block', fontSize: '10px', color: 'var(--text-muted)' }}>
지번: {result.parcelAddressName}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
+500
View File
@@ -0,0 +1,500 @@
// "분석" tab: network analyses that go beyond a single EPS run.
// Server-side ones (fire flow, criticality, ...) call /api/jobs/{name} with the
// current model as INP; graph-only ones run in the browser.
import React, { useMemo, useState } from 'react';
import { Flame, Network, Boxes, Play, Crosshair, X, Wrench, ShieldAlert, Droplets, ArrowLeftRight, Gauge, Waves, Zap, ClipboardList, PenTool, Mountain, Map as MapIcon, Target, Timer } from 'lucide-react';
import { EditToolsSection } from './EditToolsSection';
import { DemSection, type DemSectionProps } from './DemSection';
import { GisToolsSection, type GisToolsProps } from './GisToolsSection';
import { AutoCalibrationSection, PumpScheduleSection, type OptimizeProps } from './OptimizeSections';
import { CriticalitySection, ServiceAreaSection, FlowDirectionSection, VspSection, ContourSection, EnergySection, PipeReportSection, type AnalysisSectionsExtraProps } from './AnalysisSections';
import type { NetworkModel } from './inpModel';
import { generateInp } from './inpModel';
import type { SimulationResult, UserGisLayer } from './types';
import type { CRS } from './crs';
import { modelToLngLat } from './crs';
import { createDisplayConverter, getDisplayUnits } from './unitSystem';
import { runJob } from './api';
import { findNetworkGroups } from './networkGroups';
import { DEFAULT_EDIT_OPTIONS, fillZeroElevations, loadEditOptions, recalcPipeLengths, saveEditOptions, type EditOptions } from './networkTools';
import { setLabels } from './extrasActions';
import { BLOCK_STATUS_LABEL, blocksFromAssets, blocksFromPolygons, checkBlockIsolation, type BlockReport } from './blockIsolation';
type Converter = ReturnType<typeof createDisplayConverter>;
type DisplayUnits = ReturnType<typeof getDisplayUnits>;
export type AnalysisSection = 'fireflow' | 'groups' | 'blocks' | 'criticality' | 'service' | 'flowdir' | 'vsp' | 'contour' | 'energy' | 'pipes' | 'calib' | 'pumpopt' | 'edit' | 'dem' | 'gis' | 'tools';
export interface AnalysisPanelProps {
model: NetworkModel;
result: SimulationResult | null;
crs: CRS;
userLayers: UserGisLayer[];
selectedNodeIds: string[];
selectedLinkIds: string[];
units: Converter;
displayUnits: DisplayUnits;
minPressureThreshold: number;
/** Results survive switching tabs: the panel keeps them in App state. */
cache: Record<string, unknown>;
setCache: (key: string, value: unknown) => void;
onFocus: (type: 'node' | 'link', id: string) => void;
onSelectElements: (nodeIds: string[], linkIds: string[]) => void;
/** Apply a whole-model edit with undo. */
onApplyModel: (next: NetworkModel, message: string) => void;
}
const pill = (active: boolean): React.CSSProperties => ({ height: 'var(--h-btn-sm)', padding: '0 var(--s-4)', fontSize: 'var(--fs-xs)' });
const th: React.CSSProperties = { padding: '4px 5px', textAlign: 'right', fontWeight: 600, background: 'var(--bg-subtle)', position: 'sticky', top: 0 };
const td: React.CSSProperties = { padding: '3px 5px', textAlign: 'right', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-mono)' };
const fmt = (v: number | undefined, d = 2) => (v === undefined || !Number.isFinite(v) ? '-' : v.toFixed(d));
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
export const AnalysisPanel: React.FC<AnalysisPanelProps & AnalysisSectionsExtraProps & DemSectionProps & GisToolsProps & OptimizeProps> = (props) => {
const [section, setSection] = useState<AnalysisSection>((props.cache.section as AnalysisSection) ?? 'fireflow');
const go = (s: AnalysisSection) => {
setSection(s);
props.setCache('section', s);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)', fontSize: 'var(--fs-xs)' }}>
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap', borderBottom: '1px solid var(--border)', paddingBottom: 'var(--s-3)' }}>
<button className={`btn btn-secondary ${section === 'fireflow' ? 'btn-primary' : ''}`} style={pill(section === 'fireflow')} onClick={() => go('fireflow')}>
<Flame size={12} /><span>소화전 모의</span>
</button>
<button className={`btn btn-secondary ${section === 'groups' ? 'btn-primary' : ''}`} style={pill(section === 'groups')} onClick={() => go('groups')}>
<Network size={12} /><span>관망 그룹</span>
</button>
<button className={`btn btn-secondary ${section === 'blocks' ? 'btn-primary' : ''}`} style={pill(section === 'blocks')} onClick={() => go('blocks')}>
<Boxes size={12} /><span>소블록 고립확인</span>
</button>
{([
['criticality', '관로 중요도', <ShieldAlert size={12} key="i" />],
['service', '급수 구역', <Droplets size={12} key="i" />],
['flowdir', '흐름 방향', <ArrowLeftRight size={12} key="i" />],
['vsp', 'VSP', <Gauge size={12} key="i" />],
['contour', '등치선', <Waves size={12} key="i" />],
['energy', '에너지 비용', <Zap size={12} key="i" />],
['pipes', '관로 통계', <ClipboardList size={12} key="i" />],
['calib', '자동 보정', <Target size={12} key="i" />],
['pumpopt', '펌프 운전 최적화', <Timer size={12} key="i" />],
['edit', '편집 도구', <PenTool size={12} key="i" />],
['dem', '지형(DEM)', <Mountain size={12} key="i" />],
['gis', 'GIS 도구', <MapIcon size={12} key="i" />],
] as Array<[AnalysisSection, string, React.ReactNode]>).map(([key, label, icon]) => (
<button key={key} className={`btn btn-secondary ${section === key ? 'btn-primary' : ''}`} style={pill(section === key)} onClick={() => go(key)}>
{icon}<span>{label}</span>
</button>
))}
<button className={`btn btn-secondary ${section === 'tools' ? 'btn-primary' : ''}`} style={pill(section === 'tools')} onClick={() => go('tools')}>
<Wrench size={12} /><span>관망 도구</span>
</button>
</div>
{section === 'fireflow' && <FireFlowSection {...props} />}
{section === 'groups' && <GroupsSection {...props} />}
{section === 'blocks' && <BlocksSection {...props} />}
{section === 'criticality' && <CriticalitySection {...props} />}
{section === 'service' && <ServiceAreaSection {...props} />}
{section === 'flowdir' && <FlowDirectionSection {...props} />}
{section === 'vsp' && <VspSection {...props} />}
{section === 'contour' && <ContourSection {...props} />}
{section === 'energy' && <EnergySection {...props} />}
{section === 'pipes' && <PipeReportSection {...props} />}
{section === 'calib' && <AutoCalibrationSection {...props} />}
{section === 'pumpopt' && <PumpScheduleSection {...props} />}
{section === 'edit' && <EditToolsSection {...props} />}
{section === 'dem' && <DemSection {...props} />}
{section === 'gis' && <GisToolsSection {...props} />}
{section === 'tools' && <ToolsSection {...props} />}
</div>
);
};
// ---------------------------------------------------------------- 소화전 모의
interface FireFlowResult {
windowLabel: string;
flow: number;
hydrants: Array<{ id: string; basePressure?: number; firePressure?: number; available?: number }>;
nodes: Array<{ id: string; basePressure: number; firePressure: number; diff: number; time: string }>;
belowThreshold: number | null;
belowThresholdBefore: number | null;
warnings: Array<{ time: number; code: number }>;
}
const FireFlowSection: React.FC<AnalysisPanelProps> = ({ model, units, displayUnits, selectedNodeIds, minPressureThreshold, cache, setCache, onFocus, onSelectElements }) => {
const durationH = useMemo(() => {
const d = model.options.timeEntries.find((e) => e.key.toUpperCase() === 'DURATION')?.value ?? '24';
const [h, m] = d.split(':').map(Number);
return (h || 0) + (m || 0) / 60 || 24;
}, [model]);
const [hydrants, setHydrants] = useState<string[]>((cache.ffHydrants as string[]) ?? []);
const [idInput, setIdInput] = useState('');
const [flow, setFlow] = useState<string>((cache.ffFlow as string) ?? String(Math.round(units.flow(units.toFlow(1000)))));
const [start, setStart] = useState<string>((cache.ffStart as string) ?? '0');
const [end, setEnd] = useState<string>((cache.ffEnd as string) ?? String(durationH));
const [residual, setResidual] = useState<string>((cache.ffResidual as string) ?? '10');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const result = cache.ffResult as FireFlowResult | undefined;
const addIds = (ids: string[]) => {
const valid = ids.filter((id) => model.nodes[id]?.type === 'JUNCTION');
const next = [...new Set([...hydrants, ...valid])];
setHydrants(next);
setCache('ffHydrants', next);
if (valid.length < ids.length) setError('접합점(JUNCTION)만 소화전으로 지정할 수 있습니다.');
};
const run = async () => {
setError(null);
const q = Number(flow);
if (!hydrants.length) return setError('소화전 절점을 추가하세요.');
if (!(q > 0)) return setError('소화 유량을 입력하세요.');
setBusy(true);
setCache('ffFlow', flow);
setCache('ffStart', start);
setCache('ffEnd', end);
setCache('ffResidual', residual);
try {
const r = await runJob<FireFlowResult>('fireflow', generateInp(model), {
nodes: hydrants,
flow: units.toFlow(q),
start: Number(start),
end: Number(end),
residual: residual.trim() ? units.toPressure(Number(residual)) : null,
threshold: units.toPressure(minPressureThreshold),
});
setCache('ffResult', r);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const p = units.pressure;
const affected = result ? result.nodes.filter((n) => p(n.diff) <= -0.1) : [];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
소화전 절점에 소화 유량을 더해 지정 시간대를 다시 해석하고, 시간대 최저 압력을 소화 전후로 비교합니다.
잔류압력을 입력하면 그 압력을 지키며 낼 수 있는 최대 소화 유량도 계산합니다.
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>소화전 절점</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
{hydrants.map((id) => (
<span key={id} style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 6px', background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 10 }}>
<a onClick={() => onFocus('node', id)} style={{ cursor: 'pointer' }}>{id}</a>
<X size={11} style={{ cursor: 'pointer' }} onClick={() => { const n = hydrants.filter((h) => h !== id); setHydrants(n); setCache('ffHydrants', n); }} aria-label={`${id} 제거`} />
</span>
))}
{!hydrants.length && <span style={{ color: 'var(--text-muted)' }}>없음</span>}
</div>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-secondary" disabled={!selectedNodeIds.length} onClick={() => addIds(selectedNodeIds)} style={{ height: 24, fontSize: 'var(--fs-2xs)' }} title="지도에서 선택한 절점을 추가">
<Crosshair size={11} />선택 절점 추가 ({selectedNodeIds.length})
</button>
<input value={idInput} onChange={(e) => setIdInput(e.target.value)} placeholder="절점 ID" style={{ ...inputStyle, width: 90 }}
onKeyDown={(e) => { if (e.key === 'Enter' && idInput.trim()) { addIds([idInput.trim()]); setIdInput(''); } }} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label>소화 유량 ({displayUnits.flow}, 1곳당)<input type="number" value={flow} onChange={(e) => setFlow(e.target.value)} style={inputStyle} /></label>
<label>잔류압력 기준 ({displayUnits.pressure})<input type="number" value={residual} onChange={(e) => setResidual(e.target.value)} placeholder="생략 가능" style={inputStyle} /></label>
<label>시작 (시)<input type="number" step="0.5" value={start} onChange={(e) => setStart(e.target.value)} style={inputStyle} /></label>
<label>종료 (시)<input type="number" step="0.5" value={end} onChange={(e) => setEnd(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-primary" onClick={() => void run()} disabled={busy} style={{ height: 28 }}>
<Play size={12} />{busy ? '해석 중...' : '소화전 모의 실행'}
</button>
{error && <div style={{ color: 'var(--crit)' }}>{error}</div>}
{result && (
<>
<div style={{ color: 'var(--text-secondary)' }}>
시간대 {result.windowLabel} · 소화 유량 {fmt(units.flow(result.flow), 1)} {displayUnits.flow} ·
최저압력 기준({minPressureThreshold} {displayUnits.pressure}) 미달 절점 {result.belowThresholdBefore ?? '-'} → <b>{result.belowThreshold ?? '-'}</b>개
{result.warnings.length > 0 && <span style={{ color: 'var(--warn, #b45309)' }}> · 엔진 경고 {result.warnings.length}건</span>}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
<th style={{ ...th, textAlign: 'left' }}>소화전</th><th style={th}>소화 시 최저</th><th style={th}>최대 하락</th><th style={th}>확보 가능 유량</th>
</tr></thead>
<tbody>
{result.hydrants.map((h) => (
<tr key={h.id} onClick={() => onFocus('node', h.id)} style={{ cursor: 'pointer' }}>
<td style={{ ...td, textAlign: 'left' }}>{h.id}</td>
<td style={{ ...td, color: residual.trim() && h.firePressure !== undefined && p(h.firePressure) < Number(residual) ? 'var(--crit)' : undefined }}>{fmt(h.firePressure !== undefined ? p(h.firePressure) : undefined)}</td>
<td style={td}>{(() => { const n = result.nodes.find((x) => x.id === h.id); return n ? `${fmt(p(n.diff))} (${n.time})` : '-'; })()}</td>
<td style={td}>{h.available !== undefined ? `${fmt(units.flow(h.available), 0)} ${displayUnits.flow}` : '-'}</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<b>압력 변화 절점 ({affected.length}곳이 0.1 {displayUnits.pressure} 이상 하락)</b>
<button className="btn btn-secondary" disabled={!affected.length} onClick={() => onSelectElements(affected.map((n) => n.id), [])} style={{ height: 22, fontSize: 'var(--fs-2xs)' }}>지도에서 선택</button>
</div>
<div style={{ maxHeight: 260, overflowY: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
<th style={{ ...th, textAlign: 'left' }}>절점</th><th style={th}>시각</th><th style={th}>이전 압력</th><th style={th}>이후 압력</th><th style={th}>차이</th>
</tr></thead>
<tbody>
{result.nodes.slice(0, 300).map((n) => (
<tr key={n.id} onClick={() => onFocus('node', n.id)} style={{ cursor: 'pointer' }}>
<td style={{ ...td, textAlign: 'left' }}>{n.id}</td>
<td style={td}>{n.time}</td>
<td style={td}>{fmt(p(n.basePressure))}</td>
<td style={{ ...td, color: p(n.firePressure) < minPressureThreshold ? 'var(--crit)' : undefined }}>{fmt(p(n.firePressure))}</td>
<td style={td}>{fmt(p(n.diff))}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ color: 'var(--text-muted)' }}>
소화전 표는 시간대 최저 압력, 절점 표는 같은 시각끼리 비교해 가장 크게 떨어진 시각의 압력입니다(탱크·펌프 제어로 최저 시각이 바뀔 수 있어서).
행을 누르면 지도에서 해당 절점으로 이동합니다.
</div>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 관망 그룹
const GroupsSection: React.FC<AnalysisPanelProps> = ({ model, units, displayUnits, onSelectElements, onFocus }) => {
const [excludeClosed, setExcludeClosed] = useState(true);
const groups = useMemo(() => findNetworkGroups(model, excludeClosed), [model, excludeClosed]);
const noSource = groups.filter((g) => g.sources === 0);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
서로 연결된 관망 묶음을 찾습니다. 수원(배수지·탱크)이 없는 그룹은 물을 공급받을 수 없는 구간입니다.
</div>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={excludeClosed} onChange={(e) => setExcludeClosed(e.target.checked)} />
닫힌 관로·밸브는 연결로 보지 않음
</label>
<div>그룹 <b>{groups.length}</b>개 {noSource.length > 0 && <span style={{ color: 'var(--crit)' }}>· 수원 없는 그룹 {noSource.length}개</span>}</div>
<div style={{ maxHeight: 360, overflowY: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
<th style={{ ...th, textAlign: 'left' }}>그룹</th><th style={th}>절점</th><th style={th}>수원</th>
<th style={th}>관 연장 ({displayUnits.length})</th><th style={th}>기본수요 ({displayUnits.flow})</th>
</tr></thead>
<tbody>
{groups.map((g) => (
<tr key={g.index} style={{ cursor: 'pointer', color: g.sources === 0 ? 'var(--crit)' : undefined }}
onClick={() => { onSelectElements(g.nodeIds, g.linkIds); onFocus('node', g.nodeIds[0]); }}
title="클릭하면 그룹 전체를 지도에서 선택합니다">
<td style={{ ...td, textAlign: 'left' }}>{g.index}</td>
<td style={td}>{g.nodeIds.length}</td>
<td style={td}>{g.sources}</td>
<td style={td}>{fmt(units.length(g.pipeLength), 0)}</td>
<td style={td}>{fmt(units.flow(g.baseDemand), 1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
// ---------------------------------------------------------------- 소블록 고립확인
function modelExtents(model: NetworkModel) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(model.nodes)) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
return { minX, minY, maxX, maxY };
}
const BlocksSection: React.FC<AnalysisPanelProps> = ({ model, result, crs, userLayers, units, displayUnits, onFocus, onSelectElements }) => {
const polygonLayers = userLayers.filter((l) => l.geomTypes.some((g) => g.includes('Polygon')));
const [source, setSource] = useState<string>('assets');
const layer = polygonLayers.find((l) => l.id === source);
const fields = layer ? Object.keys(layer.featureCollection.features[0]?.properties ?? {}) : [];
const [nameField, setNameField] = useState('');
const [open, setOpen] = useState<string | null>(null);
const { reports, assigned } = useMemo(() => {
let blockOf: Record<string, string> = {};
if (source === 'assets') blockOf = blocksFromAssets(model);
else if (layer) {
const ext = modelExtents(model);
blockOf = blocksFromPolygons(model, layer.featureCollection, (x, y) => modelToLngLat(x, y, crs, ext), nameField || undefined);
}
return { reports: checkBlockIsolation(model, blockOf, result), assigned: Object.keys(blockOf).length };
}, [model, result, source, layer, nameField, crs]);
const statusColor = (r: BlockReport) => (r.status === 'single-inlet' ? 'var(--ok, #15803d)' : r.status === 'multi-open' ? 'var(--crit)' : 'var(--warn, #b45309)');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
소블록마다 경계를 지나는 관로·밸브를 찾고, 열린 연결이 하나(단일 유입)인지 확인합니다.
해석 결과가 있으면 실제로 물이 드나드는 지점도 표시합니다.
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 6, alignItems: 'center' }}>
<span>소블록 정보</span>
<select value={source} onChange={(e) => setSource(e.target.value)} style={inputStyle}>
<option value="assets">절점 속성 '소블록'</option>
{polygonLayers.map((l) => <option key={l.id} value={l.id}>폴리곤 레이어: {l.name}</option>)}
</select>
{layer && (
<>
<span>블록 이름 필드</span>
<select value={nameField} onChange={(e) => setNameField(e.target.value)} style={inputStyle}>
<option value="">(자동)</option>
{fields.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
</>
)}
</div>
{assigned === 0 ? (
<div style={{ color: 'var(--text-muted)' }}>
소블록이 지정된 절점이 없습니다. 소블록 폴리곤 레이어를 추가하거나(프로젝트 &gt; GIS 레이어 추가), 절점 속성에 소블록을 입력하세요.
</div>
) : (
<>
<div>블록 {reports.length}개 · 블록 지정 절점 {assigned}/{Object.keys(model.nodes).length}{!result && ' · 해석 결과가 없어 흐름 방향은 표시하지 않습니다'}</div>
<div style={{ maxHeight: 420, overflowY: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
<th style={{ ...th, textAlign: 'left' }}>블록</th><th style={th}>절점</th><th style={th}>열린 경계</th><th style={th}>실제 흐름</th><th style={{ ...th, textAlign: 'left' }}>판정</th>
</tr></thead>
<tbody>
{reports.map((r) => (
<React.Fragment key={r.block}>
<tr style={{ cursor: 'pointer' }} onClick={() => setOpen(open === r.block ? null : r.block)}>
<td style={{ ...td, textAlign: 'left' }}>{open === r.block ? '▾' : '▸'} {r.block}</td>
<td style={td}>{r.nodeCount}</td>
<td style={td}>{r.openCount}/{r.boundary.length}</td>
<td style={td}>{result ? r.activeFlows : '-'}</td>
<td style={{ ...td, textAlign: 'left', fontFamily: 'inherit', color: statusColor(r) }}>{BLOCK_STATUS_LABEL[r.status]}</td>
</tr>
{open === r.block && (
<tr><td colSpan={5} style={{ padding: '4px 8px', background: 'var(--bg-subtle)' }}>
<button className="btn btn-secondary" style={{ height: 20, fontSize: 'var(--fs-2xs)', marginBottom: 4 }}
onClick={() => onSelectElements([], r.boundary.map((b) => b.id))}>경계 링크 지도에서 선택</button>
{r.boundary.map((b) => (
<div key={b.id} style={{ display: 'flex', gap: 6, cursor: 'pointer' }} onClick={() => onFocus('link', b.id)}>
<span style={{ minWidth: 70 }}>{b.kind === 'pipe' ? '관로' : b.kind === 'valve' ? '밸브' : '펌프'} {b.id}</span>
<span>↔ {b.otherBlock || '(블록 없음)'}</span>
<span style={{ color: b.closed ? 'var(--text-muted)' : 'var(--crit)' }}>{b.closed ? '닫힘' : '열림'}</span>
{b.direction && <span>{b.direction === 'in' ? '유입' : b.direction === 'out' ? '유출' : b.direction === 'both' ? '양방향' : '흐름 없음'}{b.maxFlow !== undefined && b.direction !== 'none' ? ` 최대 ${fmt(units.flow(b.maxFlow), 1)} ${displayUnits.flow}` : ''}</span>}
</div>
))}
</td></tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 관망 도구
const ToolsSection: React.FC<AnalysisPanelProps> = ({ model, crs, selectedNodeIds, selectedLinkIds, units, displayUnits, onApplyModel }) => {
const [opts, setOpts] = useState<EditOptions>(() => loadEditOptions());
const setOpt = (patch: Partial<EditOptions>) => {
const next = { ...opts, ...patch };
setOpts(next);
saveEditOptions(next);
};
const selectedPipes = selectedLinkIds.filter((id) => model.pipes[id]);
const recalc = (ids?: string[]) => {
if (crs === 'LOCAL' && !window.confirm('좌표계가 "로컬 평면"이라 좌표 거리가 실제 길이가 아닙니다. 그래도 좌표 거리로 관로 연장을 바꿀까요? (실행 취소로 되돌릴 수 있습니다)')) return;
const { model: next, changes, isPhysical } = recalcPipeLengths(model, crs, ids);
const total = changes.reduce((a, c) => a + Math.abs(c.after - c.before), 0);
onApplyModel(
next,
changes.length
? `관로 ${changes.length}개 연장을 좌표로 다시 계산했습니다 (변화 합계 ${units.length(total).toFixed(1)} ${displayUnits.length})${isPhysical ? '' : ' - 로컬 좌표 기준'}.`
: '좌표와 다른 관로 연장이 없습니다.'
);
};
const fillElev = () => {
const { model: next, filled } = fillZeroElevations(model);
onApplyModel(next, filled.length ? `표고가 0인 절점 ${filled.length}개에 가장 가까운 절점의 표고를 넣었습니다.` : '표고가 0인 절점이 없습니다.');
};
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={box}>
<b>편집 옵션</b>
<label style={{ display: 'flex', gap: 6 }}>
<input type="checkbox" checked={opts.autoLength} onChange={(e) => setOpt({ autoLength: e.target.checked })} />
관로길이 자동계산 (절점을 옮기면 연결 관로 연장을 다시 계산, 로컬 좌표계에서는 적용 안 함)
</label>
<label style={{ display: 'flex', gap: 6 }}>
<input type="checkbox" checked={opts.nearestElevation} onChange={(e) => setOpt({ nearestElevation: e.target.checked })} />
가장 가까운 절점의 표고로 입력 (새 절점)
</label>
{(opts.autoLength !== DEFAULT_EDIT_OPTIONS.autoLength || opts.nearestElevation !== DEFAULT_EDIT_OPTIONS.nearestElevation) && (
<span style={{ color: 'var(--text-muted)' }}>기본값과 다르게 설정되어 있습니다.</span>
)}
</div>
<div style={box}>
<b>관로길이 재계산</b>
<span style={{ color: 'var(--text-secondary)' }}>절점 좌표와 꺾임점([VERTICES])을 따라 연장을 다시 잽니다. 좌표계가 맞아야 실제 거리가 됩니다.</span>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary" onClick={() => recalc()} style={{ height: 26 }}>전체 관로</button>
<button className="btn btn-secondary" disabled={!selectedPipes.length} onClick={() => recalc(selectedPipes)} style={{ height: 26 }}>선택 관로 ({selectedPipes.length})</button>
</div>
</div>
<div style={box}>
<b>표고 보완</b>
<span style={{ color: 'var(--text-secondary)' }}>표고가 0인 절점·탱크에 가장 가까운 절점의 표고를 넣습니다. DEM을 불러왔다면 DEM 표고 입력이 더 정확합니다.</span>
<button className="btn btn-secondary" onClick={fillElev} style={{ height: 26, alignSelf: 'flex-start' }}>표고 0인 절점 채우기</button>
</div>
<LabelsBox model={model} selectedNodeIds={selectedNodeIds} onApplyModel={onApplyModel} box={box} />
<div style={{ color: 'var(--text-muted)' }}>모든 도구는 실행 취소(Ctrl+Z)할 수 있습니다.</div>
</div>
);
};
/** 지도 라벨 ([LABELS]): text objects saved in the INP and drawn on the map. */
const LabelsBox: React.FC<{ model: NetworkModel; selectedNodeIds: string[]; onApplyModel: AnalysisPanelProps['onApplyModel']; box: React.CSSProperties }> = ({ model, selectedNodeIds, onApplyModel, box }) => {
const labels = model.extras?.labels ?? [];
const [text, setText] = useState('');
const anchorNode = selectedNodeIds.map((id) => model.nodes[id]).find(Boolean);
const add = () => {
if (!anchorNode || !text.trim()) return;
onApplyModel(setLabels(model, [...labels, { x: anchorNode.x, y: anchorNode.y, text: text.trim() }]), `지도 라벨 "${text.trim()}"을(를) 절점 ${anchorNode.id} 위치에 추가했습니다.`);
setText('');
};
return (
<div style={box}>
<b>지도 라벨 ({labels.length})</b>
<span style={{ color: 'var(--text-secondary)' }}>INP의 [LABELS]로 저장되어 EPANET에서도 보이는 글자입니다. 절점을 하나 선택하고 그 위치에 추가합니다.</span>
<div style={{ display: 'flex', gap: 6 }}>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={anchorNode ? `${anchorNode.id} 위치에 넣을 글자` : '먼저 지도에서 절점 선택'} style={{ flex: 1, height: 26 }} onKeyDown={(e) => { if (e.key === 'Enter') add(); }} />
<button className="btn btn-secondary" disabled={!anchorNode || !text.trim()} onClick={add} style={{ height: 26 }}>추가</button>
</div>
{labels.length > 0 && (
<div style={{ maxHeight: 160, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
{labels.map((l, i) => (
<div key={i} style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input key={`${i}:${l.text}`} defaultValue={l.text} style={{ flex: 1, height: 22, fontSize: 11 }} aria-label={`라벨 ${i + 1}`}
onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== l.text) onApplyModel(setLabels(model, labels.map((x, k) => (k === i ? { ...x, text: v } : x))), `지도 라벨을 "${v}"(으)로 바꿨습니다.`); }} />
<button className="btn btn-ghost" style={{ padding: 2 }} aria-label={`라벨 ${i + 1} 삭제`} onClick={() => onApplyModel(setLabels(model, labels.filter((_, k) => k !== i)), `지도 라벨 "${l.text}"을(를) 지웠습니다.`)}><X size={11} /></button>
</div>
))}
</div>
)}
</div>
);
};
+531
View File
@@ -0,0 +1,531 @@
// 분석 tab, part 2: 관로 중요도, 급수 구역(수원 추적), 흐름 방향, VSP, 등압선,
// 에너지 비용, 관로 통계. Server jobs go through runJob; results are cached in
// App state like the other analysis sections.
import React, { useEffect, useMemo, useState } from 'react';
import { Play, Crosshair, Download } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import { generateInp } from './inpModel';
import { runJob } from './api';
import { modelToLngLat } from './crs';
import type { Feature, FeatureCollection } from 'geojson';
import type { MapAnnotation, UserGisLayer } from './types';
import type { AnalysisPanelProps } from './AnalysisPanel';
import {
analyzeFlowDirection,
contourLevels,
idwGrid,
isolineSegments,
joinSegments,
niceInterval,
pipeGroupKey,
pipeReport,
type PipeGroupKey,
} from './analysisTools';
export interface AnalysisSectionsExtraProps {
onAddUserLayer?: (layer: UserGisLayer) => void;
onAnnotations?: (annotations: MapAnnotation[] | null) => void;
}
type Props = AnalysisPanelProps & AnalysisSectionsExtraProps;
const th: React.CSSProperties = { padding: '4px 5px', textAlign: 'right', fontWeight: 600, background: 'var(--bg-subtle)', position: 'sticky', top: 0 };
const td: React.CSSProperties = { padding: '3px 5px', textAlign: 'right', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-mono)' };
const tdl: React.CSSProperties = { ...td, textAlign: 'left' };
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
const col: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' };
const note: React.CSSProperties = { color: 'var(--text-secondary)', lineHeight: 1.5 };
const fmt = (v: number | null | undefined, d = 2) => (v === null || v === undefined || !Number.isFinite(v) ? '-' : v.toFixed(d));
const PALETTE = ['#2563eb', '#dc2626', '#16a34a', '#9333ea', '#ea580c', '#0891b2', '#ca8a04', '#db2777'];
function hoursOf(model: NetworkModel, key: string, fallback: number): number {
const raw = model.options.timeEntries.find((e) => e.key.toUpperCase() === key)?.value?.trim();
if (!raw) return fallback;
const [num, unit = ''] = raw.split(/\s+/);
if (num.includes(':')) {
const [h, m = '0', s = '0'] = num.split(':');
return Number(h) + Number(m) / 60 + Number(s) / 3600;
}
const v = Number(num);
if (!Number.isFinite(v)) return fallback;
const u = unit.toUpperCase();
return u.startsWith('MIN') ? v / 60 : u.startsWith('SEC') ? v / 3600 : u.startsWith('DAY') ? v * 24 : v;
}
function downloadText(name: string, text: string, type = 'text/csv;charset=utf-8') {
const blob = new Blob([type.startsWith('text/csv') ? '' + text : text], { type });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
function useJob<T>(cache: Props['cache'], setCache: Props['setCache'], key: string) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async (name: string, model: NetworkModel, params: Record<string, unknown>) => {
setError(null);
setBusy(true);
try {
setCache(key, await runJob<T>(name, generateInp(model), params));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return { busy, error, setError, run, result: cache[key] as T | undefined };
}
// ---------------------------------------------------------------- 관로 중요도
interface CriticalityResult {
windowLabel: string;
threshold: number;
belowBefore: number;
totalDemand: number;
links: Array<{ id: string; affectedNodes: number; affectedDemand: number; minPressure: number | null; maxDrop: number | null; nodes: string[]; warning?: boolean; disconnected?: boolean; error?: string }>;
}
export const CriticalitySection: React.FC<Props> = ({ model, units, displayUnits, selectedLinkIds, minPressureThreshold, cache, setCache, onFocus, onSelectElements }) => {
const job = useJob<CriticalityResult>(cache, setCache, 'critResult');
const selectedPipes = selectedLinkIds.filter((id) => model.pipes[id]);
const [scope, setScope] = useState<'all' | 'selected'>(selectedPipes.length ? 'selected' : 'all');
const [threshold, setThreshold] = useState(String(minPressureThreshold));
const [start, setStart] = useState('0');
const [end, setEnd] = useState(String(hoursOf(model, 'DURATION', 24) || 24));
const r = job.result;
const p = units.pressure;
const flow = units.flow;
const count = scope === 'selected' ? selectedPipes.length : Object.keys(model.pipes).length;
const exportCsv = () => {
if (!r) return;
const lines = ['관로,영향 절점 수,영향 수요,최저 압력,최대 하락,단절,영향 절점'];
for (const l of r.links) lines.push([l.id, l.affectedNodes, flow(l.affectedDemand), l.disconnected ? '' : fmt(l.minPressure === null ? null : p(l.minPressure)), fmt(l.maxDrop === null ? null : p(l.maxDrop)), l.disconnected ? 'Y' : '', `"${l.nodes.join(' ')}"`].join(','));
downloadText('관로중요도.csv', lines.join('\r\n'));
};
return (
<div style={col}>
<div style={note}>
관로를 하나씩 닫고 다시 해석해, 최저압력 기준 아래로 떨어지는 절점과 그 수요량으로 관로의 중요도를 매깁니다. 관로 수만큼 해석하므로 큰 관망은 수십 초 걸립니다.
</div>
<div style={{ display: 'flex', gap: 10 }}>
<label><input type="radio" checked={scope === 'all'} onChange={() => setScope('all')} /> 전체 관로 ({Object.keys(model.pipes).length})</label>
<label><input type="radio" checked={scope === 'selected'} disabled={!selectedPipes.length} onChange={() => setScope('selected')} /> 선택 관로 ({selectedPipes.length})</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
<label>최저압력 기준 ({displayUnits.pressure})<input type="number" value={threshold} onChange={(e) => setThreshold(e.target.value)} style={inputStyle} /></label>
<label>시작 (시)<input type="number" value={start} onChange={(e) => setStart(e.target.value)} style={inputStyle} /></label>
<label>종료 (시)<input type="number" value={end} onChange={(e) => setEnd(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-primary" style={{ height: 28 }} disabled={job.busy || !count}
onClick={() => void job.run('criticality', model, { links: scope === 'selected' ? selectedPipes : [], threshold: units.toPressure(Number(threshold)), start: Number(start), end: Number(end) })}>
<Play size={12} />{job.busy ? `해석 중... (관로 ${count}개)` : `관로 중요도 분석 (${count}개)`}
</button>
{job.error && <div style={{ color: 'var(--crit)' }}>{job.error}</div>}
{r && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ ...note, flex: 1 }}>
시간대 {r.windowLabel} · 기준 {fmt(p(r.threshold), 1)} {displayUnits.pressure} · 평상시 미달 {r.belowBefore}곳 · 영향 있는 관로 <b>{r.links.filter((l) => l.affectedNodes > 0).length}</b>/{r.links.length}
</span>
<button className="btn btn-secondary" style={{ height: 22 }} onClick={exportCsv}><Download size={11} />CSV</button>
</div>
<div style={{ maxHeight: 360, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>관로</th><th style={th}>영향 절점</th><th style={th}>영향 수요 ({displayUnits.flow})</th><th style={th}>최저 압력</th><th style={th}>최대 하락</th></tr></thead>
<tbody>
{r.links.slice(0, 300).map((l) => (
<tr key={l.id} style={{ cursor: 'pointer' }} title="누르면 이 관로와 영향 절점을 선택합니다"
onClick={() => { onFocus('link', l.id); onSelectElements(l.nodes, [l.id]); }}>
<td style={tdl}>{l.id}</td>
<td style={td}>{l.affectedNodes}</td>
<td style={td}>{fmt(flow(l.affectedDemand), 1)}</td>
<td style={{ ...td, color: l.disconnected ? 'var(--crit)' : undefined }}>{l.error ? '오류' : l.disconnected ? '단절' : fmt(l.minPressure === null ? null : p(l.minPressure), 1)}</td>
<td style={td}>{l.disconnected ? '-' : fmt(l.maxDrop === null ? null : p(l.maxDrop), 1)}</td>
</tr>
))}
</tbody>
</table>
</div>
<span style={{ color: 'var(--text-muted)' }}>"단절"은 관로를 닫으면 물이 가지 못하는 절점이 생긴다는 뜻입니다. 행을 누르면 영향 절점이 지도에서 선택됩니다.</span>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 급수 구역
interface ServiceAreaResult {
sources: string[];
windowLabel: string;
nodes: Record<string, { source: string | null; share: number; shares: Record<string, number> }>;
summary: Array<{ source: string; nodes: number; mixed: number }>;
unreached: number;
notes: string[];
}
export const ServiceAreaSection: React.FC<Props> = ({ model, cache, setCache, onSelectElements, onAnnotations }) => {
const job = useJob<ServiceAreaResult>(cache, setCache, 'saResult');
const [includeTanks, setIncludeTanks] = useState(false);
const [showMap, setShowMap] = useState(false);
const r = job.result;
const color = (s: string | null) => (s && r ? PALETTE[r.sources.indexOf(s) % PALETTE.length] : '#64748b');
const toggleMap = (on: boolean) => {
setShowMap(on);
if (!onAnnotations) return;
if (!on || !r) return onAnnotations(null);
onAnnotations(Object.entries(r.nodes).filter(([id, v]) => v.source && model.nodes[id]?.type === 'JUNCTION').map(([id, v]) => ({ type: 'node' as const, id, text: `${v.source} ${v.share.toFixed(0)}%`, color: color(v.source) })));
};
const nodesOf = (s: string | null) => (r ? Object.entries(r.nodes).filter(([, v]) => v.source === s).map(([id]) => id) : []);
// Map labels belong to this view: clear them when leaving it.
useEffect(() => () => onAnnotations?.(null), [onAnnotations]);
return (
<div style={col}>
<div style={note}>
EPANET 수원 추적(TRACE)으로 각 절점의 물이 어느 배수지에서 몇 % 오는지 계산해 급수 구역을 나눕니다. 추적은 72시간 이상 돌려 마지막 24시간을 평균합니다.
</div>
<label><input type="checkbox" checked={includeTanks} onChange={(e) => setIncludeTanks(e.target.checked)} /> 탱크도 수원으로 추적 (탱크를 거친 물이 배수지 몫과 겹쳐 합계가 100%를 넘을 수 있음)</label>
<button className="btn btn-primary" style={{ height: 28 }} disabled={job.busy} onClick={() => { toggleMap(false); void job.run('servicearea', model, { includeTanks }); }}>
<Play size={12} />{job.busy ? '추적 중...' : '급수 구역 분석'}
</button>
{job.error && <div style={{ color: 'var(--crit)' }}>{job.error}</div>}
{r && (
<>
{r.notes.map((n) => <div key={n} style={{ color: 'var(--text-muted)' }}>{n}</div>)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>수원</th><th style={th}>주 급수 절점</th><th style={th}>혼합(80% 미만)</th><th style={th} /></tr></thead>
<tbody>
{r.summary.map((s) => (
<tr key={s.source}>
<td style={tdl}><span style={{ color: color(s.source) }}>●</span> {s.source}</td>
<td style={td}>{s.nodes}</td>
<td style={td}>{s.mixed}</td>
<td style={td}><button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => onSelectElements(nodesOf(s.source), [])}><Crosshair size={10} />선택</button></td>
</tr>
))}
{r.unreached > 0 && (
<tr>
<td style={tdl}>도달 안 됨</td><td style={td}>{r.unreached}</td><td style={td} />
<td style={td}><button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => onSelectElements(nodesOf(null), [])}><Crosshair size={10} />선택</button></td>
</tr>
)}
</tbody>
</table>
{onAnnotations && <label><input type="checkbox" checked={showMap} onChange={(e) => toggleMap(e.target.checked)} /> 지도에 절점별 주 수원·비율 표시</label>}
{r.unreached > 0 && <span style={{ color: 'var(--text-muted)' }}>"도달 안 됨"은 평균 0.5% 미만으로, 탱크에서만 공급받거나 물이 거의 흐르지 않는 절점입니다. 탱크 포함으로 다시 확인하세요.</span>}
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 흐름 방향
export const FlowDirectionSection: React.FC<Props> = ({ result, units, displayUnits, onFocus, onSelectElements }) => {
const rows = useMemo(() => (result ? analyzeFlowDirection(result) : []), [result]);
if (!result) return <div style={note}>수리해석을 먼저 실행하세요.</div>;
const reversing = rows.filter((r) => r.reversals > 0);
const noFlow = rows.filter((r) => r.dominant === 0);
return (
<div style={col}>
<div style={note}>해석 기간 동안 흐름 방향이 바뀌는 관로(역류)와 흐름이 전혀 없는 관로를 찾습니다. 탁수·체류 관리와 밸브 조작 검토에 씁니다.</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span style={{ flex: 1 }}>방향이 바뀌는 관로 <b>{reversing.length}</b>개 · 흐름 없음 {noFlow.length}개</span>
<button className="btn btn-secondary" style={{ height: 22 }} disabled={!reversing.length} onClick={() => onSelectElements([], reversing.map((r) => r.id))}><Crosshair size={11} />역류 관로 선택</button>
<button className="btn btn-secondary" style={{ height: 22 }} disabled={!noFlow.length} onClick={() => onSelectElements([], noFlow.map((r) => r.id))}><Crosshair size={11} />무흐름 관로 선택</button>
</div>
{reversing.length > 0 && (
<div style={{ maxHeight: 360, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>관로</th><th style={th}>방향 전환 횟수</th><th style={th}>역방향 시간 비율</th><th style={th}>최대 유량 ({displayUnits.flow})</th></tr></thead>
<tbody>
{reversing.slice(0, 500).map((r) => (
<tr key={r.id} style={{ cursor: 'pointer' }} onClick={() => onFocus('link', r.id)}>
<td style={tdl}>{r.id}</td><td style={td}>{r.reversals}</td><td style={td}>{fmt(r.reversedShare, 0)}%</td><td style={td}>{fmt(units.flow(r.maxAbsFlow), 2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
// ---------------------------------------------------------------- VSP
interface VspResult {
pump: string;
node: string;
target: number;
rows: Array<{ hour: number; speed?: number; pressure?: number; flow?: number; power?: number; status?: 'ok' | 'max' | 'min'; error?: string }>;
note: string;
}
export const VspSection: React.FC<Props> = ({ model, units, displayUnits, selectedNodeIds, cache, setCache, onApplyModel }) => {
const job = useJob<VspResult>(cache, setCache, 'vspResult');
const pumps = Object.keys(model.pumps);
const [pump, setPump] = useState(pumps[0] ?? '');
const [node, setNode] = useState('');
const [target, setTarget] = useState('20');
const [minS, setMinS] = useState('0.5');
const [maxS, setMaxS] = useState('1.2');
const r = job.result;
const patternStepH = hoursOf(model, 'PATTERN TIMESTEP', 1);
const durationH = hoursOf(model, 'DURATION', 24);
if (!pumps.length) return <div style={note}>모델에 펌프가 없습니다.</div>;
const apply = () => {
if (!r) return;
const speeds = r.rows.map((row) => row.speed ?? 1);
const pid = `VSP_${r.pump}`.slice(0, 31);
const pumpRec = model.pumps[r.pump];
if (!pumpRec) return;
const params = pumpRec.parameters.replace(/\s+PATTERN\s+\S+/i, '').trim() + ` PATTERN ${pid}`;
onApplyModel(
{ ...model, patterns: { ...model.patterns, [pid]: { id: pid, description: `VSP ${r.node} 목표압력 ${r.target}`, multipliers: speeds } }, pumps: { ...model.pumps, [r.pump]: { ...pumpRec, parameters: params } } },
`펌프 ${r.pump}에 시간별 속도 패턴 ${pid}(${speeds.length}개)를 적용했습니다.`
);
};
return (
<div style={col}>
<div style={note}>제어 절점이 목표 압력이 되도록 시간별 펌프 회전수(상대 속도)를 이분법으로 찾습니다. 시간마다 정상상태로 풀며 탱크는 초기 수위입니다.</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label>펌프<select value={pump} onChange={(e) => setPump(e.target.value)} style={inputStyle}>{pumps.map((p) => <option key={p} value={p}>{p}</option>)}</select></label>
<label>제어 절점
<div style={{ display: 'flex', gap: 4 }}>
<input value={node} onChange={(e) => setNode(e.target.value)} placeholder="절점 ID" style={inputStyle} />
<button className="btn btn-ghost" style={{ height: 24, padding: '0 4px' }} disabled={!selectedNodeIds.length} onClick={() => setNode(selectedNodeIds[0])} title="선택 절점"><Crosshair size={11} /></button>
</div>
</label>
<label>목표 압력 ({displayUnits.pressure})<input type="number" value={target} onChange={(e) => setTarget(e.target.value)} style={inputStyle} /></label>
<label>속도 범위 (최소~최대)
<div style={{ display: 'flex', gap: 4 }}>
<input type="number" step="0.05" value={minS} onChange={(e) => setMinS(e.target.value)} style={inputStyle} />
<input type="number" step="0.05" value={maxS} onChange={(e) => setMaxS(e.target.value)} style={inputStyle} />
</div>
</label>
</div>
<button className="btn btn-primary" style={{ height: 28 }} disabled={job.busy || !node.trim() || !model.nodes[node.trim()]}
onClick={() => void job.run('vsp', model, {
pump, node: node.trim(), target: units.toPressure(Number(target)), minSpeed: Number(minS), maxSpeed: Number(maxS),
hours: Array.from({ length: Math.max(1, Math.round((durationH || 24) / patternStepH)) }, (_, k) => k * patternStepH),
})}>
<Play size={12} />{job.busy ? '계산 중...' : 'VSP 속도 계산'}
</button>
{job.error && <div style={{ color: 'var(--crit)' }}>{job.error}</div>}
{r && (
<>
<div style={{ maxHeight: 320, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>시각</th><th style={th}>속도</th><th style={th}>압력</th><th style={th}>유량</th><th style={th}>전력 (kW)</th><th style={th}>상태</th></tr></thead>
<tbody>
{r.rows.map((row) => (
<tr key={row.hour}>
<td style={tdl}>{Math.floor(row.hour)}:{String(Math.round((row.hour % 1) * 60)).padStart(2, '0')}</td>
<td style={td}>{fmt(row.speed, 3)}</td>
<td style={td}>{row.pressure !== undefined ? fmt(units.pressure(row.pressure), 1) : '-'}</td>
<td style={td}>{row.flow !== undefined ? fmt(units.flow(row.flow), 1) : '-'}</td>
<td style={td}>{fmt(row.power, 1)}</td>
<td style={{ ...td, color: row.status === 'ok' ? undefined : 'var(--warn, #b45309)' }}>{row.error ? '오류' : row.status === 'max' ? '최대에도 부족' : row.status === 'min' ? '최소에도 초과' : '목표 달성'}</td>
</tr>
))}
</tbody>
</table>
</div>
<span style={{ color: 'var(--text-muted)' }}>{r.note}</span>
<button className="btn btn-secondary" style={{ height: 26, alignSelf: 'flex-start' }} onClick={apply} title="속도들을 패턴으로 만들어 펌프의 PATTERN으로 지정합니다 (실행 취소 가능)">
속도 패턴으로 펌프에 적용
</button>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 등압선
type ContourVar = 'pressure' | 'head' | 'elevation';
type ContourStat = 'min' | 'mean' | 'max' | 'now';
export const ContourSection: React.FC<Props> = ({ model, result, crs, units, displayUnits, onAddUserLayer }) => {
const [variable, setVariable] = useState<ContourVar>('pressure');
const [stat, setStat] = useState<ContourStat>('min');
const [interval, setStep] = useState('');
const [made, setMade] = useState<{ fc: FeatureCollection; levels: number; name: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const conv = variable === 'pressure' ? units.pressure : units.head;
const unit = variable === 'pressure' ? displayUnits.pressure : displayUnits.head;
const points = useMemo(() => {
const out: Array<{ x: number; y: number; v: number }> = [];
for (const n of Object.values(model.nodes)) {
if (n.type !== 'JUNCTION') continue;
let v: number | undefined;
if (variable === 'elevation') v = n.elevation;
else {
const nr = result?.nodes[n.id];
if (!nr) continue;
const s = nr.timeSeries?.[variable];
if (stat === 'now' || !s?.length) v = nr[variable];
else v = stat === 'min' ? Math.min(...s) : stat === 'max' ? Math.max(...s) : s.reduce((a, b) => a + b, 0) / s.length;
}
if (v !== undefined && Number.isFinite(v)) out.push({ x: n.x, y: n.y, v: conv(v) });
}
return out;
}, [model, result, variable, stat, conv]);
const range = useMemo(() => {
let lo = Infinity, hi = -Infinity;
for (const p of points) { lo = Math.min(lo, p.v); hi = Math.max(hi, p.v); }
return [lo, hi] as const;
}, [points]);
const make = () => {
setError(null);
try {
const step = Number(interval) > 0 ? Number(interval) : niceInterval(range[0], range[1]);
const grid = idwGrid(points, 140);
const ext = (() => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(model.nodes)) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); }
return { minX, minY, maxX, maxY };
})();
const features: Feature[] = [];
const levels = contourLevels(range[0], range[1], step);
for (const level of levels) {
for (const line of joinSegments(isolineSegments(grid, level))) {
if (line.length < 2) continue;
features.push({ type: 'Feature', properties: { level, label: `${level} ${unit}` }, geometry: { type: 'LineString', coordinates: line.map(([x, y]) => modelToLngLat(x, y, crs, ext)) } });
}
}
if (!features.length) throw new Error('등치선이 만들어지지 않았습니다. 간격을 줄여 보세요.');
const name = `등치선 ${variable === 'pressure' ? '압력' : variable === 'head' ? '동수두' : '표고'}${variable === 'elevation' ? '' : ` (${{ min: '최소', mean: '평균', max: '최대', now: '현재' }[stat]})`} ${step}${unit}`;
setMade({ fc: { type: 'FeatureCollection', features }, levels: levels.length, name });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div style={col}>
<div style={note}>절점 값을 역거리 가중(IDW)으로 격자 보간해 등치선을 만들고, GIS 레이어로 지도에 올리거나 GeoJSON으로 저장합니다. 관망에서 먼 빈 곳은 그리지 않습니다.</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
<label>항목<select value={variable} onChange={(e) => { setVariable(e.target.value as ContourVar); setMade(null); }} style={inputStyle}>
<option value="pressure">압력</option><option value="head">동수두</option><option value="elevation">표고</option>
</select></label>
<label>시간 통계<select value={stat} disabled={variable === 'elevation'} onChange={(e) => { setStat(e.target.value as ContourStat); setMade(null); }} style={inputStyle}>
<option value="min">최소</option><option value="mean">평균</option><option value="max">최대</option><option value="now">기본 결과값</option>
</select></label>
<label>간격 ({unit})<input type="number" value={interval} onChange={(e) => setStep(e.target.value)} placeholder={Number.isFinite(range[0]) ? String(niceInterval(range[0], range[1])) : '자동'} style={inputStyle} /></label>
</div>
<div style={{ color: 'var(--text-muted)' }}>값 {points.length}개 · 범위 {fmt(range[0], 1)} ~ {fmt(range[1], 1)} {unit}{variable !== 'elevation' && !result ? ' · 수리해석 결과가 필요합니다' : ''}</div>
<button className="btn btn-primary" style={{ height: 28 }} disabled={points.length < 3} onClick={make}><Play size={12} />등치선 만들기</button>
{error && <div style={{ color: 'var(--crit)' }}>{error}</div>}
{made && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ flex: 1 }}>{made.name}: 선 {made.fc.features.length}개 · 등급 {made.levels}개</span>
{onAddUserLayer && (
<button className="btn btn-secondary" style={{ height: 24 }} onClick={() => onAddUserLayer({
id: `contour-${Date.now()}`, name: made.name, fileName: `${made.name}.geojson`, featureCollection: made.fc, featureCount: made.fc.features.length,
geomTypes: ['LineString'], estimatedCrs: 'EPSG:4326', visible: true, opacity: 0.9, color: '#7c3aed', createdAt: new Date().toISOString(),
})}>지도 레이어로 추가</button>
)}
<button className="btn btn-secondary" style={{ height: 24 }} onClick={() => downloadText(`${made.name}.geojson`, JSON.stringify(made.fc), 'application/geo+json')}><Download size={11} />GeoJSON</button>
</div>
)}
</div>
);
};
// ---------------------------------------------------------------- 에너지 비용
interface EnergyResult {
pumps: Array<{ id: string; utilization: number; efficiency: number | null; kwh: number; kwhPerM3: number | null; volume: number; avgKw: number; peakKw: number; cost: number; costPerDay: number }>;
hours: number;
globalPrice: number;
demandCharge: number;
peakKw: number;
demandCost: number;
totalCost: number;
steady: boolean;
}
export const EnergySection: React.FC<Props> = ({ model, cache, setCache }) => {
const job = useJob<EnergyResult>(cache, setCache, 'energyResult');
const r = job.result;
if (!Object.keys(model.pumps).length) return <div style={note}>모델에 펌프가 없습니다.</div>;
const exportCsv = () => {
if (!r) return;
const lines = ['펌프,가동률(%),평균효율(%),전력량(kWh),원단위(kWh/m3),양수량(m3),평균kW,최대kW,전력비,일평균 전력비'];
for (const p of r.pumps) lines.push([p.id, p.utilization, p.efficiency ?? '', p.kwh, p.kwhPerM3 ?? '', p.volume, p.avgKw, p.peakKw, p.cost, p.costPerDay].join(','));
lines.push(`최대 수요 요금,,,,,,,${r.peakKw},${r.demandCost},`, `합계,,,,,,,,${r.totalCost},`);
downloadText('에너지보고서.csv', lines.join('\r\n'));
};
return (
<div style={col}>
<div style={note}>펌프별 가동률·효율·전력량과 원단위(kWh/m³), [ENERGY]의 단가·단가 패턴·최대 수요 요금을 반영한 전력비를 해석의 모든 시간 간격에 걸쳐 적분합니다. 단가는 모델 설정의 "에너지·수질 반응 옵션"과 펌프의 "추가 속성"에서 넣습니다.</div>
<button className="btn btn-primary" style={{ height: 28 }} disabled={job.busy} onClick={() => void job.run('energy', model, {})}><Play size={12} />{job.busy ? '계산 중...' : '에너지 보고서 계산'}</button>
{job.error && <div style={{ color: 'var(--crit)' }}>{job.error}</div>}
{r && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ ...note, flex: 1 }}>기간 {fmt(r.hours, 1)}시간{r.steady ? ' (정상상태: 1시간 기준)' : ''} · 전역 단가 {r.globalPrice} · 최대 동시 전력 {fmt(r.peakKw, 1)} kW · 총 전력비 <b>{fmt(r.totalCost, 2)}</b>{r.demandCharge ? ` (수요 요금 ${fmt(r.demandCost, 2)} 포함)` : ''}</span>
<button className="btn btn-secondary" style={{ height: 22 }} onClick={exportCsv}><Download size={11} />CSV</button>
</div>
<div style={{ overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>{['펌프', '가동률 %', '효율 %', 'kWh', 'kWh/m³', '평균 kW', '최대 kW', '전력비', '일 전력비'].map((h, i) => <th key={h} style={i ? th : { ...th, textAlign: 'left' }}>{h}</th>)}</tr></thead>
<tbody>
{r.pumps.map((p) => (
<tr key={p.id}>
<td style={tdl}>{p.id}</td><td style={td}>{fmt(p.utilization, 1)}</td><td style={td}>{fmt(p.efficiency, 1)}</td><td style={td}>{fmt(p.kwh, 1)}</td>
<td style={td}>{fmt(p.kwhPerM3, 3)}</td><td style={td}>{fmt(p.avgKw, 1)}</td><td style={td}>{fmt(p.peakKw, 1)}</td><td style={td}>{fmt(p.cost, 2)}</td><td style={td}>{fmt(p.costPerDay, 2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 관로 통계
const GROUP_LABEL: Record<PipeGroupKey, string> = { diameter: '관경', material: '관재질', installDecade: '설치 연대', block: '소블록', pipeKind: '관로 구분', roughness: '조도계수' };
export const PipeReportSection: React.FC<Props> = ({ model, result, units, displayUnits, onSelectElements }) => {
const [by, setBy] = useState<PipeGroupKey>('diameter');
const rep = useMemo(() => pipeReport(model, by, result), [model, by, result]);
const keyLabel = (k: string) => (by === 'diameter' && k !== '(미입력)' ? `${fmt(units.diameter(Number(k)), 0)} ${displayUnits.diameter}` : k);
const idsOf = (k: string) => Object.keys(model.pipes).filter((id) => pipeGroupKey(model, id, by) === k);
const exportCsv = () => {
const lines = [`${GROUP_LABEL[by]},관로 수,연장(${displayUnits.length}),비율(%),최대 유속(${displayUnits.velocity}),평균 경과년수`];
for (const r of rep.rows) lines.push([keyLabel(r.key), r.count, fmt(units.length(r.length), 1), fmt(r.share, 1), r.maxVelocity !== undefined ? fmt(units.velocity(r.maxVelocity), 2) : '', r.meanAge !== undefined ? fmt(r.meanAge, 1) : ''].join(','));
downloadText(`관로통계_${GROUP_LABEL[by]}.csv`, lines.join('\r\n'));
};
return (
<div style={col}>
<div style={note}>관로를 관경·재질·설치 연대·소블록별로 묶어 개수·연장·비율과 최대 유속, 평균 경과년수를 봅니다. 재질·설치년도·소블록은 자산 속성(속성 테이블)에서 옵니다.</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ flex: 1 }}>구분<select value={by} onChange={(e) => setBy(e.target.value as PipeGroupKey)} style={inputStyle}>
{(Object.keys(GROUP_LABEL) as PipeGroupKey[]).map((k) => <option key={k} value={k}>{GROUP_LABEL[k]}</option>)}
</select></label>
<button className="btn btn-secondary" style={{ height: 24, alignSelf: 'flex-end' }} onClick={exportCsv}><Download size={11} />CSV</button>
</div>
<div style={{ color: 'var(--text-secondary)' }}>관로 {rep.count}개 · 총 연장 {fmt(units.length(rep.totalLength), 1)} {displayUnits.length}</div>
<div style={{ maxHeight: 380, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>{GROUP_LABEL[by]}</th><th style={th}>개수</th><th style={th}>연장 ({displayUnits.length})</th><th style={th}>비율</th><th style={th}>최대 유속</th><th style={th}>경과년수</th></tr></thead>
<tbody>
{rep.rows.map((r) => (
<tr key={r.key} style={{ cursor: 'pointer' }} title="누르면 이 그룹의 관로를 선택합니다" onClick={() => onSelectElements([], idsOf(r.key))}>
<td style={tdl}>{keyLabel(r.key)}</td><td style={td}>{r.count}</td><td style={td}>{fmt(units.length(r.length), 1)}</td><td style={td}>{fmt(r.share, 1)}%</td>
<td style={td}>{r.maxVelocity !== undefined ? fmt(units.velocity(r.maxVelocity), 2) : '-'}</td><td style={td}>{r.meanAge !== undefined ? fmt(r.meanAge, 1) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
+2194
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
// 백업 기록: list the automatic/manual backups, restore one (undoable) or download it.
import React, { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { deleteBackup, listBackups, MAX_BACKUPS, type BackupRecord } from './backupStore';
interface Props {
onRestore: (rec: BackupRecord) => void;
onBackupNow: () => Promise<void>;
onClose: () => void;
}
export const BackupModal: React.FC<Props> = ({ onRestore, onBackupNow, onClose }) => {
const [items, setItems] = useState<BackupRecord[] | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = () => listBackups().then(setItems).catch((e) => setError(e instanceof Error ? e.message : String(e)));
useEffect(() => { void reload(); }, []);
const download = (b: BackupRecord) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([b.inp], { type: 'text/plain;charset=utf-8' }));
a.download = `${b.fileName.replace(/\.[^.]+$/, '')}_백업_${b.savedAt.slice(0, 16).replace(/[-:T]/g, '')}.inp`;
a.click();
URL.revokeObjectURL(a.href);
};
return (
<div role="dialog" aria-modal="true" aria-label="백업 기록" style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,.35)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 'min(640px, 96vw)', maxHeight: '86vh', background: 'var(--bg-panel)', borderRadius: 8, boxShadow: '0 12px 40px rgba(0,0,0,.25)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
<b style={{ flex: 1 }}>백업 기록</b>
<button className="btn btn-ghost" onClick={onClose} aria-label="닫기"><X size={14} /></button>
</div>
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10, fontSize: 12, overflow: 'auto' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ flex: 1, color: 'var(--text-secondary)' }}>
모델이 수정된 상태면 10분마다 이 브라우저에 자동으로 백업합니다 (최근 {MAX_BACKUPS}개). 복원은 실행 취소(Ctrl+Z)로 되돌릴 수 있습니다.
</span>
<button className="btn btn-secondary" style={{ height: 26 }} onClick={() => void onBackupNow().then(reload)}>지금 백업</button>
</div>
{error && <div style={{ color: 'var(--crit)' }}>{error}</div>}
{items && !items.length && <div style={{ color: 'var(--text-muted)' }}>백업이 아직 없습니다.</div>}
{items && items.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>{['시각', '파일', '규모', '', ''].map((h, i) => <th key={i} style={{ textAlign: 'left', padding: '4px 6px', background: 'var(--bg-subtle)' }}>{h}</th>)}</tr></thead>
<tbody>
{items.map((b) => (
<tr key={b.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '4px 6px' }}>{new Date(b.savedAt).toLocaleString('ko-KR')}{b.reason === 'manual' ? ' (수동)' : ''}</td>
<td style={{ padding: '4px 6px' }}>{b.fileName}</td>
<td style={{ padding: '4px 6px' }}>절점 {b.nodes} · 링크 {b.links}</td>
<td style={{ padding: '4px 6px' }}>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11 }} onClick={() => { onRestore(b); onClose(); }}>복원</button>{' '}
<button className="btn btn-ghost" style={{ height: 22, fontSize: 11 }} onClick={() => download(b)}>.inp</button>
</td>
<td style={{ padding: '4px 6px' }}>
<button className="btn btn-ghost" style={{ height: 22, fontSize: 11 }} aria-label="백업 삭제" onClick={() => void deleteBackup(b.id).then(reload)}><X size={11} /></button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
};
+734
View File
@@ -0,0 +1,734 @@
import React, { useState } from 'react';
import {
Trash2,
Copy,
X,
Check,
Move,
Layers,
ArrowRight,
Sliders,
Maximize2,
} from 'lucide-react';
import type { NetworkModel, InpNode, InpPipe } from './inpModel';
import type { MultiSelectionState, SelectionTarget } from './types';
import {
m3DayToFlow,
metersToLength,
millimetersToDiameter,
type DisplayUnitMode,
} from './unitSystem';
export interface BatchPropertyEditorProps {
model: NetworkModel;
multiSelection: MultiSelectionState;
selectedTarget: SelectionTarget;
displayUnitMode?: DisplayUnitMode;
onUpdatePipes: (pipeIds: string[], updates: Partial<InpPipe>) => void;
onUpdateNodes: (nodeIds: string[], updater: (node: InpNode) => Partial<InpNode>) => void;
onMoveNodes: (nodeIds: string[], dx: number, dy: number) => void;
onDeleteSelected: () => void;
onClearSelection: () => void;
onSelectTarget: (target: SelectionTarget) => void;
onCopyClipboard?: () => void;
}
export const BatchPropertyEditor: React.FC<BatchPropertyEditorProps> = ({
model,
multiSelection,
selectedTarget,
displayUnitMode = 'korean',
onUpdatePipes,
onUpdateNodes,
onMoveNodes,
onDeleteSelected,
onClearSelection,
onSelectTarget,
onCopyClipboard,
}) => {
// Extract all currently selected node IDs and pipe IDs
const selectedNodeIds = Array.from(
new Set([
...multiSelection.nodes,
...(selectedTarget?.type === 'node' ? [selectedTarget.id] : []),
])
);
const selectedPipeIds = Array.from(
new Set([
...multiSelection.links,
...(selectedTarget?.type === 'link' ? [selectedTarget.id] : []),
])
);
// Pipe batch state
const [batchDiameter, setBatchDiameter] = useState<string>('');
const [batchRoughness, setBatchRoughness] = useState<string>('');
const [batchStatus, setBatchStatus] = useState<string>('OPEN');
const [batchMinorLoss, setBatchMinorLoss] = useState<string>('');
const [batchTag, setBatchTag] = useState<string>('');
const [batchDesc, setBatchDesc] = useState<string>('');
// Node batch state
const [batchElevMode, setBatchElevMode] = useState<'set' | 'offset'>('offset');
const [batchElevValue, setBatchElevValue] = useState<string>('');
const [batchDemandMode, setBatchDemandMode] = useState<'set' | 'mult'>('mult');
const [batchDemandValue, setBatchDemandValue] = useState<string>('1.0');
const [batchPattern, setBatchPattern] = useState<string>('');
// Move offset state
const [moveDx, setMoveDx] = useState<string>('0');
const [moveDy, setMoveDy] = useState<string>('0');
// Success flash messages
const [flashMsg, setFlashMsg] = useState<string | null>(null);
const notify = (msg: string) => {
setFlashMsg(msg);
setTimeout(() => setFlashMsg(null), 2500);
};
// 1. Pipe actions
// The batch editor's inputs are always entered in the fixed Korean/metric
// display units shown in its labels (mm / m / m³/day), regardless of the
// app-wide displayUnitMode toggle, so they always go through the display ->
// model conversion (never a raw pass-through).
const applyDiameter = (diamMm: number) => {
if (!Number.isFinite(diamMm) || diamMm <= 0) return;
const sourceUnits = model.options.units || 'LPS';
const internalDiam = millimetersToDiameter(diamMm, sourceUnits);
onUpdatePipes(selectedPipeIds, { diameter: internalDiam });
notify(`관경 D${diamMm} 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
const applyRoughness = (roughness: number) => {
if (!Number.isFinite(roughness) || roughness <= 0) return;
onUpdatePipes(selectedPipeIds, { roughness });
notify(`조도계수 C=${roughness} 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
const applyStatus = (status: 'OPEN' | 'CLOSED' | 'CV') => {
onUpdatePipes(selectedPipeIds, { status });
notify(`관로 상태 '${status}' 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
const applyMinorLoss = () => {
const val = parseFloat(batchMinorLoss);
if (!Number.isFinite(val) || val < 0) return;
onUpdatePipes(selectedPipeIds, { minorLoss: val });
notify(`미소손실계수 ${val} 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
const applyTagDesc = () => {
const updates: Partial<InpPipe> = {};
if (batchTag.trim()) updates.tag = batchTag.trim();
if (batchDesc.trim()) updates.description = batchDesc.trim();
if (Object.keys(updates).length === 0) return;
onUpdatePipes(selectedPipeIds, updates);
notify(`태그/설명 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
// 2. Node actions
const applyElevation = () => {
const val = parseFloat(batchElevValue);
if (!Number.isFinite(val)) return;
const sourceUnits = model.options.units || 'LPS';
// batchElevValue is always entered in meters (display); convert the
// set-value / offset-delta into the model's native length unit (m or ft).
const modelVal = metersToLength(val, sourceUnits);
onUpdateNodes(selectedNodeIds, (node) => {
if (batchElevMode === 'set') {
return { elevation: modelVal };
} else {
return { elevation: Math.round((node.elevation + modelVal) * 100) / 100 };
}
});
notify(
batchElevMode === 'set'
? `지반고 ${val}m 일괄 설정 (${selectedNodeIds.length}개 절점)`
: `지반고 ${val >= 0 ? `+${val}` : val}m 일괄 증감 (${selectedNodeIds.length}개 절점)`
);
};
const applyDemand = () => {
const val = parseFloat(batchDemandValue);
if (!Number.isFinite(val)) return;
const sourceUnits = model.options.units || 'LPS';
onUpdateNodes(selectedNodeIds, (node) => {
if (batchDemandMode === 'set') {
// batchDemandValue (set mode) is entered in m³/day (display);
// convert into the model's native flow unit before storing.
return { demand: m3DayToFlow(val, sourceUnits) };
} else {
// Multiplier is dimensionless; applies directly to the stored value.
const curDemand = node.demand ?? 0;
return { demand: Math.round(curDemand * val * 1000) / 1000 };
}
});
notify(
batchDemandMode === 'set'
? `기본수요량 ${val} m³/day 일괄 설정 (${selectedNodeIds.length}개 절점)`
: `기본수요량 ×${val}배 일괄 곱셈 (${selectedNodeIds.length}개 절점)`
);
};
const applyPattern = () => {
if (!batchPattern) return;
onUpdateNodes(selectedNodeIds, () => ({ pattern: batchPattern }));
notify(`수요 패턴 '${batchPattern}' 일괄 지정 (${selectedNodeIds.length}개 절점)`);
};
const applyMoveOffset = () => {
const dx = parseFloat(moveDx) || 0;
const dy = parseFloat(moveDy) || 0;
if (dx === 0 && dy === 0) return;
onMoveNodes(selectedNodeIds, dx, dy);
notify(`절점 ${selectedNodeIds.length}개 일괄 이동 완료 (ΔX: ${dx}, ΔY: ${dy})`);
};
const patterns = Object.keys(model.patterns || {});
return (
<div
style={{
background: 'var(--bg-panel)',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border-strong)',
overflow: 'hidden',
fontSize: '12px',
boxShadow: 'var(--shadow-sm)',
}}
>
{/* Header with Element Counts & Global Multi Actions */}
<div
style={{
padding: '10px 12px',
background: 'var(--bg-subtle)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '6px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Layers size={15} style={{ color: 'var(--accent)' }} />
<strong style={{ color: 'var(--text)', fontSize: '13px' }}>
다중 선택 일괄 편집기
</strong>
<span
style={{
fontSize: '11px',
padding: '1px 6px',
borderRadius: '10px',
background: 'var(--accent-wash)',
color: 'var(--accent)',
fontWeight: 600,
}}
>
총 {selectedNodeIds.length + selectedPipeIds.length}개
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{onCopyClipboard && (
<button
type="button"
className="btn btn-secondary"
onClick={onCopyClipboard}
title="선택된 요소 복사 (Ctrl+C)"
style={{ height: '24px', padding: '0 6px', fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '3px' }}
>
<Copy size={11} />
복사
</button>
)}
<button
type="button"
onClick={onDeleteSelected}
className="btn btn-danger"
title="선택된 요소 일괄 삭제"
style={{
height: '24px',
padding: '0 6px',
fontSize: '11px',
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
background: 'var(--crit-wash)',
color: 'var(--crit)',
border: '1px solid var(--crit)',
}}
>
<Trash2 size={11} />
삭제
</button>
<button
type="button"
className="btn btn-secondary"
onClick={onClearSelection}
title="전체 선택 해제 (ESC)"
style={{ height: '24px', padding: '0 6px', fontSize: '11px' }}
>
<X size={11} />
</button>
</div>
</div>
{/* Toast Flash Notice */}
{flashMsg && (
<div
style={{
padding: '6px 12px',
background: 'var(--good-wash)',
color: 'var(--good)',
borderBottom: '1px solid var(--good)',
fontSize: '11px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<Check size={13} />
{flashMsg}
</div>
)}
{/* Selected Items ID Chips Preview */}
<div
style={{
padding: '6px 12px',
background: 'var(--bg-app)',
borderBottom: '1px solid var(--border)',
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
maxHeight: '75px',
overflowY: 'auto',
}}
>
{selectedNodeIds.map((id) => (
<span
key={`node-chip-${id}`}
onClick={() => onSelectTarget({ type: 'node', id })}
style={{
cursor: 'pointer',
fontSize: '10px',
padding: '1px 5px',
borderRadius: '3px',
background: selectedTarget?.type === 'node' && selectedTarget.id === id ? 'var(--accent)' : 'var(--accent-wash)',
color: selectedTarget?.type === 'node' && selectedTarget.id === id ? '#fff' : 'var(--accent)',
fontFamily: 'var(--font-mono)',
}}
title="클릭 시 대표 요소로 포커스"
>
절점 {id}
</span>
))}
{selectedPipeIds.map((id) => (
<span
key={`pipe-chip-${id}`}
onClick={() => onSelectTarget({ type: 'link', id })}
style={{
cursor: 'pointer',
fontSize: '10px',
padding: '1px 5px',
borderRadius: '3px',
background: selectedTarget?.type === 'link' && selectedTarget.id === id ? 'var(--good)' : 'var(--good-wash)',
color: selectedTarget?.type === 'link' && selectedTarget.id === id ? '#fff' : 'var(--good)',
fontFamily: 'var(--font-mono)',
}}
title="클릭 시 대표 요소로 포커스"
>
관로 {id}
</span>
))}
</div>
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* ======================================================== */}
{/* 1. PIPE BATCH EDIT CARD */}
{/* ======================================================== */}
{selectedPipeIds.length > 0 && (
<div
style={{
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
padding: '10px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>
관로 일괄 속성 변경 ({selectedPipeIds.length}개 관로)
</span>
</div>
{/* Pipe Diameter */}
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
관경 (Diameter mm)
</label>
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
<input
type="number"
step="10"
value={batchDiameter}
onChange={(e) => setBatchDiameter(e.target.value)}
placeholder="예: 150"
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!batchDiameter}
onClick={() => applyDiameter(parseFloat(batchDiameter))}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
<div style={{ display: 'flex', gap: '3px', flexWrap: 'wrap' }}>
{[80, 100, 150, 200, 250, 300, 400, 500].map((d) => (
<button
key={`diam-${d}`}
type="button"
onClick={() => {
setBatchDiameter(String(d));
applyDiameter(d);
}}
style={{
padding: '2px 5px',
fontSize: '10px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '3px',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
}}
>
D{d}
</button>
))}
</div>
</div>
{/* Roughness Presets */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
Hazen-Williams 조도계수 (Roughness C)
</label>
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
<input
type="number"
step="5"
value={batchRoughness}
onChange={(e) => setBatchRoughness(e.target.value)}
placeholder="예: 130"
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!batchRoughness}
onClick={() => applyRoughness(parseFloat(batchRoughness))}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
<div style={{ display: 'flex', gap: '3px', flexWrap: 'wrap' }}>
{[
{ name: '주철관 (100)', val: 100 },
{ name: '강관 (110)', val: 110 },
{ name: '닥타일주철 (130)', val: 130 },
{ name: 'PVC/PE (140)', val: 140 },
].map((preset) => (
<button
key={`rough-${preset.val}`}
type="button"
onClick={() => {
setBatchRoughness(String(preset.val));
applyRoughness(preset.val);
}}
style={{
padding: '2px 5px',
fontSize: '10px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '3px',
cursor: 'pointer',
}}
>
{preset.name}
</button>
))}
</div>
</div>
{/* Pipe Status */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
관로 개폐 상태 (Status)
</label>
<div style={{ display: 'flex', gap: '4px' }}>
<button
type="button"
onClick={() => applyStatus('OPEN')}
className="btn btn-secondary"
style={{ flex: 1, padding: '4px', fontSize: '11px', color: 'var(--good)' }}
>
전체 열림 (OPEN)
</button>
<button
type="button"
onClick={() => applyStatus('CLOSED')}
className="btn btn-secondary"
style={{ flex: 1, padding: '4px', fontSize: '11px', color: 'var(--crit)' }}
>
전체 닫힘 (CLOSED)
</button>
<button
type="button"
onClick={() => applyStatus('CV')}
className="btn btn-secondary"
style={{ flex: 1, padding: '4px', fontSize: '11px', color: 'var(--accent)' }}
>
체크밸브 (CV)
</button>
</div>
</div>
{/* Minor Loss & Tag */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
<div>
<label style={{ display: 'block', fontSize: '10px', color: 'var(--text-muted)' }}>미소손실 K</label>
<div style={{ display: 'flex', gap: '2px' }}>
<input
type="number"
step="0.1"
value={batchMinorLoss}
onChange={(e) => setBatchMinorLoss(e.target.value)}
placeholder="0.0"
style={{ width: '100%', padding: '2px 4px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '3px' }}
/>
<button type="button" className="btn btn-secondary" onClick={applyMinorLoss} style={{ padding: '0 4px', fontSize: '10px' }}>
적용
</button>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '10px', color: 'var(--text-muted)' }}>설명/태그</label>
<div style={{ display: 'flex', gap: '2px' }}>
<input
type="text"
value={batchTag}
onChange={(e) => setBatchTag(e.target.value)}
placeholder="배수관로"
style={{ width: '100%', padding: '2px 4px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '3px' }}
/>
<button type="button" className="btn btn-secondary" onClick={applyTagDesc} style={{ padding: '0 4px', fontSize: '10px' }}>
적용
</button>
</div>
</div>
</div>
</div>
)}
{/* ======================================================== */}
{/* 2. NODE BATCH EDIT CARD */}
{/* ======================================================== */}
{selectedNodeIds.length > 0 && (
<div
style={{
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
padding: '10px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>
절점 일괄 속성 변경 ({selectedNodeIds.length}개 절점)
</span>
</div>
{/* Node Elevation */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3px' }}>
<label style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>
지반고 (Elevation m)
</label>
<div style={{ display: 'flex', gap: '6px', fontSize: '10px' }}>
<label style={{ cursor: 'pointer' }}>
<input
type="radio"
name="batchElevMode"
checked={batchElevMode === 'offset'}
onChange={() => setBatchElevMode('offset')}
/>{' '}
증감 가감 (±)
</label>
<label style={{ cursor: 'pointer' }}>
<input
type="radio"
name="batchElevMode"
checked={batchElevMode === 'set'}
onChange={() => setBatchElevMode('set')}
/>{' '}
값 고정
</label>
</div>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="number"
step="0.5"
value={batchElevValue}
onChange={(e) => setBatchElevValue(e.target.value)}
placeholder={batchElevMode === 'offset' ? '+1.5 또는 -0.5' : '30.0'}
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!batchElevValue}
onClick={applyElevation}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
</div>
{/* Node Demand */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3px' }}>
<label style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>
기본수요량 (Base Demand)
</label>
<div style={{ display: 'flex', gap: '6px', fontSize: '10px' }}>
<label style={{ cursor: 'pointer' }}>
<input
type="radio"
name="batchDemandMode"
checked={batchDemandMode === 'mult'}
onChange={() => setBatchDemandMode('mult')}
/>{' '}
배율 곱셈 (×)
</label>
<label style={{ cursor: 'pointer' }}>
<input
type="radio"
name="batchDemandMode"
checked={batchDemandMode === 'set'}
onChange={() => setBatchDemandMode('set')}
/>{' '}
값 고정
</label>
</div>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="number"
step="0.1"
value={batchDemandValue}
onChange={(e) => setBatchDemandValue(e.target.value)}
placeholder={batchDemandMode === 'mult' ? '1.2 (20% 증가)' : '100.0'}
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!batchDemandValue}
onClick={applyDemand}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
</div>
{/* Demand Pattern */}
{patterns.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
수요 패턴 (Pattern ID)
</label>
<div style={{ display: 'flex', gap: '4px' }}>
<select
value={batchPattern}
onChange={(e) => setBatchPattern(e.target.value)}
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">패턴 선택...</option>
{patterns.map((patId) => (
<option key={patId} value={patId}>
{patId} {model.patterns[patId]?.description ? `(${model.patterns[patId].description})` : ''}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
disabled={!batchPattern}
onClick={applyPattern}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
</div>
)}
{/* CAD Move Offset */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
절점 위치 일괄 평행이동 (CAD Move Offset)
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '4px' }}>
<div>
<input
type="number"
step="1"
value={moveDx}
onChange={(e) => setMoveDx(e.target.value)}
placeholder="ΔX (m)"
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
<div>
<input
type="number"
step="1"
value={moveDy}
onChange={(e) => setMoveDy(e.target.value)}
placeholder="ΔY (m)"
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={applyMoveOffset}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '3px' }}
>
<Move size={11} />
이동
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
};
+172
View File
@@ -0,0 +1,172 @@
import React, { useEffect, useRef } from 'react';
import { Activity, Table, X } from 'lucide-react';
export type BottomPanelTab = 'table' | 'eps';
interface BottomPanelProps {
activeTab: BottomPanelTab | null;
height: number;
epsAvailable: boolean;
onToggleTab: (tab: BottomPanelTab) => void;
onHeightChange: (height: number) => void;
tableContent: React.ReactNode;
epsContent: React.ReactNode;
}
export const BottomPanel: React.FC<BottomPanelProps> = ({
activeTab,
height,
epsAvailable,
onToggleTab,
onHeightChange,
tableContent,
epsContent,
}) => {
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
useEffect(() => {
const handleMove = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag) return;
const next = Math.min(
Math.max(drag.startHeight + drag.startY - event.clientY, 160),
Math.floor(window.innerHeight * 0.7)
);
onHeightChange(next);
};
const handleUp = () => {
dragRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
}, [onHeightChange]);
if (!activeTab) return null;
return (
<section
aria-label="하단 패널"
style={{
position: 'absolute',
left: 44,
right: 0,
bottom: 0,
height,
minHeight: 160,
maxHeight: '70vh',
zIndex: 300,
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-panel)',
borderTop: '1px solid var(--border-strong)',
boxShadow: '0 -4px 16px rgba(15, 23, 42, 0.12)',
}}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label="하단 패널 높이 조절"
onPointerDown={(event) => {
dragRef.current = { startY: event.clientY, startHeight: height };
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
event.currentTarget.setPointerCapture(event.pointerId);
}}
style={{
position: 'absolute',
top: -4,
left: 0,
right: 0,
height: 8,
cursor: 'ns-resize',
zIndex: 2,
}}
/>
<header
style={{
flex: '0 0 34px',
display: 'flex',
alignItems: 'stretch',
justifyContent: 'space-between',
background: 'var(--bg-subtle)',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', alignItems: 'stretch' }}>
<button
onClick={() => onToggleTab('table')}
style={{
minWidth: 116,
border: 'none',
borderBottom: activeTab === 'table' ? '2px solid var(--accent)' : '2px solid transparent',
background: 'transparent',
color: activeTab === 'table' ? 'var(--text)' : 'var(--text-secondary)',
fontSize: 'var(--fs-xs)',
fontWeight: activeTab === 'table' ? 600 : 500,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--s-2)',
}}
title="요소 테이블 열기/닫기 (Ctrl+T)"
>
<Table size={13} />
요소 테이블
</button>
{epsAvailable && (
<button
onClick={() => onToggleTab('eps')}
style={{
minWidth: 116,
border: 'none',
borderBottom: activeTab === 'eps' ? '2px solid var(--accent)' : '2px solid transparent',
background: 'transparent',
color: activeTab === 'eps' ? 'var(--text)' : 'var(--text-secondary)',
fontSize: 'var(--fs-xs)',
fontWeight: activeTab === 'eps' ? 600 : 500,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--s-2)',
}}
title="EPS 시간 컨트롤러 열기/닫기"
>
<Activity size={13} />
EPS 시간
</button>
)}
</div>
<button
onClick={() => onToggleTab(activeTab)}
style={{
width: 34,
border: 'none',
background: 'transparent',
color: 'var(--text-muted)',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="하단 패널 닫기"
aria-label="하단 패널 닫기"
>
<X size={14} />
</button>
</header>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: activeTab === 'table' ? 0 : 'var(--s-4)' }}>
{activeTab === 'table' ? tableContent : epsContent}
</div>
</section>
);
};
+295
View File
@@ -0,0 +1,295 @@
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>
);
};
+505
View File
@@ -0,0 +1,505 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Copy,
Plus,
Maximize2,
Move,
Pencil,
Activity,
GitCommit,
Link,
Trash2,
Ruler,
FileCode,
ArrowRight,
ArrowLeftRight,
MapPin,
Mountain,
Scissors,
} from 'lucide-react';
import type { MapContextMenuEvent, ContextMenuTarget } from './types';
import { lngLatToModel, type CRS } from './crs';
import {
reverseGeocodeKakao,
lookupApproxElevation,
formatMapServiceError,
type ElevationResult,
type KakaoAddressResult,
} from './mapServices';
interface ContextMenuProps {
crs: CRS;
event: MapContextMenuEvent;
onClose: () => void;
onCopyText: (text: string, label: string) => void;
onAddNodeAtCoords: (x: number, y: number) => void;
onFitBounds: () => void;
onPanTo: (lngLat: [number, number]) => void;
onInspectTarget: (target: { type: 'node' | 'link'; id: string } | { type: 'gis-feature'; layerId: string; featureId: string | number }) => void;
onViewResults: (target: { type: 'node' | 'link'; id: string }) => void;
onStartPipeFromNode: (nodeId: string) => void;
onSelectConnectedLinks?: (nodeId: string) => void;
onSelectConnectedNodes?: (pipeId: string) => void;
onCalculatePipeLength?: (pipeId: string) => void;
onReversePipe?: (pipeId: string) => void;
onSplitPipe?: (pipeId: string) => void;
onDeleteNode?: (nodeId: string) => void;
onDeletePipe?: (pipeId: string) => void;
onZoomToLayer?: (layerId: string) => void;
canRunResults?: boolean;
}
interface MenuItem {
id: string;
label: string | React.ReactNode;
sublabel?: string | React.ReactNode;
icon: React.ReactNode;
disabled?: boolean;
disabledReason?: string;
danger?: boolean;
closeOnClick?: boolean; // Defaults to true
action: () => void | Promise<void>;
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
crs,
event,
onClose,
onCopyText,
onAddNodeAtCoords,
onFitBounds,
onPanTo,
onInspectTarget,
onViewResults,
onStartPipeFromNode,
onSelectConnectedLinks,
onSelectConnectedNodes,
onSplitPipe,
onCalculatePipeLength,
onReversePipe,
onDeleteNode,
onDeletePipe,
onZoomToLayer,
canRunResults = false,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const [pos, setPos] = useState<{ x: number; y: number }>({ x: event.x, y: event.y });
// Async action states
const [addressState, setAddressState] = useState<{ loading: boolean; result?: KakaoAddressResult | null; error?: string }>({ loading: false });
const [elevationState, setElevationState] = useState<{ loading: boolean; result?: ElevationResult | null; error?: string }>({ loading: false });
const { target } = event;
// Build menu items based on target
const items: MenuItem[] = [];
let coordsHeader = null;
if (target.type === 'canvas') {
const [modelX, modelY] = target.modelCoords;
const [lng, lat] = target.lngLat;
const [epsg5179X, epsg5179Y] = lngLatToModel(lng, lat, 'EPSG:5179', { minX: 0, minY: 0, maxX: 0, maxY: 0 });
const allCoordsText = `지도 CRS (${crs}): ${modelX.toFixed(2)}, ${modelY.toFixed(2)}
WGS84: ${lng.toFixed(6)}, ${lat.toFixed(6)}
EPSG:5179: ${epsg5179X.toFixed(2)}, ${epsg5179Y.toFixed(2)}`;
coordsHeader = (
<div style={{
padding: '6px 8px',
borderBottom: '1px solid var(--border)',
marginBottom: '4px',
fontSize: '11px',
color: 'var(--text-secondary)',
display: 'flex',
flexDirection: 'column',
gap: '4px',
fontFamily: 'var(--font-mono)'
}}>
<strong style={{ color: 'var(--text)', fontFamily: 'inherit' }}>좌표 표시</strong>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px' }}>
<span style={{ color: 'var(--text-muted)' }}>지도 CRS ({crs})</span>
<span>{modelX.toFixed(2)}, {modelY.toFixed(2)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px' }}>
<span style={{ color: 'var(--text-muted)' }}>WGS84</span>
<span>{lng.toFixed(6)}, {lat.toFixed(6)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px' }}>
<span style={{ color: 'var(--text-muted)' }}>EPSG:5179</span>
<span>{epsg5179X.toFixed(2)}, {epsg5179Y.toFixed(2)}</span>
</div>
</div>
);
items.push(
{
id: 'copy-coords',
label: '좌표 모두 복사',
icon: <Copy size={14} aria-hidden="true" />,
action: () => onCopyText(allCoordsText, '좌표'),
},
{
id: 'lookup-address',
label: addressState.loading ? '주소 조회 중...' : '주소 조회',
sublabel: addressState.error ? (
<span style={{ color: 'var(--crit)' }}>{addressState.error}</span>
) : addressState.result ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '2px' }}>
{addressState.result.roadAddressName && <span style={{ color: 'var(--text)' }}>도로명: {addressState.result.roadAddressName}</span>}
<span style={{ color: 'var(--text)' }}>지번: {addressState.result.parcelAddressName || addressState.result.addressName}</span>
</div>
) : undefined,
icon: <MapPin size={14} aria-hidden="true" />,
closeOnClick: false,
action: async () => {
if (addressState.loading) return;
setAddressState({ loading: true });
try {
const result = await reverseGeocodeKakao(lng, lat);
setAddressState({
loading: false,
result,
error: result ? undefined : '해당 좌표의 주소 결과가 없습니다.',
});
} catch (caught) {
setAddressState({ loading: false, error: formatMapServiceError(caught) });
}
},
},
{
id: 'lookup-elevation',
label: elevationState.loading ? '표고 조회 중...' : '표고 조회 (근사)',
sublabel: elevationState.error ? (
<span style={{ color: 'var(--crit)' }}>{elevationState.error}</span>
) : elevationState.result ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '2px' }}>
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{elevationState.result.elevationMeters.toFixed(1)} m</span>
<span style={{ color: 'var(--text-muted)', fontSize: '9px', lineHeight: '1.2' }}>출처: {elevationState.result.source}<br/>해상도: {elevationState.result.resolution} · 공식 한국 DEM 아님</span>
</div>
) : undefined,
icon: <Mountain size={14} aria-hidden="true" />,
closeOnClick: false,
action: async () => {
if (elevationState.loading) return;
setElevationState({ loading: true });
try {
const result = await lookupApproxElevation(lng, lat);
setElevationState({ loading: false, result });
} catch (caught) {
setElevationState({ loading: false, error: formatMapServiceError(caught) });
}
},
},
{
id: 'add-node-here',
label: '현재 위치에 절점 추가',
icon: <Plus size={14} aria-hidden="true" />,
action: () => onAddNodeAtCoords(modelX, modelY),
},
{
id: 'fit-bounds',
label: '전체 관망 보기 (Fit)',
icon: <Maximize2 size={14} aria-hidden="true" />,
action: onFitBounds,
},
{
id: 'pan-to-here',
label: '지도 중심으로 이동',
icon: <Move size={14} aria-hidden="true" />,
action: () => onPanTo(target.lngLat),
}
);
} else if (target.type === 'node') {
const nodeId = target.id;
items.push(
{
id: 'node-inspect',
label: `절점 [${nodeId}] 속성 보기`,
icon: <Pencil size={14} aria-hidden="true" />,
action: () => onInspectTarget({ type: 'node', id: nodeId }),
},
{
id: 'node-results',
label: '절점 수리결과 보기',
icon: <Activity size={14} aria-hidden="true" />,
disabled: !canRunResults,
disabledReason: '해석 미실행 상태입니다',
action: () => onViewResults({ type: 'node', id: nodeId }),
},
{
id: 'node-copy-coords',
label: '절점 좌표 복사',
icon: <Copy size={14} aria-hidden="true" />,
action: () => {
const coords = target.modelCoords
? `${target.modelCoords[0].toFixed(2)}, ${target.modelCoords[1].toFixed(2)}`
: `${target.lngLat[0].toFixed(5)}, ${target.lngLat[1].toFixed(5)}`;
onCopyText(coords, '절점 좌표');
},
},
{
id: 'node-start-pipe',
label: '이 절점에서 관로 시작',
icon: <ArrowRight size={14} aria-hidden="true" />,
action: () => onStartPipeFromNode(nodeId),
},
{
id: 'node-select-connected',
label: '연결 관로 선택',
icon: <Link size={14} aria-hidden="true" />,
action: () => onSelectConnectedLinks?.(nodeId),
},
{
id: 'node-delete',
label: '절점 삭제',
icon: <Trash2 size={14} aria-hidden="true" />,
danger: true,
action: () => onDeleteNode?.(nodeId),
}
);
} else if (target.type === 'link') {
const pipeId = target.id;
items.push(
{
id: 'link-inspect',
label: `관로 [${pipeId}] 속성 보기`,
icon: <Pencil size={14} aria-hidden="true" />,
action: () => onInspectTarget({ type: 'link', id: pipeId }),
},
{
id: 'link-results',
label: '관로 수리결과 보기',
icon: <Activity size={14} aria-hidden="true" />,
disabled: !canRunResults,
disabledReason: '해석 미실행 상태입니다',
action: () => onViewResults({ type: 'link', id: pipeId }),
},
{
id: 'link-copy-id',
label: '관로 ID 복사',
icon: <Copy size={14} aria-hidden="true" />,
action: () => onCopyText(pipeId, '관로 ID'),
},
{
id: 'link-select-connected-nodes',
label: '연결 절점 선택',
icon: <GitCommit size={14} aria-hidden="true" />,
action: () => onSelectConnectedNodes?.(pipeId),
},
{
id: 'link-calc-length',
label: '좌표 거리로 Length 재계산',
icon: <Ruler size={14} aria-hidden="true" />,
action: () => onCalculatePipeLength?.(pipeId),
},
{
id: 'link-reverse-direction',
label: '관로 방향 변경 (Node1 / Node2 교환)',
icon: <ArrowLeftRight size={14} aria-hidden="true" />,
action: () => onReversePipe?.(pipeId),
},
{
id: 'link-split',
label: '관로 분할 (절점 삽입 및 2개 관로 분리)',
icon: <Scissors size={14} aria-hidden="true" />,
action: () => onSplitPipe?.(pipeId),
},
{
id: 'link-delete',
label: '관로 삭제',
icon: <Trash2 size={14} aria-hidden="true" />,
danger: true,
action: () => onDeletePipe?.(pipeId),
}
);
} else if (target.type === 'gis-feature') {
const { layerId, featureId, properties } = target;
items.push(
{
id: 'gis-inspect',
label: `GIS 객체 [${featureId}] 속성 보기`,
icon: <Pencil size={14} aria-hidden="true" />,
action: () => onInspectTarget({ type: 'gis-feature', layerId, featureId }),
},
{
id: 'gis-copy-props',
label: '속성 데이터 (JSON) 복사',
icon: <Copy size={14} aria-hidden="true" />,
action: () => onCopyText(JSON.stringify(properties, null, 2), 'GIS 속성'),
},
{
id: 'gis-zoom-layer',
label: '해당 레이어로 확대',
icon: <Maximize2 size={14} aria-hidden="true" />,
action: () => onZoomToLayer?.(layerId),
}
);
}
// Adjust coordinates so the menu never overflows viewport
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
const pad = 8;
const x = Math.max(pad, Math.min(event.x, window.innerWidth - rect.width - pad));
const y = Math.max(pad, Math.min(event.y, window.innerHeight - rect.height - pad));
setPos({ x, y });
}, [event.x, event.y]);
// Handle ESC and click outside
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => {
const next = (prev + 1) % items.length;
return items[next].disabled ? (next + 1) % items.length : next;
});
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => {
const next = (prev - 1 + items.length) % items.length;
return items[next].disabled ? (next - 1 + items.length) % items.length : next;
});
} else if (e.key === 'Enter' && selectedIndex >= 0 && selectedIndex < items.length) {
e.preventDefault();
const item = items[selectedIndex];
if (!item.disabled) {
item.action();
if (item.closeOnClick !== false) {
onClose();
}
}
}
};
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
const timer = window.setTimeout(() => {
window.addEventListener('mousedown', handleMouseDown);
}, 50);
return () => {
window.clearTimeout(timer);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mousedown', handleMouseDown);
};
}, [items, onClose, selectedIndex]);
return (
<div
ref={menuRef}
style={{
position: 'fixed',
left: pos.x,
top: pos.y,
zIndex: 1000,
width: target.type === 'canvas' ? '330px' : '230px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
boxShadow: 'var(--shadow-pop)',
padding: '4px',
display: 'flex',
flexDirection: 'column',
gap: '1px',
fontSize: '12px',
fontFamily: 'inherit',
}}
role="menu"
aria-label="지도 컨텍스트 메뉴"
>
<div
style={{
padding: '4px 8px 3px',
fontSize: '10px',
fontWeight: 600,
color: 'var(--text-muted)',
borderBottom: '1px solid var(--border)',
marginBottom: '2px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{target.type === 'canvas'
? '지도 작업'
: target.type === 'node'
? `절점: ${target.id}`
: target.type === 'link'
? `관로: ${target.id}`
: `GIS 피처: ${target.featureId}`}
</div>
{coordsHeader}
{items.map((item, idx) => {
const isSelected = selectedIndex === idx;
return (
<button
key={item.id}
onClick={() => {
if (item.disabled) return;
item.action();
if (item.closeOnClick !== false) {
onClose();
}
}}
disabled={item.disabled}
title={item.disabled ? item.disabledReason : undefined}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '5px 8px',
border: 'none',
background: isSelected
? 'var(--accent-wash)'
: 'transparent',
color: item.disabled
? 'var(--text-disabled)'
: item.danger
? 'var(--crit)'
: 'var(--text)',
borderRadius: '2px',
cursor: item.disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
fontSize: '12px',
transition: 'background var(--dur) var(--ease)',
}}
onMouseEnter={() => !item.disabled && setSelectedIndex(idx)}
role="menuitem"
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
color: item.disabled
? 'var(--text-disabled)'
: item.danger
? 'var(--crit)'
: 'var(--text-secondary)',
}}
>
{item.icon}
</span>
<span>{item.label}</span>
</div>
{item.sublabel && (
<div style={{ maxWidth: '205px', fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)', textAlign: 'right' }}>
{item.sublabel}
</div>
)}
</button>
);
})}
</div>
);
};
+283
View File
@@ -0,0 +1,283 @@
import React, { useState } from 'react';
import { Trash2, Plus, Clock } from 'lucide-react';
import type { InpControl } from './inpModel';
interface ControlEditorProps {
controls: InpControl[];
linkIds: string[];
nodeIds: string[];
onAddControl: (ctrl: Omit<InpControl, 'id' | 'rawText'>) => void;
onUpdateControl: (id: string, updates: Partial<InpControl>) => void;
onDeleteControl: (id: string) => void;
}
export const ControlEditor: React.FC<ControlEditorProps> = ({
controls,
linkIds,
nodeIds,
onAddControl,
onUpdateControl,
onDeleteControl,
}) => {
// New rule form state
const [newLinkId, setNewLinkId] = useState<string>(linkIds[0] || '9');
const [newStatus, setNewStatus] = useState<'OPEN' | 'CLOSED'>('OPEN');
const [newNodeId, setNewNodeId] = useState<string>(nodeIds[0] || '2');
const [newCondition, setNewCondition] = useState<'BELOW' | 'ABOVE'>('BELOW');
const [newValue, setNewValue] = useState<number>(110);
const handleAddNew = (e: React.FormEvent) => {
e.preventDefault();
onAddControl({
linkId: newLinkId,
status: newStatus,
nodeId: newNodeId,
condition: newCondition,
value: newValue,
});
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', fontSize: '12px' }}>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5, background: 'var(--bg-subtle)', padding: '10px', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<strong>운영 제어(Controls) 규칙 안내:</strong>
<div style={{ marginTop: '4px', fontSize: '11px', color: 'var(--text-secondary)' }}>
배수지(탱크) 수위나 특정 절점의 압력 변화에 따라 <strong>펌프를 가동(OPEN)하거나 정지(CLOSED)</strong>하는 EPANET 핵심 운영 규칙입니다.
</div>
</div>
{/* Control Rules List */}
<div>
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: '8px' }}>
등록된 운영 제어 규칙 ({controls.length}건):
</div>
{controls.length === 0 ? (
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--text-muted)', border: '1px dashed #cbd5e1', borderRadius: '6px' }}>
등록된 제어 규칙이 없습니다. 아래 폼에서 새 규칙을 추가하세요.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{controls.map((ctrl) => (
<div
key={ctrl.id}
style={{
background: 'var(--bg-panel)',
border: '1px solid #cbd5e1',
borderRadius: '6px',
padding: '10px',
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontWeight: 700, color: 'var(--accent)' }}>규칙 {ctrl.id}</span>
<span
style={{
fontSize: '11px',
padding: '1px 6px',
borderRadius: '4px',
background: ctrl.status === 'OPEN' ? 'var(--good-wash)' : 'var(--crit-wash)',
color: ctrl.status === 'OPEN' ? 'var(--good)' : 'var(--crit)',
fontWeight: 600,
}}
>
{ctrl.status === 'OPEN' ? '가동 (OPEN)' : '정지 (CLOSED)'}
</span>
</div>
<button
onClick={() => onDeleteControl(ctrl.id)}
style={{
border: 'none',
background: 'none',
color: 'var(--crit)',
cursor: 'pointer',
fontSize: '11px',
padding: '2px 4px',
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--s-1)',
}}
title="규칙 삭제"
>
<Trash2 size={12} aria-hidden="true" />
삭제
</button>
</div>
{/* Friendly description */}
<div style={{ fontSize: '12px', color: 'var(--text)', marginBottom: '8px' }}>
대상 링크 <strong>{ctrl.linkId}</strong>을(를){' '}
<strong style={{ color: ctrl.status === 'OPEN' ? 'var(--good)' : 'var(--crit)' }}>
{ctrl.status === 'OPEN' ? '가동(OPEN)' : '정지(CLOSED)'}
</strong>
{ctrl.nodeId && ctrl.condition && ctrl.value !== undefined && (
<span>
{' '}(조건: 절점 <strong>{ctrl.nodeId}</strong> 수위가{' '}
<strong>{ctrl.value}</strong>{' '}
{ctrl.condition === 'BELOW' ? '이하일 때' : '이상일 때'})
</span>
)}
{ctrl.atClause && (
<span>
{' '}(조건: <strong>{ctrl.atClause}</strong>)
</span>
)}
</div>
{ctrl.atClause && (
<div style={{ fontSize: '11px', color: 'var(--warn)', background: 'var(--warn-wash)', border: '1px solid #fde68a', borderRadius: '4px', padding: '4px 8px', marginBottom: '8px', display: 'flex', alignItems: 'flex-start', gap: 'var(--s-2)' }}>
<Clock size={13} aria-hidden="true" style={{ flexShrink: 0, marginTop: '1px' }} />
<span>시간 기반 제어 - 아래에서 동작(가동/정지)만 편집 가능하며, <strong>{ctrl.atClause}</strong> 조건은 그대로 유지됩니다.</span>
</div>
)}
{/* Edit inline fields */}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', background: 'var(--bg-subtle)', padding: '6px 8px', borderRadius: '4px' }}>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>동작:</span>
<select
value={ctrl.status}
onChange={(e) => onUpdateControl(ctrl.id, { status: e.target.value as 'OPEN' | 'CLOSED' })}
style={{ fontSize: '11px', padding: '2px 4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="OPEN">가동 (OPEN)</option>
<option value="CLOSED">정지 (CLOSED)</option>
</select>
{ctrl.nodeId && (
<>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)', marginLeft: '4px' }}>조건:</span>
<select
value={ctrl.condition || 'BELOW'}
onChange={(e) => onUpdateControl(ctrl.id, { condition: e.target.value as 'BELOW' | 'ABOVE' })}
style={{ fontSize: '11px', padding: '2px 4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="BELOW">이하 (BELOW)</option>
<option value="ABOVE">이상 (ABOVE)</option>
</select>
<input
type="number"
step="1"
value={ctrl.value ?? 100}
onChange={(e) => onUpdateControl(ctrl.id, { value: parseFloat(e.target.value) || 0 })}
style={{ width: '60px', fontSize: '11px', padding: '2px 4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Add New Rule Form */}
<form
onSubmit={handleAddNew}
style={{
background: 'var(--bg-subtle)',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ fontWeight: 600, color: 'var(--text)', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Plus size={14} aria-hidden="true" />
새 운영 제어 규칙 추가:
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
제어 대상 링크 (펌프/관로)
</label>
<select
value={newLinkId}
onChange={(e) => setNewLinkId(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
{linkIds.map((id) => (
<option key={id} value={id}>
링크 {id}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
동작 상태
</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value as 'OPEN' | 'CLOSED')}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="OPEN">가동 (OPEN)</option>
<option value="CLOSED">정지 (CLOSED)</option>
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
조건 절점 (탱크/노드)
</label>
<select
value={newNodeId}
onChange={(e) => setNewNodeId(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
{nodeIds.map((id) => (
<option key={id} value={id}>
절점 {id}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
조건 연산자
</label>
<select
value={newCondition}
onChange={(e) => setNewCondition(e.target.value as 'BELOW' | 'ABOVE')}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="BELOW">이하 (BELOW)</option>
<option value="ABOVE">이상 (ABOVE)</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
수위/압력 임계값
</label>
<input
type="number"
step="1"
value={newValue}
onChange={(e) => setNewValue(parseFloat(e.target.value) || 0)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
</div>
<button
type="submit"
className="btn btn-primary"
style={{ marginTop: '4px', padding: '6px 12px', fontSize: '12px' }}
>
규칙 추가하기
</button>
</form>
</div>
);
};
+167
View File
@@ -0,0 +1,167 @@
// 곡선 편집: pump / efficiency / volume / headloss curves with a point table
// and a chart (GISpipe 커브 편집 parity). Edits are applied in one undoable step.
import React, { useMemo, useState } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { addCurve, deleteCurve, getNextCurveId, type InpCurve, type NetworkModel } from './inpModel';
import { CURVE_TYPES, withCurveType, type CurveType } from './inpExtras';
import { curveReferences, curveWarnings, inferCurveType } from './curveTools';
import { getUnitSystem } from './unitSystem';
const TYPE_LABEL: Record<CurveType, string> = { PUMP: '펌프', EFFICIENCY: '효율', VOLUME: '체적', HEADLOSS: '손실수두' };
function axisLabels(type: CurveType, flowUnit: string, lengthUnit: string): [string, string] {
const vol = lengthUnit === 'm' ? 'm³' : 'ft³';
switch (type) {
case 'PUMP': return [`유량 (${flowUnit})`, `양정 (${lengthUnit})`];
case 'EFFICIENCY': return [`유량 (${flowUnit})`, '효율 (%)'];
case 'VOLUME': return [`수위 (${lengthUnit})`, `체적 (${vol})`];
case 'HEADLOSS': return [`유량 (${flowUnit})`, `손실수두 (${lengthUnit})`];
}
}
interface Row { x: string; y: string }
export const CurveEditorModal: React.FC<{
model: NetworkModel;
onApply: (next: NetworkModel, message: string) => void;
onClose: () => void;
}> = ({ model, onApply, onClose }) => {
const types = useMemo(() => {
const t: Record<string, CurveType> = {};
for (const id of Object.keys(model.curves)) t[id] = inferCurveType(model, id);
return t;
}, [model]);
const [tab, setTab] = useState<CurveType>('PUMP');
const ids = Object.keys(model.curves).filter((id) => types[id] === tab);
const [selected, setSelected] = useState<string | null>(ids[0] ?? null);
const current = selected ? model.curves[selected] : null;
const [rows, setRows] = useState<Row[]>(current ? current.points.map((p) => ({ x: String(p.x), y: String(p.y) })) : []);
const [description, setDescription] = useState((current?.description ?? '').replace(/^(PUMP|EFFICIENCY|VOLUME|HEADLOSS)\s*:\s*/i, ''));
const [error, setError] = useState<string | null>(null);
const flowUnit = model.options.units;
const lengthUnit = getUnitSystem(flowUnit) === 'US' ? 'ft' : 'm';
const [xLabel, yLabel] = axisLabels(tab, flowUnit, lengthUnit);
const pick = (id: string | null) => {
setSelected(id);
const c = id ? model.curves[id] : null;
setRows(c ? c.points.map((p) => ({ x: String(p.x), y: String(p.y) })) : []);
setDescription((c?.description ?? '').replace(/^(PUMP|EFFICIENCY|VOLUME|HEADLOSS)\s*:\s*/i, ''));
setError(null);
};
const switchTab = (t: CurveType) => {
setTab(t);
const first = Object.keys(model.curves).find((id) => types[id] === t) ?? null;
pick(first);
};
const points = rows.map((r) => ({ x: Number(r.x), y: Number(r.y) }));
const valid = points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
const warnings = valid ? curveWarnings(tab, points) : ['숫자가 아닌 값이 있습니다.'];
const save = () => {
if (!selected) return;
if (!valid || points.length === 0) return setError('점을 하나 이상, 숫자로 입력하세요.');
const curve: InpCurve = { id: selected, description: withCurveType(description, tab), points };
onApply(addCurve(model, curve), `곡선 ${selected}(${TYPE_LABEL[tab]})을 저장했습니다.`);
};
const create = () => {
const id = getNextCurveId(model, tab === 'PUMP' ? 'PC' : tab === 'EFFICIENCY' ? 'EC' : tab === 'VOLUME' ? 'VC' : 'HC');
const seed = tab === 'PUMP' ? [{ x: 0, y: 60 }, { x: 50, y: 50 }, { x: 100, y: 30 }] : tab === 'EFFICIENCY' ? [{ x: 0, y: 40 }, { x: 50, y: 75 }, { x: 100, y: 60 }] : [{ x: 0, y: 0 }, { x: 10, y: 100 }];
onApply(addCurve(model, { id, description: withCurveType('', tab), points: seed }), `새 ${TYPE_LABEL[tab]} 곡선 ${id}을(를) 만들었습니다.`);
setSelected(id);
setRows(seed.map((p) => ({ x: String(p.x), y: String(p.y) })));
setDescription('');
};
const remove = () => {
if (!selected) return;
const refs = curveReferences(model, selected);
if (refs.length) return setError(`사용 중인 곡선이라 지울 수 없습니다: ${refs.join(', ')}`);
if (!window.confirm(`곡선 ${selected}을(를) 삭제할까요?`)) return;
onApply(deleteCurve(model, selected), `곡선 ${selected}을(를) 삭제했습니다.`);
pick(null);
};
// chart
const W = 300, H = 190, pl = 42, pr = 10, pt = 10, pb = 30;
const xs = points.map((p) => p.x).filter(Number.isFinite);
const ys = points.map((p) => p.y).filter(Number.isFinite);
const x0 = Math.min(0, ...xs), x1 = Math.max(1e-9, ...xs), y0 = Math.min(0, ...ys), y1 = Math.max(1e-9, ...ys);
const sx = (v: number) => pl + ((v - x0) / (x1 - x0 || 1)) * (W - pl - pr);
const sy = (v: number) => H - pb - ((v - y0) / (y1 - y0 || 1)) * (H - pt - pb);
const path = valid ? points.map((p, i) => `${i ? 'L' : 'M'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join('') : '';
const cell: React.CSSProperties = { width: 80, height: 22, fontSize: 11, fontFamily: 'var(--font-mono)' };
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 1050, background: 'rgba(15,23,42,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
<div role="dialog" aria-label="곡선 편집" onClick={(e) => e.stopPropagation()}
style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', boxShadow: 'var(--shadow-modal)', width: 720, maxWidth: '96vw', fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--bg-subtle)', fontWeight: 600 }}>
<span>곡선 편집</span>
<button className="btn btn-ghost" style={{ padding: 3 }} onClick={onClose} aria-label="닫기"><X size={14} /></button>
</div>
<div style={{ display: 'flex', gap: 6, padding: '8px 14px', borderBottom: '1px solid var(--border)' }}>
{CURVE_TYPES.map((t) => (
<button key={t} className={`btn btn-secondary ${tab === t ? 'btn-primary' : ''}`} style={{ height: 24, fontSize: 11 }} onClick={() => switchTab(t)}>
{TYPE_LABEL[t]} ({Object.values(types).filter((x) => x === t).length})
</button>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr 300px', gap: 12, padding: 14, minHeight: 260 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{ids.map((id) => (
<button key={id} className={`btn ${selected === id ? 'btn-primary' : 'btn-ghost'}`} style={{ height: 24, justifyContent: 'flex-start', fontSize: 11 }} onClick={() => pick(id)}>
{id} <span style={{ opacity: 0.6, marginLeft: 4 }}>({model.curves[id].points.length}점)</span>
</button>
))}
{!ids.length && <span style={{ color: 'var(--text-muted)' }}>곡선 없음</span>}
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11, marginTop: 6 }} onClick={create}><Plus size={11} />새 곡선</button>
</div>
<div>
{current ? (
<>
<label style={{ display: 'grid', gap: 2, marginBottom: 6 }}>설명<input value={description} onChange={(e) => setDescription(e.target.value)} style={{ height: 22, fontSize: 11 }} /></label>
<div style={{ color: 'var(--text-muted)', marginBottom: 4 }}>{curveReferences(model, current.id).join(', ') || '사용처 없음'}</div>
<table style={{ borderCollapse: 'collapse' }}>
<thead><tr><th style={{ textAlign: 'left', fontWeight: 600 }}>{xLabel}</th><th style={{ textAlign: 'left', fontWeight: 600 }}>{yLabel}</th><th /></tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td><input value={r.x} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, x: e.target.value } : x)))} style={cell} aria-label={`${i + 1}행 X`} /></td>
<td><input value={r.y} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, y: e.target.value } : x)))} style={cell} aria-label={`${i + 1}행 Y`} /></td>
<td><button className="btn btn-ghost" style={{ padding: 2 }} onClick={() => setRows(rows.filter((_, k) => k !== i))} aria-label={`${i + 1}행 삭제`}><Trash2 size={11} /></button></td>
</tr>
))}
</tbody>
</table>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11, marginTop: 4 }} onClick={() => setRows([...rows, { x: '', y: '' }])}><Plus size={11} />점 추가</button>
</>
) : (
<span style={{ color: 'var(--text-muted)' }}>왼쪽에서 곡선을 고르거나 새로 만드세요.</span>
)}
</div>
<div>
<svg width={W} height={H} style={{ border: '1px solid var(--border)', borderRadius: 4, background: 'var(--bg-panel)' }} role="img" aria-label="곡선 그래프">
<line x1={pl} y1={H - pb} x2={W - pr} y2={H - pb} stroke="#94a3b8" />
<line x1={pl} y1={pt} x2={pl} y2={H - pb} stroke="#94a3b8" />
<text x={pl} y={H - 8} fontSize={9} fill="#64748b">{xLabel}</text>
<text x={4} y={pt + 8} fontSize={9} fill="#64748b">{y1.toPrecision(3)}</text>
<text x={W - pr} y={H - pb + 12} fontSize={9} fill="#64748b" textAnchor="end">{x1.toPrecision(3)}</text>
{path && <path d={path} fill="none" stroke="#2563eb" strokeWidth={2} />}
{valid && points.map((p, i) => <circle key={i} cx={sx(p.x)} cy={sy(p.y)} r={3} fill="#2563eb" />)}
</svg>
{current && warnings.map((w) => <div key={w} style={{ color: 'var(--warn, #b45309)', marginTop: 4 }}>⚠ {w}</div>)}
{error && <div style={{ color: 'var(--crit)', marginTop: 4 }}>{error}</div>}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '10px 14px', borderTop: '1px solid var(--border)', background: 'var(--bg-subtle)' }}>
<button className="btn btn-secondary" style={{ fontSize: 11 }} disabled={!current} onClick={remove}><Trash2 size={11} />곡선 삭제</button>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary" style={{ fontSize: 11 }} onClick={onClose}>닫기</button>
<button className="btn btn-primary" style={{ fontSize: 11 }} disabled={!current} onClick={save}>저장</button>
</div>
</div>
</div>
</div>
);
};
+273
View File
@@ -0,0 +1,273 @@
// 분석 tab > 지형(DEM): load a DEM (or build one from points), show it on the
// map, fill node elevations as ground or pipe-invert level, check elevations
// against it, or fetch coarse internet elevations when there is no DEM file.
import React, { useMemo, useState } from 'react';
import { Crosshair, Upload } from 'lucide-react';
import type { AnalysisPanelProps } from './AnalysisPanel';
import type { RasterOverlay, UserGisLayer, View3D } from './types';
import { demFromPoints, demHydro, demPreviewUrl, sampleDem, uploadDem, type DemInfo } from './api';
import { applyGroundElevations, checkElevations, crsProj4, type ElevationMode, type ElevationOptions } from './demTools';
import { lookupApproxElevations } from './mapServices';
import { modelToLngLat } from './crs';
import { getUnitSystem } from './unitSystem';
export interface DemSectionProps {
onRasterOverlays?: (update: (prev: RasterOverlay[]) => RasterOverlay[]) => void;
view3d?: View3D;
onView3d?: (v: View3D) => void;
onAddUserLayer?: (layer: UserGisLayer) => void;
}
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
const btn: React.CSSProperties = { height: 24, fontSize: 'var(--fs-2xs)' };
const muted: React.CSSProperties = { color: 'var(--text-muted)' };
const th: React.CSSProperties = { padding: '3px 5px', textAlign: 'right', background: 'var(--bg-subtle)', position: 'sticky', top: 0 };
const td: React.CSSProperties = { padding: '2px 5px', textAlign: 'right', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-mono)' };
export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ model, crs, userLayers, selectedNodeIds, cache, setCache, onApplyModel, onFocus, onSelectElements, onRasterOverlays, view3d, onView3d, onAddUserLayer }) => {
const [minArea, setMinArea] = useState('0.1');
const addLayer = (name: string, fc: import('geojson').FeatureCollection, color: string) => onAddUserLayer?.({
id: `layer-${Date.now()}`, name, fileName: `${name}.geojson`, featureCollection: fc, featureCount: fc.features.length,
geomTypes: [...new Set(fc.features.map((f) => f.geometry?.type).filter((t): t is NonNullable<typeof t> => !!t))],
estimatedCrs: 'EPSG:4326', visible: true, opacity: 0.85, color, createdAt: new Date().toISOString(),
});
const v3: View3D = view3d ?? { enabled: false, exaggeration: 1.5, columns: 'none', columnScale: 1 };
const set3d = (patch: Partial<View3D>) => onView3d?.({ ...v3, ...patch });
const dem = cache.demInfo as DemInfo | undefined;
const [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [mode, setMode] = useState<ElevationMode>('ground');
const [depth, setDepth] = useState('1.2');
const [useAssetDepth, setUseAssetDepth] = useState(true);
const [target, setTarget] = useState<'all' | 'selected' | 'zero'>('all');
const [tol, setTol] = useState('1');
const [opacity, setOpacity] = useState(0.7);
const [shown, setShown] = useState(true);
const [ptSource, setPtSource] = useState('network');
const [ptField, setPtField] = useState('');
const [cell, setCell] = useState('5');
const check = cache.demCheck as ReturnType<typeof checkElevations> | undefined;
const proj = crsProj4(crs);
const us = getUnitSystem(model.options.units) === 'US';
const lenUnit = us ? 'ft' : 'm';
const opts: ElevationOptions = { mode, defaultDepth: Number(depth) || 0, useAssetDepth };
const pointLayers = userLayers.filter((l) => l.geomTypes.some((g) => g.includes('Point')));
const ptLayer = pointLayers.find((l) => l.id === ptSource);
const numericFields = useMemo(() => {
const props = ptLayer?.featureCollection.features[0]?.properties ?? {};
return Object.keys(props).filter((k) => ptLayer!.featureCollection.features.slice(0, 50).some((f) => Number.isFinite(Number(f.properties?.[k]))));
}, [ptLayer]);
const targetIds = () => {
const ids = target === 'selected' ? selectedNodeIds.filter((id) => model.nodes[id]) : Object.keys(model.nodes);
return target === 'zero' ? ids.filter((id) => model.nodes[id].elevation === 0) : ids;
};
const task = async (label: string, fn: () => Promise<void>) => {
setErr(null);
setBusy(label);
try {
await fn();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
}
};
const showDem = (info: DemInfo, visible = shown, op = opacity) => {
onRasterOverlays?.((prev) => [...prev.filter((r) => r.id !== 'dem'), { id: 'dem', name: `DEM ${info.name}`, url: `${demPreviewUrl(info.id)}?v=${info.id}`, corners: info.corners, opacity: op, visible }]);
};
const setDem = (info: DemInfo) => {
setCache('demInfo', info);
setCache('demCheck', undefined);
showDem(info, true);
setShown(true);
};
const sampleGround = async (ids: string[]) => {
if (!dem || !proj) throw new Error('DEM과 좌표계가 필요합니다.');
const vals = await sampleDem(dem.id, ids.map((id) => [model.nodes[id].x, model.nodes[id].y]), proj);
return Object.fromEntries(ids.map((id, k) => [id, vals[k]]));
};
const applyGround = (ground: Record<string, number | null>, source: string) => {
const r = applyGroundElevations(model, ground, opts, target === 'zero');
onApplyModel(r.model, `${source} ${mode === 'invert' ? '관저고' : '지반고'}로 절점 ${r.changed.length}개 표고를 넣었습니다${r.missing.length ? ` (범위 밖·자료 없음 ${r.missing.length}개 제외)` : ''}.`);
};
if (!proj) {
return <div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>좌표계가 "로컬 평면"이라 DEM 위치를 맞출 수 없습니다. 상단에서 관망의 실제 좌표계(예: EPSG:5186)를 지정하세요.</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
{err && <div style={{ color: 'var(--crit)' }}>{err}</div>}
{busy && <div style={{ color: 'var(--text-secondary)' }}>{busy}...</div>}
<div style={box}>
<b>DEM</b>
<label className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start', display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<Upload size={11} />DEM 파일 불러오기 (GeoTIFF·ASC·IMG, zip)
<input type="file" accept=".tif,.tiff,.asc,.img,.zip,.dem,.bil,.flt,.hgt" style={{ display: 'none' }} onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void task(`DEM 올리는 중 (${(f.size / 1048576).toFixed(1)} MB)`, async () => setDem(await uploadDem(f)));
}} />
</label>
{dem ? (
<>
<span>{dem.name} · {dem.width}×{dem.height} · 격자 {dem.resolution[0].toFixed(2)} · 표고 {dem.min?.toFixed(1)}~{dem.max?.toFixed(1)}</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label><input type="checkbox" checked={shown} onChange={(e) => { setShown(e.target.checked); showDem(dem, e.target.checked); }} /> 지도에 표시</label>
<input type="range" min={0.1} max={1} step={0.05} value={opacity} aria-label="DEM 투명도" onChange={(e) => { const v = Number(e.target.value); setOpacity(v); showDem(dem, shown, v); }} />
</div>
</>
) : (
<span style={muted}>국토지리정보원 수치표고모델 등 DEM 파일을 넣으세요. 좌표계 정보(.prj 또는 GeoTIFF)가 있어야 합니다. 서버에 보관되어 다른 화면을 오가도 유지됩니다.</span>
)}
</div>
{onView3d && (
<div style={box}>
<b>3D 보기</b>
<label><input type="checkbox" checked={v3.enabled} onChange={(e) => set3d({ enabled: e.target.checked, demId: dem?.id })} /> 지도를 기울여 3D로 보기{dem ? ' (DEM 지형 포함)' : ''}</label>
{v3.enabled && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
<label>지형 과장<input type="number" step="0.5" min={0.5} value={v3.exaggeration} disabled={!dem} onChange={(e) => set3d({ exaggeration: Number(e.target.value) || 1, demId: dem?.id })} style={inputStyle} /></label>
<label>절점 기둥
<select value={v3.columns} onChange={(e) => set3d({ columns: e.target.value as View3D['columns'] })} style={inputStyle}>
<option value="none">없음</option><option value="pressure">압력</option><option value="head">동수두</option><option value="elevation">표고</option>
</select>
</label>
<label>기둥 배율<input type="number" step="0.5" min={0.1} value={v3.columnScale} onChange={(e) => set3d({ columnScale: Number(e.target.value) || 1 })} style={inputStyle} /></label>
</div>
)}
<span style={muted}>마우스 오른쪽 버튼(또는 Ctrl+드래그)으로 시점을 돌릴 수 있습니다. 기둥 색은 압력 기준(빨강 낮음 → 파랑 높음)이며 수치 판단은 2D 화면과 종단을 쓰세요.</span>
</div>
)}
<div style={box}>
<b>절점 표고 입력</b>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label>표고 기준
<select value={mode} onChange={(e) => setMode(e.target.value as ElevationMode)} style={inputStyle}>
<option value="ground">지반고 (DEM 그대로)</option>
<option value="invert">관저고 (지반고 − 매설깊이)</option>
</select>
</label>
<label>대상
<select value={target} onChange={(e) => setTarget(e.target.value as typeof target)} style={inputStyle}>
<option value="all">전체 절점</option>
<option value="selected">선택 절점 ({selectedNodeIds.length})</option>
<option value="zero">표고가 0인 절점만</option>
</select>
</label>
{mode === 'invert' && <label>기본 매설깊이 (m)<input type="number" step="0.1" value={depth} onChange={(e) => setDepth(e.target.value)} style={inputStyle} /></label>}
{mode === 'invert' && <label style={{ alignSelf: 'end' }}><input type="checkbox" checked={useAssetDepth} onChange={(e) => setUseAssetDepth(e.target.checked)} /> 자산 속성 "매설깊이"가 있으면 그 값</label>}
</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button className="btn btn-primary" style={btn} disabled={!dem || !!busy} onClick={() => void task('DEM에서 표고 읽는 중', async () => applyGround(await sampleGround(targetIds()), 'DEM'))}>DEM 표고 입력</button>
<button className="btn btn-secondary" style={btn} disabled={!!busy} title="Copernicus 90m 근사값 (설계용으로는 DEM 파일 권장)"
onClick={() => void task('인터넷 표고 조회 중', async () => {
const ids = targetIds();
if (ids.length > 20000) throw new Error('절점이 2만 개를 넘습니다. 선택해서 나눠 실행하세요.');
const ext = { minX: 0, minY: 0, maxX: 1, maxY: 1 };
const vals = await lookupApproxElevations(ids.map((id) => modelToLngLat(model.nodes[id].x, model.nodes[id].y, crs, ext)), (done) => setBusy(`인터넷 표고 조회 중 ${done}/${ids.length}`));
applyGround(Object.fromEntries(ids.map((id, k) => [id, vals[k]])), '인터넷(90m)');
})}>인터넷 표고 (90m 근사)</button>
</div>
<span style={muted}>배수지는 수위(동수두)이므로 바꾸지 않습니다. 실행 취소(Ctrl+Z)할 수 있습니다.{us ? ' DEM 미터 값을 ft로 바꿔 넣습니다.' : ''}</span>
</div>
<div style={box}>
<b>표고 검증</b>
<div style={{ display: 'flex', gap: 6, alignItems: 'end' }}>
<label style={{ width: 120 }}>허용 차이 ({lenUnit})<input type="number" step="0.1" value={tol} onChange={(e) => setTol(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!dem || !!busy} onClick={() => void task('표고 비교 중', async () => {
const ground = await sampleGround(Object.keys(model.nodes));
setCache('demCheck', checkElevations(model, ground, opts, Number(tol) || 0));
})}>위 기준({mode === 'invert' ? '관저고' : '지반고'})으로 검증</button>
</div>
{check && (
<>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ flex: 1 }}>검사 {check.checked}개 · 차이 큰 절점 <b>{check.rows.length}</b>개{check.missing ? ` · DEM 범위 밖 ${check.missing}개` : ''}</span>
{check.rows.length > 0 && <button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => onSelectElements(check.rows.map((r) => r.id), [])}><Crosshair size={10} />모두 선택</button>}
</div>
{check.rows.length > 0 && (
<div style={{ maxHeight: 240, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>절점</th><th style={th}>모델 표고</th><th style={th}>DEM 기준</th><th style={th}>차이</th></tr></thead>
<tbody>
{check.rows.slice(0, 500).map((r) => (
<tr key={r.id} style={{ cursor: 'pointer' }} onClick={() => onFocus('node', r.id)}>
<td style={{ ...td, textAlign: 'left' }}>{r.id}</td><td style={td}>{r.model.toFixed(2)}</td><td style={td}>{r.expected.toFixed(2)}</td><td style={td}>{r.diff > 0 ? '+' : ''}{r.diff.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
{dem && onAddUserLayer && (
<div style={box}>
<b>유역 · 하천</b>
<span style={muted}>DEM의 웅덩이를 메운 뒤 D8 흐름 방향과 누적 유량으로 하천선을 뽑고, 선택 절점(취수원·배수지 등)을 출구로 하는 상류 유역을 구합니다. 큰 DEM은 100만 칸 이하로 줄여 계산합니다.</span>
<div style={{ display: 'flex', gap: 6, alignItems: 'end', flexWrap: 'wrap' }}>
<label style={{ width: 130 }}>최소 유역 (km²)<input type="number" step="0.05" value={minArea} onChange={(e) => setMinArea(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!!busy} onClick={() => void task('하천 추출 중', async () => {
const fc = await demHydro(dem.id, { op: 'streams', minAreaKm2: Number(minArea) || 0.1 });
if (!fc.features.length) throw new Error('조건에 맞는 하천이 없습니다. 최소 유역을 줄이세요.');
addLayer(`하천 (≥${minArea} km²)`, fc, '#0284c7');
})}>하천선 추출</button>
<button className="btn btn-secondary" style={btn} disabled={!!busy || !selectedNodeIds.length} title="선택한 절점마다 상류 유역" onClick={() => void task('유역 계산 중', async () => {
const ids = selectedNodeIds.filter((id) => model.nodes[id]);
const fc = await demHydro(dem.id, { op: 'watershed', points: ids.map((id) => [model.nodes[id].x, model.nodes[id].y]), crs: proj, snap: 50 });
fc.features.forEach((f) => { if (f.properties) f.properties.node = ids[Number(f.properties.point)]; });
if (!fc.features.length) throw new Error('선택 절점이 DEM 범위 밖입니다.');
addLayer(`유역 (${ids.slice(0, 3).join(', ')}${ids.length > 3 ? ' …' : ''})`, fc, '#0d9488');
})}>선택 절점 유역 ({selectedNodeIds.length})</button>
</div>
</div>
)}
<div style={box}>
<b>점 표고로 DEM 만들기 (TIN)</b>
<span style={muted}>표고 점들을 삼각망으로 이어 격자 DEM을 만듭니다. 측량 표고점 레이어나 관망 절점 표고를 쓸 수 있습니다.</span>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 80px', gap: 6 }}>
<label>점 자료
<select value={ptSource} onChange={(e) => { setPtSource(e.target.value); setPtField(''); }} style={inputStyle}>
<option value="network">관망 절점 표고</option>
{pointLayers.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label>표고 필드
<select value={ptField} disabled={!ptLayer} onChange={(e) => setPtField(e.target.value)} style={inputStyle}>
<option value="">{ptLayer ? '선택' : '-'}</option>
{numericFields.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
</label>
<label>격자 (m)<input type="number" value={cell} onChange={(e) => setCell(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} disabled={!!busy || (!!ptLayer && !ptField)} onClick={() => void task('DEM 만드는 중', async () => {
if (!ptLayer) {
const k = us ? 1 / 3.280839895 : 1;
const pts = Object.values(model.nodes).filter((n) => n.type !== 'RESERVOIR' && n.elevation !== 0).map((n) => [n.x, n.y, n.elevation * k] as [number, number, number]);
setDem(await demFromPoints(pts, proj, Number(cell), '관망 절점 표고 DEM'));
} else {
const pts: [number, number, number][] = [];
for (const f of ptLayer.featureCollection.features) {
const z = Number(f.properties?.[ptField]);
const g = f.geometry;
if (!Number.isFinite(z) || !g) continue;
if (g.type === 'Point') pts.push([g.coordinates[0], g.coordinates[1], z]);
else if (g.type === 'MultiPoint') for (const c of g.coordinates) pts.push([c[0], c[1], z]);
}
setDem(await demFromPoints(pts, 'EPSG:4326', Number(cell), `${ptLayer.name} ${ptField} DEM`, proj));
}
})}>DEM 만들기</button>
</div>
</div>
);
};
+219
View File
@@ -0,0 +1,219 @@
// 수요량 테이블: every junction's demand categories at a glance, Excel paste /
// CSV bulk input, 수요 배분 and 누수(이미터) 배분 over a node set.
import React, { useMemo, useState } from 'react';
import { X } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import { getUnitSystem, createDisplayConverter, getDisplayUnits } from './unitSystem';
import {
applyDemandRows,
distributeDemand,
distributeEmitters,
getDemandCategories,
parseDemandRows,
totalBaseDemand,
} from './extrasActions';
interface Props {
model: NetworkModel;
units: ReturnType<typeof createDisplayConverter>;
displayUnits: ReturnType<typeof getDisplayUnits>;
selectedNodeIds: string[];
onApply: (next: NetworkModel, message: string) => void;
onFocusNode: (id: string) => void;
onClose: () => void;
}
type Tab = 'table' | 'paste' | 'distribute' | 'leak';
const fmt = (v: number) => (Math.abs(v) >= 100 ? v.toFixed(1) : Math.abs(v) >= 1 ? v.toFixed(3) : v.toPrecision(3));
const field: React.CSSProperties = { display: 'grid', gap: 2, fontSize: 11, color: 'var(--text-secondary)' };
const inp: React.CSSProperties = { padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: 4, fontSize: 12 };
export const DemandTableModal: React.FC<Props> = ({ model, units, displayUnits, selectedNodeIds, onApply, onFocusNode, onClose }) => {
const [tab, setTab] = useState<Tab>('table');
const [filter, setFilter] = useState('');
const junctions = useMemo(() => Object.values(model.nodes).filter((n) => n.type === 'JUNCTION').map((n) => n.id), [model]);
const selJunctions = selectedNodeIds.filter((id) => model.nodes[id]?.type === 'JUNCTION');
const [scope, setScope] = useState<'selected' | 'all'>(selJunctions.length ? 'selected' : 'all');
const targetIds = scope === 'selected' ? selJunctions : junctions;
const rows = useMemo(() => {
const f = filter.trim().toLowerCase();
return junctions
.filter((id) => !f || id.toLowerCase().includes(f))
.map((id) => {
const cats = getDemandCategories(model, id);
return { id, total: totalBaseDemand(model, id), cats, emitter: model.extras?.emitters[id] };
});
}, [model, junctions, filter]);
const grandTotal = useMemo(() => junctions.reduce((a, id) => a + totalBaseDemand(model, id), 0), [model, junctions]);
const exportCsv = () => {
const lines = ['ID,수요량,패턴,범주'];
for (const id of junctions) for (const c of getDemandCategories(model, id)) lines.push([id, units.flow(c.demand), c.pattern ?? '', c.category ?? ''].join(','));
const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '수요량.csv';
a.click();
URL.revokeObjectURL(a.href);
};
return (
<div role="dialog" aria-modal="true" aria-label="수요량 테이블" style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,.35)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 'min(760px, 96vw)', maxHeight: '88vh', background: 'var(--bg-panel)', borderRadius: 8, boxShadow: '0 12px 40px rgba(0,0,0,.25)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
<b style={{ flex: 1 }}>수요량 테이블 · 일괄 입력 · 배분</b>
<button className="btn btn-ghost" onClick={onClose} aria-label="닫기"><X size={14} /></button>
</div>
<div style={{ display: 'flex', gap: 4, padding: '8px 14px 0' }}>
{([['table', '절점 수요량'], ['paste', '붙여넣기 / CSV'], ['distribute', '수요 배분'], ['leak', '누수(이미터) 배분']] as [Tab, string][]).map(([k, l]) => (
<button key={k} className={tab === k ? 'btn btn-primary' : 'btn btn-secondary'} style={{ height: 26, fontSize: 12 }} onClick={() => setTab(k)}>{l}</button>
))}
</div>
<div style={{ padding: 14, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 10, fontSize: 12 }}>
{tab === 'table' && (
<>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="ID 검색" style={{ ...inp, width: 160 }} />
<span style={{ color: 'var(--text-muted)', flex: 1 }}>접합점 {junctions.length}개 · 기본 수요 합계 {fmt(units.flow(grandTotal))} {displayUnits.flow}</span>
<button className="btn btn-secondary" style={{ height: 26, fontSize: 12 }} onClick={exportCsv}>CSV 내보내기</button>
</div>
<div style={{ border: '1px solid var(--border)', maxHeight: 420, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11.5 }}>
<thead>
<tr>{['ID', `합계 (${displayUnits.flow})`, '범주', '패턴', '이미터 계수'].map((h) => <th key={h} style={{ position: 'sticky', top: 0, background: 'var(--bg-subtle)', textAlign: 'left', padding: '4px 6px' }}>{h}</th>)}</tr>
</thead>
<tbody>
{rows.slice(0, 2000).map((r) => (
<tr key={r.id} onClick={() => onFocusNode(r.id)} style={{ cursor: 'pointer', borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '3px 6px' }}>{r.id}</td>
<td style={{ padding: '3px 6px', fontFamily: 'var(--font-mono)' }}>{fmt(units.flow(r.total))}</td>
<td style={{ padding: '3px 6px' }}>{r.cats.length > 1 || r.cats[0]?.category ? r.cats.map((c) => `${c.category ?? '-'} ${fmt(units.flow(c.demand))}`).join(' · ') : '-'}</td>
<td style={{ padding: '3px 6px' }}>{[...new Set(r.cats.map((c) => c.pattern || '(기본)'))].join(', ')}</td>
<td style={{ padding: '3px 6px' }}>{r.emitter ?? ''}</td>
</tr>
))}
</tbody>
</table>
{rows.length > 2000 && <div style={{ padding: 6, color: 'var(--text-muted)' }}>처음 2,000개만 표시합니다. ID 검색으로 좁히세요.</div>}
</div>
<span style={{ color: 'var(--text-muted)' }}>행을 누르면 지도가 그 절점으로 이동합니다. 범주별 편집은 절점을 선택한 뒤 속성창의 "추가 속성"에서 합니다.</span>
</>
)}
{tab === 'paste' && <PasteTab model={model} units={units} displayUnits={displayUnits} onApply={onApply} />}
{(tab === 'distribute' || tab === 'leak') && (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span>대상</span>
<label><input type="radio" checked={scope === 'selected'} disabled={!selJunctions.length} onChange={() => setScope('selected')} /> 선택한 접합점 ({selJunctions.length})</label>
<label><input type="radio" checked={scope === 'all'} onChange={() => setScope('all')} /> 전체 접합점 ({junctions.length})</label>
</div>
)}
{tab === 'distribute' && <DistributeTab model={model} units={units} displayUnits={displayUnits} ids={targetIds} onApply={onApply} />}
{tab === 'leak' && <LeakTab model={model} units={units} displayUnits={displayUnits} ids={targetIds} onApply={onApply} />}
</div>
</div>
</div>
);
};
type SubProps = Pick<Props, 'model' | 'units' | 'displayUnits' | 'onApply'>;
const PasteTab: React.FC<SubProps> = ({ model, units, displayUnits, onApply }) => {
const [text, setText] = useState('');
const [mode, setMode] = useState<'replace' | 'add'>('replace');
const parsed = useMemo(() => parseDemandRows(text), [text]);
const apply = () => {
const r = applyDemandRows(model, parsed.rows, mode, units.toFlow);
if (!r.updated.length) {
alert(`적용할 접합점이 없습니다.${r.unknown.length ? ` 모델에 없는 ID: ${r.unknown.slice(0, 10).join(', ')}` : ''}`);
return;
}
onApply(r.model, `수요량 일괄 입력: 접합점 ${r.updated.length}개${r.unknown.length ? `, 없는 ID ${r.unknown.length}개 무시` : ''}`);
setText('');
};
return (
<>
<span style={{ color: 'var(--text-secondary)' }}>
엑셀에서 <b>ID · 수요량 · [패턴] · [범주]</b> 열을 복사해 붙여넣거나 CSV 파일을 여세요. 수요량 단위는 {displayUnits.flow}입니다. 같은 ID가 여러 줄이면 범주가 여러 개가 됩니다.
</span>
<input type="file" accept=".csv,.txt,.tsv" onChange={async (e) => { const f = e.target.files?.[0]; if (f) setText(await f.text()); e.target.value = ''; }} />
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={10} placeholder={'J-1\t12.5\tP1\t가정용\nJ-1\t3\tP2\t상업용\nJ-2\t8'} style={{ ...inp, fontFamily: 'var(--font-mono)' }} />
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<label><input type="radio" checked={mode === 'replace'} onChange={() => setMode('replace')} /> 바꾸기 (목록의 절점 수요를 이 값으로)</label>
<label><input type="radio" checked={mode === 'add'} onChange={() => setMode('add')} /> 범주 추가 (기존 수요 유지)</label>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ flex: 1, color: parsed.bad.length ? '#b45309' : 'var(--text-muted)' }}>
읽은 행 {parsed.rows.length}개{parsed.bad.length ? ` · 읽지 못한 줄: ${parsed.bad.slice(0, 8).join(', ')}${parsed.bad.length > 8 ? ' …' : ''}` : ''}
</span>
<button className="btn btn-primary" disabled={!parsed.rows.length} onClick={apply}>적용</button>
</div>
</>
);
};
const DistributeTab: React.FC<SubProps & { ids: string[] }> = ({ model, units, displayUnits, ids, onApply }) => {
const [total, setTotal] = useState('');
const [by, setBy] = useState<'demand' | 'equal'>('demand');
const [category, setCategory] = useState('배분');
const [pattern, setPattern] = useState('');
const apply = () => {
try {
const next = distributeDemand(model, ids, units.toFlow(Number(total)), by, category.trim() || '배분', pattern || undefined);
onApply(next, `수요 ${total} ${displayUnits.flow}를 접합점 ${ids.length}개에 ${by === 'demand' ? '수요 비례' : '균등'} 배분했습니다 (범주 "${category}").`);
} catch (e) {
alert(e instanceof Error ? e.message : String(e));
}
};
return (
<>
<span style={{ color: 'var(--text-secondary)' }}>총량을 대상 접합점에 새 수요 범주로 나눠 더합니다 (기존 수요는 그대로). 대규모 수용가·신규 개발 수요 반영에 씁니다.</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 10 }}>
<label style={field}>배분할 총량 ({displayUnits.flow})<input type="number" value={total} onChange={(e) => setTotal(e.target.value)} style={inp} /></label>
<label style={field}>배분 방식
<select value={by} onChange={(e) => setBy(e.target.value as 'demand' | 'equal')} style={inp}>
<option value="demand">기존 수요 비례</option>
<option value="equal">균등</option>
</select>
</label>
<label style={field}>범주 이름<input value={category} onChange={(e) => setCategory(e.target.value)} style={inp} /></label>
<label style={field}>패턴
<select value={pattern} onChange={(e) => setPattern(e.target.value)} style={inp}>
<option value="">(기본)</option>
{Object.keys(model.patterns).map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
</div>
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} disabled={!ids.length || !(Number(total) > 0)} onClick={apply}>배분 적용 ({ids.length}개)</button>
</>
);
};
const LeakTab: React.FC<SubProps & { ids: string[] }> = ({ model, units, displayUnits, ids, onApply }) => {
const si = getUnitSystem(model.options.units) === 'SI';
const [total, setTotal] = useState('');
const [pressure, setPressure] = useState(si ? '30' : '40');
const [exp, setExp] = useState(String(model.options.emitterExponent ?? 0.5));
const apply = () => {
try {
const r = distributeEmitters(model, ids, units.toFlow(Number(total)), Number(pressure), Number(exp));
onApply(r.model, `누수량 ${total} ${displayUnits.flow}를 접합점 ${Object.keys(r.coefficients).length}개의 이미터 계수로 배분했습니다 (관로 연장 비례).`);
} catch (e) {
alert(e instanceof Error ? e.message : String(e));
}
};
return (
<>
<span style={{ color: 'var(--text-secondary)' }}>
총 누수량을 각 접합점에 연결된 관로 연장(반씩) 비율로 나누고, 기준 압력에서 그 유량이 나오도록 이미터 계수 C = Q / P<sup>지수</sup>를 넣습니다. 기존 이미터 계수는 덮어씁니다.
</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 10 }}>
<label style={field}>총 누수량 ({displayUnits.flow})<input type="number" value={total} onChange={(e) => setTotal(e.target.value)} style={inp} /></label>
<label style={field}>기준 압력 ({si ? 'm' : 'psi'})<input type="number" value={pressure} onChange={(e) => setPressure(e.target.value)} style={inp} /></label>
<label style={field}>압력 지수<input type="number" step="0.05" value={exp} onChange={(e) => setExp(e.target.value)} style={inp} /></label>
</div>
{Number(exp) !== (model.options.emitterExponent ?? 0.5) && <span style={{ color: '#b45309' }}>해석은 모델 설정의 이미터 지수({model.options.emitterExponent ?? 0.5})를 씁니다. 다르게 하려면 모델 설정도 바꾸세요.</span>}
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} disabled={!ids.length || !(Number(total) > 0)} onClick={apply}>이미터 배분 적용 ({ids.length}개)</button>
</>
);
};
+190
View File
@@ -0,0 +1,190 @@
// 분석 tab > 편집 도구: transform, splits, node dissolve/merge, pipe check,
// flow tracing and shortest path. Every change goes through onApplyModel (undo).
import React, { useMemo, useState } from 'react';
import { Crosshair } from 'lucide-react';
import type { AnalysisPanelProps } from './AnalysisPanel';
import {
autoFixPipes,
centroid,
connectNodeToPipe,
dissolveNode,
findPipeIssues,
mergeNodes,
similarityFrom2Points,
splitPipeByLength,
splitPipeEqual,
traceFlow,
transformFn,
transformNetwork,
} from './editTools';
import { findProfilePath } from './profilePath';
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
const btn: React.CSSProperties = { height: 24, fontSize: 'var(--fs-2xs)' };
const muted: React.CSSProperties = { color: 'var(--text-muted)' };
export const EditToolsSection: React.FC<AnalysisPanelProps> = ({ model, result, selectedNodeIds, selectedLinkIds, units, displayUnits, onApplyModel, onSelectElements }) => {
const [err, setErr] = useState<string | null>(null);
const run = (fn: () => void) => {
setErr(null);
try {
fn();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
};
const selPipes = selectedLinkIds.filter((id) => model.pipes[id]);
const selNodes = selectedNodeIds.filter((id) => model.nodes[id]);
// transform
const [dx, setDx] = useState('0');
const [dy, setDy] = useState('0');
const [rot, setRot] = useState('0');
const [scale, setScale] = useState('1');
const [onlySel, setOnlySel] = useState(false);
// 2-point fix
const [refA, setRefA] = useState('');
const [refB, setRefB] = useState('');
const [toA, setToA] = useState('');
const [toB, setToB] = useState('');
// split
const [parts, setParts] = useState('2');
const [interval, setStep] = useState('');
// merge
const [keep, setKeep] = useState('');
const issues = useMemo(() => findPipeIssues(model), [model]);
const xy = (s: string) => {
const [x, y] = s.split(/[\s,]+/).map(Number);
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(`좌표 "${s}"를 읽을 수 없습니다 (예: 203512.4, 451230.8).`);
return { x, y };
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
{err && <div style={{ color: 'var(--crit)' }}>{err}</div>}
<div style={box}>
<b>이동 · 회전 · 축척</b>
<span style={muted}>모델 좌표 단위로 옮기고, 대상 절점들의 중심을 기준으로 회전·확대합니다. 양 끝이 함께 움직이는 관로의 꺾임점도 따라갑니다. 연장은 그대로이니 필요하면 관망 도구의 관로길이 재계산을 쓰세요.</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4 }}>
<label>X 이동<input value={dx} onChange={(e) => setDx(e.target.value)} style={inputStyle} /></label>
<label>Y 이동<input value={dy} onChange={(e) => setDy(e.target.value)} style={inputStyle} /></label>
<label>회전(°)<input value={rot} onChange={(e) => setRot(e.target.value)} style={inputStyle} /></label>
<label>축척<input value={scale} onChange={(e) => setScale(e.target.value)} style={inputStyle} /></label>
</div>
<label><input type="checkbox" checked={onlySel} disabled={!selNodes.length} onChange={(e) => setOnlySel(e.target.checked)} /> 선택 절점만 ({selNodes.length})</label>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} onClick={() => run(() => {
const ids = onlySel ? selNodes : undefined;
const t = { dx: Number(dx) || 0, dy: Number(dy) || 0, rotate: Number(rot) || 0, scale: Number(scale) || 1, origin: centroid(model, ids) };
const r = transformNetwork(model, transformFn(t), ids);
onApplyModel(r.model, `절점 ${r.moved}개를 변환했습니다 (이동 ${t.dx}, ${t.dy} · 회전 ${t.rotate}° · 축척 ${t.scale}).`);
})}>적용</button>
</div>
<div style={box}>
<b>2점 좌표 보정</b>
<span style={muted}>기준 절점 두 개의 올바른 좌표를 넣으면 관망 전체를 그에 맞게 이동·회전·축척합니다 (좌표계가 어긋난 도면 보정).</span>
<div style={{ display: 'grid', gridTemplateColumns: '80px 1fr', gap: 4, alignItems: 'center' }}>
<input value={refA} onChange={(e) => setRefA(e.target.value)} placeholder="기준 절점 1" style={inputStyle} />
<input value={toA} onChange={(e) => setToA(e.target.value)} placeholder="올바른 X, Y" style={inputStyle} />
<input value={refB} onChange={(e) => setRefB(e.target.value)} placeholder="기준 절점 2" style={inputStyle} />
<input value={toB} onChange={(e) => setToB(e.target.value)} placeholder="올바른 X, Y" style={inputStyle} />
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} onClick={() => run(() => {
const a = model.nodes[refA.trim()], b = model.nodes[refB.trim()];
if (!a || !b) throw new Error('기준 절점 ID를 확인하세요.');
const r = transformNetwork(model, similarityFrom2Points(a, b, xy(toA), xy(toB)));
onApplyModel(r.model, `2점 보정으로 절점 ${r.moved}개 좌표를 바꿨습니다.`);
})}>보정 적용</button>
</div>
<div style={box}>
<b>관로 분할 (선택 관로 {selPipes.length}개)</b>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr auto', gap: 4, alignItems: 'end' }}>
<label>등분 수<input type="number" min={2} value={parts} onChange={(e) => setParts(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!selPipes.length} onClick={() => run(() => {
let m = model; let made = 0;
for (const id of selPipes) { const r = splitPipeEqual(m, id, Number(parts)); m = r.model; made += r.junctions.length; }
onApplyModel(m, `관로 ${selPipes.length}개를 ${parts}등분했습니다 (절점 ${made}개 추가).`);
})}>N등분</button>
<label>최대 간격 ({displayUnits.length})<input type="number" value={interval} onChange={(e) => setStep(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!selPipes.length || !(Number(interval) > 0)} onClick={() => run(() => {
let m = model; let made = 0;
for (const id of selPipes) { const r = splitPipeByLength(m, id, units.toLength(Number(interval))); m = r.model; made += r.junctions.length; }
onApplyModel(m, made ? `관로를 ${interval} ${displayUnits.length} 이하로 나눴습니다 (절점 ${made}개 추가).` : '간격보다 긴 관로가 없습니다.');
})}>간격 분할</button>
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} disabled={selPipes.length !== 1 || selNodes.length !== 1}
title="절점 하나와 관로 하나를 선택하세요" onClick={() => run(() => {
const r = connectNodeToPipe(model, selNodes[0], selPipes[0]);
onApplyModel(r.model, `절점 ${selNodes[0]}를 관로 ${selPipes[0]}에 접속했습니다 (분할 관로 ${r.newPipeId}).`);
})}>접속 분할: 선택 절점을 선택 관로에 연결</button>
</div>
<div style={box}>
<b>절점 제거 · 병합 (선택 절점 {selNodes.length}개)</b>
<span style={muted}>제거: 관경·조도가 같은 관로 2개 사이의 수요 없는 접합점을 없애고 관로를 하나로 합칩니다 (절점 자리는 꺾임점). 병합: 다른 절점들의 관로·수요를 남길 절점으로 옮깁니다.</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
<button className="btn btn-secondary" style={btn} disabled={!selNodes.length} onClick={() => run(() => {
let m = model;
for (const id of selNodes) m = dissolveNode(m, id);
onApplyModel(m, `절점 ${selNodes.length}개를 제거하고 관로를 합쳤습니다.`);
})}>선택 절점 제거</button>
<select value={keep} onChange={(e) => setKeep(e.target.value)} style={{ ...inputStyle, width: 110 }} aria-label="남길 절점">
<option value="">남길 절점</option>
{selNodes.map((id) => <option key={id} value={id}>{id}</option>)}
</select>
<button className="btn btn-secondary" style={btn} disabled={selNodes.length < 2 || !selNodes.includes(keep)} onClick={() => run(() => {
let m = model; const dropped: string[] = [];
for (const id of selNodes) if (id !== keep) { const r = mergeNodes(m, keep, id); m = r.model; dropped.push(...r.removedLinks); }
onApplyModel(m, `절점 ${selNodes.length - 1}개를 ${keep}에 병합했습니다${dropped.length ? ` (사이 관로 ${dropped.length}개 삭제)` : ''}.`);
})}>병합</button>
</div>
</div>
<div style={box}>
<b>관로 점검</b>
{([
['자기 자신으로 돌아오는 관로', issues.selfLoops, 'link'],
['끝 절점이 없는 관로', issues.dangling, 'link'],
['연장 0 이하', issues.nonPositiveLength, 'link'],
['관경 0', issues.zeroDiameter, 'link'],
['같은 두 절점을 잇는 평행 관로', issues.parallel.flat(), 'link'],
['연결 관로 없는 접합점', issues.orphanNodes, 'node'],
] as Array<[string, string[], 'node' | 'link']>).map(([label, ids, kind]) => (
<div key={label} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ flex: 1, color: ids.length ? undefined : 'var(--text-muted)' }}>{label}: <b>{ids.length}</b></span>
{ids.length > 0 && <button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => (kind === 'node' ? onSelectElements(ids, []) : onSelectElements([], ids))}><Crosshair size={10} />선택</button>}
</div>
))}
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} disabled={!issues.selfLoops.length && !issues.dangling.length && !issues.orphanNodes.length} onClick={() => run(() => {
const r = autoFixPipes(model);
onApplyModel(r.model, `자동 정리: 관로 ${r.removedPipes.length}개, 고립 접합점 ${r.removedNodes.length}개를 지웠습니다.`);
})}>자동 정리 (자기 루프·끝 없는 관로·수요 없는 고립 절점 삭제)</button>
<span style={muted}>평행 관로와 연장·관경 0은 의도일 수 있어 자동으로 고치지 않습니다. 선택해 확인하세요.</span>
</div>
<div style={box}>
<b>추적 · 경로</b>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button className="btn btn-secondary" style={btn} disabled={!result || selNodes.length !== 1} onClick={() => run(() => {
const r = traceFlow(model, result!, selNodes[0], 'up');
onSelectElements(r.nodes, r.links);
})}>상류 추적</button>
<button className="btn btn-secondary" style={btn} disabled={!result || selNodes.length !== 1} onClick={() => run(() => {
const r = traceFlow(model, result!, selNodes[0], 'down');
onSelectElements(r.nodes, r.links);
})}>하류 추적</button>
<button className="btn btn-secondary" style={btn} disabled={selNodes.length !== 2} onClick={() => run(() => {
const p = findProfilePath(model, selNodes[0], selNodes[1]);
if (!p) throw new Error('두 절점을 잇는 경로가 없습니다.');
onSelectElements(p.nodeIds, p.linkIds);
})}>두 절점 최단 경로</button>
</div>
<span style={muted}>상·하류 추적은 해석 결과의 평균 흐름 방향을 따라갑니다 (절점 1개 선택). 최단 경로는 관로 연장 기준입니다 (절점 2개 선택).</span>
</div>
</div>
);
};
+269
View File
@@ -0,0 +1,269 @@
// "추가 속성 (INP 고급)" under the property inspector: demand categories,
// emitter, initial/source quality, tank mixing & reaction, pump energy and
// [STATUS] override -- the inputs that used to be preserved raw only.
import React, { useEffect, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import { MIXING_MODELS, SOURCE_TYPES, type MixingModel, type SourceType } from './inpExtras';
import {
getDemandCategories,
setDemandCategories,
setEmitter,
setInitialQuality,
setMixing,
setPumpEnergy,
setSource,
setStatusOverride,
setTankReaction,
} from './extrasActions';
import { createDisplayConverter, getDisplayUnits } from './unitSystem';
import { inferCurveType } from './curveTools';
type Converter = ReturnType<typeof createDisplayConverter>;
type DisplayUnits = ReturnType<typeof getDisplayUnits>;
interface Props {
model: NetworkModel;
target: { type: 'node' | 'link'; id: string };
units: Converter;
displayUnits: DisplayUnits;
onApply: (next: NetworkModel, message: string) => void;
}
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6, background: 'var(--bg-panel)' };
const inp: React.CSSProperties = { height: 22, fontSize: 11, width: '100%' };
const row: React.CSSProperties = { display: 'grid', gridTemplateColumns: '110px 1fr', gap: 6, alignItems: 'center' };
const SOURCE_LABEL: Record<SourceType, string> = { CONCEN: '농도', MASS: '질량 부스터', SETPOINT: '설정점 부스터', FLOWPACED: '유량 비례 부스터' };
const MIX_LABEL: Record<MixingModel, string> = { MIXED: '완전 혼합', '2COMP': '2구획 혼합', FIFO: 'FIFO 플러그', LIFO: 'LIFO 플러그' };
export const ElementExtrasEditor: React.FC<Props> = (props) => {
const [open, setOpen] = useState(true);
const { model, target } = props;
const node = target.type === 'node' ? model.nodes[target.id] : undefined;
const pump = target.type === 'link' ? model.pumps[target.id] : undefined;
const pipe = target.type === 'link' ? model.pipes[target.id] : undefined;
const valve = target.type === 'link' ? model.valves?.[target.id] : undefined;
if (!node && !pump && !pipe && !valve) return null;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 'var(--fs-xs)' }}>
<button type="button" onClick={() => setOpen(!open)} style={{ border: 'none', background: 'none', padding: 0, textAlign: 'left', fontWeight: 600, cursor: 'pointer', color: 'inherit' }}>
{open ? '▾' : '▸'} 추가 속성 (다중 수요·이미터·수질·{node?.type === 'TANK' ? '혼합·' : ''}{pump ? '에너지·' : ''}초기 상태)
</button>
{open && (
<>
{node?.type === 'JUNCTION' && <DemandCategories {...props} />}
{node?.type === 'JUNCTION' && <EmitterBox {...props} />}
{node?.type === 'TANK' && <TankBox {...props} />}
{node && <QualityBox {...props} />}
{pump && <PumpEnergyBox {...props} />}
{(pipe || valve || pump) && <StatusBox {...props} kind={pump ? 'pump' : valve ? 'valve' : 'pipe'} />}
</>
)}
</div>
);
};
const DemandCategories: React.FC<Props> = ({ model, target, units, displayUnits, onApply }) => {
const load = () => getDemandCategories(model, target.id).map((c) => ({ demand: String(Math.round(units.flow(c.demand) * 1e4) / 1e4), pattern: c.pattern ?? '', category: c.category ?? '' }));
const [rows, setRows] = useState(load);
useEffect(() => setRows(load()), [model, target.id]); // eslint-disable-line react-hooks/exhaustive-deps
const patterns = Object.keys(model.patterns);
const total = rows.reduce((a, r) => a + (Number(r.demand) || 0), 0);
const save = () => {
try {
const cats = rows.map((r) => ({ demand: units.toFlow(Number(r.demand) || 0), pattern: r.pattern || undefined, category: r.category || undefined }));
onApply(setDemandCategories(model, target.id, cats), `절점 ${target.id} 수요량 범주 ${cats.length}개를 저장했습니다.`);
} catch (e) {
alert(e instanceof Error ? e.message : String(e));
}
};
return (
<div style={box}>
<b>수요량 범주 ({displayUnits.flow})</b>
<span style={{ color: 'var(--text-muted)' }}>범주가 둘 이상이면 [DEMANDS]로 저장되고, 첫 범주가 기본 수요량이 됩니다. 합계 {Math.round(total * 1000) / 1000}</span>
<table style={{ borderCollapse: 'collapse' }}>
<thead><tr><th style={{ textAlign: 'left' }}>수요량</th><th style={{ textAlign: 'left' }}>패턴</th><th style={{ textAlign: 'left' }}>범주</th><th /></tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td><input value={r.demand} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, demand: e.target.value } : x)))} style={{ ...inp, width: 70 }} aria-label={`${i + 1}번째 수요량`} /></td>
<td>
<select value={r.pattern} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, pattern: e.target.value } : x)))} style={{ ...inp, width: 80 }} aria-label={`${i + 1}번째 패턴`}>
<option value="">(기본)</option>
{patterns.map((p) => <option key={p} value={p}>{p}</option>)}
{r.pattern && !patterns.includes(r.pattern) && <option value={r.pattern}>{r.pattern} (없음)</option>}
</select>
</td>
<td><input value={r.category} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, category: e.target.value } : x)))} style={{ ...inp, width: 80 }} placeholder="예: 가정용" aria-label={`${i + 1}번째 범주`} /></td>
<td><button className="btn btn-ghost" style={{ padding: 2 }} disabled={rows.length <= 1} onClick={() => setRows(rows.filter((_, k) => k !== i))} aria-label={`${i + 1}번째 범주 삭제`}><Trash2 size={11} /></button></td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11 }} onClick={() => setRows([...rows, { demand: '0', pattern: '', category: '' }])}><Plus size={11} />범주 추가</button>
<button className="btn btn-primary" style={{ height: 22, fontSize: 11 }} onClick={save}>저장</button>
</div>
</div>
);
};
const EmitterBox: React.FC<Props> = ({ model, target, onApply }) => {
const cur = model.extras?.emitters[target.id];
const [v, setV] = useState(cur !== undefined ? String(cur) : '');
useEffect(() => setV(cur !== undefined ? String(cur) : ''), [cur, target.id]);
return (
<div style={box}>
<b>이미터 (누수·스프링클러)</b>
<div style={row}>
<span title="압력 1단위에서의 유출량 (원본 유량 단위 / 압력^지수)">유출 계수</span>
<div style={{ display: 'flex', gap: 4 }}>
<input value={v} onChange={(e) => setV(e.target.value)} placeholder="없음" style={inp} />
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11 }} onClick={() => onApply(setEmitter(model, target.id, v.trim() ? Number(v) : null), v.trim() ? `절점 ${target.id} 이미터 계수 ${v}` : `절점 ${target.id} 이미터 제거`)}>적용</button>
</div>
</div>
<span style={{ color: 'var(--text-muted)' }}>원본 모델 단위 ({model.options.units}, 지수 {model.options.emitterExponent ?? 0.5}). 여러 절점에 누수량을 나눌 때는 수요량 테이블의 "누수(이미터) 배분"을 쓰세요.</span>
</div>
);
};
const QualityBox: React.FC<Props> = ({ model, target, onApply }) => {
const q = model.extras?.quality[target.id];
const src = model.extras?.sources[target.id];
const [init, setInit] = useState(q !== undefined ? String(q) : '');
const [type, setType] = useState<SourceType | ''>(src?.type ?? '');
const [strength, setStrength] = useState(src ? String(src.strength) : '');
const [pattern, setPattern] = useState(src?.pattern ?? '');
useEffect(() => {
setInit(q !== undefined ? String(q) : '');
setType(src?.type ?? '');
setStrength(src ? String(src.strength) : '');
setPattern(src?.pattern ?? '');
}, [q, src, target.id]);
const apply = () => {
let m = setInitialQuality(model, target.id, init.trim() ? Number(init) : null);
m = setSource(m, target.id, type && strength.trim() ? { type, strength: Number(strength), pattern: pattern || undefined } : null);
onApply(m, `절점 ${target.id} 수질 입력을 저장했습니다.`);
};
return (
<div style={box}>
<b>수질</b>
<div style={row}><span>초기 수질</span><input value={init} onChange={(e) => setInit(e.target.value)} placeholder="0" style={inp} /></div>
<div style={row}>
<span>수원 수질 형식</span>
<select value={type} onChange={(e) => setType(e.target.value as SourceType | '')} style={inp}>
<option value="">없음</option>
{SOURCE_TYPES.map((t) => <option key={t} value={t}>{SOURCE_LABEL[t]} ({t})</option>)}
</select>
</div>
{type && (
<>
<div style={row}><span>수원 농도/질량</span><input value={strength} onChange={(e) => setStrength(e.target.value)} style={inp} /></div>
<div style={row}>
<span>수원 패턴</span>
<select value={pattern} onChange={(e) => setPattern(e.target.value)} style={inp}>
<option value="">(일정)</option>
{Object.keys(model.patterns).map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
</>
)}
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11, alignSelf: 'flex-start' }} onClick={apply}>수질 저장</button>
<span style={{ color: 'var(--text-muted)' }}>모델 설정의 수질 모드가 "화학물질"일 때 해석에 쓰입니다. 질량 부스터는 질량 유량(mg/min)입니다.</span>
</div>
);
};
const TankBox: React.FC<Props> = ({ model, target, onApply }) => {
const mix = model.extras?.mixing[target.id];
const k = model.extras?.tankReactions[target.id];
const [mode, setMode] = useState<MixingModel>(mix?.model ?? 'MIXED');
const [frac, setFrac] = useState(mix?.fraction !== undefined ? String(mix.fraction) : '0.5');
const [react, setReact] = useState(k !== undefined ? String(k) : '');
useEffect(() => {
setMode(mix?.model ?? 'MIXED');
setFrac(mix?.fraction !== undefined ? String(mix.fraction) : '0.5');
setReact(k !== undefined ? String(k) : '');
}, [mix, k, target.id]);
const apply = () => {
let m = setMixing(model, target.id, { model: mode, fraction: mode === '2COMP' ? Number(frac) : undefined });
m = setTankReaction(m, target.id, react.trim() ? Number(react) : null);
onApply(m, `탱크 ${target.id} 혼합·반응 설정을 저장했습니다.`);
};
return (
<div style={box}>
<b>탱크 혼합·반응</b>
<div style={row}>
<span>혼합 모델</span>
<select value={mode} onChange={(e) => setMode(e.target.value as MixingModel)} style={inp}>
{MIXING_MODELS.map((m) => <option key={m} value={m}>{MIX_LABEL[m]} ({m})</option>)}
</select>
</div>
{mode === '2COMP' && <div style={row}><span>혼합 구획 비율</span><input value={frac} onChange={(e) => setFrac(e.target.value)} style={inp} /></div>}
<div style={row}><span title="1/day, 성장은 양수·감쇠는 음수">반응 계수</span><input value={react} onChange={(e) => setReact(e.target.value)} placeholder="전역값 사용" style={inp} /></div>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11, alignSelf: 'flex-start' }} onClick={apply}>저장</button>
</div>
);
};
const PumpEnergyBox: React.FC<Props> = ({ model, target, onApply }) => {
const pe = model.extras?.energy.pumps[target.id];
const [eff, setEff] = useState(pe?.efficiency ?? '');
const [price, setPrice] = useState(pe?.price !== undefined ? String(pe.price) : '');
const [pattern, setPattern] = useState(pe?.pattern ?? '');
useEffect(() => {
setEff(pe?.efficiency ?? '');
setPrice(pe?.price !== undefined ? String(pe.price) : '');
setPattern(pe?.pattern ?? '');
}, [pe, target.id]);
const effCurves = Object.keys(model.curves).filter((id) => inferCurveType(model, id) === 'EFFICIENCY' || id === eff);
return (
<div style={box}>
<b>펌프 에너지</b>
<div style={row}>
<span>효율 곡선</span>
<select value={eff} onChange={(e) => setEff(e.target.value)} style={inp}>
<option value="">(전역 효율)</option>
{effCurves.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div style={row}><span>전력 단가 (/kWh)</span><input value={price} onChange={(e) => setPrice(e.target.value)} placeholder="전역 단가" style={inp} /></div>
<div style={row}>
<span>단가 패턴</span>
<select value={pattern} onChange={(e) => setPattern(e.target.value)} style={inp}>
<option value="">(전역 패턴)</option>
{Object.keys(model.patterns).map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11, alignSelf: 'flex-start' }}
onClick={() => onApply(setPumpEnergy(model, target.id, { efficiency: eff || undefined, price: price.trim() ? Number(price) : undefined, pattern: pattern || undefined }), `펌프 ${target.id} 에너지 설정을 저장했습니다.`)}>저장</button>
<span style={{ color: 'var(--text-muted)' }}>효율 곡선은 "곡선 편집"의 효율 탭에서 만듭니다.</span>
</div>
);
};
const StatusBox: React.FC<Props & { kind: 'pipe' | 'valve' | 'pump' }> = ({ model, target, onApply, kind }) => {
const cur = model.extras?.statusOverrides[target.id] ?? '';
const [v, setV] = useState(cur);
useEffect(() => setV(cur), [cur, target.id]);
const options = kind === 'valve' ? ['OPEN', 'CLOSED', 'ACTIVE'] : ['OPEN', 'CLOSED'];
const isNumeric = v !== '' && !options.includes(v.toUpperCase());
return (
<div style={box}>
<b>초기 상태 재정의 [STATUS]</b>
<div style={row}>
<span>상태</span>
<div style={{ display: 'flex', gap: 4 }}>
<select value={isNumeric ? '#' : v.toUpperCase()} onChange={(e) => setV(e.target.value === '#' ? '1' : e.target.value)} style={inp}>
<option value="">재정의 없음</option>
{options.map((o) => <option key={o} value={o}>{o === 'OPEN' ? '열림' : o === 'CLOSED' ? '닫힘' : '활성'} ({o})</option>)}
{kind !== 'pipe' && <option value="#">{kind === 'pump' ? '속도 설정' : '설정값'}</option>}
</select>
{isNumeric && <input value={v} onChange={(e) => setV(e.target.value)} style={{ ...inp, width: 60 }} />}
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11 }} onClick={() => onApply(setStatusOverride(model, target.id, v || null), v ? `${target.id} 초기 상태를 ${v}(으)로 재정의했습니다.` : `${target.id} 초기 상태 재정의를 지웠습니다.`)}>적용</button>
</div>
</div>
<span style={{ color: 'var(--text-muted)' }}>[STATUS] 값은 관로·밸브·펌프 자체의 상태보다 우선합니다.</span>
</div>
);
};
+85
View File
@@ -0,0 +1,85 @@
// 에너지 옵션 ([ENERGY] globals) and 전역 반응 옵션 ([REACTIONS] ORDER/GLOBAL/
// LIMITING/ROUGHNESS) for the model settings page.
import React, { useState } from 'react';
import type { NetworkModel } from './inpModel';
import { getKeyValue, setEnergyGlobal, setReactionGlobal } from './extrasActions';
import { inferCurveType } from './curveTools';
interface Props {
model: NetworkModel;
onApply: (next: NetworkModel, message: string) => void;
}
const label: React.CSSProperties = { display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' };
const input: React.CSSProperties = { width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' };
export const EnergyReactionOptions: React.FC<Props> = ({ model, onApply }) => {
const [open, setOpen] = useState(false);
const energy = model.extras?.energy.global;
const react = model.extras?.reactionsGlobal;
const setE = (key: string, value: string) => onApply(setEnergyGlobal(model, key, value.trim() || null), `에너지 옵션 ${key} = ${value || '(기본)'}`);
const setR = (key: string, value: string) => onApply(setReactionGlobal(model, key, value.trim() || null), `반응 옵션 ${key} = ${value || '(기본)'}`);
// Blur-commit text fields so typing does not push one undo step per key.
const text = (kind: 'E' | 'R', key: string, title: string, help: string, placeholder: string) => {
const cur = getKeyValue(kind === 'E' ? energy : react, key) ?? '';
return (
<label style={label} title={help}>
{title}
<input key={`${key}:${cur}`} defaultValue={cur} placeholder={placeholder} style={input}
onBlur={(e) => { if (e.target.value.trim() !== cur) (kind === 'E' ? setE : setR)(key, e.target.value); }} />
</label>
);
};
const order = (key: string, title: string, values: string[]) => (
<label style={label}>
{title}
<select value={getKeyValue(react, key) ?? ''} onChange={(e) => setR(key, e.target.value)} style={input}>
<option value="">기본 (1차)</option>
{values.map((v) => <option key={v} value={v}>{v}차</option>)}
</select>
</label>
);
const patterns = Object.keys(model.patterns);
const effCurves = Object.keys(model.curves).filter((id) => inferCurveType(model, id) === 'EFFICIENCY');
const globalEff = getKeyValue(energy, 'Global Efficiency') ?? '';
return (
<div style={{ background: 'var(--bg-subtle)', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '12px' }}>
<button type="button" onClick={() => setOpen(!open)} style={{ border: 'none', background: 'none', padding: 0, fontWeight: 600, cursor: 'pointer', color: 'inherit' }}>
{open ? '▾' : '▸'} 에너지·수질 반응 옵션 ([ENERGY]·[REACTIONS])
</button>
{open && (
<div style={{ display: 'grid', gap: 10, marginTop: 8 }}>
<b style={{ fontSize: 11 }}>에너지</b>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '10px' }}>
<label style={label} title="효율 곡선이 없는 펌프에 적용되는 효율(%) 또는 효율 곡선 ID">
전역 펌프 효율 (%)
<input key={`ge:${globalEff}`} defaultValue={globalEff} placeholder="75" list="spipe-eff-curves" style={input}
onBlur={(e) => { if (e.target.value.trim() !== globalEff) setE('Global Efficiency', e.target.value); }} />
<datalist id="spipe-eff-curves">{effCurves.map((c) => <option key={c} value={c} />)}</datalist>
</label>
{text('E', 'Global Price', '전역 전력 단가 (/kWh)', '펌프별 단가가 없을 때 쓰는 kWh당 비용', '0')}
<label style={label} title="시간대별 전력 단가 배율 패턴">
전역 단가 패턴
<select value={getKeyValue(energy, 'Global Pattern') ?? ''} onChange={(e) => setE('Global Pattern', e.target.value)} style={input}>
<option value="">(일정)</option>
{patterns.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
{text('E', 'Demand Charge', '최대 수요 요금 (/kW)', '해석 기간 최대 전력(kW)당 추가 요금', '0')}
</div>
<b style={{ fontSize: 11 }}>수질 반응</b>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '10px' }}>
{order('Order Bulk', '수체 반응 차수', ['0', '1', '2'])}
{order('Order Wall', '관벽 반응 차수', ['0', '1'])}
{order('Order Tank', '탱크 반응 차수', ['0', '1', '2'])}
{text('R', 'Global Bulk', '전역 수체 반응계수 (1/day)', '관로별 값이 없을 때. 감쇠는 음수 (예: 염소 -0.5)', '0')}
{text('R', 'Global Wall', '전역 관벽 반응계수', '1차: m/day(ft/day), 0차: mg/m²/day. 감쇠는 음수', '0')}
{text('R', 'Limiting Potential', '한계 농도', '반응이 이 농도에 비례하도록 하는 한계값 (0 = 미사용)', '0')}
{text('R', 'Roughness Correlation', '조도-관벽 상관계수', '관벽 계수 = 상관계수 / C값 (H-W). 0 = 미사용', '0')}
</div>
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>빈칸은 EPANET 기본값을 씁니다. 펌프별 효율·단가는 펌프를 선택해 "추가 속성"에서, 탱크 반응계수는 탱크의 "추가 속성"에서 지정합니다.</span>
</div>
)}
</div>
);
};
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
import { CheckCircle2, AlertTriangle } from 'lucide-react';
import { type ComparisonResult, type SimulationResult } from './types';
export interface EngineComparisonPanelProps {
comparison: ComparisonResult | null;
serverResult: SimulationResult | null;
wasmResult: SimulationResult | null;
}
/** Native DLL vs WASM cross-validation summary (verification tab). */
export const EngineComparisonPanel: React.FC<EngineComparisonPanelProps> = ({
comparison,
serverResult,
wasmResult,
}) => (
<div>
{!comparison || !serverResult || !wasmResult ? (
<div style={{ padding: 'var(--s-8)', textAlign: 'center', color: 'var(--text-secondary)', fontSize: 'var(--fs-sm)' }}>
{wasmResult && !serverResult
? '서버(Native) 엔진 결과가 없어 교차검증을 할 수 없습니다. WASM 엔진 결과는 지도·결과 탭에서 확인할 수 있습니다.'
: serverResult && !wasmResult
? 'WASM 엔진 결과가 없어 교차검증을 할 수 없습니다. 서버(Native) 엔진 결과는 지도·결과 탭에서 확인할 수 있습니다.'
: "비교할 수리해석 결과가 없습니다. 상단 '해석 실행' 버튼을 클릭하세요."}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)' }}>
<div className="diff-grid">
<div className="result-card card-server">
<strong>서버 (Native DLL)</strong>
<div>버전: {serverResult.engine.version}</div>
<div>시간: {serverResult.timeSeconds}초</div>
<div style={{ color: 'var(--text-muted)' }}>
노드: {Object.keys(serverResult.nodes).length} / 링크: {Object.keys(serverResult.links).length}
</div>
</div>
<div className="result-card card-wasm">
<strong>클라이언트 (WASM)</strong>
<div>버전: {wasmResult.engine.version}</div>
<div>시간: {wasmResult.timeSeconds}초</div>
<div style={{ color: 'var(--text-muted)' }}>
노드: {Object.keys(wasmResult.nodes).length} / 링크: {Object.keys(wasmResult.links).length}
</div>
</div>
</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)', background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 'var(--s-4) var(--s-5)', lineHeight: 'var(--lh-prose)' }}>
<strong>수치 정합성 판정 기준</strong>: 각 물리량별 허용오차 <code>absTol + relTol × max(|Native|, |WASM|)</code> 기준으로 정합성을 판정합니다.
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--s-2)' }}>
<h4 style={{ margin: 0, fontSize: 'var(--fs-md)', color: 'var(--text)' }}>최대 절대 오차</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>기준: Head/Press 1e-4, Demand/Flow 1e-3, Vel/HL 1e-5</span>
</div>
<div className="diff-grid">
<div className="diff-row">
<span>Demand (수요량)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.demand.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Head (수두)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.head.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Pressure (수압)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.pressure.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Flow (유량)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.flow.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Velocity (유속)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.velocity.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Headloss (손실)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.headloss.toExponential(2)}</span>
</div>
</div>
</div>
{comparison.passed ? (
<div style={{ padding: 'var(--s-4)', background: 'var(--good-wash)', color: 'var(--good)', border: '1px solid var(--good)', borderRadius: 'var(--r-sm)', fontSize: 'var(--fs-sm)', display: 'flex', alignItems: 'center', gap: 'var(--s-3)' }}>
<CheckCircle2 size={16} />
<span><strong>정합성 통과</strong>: Native C DLL과 WebAssembly 해석 결과가 허용오차 범위 내에서 일치합니다.</span>
</div>
) : (
<div style={{ padding: 'var(--s-4)', background: 'var(--crit-wash)', color: 'var(--crit)', border: '1px solid var(--crit)', borderRadius: 'var(--r-sm)', fontSize: 'var(--fs-sm)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-3)', fontWeight: 'var(--fw-semi)' }}>
<AlertTriangle size={16} />
<span>허용오차 초과 또는 데이터 불일치 발생</span>
</div>
{comparison.missingIds.length > 0 && (
<div style={{ marginTop: 'var(--s-2)', fontSize: 'var(--fs-xs)' }}>
누락/단위 불일치: {comparison.missingIds.join(', ')}
</div>
)}
</div>
)}
</div>
)}
</div>
);
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import { Camera, ChevronDown, Download, FileText, Layers, Table } from 'lucide-react';
interface ExportMenuProps {
open: boolean;
onToggle: () => void;
onClose: () => void;
disabled: boolean;
hasResult: boolean;
onExportInp: () => void;
onExportNet: () => void;
onExportXlsx: () => void;
onExportDxf: () => void;
onExportCsv: () => void;
onCaptureMap: () => void;
onExportShapefile: () => void;
onShowInpText: () => void;
onOpenAttributeTable: () => void;
}
/** Header "내보내기" dropdown. Result-based exports are disabled until a run exists. */
export const ExportMenu: React.FC<ExportMenuProps> = ({
open,
onToggle,
onClose,
disabled,
hasResult,
onExportInp,
onExportNet,
onExportXlsx,
onExportDxf,
onExportCsv,
onCaptureMap,
onExportShapefile,
onShowInpText,
onOpenAttributeTable,
}) => {
const act = (fn: () => void) => () => {
fn();
onClose();
};
return (
<div className="dropdown-container">
<button
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
disabled={disabled}
className="btn btn-secondary"
title="INP, DXF, XLSX, CSV 등 내보내기"
>
<Download size={14} />
<span>내보내기</span>
<ChevronDown size={12} style={{ opacity: 0.6 }} />
</button>
{open && (
<div className="dropdown-menu align-right" style={{ width: '220px' }}>
<div className="dropdown-header">EPANET 모델 파일</div>
<button className="dropdown-item" onClick={act(onExportInp)}>
<FileText size={14} />
<span>.inp 모델 파일 다운로드</span>
</button>
<button className="dropdown-item" onClick={act(onExportNet)}>
<FileText size={14} />
<span>.net 네트워크 바이너리</span>
</button>
<div className="dropdown-divider" />
<div className="dropdown-header">엔지니어링 산출물</div>
<button className="dropdown-item" disabled={!hasResult} onClick={act(onExportXlsx)}>
<Table size={14} />
<span>검토용 수리계산서 (.xlsx)</span>
</button>
<button className="dropdown-item" onClick={act(onExportDxf)}>
<Layers size={14} />
<span>AutoCAD 표준 도면 (.dxf)</span>
</button>
<button className="dropdown-item" disabled={!hasResult} onClick={act(onExportCsv)}>
<FileText size={14} />
<span>24h 시계열 수치 (.csv)</span>
</button>
<div className="dropdown-divider" />
<button className="dropdown-item" onClick={act(onCaptureMap)}>
<Camera size={14} />
<span>지도 화면 캡처 (.png)</span>
</button>
<button className="dropdown-item" onClick={act(onExportShapefile)}>
<Layers size={14} />
<span>GIS Shapefile (.zip)</span>
</button>
<button className="dropdown-item" onClick={act(onShowInpText)}>
<FileText size={14} />
<span>INP 텍스트 전문 보기</span>
</button>
<button className="dropdown-item" onClick={act(onOpenAttributeTable)}>
<Table size={14} />
<span>속성 테이블 열기</span>
</button>
</div>
)}
</div>
);
};
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import { Layers } from 'lucide-react';
import { type SelectionTarget, type UserGisLayer } from './types';
export interface GisFeatureInspectorProps {
selectedTarget: Extract<SelectionTarget, { type: 'gis-feature' }>;
userLayers: UserGisLayer[];
handleConvertGisLayerToNetwork: (layer: UserGisLayer) => void;
}
/** Attributes of a selected GIS reference feature, with convert-to-network. */
export const GisFeatureInspector: React.FC<GisFeatureInspectorProps> = ({
selectedTarget,
userLayers,
handleConvertGisLayerToNetwork,
}) => (
<div
style={{
background: 'var(--bg-subtle)',
padding: '12px',
borderRadius: '6px',
border: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h3 style={{ margin: 0, color: 'var(--text)', fontSize: '14px' }}>
GIS 객체: <span style={{ color: 'var(--accent)' }}>#{selectedTarget.featureId}</span>
</h3>
<span
style={{
fontSize: '10px',
padding: '2px 6px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '4px',
color: 'var(--text-secondary)',
}}
>
{selectedTarget.geometryType || 'Feature'}
</span>
</div>
<div style={{ maxHeight: '280px', overflowY: 'auto', border: '1px solid var(--border)', borderRadius: '4px', background: 'var(--bg-panel)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '11px' }}>
<thead>
<tr style={{ background: 'var(--bg-subtle)', borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
<th style={{ padding: '4px 6px', width: '40%' }}>속성 (Field)</th>
<th style={{ padding: '4px 6px' }}>값 (Value)</th>
</tr>
</thead>
<tbody>
{Object.entries(selectedTarget.properties).map(([k, v]) => (
<tr key={k} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '3px 6px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)' }}>{k}</td>
<td style={{ padding: '3px 6px', color: 'var(--text)', wordBreak: 'break-word' }}>{String(v ?? '-')}</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() => {
const layer = userLayers.find((l) => l.id === selectedTarget.layerId);
if (layer) handleConvertGisLayerToNetwork(layer);
}}
className="btn btn-secondary"
style={{ width: '100%', marginTop: '8px', fontSize: '11px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}
>
<Layers size={13} aria-hidden="true" />
<span>이 레이어를 관망으로 변환</span>
</button>
</div>
);
+342
View File
@@ -0,0 +1,342 @@
// 분석 tab > GIS 도구: layer CRS reassignment and export, geometry operations,
// Voronoi / grid generation, Excel/CSV points, OSM buildings, and layer -> network
// tools (valves, meters, small blocks, asset tables).
import React, { useMemo, useState } from 'react';
import proj4 from 'proj4';
import * as XLSX from 'xlsx';
import type { Feature, FeatureCollection } from 'geojson';
import type { AnalysisPanelProps } from './AnalysisPanel';
import type { RasterOverlay, UserGisLayer } from './types';
import { exportLayerFile, gisOp, reprojectLayer } from './api';
import { crsProj4 } from './demTools';
import { lngLatToModel, modelToLngLat, SUPPORTED_CRS_DEFINITIONS } from './crs';
import { blocksFromPolygons } from './blockIsolation';
import { applyAssetTable, assignMeters, extentCorners, insertValves, worldFileCorners, writeBlocks } from './gisTools';
import { m3DayToFlow } from './unitSystem';
export interface GisToolsProps {
onAddUserLayer?: (layer: UserGisLayer) => void;
onSetUserLayers?: (update: (prev: UserGisLayer[]) => UserGisLayer[]) => void;
rasterOverlays?: RasterOverlay[];
onRasterOverlays?: (update: (prev: RasterOverlay[]) => RasterOverlay[]) => void;
}
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
const btn: React.CSSProperties = { height: 24, fontSize: 'var(--fs-2xs)' };
const muted: React.CSSProperties = { color: 'var(--text-muted)' };
const grid = (cols: string): React.CSSProperties => ({ display: 'grid', gridTemplateColumns: cols, gap: 6, alignItems: 'end' });
const PROJECTED = SUPPORTED_CRS_DEFINITIONS.filter((d) => d.code !== 'LOCAL' && d.proj4Def);
const COLORS = ['#7c3aed', '#0891b2', '#ca8a04', '#db2777', '#16a34a', '#ea580c'];
function newLayer(name: string, fc: FeatureCollection, sourceCrs?: string): UserGisLayer {
const geomTypes = [...new Set(fc.features.map((f) => f.geometry?.type).filter((t): t is NonNullable<typeof t> => !!t))];
return {
id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name, fileName: `${name}.geojson`, featureCollection: fc, featureCount: fc.features.length, geomTypes,
estimatedCrs: 'EPSG:4326', visible: true, opacity: 0.8, color: COLORS[Math.floor(Math.random() * COLORS.length)],
createdAt: new Date().toISOString(), sourceCrs,
};
}
function pointsOf(layer: UserGisLayer): Array<{ lon: number; lat: number; props: Record<string, unknown> }> {
const out: Array<{ lon: number; lat: number; props: Record<string, unknown> }> = [];
for (const f of layer.featureCollection.features) {
const g = f.geometry;
const props = (f.properties ?? {}) as Record<string, unknown>;
if (g?.type === 'Point') out.push({ lon: g.coordinates[0], lat: g.coordinates[1], props });
else if (g?.type === 'MultiPoint') for (const c of g.coordinates) out.push({ lon: c[0], lat: c[1], props });
}
return out;
}
export const GisToolsSection: React.FC<AnalysisPanelProps & GisToolsProps> = ({ model, crs, userLayers, onApplyModel, onAddUserLayer, onSetUserLayers, rasterOverlays = [], onRasterOverlays }) => {
const [layerId, setLayerId] = useState(userLayers[0]?.id ?? '');
const layer = userLayers.find((l) => l.id === layerId) ?? userLayers[0];
const fields = useMemo(() => Object.keys(layer?.featureCollection.features[0]?.properties ?? {}), [layer]);
const isPoint = !!layer?.geomTypes.some((g) => g.includes('Point'));
const isPolygon = !!layer?.geomTypes.some((g) => g.includes('Polygon'));
const proj = crsProj4(crs);
const ext = useMemo(() => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(model.nodes)) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); }
return { minX, minY, maxX, maxY };
}, [model]);
const toModel = (lon: number, lat: number) => { const [x, y] = lngLatToModel(lon, lat, crs, ext); return { x, y }; };
const [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const task = async (label: string, fn: () => Promise<void> | void) => {
setErr(null); setMsg(null); setBusy(label);
try { await fn(); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { setBusy(null); }
};
const add = (l: UserGisLayer) => { onAddUserLayer?.(l); setMsg(`"${l.name}" 레이어를 추가했습니다 (${l.featureCount}개).`); };
// state for the forms
const [assignCrs, setAssignCrs] = useState<string>(crs !== 'LOCAL' ? crs : 'EPSG:5186');
const [expFmt, setExpFmt] = useState('shp');
const [expCrs, setExpCrs] = useState<string>(crs !== 'LOCAL' ? crs : 'EPSG:4326');
const [op, setOp] = useState('buffer');
const [dist, setDist] = useState('10');
const [otherId, setOtherId] = useState('');
const [dissolveField, setDissolveField] = useState('');
const [cell, setCell] = useState('100');
const [sheet, setSheet] = useState<{ name: string; rows: Record<string, unknown>[] } | null>(null);
const [xCol, setXCol] = useState('');
const [yCol, setYCol] = useState('');
const [ptCrs, setPtCrs] = useState<string>(crs !== 'LOCAL' ? crs : 'EPSG:4326');
const [tolM, setTolM] = useState('5');
const [idField, setIdField] = useState('');
const [statusField, setStatusField] = useState('');
const [closedValue, setClosedValue] = useState('폐');
const [flowField, setFlowField] = useState('');
const [flowUnit, setFlowUnit] = useState<'m3month' | 'm3day' | 'lps'>('m3month');
const [nameField, setNameField] = useState('');
const [table, setTable] = useState('');
const [tableKind, setTableKind] = useState<'link' | 'node'>('link');
const [img, setImg] = useState<{ file: File; world?: string; w: number; h: number } | null>(null);
const [imgExt, setImgExt] = useState('');
const images = rasterOverlays.filter((r) => r.id.startsWith('img-'));
const pickImage = async (files: FileList | null) => {
const list = [...(files ?? [])];
const file = list.find((f) => /\.(png|jpe?g|webp|gif)$/i.test(f.name));
if (!file) throw new Error('PNG/JPG 이미지를 고르세요 (월드 파일 .pgw/.jgw를 함께 고르면 위치가 자동으로 맞습니다).');
const worldFile = list.find((f) => /\.(pgw|jgw|pngw|jpgw|wld|tfw)$/i.test(f.name));
const url = URL.createObjectURL(file);
const dims = await new Promise<{ w: number; h: number }>((resolve, reject) => {
const im = new Image();
im.onload = () => resolve({ w: im.naturalWidth, h: im.naturalHeight });
im.onerror = () => reject(new Error('이미지를 읽을 수 없습니다.'));
im.src = url;
});
URL.revokeObjectURL(url);
setImg({ file, world: worldFile ? await worldFile.text() : undefined, ...dims });
setImgExt(`${ext.minX.toFixed(1)}, ${ext.minY.toFixed(1)}, ${ext.maxX.toFixed(1)}, ${ext.maxY.toFixed(1)}`);
};
const crsOptions = (value: string, onChange: (v: string) => void, withWgs = true) => (
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
{PROJECTED.filter((d) => withWgs || d.code !== 'EPSG:4326').map((d) => <option key={d.code} value={d.code}>{d.name} ({d.code})</option>)}
</select>
);
const proj4Of = (code: string) => PROJECTED.find((d) => d.code === code)?.proj4Def ?? code;
if (!proj) return <div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>좌표계가 "로컬 평면"이면 GIS 레이어와 관망을 맞출 수 없습니다. 상단에서 관망 좌표계를 지정하세요.</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
{busy && <div style={{ color: 'var(--text-secondary)' }}>{busy}...</div>}
{err && <div style={{ color: 'var(--crit)' }}>{err}</div>}
{msg && <div style={{ color: 'var(--ok, #15803d)' }}>{msg}</div>}
<div style={box}>
<b>대상 레이어</b>
{userLayers.length ? (
<>
<select value={layer?.id ?? ''} onChange={(e) => setLayerId(e.target.value)} style={inputStyle} aria-label="대상 레이어">
{userLayers.map((l) => <option key={l.id} value={l.id}>{l.name} ({l.featureCount}, {l.geomTypes.join('/')})</option>)}
</select>
<span style={muted}>원본 좌표계: {layer?.sourceCrs ? (PROJECTED.find((d) => d.proj4Def === layer.sourceCrs)?.code ?? layer.sourceCrs.slice(0, 40)) : layer?.estimatedCrs}</span>
</>
) : <span style={muted}>GIS 레이어가 없습니다. 프로젝트 메뉴의 GIS 레이어 추가(SHP·DXF·DGN·MIF·GML·KML·GPKG·GeoJSON)로 넣거나, 아래 생성 기능을 쓰세요.</span>}
</div>
{layer && (
<div style={box}>
<b>좌표계 다시 지정 · 내보내기</b>
<div style={grid('1fr auto')}>
<label>실제 좌표계{crsOptions(assignCrs, setAssignCrs)}</label>
<button className="btn btn-secondary" style={btn} disabled={!!busy} onClick={() => void task('좌표 다시 계산', async () => {
const to = proj4Of(assignCrs);
const fc = await reprojectLayer(layer.featureCollection, layer.sourceCrs ?? 'EPSG:4326', to);
onSetUserLayers?.((prev) => prev.map((l) => (l.id === layer.id ? { ...l, featureCollection: fc, sourceCrs: to, estimatedCrs: assignCrs } : l)));
setMsg(`레이어 좌표를 ${assignCrs} 기준으로 다시 놓았습니다.`);
})}>다시 지정</button>
</div>
<span style={muted}>파일 좌표가 원래 이 좌표계였는데 다르게 읽혀 엉뚱한 곳에 놓였을 때 씁니다.</span>
<div style={grid('90px 1fr auto')}>
<label>형식<select value={expFmt} onChange={(e) => setExpFmt(e.target.value)} style={inputStyle}>
<option value="shp">SHP (zip)</option><option value="gpkg">GeoPackage</option><option value="dxf">DXF</option><option value="geojson">GeoJSON</option><option value="kml">KML</option>
</select></label>
<label>좌표계{crsOptions(expCrs, setExpCrs)}</label>
<button className="btn btn-secondary" style={btn} disabled={!!busy} onClick={() => void task('내보내는 중', () => exportLayerFile(layer.featureCollection, proj4Of(expCrs), expFmt, layer.name))}>내보내기</button>
</div>
</div>
)}
{layer && (
<div style={box}>
<b>도형 연산</b>
<div style={grid('110px 1fr auto')}>
<label>연산<select value={op} onChange={(e) => setOp(e.target.value)} style={inputStyle}>
<option value="buffer">버퍼</option><option value="dissolve">병합(디졸브)</option><option value="clip">잘라내기(교차)</option><option value="difference">빼기(차이)</option>
</select></label>
{op === 'buffer' && <label>거리 (m)<input type="number" value={dist} onChange={(e) => setDist(e.target.value)} style={inputStyle} /></label>}
{op === 'dissolve' && <label>기준 필드<select value={dissolveField} onChange={(e) => setDissolveField(e.target.value)} style={inputStyle}><option value="">(전체 하나로)</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>}
{(op === 'clip' || op === 'difference') && <label>기준 레이어<select value={otherId} onChange={(e) => setOtherId(e.target.value)} style={inputStyle}><option value="">선택</option>{userLayers.filter((l) => l.id !== layer.id).map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}</select></label>}
<button className="btn btn-secondary" style={btn} disabled={!!busy || ((op === 'clip' || op === 'difference') && !otherId)} onClick={() => void task('계산 중', async () => {
const other = userLayers.find((l) => l.id === otherId)?.featureCollection;
const fc = await gisOp(op, { crs: proj, layer: layer.featureCollection, distance: Number(dist), field: dissolveField || null, other });
add(newLayer(`${layer.name} ${{ buffer: `버퍼 ${dist}m`, dissolve: '병합', clip: '잘라내기', difference: '빼기' }[op]}`, fc));
})}>실행</button>
</div>
</div>
)}
<div style={box}>
<b>레이어 만들기</b>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'end' }}>
<button className="btn btn-secondary" style={btn} disabled={!!busy} title="각 접합점이 가장 가까운 영역 (급수 면적 배분 등)" onClick={() => void task('보로노이 계산', async () => {
const js = Object.values(model.nodes).filter((n) => n.type === 'JUNCTION');
const fc = await gisOp('voronoi', { crs: proj, points: js.map((n) => [n.x, n.y]), ids: js.map((n) => n.id) });
add(newLayer('절점 보로노이', fc));
})}>절점 보로노이</button>
<label style={{ width: 90 }}>격자 (m)<input type="number" value={cell} onChange={(e) => setCell(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!!busy} onClick={() => void task('격자 만들기', async () => {
const fc = await gisOp('grid', { crs: proj, bounds: [ext.minX, ext.minY, ext.maxX, ext.maxY], cell: Number(cell) });
add(newLayer(`격자 ${cell}m`, fc));
})}>관망 범위 격자</button>
<button className="btn btn-secondary" style={btn} disabled={!!busy} title="OpenStreetMap 건물 외곽선 (인터넷 필요)" onClick={() => void task('OSM 건물 불러오는 중', async () => {
const [w, s] = modelToLngLat(ext.minX, ext.minY, crs, ext);
const [e, n] = modelToLngLat(ext.maxX, ext.maxY, crs, ext);
const pad = 0.002;
const bbox = [Math.min(s, n) - pad, Math.min(w, e) - pad, Math.max(s, n) + pad, Math.max(w, e) + pad];
if ((bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) > 0.0025) throw new Error('관망 범위가 너무 넓습니다 (약 25 km² 이하). 일부를 잘라 쓰세요.');
const q = `[out:json][timeout:60];(way["building"](${bbox.join(',')}););out geom;`;
const res = await fetch('https://overpass-api.de/api/interpreter', { method: 'POST', body: new URLSearchParams({ data: q }) });
if (!res.ok) throw new Error(`OSM 서버 오류 ${res.status} (잠시 뒤 다시 시도)`);
const data = await res.json() as { elements: Array<{ id: number; tags?: Record<string, string>; geometry?: Array<{ lat: number; lon: number }> }> };
const features: Feature[] = data.elements.filter((el) => (el.geometry?.length ?? 0) >= 4).map((el) => ({
type: 'Feature', properties: { osm_id: el.id, building: el.tags?.building ?? '', name: el.tags?.name ?? '', levels: el.tags?.['building:levels'] ?? '' },
geometry: { type: 'Polygon', coordinates: [el.geometry!.map((p) => [p.lon, p.lat])] },
}));
if (!features.length) throw new Error('관망 범위에 OSM 건물 자료가 없습니다.');
add(newLayer('OSM 건물', { type: 'FeatureCollection', features }));
})}>OSM 건물</button>
</div>
<div style={grid('1fr')}>
<label>엑셀·CSV 좌표 → 점 레이어
<input type="file" accept=".xlsx,.xls,.csv" onChange={async (e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (!f) return;
await task('표 읽는 중', async () => {
const wb = XLSX.read(await f.arrayBuffer(), { type: 'array' });
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(wb.Sheets[wb.SheetNames[0]], { defval: '' });
if (!rows.length) throw new Error('행이 없습니다.');
const cols = Object.keys(rows[0]);
setSheet({ name: f.name.replace(/\.[^.]+$/, ''), rows });
setXCol(cols.find((c) => /^(x|경도|lon|lng|x좌표)/i.test(c)) ?? cols[0]);
setYCol(cols.find((c) => /^(y|위도|lat|y좌표)/i.test(c)) ?? cols[1] ?? cols[0]);
});
}} />
</label>
</div>
{sheet && (
<div style={grid('1fr 1fr 1.4fr auto')}>
<label>X 열<select value={xCol} onChange={(e) => setXCol(e.target.value)} style={inputStyle}>{Object.keys(sheet.rows[0]).map((c) => <option key={c} value={c}>{c}</option>)}</select></label>
<label>Y 열<select value={yCol} onChange={(e) => setYCol(e.target.value)} style={inputStyle}>{Object.keys(sheet.rows[0]).map((c) => <option key={c} value={c}>{c}</option>)}</select></label>
<label>좌표계{crsOptions(ptCrs, setPtCrs)}</label>
<button className="btn btn-secondary" style={btn} onClick={() => void task('점 만들기', () => {
const def = proj4Of(ptCrs);
const features: Feature[] = [];
let bad = 0;
for (const r of sheet.rows) {
const x = Number(r[xCol]), y = Number(r[yCol]);
if (!Number.isFinite(x) || !Number.isFinite(y)) { bad++; continue; }
const [lon, lat] = ptCrs === 'EPSG:4326' ? [x, y] : proj4(def, 'EPSG:4326', [x, y]);
features.push({ type: 'Feature', properties: r as Record<string, never>, geometry: { type: 'Point', coordinates: [lon, lat] } });
}
add(newLayer(sheet.name, { type: 'FeatureCollection', features }, ptCrs === 'EPSG:4326' ? undefined : def));
if (bad) setMsg(`점 ${features.length}개를 만들었습니다 (좌표 없는 행 ${bad}개 제외).`);
})}>점 레이어 추가</button>
</div>
)}
</div>
{layer && (
<div style={box}>
<b>관망에 반영</b>
{isPoint && (
<>
<div style={grid('1fr 1fr 1fr 70px')}>
<label>밸브 ID 필드<select value={idField} onChange={(e) => setIdField(e.target.value)} style={inputStyle}><option value="">(자동 번호)</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>
<label>개폐 필드<select value={statusField} onChange={(e) => setStatusField(e.target.value)} style={inputStyle}><option value="">(모두 열림)</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>
<label>닫힘 값<input value={closedValue} onChange={(e) => setClosedValue(e.target.value)} disabled={!statusField} style={inputStyle} /></label>
<label>허용 (m)<input type="number" value={tolM} onChange={(e) => setTolM(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} onClick={() => void task('밸브 넣는 중', () => {
const specs = pointsOf(layer).map((p) => ({ ...toModel(p.lon, p.lat), id: idField ? String(p.props[idField] ?? '') : undefined, closed: !!statusField && String(p.props[statusField] ?? '').trim() === closedValue.trim() }));
const r = insertValves(model, specs, Number(tolM) || 5);
onApplyModel(r.model, `밸브 ${r.added.length}개를 관로에 넣었습니다${r.skipped ? ` (관로에서 ${tolM}m 넘게 떨어졌거나 끝점 위인 ${r.skipped}개 제외)` : ''}.`);
})}>점을 밸브로 추가 (가장 가까운 관로를 나눠 TCV 삽입)</button>
<div style={grid('1fr 110px 70px')}>
<label>사용량 필드<select value={flowField} onChange={(e) => setFlowField(e.target.value)} style={inputStyle}><option value="">선택</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>
<label>단위<select value={flowUnit} onChange={(e) => setFlowUnit(e.target.value as typeof flowUnit)} style={inputStyle}><option value="m3month">m³/월</option><option value="m3day">m³/일</option><option value="lps">L/s</option></select></label>
<label>허용 (m)<input type="number" value={tolM} onChange={(e) => setTolM(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} disabled={!flowField} onClick={() => void task('계량기 배정 중', () => {
const toM3Day = (v: number) => (flowUnit === 'm3month' ? v / 30 : flowUnit === 'lps' ? v * 86.4 : v);
const meters = pointsOf(layer).map((p) => ({ ...toModel(p.lon, p.lat), flow: m3DayToFlow(toM3Day(Number(p.props[flowField])), model.options.units) }));
const r = assignMeters(model, meters, Number(tolM) || 5);
onApplyModel(r.model, `계량기 ${meters.length - r.unassigned}개를 접합점 ${Object.keys(r.nodes).length}곳의 "계량기" 수요로 배정했습니다${r.unassigned ? ` (${r.unassigned}개는 허용 거리 밖·값 없음)` : ''}.`);
})}>계량기 배정 (가장 가까운 접합점의 "계량기" 수요 범주)</button>
</>
)}
{isPolygon && (
<div style={grid('1fr auto')}>
<label>블록 이름 필드<select value={nameField} onChange={(e) => setNameField(e.target.value)} style={inputStyle}><option value="">(도형 번호)</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>
<button className="btn btn-secondary" style={btn} onClick={() => void task('소블록 매핑', () => {
const map = blocksFromPolygons(model, layer.featureCollection, (x, y) => modelToLngLat(x, y, crs, ext), nameField || undefined);
const r = writeBlocks(model, map);
onApplyModel(r.model, `소블록: 절점 ${r.nodes}개, 관로 ${r.links}개의 자산 속성 "소블록"을 채웠습니다.`);
})}>소블록 매핑</button>
</div>
)}
{!isPoint && !isPolygon && <span style={muted}>점 레이어는 밸브·계량기, 면 레이어는 소블록 매핑에 씁니다. 선 레이어는 프로젝트 메뉴의 "GIS로 관망 만들기"를 쓰세요.</span>}
</div>
)}
<div style={box}>
<b>이미지 겹치기 (항공사진·도면)</b>
<span style={muted}>PNG/JPG와 월드 파일(.pgw/.jgw)을 함께 고르면 관망 좌표계 기준으로 제자리에 놓입니다. 월드 파일이 없으면 범위(최소X, 최소Y, 최대X, 최대Y)를 입력하세요.</span>
<input type="file" multiple accept=".png,.jpg,.jpeg,.webp,.gif,.pgw,.jgw,.pngw,.jpgw,.wld,.tfw" onChange={(e) => { const fl = e.target.files; void task('이미지 읽는 중', () => pickImage(fl)); e.target.value = ''; }} />
{img && (
<>
<span>{img.file.name} · {img.w}×{img.h}px · {img.world ? '월드 파일 있음' : '범위 직접 입력'}</span>
{!img.world && <input value={imgExt} onChange={(e) => setImgExt(e.target.value)} placeholder="최소X, 최소Y, 최대X, 최대Y (관망 좌표계)" style={inputStyle} aria-label="이미지 범위" />}
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} onClick={() => void task('이미지 놓는 중', () => {
const corners = img.world ? worldFileCorners(img.world, img.w, img.h) : extentCorners(...(imgExt.split(/[\s,]+/).filter(Boolean).map(Number) as [number, number, number, number]));
const id = `img-${Date.now()}`;
onRasterOverlays?.((prev) => [...prev, { id, name: img.file.name, url: URL.createObjectURL(img.file), corners: corners.map((c) => modelToLngLat(c.x, c.y, crs, ext)), opacity: 0.7, visible: true }]);
setImg(null);
setMsg(`이미지 "${img.file.name}"를 지도에 겹쳤습니다.`);
})}>지도에 겹치기</button>
</>
)}
{images.map((r) => (
<div key={r.id} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ flex: 1 }}><input type="checkbox" checked={r.visible} onChange={(e) => onRasterOverlays?.((prev) => prev.map((x) => (x.id === r.id ? { ...x, visible: e.target.checked } : x)))} /> {r.name}</label>
<input type="range" min={0.1} max={1} step={0.05} value={r.opacity} aria-label={`${r.name} 투명도`} onChange={(e) => onRasterOverlays?.((prev) => prev.map((x) => (x.id === r.id ? { ...x, opacity: Number(e.target.value) } : x)))} />
<button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => { URL.revokeObjectURL(r.url); onRasterOverlays?.((prev) => prev.filter((x) => x.id !== r.id)); }}>삭제</button>
</div>
))}
</div>
<div style={box}>
<b>자산 속성 일괄 입력</b>
<span style={muted}>엑셀에서 첫 행 머리글(ID, 관재질, 설치년도, 매설깊이(m), 소블록 ...)과 함께 복사해 붙여넣으세요.</span>
<textarea rows={5} value={table} onChange={(e) => setTable(e.target.value)} placeholder={'ID\t관재질\t설치년도\nP-1\tDCIP\t1998'} style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label><input type="radio" checked={tableKind === 'link'} onChange={() => setTableKind('link')} /> 관로·링크</label>
<label><input type="radio" checked={tableKind === 'node'} onChange={() => setTableKind('node')} /> 절점</label>
<button className="btn btn-secondary" style={btn} disabled={!table.trim()} onClick={() => void task('속성 입력', () => {
const r = applyAssetTable(model, table, tableKind);
onApplyModel(r.model, `${tableKind === 'link' ? '링크' : '절점'} ${r.updated}개의 속성(${r.fields.join(', ')})을 넣었습니다${r.unknown.length ? ` (없는 ID ${r.unknown.length}개)` : ''}.`);
})}>적용</button>
</div>
</div>
</div>
);
};
+127
View File
@@ -0,0 +1,127 @@
import React from 'react';
import { Compass, Pencil, Activity, Search, Undo2, Redo2 } from 'lucide-react';
import { type SimulationResult, type SelectionTarget } from './types';
import { type NetworkModel } from './inpModel';
export interface HeaderCenterProps {
appMode: 'explore' | 'edit' | 'result';
setAppMode: React.Dispatch<React.SetStateAction<'explore' | 'edit' | 'result'>>;
wasmResult: SimulationResult | null;
networkModel: NetworkModel | null;
undoStack: NetworkModel[];
redoStack: NetworkModel[];
handleUndo: () => void;
handleRedo: () => void;
searchInputRef: React.RefObject<HTMLInputElement>;
searchQuery: string;
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
setSelectedTarget: React.Dispatch<React.SetStateAction<SelectionTarget>>;
setActiveTab: React.Dispatch<React.SetStateAction<import('./types').RightTab | null>>;
}
/** Header center: mode segment (탐색/편집/결과), undo/redo, quick search (Ctrl+K). */
export const HeaderCenter: React.FC<HeaderCenterProps> = ({
appMode,
setAppMode,
wasmResult,
networkModel,
undoStack,
redoStack,
handleUndo,
handleRedo,
searchInputRef,
searchQuery,
setSearchQuery,
setSelectedTarget,
setActiveTab,
}) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* Mode Segment: 탐색(1) | 편집(2) | 결과(3) */}
<div className="mode-segment">
<button
onClick={() => setAppMode('explore')}
className={`mode-btn ${appMode === 'explore' ? 'active' : ''}`}
title="탐색 모드 (단축키 1) - 요소 정보 조회 및 이동"
>
<Compass size={13} />
<span>탐색</span>
</button>
<button
onClick={() => setAppMode('edit')}
className={`mode-btn ${appMode === 'edit' ? 'active' : ''}`}
title="편집 모드 (단축키 2) - 요소 작도, 수정, 연결"
>
<Pencil size={13} />
<span>편집</span>
</button>
<button
onClick={() => {
if (wasmResult) setAppMode('result');
}}
disabled={!wasmResult}
className={`mode-btn ${appMode === 'result' ? 'active' : ''}`}
title={wasmResult ? '결과 모드 (단축키 3) - 시계열 및 수리 결과' : '먼저 해석을 실행하세요'}
>
<Activity size={13} />
<span>결과</span>
</button>
</div>
{/* Undo / Redo Buttons */}
<div style={{ display: 'flex', gap: '2px' }}>
<button
type="button"
className="btn btn-secondary"
onClick={handleUndo}
disabled={undoStack.length === 0 || !networkModel}
title="실행 취소 (Ctrl+Z)"
style={{ padding: '0 7px', height: '28px' }}
>
<Undo2 size={13} />
</button>
<button
type="button"
className="btn btn-secondary"
onClick={handleRedo}
disabled={redoStack.length === 0 || !networkModel}
title="다시 실행 (Ctrl+Y / Ctrl+Shift+Z)"
style={{ padding: '0 7px', height: '28px' }}
>
<Redo2 size={13} />
</button>
</div>
{/* Quick Search (Ctrl+K) */}
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Search
size={13}
style={{ position: 'absolute', left: '8px', color: 'var(--text-muted)', pointerEvents: 'none' }}
/>
<input
ref={searchInputRef}
type="text"
placeholder="검색 (Ctrl+K)"
value={searchQuery}
onChange={(e) => {
const q = e.target.value;
setSearchQuery(q);
if (!networkModel || !q.trim()) return;
const trimmed = q.trim();
if (networkModel.nodes[trimmed]) {
setSelectedTarget({ type: 'node', id: trimmed });
setActiveTab('inspector');
} else if (networkModel.pipes[trimmed] || networkModel.pumps[trimmed] || networkModel.valves?.[trimmed]) {
setSelectedTarget({ type: 'link', id: trimmed });
setActiveTab('inspector');
}
}}
style={{
width: '140px',
height: '28px',
paddingLeft: '26px',
fontSize: '11px',
}}
/>
</div>
</div>
);
+133
View File
@@ -0,0 +1,133 @@
import React from 'react';
import { Ruler } from 'lucide-react';
import type { InpNode, InpPipe } from './inpModel';
interface HglProfileViewerProps {
pipe: InpPipe;
node1: InpNode;
node2: InpNode;
node1Head: number;
node2Head: number;
node1Pressure: number;
node2Pressure: number;
unit: string;
pressureUnit?: string;
}
export const HglProfileViewer: React.FC<HglProfileViewerProps> = ({
pipe,
node1,
node2,
node1Head,
node2Head,
node1Pressure,
node2Pressure,
unit,
pressureUnit = unit,
}) => {
const elev1 = node1.elevation;
const elev2 = node2.elevation;
const hgl1 = node1Head || elev1 + node1Pressure;
const hgl2 = node2Head || elev2 + node2Pressure;
// Chart dimensions
const width = 320;
const height = 110;
const padLeft = 32;
const padRight = 16;
const padTop = 14;
const padBottom = 22;
const plotWidth = width - padLeft - padRight;
const plotHeight = height - padTop - padBottom;
const minVal = Math.min(elev1, elev2, hgl1, hgl2, 0);
const maxVal = Math.max(elev1, elev2, hgl1, hgl2, 10);
const range = maxVal - minVal || 1;
const getY = (val: number) => {
return padTop + plotHeight - ((val - minVal) / range) * plotHeight;
};
const x1 = padLeft;
const x2 = padLeft + plotWidth;
const yElev1 = getY(elev1);
const yElev2 = getY(elev2);
const yHgl1 = getY(hgl1);
const yHgl2 = getY(hgl2);
return (
<div
style={{
background: 'var(--bg-panel)',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '10px',
marginTop: '10px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<span style={{ fontWeight: 600, fontSize: '12px', color: 'var(--text)', display: 'inline-flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Ruler size={13} aria-hidden="true" />
관로 종단 수리동수두선 (HGL Profile)
</span>
<span style={{ fontSize: '10px', color: 'var(--text-secondary)' }}>연장: {pipe.length}</span>
</div>
<svg viewBox={`0 0 ${width} ${height}`} style={{ width: '100%', height: '110px' }}>
{/* Ground fill polygon */}
<polygon
points={`${x1},${yElev1} ${x2},${yElev2} ${x2},${padTop + plotHeight} ${x1},${padTop + plotHeight}`}
fill="#f1f5f9"
stroke="none"
/>
{/* Pressure water column fill between Ground and HGL */}
<polygon
points={`${x1},${yHgl1} ${x2},${yHgl2} ${x2},${yElev2} ${x1},${yElev1}`}
fill="rgba(59, 130, 246, 0.15)"
stroke="none"
/>
{/* Ground Elevation Line (Brown/Slate) */}
<line x1={x1} y1={yElev1} x2={x2} y2={yElev2} stroke="#78716c" strokeWidth="2.5" />
{/* HGL Hydraulic Grade Line (Blue) */}
<line x1={x1} y1={yHgl1} x2={x2} y2={yHgl2} stroke="#2563eb" strokeWidth="2" strokeDasharray="4,2" />
{/* Points & Labels at Node 1 */}
<circle cx={x1} cy={yHgl1} r="3" fill="#2563eb" />
<circle cx={x1} cy={yElev1} r="3" fill="#78716c" />
<text x={x1} y={yHgl1 - 4} fontSize="8" fontWeight="bold" fill="#2563eb" textAnchor="start">
HGL {hgl1.toFixed(1)}
</text>
<text x={x1} y={height - 4} fontSize="8" fill="#475569" textAnchor="start">
{pipe.node1} (지반 {elev1.toFixed(1)})
</text>
{/* Points & Labels at Node 2 */}
<circle cx={x2} cy={yHgl2} r="3" fill="#2563eb" />
<circle cx={x2} cy={yElev2} r="3" fill="#78716c" />
<text x={x2} y={yHgl2 - 4} fontSize="8" fontWeight="bold" fill="#2563eb" textAnchor="end">
HGL {hgl2.toFixed(1)}
</text>
<text x={x2} y={height - 4} fontSize="8" fill="#475569" textAnchor="end">
{pipe.node2} (지반 {elev2.toFixed(1)})
</text>
</svg>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: 'var(--text-secondary)', marginTop: '4px', borderTop: '1px solid #f8fafc', paddingTop: '4px' }}>
<span>
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>■ 동수압</span>: {node1Pressure.toFixed(1)} $\to$ {node2Pressure.toFixed(1)} {pressureUnit}
</span>
<span>
<span style={{ color: '#78716c', fontWeight: 600 }}>■ 지반고차</span>: {(elev2 - elev1).toFixed(1)}
</span>
<span>
<span style={{ color: 'var(--crit)', fontWeight: 600 }}>■ 마찰손실</span>: {Math.abs(hgl1 - hgl2).toFixed(2)}
</span>
</div>
</div>
);
};
+544
View File
@@ -0,0 +1,544 @@
import React, { useState } from 'react';
import { Filter, Plus, Trash2, Bookmark } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import type { MultiSelectionState, SelectionTarget, SimulationResult } from './types';
import {
computeUnitHeadloss,
diameterToMillimeters,
flowToM3Day,
getDisplayUnits,
headToMeters,
lengthToMeters,
pressureToMeters,
resolvePressureUnit,
velocityToMetersPerSecond,
type DisplayUnitMode,
createDisplayConverter,
} from './unitSystem';
import { rowMatchesAllConditions, type FilterCondition, type FilterOperator } from './resultSync';
const NODE_FILTER_FIELDS: Array<{ key: string; label: string }> = [
{ key: 'pressure', label: '동수압' },
{ key: 'demand', label: '수요량' },
{ key: 'head', label: '수리동수두' },
{ key: 'elev', label: '지반고' },
];
const LINK_FILTER_FIELDS: Array<{ key: string; label: string }> = [
{ key: 'flow', label: '유량' },
{ key: 'velocity', label: '유속' },
{ key: 'headloss', label: '손실수두(총)' },
{ key: 'gradient', label: '동수경사' },
{ key: 'diameter', label: '관경' },
{ key: 'length', label: '연장' },
];
const FILTER_OPERATORS: FilterOperator[] = ['<', '<=', '>', '>=', '=='];
interface SavedSelectionSet {
name: string;
subTab: 'nodes' | 'links';
ids: string[];
}
interface HydraulicTableViewerProps {
model: NetworkModel;
simulationResult: SimulationResult | null;
displayUnitMode?: DisplayUnitMode;
timeStepIndex: number;
selectedTarget: SelectionTarget;
multiSelection?: MultiSelectionState;
onMultiSelectionChange?: React.Dispatch<React.SetStateAction<MultiSelectionState>>;
onSelectTarget: (t: SelectionTarget) => void;
/**
* Project-wide judgement criteria (same setting the map's violation
* highlighting uses, user-adjustable in App.tsx) -- kept in sync here
* instead of this table defining its own separate minimum-pressure/
* maximum-velocity standard that silently disagreed with the map's.
*/
minPressureThreshold?: number;
maxVelocityThreshold?: number;
}
export const HydraulicTableViewer: React.FC<HydraulicTableViewerProps> = ({
model,
simulationResult,
displayUnitMode = 'korean',
minPressureThreshold = 15,
maxVelocityThreshold = 2.5,
timeStepIndex,
selectedTarget,
multiSelection,
onMultiSelectionChange,
onSelectTarget,
}) => {
const [subTab, setSubTab] = useState<'nodes' | 'links'>('nodes');
const [onlyWarnings, setOnlyWarnings] = useState(false);
// Compound numeric filter (P2-1), e.g. "pressure < 15 AND velocity > 2" --
// kept separately per table since node/link fields don't overlap.
const [nodeConditions, setNodeConditions] = useState<FilterCondition[]>([]);
const [linkConditions, setLinkConditions] = useState<FilterCondition[]>([]);
const [savedSets, setSavedSets] = useState<SavedSelectionSet[]>([]);
const activeConditions = subTab === 'nodes' ? nodeConditions : linkConditions;
const setActiveConditions = subTab === 'nodes' ? setNodeConditions : setLinkConditions;
const activeFilterFields = subTab === 'nodes' ? NODE_FILTER_FIELDS : LINK_FILTER_FIELDS;
const sourceUnits = model.options.units || simulationResult?.units.flow || 'LPS';
// The engine's own reported pressure unit (psi/kPa/m/bar/ft) can differ
// from what the flow unit's US/SI system implies (EPANET 2.2+ EN_PRESSUNITS),
// so it -- not a guess from the flow unit -- drives both the displayed
// value and the low-pressure warning threshold below.
const resultPressureUnit = resolvePressureUnit(simulationResult?.units?.pressure, sourceUnits);
const displayUnits = getDisplayUnits(sourceUnits, displayUnitMode, resultPressureUnit);
const units = createDisplayConverter(displayUnitMode, sourceUnits, resultPressureUnit, model.options.specificGravity);
const displayHead = units.head;
const displayFlow = units.flow;
const displayPressure = units.pressure;
const displayLength = units.length;
const displayDiameter = units.diameter;
const displayVelocity = units.velocity;
const formatSecs = (secs: number) => {
const hh = String(Math.floor(secs / 3600)).padStart(2, '0');
return `${hh}:00`;
};
const currentTimeStr = simulationResult?.timeSteps
? formatSecs(simulationResult.timeSteps[timeStepIndex] ?? 0)
: '00:00';
// 1. Process Nodes data
const nodeRows = Object.values(model.nodes).map((node, idx) => {
const res = simulationResult?.nodes[node.id];
const elev = displayHead(node.elevation);
const rawDemand = res?.timeSeries
? (res.timeSeries.demand[timeStepIndex] ?? res.demand)
: (res?.demand ?? (node.demand ?? 0));
const rawHead = res?.timeSeries
? (res.timeSeries.head[timeStepIndex] ?? res.head)
: (res?.head ?? node.elevation);
const rawPressure = res?.timeSeries
? (res.timeSeries.pressure[timeStepIndex] ?? res.pressure)
: (res?.pressure ?? 0);
const demand = displayFlow(rawDemand);
const head = displayHead(rawHead);
const pressure = displayPressure(rawPressure);
const pressureMeters = pressureToMeters(rawPressure, resultPressureUnit, model.options.specificGravity);
let statusType: 'ok' | 'warn' | 'info' = 'ok';
let statusText = '적정';
if (node.type === 'JUNCTION') {
const minThreshold = minPressureThreshold;
const maxThreshold = 70;
if (pressureMeters < minThreshold) {
statusType = 'warn';
statusText = '수압부족';
} else if (pressureMeters > maxThreshold) {
statusType = 'warn';
statusText = '과압';
}
} else {
statusType = 'info';
statusText = node.type === 'RESERVOIR' ? '상수원' : '배수지';
}
return {
idx: idx + 1,
id: node.id,
type: node.type,
elev,
demand: Math.round(demand * 100) / 100,
head: Math.round(head * 100) / 100,
pressure: Math.round(pressure * 100) / 100,
statusType,
statusText,
};
});
// 2. Process Links data
const linkRows = Object.values(model.pipes).map((pipe, idx) => {
const res = simulationResult?.links[pipe.id];
const length = displayLength(pipe.length);
const rawFlow = res?.timeSeries
? (res.timeSeries.flow[timeStepIndex] ?? res.flow)
: (res?.flow ?? 0);
const rawVelocity = res?.timeSeries
? (res.timeSeries.velocity[timeStepIndex] ?? res.velocity)
: (res?.velocity ?? 0);
const rawHeadloss = res?.timeSeries
? (res.timeSeries.headloss[timeStepIndex] ?? res.headloss)
: (res?.headloss ?? 0);
const flow = displayFlow(rawFlow);
const velocity = displayVelocity(rawVelocity);
const velocityMeters = velocityToMetersPerSecond(rawVelocity, sourceUnits);
// EN_HEADLOSS is the TOTAL head difference across the whole pipe, in the
// model's head unit (feet for a US-unit model) -- convert it for display
// just like every other head-length quantity, and keep it clearly
// distinct from the length-normalized gradient below (a longer pipe
// naturally accumulates more total headloss without being "worse").
const headloss = displayHead(rawHeadloss);
// 동수경사 (hydraulic gradient, per-mille): headloss normalized by pipe
// length, in the SAME display unit for both -- so it stays a valid
// per-pipe comparison metric regardless of unit system or pipe length.
const gradient = Math.round(computeUnitHeadloss(Math.abs(headloss), length) * 100) / 100;
let statusType: 'ok' | 'warn' | 'info' = 'ok';
let statusText = '적정';
const minVel = 0.3;
const maxVel = maxVelocityThreshold;
if (Math.abs(rawFlow) < 1e-9) {
statusType = 'info';
statusText = '정체/무유량';
} else if (velocityMeters < minVel) {
statusType = 'warn';
statusText = '유속미달';
} else if (velocityMeters > maxVel) {
statusType = 'warn';
statusText = '유속과다';
}
return {
idx: idx + 1,
id: pipe.id,
node1: pipe.node1,
node2: pipe.node2,
diameter: displayDiameter(pipe.diameter),
length,
roughness: pipe.roughness,
flow: Math.round(flow * 100) / 100,
velocity: Math.round(velocity * 100) / 100,
headloss: Math.round(headloss * 100) / 100,
gradient,
statusType,
statusText,
pipeStatus: pipe.status,
};
});
const filteredNodeRows = nodeRows
.filter((r) => !onlyWarnings || r.statusType === 'warn')
.filter((r) => rowMatchesAllConditions(r, nodeConditions));
const filteredLinkRows = linkRows
.filter((r) => !onlyWarnings || r.statusType === 'warn')
.filter((r) => rowMatchesAllConditions(r, linkConditions));
const addCondition = () => {
setActiveConditions([...activeConditions, { field: activeFilterFields[0].key, operator: '<', value: 0 }]);
};
const updateCondition = (idx: number, patch: Partial<FilterCondition>) => {
setActiveConditions(activeConditions.map((c, i) => (i === idx ? { ...c, ...patch } : c)));
};
const removeCondition = (idx: number) => {
setActiveConditions(activeConditions.filter((_, i) => i !== idx));
};
const activeFilteredRows = subTab === 'nodes' ? filteredNodeRows : filteredLinkRows;
const saveCurrentFilterAsSet = () => {
if (activeFilteredRows.length === 0) {
return;
}
const name = window.prompt(
`현재 필터 결과(${activeFilteredRows.length}개)를 선택 집합으로 저장합니다. 이름을 입력하세요:`,
`${subTab === 'nodes' ? '절점' : '관로'} 필터 ${savedSets.length + 1}`
);
if (!name || !name.trim()) return;
setSavedSets([
...savedSets,
{ name: name.trim(), subTab, ids: activeFilteredRows.map((r) => r.id) },
]);
};
const applySavedSet = (set: SavedSelectionSet) => {
setSubTab(set.subTab);
if (onMultiSelectionChange) {
onMultiSelectionChange({
nodes: new Set(set.subTab === 'nodes' ? set.ids : []),
links: new Set(set.subTab === 'links' ? set.ids : []),
gisFeatures: [],
});
}
};
const removeSavedSet = (idx: number) => {
setSavedSets(savedSets.filter((_, i) => i !== idx));
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)', fontSize: 'var(--fs-sm)' }}>
{/* Sub tabs and filters */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border)', paddingBottom: 'var(--s-3)' }}>
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
<button
onClick={() => setSubTab('nodes')}
className={`btn btn-secondary ${subTab === 'nodes' ? 'btn-primary' : ''}`}
style={{ height: 'var(--h-btn-sm)', padding: '0 var(--s-5)', fontSize: 'var(--fs-sm)' }}
>
절점 수리계산서 ({nodeRows.length})
</button>
<button
onClick={() => setSubTab('links')}
className={`btn btn-secondary ${subTab === 'links' ? 'btn-primary' : ''}`}
style={{ height: 'var(--h-btn-sm)', padding: '0 var(--s-5)', fontSize: 'var(--fs-sm)' }}
>
관로 수리계산서 ({linkRows.length})
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-4)', fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)' }}>
<span style={{ fontWeight: 'var(--fw-semi)', color: 'var(--text)' }}>{currentTimeStr} 시점</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={onlyWarnings}
onChange={(e) => setOnlyWarnings(e.target.checked)}
/>
<span>기준 미달만 보기</span>
</label>
</div>
</div>
{/* Compound numeric condition filter (P2-1): e.g. "pressure < 15 AND
velocity > 2" -- every condition must hold (AND) for a row to show.
The currently filtered set can be saved and re-selected later. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)', padding: 'var(--s-3)', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', fontWeight: 'var(--fw-semi)', fontSize: 'var(--fs-xs)' }}>
<Filter size={12} aria-hidden="true" />
조건 필터 (AND) -- {activeFilteredRows.length}개 일치
</span>
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
<button type="button" className="btn btn-secondary" onClick={addCondition} style={{ height: '22px', padding: '0 8px', fontSize: 'var(--fs-2xs)', display: 'inline-flex', alignItems: 'center', gap: '3px' }}>
<Plus size={11} aria-hidden="true" />
조건 추가
</button>
<button
type="button"
className="btn btn-secondary"
onClick={saveCurrentFilterAsSet}
disabled={activeFilteredRows.length === 0}
title="현재 필터에 일치하는 절점/관로를 이름 붙여 저장하고 나중에 다시 선택할 수 있습니다."
style={{ height: '22px', padding: '0 8px', fontSize: 'var(--fs-2xs)', display: 'inline-flex', alignItems: 'center', gap: '3px' }}
>
<Bookmark size={11} aria-hidden="true" />
선택 집합으로 저장
</button>
</div>
</div>
{activeConditions.map((cond, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', fontSize: 'var(--fs-xs)' }}>
{idx > 0 && <span style={{ color: 'var(--text-muted)', fontWeight: 'var(--fw-semi)' }}>AND</span>}
<select
value={cond.field}
onChange={(e) => updateCondition(idx, { field: e.target.value })}
style={{ padding: '2px 4px', fontSize: 'var(--fs-xs)' }}
>
{activeFilterFields.map((f) => (
<option key={f.key} value={f.key}>{f.label}</option>
))}
</select>
<select
value={cond.operator}
onChange={(e) => updateCondition(idx, { operator: e.target.value as FilterOperator })}
style={{ padding: '2px 4px', fontSize: 'var(--fs-xs)', fontFamily: 'var(--font-mono)' }}
>
{FILTER_OPERATORS.map((op) => (
<option key={op} value={op}>{op}</option>
))}
</select>
<input
type="number"
value={cond.value}
onChange={(e) => updateCondition(idx, { value: Number(e.target.value) || 0 })}
style={{ width: '90px', padding: '2px 4px', fontSize: 'var(--fs-xs)', fontFamily: 'var(--font-mono)' }}
/>
<button
type="button"
onClick={() => removeCondition(idx)}
aria-label="조건 삭제"
title="조건 삭제"
style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--crit)', display: 'inline-flex', alignItems: 'center' }}
>
<Trash2 size={12} aria-hidden="true" />
</button>
</div>
))}
{savedSets.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-1)', paddingTop: 'var(--s-2)', borderTop: '1px solid var(--border)' }}>
<span style={{ fontSize: 'var(--fs-2xs)', color: 'var(--text-secondary)' }}>저장된 선택 집합:</span>
{savedSets.map((set, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 'var(--fs-xs)' }}>
<span>
{set.name} ({set.subTab === 'nodes' ? '절점' : '관로'} {set.ids.length}개)
</span>
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
<button type="button" className="btn btn-secondary" onClick={() => applySavedSet(set)} style={{ height: '20px', padding: '0 6px', fontSize: 'var(--fs-2xs)' }}>
선택
</button>
<button type="button" onClick={() => removeSavedSet(idx)} aria-label="저장된 집합 삭제" style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--crit)', display: 'inline-flex', alignItems: 'center' }}>
<Trash2 size={12} aria-hidden="true" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Nodes Table */}
{subTab === 'nodes' && (
<div style={{ maxHeight: '380px', overflowY: 'auto', border: '1px solid var(--border-strong)', borderRadius: 'var(--r-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' }}>
<thead>
<tr style={{ background: 'var(--bg-subtle)', position: 'sticky', top: 0, zIndex: 2, borderBottom: '1px solid var(--border-strong)' }}>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>No</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'left' }}>절점 ID</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'left' }}>유형</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>지반고 ({displayUnits.head})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>수요량 ({displayUnits.demand})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>수리동수두 ({displayUnits.head})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>동수압 ({displayUnits.pressure})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>수압 판정</th>
</tr>
</thead>
<tbody>
{filteredNodeRows.map((r) => {
const isSelected =
(selectedTarget?.type === 'node' && selectedTarget.id === r.id) ||
Boolean(multiSelection?.nodes.has(r.id));
return (
<tr
key={r.id}
onClick={(e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
if (onMultiSelectionChange) {
const next: MultiSelectionState = {
nodes: new Set(multiSelection?.nodes ?? []),
links: new Set(multiSelection?.links ?? []),
gisFeatures: [...(multiSelection?.gisFeatures ?? [])],
};
if (selectedTarget?.type === 'node') next.nodes.add(selectedTarget.id);
if (next.nodes.has(r.id)) next.nodes.delete(r.id);
else next.nodes.add(r.id);
onMultiSelectionChange(next);
}
} else {
onSelectTarget({ type: 'node', id: r.id });
}
}}
style={{
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: isSelected ? 'var(--accent-wash)' : 'transparent',
}}
>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center', color: 'var(--text-disabled)' }}>{r.idx}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', fontWeight: 'var(--fw-semi)', color: 'var(--accent)' }}>{r.id}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', color: 'var(--text-secondary)' }}>{r.type}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.elev.toFixed(1)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.demand.toFixed(1)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.head.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: 'var(--fw-semi)' }}>{r.pressure.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center' }}>
<span
className={
r.statusType === 'warn' ? 'badge-crit' : r.statusType === 'info' ? 'badge-neutral' : 'badge-good'
}
style={{ fontSize: 'var(--fs-2xs)' }}
>
{r.statusText}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Links Table */}
{subTab === 'links' && (
<div style={{ maxHeight: '380px', overflowY: 'auto', border: '1px solid var(--border-strong)', borderRadius: 'var(--r-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' }}>
<thead>
<tr style={{ background: 'var(--bg-subtle)', position: 'sticky', top: 0, zIndex: 2, borderBottom: '1px solid var(--border-strong)' }}>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>No</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'left' }}>관로 ID</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>시작절점 → 끝절점</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>관경 ({displayUnits.diameter})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>연장 ({displayUnits.length})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>조도계수</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>유량 ({displayUnits.flow})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>유속 ({displayUnits.velocity})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>손실수두 (총, m)</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>동수경사(‰)</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>유속 판정</th>
</tr>
</thead>
<tbody>
{filteredLinkRows.map((r) => {
const isSelected =
(selectedTarget?.type === 'link' && selectedTarget.id === r.id) ||
Boolean(multiSelection?.links.has(r.id));
return (
<tr
key={r.id}
onClick={(e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
if (onMultiSelectionChange) {
const next: MultiSelectionState = {
nodes: new Set(multiSelection?.nodes ?? []),
links: new Set(multiSelection?.links ?? []),
gisFeatures: [...(multiSelection?.gisFeatures ?? [])],
};
if (selectedTarget?.type === 'link') next.links.add(selectedTarget.id);
if (next.links.has(r.id)) next.links.delete(r.id);
else next.links.add(r.id);
onMultiSelectionChange(next);
}
} else {
onSelectTarget({ type: 'link', id: r.id });
}
}}
style={{
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: isSelected ? 'var(--accent-wash)' : 'transparent',
}}
>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center', color: 'var(--text-disabled)' }}>{r.idx}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', fontWeight: 'var(--fw-semi)', color: 'var(--accent)' }}>{r.id}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center', fontFamily: 'var(--font-mono)' }}>
{r.node1} → {r.node2}
</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.diameter}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.length.toFixed(1)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.roughness}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: 'var(--fw-semi)' }}>{r.flow.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.velocity.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.headloss.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.gradient.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center' }}>
<span
className={
r.statusType === 'warn' ? 'badge-crit' : r.statusType === 'info' ? 'badge-neutral' : 'badge-good'
}
style={{ fontSize: 'var(--fs-2xs)' }}
>
{r.statusText}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)' }}>
* 표의 행을 클릭하면 지도가 해당 객체 위치로 자동 포커스 이동합니다.
</div>
</div>
);
};
+485
View File
@@ -0,0 +1,485 @@
import React, { useState } from 'react';
import type { FeatureCollection, Geometry, GeoJsonProperties } from 'geojson';
import { Map, X, FolderOpen, MapPin, Info, Droplet, Check, Play } from 'lucide-react';
import {
autoDetectFields,
buildNetworkFromGeoJSON,
DetectedFields,
FieldMappingConfig,
} from './gisImport';
import { generateInp, NetworkModel } from './inpModel';
interface ImportWizardModalProps {
onClose: () => void;
onImportComplete: (model: NetworkModel, inpStr: string) => void;
initialGeoJson?: FeatureCollection<Geometry, GeoJsonProperties>;
initialFileName?: string;
}
export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
onClose,
onImportComplete,
initialGeoJson,
initialFileName,
}) => {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [geojsonData, setGeojsonData] = useState<FeatureCollection<Geometry, GeoJsonProperties> | null>(null);
const [fileName, setFileName] = useState<string>('');
const [detected, setDetected] = useState<DetectedFields | null>(null);
// Field mapping state
const [pipeIdField, setPipeIdField] = useState<string>('');
const [diameterField, setDiameterField] = useState<string>('');
const [lengthField, setLengthField] = useState<string>('');
const [roughnessField, setRoughnessField] = useState<string>('');
const [materialField, setMaterialField] = useState<string>('');
const [defaultDiameter, setDefaultDiameter] = useState<number>(200);
const [defaultRoughness, setDefaultRoughness] = useState<number>(120);
const [sourceHead, setSourceHead] = useState<number>(60);
// Auto-load if initialGeoJson is passed
React.useEffect(() => {
if (initialGeoJson) {
processGeoJSON(initialGeoJson, initialFileName || 'uploaded_layer.geojson');
}
}, [initialGeoJson, initialFileName]);
// 1. Handle File upload
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = (evt) => {
try {
const parsed = JSON.parse(String(evt.target?.result)) as FeatureCollection<Geometry, GeoJsonProperties>;
processGeoJSON(parsed, file.name);
} catch (err: unknown) {
alert('유효하지 않은 GeoJSON 파일입니다.');
}
};
reader.readAsText(file);
};
// Load Seoul Sample
const handleLoadSample = async () => {
try {
const res = await fetch('/sample_gis_pipes.geojson');
if (!res.ok) throw new Error('샘플 로드 실패');
const parsed = (await res.json()) as FeatureCollection<Geometry, GeoJsonProperties>;
processGeoJSON(parsed, 'sample_gis_pipes.geojson');
} catch (err: unknown) {
alert('샘플 GeoJSON 로드 실패');
}
};
const processGeoJSON = (data: FeatureCollection<Geometry, GeoJsonProperties>, name: string) => {
setGeojsonData(data);
setFileName(name);
// Sample first feature properties for field detection
const firstProps = data.features[0]?.properties || {};
const det = autoDetectFields(firstProps);
setDetected(det);
setPipeIdField(det.pipeIdField || '');
setDiameterField(det.diameterField || '');
setLengthField(det.lengthField || '');
setRoughnessField(det.roughnessField || '');
setMaterialField(det.materialField || '');
setStep(2);
};
const buildConfig = (): FieldMappingConfig => ({
pipeIdField: pipeIdField || undefined,
diameterField: diameterField || undefined,
lengthField: lengthField || undefined,
roughnessField: roughnessField || undefined,
materialField: materialField || undefined,
defaultDiameter,
defaultRoughness,
sourceHead,
});
// Live conversion preview for the step-3 summary screen -- computed from
// the same function handleFinish uses, so the "estimated value" counts
// shown to the user always match what will actually be generated.
const preview = React.useMemo(() => {
if (!geojsonData || step !== 3) return null;
try {
return buildNetworkFromGeoJSON(geojsonData, buildConfig());
} catch {
return null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [geojsonData, step, pipeIdField, diameterField, lengthField, roughnessField, materialField, defaultDiameter, defaultRoughness, sourceHead]);
// Complete import
const handleFinish = () => {
if (!geojsonData) return;
const { model } = buildNetworkFromGeoJSON(geojsonData, buildConfig());
const inpStr = generateInp(model);
onImportComplete(model, inpStr);
onClose();
};
return (
<div className="modal-overlay" style={{ zIndex: 'var(--z-modal)' }}>
<div
className="modal-content"
style={{
width: '90%',
maxWidth: '620px',
maxHeight: '85vh',
padding: 'var(--s-6)',
display: 'flex',
flexDirection: 'column',
fontSize: 'var(--fs-sm)',
}}
>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--s-6)' }}>
<div>
<h3 style={{ margin: 0, fontSize: 'var(--fs-md)', color: 'var(--text)', fontWeight: 'var(--fw-semi)', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Map size={16} aria-hidden="true" />
지자체 상수도 GIS 데이터 가져오기 마법사
</h3>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)', marginTop: 'var(--s-1)' }}>
GeoJSON 관로 데이터를 EPANET 수리해석 모델로 자동 위상 정합 변환합니다.
</div>
</div>
<button
onClick={onClose}
className="tool-btn"
style={{ fontSize: 'var(--fs-lg)' }}
aria-label="닫기"
title="닫기"
>
<X size={16} aria-hidden="true" />
</button>
</div>
{/* Step Indicator */}
<div style={{ display: 'flex', gap: 'var(--s-4)', marginBottom: 'var(--s-6)' }}>
<div
style={{
flex: 1,
padding: 'var(--s-3)',
textAlign: 'center',
borderRadius: 'var(--r-sm)',
background: step === 1 ? 'var(--accent)' : 'var(--bg-subtle)',
color: step === 1 ? 'var(--text-on-accent)' : 'var(--text-secondary)',
fontWeight: 'var(--fw-semi)',
}}
>
1. 파일 선택
</div>
<div
style={{
flex: 1,
padding: 'var(--s-3)',
textAlign: 'center',
borderRadius: 'var(--r-sm)',
background: step === 2 ? 'var(--accent)' : 'var(--bg-subtle)',
color: step === 2 ? 'var(--text-on-accent)' : 'var(--text-secondary)',
fontWeight: 'var(--fw-semi)',
}}
>
2. 필드 매핑
</div>
<div
style={{
flex: 1,
padding: 'var(--s-3)',
textAlign: 'center',
borderRadius: 'var(--r-sm)',
background: step === 3 ? 'var(--accent)' : 'var(--bg-subtle)',
color: step === 3 ? 'var(--text-on-accent)' : 'var(--text-secondary)',
fontWeight: 'var(--fw-semi)',
}}
>
3. 수리조건 및 생성
</div>
</div>
{/* STEP 1: File Selection */}
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '16px 0' }}>
<div
style={{
border: '2px dashed #cbd5e1',
borderRadius: '8px',
padding: '32px',
textAlign: 'center',
background: 'var(--bg-subtle)',
}}
>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px', color: 'var(--text-secondary)' }}><FolderOpen size={28} aria-hidden="true" /></div>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '4px' }}>
상수관로 GeoJSON 파일 업로드
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '11px', marginBottom: '16px' }}>
LineString 형상의 지자체 상수관망 GeoJSON 파일을 선택하세요.
</div>
<label className="btn btn-primary" style={{ display: 'inline-block', cursor: 'pointer' }}>
내 컴퓨터에서 파일 찾기
<input type="file" accept=".geojson,.json" style={{ display: 'none' }} onChange={handleFileUpload} />
</label>
</div>
<div style={{ textAlign: 'center' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '11px' }}>또는</span>
</div>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleLoadSample}
className="btn btn-secondary"
style={{ background: 'var(--good-wash)', borderColor: '#86efac', color: 'var(--good)', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 'var(--s-2)' }}
>
<MapPin size={14} aria-hidden="true" />
서울시 상수관망 GIS 샘플 데이터셋으로 시작하기
</button>
</div>
</div>
)}
{/* STEP 2: Field Mapping */}
{step === 2 && detected && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ background: 'var(--bg-subtle)', padding: '8px 12px', borderRadius: '4px', color: 'var(--text)' }}>
선택 파일: <strong>{fileName}</strong> (총 {geojsonData?.features.length}개 관로 피처)
<div style={{ fontSize: '11px', color: '#0369a1', marginTop: '4px', display: 'flex', alignItems: 'flex-start', gap: 'var(--s-2)' }}>
<Info size={13} aria-hidden="true" style={{ flexShrink: 0, marginTop: '1px' }} />
<span>MultiLineString 복합 선형 분해 및 좌표계 적응형 스냅핑(미터 투영 0.5m / 경위도 ~5m)이 자동 적용되어 단절 없는 일체형 관망을 구축합니다.</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
관로 ID 컬럼
</label>
<select
value={pipeIdField}
onChange={(e) => setPipeIdField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(자동 일련번호 생성)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
관경(Diameter) 컬럼
</label>
<select
value={diameterField}
onChange={(e) => setDiameterField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(기본값 사용)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
관로 연장(Length) 컬럼
</label>
<select
value={lengthField}
onChange={(e) => setLengthField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(도형 지오메트리 거리 자동 계산)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
조도계수(Roughness) 컬럼
</label>
<select
value={roughnessField}
onChange={(e) => setRoughnessField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(기본값 사용)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
</div>
<div style={{ marginTop: '4px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
관종/재질(Material) 컬럼 (DIP C=110, PE C=130, PVC C=140 자동 추정)
</label>
<select
value={materialField}
onChange={(e) => setMaterialField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(재질 미지정 - 기본 조도계수 적용)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginTop: '4px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
기본 관경 (mm)
</label>
<input
type="number"
value={defaultDiameter}
onChange={(e) => setDefaultDiameter(parseFloat(e.target.value) || 150)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
기본 조도계수 (C-value)
</label>
<input
type="number"
value={defaultRoughness}
onChange={(e) => setDefaultRoughness(parseFloat(e.target.value) || 120)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
</div>
</div>
)}
{/* STEP 3: Summary & Water Source */}
{step === 3 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ background: 'var(--bg-subtle)', padding: '12px', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<div style={{ fontWeight: 600, marginBottom: '6px', color: 'var(--text)', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Droplet size={14} aria-hidden="true" />
상수도 공급원(Reservoir) 설정:
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '11px', marginBottom: '10px' }}>
EPANET 수리해석을 위해 관망 시작 절점에 배수지/저수조(수원)를 자동 배치합니다.
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
배수지 공급 수두 (Head, m)
</label>
<input
type="number"
value={sourceHead}
onChange={(e) => setSourceHead(parseFloat(e.target.value) || 60)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
</div>
<div style={{ padding: '10px', background: 'var(--good-wash)', borderRadius: '6px', border: '1px solid #bbf7d0', color: 'var(--good)', display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--s-2)' }}>
<Check size={13} aria-hidden="true" style={{ flexShrink: 0, marginTop: '1px' }} />
<span>관로 끝점 좌표를 분석하여 인접 절점을 자동 스냅 결합(위상 정합)합니다.</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--s-2)' }}>
<Check size={13} aria-hidden="true" style={{ flexShrink: 0, marginTop: '1px' }} />
<span>모델 생성 즉시 Native DLL과 브라우저 WASM 듀얼 엔진으로 수리해석이 실행됩니다.</span>
</div>
</div>
{/* Auto-generated / estimated value disclosure: without this, a
placeholder like "10m elevation" or a proximity-snapped
endpoint looks indistinguishable from real surveyed data. */}
{preview && (
<div style={{ padding: '10px', background: 'var(--warn-wash)', borderRadius: '6px', border: '1px solid var(--warn)', color: 'var(--warn)', display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Info size={13} aria-hidden="true" />
검토 필요: 자동 생성/추정된 값
</div>
<ul style={{ margin: 0, paddingLeft: '18px', fontSize: '11px', lineHeight: 1.6 }}>
<li>
절점 지반고: {preview.summary.placeholderElevationCount}개 전체가 임시값(10m)입니다.
GIS 데이터에 표고 필드가 없어 실측값이 아닙니다.
</li>
<li>
절점 기본수요량: {preview.summary.placeholderDemandCount}개 전체가 임시값(10 LPS)입니다.
</li>
<li>
관경: 전체 {preview.summary.totalPipeCount}개 중{' '}
<strong>{preview.summary.defaultDiameterPipeCount}개</strong>가 GIS 필드값이 아닌
기본값({defaultDiameter}mm)으로 채워졌습니다.
</li>
<li>
조도계수: 전체 {preview.summary.totalPipeCount}개 중{' '}
<strong>{preview.summary.defaultRoughnessPipeCount}개</strong>가 실측값이 아닌
기본값/재질 추정값입니다.
</li>
{preview.summary.autoSnappedEndpointCount > 0 && (
<li>
끝점 자동 결합: <strong>{preview.summary.autoSnappedEndpointCount}건</strong>이 정확히
일치하지 않는 좌표를 근접 거리로 병합한 것입니다. 실제로 연결된 지점인지
확인이 필요합니다.
</li>
)}
</ul>
</div>
)}
</div>
)}
{/* Footer Navigation */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px', borderTop: '1px solid #e2e8f0', paddingTop: '12px' }}>
{step > 1 && (
<button onClick={() => setStep((s) => (s - 1) as 1 | 2)} className="btn btn-secondary">
이전
</button>
)}
{step < 3 ? (
<button
onClick={() => {
if (!geojsonData) return alert('파일을 먼저 선택하세요.');
setStep((s) => (s + 1) as 2 | 3);
}}
className="btn btn-primary"
>
다음
</button>
) : (
<button onClick={handleFinish} className="btn btn-primary" style={{ background: 'var(--good)', display: 'inline-flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Play size={14} aria-hidden="true" />
관망 모델 생성 및 수리해석
</button>
)}
<button onClick={onClose} className="btn btn-secondary">
취소
</button>
</div>
</div>
</div>
);
};
+97
View File
@@ -0,0 +1,97 @@
import React from 'react';
import { generateInp, type NetworkModel } from './inpModel';
import { X } from 'lucide-react';
export interface InpTextModalProps {
networkModel: NetworkModel;
showInpModal: boolean;
setShowInpModal: React.Dispatch<React.SetStateAction<boolean>>;
}
/** Shows the regenerated INP text with copy/download. */
export const InpTextModal: React.FC<InpTextModalProps> = ({
networkModel,
showInpModal,
setShowInpModal,
}) => (
<div className="modal-overlay">
<div
className="modal-content"
style={{
width: '80%',
maxWidth: '800px',
maxHeight: '85vh',
padding: 'var(--s-6)',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 'var(--s-5)',
}}
>
<h3 style={{ margin: 0, fontSize: 'var(--fs-lg)', color: 'var(--text)', fontWeight: 'var(--fw-semi)' }}>재생성된 EPANET INP 파일</h3>
<button
onClick={() => setShowInpModal(false)}
className="tool-btn"
style={{ fontSize: 'var(--fs-lg)' }}
aria-label="닫기"
title="닫기"
>
<X size={16} aria-hidden="true" />
</button>
</div>
<textarea
readOnly
value={generateInp(networkModel)}
style={{
flex: 1,
minHeight: '350px',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-sm)',
padding: 'var(--s-4)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
color: 'var(--text)',
resize: 'none',
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--s-4)', marginTop: 'var(--s-5)' }}>
<button
onClick={() => {
navigator.clipboard.writeText(generateInp(networkModel));
alert('클립보드에 복사되었습니다.');
}}
className="btn btn-secondary"
>
클립보드 복사
</button>
<button
onClick={() => {
const content = generateInp(networkModel);
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'modified_network.inp';
a.click();
URL.revokeObjectURL(url);
}}
className="btn btn-primary"
>
.inp 파일 다운로드
</button>
<button onClick={() => setShowInpModal(false)} className="btn btn-secondary">
닫기
</button>
</div>
</div>
</div>
);
+318
View File
@@ -0,0 +1,318 @@
import React, { useState } from 'react';
import { Type, X, RotateCcw, Check } from 'lucide-react';
export interface LayerLabelConfig {
showLabel: boolean;
field: string;
fontSize: number; // 9 ~ 16px, default 10px
fontWeight: number; // 400, 500, 600
color: string;
halo: boolean;
haloColor: string;
}
interface LayerLabelModalProps {
layerKey: string;
layerTitle: string;
availableFields: Array<{ id: string; label: string }>;
initialConfig?: Partial<LayerLabelConfig>;
onClose: () => void;
onApply: (config: LayerLabelConfig) => void;
}
const LABEL_COLOR_SWATCHES = [
'#0f172a', // Dark slate
'#334155', // Slate
'#1e293b', // Deep slate
'#0369a1', // Sky
'#15803d', // Green
'#b45309', // Amber
'#b91c1c', // Red
'#6d28d9', // Purple
];
export const LayerLabelModal: React.FC<LayerLabelModalProps> = ({
layerKey,
layerTitle,
availableFields,
initialConfig,
onClose,
onApply,
}) => {
const [showLabel, setShowLabel] = useState<boolean>(
initialConfig?.showLabel ?? true
);
const [field, setField] = useState<string>(
initialConfig?.field || (availableFields[0]?.id ?? 'id')
);
const [fontSize, setFontSize] = useState<number>(
initialConfig?.fontSize || 10
);
const [fontWeight, setFontWeight] = useState<number>(
initialConfig?.fontWeight || 500
);
const [color, setColor] = useState<string>(
initialConfig?.color || '#334155'
);
const [halo, setHalo] = useState<boolean>(
initialConfig?.halo ?? true
);
const [haloColor, setHaloColor] = useState<string>(
initialConfig?.haloColor || '#ffffff'
);
const handleSave = () => {
const config: LayerLabelConfig = {
showLabel,
field,
fontSize,
fontWeight,
color,
halo,
haloColor,
};
localStorage.setItem(`gispipe_layer_label_${layerKey}`, JSON.stringify(config));
onApply(config);
onClose();
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1050,
background: 'rgba(15, 23, 42, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={onClose}
>
<div
style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
boxShadow: 'var(--shadow-modal)',
width: '100%',
maxWidth: '400px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
fontSize: '12px',
}}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="레이어 라벨 설정"
>
{/* Modal Header */}
<div
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 600 }}>
<Type size={15} style={{ color: 'var(--accent)' }} aria-hidden="true" />
<span>라벨 설정: {layerTitle}</span>
</div>
<button
onClick={onClose}
className="btn btn-ghost"
style={{ padding: '3px' }}
title="닫기"
aria-label="닫기"
>
<X size={15} aria-hidden="true" />
</button>
</div>
{/* Modal Body */}
<div style={{ padding: '14px', display: 'flex', flexDirection: 'column', gap: '12px', maxHeight: '75vh', overflowY: 'auto' }}>
{/* 1. Show/Hide Label Toggle */}
<div style={{ padding: '8px 10px', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', fontWeight: 500 }}>
<span>라벨 텍스트 표시</span>
<input
type="checkbox"
checked={showLabel}
onChange={(e) => setShowLabel(e.target.checked)}
/>
</label>
</div>
{showLabel && (
<>
{/* 2. Field Selection */}
<div>
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 500, color: 'var(--text-secondary)' }}>
표시할 속성 필드
</label>
<select
value={field}
onChange={(e) => setField(e.target.value)}
style={{
width: '100%',
padding: '5px 8px',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
}}
>
{availableFields.map((f) => (
<option key={f.id} value={f.id}>
{f.label} ({f.id})
</option>
))}
</select>
</div>
{/* 3. Font Size */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', color: 'var(--text-secondary)' }}>
<span>글자 크기 (Font Size)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{fontSize}px</span>
</div>
<input
type="range"
min={9}
max={16}
value={fontSize}
onChange={(e) => setFontSize(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--accent)' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: 'var(--text-muted)' }}>
<span>작게 (9px)</span>
<span>기본 (10px)</span>
<span>크게 (16px)</span>
</div>
</div>
{/* 4. Font Weight */}
<div>
<label style={{ display: 'block', marginBottom: '4px', color: 'var(--text-secondary)' }}>
글자 굵기 (Weight)
</label>
<div style={{ display: 'flex', gap: '6px' }}>
{[
{ val: 400, label: '기본 (400)' },
{ val: 500, label: '중간 (500)' },
{ val: 600, label: '굵게 (600)' },
].map((w) => (
<button
key={w.val}
type="button"
onClick={() => setFontWeight(w.val)}
className={`btn ${fontWeight === w.val ? 'btn-primary' : 'btn-secondary'}`}
style={{ flex: 1, padding: '4px 6px', fontSize: '11px' }}
>
{w.label}
</button>
))}
</div>
</div>
{/* 5. Text Color */}
<div>
<label style={{ display: 'block', marginBottom: '4px', color: 'var(--text-secondary)' }}>
텍스트 색상
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
style={{ width: '32px', height: '28px', padding: 0, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', cursor: 'pointer' }}
/>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{LABEL_COLOR_SWATCHES.map((swatch) => (
<div
key={swatch}
onClick={() => setColor(swatch)}
style={{
width: '20px',
height: '20px',
borderRadius: '4px',
background: swatch,
cursor: 'pointer',
border: color === swatch ? '2px solid var(--text)' : '1px solid var(--border)',
}}
/>
))}
</div>
</div>
</div>
{/* 6. Text Halo */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', marginBottom: '8px' }}>
<span>텍스트 외곽 테두리 (Halo)</span>
<input
type="checkbox"
checked={halo}
onChange={(e) => setHalo(e.target.checked)}
/>
</label>
{halo && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>Halo 색상:</span>
<input
type="color"
value={haloColor}
onChange={(e) => setHaloColor(e.target.value)}
style={{ width: '28px', height: '24px', padding: 0, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', cursor: 'pointer' }}
/>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>(흰색 또는 배경색 권장)</span>
</div>
)}
</div>
</>
)}
</div>
{/* Modal Footer */}
<div
style={{
padding: '10px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<button
onClick={() => {
localStorage.removeItem(`gispipe_layer_label_${layerKey}`);
onClose();
}}
className="btn btn-secondary"
style={{ fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<RotateCcw size={12} aria-hidden="true" />
<span>기본값</span>
</button>
<div style={{ display: 'flex', gap: '6px' }}>
<button onClick={onClose} className="btn btn-secondary" style={{ fontSize: '11px' }}>
취소
</button>
<button
onClick={handleSave}
className="btn btn-primary"
style={{ fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<Check size={12} aria-hidden="true" />
<span>적용</span>
</button>
</div>
</div>
</div>
</div>
);
};
+905
View File
@@ -0,0 +1,905 @@
import React, { useRef, useState, useEffect } from 'react';
import {
Layers,
ChevronLeft,
Circle,
GitCommit,
Activity,
Disc,
Droplet,
Info,
CheckSquare,
Square,
Eye,
EyeOff,
Settings,
Clock,
ChartNoAxesCombined,
TableProperties,
GitBranch,
} from 'lucide-react';
import type {
DisplayMode,
LayerVisibilityState,
SimulationResult,
UserGisLayer,
} from './types';
import type { NetworkModel } from './inpModel';
import type { TableCategory } from './FullAttributeTableModal';
import { getDisplayUnits, type DisplayUnitMode } from './unitSystem';
interface LayerPanelProps {
isOpen: boolean;
onClose: () => void;
width: number;
onWidthChange: (w: number) => void;
networkModel: NetworkModel | null;
wasmResult: SimulationResult | null;
displayMode: DisplayMode;
displayUnitMode?: DisplayUnitMode;
onDisplayModeChange: (mode: DisplayMode) => void;
visibility: LayerVisibilityState;
onVisibilityChange: (updater: (prev: LayerVisibilityState) => LayerVisibilityState) => void;
userLayers: UserGisLayer[];
onAddUserLayer: (file: File) => Promise<void>;
onToggleUserLayerVisibility: (layerId: string) => void;
onUserLayerOpacityChange: (layerId: string, opacity: number) => void;
onDeleteUserLayer: (layerId: string) => void;
onConvertToNetwork: (layer: UserGisLayer) => void;
onOpenAttributeTable: (category: TableCategory) => void;
onOpenCurveEditor?: () => void;
onOpenDemandTable?: () => void;
onOpenStyleSettings: (layerKey: string, isLine: boolean, isUserLayer: boolean) => void;
onOpenLabelSettings: (layerKey: string, isLine: boolean, isUserLayer: boolean) => void;
onZoomToLayer?: (layerKey: string) => void;
onOpenScenarios: () => void;
onOpenModelSection: (section: 'options' | 'patterns' | 'controls') => void;
}
type SelectedInfoItem =
| { type: 'network'; category: 'nodes' | 'pipes' | 'pumps' | 'valves' | 'tanksReservoirs' }
| { type: 'results'; metric: DisplayMode }
| { type: 'user'; layer: UserGisLayer }
| null;
export const LayerPanel: React.FC<LayerPanelProps> = ({
isOpen,
onClose,
width,
onWidthChange,
networkModel,
wasmResult,
displayMode,
displayUnitMode = 'korean',
onDisplayModeChange,
visibility,
onVisibilityChange,
userLayers,
onToggleUserLayerVisibility,
onDeleteUserLayer,
onConvertToNetwork,
onOpenAttributeTable,
onOpenCurveEditor,
onOpenDemandTable,
onOpenStyleSettings,
onOpenLabelSettings,
onOpenScenarios,
onOpenModelSection,
}) => {
const displayUnits = getDisplayUnits(
networkModel?.options.units || wasmResult?.units.flow || 'LPS',
displayUnitMode
);
const [selectedInfo, setSelectedInfo] = useState<SelectedInfoItem>(null);
const [layerContextMenu, setLayerContextMenu] = useState<{
x: number;
y: number;
layerKey: string;
category: TableCategory;
layerTitle: string;
isLine: boolean;
isUserLayer: boolean;
userLayerObj?: UserGisLayer;
} | null>(null);
// Close context menu on ESC or outside click
useEffect(() => {
if (!layerContextMenu) return;
const handleClose = () => setLayerContextMenu(null);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLayerContextMenu(null);
};
window.addEventListener('mousedown', handleClose);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('mousedown', handleClose);
window.removeEventListener('keydown', handleKeyDown);
};
}, [layerContextMenu]);
// Resize handling
const isResizingRef = useRef(false);
const startXRef = useRef(0);
const startWidthRef = useRef(width);
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
isResizingRef.current = true;
startXRef.current = e.clientX;
startWidthRef.current = width;
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizingRef.current) return;
const dx = moveEvent.clientX - startXRef.current;
const newWidth = Math.max(220, Math.min(420, startWidthRef.current + dx));
onWidthChange(newWidth);
};
const handleMouseUp = () => {
isResizingRef.current = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
const handleResizeDoubleClick = () => {
onWidthChange(264);
};
// Counts from network model
const junctionCount = networkModel
? Object.values(networkModel.nodes).filter((n) => n.type === 'JUNCTION').length
: 0;
const tankReservoirCount = networkModel
? Object.values(networkModel.nodes).filter((n) => n.type === 'TANK' || n.type === 'RESERVOIR').length
: 0;
const pipeCount = networkModel ? Object.keys(networkModel.pipes).length : 0;
const pumpCount = networkModel ? Object.keys(networkModel.pumps).length : 0;
const valveCount = networkModel && networkModel.valves ? Object.keys(networkModel.valves).length : 0;
if (!isOpen) return null;
return (
<aside
className="layer-panel"
style={{
width: `${width}px`,
minWidth: '220px',
maxWidth: '420px',
background: 'var(--bg-panel)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
position: 'relative',
zIndex: 20,
height: '100%',
userSelect: 'none',
}}
aria-label="레이어 패널"
>
{/* 1. Panel Header */}
<div
style={{
height: '38px',
padding: '0 10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-subtle)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontWeight: 600, fontSize: '12px' }}>
<Layers size={14} style={{ color: 'var(--accent)' }} aria-hidden="true" />
<span>레이어</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
(단축키: [)
</span>
</div>
<button
onClick={onClose}
className="btn btn-ghost"
style={{ padding: '3px', borderRadius: '4px', color: 'var(--text-secondary)' }}
title="레이어 패널 닫기 (단축키 [)"
aria-label="레이어 패널 닫기"
>
<ChevronLeft size={15} aria-hidden="true" />
</button>
</div>
{/* 2. Scrollable Layer Tree */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* GROUP 1: 관망 모델 (Network Model) */}
<div className="layer-group">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '4px',
padding: '2px 4px',
borderBottom: '1px solid var(--border)',
}}
>
<span>관망 모델</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
{junctionCount + tankReservoirCount + pipeCount + pumpCount + valveCount} 요소
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1px' }}>
{/* 1.1 절점 (Junctions) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'nodes' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'nodes' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'nodes',
category: 'junctions',
layerTitle: '절점 (Junctions)',
isLine: false,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.nodes}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, nodes: e.target.checked }));
}}
title="절점 레이어 표시/숨김"
style={{ cursor: 'pointer' }}
/>
<Circle size={13} style={{ color: '#3b82f6' }} aria-hidden="true" />
<span>절점 (Junctions)</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{junctionCount}
</span>
</div>
{/* 1.2 관로 (Pipes) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'pipes' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'pipes' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'pipes',
category: 'pipes',
layerTitle: '관로 (Pipes)',
isLine: true,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.pipes}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, pipes: e.target.checked }));
}}
title="관로 레이어 표시/숨김"
style={{ cursor: 'pointer' }}
/>
<GitCommit size={13} style={{ color: '#64748b' }} aria-hidden="true" />
<span>관로 (Pipes)</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{pipeCount}
</span>
</div>
{/* 1.3 펌프 (Pumps) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'pumps' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'pumps' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'pumps',
category: 'pumps',
layerTitle: '펌프 (Pumps)',
isLine: true,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.pumps}
disabled={pumpCount === 0}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, pumps: e.target.checked }));
}}
title={pumpCount === 0 ? '펌프 없음' : '펌프 레이어 표시/숨김'}
style={{ cursor: pumpCount === 0 ? 'not-allowed' : 'pointer' }}
/>
<Activity size={13} style={{ color: pumpCount > 0 ? '#16a34a' : 'var(--text-disabled)' }} aria-hidden="true" />
<span style={{ color: pumpCount === 0 ? 'var(--text-disabled)' : 'var(--text)' }}>
펌프 (Pumps)
</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{pumpCount}
</span>
</div>
{/* 1.4 밸브 (Valves) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'valves' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'valves' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'valves',
category: 'valves',
layerTitle: '밸브 (Valves)',
isLine: true,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.valves}
disabled={valveCount === 0}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, valves: e.target.checked }));
}}
title={valveCount === 0 ? '밸브 없음' : '밸브 레이어 표시/숨김'}
style={{ cursor: valveCount === 0 ? 'not-allowed' : 'pointer' }}
/>
<Disc size={13} style={{ color: valveCount > 0 ? '#d97706' : 'var(--text-disabled)' }} aria-hidden="true" />
<span style={{ color: valveCount === 0 ? 'var(--text-disabled)' : 'var(--text)' }}>
밸브 (Valves)
</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{valveCount}
</span>
</div>
{/* 1.5 탱크 / 저수지 (Tanks / Reservoirs) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'tanksReservoirs' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'tanksReservoirs' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'tanksReservoirs',
category: 'reservoirs',
layerTitle: '탱크 / 저수지',
isLine: false,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.tanksReservoirs}
disabled={tankReservoirCount === 0}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, tanksReservoirs: e.target.checked }));
}}
title={tankReservoirCount === 0 ? '탱크/저수지 없음' : '탱크/저수지 레이어 표시/숨김'}
style={{ cursor: tankReservoirCount === 0 ? 'not-allowed' : 'pointer' }}
/>
<Droplet size={13} style={{ color: tankReservoirCount > 0 ? '#0284c7' : 'var(--text-disabled)' }} aria-hidden="true" />
<span style={{ color: tankReservoirCount === 0 ? 'var(--text-disabled)' : 'var(--text)' }}>
탱크 / 저수지
</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{tankReservoirCount}
</span>
</div>
</div>
</div>
{/* GROUP 2: 수리해석 결과 표시 (Results) */}
<div className="layer-group">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '4px',
padding: '2px 4px',
borderBottom: '1px solid var(--border)',
}}
>
<span>수리해석 결과 표시</span>
<span style={{ fontSize: '10px', color: wasmResult ? 'var(--good)' : 'var(--text-muted)' }}>
{wasmResult ? '해석 완료' : '미실행'}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{[
{ id: 'pressure', label: '수압', unit: displayUnits.pressure },
{ id: 'head', label: '동수두', unit: displayUnits.head },
{ id: 'flow', label: '유량', unit: displayUnits.flow },
{ id: 'velocity', label: '유속', unit: displayUnits.velocity },
{ id: 'headloss', label: '마찰손실수두', unit: 'm/km' },
{ id: 'quality', label: '수질', unit: wasmResult?.qualityInfo?.chemUnits || 'mg/L' },
].map((metric) => {
const isSelected = displayMode === metric.id;
const disabled = !wasmResult;
return (
<div
key={metric.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '3px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '11px',
cursor: disabled ? 'not-allowed' : 'pointer',
background: isSelected ? 'var(--accent-wash)' : 'transparent',
color: disabled ? 'var(--text-disabled)' : 'var(--text)',
}}
onClick={() => {
if (disabled) return;
onDisplayModeChange(metric.id as DisplayMode);
setSelectedInfo({ type: 'results', metric: metric.id as DisplayMode });
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="result-metric"
checked={isSelected}
disabled={disabled}
onChange={() => onDisplayModeChange(metric.id as DisplayMode)}
style={{ cursor: disabled ? 'not-allowed' : 'pointer' }}
/>
<span>{metric.label}</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>{metric.unit}</span>
</div>
);
})}
</div>
</div>
{/* GROUP: 사용자 GIS 레이어 (GeoJSON / Shapefile). Right-click for
attribute table, style, label, convert-to-network, delete. */}
{userLayers.length > 0 && (
<div className="layer-group">
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '4px',
padding: '2px 4px',
borderBottom: '1px solid var(--border)',
}}
>
GIS 레이어 ({userLayers.length}) · 우클릭: 관망 변환 등
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1px' }}>
{userLayers.map((layer) => {
const isLine = layer.geomTypes.some((g) => /Line/i.test(g));
const isSelected = selectedInfo?.type === 'user' && selectedInfo.layer.id === layer.id;
return (
<div
key={layer.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: isSelected ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'user', layer })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: layer.id,
category: `gis_${layer.id}`,
layerTitle: layer.name,
isLine,
isUserLayer: true,
userLayerObj: layer,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
<input
type="checkbox"
checked={layer.visible}
onChange={(e) => {
e.stopPropagation();
onToggleUserLayerVisibility(layer.id);
}}
title={`${layer.name} 표시/숨김`}
style={{ cursor: 'pointer' }}
/>
<span style={{ width: 10, height: 10, borderRadius: isLine ? 0 : '50%', background: layer.color, flexShrink: 0 }} aria-hidden="true" />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={layer.fileName}>
{layer.name}
</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{layer.featureCount}
</span>
</div>
);
})}
</div>
</div>
)}
{/* GROUP 3: 모델 구성요소 */}
<div className="layer-group">
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '4px',
padding: '2px 4px',
borderBottom: '1px solid var(--border)',
}}
>
모델 구성요소
</div>
<div style={{ display: 'grid', gap: '2px' }}>
{[
{ id: 'scenarios', label: '시나리오', icon: <GitBranch size={12} />, action: onOpenScenarios },
{ id: 'options', label: '모델 설정', icon: <Settings size={12} />, action: () => onOpenModelSection('options') },
{ id: 'patterns', label: '패턴', icon: <Layers size={12} />, action: () => onOpenModelSection('patterns') },
{ id: 'curves', label: '곡선 편집', icon: <ChartNoAxesCombined size={12} />, action: () => (onOpenCurveEditor ? onOpenCurveEditor() : onOpenAttributeTable('curves')) },
...(onOpenDemandTable ? [{ id: 'demands', label: '수요량 테이블', icon: <TableProperties size={12} />, action: onOpenDemandTable }] : []),
{ id: 'controls', label: '컨트롤', icon: <Clock size={12} />, action: () => onOpenModelSection('controls') },
{ id: 'rules', label: '규칙', icon: <Info size={12} />, action: () => onOpenAttributeTable('rules') },
].map((item) => (
<button
key={item.id}
type="button"
className="btn btn-ghost"
onClick={item.action}
disabled={!networkModel}
style={{
justifyContent: 'flex-start',
padding: '5px 6px',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
{item.icon}
{item.label}
</button>
))}
</div>
</div>
</div>
{/* 3. Bottom Layer Info Metadata Card */}
<div
style={{
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
padding: '8px 10px',
fontSize: '11px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontWeight: 600, color: 'var(--text)', marginBottom: '4px' }}>
<Info size={12} aria-hidden="true" />
<span>선택 레이어 정보</span>
</div>
{selectedInfo ? (
selectedInfo.type === 'network' ? (
<div style={{ display: 'grid', gridTemplateColumns: '70px 1fr', gap: '3px', color: 'var(--text-secondary)' }}>
<span>구분:</span>
<strong style={{ color: 'var(--text)' }}>
{selectedInfo.category === 'nodes'
? '절점 (Junctions)'
: selectedInfo.category === 'pipes'
? '관로 (Pipes)'
: selectedInfo.category === 'pumps'
? '펌프 (Pumps)'
: selectedInfo.category === 'valves'
? '밸브 (Valves)'
: '탱크 / 저수지'}
</strong>
<span>객체 수:</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>
{selectedInfo.category === 'nodes'
? `${junctionCount}개`
: selectedInfo.category === 'pipes'
? `${pipeCount}개`
: selectedInfo.category === 'pumps'
? `${pumpCount}개`
: selectedInfo.category === 'valves'
? `${valveCount}개`
: `${tankReservoirCount}개`}
</span>
<span>형상 유형:</span>
<span>
{selectedInfo.category === 'nodes' || selectedInfo.category === 'tanksReservoirs'
? 'Point (점)'
: 'LineString (선)'}
</span>
<span>표시 상태:</span>
<span style={{ color: visibility[selectedInfo.category] ? 'var(--good)' : 'var(--text-muted)' }}>
{visibility[selectedInfo.category] ? '표시 중' : '숨김'}
</span>
</div>
) : selectedInfo.type === 'results' ? (
<div style={{ display: 'grid', gridTemplateColumns: '70px 1fr', gap: '3px', color: 'var(--text-secondary)' }}>
<span>지표:</span>
<strong style={{ color: 'var(--text)' }}>{selectedInfo.metric}</strong>
<span>해석 엔진:</span>
<span>{wasmResult?.engine.runtime.toUpperCase()} 2.3.5</span>
<span>단위:</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>
{selectedInfo.metric === 'pressure'
? displayUnits.pressure
: selectedInfo.metric === 'head'
? displayUnits.head
: selectedInfo.metric === 'flow'
? displayUnits.flow
: selectedInfo.metric === 'velocity'
? displayUnits.velocity
: selectedInfo.metric === 'headloss'
? 'm/km'
: '-'}
</span>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '70px 1fr', gap: '3px', color: 'var(--text-secondary)' }}>
<span>레이어명:</span>
<strong style={{ color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedInfo.layer.name}
</strong>
<span>피처 수:</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{selectedInfo.layer.featureCount}개</span>
<span>형상 유형:</span>
<span>{selectedInfo.layer.geomTypes.join(', ')}</span>
<span>추정 좌표:</span>
<span style={{ color: 'var(--accent)', fontWeight: 500 }}>{selectedInfo.layer.estimatedCrs}</span>
<span>표시 상태:</span>
<span style={{ color: selectedInfo.layer.visible ? 'var(--good)' : 'var(--text-muted)' }}>
{selectedInfo.layer.visible ? `표시 중 (${Math.round(selectedInfo.layer.opacity * 100)}%)` : '숨김'}
</span>
</div>
)
) : (
<div style={{ color: 'var(--text-muted)', fontSize: '10px' }}>
위 목록에서 레이어를 클릭하면 상세 정보가 표시됩니다.
</div>
)}
</div>
{/* 4. Resizer Handle on Right Edge */}
<div
onMouseDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick}
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: '5px',
cursor: 'col-resize',
zIndex: 30,
}}
title="드래그하여 폭 조절 (더블클릭: 기본폭 264px 초기화)"
/>
{/* Layer Context Menu */}
{layerContextMenu && (
<div
style={{
position: 'fixed',
left: Math.min(layerContextMenu.x, window.innerWidth - 190),
top: Math.min(layerContextMenu.y, window.innerHeight - 200),
zIndex: 1200,
width: '180px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
boxShadow: 'var(--shadow-pop)',
padding: '4px',
display: 'flex',
flexDirection: 'column',
gap: '1px',
fontSize: '12px',
}}
onMouseDown={(e) => e.stopPropagation()}
role="menu"
aria-label="레이어 컨텍스트 메뉴"
>
<div
style={{
padding: '3px 6px',
fontSize: '10px',
fontWeight: 600,
color: 'var(--text-muted)',
borderBottom: '1px solid var(--border)',
marginBottom: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{layerContextMenu.layerTitle}
</div>
<button
className="dropdown-item"
onClick={() => {
onOpenAttributeTable(layerContextMenu.category);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
속성 테이블 열기
</button>
<button
className="dropdown-item"
onClick={() => {
if (layerContextMenu.isUserLayer) {
onToggleUserLayerVisibility(layerContextMenu.layerKey);
} else {
const k = layerContextMenu.layerKey as keyof LayerVisibilityState;
if (typeof visibility[k] === 'boolean') {
onVisibilityChange((prev) => ({
...prev,
[k]: !prev[k],
}));
}
}
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
표시 / 숨기기
</button>
<div style={{ height: '1px', background: 'var(--border)', margin: '2px 0' }} />
<button
className="dropdown-item"
onClick={() => {
onOpenStyleSettings(layerContextMenu.layerKey, layerContextMenu.isLine, layerContextMenu.isUserLayer);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
스타일 설정...
</button>
<button
className="dropdown-item"
onClick={() => {
onOpenLabelSettings(layerContextMenu.layerKey, layerContextMenu.isLine, layerContextMenu.isUserLayer);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
라벨 설정...
</button>
{layerContextMenu.isUserLayer && (
<>
<div style={{ height: '1px', background: 'var(--border)', margin: '2px 0' }} />
<button
className="dropdown-item"
onClick={() => {
if (layerContextMenu.userLayerObj) onConvertToNetwork(layerContextMenu.userLayerObj);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
관망으로 변환
</button>
<button
className="dropdown-item"
onClick={() => {
onDeleteUserLayer(layerContextMenu.layerKey);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer', color: 'var(--crit)' }}
>
레이어 삭제
</button>
</>
)}
</div>
)}
</aside>
);
};
+393
View File
@@ -0,0 +1,393 @@
import React, { useState } from 'react';
import { Palette, X, RotateCcw, Check, Sparkles } from 'lucide-react';
import type { DisplayMode, SimulationResult } from './types';
import type { NetworkModel } from './inpModel';
import { computeUnitHeadloss } from './unitSystem';
export interface LayerStyleConfig {
useColormap: boolean;
colormapPreset: 'blue' | 'amber' | 'green' | 'purple' | 'diverging';
minValue?: number;
maxValue?: number;
solidColor: string;
sizeOrWidth: number; // radius for point (3~10px), width for line (0.8~5px)
opacity: number; // 0.0 ~ 1.0
}
interface LayerStyleModalProps {
layerKey: string;
layerTitle: string;
isLine: boolean;
isUserLayer: boolean;
simulationResult: SimulationResult | null;
networkModel?: NetworkModel | null;
displayMode: DisplayMode;
initialConfig?: Partial<LayerStyleConfig>;
onClose: () => void;
onApply: (config: LayerStyleConfig) => void;
}
const COLOR_PRESETS = [
{ id: 'blue', label: '블루 (기본 수압/수두/유량)', colors: ['#dbeafe', '#93c5fd', '#3b82f6', '#1d4ed8', '#172554'] },
{ id: 'amber', label: '앰버 (기본 유속/손실수두)', colors: ['#d9a873', '#c68941', '#b06b00', '#935100', '#713d00'] },
{ id: 'green', label: '그린 (적정/안정 수압)', colors: ['#bbf7d0', '#86efac', '#4ade80', '#22c55e', '#15803d'] },
{ id: 'purple', label: '퍼플 (특수/수질 농도)', colors: ['#e9d5ff', '#d8b4fe', '#c084fc', '#a855f7', '#7e22ce'] },
{ id: 'diverging', label: '다이버지 (차이/편차)', colors: ['#0b5ca3', '#80ade0', '#e5e6e8', '#db948b', '#98342e'] },
] as const;
const SOLID_SWATCHES = [
'#0284c7', // Sky blue
'#2563eb', // Blue
'#16a34a', // Green
'#d97706', // Amber
'#dc2626', // Red
'#7c3aed', // Purple
'#64748b', // Slate
'#0f172a', // Dark
];
export const LayerStyleModal: React.FC<LayerStyleModalProps> = ({
layerKey,
layerTitle,
isLine,
isUserLayer,
simulationResult,
networkModel,
displayMode,
initialConfig,
onClose,
onApply,
}) => {
const [useColormap, setUseColormap] = useState<boolean>(
initialConfig?.useColormap ?? (!isUserLayer && !!simulationResult)
);
const [colormapPreset, setColormapPreset] = useState<'blue' | 'amber' | 'green' | 'purple' | 'diverging'>(
initialConfig?.colormapPreset || (displayMode === 'flow' ? 'blue' : isLine ? 'amber' : 'blue')
);
const [minValue, setMinValue] = useState<string>(
initialConfig?.minValue !== undefined ? String(initialConfig.minValue) : '0'
);
const [maxValue, setMaxValue] = useState<string>(
initialConfig?.maxValue !== undefined ? String(initialConfig.maxValue) : '100'
);
const [solidColor, setSolidColor] = useState<string>(
initialConfig?.solidColor || (isLine ? '#64748b' : '#3b82f6')
);
const [sizeOrWidth, setSizeOrWidth] = useState<number>(
initialConfig?.sizeOrWidth || (isLine ? 1.2 : 5)
);
// Auto Range Calculation from actual simulation results
const handleAutoRange = () => {
if (!simulationResult) return;
let min = Infinity;
let max = -Infinity;
if (!isLine) {
// Node values
Object.values(simulationResult.nodes).forEach((n) => {
let val = 0;
if (displayMode === 'pressure') val = n.pressure;
else if (displayMode === 'head') val = n.head;
else if (displayMode === 'quality') val = n.quality ?? 0;
if (Number.isFinite(val)) {
if (val < min) min = val;
if (val > max) max = val;
}
});
} else {
// Link values
Object.entries(simulationResult.links).forEach(([linkId, l]) => {
let val = 0;
if (displayMode === 'flow') val = l.flow;
else if (displayMode === 'velocity') val = l.velocity;
else if (displayMode === 'headloss') {
// EN_HEADLOSS is a TOTAL across the whole pipe and scales with pipe
// length, so the auto-range must use the length-normalized unit
// headloss (matching the map's own "headloss" property) -- ranging
// on the raw total would bias the scale toward long pipes.
const length = networkModel?.pipes[linkId]?.length ?? 0;
val = computeUnitHeadloss(l.headloss, length);
}
if (Number.isFinite(val)) {
const absVal = Math.abs(val);
if (absVal < min) min = absVal;
if (absVal > max) max = absVal;
}
});
}
if (min !== Infinity && max !== -Infinity) {
setMinValue(String(Math.floor(min)));
setMaxValue(String(Math.ceil(max) || 10));
}
};
const handleSave = () => {
const config: LayerStyleConfig = {
useColormap: isUserLayer ? false : useColormap,
colormapPreset,
minValue: parseFloat(minValue) || 0,
maxValue: parseFloat(maxValue) || 100,
solidColor,
sizeOrWidth,
opacity: initialConfig?.opacity ?? 0.9,
};
localStorage.setItem(`gispipe_layer_style_${layerKey}`, JSON.stringify(config));
onApply(config);
onClose();
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1050,
background: 'rgba(15, 23, 42, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={onClose}
>
<div
style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
boxShadow: 'var(--shadow-modal)',
width: '100%',
maxWidth: '440px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
fontSize: '12px',
}}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="레이어 스타일 설정"
>
{/* Modal Header */}
<div
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 600 }}>
<Palette size={15} style={{ color: 'var(--accent)' }} aria-hidden="true" />
<span>스타일 설정: {layerTitle}</span>
</div>
<button
onClick={onClose}
className="btn btn-ghost"
style={{ padding: '3px' }}
title="닫기"
aria-label="닫기"
>
<X size={15} aria-hidden="true" />
</button>
</div>
{/* Modal Body */}
<div style={{ padding: '14px', display: 'flex', flexDirection: 'column', gap: '14px', maxHeight: '75vh', overflowY: 'auto' }}>
{/* Section 1: Colormap vs Single color */}
{!isUserLayer && (
<div style={{ padding: '8px 10px', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', fontWeight: 500 }}>
<span>수리해석 결과 컬러맵 적용</span>
<input
type="checkbox"
checked={useColormap}
disabled={!simulationResult}
onChange={(e) => setUseColormap(e.target.checked)}
/>
</label>
{!simulationResult && (
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '4px' }}>
해석 결과가 없을 때는 기본 단색 스타일이 적용됩니다.
</div>
)}
</div>
)}
{/* Section 2: Colormap Preset & Range */}
{useColormap && !isUserLayer && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500, color: 'var(--text-secondary)' }}>
색상 팔레트 프리셋
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{COLOR_PRESETS.map((p) => {
const isSelected = colormapPreset === p.id;
return (
<div
key={p.id}
onClick={() => setColormapPreset(p.id)}
style={{
padding: '6px 8px',
borderRadius: 'var(--r-sm)',
border: isSelected ? '1.5px solid var(--accent)' : '1px solid var(--border)',
background: isSelected ? 'var(--accent-wash)' : 'var(--bg-panel)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}
>
<span style={{ fontSize: '11px', fontWeight: isSelected ? 600 : 400 }}>{p.label}</span>
<div style={{ display: 'flex', gap: '2px', height: '10px', width: '80px', borderRadius: '2px', overflow: 'hidden' }}>
{p.colors.map((c, i) => (
<div key={i} style={{ flex: 1, background: c }} />
))}
</div>
</div>
);
})}
</div>
</div>
{/* Range settings */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<label style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>
값 표시 범위 ({displayMode})
</label>
<button
onClick={handleAutoRange}
className="btn btn-secondary"
style={{ padding: '2px 6px', fontSize: '10px', display: 'inline-flex', alignItems: 'center', gap: '3px' }}
title="해석 결과의 최소/최대값 자동 적용"
>
<Sparkles size={11} aria-hidden="true" />
<span>자동 범위</span>
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>최솟값 (0%)</span>
<input
type="number"
value={minValue}
onChange={(e) => setMinValue(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid var(--border-strong)', borderRadius: 'var(--r-sm)', fontSize: '12px' }}
/>
</div>
<div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>최댓값 (100%)</span>
<input
type="number"
value={maxValue}
onChange={(e) => setMaxValue(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid var(--border-strong)', borderRadius: 'var(--r-sm)', fontSize: '12px' }}
/>
</div>
</div>
</div>
</div>
)}
{/* Section 3: Solid Color Selection (when colormap is off or for user GIS) */}
{(!useColormap || isUserLayer) && (
<div>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500, color: 'var(--text-secondary)' }}>
기본 색상
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="color"
value={solidColor}
onChange={(e) => setSolidColor(e.target.value)}
style={{ width: '32px', height: '28px', padding: 0, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', cursor: 'pointer' }}
/>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{SOLID_SWATCHES.map((swatch) => (
<div
key={swatch}
onClick={() => setSolidColor(swatch)}
style={{
width: '20px',
height: '20px',
borderRadius: '4px',
background: swatch,
cursor: 'pointer',
border: solidColor === swatch ? '2px solid var(--text)' : '1px solid var(--border)',
}}
/>
))}
</div>
</div>
</div>
)}
{/* Section 4: Stroke Width or Node Size */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', color: 'var(--text-secondary)' }}>
<span>{isLine ? '선 두께 (Stroke Width)' : '절점 크기 (Node Radius)'}</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{sizeOrWidth}{isLine ? 'px' : 'px'}</span>
</div>
<input
type="range"
min={isLine ? 0.8 : 3}
max={isLine ? 4.0 : 10}
step={isLine ? 0.2 : 0.5}
value={sizeOrWidth}
onChange={(e) => setSizeOrWidth(parseFloat(e.target.value))}
style={{ width: '100%', accentColor: 'var(--accent)' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: 'var(--text-muted)' }}>
<span>{isLine ? '얇게 (0.8px)' : '작게 (3px)'}</span>
<span>{isLine ? '두껍게 (4.0px)' : '크게 (10px)'}</span>
</div>
</div>
</div>
{/* Modal Footer */}
<div
style={{
padding: '10px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<button
onClick={() => {
localStorage.removeItem(`gispipe_layer_style_${layerKey}`);
onClose();
}}
className="btn btn-secondary"
style={{ fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<RotateCcw size={12} aria-hidden="true" />
<span>기본값 초기화</span>
</button>
<div style={{ display: 'flex', gap: '6px' }}>
<button onClick={onClose} className="btn btn-secondary" style={{ fontSize: '11px' }}>
취소
</button>
<button
onClick={handleSave}
className="btn btn-primary"
style={{ fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<Check size={12} aria-hidden="true" />
<span>적용</span>
</button>
</div>
</div>
</div>
</div>
);
};
+230
View File
@@ -0,0 +1,230 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronUp, Settings2, X } from 'lucide-react';
export * from './legendScale';
import { bandLabels, classBreaks, type LegendOverride, type LegendScale } from './legendScale';
// The scales currently drawn on the map, so the PNG export composites the same
// legend the user sees (there is a single map instance).
let activeScales: LegendScale[] = [];
export const setActiveLegendScales = (scales: LegendScale[]) => { activeScales = scales; };
export const getActiveLegendScales = () => activeScales;
export const Legend: React.FC<{
scales: LegendScale[];
note?: string;
/** Opens the editor for one scale (title); omit to hide the edit buttons. */
onEdit?: (title: string) => void;
}> = ({ scales, note, onEdit }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
if (scales.length === 0) return null;
return (
<div
style={{
position: 'absolute',
bottom: '24px',
right: '12px',
zIndex: 10,
background: 'rgba(255, 255, 255, 0.94)',
padding: isCollapsed ? '4px 8px' : '6px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
fontSize: '10px',
boxShadow: 'var(--shadow-md)',
width: isCollapsed ? 'auto' : '200px',
backdropFilter: 'blur(4px)',
userSelect: 'none',
}}
>
<div
onClick={() => setIsCollapsed((v) => !v)}
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', gap: '6px' }}
title={isCollapsed ? '범례 펼치기' : '범례 접기'}
>
<span style={{ fontWeight: 700, fontSize: '10px', color: 'var(--text-secondary)' }}>
{isCollapsed ? `범례 (${scales.map((s) => s.title).join('·')})` : '관망 범례'}
</span>
<button
style={{ border: 'none', background: 'none', padding: 0, cursor: 'pointer', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}
>
{isCollapsed ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
</div>
{!isCollapsed && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '6px' }}>
{note && <div style={{ fontSize: '9px', color: 'var(--text-muted)' }}>{note}</div>}
{scales.map((s) => {
const labels = bandLabels(s.breaks);
return (
<div key={s.title}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3px' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{s.title}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '9px', color: 'var(--text-muted)' }}>
{s.unit}
{onEdit && (
<button
onClick={() => onEdit(s.title)}
title={`${s.title} 범례 설정 (구간·색·값에 따른 크기)`}
aria-label={`${s.title} 범례 설정`}
style={{ border: 'none', background: 'none', padding: 0, cursor: 'pointer', color: 'var(--text-muted)', display: 'flex' }}
>
<Settings2 size={11} />
</button>
)}
</span>
</div>
<div style={{ display: 'flex', gap: '1px' }}>
{s.colors.map((c, i) => (
<div key={i} style={{ flex: 1, minWidth: 0 }}>
<div style={{ background: c, height: '7px' }} />
<div style={{ marginTop: '2px', fontSize: '8.5px', fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden' }}>
{labels[i]}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
/**
* Composite the active legend onto a canvas for PNG image export (Q4).
*/
export function drawLegendOnCanvas(
ctx: CanvasRenderingContext2D,
canvasWidth: number,
canvasHeight: number,
dpr: number,
scales: LegendScale[] = activeScales
) {
if (scales.length === 0) return;
const boxWidth = 220 * dpr;
const sectionH = 45 * dpr;
const boxHeight = (24 + 45 * scales.length) * dpr;
const margin = 20 * dpr;
const x = canvasWidth - boxWidth - margin;
const y = canvasHeight - boxHeight - margin;
ctx.save();
ctx.fillStyle = 'rgba(255, 255, 255, 0.94)';
ctx.strokeStyle = '#cbd5e1';
ctx.lineWidth = 1 * dpr;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(x, y, boxWidth, boxHeight, 6 * dpr);
} else {
ctx.rect(x, y, boxWidth, boxHeight);
}
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#1e293b';
ctx.font = `bold ${11 * dpr}px sans-serif`;
ctx.fillText('관망 범례', x + 10 * dpr, y + 16 * dpr);
scales.forEach((s, si) => {
const startY = y + 33 * dpr + si * sectionH;
ctx.fillStyle = '#334155';
ctx.font = `600 ${9.5 * dpr}px sans-serif`;
ctx.fillText(s.title, x + 10 * dpr, startY);
ctx.fillStyle = '#64748b';
ctx.font = `${8.5 * dpr}px sans-serif`;
ctx.fillText(s.unit, x + boxWidth - 10 * dpr - ctx.measureText(s.unit).width, startY);
const barY = startY + 4 * dpr;
const barW = (boxWidth - 20 * dpr) / s.colors.length;
const barH = 7 * dpr;
const labels = bandLabels(s.breaks);
ctx.font = `${7.5 * dpr}px monospace`;
s.colors.forEach((c, i) => {
const bx = x + 10 * dpr + i * barW;
ctx.fillStyle = c;
ctx.fillRect(bx, barY, barW - 1 * dpr, barH);
ctx.fillStyle = '#64748b';
const tw = ctx.measureText(labels[i]).width;
ctx.fillText(labels[i], bx + (barW - tw) / 2, barY + barH + 9 * dpr);
});
});
ctx.restore();
}
/** 범례 설정: class edges, colours, equal-interval / quantile, reverse, size by value. */
export const LegendEditor: React.FC<{
scale: LegendScale;
defaultScale: LegendScale;
/** Current values of the scale's property (for equal interval / quantile). */
values: number[];
onSave: (override: LegendOverride | null) => void;
onClose: () => void;
}> = ({ scale, defaultScale, values, onSave, onClose }) => {
const n = scale.colors.length;
const [breaks, setBreaks] = useState<string[]>(scale.breaks.map((b) => String(b)));
const [colors, setColors] = useState<string[]>([...scale.colors]);
const [sizeBy, setSizeBy] = useState(Boolean(scale.sizeByValue));
const [msg, setMsg] = useState<string | null>(null);
const vals = scale.absolute ? values.map(Math.abs) : values;
const auto = (method: 'equal' | 'quantile') => {
const b = classBreaks(vals, n, method);
if (!b) return setMsg('값이 없거나 모두 같아 자동 구간을 만들 수 없습니다. 해석을 먼저 실행하세요.');
setBreaks(b.map(String));
setMsg(null);
};
const save = () => {
const nums = breaks.map(Number);
if (nums.some((x) => !Number.isFinite(x))) return setMsg('구간 값은 숫자여야 합니다.');
for (let i = 2; i < nums.length; i++) if (!(nums[i] > nums[i - 1])) return setMsg('구간 경계는 커지는 순서여야 합니다.');
onSave({ unit: scale.unit, breaks: nums, colors, sizeByValue: sizeBy });
onClose();
};
const labels = bandLabels(breaks.map(Number));
const inp: React.CSSProperties = { width: 64, height: 22, fontSize: 11, fontFamily: 'var(--font-mono)' };
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 1050, background: 'rgba(15,23,42,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
<div role="dialog" aria-label="범례 설정" onClick={(e) => e.stopPropagation()}
style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', boxShadow: 'var(--shadow-modal)', width: 360, fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--bg-subtle)', fontWeight: 600 }}>
<span>범례 설정: {scale.title} ({scale.unit})</span>
<button className="btn btn-ghost" style={{ padding: 3 }} onClick={onClose} aria-label="닫기"><X size={14} /></button>
</div>
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ color: 'var(--text-secondary)' }}>
{scale.absolute ? '절댓값 기준. ' : ''}첫 칸은 표시용 하한이고, 나머지 값이 각 색 구간의 시작 경계입니다.
</div>
{colors.map((c, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="color" value={c} onChange={(e) => setColors(colors.map((x, k) => (k === i ? e.target.value : x)))} style={{ width: 30, height: 22, padding: 0, border: '1px solid var(--border)' }} aria-label={`${i + 1}번째 색`} />
<input type="number" value={breaks[i]} onChange={(e) => setBreaks(breaks.map((x, k) => (k === i ? e.target.value : x)))} style={inp} aria-label={`${i + 1}번째 경계`} />
<span style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>{labels[i]}</span>
</div>
))}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11 }} onClick={() => auto('equal')}>등간격</button>
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11 }} onClick={() => auto('quantile')}>등분위</button>
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11 }} onClick={() => setColors([...colors].reverse())}>색상 반전</button>
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11 }} onClick={() => { setBreaks(defaultScale.breaks.map(String)); setColors([...defaultScale.colors]); setSizeBy(false); }}>기본값</button>
</div>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={sizeBy} onChange={(e) => setSizeBy(e.target.checked)} />
값에 따라 크기 표시 (절점 반지름 / 관로 굵기)
</label>
{msg && <div style={{ color: 'var(--crit)' }}>{msg}</div>}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '10px 14px', borderTop: '1px solid var(--border)', background: 'var(--bg-subtle)' }}>
<button className="btn btn-secondary" style={{ fontSize: 11 }} onClick={() => { onSave(null); onClose(); }}>설정 지우기</button>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary" style={{ fontSize: 11 }} onClick={onClose}>취소</button>
<button className="btn btn-primary" style={{ fontSize: 11 }} onClick={save}>적용</button>
</div>
</div>
</div>
</div>
);
};
+625
View File
@@ -0,0 +1,625 @@
import React from 'react';
import { TimeSeriesChart } from './TimeSeriesChart';
import { HglProfileViewer } from './HglProfileViewer';
import { computeUnitHeadloss, type DisplayUnitLabels } from './unitSystem';
import { NumberField } from './NumberField';
import { Trash2, AlertTriangle, Zap, Ruler } from 'lucide-react';
import { type InpPipe, type InpPump, type InpValve, type NetworkModel } from './inpModel';
import { type LinkResult, type SimulationResult, type DisplayMode } from './types';
export interface LinkInspectorProps {
selectedPipe: InpPipe;
selectedPump: InpPump | null | undefined;
selectedValve: InpValve | null | undefined;
selectedLinkResult: LinkResult | null | undefined;
networkModel: NetworkModel | null;
wasmResult: SimulationResult | null;
allNodeIds: string[];
displayMode: DisplayMode;
displayUnits: DisplayUnitLabels;
displayDiameterValue: (value: number) => number;
displayFlowValue: (value: number) => number;
displayHeadValue: (value: number) => number;
displayLengthValue: (value: number) => number;
displayPressureValue: (value: number) => number;
displayVelocityValue: (value: number) => number;
inputDiameterValue: (value: number) => number;
inputLengthValue: (value: number) => number;
timeStepIndex: number;
setTimeStepIndex: React.Dispatch<React.SetStateAction<number>>;
pipeConversionValveType: string;
setPipeConversionValveType: React.Dispatch<React.SetStateAction<string>>;
pipeConversionSetting: string;
setPipeConversionSetting: React.Dispatch<React.SetStateAction<string>>;
handleUpdatePipe: (id: string, updates: Partial<InpPipe>) => void;
handleUpdateValve: (id: string, updates: Partial<InpValve>) => void;
handleConvertPipeToValve: (pipeId: string) => void;
handleRecalculateLength: (pipeId: string) => void;
handleReversePipe: (pipeId: string) => void;
handleSplitPipe: (pipeId: string) => void;
handleDeleteSelected: () => void;
}
/** Properties and results of the selected pipe, pump or valve (inspector tab). */
export const LinkInspector: React.FC<LinkInspectorProps> = ({
selectedPipe,
selectedPump,
selectedValve,
selectedLinkResult,
networkModel,
wasmResult,
allNodeIds,
displayMode,
displayUnits,
displayDiameterValue,
displayFlowValue,
displayHeadValue,
displayLengthValue,
displayPressureValue,
displayVelocityValue,
inputDiameterValue,
inputLengthValue,
timeStepIndex,
setTimeStepIndex,
pipeConversionValveType,
setPipeConversionValveType,
pipeConversionSetting,
setPipeConversionSetting,
handleUpdatePipe,
handleUpdateValve,
handleConvertPipeToValve,
handleRecalculateLength,
handleReversePipe,
handleSplitPipe,
handleDeleteSelected,
}) => (
<div
style={{
background: 'var(--bg-subtle)',
padding: '12px',
borderRadius: '6px',
border: '1px solid #e2e8f0',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h3 style={{ margin: 0, color: 'var(--text)', fontSize: '15px' }}>
{selectedPump ? '펌프' : selectedValve ? '밸브' : '관로'}:{' '}
<span style={{ color: selectedPump ? 'var(--warn)' : selectedValve ? '#7c3aed' : 'var(--accent)' }}>
{selectedPipe.id} {selectedValve ? `(${selectedValve.valveType})` : ''}
</span>
</h3>
{!selectedPump && !selectedValve && (
<>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleSplitPipe(selectedPipe.id)}
title="이 관로 중간에 새 절점을 삽입하고 2개의 관로로 분할합니다."
style={{ marginLeft: 'auto', marginRight: '4px', fontSize: '11px' }}
>
관로 분할
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleReversePipe(selectedPipe.id)}
title="Node1/Node2만 교환합니다. 해석 유량의 실제 방향은 유량 부호로 판단합니다."
style={{ marginRight: '6px', fontSize: '11px' }}
>
방향 변경
</button>
</>
)}
<button
onClick={handleDeleteSelected}
style={{
padding: '2px 8px',
background: 'var(--crit-wash)',
color: 'var(--crit)',
border: '1px solid var(--crit)',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--s-1)',
}}
>
<Trash2 size={12} aria-hidden="true" />
삭제
</button>
</div>
{!selectedPump && !selectedValve && (
<div style={{ marginBottom: '10px', display: 'grid', gap: '8px' }}>
<div style={{ fontWeight: 700 }}>관로 기본 정보</div>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
관로 ID
<input value={selectedPipe.id} readOnly style={{ background: 'var(--bg-panel)' }} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '6px', alignItems: 'end' }}>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
변경할 밸브 유형
<select
value={pipeConversionValveType}
onChange={(e) => setPipeConversionValveType(e.target.value)}
>
{['PRV', 'PSV', 'PBV', 'FCV', 'TCV', 'GPV', 'PCV'].map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
설정값
<input
value={pipeConversionSetting}
onChange={(e) => setPipeConversionSetting(e.target.value)}
placeholder={pipeConversionValveType === 'GPV' ? '곡선 ID' : '0'}
/>
</label>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleConvertPipeToValve(selectedPipe.id)}
title="현재 관로의 연결 절점, 관경, 미소손실을 유지해 밸브로 변경"
>
밸브로 변경
</button>
</div>
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginBottom: '12px',
}}
>
<div>
<label
title="EPANET Node1 기준 시작절점입니다. 양수 유량이면 실제 흐름은 시작절점에서 끝절점 방향입니다."
style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}
>
시작절점
</label>
<select
value={selectedPipe.node1}
onChange={(e) => {
if (selectedValve) handleUpdateValve(selectedValve.id, { node1: e.target.value });
else handleUpdatePipe(selectedPipe.id, { node1: e.target.value });
}}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
>
{allNodeIds.map((nid) => (
<option key={nid} value={nid}>
{nid}
</option>
))}
</select>
</div>
<div>
<label
title="EPANET Node2 기준 끝절점입니다. 음수 유량이면 실제 흐름은 끝절점에서 시작절점 방향입니다."
style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}
>
끝절점
</label>
<select
value={selectedPipe.node2}
onChange={(e) => {
if (selectedValve) handleUpdateValve(selectedValve.id, { node2: e.target.value });
else handleUpdatePipe(selectedPipe.id, { node2: e.target.value });
}}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
>
{allNodeIds.map((nid) => (
<option key={nid} value={nid}>
{nid}
</option>
))}
</select>
</div>
</div>
{!selectedPump && !selectedValve && (
<>
<div style={{ marginBottom: '12px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2px',
}}
>
<label style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>관로길이 ({displayUnits.length})</label>
<button
onClick={() => handleRecalculateLength(selectedPipe.id)}
style={{
fontSize: '11px',
padding: '2px 6px',
background: 'var(--accent-wash)',
color: 'var(--accent-hover)',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--s-1)',
}}
title="절점 간 유클리드 좌표 거리로 길이를 자동 산정합니다"
>
<Ruler size={12} aria-hidden="true" />
좌표 거리로 계산
</button>
</div>
<NumberField
key={`len-${selectedPipe.id}`}
value={displayLengthValue(selectedPipe.length)}
onCommit={(v) =>
handleUpdatePipe(selectedPipe.id, {
length: inputLengthValue(v),
})
}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginBottom: '12px',
}}
>
<div>
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
관경 ({displayUnits.diameter})
</label>
<NumberField
key={`dia-${selectedPipe.id}`}
value={displayDiameterValue(selectedPipe.diameter)}
onCommit={(v) =>
handleUpdatePipe(selectedPipe.id, {
diameter: inputDiameterValue(v),
})
}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
조도계수
</label>
<input
type="number"
step="1"
value={selectedPipe.roughness}
onChange={(e) =>
handleUpdatePipe(selectedPipe.id, {
roughness: parseFloat(e.target.value) || 100,
})
}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
/>
</div>
</div>
</>
)}
{selectedValve && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '12px' }}>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
밸브 유형
<select
value={selectedValve.valveType}
onChange={(e) => handleUpdateValve(selectedValve.id, { valveType: e.target.value })}
>
{['PRV', 'PSV', 'PBV', 'FCV', 'TCV', 'GPV', 'PCV'].map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
설정값
<input
value={selectedValve.setting}
onChange={(e) => {
const numeric = Number(e.target.value);
handleUpdateValve(selectedValve.id, {
setting: e.target.value !== '' && Number.isFinite(numeric) ? numeric : e.target.value,
});
}}
/>
</label>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
관경 ({displayUnits.diameter})
<NumberField
key={`vdia-${selectedValve.id}`}
value={displayDiameterValue(selectedValve.diameter)}
onCommit={(v) => handleUpdateValve(selectedValve.id, {
diameter: inputDiameterValue(Math.max(0, v)),
})}
/>
</label>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
미소손실계수
<input
type="number"
min="0"
step="0.01"
value={selectedValve.minorLoss}
onChange={(e) => handleUpdateValve(selectedValve.id, {
minorLoss: Number(e.target.value) || 0,
})}
/>
</label>
</div>
)}
{!selectedPump && !selectedValve && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '12px' }}>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
미소손실계수
<input
type="number"
step="0.01"
value={selectedPipe.minorLoss}
onChange={(e) => handleUpdatePipe(selectedPipe.id, { minorLoss: Number(e.target.value) || 0 })}
/>
</label>
<label
title="EPANET [REACTIONS] BULK 관로별 재정의 값입니다. 반응 차수에 따라 단위가 달라질 수 있으며 빈 값은 GLOBAL BULK를 따릅니다."
style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}
>
수체반응계수
<input
type="number"
step="any"
value={selectedPipe.bulkCoeff ?? ''}
placeholder="전역값 사용"
onChange={(e) => handleUpdatePipe(selectedPipe.id, { bulkCoeff: e.target.value === '' ? undefined : Number(e.target.value) })}
/>
</label>
<label
title="EPANET [REACTIONS] WALL 관로별 재정의 값입니다. 반응 차수에 따라 단위가 달라질 수 있으며 빈 값은 GLOBAL WALL을 따릅니다."
style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}
>
관벽반응계수
<input
type="number"
step="any"
value={selectedPipe.wallCoeff ?? ''}
placeholder="전역값 사용"
onChange={(e) => handleUpdatePipe(selectedPipe.id, { wallCoeff: e.target.value === '' ? undefined : Number(e.target.value) })}
/>
</label>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
태그
<input value={selectedPipe.tag ?? ''} onChange={(e) => handleUpdatePipe(selectedPipe.id, { tag: e.target.value })} />
</label>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)', gridColumn: '1 / -1' }}>
설명
<input value={selectedPipe.description ?? ''} onChange={(e) => handleUpdatePipe(selectedPipe.id, { description: e.target.value })} />
</label>
</div>
)}
{!selectedPump && !selectedValve && (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
상태 (Status)
</label>
<select
value={selectedPipe.status}
onChange={(e) =>
handleUpdatePipe(selectedPipe.id, { status: e.target.value })
}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
>
<option value="Open">Open (개방)</option>
<option value="Closed">Closed (차단)</option>
<option value="CV">CV (체크밸브)</option>
</select>
</div>
)}
{selectedLinkResult && (
<div
style={{
marginTop: '12px',
padding: '8px',
background: 'var(--bg-panel)',
borderRadius: '4px',
border: '1px solid #e2e8f0',
fontSize: '12px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>해석 결과</span>
{wasmResult?.timeSteps && (
<span style={{ fontSize: '11px', color: 'var(--good)', fontWeight: 600 }}>
{String(Math.floor((wasmResult.timeSteps[timeStepIndex] ?? 0) / 3600)).padStart(2, '0')}:00 시점
</span>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>유량:</span>
<span style={{ fontFamily: 'monospace' }}>
{displayFlowValue(selectedLinkResult.timeSeries?.flow[timeStepIndex] ?? selectedLinkResult.flow).toFixed(2)} {displayUnits.flow}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>유속:</span>
<span style={{ fontFamily: 'monospace' }}>
{displayVelocityValue(selectedLinkResult.timeSeries?.velocity[timeStepIndex] ?? selectedLinkResult.velocity).toFixed(2)} {displayUnits.velocity}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>손실수두(총):</span>
<span style={{ fontFamily: 'monospace' }}>
{displayHeadValue(
selectedLinkResult.timeSeries?.headloss[timeStepIndex] ?? selectedLinkResult.headloss
).toFixed(2)} {displayUnits.head}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>동수경사(‰):</span>
<span style={{ fontFamily: 'monospace' }}>
{computeUnitHeadloss(
Math.abs(displayHeadValue(
selectedLinkResult.timeSeries?.headloss[timeStepIndex] ?? selectedLinkResult.headloss
)),
displayLengthValue(selectedPipe?.length ?? 0)
).toFixed(2)}
</span>
</div>
{wasmResult?.qualityInfo?.status === 'failed' && (
<div style={{ display: 'flex', justifyContent: 'space-between', color: 'var(--warn)', fontSize: '11px', background: 'var(--warn-wash)', padding: '3px 6px', borderRadius: '4px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--s-1)' }}>
<AlertTriangle size={12} aria-hidden="true" />
수질해석 실패:
</span>
<span>수질 데이터 없음</span>
</div>
)}
{wasmResult?.qualityInfo?.status === 'success' && wasmResult.qualityInfo.qualType > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#7c3aed' }}>
<span>수질 ({wasmResult.qualityInfo.chemName}):</span>
<span style={{ fontFamily: 'monospace', fontWeight: 'bold' }}>
{(selectedLinkResult.timeSeries?.quality?.[timeStepIndex] ?? selectedLinkResult.quality ?? 0).toFixed(3)} {wasmResult.qualityInfo.chemUnits}
</span>
</div>
)}
{selectedLinkResult.timeSeries && wasmResult?.timeSteps && (
<TimeSeriesChart
title={
displayMode === 'quality'
? `${selectedPipe.id} 수질 (${wasmResult.qualityInfo?.chemName || 'Quality'})`
: `${selectedPipe.id} 유량(Flow)`
}
unit={displayMode === 'quality' ? (wasmResult.qualityInfo?.chemUnits || 'mg/L') : displayUnits.flow}
values={
displayMode === 'quality' && selectedLinkResult.timeSeries.quality
? selectedLinkResult.timeSeries.quality
: selectedLinkResult.timeSeries.flow.map(displayFlowValue)
}
timeSteps={wasmResult.timeSteps}
currentIndex={timeStepIndex}
onSelectTimeIndex={(idx) => setTimeStepIndex(idx)}
color={displayMode === 'quality' ? '#7c3aed' : 'var(--good)'}
/>
)}
{/* Pump Energy Analysis Card */}
{selectedLinkResult.totalKwh !== undefined && (
<div
style={{
marginTop: '10px',
padding: '8px 10px',
background: 'var(--warn-wash)',
border: '1px solid #fde68a',
borderRadius: '6px',
fontSize: '11px',
lineHeight: 1.5,
}}
>
<div style={{ fontWeight: 'bold', color: 'var(--warn)', marginBottom: '4px', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Zap size={13} aria-hidden="true" />
펌프 에너지 분석 (Energy Usage)
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>현재 소비전력:</span>
<span style={{ fontFamily: 'monospace', fontWeight: 'bold' }}>
{(selectedLinkResult.timeSeries?.energy?.[timeStepIndex] ?? selectedLinkResult.energy ?? 0).toFixed(2)} kW
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>펌프 효율:</span>
<span style={{ fontFamily: 'monospace' }}>
{(selectedLinkResult.timeSeries?.efficiency?.[timeStepIndex] ?? selectedLinkResult.efficiency ?? 0).toFixed(1)} %
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>일일 총 전력소비:</span>
<span style={{ fontFamily: 'monospace', fontWeight: 'bold', color: 'var(--warn)' }}>
{selectedLinkResult.totalKwh?.toFixed(2)} kWh
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>최대 피크 전력:</span>
<span style={{ fontFamily: 'monospace' }}>
{selectedLinkResult.peakKw?.toFixed(2)} kW
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>펌프 가동률:</span>
<span style={{ fontFamily: 'monospace' }}>
{selectedLinkResult.utilization?.toFixed(1)} %
</span>
</div>
{selectedLinkResult.timeSeries?.energy && wasmResult?.timeSteps && (
<div style={{ marginTop: '8px' }}>
<TimeSeriesChart
title={`${selectedPipe.id} 소비전력 (Power)`}
unit="kW"
values={selectedLinkResult.timeSeries.energy}
timeSteps={wasmResult.timeSteps}
currentIndex={timeStepIndex}
onSelectTimeIndex={(idx) => setTimeStepIndex(idx)}
color="#d97706"
/>
</div>
)}
</div>
)}
</div>
)}
{networkModel && wasmResult && (
<HglProfileViewer
pipe={{ ...selectedPipe, length: displayLengthValue(selectedPipe.length) }}
node1={{ ...networkModel.nodes[selectedPipe.node1], elevation: displayHeadValue(networkModel.nodes[selectedPipe.node1]?.elevation ?? 0) }}
node2={{ ...networkModel.nodes[selectedPipe.node2], elevation: displayHeadValue(networkModel.nodes[selectedPipe.node2]?.elevation ?? 0) }}
node1Head={displayHeadValue(wasmResult.nodes[selectedPipe.node1]?.head ?? 0)}
node2Head={displayHeadValue(wasmResult.nodes[selectedPipe.node2]?.head ?? 0)}
node1Pressure={displayPressureValue(wasmResult.nodes[selectedPipe.node1]?.pressure ?? 0)}
node2Pressure={displayPressureValue(wasmResult.nodes[selectedPipe.node2]?.pressure ?? 0)}
unit={displayUnits.head}
pressureUnit={displayUnits.pressure}
/>
)}
</div>
);
+2629
View File
File diff suppressed because it is too large. Load diff
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
import { ContextMenu } from './ContextMenu';
import { type MapContextMenuEvent, type EditMode, type SimulationResult, type SelectionTarget } from './types';
import { type NetworkModel, type InpNode } from './inpModel';
import { type CRS } from './crs';
import { type MapFocusRequest } from './Map';
export interface MapContextMenuProps {
contextMenu: MapContextMenuEvent;
setContextMenu: React.Dispatch<React.SetStateAction<MapContextMenuEvent | null>>;
appMode: 'explore' | 'edit' | 'result';
setAppMode: React.Dispatch<React.SetStateAction<'explore' | 'edit' | 'result'>>;
setEditMode: React.Dispatch<React.SetStateAction<EditMode>>;
networkModel: NetworkModel | null;
wasmResult: SimulationResult | null;
crs: CRS;
setSelectedTarget: React.Dispatch<React.SetStateAction<SelectionTarget>>;
setActiveTab: React.Dispatch<React.SetStateAction<import('./types').RightTab | null>>;
requestMapFocus: (request: Omit<MapFocusRequest, 'id'>) => void;
showToast: (msg: string) => void;
handleAddNodeAtCoords: (x: number, y: number, nodeType?: InpNode['type']) => void;
handleCopyText: (text: string, label: string) => void;
handleZoomToLayer: (layerId: string) => void;
handleDeleteSelected: () => void;
handleRecalculateLength: (pipeId: string) => void;
handleReversePipe: (pipeId: string) => void;
handleSplitPipe: (pipeId: string) => void;
}
/** Right-click menu on the map (node/link/empty-space actions). */
export const MapContextMenu: React.FC<MapContextMenuProps> = ({
contextMenu,
setContextMenu,
appMode,
setAppMode,
setEditMode,
networkModel,
wasmResult,
crs,
setSelectedTarget,
setActiveTab,
requestMapFocus,
showToast,
handleAddNodeAtCoords,
handleCopyText,
handleZoomToLayer,
handleDeleteSelected,
handleRecalculateLength,
handleReversePipe,
handleSplitPipe,
}) => (
<ContextMenu
crs={crs}
event={contextMenu}
onClose={() => setContextMenu(null)}
onCopyText={handleCopyText}
onAddNodeAtCoords={(x, y) => {
handleAddNodeAtCoords(x, y, 'JUNCTION');
setContextMenu(null);
}}
onFitBounds={() => {
showToast('전체 화면을 표시합니다.');
}}
onPanTo={(lngLat) => {
requestMapFocus({ lngLat, zoom: 17 });
showToast(`좌표 (${lngLat[0].toFixed(4)}, ${lngLat[1].toFixed(4)})로 이동했습니다.`);
}}
onInspectTarget={(target) => {
if (target.type === 'node' || target.type === 'link') {
setSelectedTarget(target);
}
setActiveTab('inspector');
}}
onViewResults={(target) => {
setSelectedTarget(target);
setActiveTab('results');
}}
onStartPipeFromNode={(nodeId) => {
if (appMode !== 'edit') setAppMode('edit');
setEditMode('add-pipe');
setSelectedTarget({ type: 'node', id: nodeId });
}}
onSplitPipe={handleSplitPipe}
onSelectConnectedLinks={(nodeId) => {
if (!networkModel) return;
const connectedPipes = Object.values(networkModel.pipes).filter(
(p) => p.node1 === nodeId || p.node2 === nodeId
);
if (connectedPipes.length > 0) {
setSelectedTarget({ type: 'link', id: connectedPipes[0].id });
setActiveTab('inspector');
showToast(`연결 관로 [${connectedPipes[0].id}] 선택됨 (총 ${connectedPipes.length}개 관로)`);
}
}}
onSelectConnectedNodes={(pipeId) => {
if (!networkModel) return;
const pipe = networkModel.pipes[pipeId];
if (pipe) {
setSelectedTarget({ type: 'node', id: pipe.node1 });
setActiveTab('inspector');
showToast(`시작 절점 [${pipe.node1}] 선택됨 (끝 절점: ${pipe.node2})`);
}
}}
onCalculatePipeLength={handleRecalculateLength}
onReversePipe={handleReversePipe}
onDeleteNode={handleDeleteSelected}
onDeletePipe={handleDeleteSelected}
onZoomToLayer={handleZoomToLayer}
canRunResults={!!wasmResult}
/>
);
+132
View File
@@ -0,0 +1,132 @@
import React from 'react';
import { BasemapType, DisplayMode, type SimulationResult } from './types';
import { AddressSearch } from './AddressSearch';
import { CRS } from './crs';
import { type MapFocusRequest } from './Map';
export interface MapHeaderProps {
crs: CRS;
basemap: BasemapType;
displayMode: DisplayMode;
setCrs: React.Dispatch<React.SetStateAction<CRS>>;
crsSelectRef: React.RefObject<HTMLSelectElement>;
setBasemap: React.Dispatch<React.SetStateAction<BasemapType>>;
setDisplayMode: React.Dispatch<React.SetStateAction<DisplayMode>>;
requestMapFocus: (request: Omit<MapFocusRequest, 'id'>) => void;
showToast: (msg: string) => void;
wasmResult: SimulationResult | null;
}
/** Map pane toolbar: CRS, basemap and result display selectors. */
export const MapHeader: React.FC<MapHeaderProps> = ({
crs,
basemap,
displayMode,
setCrs,
crsSelectRef,
setBasemap,
setDisplayMode,
requestMapFocus,
showToast,
wasmResult,
}) => (
<div className="map-header">
{/* Right toolbar: Basemap, CRS, Data */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* CRS Selector */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<select
ref={crsSelectRef}
value={crs}
onChange={(e) => setCrs(e.target.value as CRS)}
style={{ fontSize: '12px', padding: '3px 6px', border: '1px solid #cbd5e1', borderRadius: '4px', maxWidth: '175px' }}
aria-label="좌표계 선택"
>
<optgroup label="기본 및 글로벌">
<option value="LOCAL">로컬 평면 (Local)</option>
<option value="EPSG:4326">WGS84 경위도 (4326)</option>
<option value="EPSG:3857">Web Mercator (3857)</option>
</optgroup>
<optgroup label="국가표준 GRS80 (현행 법정)">
<option value="EPSG:5179">한국 UTM-K (5179)</option>
<option value="EPSG:5186">중부원점 GRS80 (5186)</option>
<option value="EPSG:5185">서부원점 GRS80 (5185)</option>
<option value="EPSG:5187">동부원점 GRS80 (5187)</option>
<option value="EPSG:5188">동해원점 GRS80 (5188)</option>
<option value="EPSG:5180">제주원점 GRS80 (5180)</option>
</optgroup>
<optgroup label="포털 지도">
<option value="EPSG:5181">카카오맵 중부 (5181)</option>
</optgroup>
<optgroup label="구 지적 / Bessel (상수도 구 GIS)">
<option value="EPSG:5174">구 지적 중부 (5174)</option>
<option value="EPSG:5176">구 기준점 중부 (5176)</option>
<option value="EPSG:5173">구 지적 서부 (5173)</option>
<option value="EPSG:5177">구 지적 동부 (5177)</option>
<option value="EPSG:5175">구 지적 제주 (5175)</option>
</optgroup>
<optgroup label="KGD2002 과도기 (Y+500,000)">
<option value="EPSG:102080">KGD2002 중부 (102080)</option>
<option value="EPSG:102081">KGD2002 서부 (102081)</option>
<option value="EPSG:102082">KGD2002 동부 (102082)</option>
<option value="EPSG:102083">KGD2002 동해 (102083)</option>
</optgroup>
</select>
</div>
{/* Basemap Selector */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>지도:</span>
<select
value={basemap}
onChange={(e) => setBasemap(e.target.value as BasemapType)}
style={{ fontSize: '12px', padding: '3px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="blank">단색 배경</option>
<option value="osm">OpenStreetMap</option>
<optgroup label="브이월드 (VWorld)">
<option value="vworld">일반 지도</option>
<option value="vworld-white">백지도</option>
<option value="vworld-midnight">야간 지도</option>
<option value="vworld-satellite">위성 영상</option>
<option value="vworld-satellite-hybrid">위성 + 도로·지명</option>
</optgroup>
</select>
</div>
{/* Data display mode */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>표시:</span>
<select
value={displayMode}
onChange={(e) =>
setDisplayMode(e.target.value as DisplayMode)
}
style={{ fontSize: '12px', padding: '3px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="pressure">압력 (Pressure)</option>
<option value="head">수두 (Head)</option>
<option value="flow">유량 (Flow)</option>
<option value="velocity">유속 (Velocity)</option>
<option value="headloss">수두손실 (Headloss)</option>
<option value="elevation">지형 표고 (Elevation)</option>
<option value="diff-pressure">수압 변화량 (Diff ΔP)</option>
<option value="diff-flow">유량 변화량 (Diff ΔQ)</option>
<option value="quality">
수질 (Quality - {wasmResult?.qualityInfo?.chemName || '농도/수령'})
</option>
</select>
</div>
</div>
<AddressSearch
onSelect={(result) => {
requestMapFocus({
lngLat: [result.longitude, result.latitude],
zoom: 17,
});
setBasemap('vworld');
showToast(`주소로 이동: ${result.roadAddressName || result.parcelAddressName || result.addressName}`);
}}
/>
</div>
);
+219
View File
@@ -0,0 +1,219 @@
import React from 'react';
import { MultiSelectionState, type SimulationResult, type SelectionTarget } from './types';
import { type NetworkModel, type InpNode, type InpPipe } from './inpModel';
import { type DisplayUnitMode, type DisplayUnitLabels } from './unitSystem';
export interface NetworkSummaryProps {
networkModel: NetworkModel;
wasmResult: SimulationResult | null;
selectedNode: InpNode | null;
selectedPipe: InpPipe | null;
selectedTarget: SelectionTarget;
multiSelection: MultiSelectionState;
setMultiSelection: React.Dispatch<React.SetStateAction<MultiSelectionState>>;
setSelectedTarget: React.Dispatch<React.SetStateAction<SelectionTarget>>;
displayUnitMode: DisplayUnitMode;
displayUnits: DisplayUnitLabels;
displayFlowValue: (value: number) => number;
displayLengthValue: (value: number) => number;
displayPressureValue: (value: number) => number;
displayVelocityValue: (value: number) => number;
}
/** Network/hydraulic summary dashboard shown when nothing is selected (inspector tab). */
export const NetworkSummary: React.FC<NetworkSummaryProps> = ({
networkModel,
wasmResult,
selectedNode,
selectedPipe,
selectedTarget,
multiSelection,
setMultiSelection,
setSelectedTarget,
displayUnitMode,
displayUnits,
displayFlowValue,
displayLengthValue,
displayPressureValue,
displayVelocityValue,
}) => (
<div style={{ color: 'var(--text-secondary)', fontSize: 'var(--fs-sm)', lineHeight: 'var(--lh-prose)' }}>
{/* Executive Hydraulic Summary Dashboard */}
{wasmResult && (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-strong)', borderRadius: 'var(--r-md)', padding: 'var(--s-5)', marginBottom: 'var(--s-5)' }}>
<div style={{ fontWeight: 'var(--fw-semi)', fontSize: 'var(--fs-sm)', color: 'var(--text)', marginBottom: 'var(--s-4)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>관망 종합 수리 통계 요약</span>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--accent)', fontWeight: 'var(--fw-semi)' }}>단위: {displayUnits.flow} / {displayUnits.pressure} / {displayUnits.velocity}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--s-4)', fontSize: 'var(--fs-xs)' }}>
<div style={{ background: 'var(--bg-subtle)', padding: 'var(--s-3) var(--s-4)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>총 관로 연장:</span>
<div style={{ fontWeight: 'var(--fw-semi)', fontSize: 'var(--fs-sm)', color: 'var(--text)' }}>
{displayUnitMode === 'korean'
? `${(Object.values(networkModel.pipes).reduce((sum, pipe) => sum + displayLengthValue(pipe.length), 0) / 1000).toFixed(2)} km`
: `${Object.values(networkModel.pipes).reduce((sum, pipe) => sum + pipe.length, 0).toFixed(2)} ${displayUnits.length}`}
</div>
</div>
<div style={{ background: 'var(--bg-subtle)', padding: 'var(--s-3) var(--s-4)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>총 기본수요량:</span>
<div style={{ fontWeight: 'var(--fw-semi)', fontSize: 'var(--fs-sm)', color: 'var(--text)' }}>
{displayFlowValue(Object.values(networkModel.nodes).reduce((sum, node) => sum + (node.demand ?? 0), 0)).toFixed(1)} {displayUnits.demand}
</div>
</div>
<div style={{ background: 'var(--bg-subtle)', padding: 'var(--s-3) var(--s-4)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>관망 최저 / 최고 수압:</span>
<div style={{ fontWeight: 'var(--fw-semi)', fontSize: 'var(--fs-sm)', color: 'var(--text)', fontFamily: 'var(--font-mono)' }}>
{Math.min(...Object.values(wasmResult.nodes).map((node) => displayPressureValue(node.pressure))).toFixed(1)} ~ {Math.max(...Object.values(wasmResult.nodes).map((node) => displayPressureValue(node.pressure))).toFixed(1)} {displayUnits.pressure}
</div>
</div>
<div style={{ background: 'var(--bg-subtle)', padding: 'var(--s-3) var(--s-4)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>관로 평균 / 최대 유속:</span>
<div style={{ fontWeight: 'var(--fw-semi)', fontSize: 'var(--fs-sm)', color: 'var(--text)', fontFamily: 'var(--font-mono)' }}>
{(Object.values(wasmResult.links).reduce((sum, link) => sum + displayVelocityValue(link.velocity), 0) / (Object.keys(wasmResult.links).length || 1)).toFixed(2)} / {Math.max(...Object.values(wasmResult.links).map((link) => displayVelocityValue(link.velocity))).toFixed(2)} {displayUnits.velocity}
</div>
</div>
</div>
</div>
)}
<div
style={{
padding: 'var(--s-4)',
background: 'var(--bg-subtle)',
borderRadius: 'var(--r-sm)',
marginBottom: 'var(--s-5)',
}}
>
<strong>작도 및 편집 안내:</strong>
<ul style={{ margin: 'var(--s-3) 0 0 0', paddingLeft: 'var(--s-7)' }}>
<li><strong>절점 작도</strong>: 지도를 클릭하여 새 절점을 생성합니다.</li>
<li><strong>관로 연결</strong>: 시작 절점 클릭 후 끝 절점을 클릭하여 관로를 작도합니다.</li>
<li><strong>스냅(Snap)</strong>: 절점 근처에 가면 마우스가 자동 스냅되어 정밀 작도를 보조합니다.</li>
<li><strong>요소 삭제</strong>: 절점이나 관로를 선택한 후 상단 삭제 버튼 또는 <code>Delete</code> 키를 누릅니다.</li>
</ul>
</div>
<h4 style={{ margin: 'var(--s-4) 0 var(--s-2) 0', fontSize: 'var(--fs-md)', color: 'var(--text)' }}>관망 요소 목록:</h4>
<div
style={{
maxHeight: '180px',
overflowY: 'auto',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--r-sm)',
}}
>
<table style={{ width: '100%', fontSize: 'var(--fs-sm)', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left' }}>유형</th>
<th style={{ textAlign: 'left' }}>ID</th>
<th style={{ textAlign: 'right' }}>좌표/연결</th>
</tr>
</thead>
<tbody>
{Object.values(networkModel.nodes).map((n) => {
const isSelected =
(selectedTarget?.type === 'node' && selectedTarget.id === n.id) ||
multiSelection.nodes.has(n.id);
return (
<tr
key={`n-${n.id}`}
onClick={(e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
const next: MultiSelectionState = {
nodes: new Set(multiSelection.nodes),
links: new Set(multiSelection.links),
gisFeatures: [...multiSelection.gisFeatures],
};
if (selectedTarget?.type === 'node') next.nodes.add(selectedTarget.id);
if (next.nodes.has(n.id)) next.nodes.delete(n.id);
else next.nodes.add(n.id);
setMultiSelection(next);
} else {
setSelectedTarget({ type: 'node', id: n.id });
}
}}
style={{
cursor: 'pointer',
background: isSelected ? 'var(--accent-wash)' : undefined,
}}
>
<td style={{ color: 'var(--accent)' }}>{n.type}</td>
<td style={{ fontWeight: 'var(--fw-semi)' }}>{n.id}</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
({n.x.toFixed(1)}, {n.y.toFixed(1)})
</td>
</tr>
);
})}
{Object.values(networkModel.pipes).map((p) => {
const isSelected =
(selectedTarget?.type === 'link' && selectedTarget.id === p.id) ||
multiSelection.links.has(p.id);
return (
<tr
key={`p-${p.id}`}
onClick={(e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
const next: MultiSelectionState = {
nodes: new Set(multiSelection.nodes),
links: new Set(multiSelection.links),
gisFeatures: [...multiSelection.gisFeatures],
};
if (selectedTarget?.type === 'link') next.links.add(selectedTarget.id);
if (next.links.has(p.id)) next.links.delete(p.id);
else next.links.add(p.id);
setMultiSelection(next);
} else {
setSelectedTarget({ type: 'link', id: p.id });
}
}}
style={{
cursor: 'pointer',
background: isSelected ? 'var(--accent-wash)' : undefined,
}}
>
<td style={{ color: 'var(--good)' }}>PIPE</td>
<td style={{ fontWeight: 'var(--fw-semi)' }}>{p.id}</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{p.node1} → {p.node2}
</td>
</tr>
);
})}
{Object.values(networkModel.pumps || {}).map((pump) => (
<tr
key={`pump-${pump.id}`}
onClick={() => setSelectedTarget({ type: 'link', id: pump.id })}
style={{ cursor: 'pointer', background: 'var(--warn-wash)' }}
>
<td style={{ color: 'var(--warn)', fontWeight: 'var(--fw-semi)' }}>PUMP</td>
<td style={{ fontWeight: 'var(--fw-semi)' }}>{pump.id}</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{pump.node1} → {pump.node2}
</td>
</tr>
))}
{Object.values(networkModel.valves || {}).map((v) => (
<tr
key={`valve-${v.id}`}
onClick={() => setSelectedTarget({ type: 'link', id: v.id })}
style={{ cursor: 'pointer' }}
>
<td style={{ color: 'var(--accent-hover)' }}>VALVE ({v.valveType})</td>
<td style={{ fontWeight: 'var(--fw-semi)' }}>{v.id}</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{v.node1} → {v.node2}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
+350
View File
@@ -0,0 +1,350 @@
import React from 'react';
import { TimeSeriesChart } from './TimeSeriesChart';
import { NumberField } from './NumberField';
import { GitCommit, Trash2, AlertTriangle, X } from 'lucide-react';
import { type InpNode, type NetworkModel } from './inpModel';
import { type NodeResult, type SimulationResult, type DisplayMode } from './types';
import { type DisplayUnitLabels } from './unitSystem';
export interface NodeInspectorProps {
selectedNode: InpNode;
selectedNodeResult: NodeResult | null | undefined;
networkModel: NetworkModel | null;
wasmResult: SimulationResult | null;
allNodeIds: string[];
displayMode: DisplayMode;
displayUnits: DisplayUnitLabels;
displayFlowValue: (value: number) => number;
displayHeadValue: (value: number) => number;
displayPressureValue: (value: number) => number;
inputFlowValue: (value: number) => number;
inputLengthValue: (value: number) => number;
timeStepIndex: number;
setTimeStepIndex: React.Dispatch<React.SetStateAction<number>>;
handleUpdateNode: (id: string, updates: Partial<InpNode>) => void;
handleAddPipeBetweenNodes: (node1Id: string, node2Id: string) => void;
handleDeleteSelected: () => void;
}
/** Properties, connections and results of the selected node (inspector tab). */
export const NodeInspector: React.FC<NodeInspectorProps> = ({
selectedNode,
selectedNodeResult,
networkModel,
wasmResult,
allNodeIds,
displayMode,
displayUnits,
displayFlowValue,
displayHeadValue,
displayPressureValue,
inputFlowValue,
inputLengthValue,
timeStepIndex,
setTimeStepIndex,
handleUpdateNode,
handleAddPipeBetweenNodes,
handleDeleteSelected,
}) => (
<div
style={{
background: 'var(--bg-subtle)',
padding: '12px',
borderRadius: '6px',
border: '1px solid #e2e8f0',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h3 style={{ margin: 0, color: 'var(--text)', fontSize: '15px' }}>
절점: <span style={{ color: 'var(--accent)' }}>{selectedNode.id}</span>
</h3>
<button
onClick={handleDeleteSelected}
style={{
padding: '2px 8px',
background: 'var(--crit-wash)',
color: 'var(--crit)',
border: '1px solid var(--crit)',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--s-1)',
}}
>
<Trash2 size={12} aria-hidden="true" />
삭제
</button>
</div>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '12px' }}>
유형: <strong>{selectedNode.type}</strong>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginBottom: '12px',
}}
>
<div>
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
X 좌표
</label>
<input
type="number"
step="0.1"
value={selectedNode.x}
onChange={(e) =>
handleUpdateNode(selectedNode.id, { x: parseFloat(e.target.value) || 0 })
}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
Y 좌표
</label>
<input
type="number"
step="0.1"
value={selectedNode.y}
onChange={(e) =>
handleUpdateNode(selectedNode.id, { y: parseFloat(e.target.value) || 0 })
}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
/>
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginBottom: '12px',
}}
>
<div>
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
{selectedNode.type === 'RESERVOIR' ? `총수두 (${displayUnits.head})` : `지반고 (${displayUnits.head})`}
</label>
<NumberField
key={`elev-${selectedNode.id}`}
value={displayHeadValue(selectedNode.elevation)}
onCommit={(v) =>
handleUpdateNode(selectedNode.id, {
elevation: inputLengthValue(v),
})
}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
/>
</div>
{selectedNode.type === 'JUNCTION' && (
<div>
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
기본수요량 ({displayUnits.demand})
</label>
<NumberField
key={`demand-${selectedNode.id}`}
value={displayFlowValue(selectedNode.demand ?? 0)}
onCommit={(v) =>
handleUpdateNode(selectedNode.id, {
demand: inputFlowValue(v),
})
}
style={{
width: '100%',
padding: '4px 6px',
border: '1px solid #cbd5e1',
borderRadius: '4px',
}}
/>
</div>
)}
{selectedNode.type === 'JUNCTION' && networkModel?.patterns && Object.keys(networkModel.patterns).length > 0 && (
<div style={{ marginTop: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
수요 패턴 (Pattern ID)
</label>
<select
value={selectedNode.pattern || ''}
onChange={(e) => handleUpdateNode(selectedNode.id, { pattern: e.target.value })}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px', fontSize: '12px' }}
>
<option value="">기본 패턴 (1)</option>
{Object.keys(networkModel.patterns).map((pid) => (
<option key={pid} value={pid}>
패턴 {pid} ({networkModel.patterns[pid].description || 'Pattern'})
</option>
))}
</select>
</div>
)}
</div>
{/* Connect Pipe Shortcut */}
<div
style={{
marginTop: '12px',
padding: '8px',
background: 'var(--accent-wash)',
borderRadius: '4px',
border: '1px solid #bfdbfe',
fontSize: '12px',
}}
>
<div style={{ fontWeight: 600, color: 'var(--accent-hover)', marginBottom: '4px', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<GitCommit size={14} aria-hidden="true" />
다른 절점과 관로 연결
</div>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<select
id="target-connect-node"
defaultValue=""
style={{
flex: 1,
padding: '4px',
border: '1px solid #93c5fd',
borderRadius: '4px',
fontSize: '12px',
}}
>
<option value="" disabled>
연결할 대상 절점 선택
</option>
{allNodeIds
.filter((id) => id !== selectedNode.id)
.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
<button
onClick={() => {
const sel = document.getElementById(
'target-connect-node'
) as HTMLSelectElement | null;
if (sel && sel.value) {
handleAddPipeBetweenNodes(selectedNode.id, sel.value);
}
}}
style={{
padding: '4px 8px',
background: 'var(--accent)',
color: 'var(--bg-panel)',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600,
fontSize: '12px',
}}
>
연결 추가
</button>
</div>
</div>
{selectedNodeResult && (
<div
style={{
marginTop: '12px',
padding: '8px',
background: 'var(--bg-panel)',
borderRadius: '4px',
border: '1px solid #e2e8f0',
fontSize: '12px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>해석 결과</span>
{wasmResult?.timeSteps && (
<span style={{ fontSize: '11px', color: 'var(--accent)', fontWeight: 600 }}>
{String(Math.floor((wasmResult.timeSteps[timeStepIndex] ?? 0) / 3600)).padStart(2, '0')}:00 시점
</span>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>수두:</span>
<span style={{ fontFamily: 'monospace' }}>
{displayHeadValue(selectedNodeResult.timeSeries?.head[timeStepIndex] ?? selectedNodeResult.head).toFixed(2)} {displayUnits.head}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>수압:</span>
<span style={{ fontFamily: 'monospace', fontWeight: 'bold' }}>
{displayPressureValue(selectedNodeResult.timeSeries?.pressure[timeStepIndex] ?? selectedNodeResult.pressure).toFixed(2)} {displayUnits.pressure}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>실제 수요:</span>
<span style={{ fontFamily: 'monospace' }}>
{displayFlowValue(selectedNodeResult.timeSeries?.demand[timeStepIndex] ?? selectedNodeResult.demand).toFixed(2)} {displayUnits.demand}
</span>
</div>
{wasmResult?.qualityInfo?.status === 'failed' && (
<div style={{ display: 'flex', justifyContent: 'space-between', color: 'var(--warn)', fontSize: '11px', background: 'var(--warn-wash)', padding: '3px 6px', borderRadius: '4px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--s-1)' }}>
<AlertTriangle size={12} aria-hidden="true" />
수질해석 실패:
</span>
<span>수질 데이터 없음</span>
</div>
)}
{wasmResult?.qualityInfo?.status === 'success' && wasmResult.qualityInfo.qualType > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#7c3aed' }}>
<span>수질 ({wasmResult.qualityInfo.chemName}):</span>
<span style={{ fontFamily: 'monospace', fontWeight: 'bold' }}>
{(selectedNodeResult.timeSeries?.quality?.[timeStepIndex] ?? selectedNodeResult.quality ?? 0).toFixed(3)} {wasmResult.qualityInfo.chemUnits}
</span>
</div>
)}
{selectedNodeResult.timeSeries && wasmResult?.timeSteps && (
<TimeSeriesChart
title={
displayMode === 'quality'
? `${selectedNode.id} 수질 (${wasmResult.qualityInfo?.chemName || 'Quality'})`
: selectedNode.type === 'TANK'
? `${selectedNode.id} 배수지/탱크 수위 (Water Level)`
: `${selectedNode.id} 수압 (Pressure)`
}
unit={
displayMode === 'quality'
? (wasmResult.qualityInfo?.chemUnits || 'mg/L')
: displayUnits.pressure
}
values={
displayMode === 'quality' && selectedNodeResult.timeSeries.quality
? selectedNodeResult.timeSeries.quality
: selectedNodeResult.timeSeries.pressure.map(displayPressureValue)
}
timeSteps={wasmResult.timeSteps}
currentIndex={timeStepIndex}
onSelectTimeIndex={(idx) => setTimeStepIndex(idx)}
color={displayMode === 'quality' ? '#7c3aed' : selectedNode.type === 'TANK' ? 'var(--accent)' : 'var(--accent)'}
/>
)}
</div>
)}
</div>
);
+46
View File
@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { formatEditableNumber } from './unitSystem';
interface NumberFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
/** Value in display units. */
value: number;
/** Called with the parsed display-unit number on blur / Enter only. */
onCommit: (value: number) => void;
}
/**
* Numeric input for unit-converted values. A controlled <input type=number>
* whose value is display(model(typed)) gets rewritten mid-typing whenever the
* ft/m or flow-unit round trip isn't exact (typing "216.4" in a US model ended
* up as 3.9999999999999996). Keeping the typed text as a local draft and
* committing only on blur/Enter avoids that round trip while editing.
*/
export const NumberField: React.FC<NumberFieldProps> = ({ value, onCommit, onBlur, onKeyDown, ...rest }) => {
const [draft, setDraft] = useState<string | null>(null);
const commit = () => {
if (draft === null) return;
const parsed = Number(draft);
setDraft(null);
if (draft.trim() !== '' && Number.isFinite(parsed)) onCommit(parsed);
};
return (
<input
{...rest}
type="text"
inputMode="decimal"
value={draft ?? formatEditableNumber(value)}
onChange={(e) => setDraft(e.target.value)}
onBlur={(e) => {
commit();
onBlur?.(e);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setDraft(null);
onKeyDown?.(e);
}}
/>
);
};
+236
View File
@@ -0,0 +1,236 @@
// 분석 tab: 자동 보정 (pipe roughness / wall coefficient fitted to field data)
// and 펌프 운전 최적화 (cheaper on/off schedule under pressure / tank limits).
import React, { useMemo, useState } from 'react';
import { Play } from 'lucide-react';
import type { AnalysisPanelProps } from './AnalysisPanel';
import type { CalibrationData } from './CalibrationPanel';
import { CALIBRATION_PARAMS } from './calibration';
import { generateInp, type NetworkModel } from './inpModel';
import { runJob } from './api';
import { pipeGroups, type PipeGroupKey } from './analysisTools';
export interface OptimizeProps {
calibrationData?: CalibrationData | null;
}
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
const note: React.CSSProperties = { color: 'var(--text-secondary)', lineHeight: 1.5 };
const th: React.CSSProperties = { padding: '3px 5px', textAlign: 'right', background: 'var(--bg-subtle)', position: 'sticky', top: 0 };
const td: React.CSSProperties = { padding: '2px 5px', textAlign: 'right', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-mono)' };
const fmt = (v: number | null | undefined, d = 2) => (v === null || v === undefined || !Number.isFinite(v) ? '-' : v.toFixed(d));
const GROUP_BY: Array<[PipeGroupKey | 'all', string]> = [['all', '전체 한 그룹'], ['material', '관재질별'], ['installDecade', '설치 연대별'], ['diameter', '관경별'], ['block', '소블록별'], ['pipeKind', '관로 구분별']];
interface CalibResult {
mode: 'roughness' | 'wall';
param: string;
groups: Array<{ name: string; pipes: number; value: number; meanBefore: number | null; meanAfter: number | null; atBound: boolean }>;
locations: Array<{ location: string; n: number; before: number; after: number }>;
rmseBefore: number;
rmseAfter: number;
evaluations: number;
converged: boolean;
observations: number;
groupMap: Record<string, string[]>;
}
export const AutoCalibrationSection: React.FC<AnalysisPanelProps & OptimizeProps> = ({ model, units, calibrationData, cache, setCache, onApplyModel }) => {
const [mode, setMode] = useState<'roughness' | 'wall'>(calibrationData?.param === 'quality' ? 'wall' : 'roughness');
const [by, setBy] = useState<PipeGroupKey | 'all'>('all');
const [lo, setLo] = useState(mode === 'wall' ? '-5' : '0.5');
const [hi, setHi] = useState(mode === 'wall' ? '0' : '1.5');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const r = cache.calibResult as CalibResult | undefined;
const groups = useMemo(() => pipeGroups(model, by), [model, by]);
const param = calibrationData?.param;
const paramLabel = CALIBRATION_PARAMS.find((p) => p.id === param)?.label ?? param;
const toModel = (v: number): number => {
switch (param) {
case 'pressure': return units.toPressure(v);
case 'head': return units.toLength(v);
case 'flow': case 'demand': return units.toFlow(v);
case 'velocity': return units.toVelocity(v);
default: return v;
}
};
const err0 = (m: string) => { setErr(m); return null; };
const run = async () => {
setErr(null);
if (!calibrationData) return err0('결과 > 실측 보정에서 실측 파일을 먼저 불러오세요.');
if (Object.keys(groups).length > 30) return err0('그룹이 30개를 넘습니다. 다른 묶음을 고르세요.');
setBusy(true);
try {
const res = await runJob<CalibResult>('calibrate', generateInp(model), {
mode, param,
observations: calibrationData.observations.map((o) => ({ location: o.location, time: o.timeSec, value: toModel(o.value) })),
groups, bounds: [Number(lo), Number(hi)], maxEvaluations: 300,
});
setCache('calibResult', { ...res, groupMap: groups });
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const apply = () => {
if (!r) return;
const pipes = { ...model.pipes };
let changed = 0;
for (const g of r.groups) {
for (const id of r.groupMap[g.name] ?? []) {
const p = pipes[id];
if (!p) continue;
if (r.mode === 'roughness') pipes[id] = { ...p, roughness: Math.round(p.roughness * g.value * 1e4) / 1e4 };
else pipes[id] = { ...p, wallCoeff: g.value };
changed++;
}
}
onApplyModel({ ...model, pipes }, `자동 보정 결과를 관로 ${changed}개에 적용했습니다 (${r.mode === 'roughness' ? '조도' : '관벽 반응계수'}, RMSE ${fmt(r.rmseBefore, 3)} → ${fmt(r.rmseAfter, 3)}).`);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={note}>
실측값(결과 &gt; 실측 보정에서 불러온 파일)에 가장 잘 맞도록 관로 그룹별 조도 배율 또는 관벽 반응계수를 최소제곱법으로 찾습니다.
실측 지점이 그룹 수보다 충분히 많아야 결과를 믿을 수 있습니다.
</div>
<div style={box}>
<span>실측 자료: {calibrationData ? `${calibrationData.fileName} · ${paramLabel} · ${calibrationData.observations.length}개 · 지점 ${new Set(calibrationData.observations.map((o) => o.location)).size}곳` : '없음'}</span>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label>보정 대상
<select value={mode} onChange={(e) => { const m = e.target.value as 'roughness' | 'wall'; setMode(m); setLo(m === 'wall' ? '-5' : '0.5'); setHi(m === 'wall' ? '0' : '1.5'); }} style={inputStyle}>
<option value="roughness">조도계수 (C값 등) 배율</option>
<option value="wall">관벽 반응계수 (잔류염소)</option>
</select>
</label>
<label>관로 묶음
<select value={by} onChange={(e) => setBy(e.target.value as PipeGroupKey | 'all')} style={inputStyle}>
{GROUP_BY.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</label>
<label>{mode === 'roughness' ? '배율 하한' : '계수 하한'}<input type="number" step="0.1" value={lo} onChange={(e) => setLo(e.target.value)} style={inputStyle} /></label>
<label>{mode === 'roughness' ? '배율 상한' : '계수 상한'}<input type="number" step="0.1" value={hi} onChange={(e) => setHi(e.target.value)} style={inputStyle} /></label>
</div>
<span style={{ color: 'var(--text-muted)' }}>그룹 {Object.keys(groups).length}개: {Object.entries(groups).slice(0, 6).map(([k, v]) => `${k}(${v.length})`).join(', ')}{Object.keys(groups).length > 6 ? ' …' : ''}</span>
<button className="btn btn-primary" style={{ height: 28 }} disabled={busy || !calibrationData} onClick={() => void run()}><Play size={12} />{busy ? '보정 계산 중...' : '자동 보정 실행'}</button>
</div>
{err && <div style={{ color: 'var(--crit)' }}>{err}</div>}
{r && (
<div style={box}>
<b>RMSE {fmt(r.rmseBefore, 3)} → {fmt(r.rmseAfter, 3)} (모델 단위) · 해석 {r.evaluations}회{r.converged ? '' : ' · 수렴 미확인'}</b>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>그룹</th><th style={th}>관로</th><th style={th}>{r.mode === 'roughness' ? '배율' : '계수'}</th><th style={th}>평균 전</th><th style={th}>평균 후</th></tr></thead>
<tbody>
{r.groups.map((g) => (
<tr key={g.name}>
<td style={{ ...td, textAlign: 'left' }}>{g.name}</td><td style={td}>{g.pipes}</td>
<td style={{ ...td, color: g.atBound ? 'var(--warn, #b45309)' : undefined }} title={g.atBound ? '범위 끝에 걸림: 범위를 넓히거나 자료를 확인하세요' : undefined}>{fmt(g.value, 3)}{g.atBound ? ' *' : ''}</td>
<td style={td}>{fmt(g.meanBefore, 2)}</td><td style={td}>{fmt(g.meanAfter, 2)}</td>
</tr>
))}
</tbody>
</table>
<details>
<summary style={{ cursor: 'pointer' }}>지점별 오차 (RMSE 전 → 후)</summary>
<div style={{ maxHeight: 200, overflow: 'auto' }}>
{r.locations.map((l) => <div key={l.location} style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{l.location} ({l.n}개): {fmt(l.before, 3)} → {fmt(l.after, 3)}</div>)}
</div>
</details>
{r.groups.some((g) => g.atBound) && <span style={{ color: 'var(--warn, #b45309)' }}>* 범위 끝에 걸린 그룹은 실측값 이상이나 다른 원인(밸브 상태, 수요)을 먼저 의심하세요.</span>}
<button className="btn btn-secondary" style={{ height: 26, alignSelf: 'flex-start' }} onClick={apply}>모델에 적용 (실행 취소 가능)</button>
</div>
)}
</div>
);
};
interface ScheduleResult {
pumps: string[];
slotSeconds: number;
schedules: Record<string, number[]>;
before: { cost: number; kwh: number; minPressure: number; violation: number; tankEnd: number[] };
after: { cost: number; kwh: number; minPressure: number; violation: number; tankEnd: number[] };
priced: boolean;
evaluations: number;
removedControls: number;
rules: number;
feasible: boolean;
}
export const PumpScheduleSection: React.FC<AnalysisPanelProps> = ({ model, units, displayUnits, minPressureThreshold, cache, setCache, onApplyModel }) => {
const pumps = Object.keys(model.pumps);
const [sel, setSel] = useState<string[]>(pumps);
const [minP, setMinP] = useState(String(minPressureThreshold));
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const r = cache.schedResult as ScheduleResult | undefined;
if (!pumps.length) return <div style={note}>모델에 펌프가 없습니다.</div>;
const run = async () => {
setErr(null);
setBusy(true);
try {
setCache('schedResult', await runJob<ScheduleResult>('pumpschedule', generateInp(model), { pumps: sel, minPressure: units.toPressure(Number(minP)), maxEvaluations: 500 }));
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const apply = () => {
if (!r) return;
let m: NetworkModel = model;
for (const pid of r.pumps) {
const pat = `SCH_${pid}`.slice(0, 31);
const pump = m.pumps[pid];
if (!pump) continue;
const params = pump.parameters.replace(/\s+PATTERN\s+\S+/i, '').trim() + ` PATTERN ${pat}`;
m = {
...m,
patterns: { ...m.patterns, [pat]: { id: pat, description: '펌프 운전 최적화 (1=가동, 0=정지)', multipliers: r.schedules[pid] } },
pumps: { ...m.pumps, [pid]: { ...pump, parameters: params } },
controls: m.controls.filter((c) => c.linkId !== pid),
};
}
onApplyModel(m, `펌프 ${r.pumps.length}대에 최적 운전 패턴을 적용하고 해당 펌프의 단순 제어 ${r.removedControls}개를 지웠습니다${r.rules ? ` (규칙 ${r.rules}개는 그대로이니 확인하세요)` : ''}.`);
};
const hours = (k: number) => `${((k * (r?.slotSeconds ?? 3600)) / 3600).toFixed(0)}`;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={note}>
패턴 간격마다 펌프를 켜고 끄는 일정을 바꿔 가며, 최저압력과 "하루 뒤 탱크 수위가 처음 이상" 조건을 지키면서 전력비가 가장 낮은 일정을 찾습니다.
단가·단가 패턴은 모델 설정의 에너지 옵션을 씁니다 (없으면 전력량 최소화). 해당 펌프의 기존 단순 제어는 무시합니다.
</div>
<div style={box}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{pumps.map((p) => <label key={p}><input type="checkbox" checked={sel.includes(p)} onChange={(e) => setSel(e.target.checked ? [...sel, p] : sel.filter((x) => x !== p))} /> {p}</label>)}
</div>
<label style={{ width: 160 }}>최저압력 ({displayUnits.pressure})<input type="number" value={minP} onChange={(e) => setMinP(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-primary" style={{ height: 28 }} disabled={busy || !sel.length} onClick={() => void run()}><Play size={12} />{busy ? '최적화 중...' : '운전 일정 최적화'}</button>
</div>
{err && <div style={{ color: 'var(--crit)' }}>{err}</div>}
{r && (
<div style={box}>
<b>{r.priced ? '전력비' : '전력량(kWh)'} {fmt(r.priced ? r.before.cost : r.before.kwh, 1)} → {fmt(r.priced ? r.after.cost : r.after.kwh, 1)} · 해석 {r.evaluations}회</b>
{r.before.violation > 0 && <span style={{ color: 'var(--warn, #b45309)' }}>현재 운전은 조건을 만족하지 않습니다 (최저압력 {fmt(units.pressure(r.before.minPressure), 1)} {displayUnits.pressure}, 탱크 마지막 수위 {r.before.tankEnd.map((v) => fmt(v, 2)).join(', ')}). 최적 일정이 비용이 더 들 수 있는 이유입니다.</span>}
{!r.feasible && <span style={{ color: 'var(--crit)' }}>조건을 만족하는 일정을 찾지 못했습니다. 최저압력을 낮추거나 펌프를 더 고르세요.</span>}
<span>최적 후 최저압력 {fmt(units.pressure(r.after.minPressure), 1)} {displayUnits.pressure} · 탱크 마지막 수위 {r.after.tankEnd.map((v) => fmt(v, 2)).join(', ')}</span>
<div style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', fontSize: 10.5 }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>펌프</th>{r.schedules[r.pumps[0]].map((_, k) => <th key={k} style={{ ...th, padding: '2px 3px' }}>{hours(k)}</th>)}</tr></thead>
<tbody>
{r.pumps.map((p) => (
<tr key={p}>
<td style={{ ...td, textAlign: 'left' }}>{p}</td>
{r.schedules[p].map((v, k) => <td key={k} style={{ ...td, padding: '2px 3px', background: v ? '#16a34a' : 'transparent', color: v ? '#fff' : 'var(--text-muted)', textAlign: 'center' }}>{v ? '●' : '·'}</td>)}
</tr>
))}
</tbody>
</table>
</div>
<span style={{ color: 'var(--text-muted)' }}>열 머리는 시작 시각(시)입니다. 시간대별 정밀 해석은 적용 후 수리해석으로 확인하세요.</span>
<button className="btn btn-secondary" style={{ height: 26, alignSelf: 'flex-start' }} disabled={!r.feasible} onClick={apply}>펌프 패턴으로 적용 (실행 취소 가능)</button>
</div>
)}
</div>
);
};
+548
View File
@@ -0,0 +1,548 @@
import React, { useState } from 'react';
import { Settings, RefreshCw, AlertTriangle, FileText, Download } from 'lucide-react';
import type { InpOptions, NetworkModel } from './inpModel';
import {
getUnitSystem,
SI_FLOW_UNITS,
US_FLOW_UNITS,
UnitSystem,
checkUnitConversion,
type DisplayUnitMode,
} from './unitSystem';
import { inspectSimulation } from './convergence';
import type { SimulationResult } from './types';
import { EnergyReactionOptions } from './EnergyReactionOptions';
interface OptionEditorProps {
options: InpOptions;
networkModel?: NetworkModel | null;
displayUnitMode?: DisplayUnitMode;
onDisplayUnitModeChange?: (mode: DisplayUnitMode) => void;
onUpdateOptions: (updates: Partial<InpOptions>) => void;
onConvertUnits?: (targetFlowUnit: string) => void;
reportText?: string;
/** Full result, so engine warning codes and implausible pressures count too. */
simulationResult?: SimulationResult | null;
engineVersion?: string;
onConfirmStaleExport?: () => boolean;
/** Undoable whole-model edits ([ENERGY]/[REACTIONS] globals). */
onApplyModel?: (next: NetworkModel, message: string) => void;
}
export const OptionEditor: React.FC<OptionEditorProps> = ({
options,
networkModel,
displayUnitMode = 'korean',
onDisplayUnitModeChange,
onUpdateOptions,
onConvertUnits,
reportText,
simulationResult,
engineVersion = '2.3.5',
onConfirmStaleExport,
onApplyModel,
}) => {
const currentSystem = getUnitSystem(options.units);
const [selectedSystem, setSelectedSystem] = useState<UnitSystem>(currentSystem);
const [targetFlowUnit, setTargetFlowUnit] = useState<string>(options.units);
const [formulaWarning, setFormulaWarning] = useState<string | null>(null);
const upsertEntry = (
entries: InpOptions['rawEntries'],
key: string,
value: string
): InpOptions['rawEntries'] => {
const index = entries.findIndex((entry) => entry.key.toUpperCase() === key.toUpperCase());
if (index < 0) return [...entries, { key, value }];
return entries.map((entry, entryIndex) => entryIndex === index ? { ...entry, value } : entry);
};
const updateRawOption = (key: string, value: string) => {
onUpdateOptions({ rawEntries: upsertEntry(options.rawEntries, key, value) });
};
const updateTimeOption = (key: string, value: string) => {
onUpdateOptions({ timeEntries: upsertEntry(options.timeEntries, key, value) });
};
const getRawOption = (key: string, fallback = '') =>
options.rawEntries.find((entry) => entry.key.toUpperCase() === key.toUpperCase())?.value ?? fallback;
const getTimeOption = (key: string, fallback = '') =>
options.timeEntries.find((entry) => entry.key.toUpperCase() === key.toUpperCase())?.value ?? fallback;
const qualityValue = getRawOption('Quality', 'NONE').trim();
const qualityUpper = qualityValue.toUpperCase();
const qualityMode = qualityUpper.startsWith('TRACE ')
? 'TRACE'
: qualityUpper.startsWith('CHEMICAL ')
? 'CHEMICAL'
: qualityUpper === 'AGE'
? 'AGE'
: 'NONE';
const traceNodeId = qualityMode === 'TRACE' ? qualityValue.slice(5).trim() : '';
const chemicalDefinition = qualityMode === 'CHEMICAL'
? qualityValue.slice('CHEMICAL'.length).trim()
: 'Chlorine mg/L';
const handleSystemChange = (sys: UnitSystem) => {
setSelectedSystem(sys);
const defaultFlow = sys === 'SI' ? 'LPS' : 'GPM';
setTargetFlowUnit(defaultFlow);
};
const handleApplyConversion = () => {
if (!networkModel || !onConvertUnits) return;
const check = checkUnitConversion(networkModel, targetFlowUnit);
if (!check.canConvert) {
alert(`단위 변환 불가: ${check.reason}`);
return;
}
const confirmMsg =
`현재 관망 모델의 전체 물리량을 [${check.sourceFlowUnit}]에서 [${check.targetFlowUnit}] (${check.targetSystem}) 단위계로 변환하시겠습니까?\n\n` +
`- 변환 대상: 관로 연장/관경, 절점 표고/수요량, 탱크 제원, 제어 임계값\n` +
`- 보존 대상: 무차원 조도(C), 미소손실(K), 패턴 배율, GIS 좌표\n\n` +
`변환 전 원본 파일은 유지되며, 변환 후 즉시 재해석을 실행해야 합니다.`;
if (window.confirm(confirmMsg)) {
onConvertUnits(targetFlowUnit);
}
};
const handleHeadlossChange = (newFormula: string) => {
if (newFormula !== options.headloss) {
setFormulaWarning(
`마찰손실 공식을 [${options.headloss}]에서 [${newFormula}]로 변경합니다. H-W(C계수: 100~140), D-W(절대조도 e: 0.001~1mm), C-M(조도계수 n: 0.01~0.015)은 물리적 단위와 의미가 완전히 다릅니다. 공식 변경 후 관로별 조도 수치를 반드시 재검토하세요.`
);
} else {
setFormulaWarning(null);
}
onUpdateOptions({ headloss: newFormula });
};
const handleDownloadRpt = () => {
if (!reportText) return;
if (onConfirmStaleExport && !onConfirmStaleExport()) return;
const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'epanet_hydraulic_report.rpt';
a.click();
URL.revokeObjectURL(url);
};
// Honest convergence check - derived from the actual engine report text
const { status: convergenceStatus, color: convergenceColor } = inspectSimulation(
simulationResult ? { ...simulationResult, reportText } : { reportText }
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', fontSize: '12px' }}>
{/* 1. Unit System & Options Card */}
<div style={{ background: 'var(--bg-subtle)', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '12px' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Settings size={14} aria-hidden="true" />
모델 설정
</div>
<div style={{ marginBottom: '10px', color: 'var(--text-secondary)', fontSize: '11px' }}>
현재: {options.units} · {options.headloss} · {options.demandModel || 'DDA'} · 화면 표시: {displayUnitMode === 'korean' ? '한국 실무 단위' : 'EPANET 원본 단위'}
</div>
<label style={{ display: 'grid', gap: '3px', marginBottom: '10px', color: 'var(--text-secondary)', fontSize: '11px' }}>
화면 표시 단위
<select
aria-label="화면 표시 단위"
value={displayUnitMode}
onChange={(e) => onDisplayUnitModeChange?.(e.target.value as DisplayUnitMode)}
disabled={!onDisplayUnitModeChange}
style={{ width: '100%', padding: '5px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="korean">한국 실무 단위 (m, mm, m³/day)</option>
<option value="original">EPANET 원본 단위 (모델 값 그대로)</option>
</select>
</label>
{/* Unit System Switcher */}
<details style={{ marginBottom: '12px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: '6px' }}>
고급: EPANET 모델 단위 변환
</summary>
<div style={{ background: 'var(--bg-panel)', border: '1px solid #cbd5e1', borderRadius: '4px', padding: '10px' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: '6px' }}>
단위계 체계 (Unit System):
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>체계 선택</label>
<select
value={selectedSystem}
onChange={(e) => handleSystemChange(e.target.value as UnitSystem)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="SI">SI 미터법 (Metric: m, mm, LPS, m수두)</option>
<option value="US">미국 단위계 (US Customary: ft, in, GPM, psi)</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>유량 단위 (Flow Unit)</label>
<select
value={targetFlowUnit}
onChange={(e) => setTargetFlowUnit(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
{(selectedSystem === 'SI' ? SI_FLOW_UNITS : US_FLOW_UNITS).map((u) => (
<option key={u} value={u}>
{u} {u === 'LPS' ? '(국내 표준)' : u === 'GPM' ? '(EPA 표준)' : ''}
</option>
))}
</select>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>
현재 모델 단위: <strong>{options.units}</strong> ({currentSystem})
</span>
{targetFlowUnit.toUpperCase() !== options.units.toUpperCase() && onConvertUnits && (
<button
onClick={handleApplyConversion}
className="btn btn-primary"
style={{ padding: '4px 10px', fontSize: '11px', background: 'var(--accent)', display: 'inline-flex', alignItems: 'center', gap: 'var(--s-1)' }}
>
<RefreshCw size={12} aria-hidden="true" />
현재 관망 물리량 변환 실행
</button>
)}
</div>
</div>
</details>
{/* Headloss Formula & Accuracy */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginBottom: '10px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
마찰손실 공식 (Headloss Formula)
</label>
<select
value={options.headloss}
onChange={(e) => handleHeadlossChange(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px', fontSize: '12px' }}
>
<option value="H-W">H-W (Hazen-Williams - 국내 설계 권장)</option>
<option value="D-W">D-W (Darcy-Weisbach - 고정밀 절대조도)</option>
<option value="C-M">C-M (Chezy-Manning)</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
수렴 허용오차 (Accuracy)
</label>
<input
type="number"
step="0.0001"
value={options.accuracy}
onChange={(e) => onUpdateOptions({ accuracy: parseFloat(e.target.value) || 0.001 })}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px', fontSize: '12px' }}
/>
</div>
</div>
{formulaWarning && (
<div style={{ padding: '8px', background: 'var(--warn-wash)', border: '1px solid #fde68a', color: 'var(--warn)', borderRadius: '4px', fontSize: '11px', marginBottom: '10px', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<AlertTriangle size={13} aria-hidden="true" />
{formulaWarning}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '10px' }}>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
최대 반복횟수
<input
type="number"
step="5"
value={options.trials}
onChange={(e) => onUpdateOptions({ trials: parseInt(e.target.value, 10) || 40 })}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</label>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
수요 모델
<select
value={(options.demandModel || 'DDA').toUpperCase()}
onChange={(e) => onUpdateOptions({ demandModel: e.target.value })}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="DDA">DDA (수요 기반)</option>
<option value="PDA">PDA (압력 기반)</option>
</select>
</label>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
수요 배율
<input type="number" step="0.01" value={options.demandMultiplier ?? 1} onChange={(e) => onUpdateOptions({ demandMultiplier: Number(e.target.value) })} />
</label>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
에미터 지수
<input type="number" step="0.01" value={options.emitterExponent ?? 0.5} onChange={(e) => onUpdateOptions({ emitterExponent: Number(e.target.value) })} />
</label>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
비중
<input type="number" step="0.01" value={options.specificGravity ?? 1} onChange={(e) => onUpdateOptions({ specificGravity: Number(e.target.value) })} />
</label>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
상대 점성
<input type="number" step="0.01" value={options.viscosity ?? 1} onChange={(e) => onUpdateOptions({ viscosity: Number(e.target.value) })} />
</label>
{(options.demandModel || 'DDA').toUpperCase() === 'PDA' && (
<>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
최소 압력 (원본 모델 단위)
<input type="number" value={options.minimumPressure ?? 0} onChange={(e) => onUpdateOptions({ minimumPressure: Number(e.target.value) })} />
</label>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
필요 압력 (원본 모델 단위)
<input type="number" value={options.requiredPressure ?? 20} onChange={(e) => onUpdateOptions({ requiredPressure: Number(e.target.value) })} />
</label>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
압력 지수
<input type="number" step="0.01" value={options.pressureExponent ?? 0.5} onChange={(e) => onUpdateOptions({ pressureExponent: Number(e.target.value) })} />
</label>
</>
)}
</div>
</div>
<AdvancedOptions
options={options}
patternIds={Object.keys(networkModel?.patterns ?? {})}
getRawOption={getRawOption}
updateRawOption={updateRawOption}
onUpdateOptions={onUpdateOptions}
/>
{networkModel && onApplyModel && <EnergyReactionOptions model={networkModel} onApply={onApplyModel} />}
<div style={{ background: 'var(--bg-subtle)', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '12px' }}>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>해석 시간 및 수질 설정</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '10px' }}>
{[
['Duration', '총 해석시간', '24:00'],
['Hydraulic Timestep', '수리 시간간격', '1:00'],
['Quality Timestep', '수질 시간간격', '0:05'],
['Pattern Timestep', '패턴 시간간격', '1:00'],
['Pattern Start', '패턴 시작시간', '0:00'],
['Report Timestep', '보고 시간간격', '1:00'],
['Report Start', '보고 시작시간', '0:00'],
['Start Clocktime', '모의 시작시각', '12:00 AM'],
].map(([key, label, fallback]) => (
<label key={key} style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
{label}
<input
type="text"
value={getTimeOption(key, fallback)}
onChange={(e) => updateTimeOption(key, e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</label>
))}
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' }}>
결과 통계
<select
aria-label="결과 통계"
value={getTimeOption('Statistic', 'NONE').toUpperCase()}
onChange={(e) => updateTimeOption('Statistic', e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="NONE">없음 (NONE)</option>
<option value="AVERAGE">평균 (AVERAGE)</option>
<option value="MINIMUM">최소 (MINIMUM)</option>
<option value="MAXIMUM">최대 (MAXIMUM)</option>
<option value="RANGE">범위 (RANGE)</option>
</select>
</label>
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px', gridColumn: '1 / -1' }}>
수질 모델
<select
aria-label="수질 모델"
value={qualityMode}
onChange={(e) => {
const mode = e.target.value;
if (mode === 'TRACE') {
const nodeId = traceNodeId || Object.keys(networkModel?.nodes || {})[0];
if (nodeId) updateRawOption('Quality', `TRACE ${nodeId}`);
return;
}
if (mode === 'CHEMICAL') {
updateRawOption('Quality', `CHEMICAL ${chemicalDefinition || 'Chlorine mg/L'}`);
return;
}
updateRawOption('Quality', mode);
}}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="NONE">수질 해석 안 함 (NONE)</option>
<option value="AGE">물 나이 (AGE)</option>
<option value="TRACE">수원 추적 (TRACE)</option>
<option value="CHEMICAL">사용자 정의 화학물질 (CHEMICAL)</option>
</select>
</label>
{qualityMode === 'TRACE' && (
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px', gridColumn: '1 / -1' }}>
추적 수원 절점
<select
value={traceNodeId}
onChange={(e) => updateRawOption('Quality', `TRACE ${e.target.value}`)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
{Object.keys(networkModel?.nodes || {}).map((nodeId) => (
<option key={nodeId} value={nodeId}>{nodeId}</option>
))}
</select>
</label>
)}
{qualityMode === 'CHEMICAL' && (
<label style={{ display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px', gridColumn: '1 / -1' }}>
화학물질 이름 및 농도 단위
<input
key={qualityValue}
type="text"
defaultValue={chemicalDefinition}
onBlur={(e) => {
const definition = e.target.value.trim();
if (definition) updateRawOption('Quality', `CHEMICAL ${definition}`);
}}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</label>
)}
<div style={{ gridColumn: '1 / -1', color: 'var(--text-muted)', fontSize: '10px' }}>
EPANET에는 탁도 전용 내장 모델이 없습니다. 필요한 경우에만 CHEMICAL의 사용자 정의 물질과 단위로 표현합니다.
</div>
</div>
</div>
{/* 2. EPANET Hydraulic Report Viewer */}
<div style={{ background: 'var(--bg-panel)', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontWeight: 600, color: 'var(--text)', fontSize: '13px', display: 'inline-flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<FileText size={14} aria-hidden="true" />
EPANET 수리해석 보고서 (.rpt)
</span>
<span style={{ fontSize: '10px', background: '#e0f2fe', color: '#0369a1', padding: '1px 5px', borderRadius: '4px', fontWeight: 600 }}>
v{engineVersion}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: convergenceColor }}>
{convergenceStatus}
</span>
{reportText && (
<button
onClick={handleDownloadRpt}
style={{
padding: '3px 8px',
background: 'var(--bg-subtle)',
border: '1px solid #cbd5e1',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '11px',
color: 'var(--text)',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--s-1)',
}}
title="정식 보고서 텍스트 파일을 다운로드합니다"
>
<Download size={12} aria-hidden="true" />
.rpt 파일 다운로드
</button>
)}
</div>
</div>
{reportText ? (
<div
style={{
maxHeight: '260px',
overflowY: 'auto',
background: 'var(--text)',
color: 'var(--border)',
padding: '10px',
borderRadius: '4px',
fontFamily: 'Consolas, monospace',
fontSize: '11px',
lineHeight: 1.4,
whiteSpace: 'pre-wrap',
}}
>
{reportText}
</div>
) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-muted)', border: '1px dashed #cbd5e1', borderRadius: '4px' }}>
수리해석 실행 후 생성된 정식 보고서가 여기에 표출됩니다.
</div>
)}
</div>
</div>
);
};
/** 고급 해석 옵션: the rest of EPANET's [OPTIONS] (GISpipe 수리 설정 parity). */
const AdvancedOptions: React.FC<{
options: InpOptions;
patternIds: string[];
getRawOption: (key: string, fallback?: string) => string;
updateRawOption: (key: string, value: string) => void;
onUpdateOptions: (updates: Partial<InpOptions>) => void;
}> = ({ options, patternIds, getRawOption, updateRawOption, onUpdateOptions }) => {
const [open, setOpen] = useState(false);
const unb = (options.unbalanced || 'STOP').trim().toUpperCase();
const isContinue = unb.startsWith('CONTINUE');
const contN = isContinue ? unb.split(/\s+/)[1] ?? '10' : '10';
const label: React.CSSProperties = { display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' };
const input: React.CSSProperties = { width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' };
const numField = (key: string, text: string, fallback: string, help: string, step = 'any') => (
<label style={label} title={help}>
{text}
<input type="number" step={step} value={getRawOption(key, fallback)} onChange={(e) => updateRawOption(key, e.target.value || fallback)} style={input} />
</label>
);
return (
<div style={{ background: 'var(--bg-subtle)', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '12px' }}>
<button type="button" onClick={() => setOpen(!open)} style={{ border: 'none', background: 'none', padding: 0, fontWeight: 600, cursor: 'pointer', color: 'inherit' }}>
{open ? '▾' : '▸'} 고급 해석 옵션 (불균형 처리·기본 패턴·수렴 제어·수질 허용오차)
</button>
{open && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '10px', marginTop: 8 }}>
<label style={label} title="최대 반복 안에 수렴하지 못할 때: 중단(STOP) 또는 링크 상태를 고정하고 N회 더 반복(CONTINUE)">
불균형 시 처리
<select value={isContinue ? 'CONTINUE' : 'STOP'} onChange={(e) => onUpdateOptions({ unbalanced: e.target.value === 'CONTINUE' ? `CONTINUE ${contN}` : 'STOP' })} style={input}>
<option value="STOP">중단 (Stop)</option>
<option value="CONTINUE">계속 (Continue)</option>
</select>
</label>
<label style={label} title="CONTINUE일 때 추가 반복 횟수">
추가 반복 횟수
<input type="number" min={1} disabled={!isContinue} value={contN} onChange={(e) => onUpdateOptions({ unbalanced: `CONTINUE ${Math.max(1, parseInt(e.target.value, 10) || 10)}` })} style={input} />
</label>
<label style={label} title="패턴을 지정하지 않은 절점 수요에 적용되는 패턴">
기본 수요 패턴
<select value={options.pattern ?? '1'} onChange={(e) => onUpdateOptions({ pattern: e.target.value })} style={input}>
{!patternIds.includes(options.pattern ?? '1') && <option value={options.pattern ?? '1'}>{options.pattern ?? '1'} (없는 패턴: 수요 일정)</option>}
{patternIds.map((id) => <option key={id} value={id}>{id}</option>)}
</select>
</label>
{numField('CHECKFREQ', 'CHECKFREQ (상태 확인 주기)', '2', '몇 번째 반복마다 펌프·체크밸브·FCV 상태를 확인할지 (기본 2)', '1')}
{numField('MAXCHECK', 'MAXCHECK (주기 확인 중단)', '10', '이 반복 이후에는 수렴 시에만 상태 확인 (기본 10)', '1')}
{numField('DAMPLIMIT', 'DAMPLIMIT (감쇠 시작 정확도)', '0', 'PRV/PSV 감쇠·상태 확인 시작 정확도. 0은 감쇠 없음, 수렴이 어려우면 0.01 권장')}
{numField('HEADERROR', '최대 수두 오차', '0', '링크별 허용 최대 수두손실 오차 (0 = 제한 없음, 원본 길이 단위)')}
{numField('FLOWCHANGE', '최대 유량 변화', '0', '수렴 판정용 최대 유량 변화 (0 = 제한 없음, 원본 유량 단위)')}
{numField('Tolerance', '수질 허용오차', '0.01', '새 물 구간을 만드는 최소 수질 변화 (기본 0.01)')}
{numField('Diffusivity', '분자 확산도 (염소 대비)', '1', '20°C 염소 대비 분자 확산도 비 (관벽 반응 물질전달). 0이면 물질전달 무시')}
</div>
)}
</div>
);
};
+245
View File
@@ -0,0 +1,245 @@
import React, { useState } from 'react';
import { Home, Building2, Factory, Plus } from 'lucide-react';
import type { InpPattern } from './inpModel';
interface PatternEditorProps {
patterns: Record<string, InpPattern>;
onUpdatePattern: (id: string, multipliers: number[]) => void;
}
export const PatternEditor: React.FC<PatternEditorProps> = ({
patterns,
onUpdatePattern,
}) => {
const patternKeys = Object.keys(patterns);
const [selectedId, setSelectedId] = useState<string>(patternKeys[0] || '1');
const handleAddPattern = () => {
const numericIds = patternKeys
.map((id) => Number(id))
.filter((value) => Number.isInteger(value) && value >= 0);
let nextId = numericIds.length > 0 ? String(Math.max(...numericIds) + 1) : `P${patternKeys.length + 1}`;
let suffix = patternKeys.length + 1;
while (patterns[nextId]) {
nextId = `P${++suffix}`;
}
onUpdatePattern(nextId, new Array(12).fill(1));
setSelectedId(nextId);
};
if (patternKeys.length === 0) {
return (
<div style={{ padding: '16px', color: 'var(--text-secondary)', fontSize: '13px', display: 'grid', gap: '10px' }}>
<span>등록된 수요 패턴이 없습니다.</span>
<button type="button" className="btn btn-primary" onClick={handleAddPattern}>
<Plus size={12} aria-hidden="true" />
새 패턴 추가
</button>
</div>
);
}
const currentPattern = patterns[selectedId] || patterns[patternKeys[0]];
const multipliers = currentPattern.multipliers;
const handleMultiplierChange = (index: number, val: number) => {
const updated = [...multipliers];
updated[index] = Math.max(0, Math.round(val * 100) / 100);
onUpdatePattern(selectedId, updated);
};
// Presets
const applyPreset = (presetMultipliers: number[]) => {
onUpdatePattern(selectedId, presetMultipliers);
};
// SVG Bar Chart Dimensions
const chartWidth = 320;
const chartHeight = 100;
const padLeft = 28;
const padRight = 10;
const padTop = 10;
const padBottom = 20;
const plotWidth = chartWidth - padLeft - padRight;
const plotHeight = chartHeight - padTop - padBottom;
const maxVal = Math.max(2.0, ...multipliers);
const barWidth = Math.max(8, (plotWidth / multipliers.length) * 0.7);
const stepWidth = plotWidth / (multipliers.length || 1);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '12px' }}>
{/* Pattern Selector */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>패턴 선택:</span>
<select
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
style={{ padding: '3px 8px', border: '1px solid #cbd5e1', borderRadius: '4px', fontSize: '12px' }}
>
{patternKeys.map((id) => (
<option key={id} value={id}>
패턴 {id} ({patterns[id].description || 'Demand Pattern'})
</option>
))}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '11px' }}>
{multipliers.length}구간 (24시간)
</span>
<button type="button" className="btn btn-secondary" onClick={handleAddPattern} style={{ fontSize: '11px' }}>
<Plus size={12} aria-hidden="true" />
새 패턴 추가
</button>
</div>
</div>
{/* SVG Multiplier Bar Chart */}
<div style={{ background: 'var(--bg-subtle)', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '8px' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: '4px', fontSize: '11px' }}>
24시간 배율(Multiplier) 분포 그래프
</div>
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} style={{ width: '100%', height: '110px' }}>
{/* Baseline 1.0 line */}
<line
x1={padLeft}
y1={padTop + plotHeight - (1.0 / maxVal) * plotHeight}
x2={padLeft + plotWidth}
y2={padTop + plotHeight - (1.0 / maxVal) * plotHeight}
stroke="#94a3b8"
strokeWidth="1"
strokeDasharray="3,3"
/>
<text
x={padLeft - 4}
y={padTop + plotHeight - (1.0 / maxVal) * plotHeight + 3}
textAnchor="end"
fontSize="8"
fill="#64748b"
>
1.0
</text>
{/* Max line */}
<text
x={padLeft - 4}
y={padTop + 6}
textAnchor="end"
fontSize="8"
fill="#94a3b8"
>
{maxVal.toFixed(1)}
</text>
{/* Bars */}
{multipliers.map((m, idx) => {
const h = (m / maxVal) * plotHeight;
const x = padLeft + idx * stepWidth + (stepWidth - barWidth) / 2;
const y = padTop + plotHeight - h;
const hour = Math.round((idx / multipliers.length) * 24);
return (
<g key={idx}>
<rect
x={x}
y={y}
width={barWidth}
height={h}
fill={m > 1.2 ? 'var(--warn)' : m < 0.8 ? '#3b82f6' : '#10b981'}
rx="2"
/>
<text
x={x + barWidth / 2}
y={chartHeight - 4}
textAnchor="middle"
fontSize="7"
fill="#64748b"
>
{hour}h
</text>
</g>
);
})}
</svg>
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', fontSize: '10px', color: 'var(--text-secondary)', marginTop: '2px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ width: 8, height: 8, background: 'var(--warn)', borderRadius: 2 }} /> 피크 (&gt;1.2)
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ width: 8, height: 8, background: '#10b981', borderRadius: 2 }} /> 평시 (0.8~1.2)
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ width: 8, height: 8, background: '#3b82f6', borderRadius: 2 }} /> 야간/저수요 (&lt;0.8)
</span>
</div>
</div>
{/* Preset Buttons */}
<div>
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: '4px' }}>실무 배율 프리셋:</div>
<div style={{ display: 'flex', gap: '6px' }}>
<button
onClick={() => applyPreset([0.6, 0.8, 1.4, 1.6, 1.2, 1.0, 0.9, 1.1, 1.5, 1.2, 0.9, 0.7])}
className="btn btn-secondary"
style={{ flex: 1, padding: '4px 6px', fontSize: '11px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 'var(--s-1)' }}
title="아침 7~9시, 저녁 18~20시 집중 피크"
>
<Home size={12} aria-hidden="true" />
주거 2-피크
</button>
<button
onClick={() => applyPreset([0.3, 0.3, 0.6, 1.3, 1.6, 1.5, 1.4, 1.3, 1.0, 0.7, 0.5, 0.3])}
className="btn btn-secondary"
style={{ flex: 1, padding: '4px 6px', fontSize: '11px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 'var(--s-1)' }}
title="오전 9시~오후 6시 업무시간 집중"
>
<Building2 size={12} aria-hidden="true" />
상업 주간형
</button>
<button
onClick={() => applyPreset(new Array(multipliers.length || 12).fill(1.0))}
className="btn btn-secondary"
style={{ flex: 1, padding: '4px 6px', fontSize: '11px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 'var(--s-1)' }}
title="24시간 균일 배율 (1.0)"
>
<Factory size={12} aria-hidden="true" />
균등 (Flat 1.0)
</button>
</div>
</div>
{/* Multiplier Inputs Grid */}
<div>
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: '6px' }}>시간대별 배율(Multiplier) 수정:</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px', maxHeight: '180px', overflowY: 'auto' }}>
{multipliers.map((m, idx) => {
const startHour = Math.round((idx / multipliers.length) * 24);
const endHour = Math.round(((idx + 1) / multipliers.length) * 24);
return (
<div key={idx} style={{ background: 'var(--bg-subtle)', padding: '4px 6px', borderRadius: '4px', border: '1px solid #e2e8f0' }}>
<label style={{ display: 'block', fontSize: '10px', color: 'var(--text-secondary)' }}>
{startHour}h ~ {endHour}h
</label>
<input
type="number"
step="0.1"
min="0"
max="10"
value={m}
onChange={(e) => handleMultiplierChange(idx, parseFloat(e.target.value) || 0)}
style={{ width: '100%', padding: '2px 4px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '3px' }}
/>
</div>
);
})}
</div>
</div>
</div>
);
};
+280
View File
@@ -0,0 +1,280 @@
// Longitudinal profile (종단) along a path between two nodes, over time:
// ground elevation, hydraulic grade line (동수두) at the current time step with
// its min-max envelope over the whole run, and the flow in each link below.
import React, { useEffect, useMemo, useState } from 'react';
import { Pause, Play, Crosshair, ArrowLeftRight } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import { findProfilePath } from './profilePath';
import type { SimulationResult } from './types';
interface ProfilePanelProps {
model: NetworkModel;
result: SimulationResult;
timeStepIndex: number;
onTimeStepIndexChange: (index: number) => void;
/** Node currently selected on the map (used by the "선택 절점" buttons). */
selectedNodeId: string | null;
toDisplay: { head: (v: number) => number; length: (v: number) => number; flow: (v: number) => number; diameter?: (v: number) => number };
units: { head: string; flow: string; length: string; diameter?: string };
onFocusNode: (id: string) => void;
}
const W = 320;
const fmt = (v: number) => (Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2));
const hhmm = (sec: number) => `${Math.floor(sec / 3600)}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}`;
export const ProfilePanel: React.FC<ProfilePanelProps> = ({
model, result, timeStepIndex, onTimeStepIndexChange, selectedNodeId, toDisplay, units, onFocusNode,
}) => {
const [fromId, setFromId] = useState('');
const [toId, setToId] = useState('');
const [playing, setPlaying] = useState(false);
const steps = result.timeSteps ?? [result.timeSeconds ?? 0];
const t = Math.min(timeStepIndex, steps.length - 1);
const path = useMemo(
() => (fromId && toId ? findProfilePath(model, fromId.trim(), toId.trim()) : null),
[model, fromId, toId]
);
useEffect(() => {
if (!playing || steps.length < 2) return;
const timer = window.setInterval(() => onTimeStepIndexChange((t + 1) % steps.length), 600);
return () => window.clearInterval(timer);
}, [playing, t, steps.length, onTimeStepIndexChange]);
const profile = useMemo(() => {
if (!path) return null;
const x: number[] = [0];
path.linkLengths.forEach((len, i) => x.push(x[i] + toDisplay.length(len)));
const ground = path.nodeIds.map((id) => {
const n = model.nodes[id];
// A reservoir has no ground elevation of its own; its head is its level.
return toDisplay.head(n?.type === 'RESERVOIR' ? (n.head ?? n.elevation) : (n?.elevation ?? 0));
});
const headAt = (id: string, k: number) => {
const r = result.nodes[id];
return toDisplay.head(r?.timeSeries?.head[k] ?? r?.head ?? 0);
};
const head = path.nodeIds.map((id) => headAt(id, t));
const envelope = path.nodeIds.map((id) => {
const s = result.nodes[id]?.timeSeries?.head;
if (!s?.length) return [headAt(id, t), headAt(id, t)];
const v = s.map((h) => toDisplay.head(h));
return [Math.min(...v), Math.max(...v)];
});
const average = path.nodeIds.map((id) => {
const s = result.nodes[id]?.timeSeries?.head;
if (!s?.length) return headAt(id, t);
return toDisplay.head(s.reduce((a, b) => a + b, 0) / s.length);
});
// EPANET flow is positive node1 -> node2; re-sign it to the path direction.
const flow = path.linkIds.map((id, i) => {
const r = result.links[id];
const link = model.pipes[id] ?? model.pumps[id] ?? model.valves?.[id];
const sign = link && link.node1 !== path.nodeIds[i] ? -1 : 1;
return sign * toDisplay.flow(r?.timeSeries?.flow[t] ?? r?.flow ?? 0);
});
const flowMax = Math.max(
1e-9,
...path.linkIds.map((id) => {
const s = result.links[id]?.timeSeries?.flow;
return s?.length ? Math.max(...s.map((q) => Math.abs(toDisplay.flow(q)))) : Math.abs(toDisplay.flow(result.links[id]?.flow ?? 0));
})
);
return { x, ground, head, envelope, average, flow, flowMax };
}, [path, model, result, t, toDisplay]);
const pickButton = (set: (v: string) => void, label: string) => (
<button
type="button"
className="btn btn-secondary"
disabled={!selectedNodeId}
onClick={() => selectedNodeId && set(selectedNodeId)}
title={selectedNodeId ? `지도에서 선택한 절점 ${selectedNodeId}` : '지도에서 절점을 먼저 선택하세요'}
style={{ height: 'var(--h-btn-sm)', padding: '0 6px', fontSize: 'var(--fs-2xs)', display: 'inline-flex', alignItems: 'center', gap: 3 }}
>
<Crosshair size={11} />{label}
</button>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)', fontSize: 'var(--fs-xs)' }}>
<datalist id="profile-node-ids">
{Object.keys(model.nodes).slice(0, 5000).map((id) => <option key={id} value={id} />)}
</datalist>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 6, alignItems: 'center' }}>
<span>시작 절점</span>
<input list="profile-node-ids" value={fromId} onChange={(e) => setFromId(e.target.value)} placeholder="절점 ID" style={{ height: 24, fontSize: 'var(--fs-xs)' }} />
{pickButton(setFromId, '선택 절점')}
<span>끝 절점</span>
<input list="profile-node-ids" value={toId} onChange={(e) => setToId(e.target.value)} placeholder="절점 ID" style={{ height: 24, fontSize: 'var(--fs-xs)' }} />
{pickButton(setToId, '선택 절점')}
</div>
{fromId && toId && (
<button type="button" className="btn btn-secondary" style={{ height: 22, fontSize: 'var(--fs-2xs)', alignSelf: 'flex-start' }}
onClick={() => { setFromId(toId); setToId(fromId); }} title="시작과 끝을 바꿔 종단 방향을 뒤집습니다">
<ArrowLeftRight size={11} />역으로
</button>
)}
{!fromId || !toId ? (
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.6 }}>
지도에서 절점을 클릭한 뒤 <b>선택 절점</b>으로 시작·끝을 지정하거나 ID를 입력하세요.
두 절점 사이 최단 경로(관로 연장 기준)를 따라 지반고·동수두·유량 종단을 시간대별로 보여줍니다.
</div>
) : !path || !profile ? (
<div style={{ color: 'var(--crit)' }}>두 절점을 잇는 경로가 없습니다. 절점 ID를 확인하세요.</div>
) : (
<>
{/* Time control (shared with the map and other result views) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
className="btn btn-secondary"
onClick={() => setPlaying((v) => !v)}
disabled={steps.length < 2}
aria-label={playing ? '정지' : '재생'}
style={{ height: 'var(--h-btn-sm)', width: 28, padding: 0, justifyContent: 'center' }}
>
{playing ? <Pause size={12} /> : <Play size={12} />}
</button>
<input
type="range"
min={0}
max={Math.max(0, steps.length - 1)}
value={t}
onChange={(e) => onTimeStepIndexChange(Number(e.target.value))}
style={{ flex: 1, accentColor: 'var(--accent)' }}
aria-label="시각"
/>
<span style={{ fontFamily: 'var(--font-mono)', minWidth: 40, textAlign: 'right' }}>{hhmm(steps[t] ?? 0)}</span>
</div>
<ProfileChart profile={profile} nodeIds={path.nodeIds} units={units} onFocusNode={onFocusNode} />
<FlowChart profile={profile} linkIds={path.linkIds} unit={units.flow} />
<div style={{ color: 'var(--text-secondary)' }}>
경로: 절점 {path.nodeIds.length}개 · 관로 {path.linkIds.length}개 · 연장 {fmt(profile.x[profile.x.length - 1])} {units.length}
</div>
<details>
<summary style={{ cursor: 'pointer' }}>구간표</summary>
<div style={{ maxHeight: 260, overflow: 'auto', border: '1px solid var(--border)', marginTop: 4 }}>
<table style={{ borderCollapse: 'collapse', width: '100%', fontFamily: 'var(--font-mono)', fontSize: 10.5 }}>
<thead><tr>
{['형식', 'ID', `거리(${units.length})`, `관경${units.diameter ? `(${units.diameter})` : ''}`, `표고(${units.head})`, '최소 수두', '최대 수두', '평균 수두'].map((h) => (
<th key={h} style={{ position: 'sticky', top: 0, background: 'var(--bg-subtle)', padding: '3px 4px', textAlign: 'right', fontFamily: 'var(--font-sans)' }}>{h}</th>
))}
</tr></thead>
<tbody>
{path.nodeIds.map((id, i) => {
const n = model.nodes[id];
const linkId = i > 0 ? path.linkIds[i - 1] : undefined;
const pipe = linkId ? model.pipes[linkId] : undefined;
const kind = n?.type === 'RESERVOIR' ? '배수지' : n?.type === 'TANK' ? '탱크' : '절점';
return (
<tr key={id} onClick={() => onFocusNode(id)} style={{ cursor: 'pointer', borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '2px 4px', textAlign: 'right' }}>{kind}</td>
<td style={{ padding: '2px 4px', textAlign: 'right' }}>{id}</td>
<td style={{ padding: '2px 4px', textAlign: 'right' }}>{fmt(profile.x[i])}</td>
<td style={{ padding: '2px 4px', textAlign: 'right' }}>{pipe && toDisplay.diameter ? fmt(toDisplay.diameter(pipe.diameter)) : linkId ? linkId : '-'}</td>
<td style={{ padding: '2px 4px', textAlign: 'right' }}>{fmt(profile.ground[i])}</td>
<td style={{ padding: '2px 4px', textAlign: 'right' }}>{fmt(profile.envelope[i][0])}</td>
<td style={{ padding: '2px 4px', textAlign: 'right' }}>{fmt(profile.envelope[i][1])}</td>
<td style={{ padding: '2px 4px', textAlign: 'right' }}>{fmt(profile.average[i])}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</details>
</>
)}
</div>
);
};
type Profile = { x: number[]; ground: number[]; head: number[]; envelope: number[][]; average: number[]; flow: number[]; flowMax: number };
const ProfileChart: React.FC<{ profile: Profile; nodeIds: string[]; units: { head: string; length: string }; onFocusNode: (id: string) => void }> = ({ profile, nodeIds, units, onFocusNode }) => {
const H = 230, pl = 42, pr = 10, pt = 22, pb = 30;
const { x, ground, head, envelope } = profile;
const xMax = Math.max(x[x.length - 1], 1e-9);
const all = [...ground, ...envelope.flat(), ...head];
let lo = Math.min(...all), hi = Math.max(...all);
if (hi === lo) { lo -= 1; hi += 1; }
const pad = (hi - lo) * 0.08; lo -= pad; hi += pad;
const sx = (v: number) => pl + (v / xMax) * (W - pl - pr);
const sy = (v: number) => H - pb - ((v - lo) / (hi - lo)) * (H - pt - pb);
const line = (vals: number[]) => vals.map((v, i) => `${i ? 'L' : 'M'}${sx(x[i]).toFixed(1)},${sy(v).toFixed(1)}`).join('');
const groundArea = `${line(ground)}L${sx(x[x.length - 1])},${H - pb}L${sx(0)},${H - pb}Z`;
const band = `${envelope.map((e, i) => `${i ? 'L' : 'M'}${sx(x[i]).toFixed(1)},${sy(e[1]).toFixed(1)}`).join('')}${[...envelope].reverse().map((e, j) => `L${sx(x[x.length - 1 - j]).toFixed(1)},${sy(e[0]).toFixed(1)}`).join('')}Z`;
// Label at most ~8 nodes so the axis stays readable.
const every = Math.max(1, Math.ceil(nodeIds.length / 8));
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={13} fontSize={11} fontWeight={600} fill="currentColor">동수두 종단 ({units.head})</text>
<g fontSize={9} fill="#64748b">
<rect x={W - 190} y={6} width={10} height={6} fill="#93c5fd" fillOpacity={0.5} /><text x={W - 177} y={12}>최소~최대</text>
<line x1={W - 128} x2={W - 116} y1={9} y2={9} stroke="#1e3a8a" strokeWidth={1} strokeDasharray="3 2" /><text x={W - 113} y={12}>평균</text>
<line x1={W - 88} x2={W - 76} y1={9} y2={9} stroke="#2563eb" strokeWidth={2} /><text x={W - 73} y={12}>현재</text>
<rect x={W - 40} y={6} width={10} height={6} fill="#d6c3a5" /><text x={W - 27} 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>
);
})}
<path d={groundArea} fill="#d6c3a5" fillOpacity={0.85} stroke="#a0845c" strokeWidth={1} />
<path d={band} fill="#93c5fd" fillOpacity={0.35} stroke="none" />
<path d={line(profile.average)} fill="none" stroke="#1e3a8a" strokeWidth={1} strokeDasharray="3 2" />
<path d={line(head)} fill="none" stroke="#2563eb" strokeWidth={2} />
{nodeIds.map((id, i) => (
<g key={id} onClick={() => onFocusNode(id)} style={{ cursor: 'pointer' }}>
<title>{`${id}: 동수두 ${fmt(head[i])} / 지반 ${fmt(ground[i])} / 수압 ${fmt(head[i] - ground[i])} ${units.head}`}</title>
<circle cx={sx(x[i])} cy={sy(head[i])} r={2.6} fill="#2563eb" />
{i % every === 0 || i === nodeIds.length - 1 ? (
<text x={sx(x[i])} y={H - pb + 12} fontSize={8.5} textAnchor="middle" fill="#475569">{id}</text>
) : null}
</g>
))}
<text x={(W + pl) / 2} y={H - 4} fontSize={9} textAnchor="middle" fill="#64748b">거리 ({units.length}) · 점을 누르면 지도 이동</text>
</svg>
);
};
const FlowChart: React.FC<{ profile: Profile; linkIds: string[]; unit: string }> = ({ profile, linkIds, unit }) => {
const H = 120, pl = 42, pr = 10, pt = 20, pb = 14;
const { x, flow, flowMax } = profile;
const xMax = Math.max(x[x.length - 1], 1e-9);
const sx = (v: number) => pl + (v / xMax) * (W - pl - pr);
const mid = pt + (H - pt - pb) / 2;
const sy = (q: number) => mid - (q / flowMax) * ((H - pt - pb) / 2);
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={13} fontSize={11} fontWeight={600} fill="currentColor">구간 유량 ({unit})</text>
<text x={W - pr} y={13} fontSize={9} textAnchor="end" fill="#64748b">+ 경로 방향 / − 역방향</text>
<line x1={pl} x2={W - pr} y1={mid} y2={mid} stroke="#94a3b8" />
<g fontSize={9} fill="#64748b">
<text x={pl - 4} y={sy(flowMax) + 3} textAnchor="end">{fmt(flowMax)}</text>
<text x={pl - 4} y={mid + 3} textAnchor="end">0</text>
<text x={pl - 4} y={sy(-flowMax) + 3} textAnchor="end">{fmt(-flowMax)}</text>
</g>
{linkIds.map((id, i) => {
const x0 = sx(x[i]);
const x1 = Math.max(sx(x[i + 1]), x0 + 1.5);
const y = sy(flow[i]);
return (
<rect key={id} x={x0} width={x1 - x0 - 0.5} y={Math.min(y, mid)} height={Math.max(Math.abs(y - mid), 0.8)} fill={flow[i] >= 0 ? '#16a34a' : '#f97316'} fillOpacity={0.8}>
<title>{`${id}: ${fmt(flow[i])} ${unit}`}</title>
</rect>
);
})}
</svg>
);
};
+212
View File
@@ -0,0 +1,212 @@
import React from 'react';
import { Check, ChevronDown, FileText, FolderOpen, Layers, Map as MapIcon, RotateCcw, Save, Package } from 'lucide-react';
import type { Scenario } from './types';
interface ProjectMenuProps {
open: boolean;
onToggle: () => void;
onClose: () => void;
loading: boolean;
hasModel: boolean;
isModified: boolean;
scenarios: Scenario[];
activeScenarioId: string;
canRetryCp949: boolean;
encodingNotice: string | null;
onLoadExample: (name: 'Net1' | 'Net2' | 'Net3') => void;
onLoadKoreaExample: () => void;
onFileUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
onAddGisLayer: (file: File) => void;
onOpenGisWizard: () => void;
onRetryCp949: () => void;
onSwitchScenario: (id: string) => void;
onCreateScenario: () => void;
onResetToOriginal: () => void;
onSaveProject: (includeResults: boolean) => void;
onOpenProject: (file: File) => void;
onOpenBackups?: () => void;
}
/** Header "프로젝트" dropdown: examples, INP open, scenarios, reset. */
export const ProjectMenu: React.FC<ProjectMenuProps> = ({
open,
onToggle,
onClose,
loading,
hasModel,
isModified,
scenarios,
activeScenarioId,
canRetryCp949,
encodingNotice,
onLoadExample,
onLoadKoreaExample,
onFileUpload,
onAddGisLayer,
onOpenGisWizard,
onRetryCp949,
onSwitchScenario,
onCreateScenario,
onResetToOriginal,
onSaveProject,
onOpenProject,
onOpenBackups,
}) => {
// Every menu action closes the menu afterwards.
const act = (fn: () => void) => () => {
fn();
onClose();
};
return (
<div className="dropdown-container">
<button
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
className="btn btn-secondary"
title="예제 관망, 파일 열기, 시나리오 관리"
>
<FolderOpen size={14} />
<span>프로젝트</span>
<ChevronDown size={12} style={{ opacity: 0.6 }} />
</button>
{open && (
<div className="dropdown-menu" style={{ width: '230px' }}>
<div className="dropdown-header">예제 관망</div>
<button className="dropdown-item" onClick={act(() => onLoadExample('Net1'))}>
<span>Net1 (염소 소독 예제)</span>
</button>
<button className="dropdown-item" onClick={act(() => onLoadExample('Net2'))}>
<span>Net2 (불소 추적 예제)</span>
</button>
<button className="dropdown-item" onClick={act(() => onLoadExample('Net3'))}>
<span>Net3 (다중 수원 대규모)</span>
</button>
<button className="dropdown-item" onClick={act(onLoadKoreaExample)}>
<span>실무 관망 (5179 익명화본)</span>
</button>
<div className="dropdown-divider" />
<div className="dropdown-header">프로젝트 파일 (.spipe)</div>
<label className="dropdown-item" style={{ margin: 0, cursor: 'pointer' }}>
<Package size={14} />
<span>프로젝트 열기...</span>
<input
type="file"
accept=".spipe,.zip"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) onOpenProject(file);
onClose();
}}
disabled={loading}
/>
</label>
{hasModel && (
<>
<button className="dropdown-item" onClick={act(() => onSaveProject(true))} title="관망, 시나리오, GIS 레이어, 스타일, 보정 자료와 해석 결과까지 한 파일로 저장">
<Save size={14} />
<span>프로젝트 저장 (해석 결과 포함)</span>
</button>
<button className="dropdown-item" onClick={act(() => onSaveProject(false))} title="해석 결과를 빼고 저장 (파일이 작음, 열고 다시 해석)">
<Save size={14} />
<span>프로젝트 저장 (결과 제외)</span>
</button>
</>
)}
{onOpenBackups && (
<button className="dropdown-item" onClick={act(onOpenBackups)} title="자동·수동 백업 목록에서 이전 상태로 되돌리기">
<RotateCcw size={14} />
<span>백업 기록...</span>
</button>
)}
<div className="dropdown-divider" />
<div className="dropdown-header">파일 가져오기</div>
<label className="dropdown-item" style={{ margin: 0, cursor: 'pointer' }}>
<FileText size={14} />
<span>INP 파일 열기...</span>
<input
type="file"
accept=".inp"
style={{ display: 'none' }}
onChange={(e) => {
onFileUpload(e);
onClose();
}}
disabled={loading}
/>
</label>
<label className="dropdown-item" style={{ margin: 0, cursor: 'pointer' }}>
<MapIcon size={14} />
<span>GIS 레이어 추가 (GeoJSON, Shapefile ZIP)...</span>
<input
type="file"
accept=".geojson,.json,.zip,.shp,.dxf,.dgn,.mif,.tab,.gml,.kml,.gpkg,.e00,.csv"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) onAddGisLayer(file);
onClose();
}}
/>
</label>
<button className="dropdown-item" onClick={act(onOpenGisWizard)}>
<Layers size={14} />
<span>GIS → 관망 변환 마법사</span>
</button>
{canRetryCp949 && (
<button className="dropdown-item" onClick={onRetryCp949}>
<RotateCcw size={14} />
<span>마지막 INP를 CP949로 다시 읽기</span>
</button>
)}
{encodingNotice && (
<div className="dropdown-header" style={{ maxWidth: 280, whiteSpace: 'normal' }}>
{encodingNotice}
</div>
)}
{hasModel && (
<>
<div className="dropdown-divider" />
<div className="dropdown-header">대안 시나리오</div>
{scenarios.map((s) => (
<button
key={s.id}
className="dropdown-item"
style={{ fontWeight: s.id === activeScenarioId ? 600 : 'normal', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}
onClick={act(() => onSwitchScenario(s.id))}
>
{s.id === activeScenarioId ? <Check size={12} aria-hidden="true" /> : <span style={{ width: 12 }} />}
<span>{s.name}</span>
</button>
))}
<button className="dropdown-item" onClick={act(onCreateScenario)}>
<Layers size={14} />
<span>새 대안 시나리오 분기</span>
</button>
<div className="dropdown-divider" />
<button
className="dropdown-item"
disabled={!isModified || loading}
onClick={act(onResetToOriginal)}
style={{ color: 'var(--crit)' }}
>
<RotateCcw size={14} />
<span>원본 모델로 되돌리기</span>
</button>
</>
)}
</div>
)}
</div>
);
};
+264
View File
@@ -0,0 +1,264 @@
// Result graphs beyond the single-element chart: several elements on one time
// series (with a table view) and the cumulative frequency (빈도) graph.
import React, { useMemo, useState } from 'react';
import { Plus, X, Crosshair } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import type { SimulationResult } from './types';
import { createDisplayConverter, getDisplayUnits } from './unitSystem';
type Converter = ReturnType<typeof createDisplayConverter>;
type DisplayUnits = ReturnType<typeof getDisplayUnits>;
export type SeriesItem = { type: 'node' | 'link'; id: string };
const COLORS = ['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#0891b2', '#db2777', '#4b5563'];
const hhmm = (s: number) => `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`;
const fmt = (v: number) => (Math.abs(v) >= 100 ? v.toFixed(1) : Math.abs(v) >= 1 ? v.toFixed(2) : v.toFixed(3));
interface VarDef { key: string; label: string; unit: (u: DisplayUnits) => string; conv: (c: Converter) => (v: number) => number }
const NODE_VARS: VarDef[] = [
{ key: 'pressure', label: '압력', unit: (u) => u.pressure, conv: (c) => c.pressure },
{ key: 'head', label: '총수두', unit: (u) => u.head, conv: (c) => c.head },
{ key: 'demand', label: '실제 수요량', unit: (u) => u.flow, conv: (c) => c.flow },
{ key: 'quality', label: '수질', unit: () => '', conv: () => (v) => v },
];
const LINK_VARS: VarDef[] = [
{ key: 'flow', label: '유량', unit: (u) => u.flow, conv: (c) => c.flow },
{ key: 'velocity', label: '유속', unit: (u) => u.velocity, conv: (c) => c.velocity },
{ key: 'headloss', label: '손실수두(총)', unit: (u) => u.head, conv: (c) => c.head },
{ key: 'quality', label: '수질', unit: () => '', conv: () => (v) => v },
];
function seriesOf(result: SimulationResult, item: SeriesItem, key: string): number[] {
const el = item.type === 'node' ? result.nodes[item.id] : result.links[item.id];
if (!el) return [];
const ts = (el as { timeSeries?: Record<string, number[] | undefined> }).timeSeries;
const s = ts?.[key];
if (s && s.length) return Array.from(s);
const v = (el as unknown as Record<string, number | undefined>)[key];
return v === undefined ? [] : [v];
}
// ---------------------------------------------------------------- multi time series
export const MultiSeriesPanel: React.FC<{
model: NetworkModel;
result: SimulationResult;
items: SeriesItem[];
setItems: (items: SeriesItem[]) => void;
selected: SeriesItem | null;
timeStepIndex: number;
onTimeStepIndexChange: (i: number) => void;
units: Converter;
displayUnits: DisplayUnits;
onFocus: (item: SeriesItem) => void;
}> = ({ model, result, items, setItems, selected, timeStepIndex, onTimeStepIndexChange, units, displayUnits, onFocus }) => {
const [kind, setKind] = useState<'node' | 'link'>(selected?.type ?? items[0]?.type ?? 'node');
const vars = kind === 'node' ? NODE_VARS : LINK_VARS;
const [varKey, setVarKey] = useState<string>(vars[0].key);
const v = vars.find((x) => x.key === varKey) ?? vars[0];
const [view, setView] = useState<'chart' | 'table'>('chart');
const [hidden, setHidden] = useState<Set<string>>(new Set());
const [idInput, setIdInput] = useState('');
const mine = items.filter((i) => i.type === kind);
// With nothing pinned, show the current map selection so the tab is never empty.
const shown = mine.length ? mine : selected && selected.type === kind ? [selected] : [];
const steps = result.timeSteps ?? [result.timeSeconds ?? 0];
const conv = v.conv(units);
const data = shown.map((it) => seriesOf(result, it, v.key).map(conv));
const add = (it: SeriesItem) => {
if (items.some((x) => x.type === it.type && x.id === it.id)) return;
setItems([...items, it]);
};
const addById = () => {
const id = idInput.trim();
if (!id) return;
if (kind === 'node' ? model.nodes[id] : model.pipes[id] || model.pumps[id] || model.valves?.[id]) add({ type: kind, id });
setIdInput('');
};
// chart geometry
const W = 320, H = 210, pl = 44, pr = 8, pt = 10, pb = 26;
const vis = data.filter((_, k) => !hidden.has(shown[k].id));
const all = vis.flat();
let lo = Math.min(...all), hi = Math.max(...all);
if (!Number.isFinite(lo)) { lo = 0; hi = 1; }
if (hi === lo) { lo -= 1; hi += 1; }
const tMax = Math.max(steps[steps.length - 1] ?? 0, 1);
const sx = (t: number) => pl + (t / tMax) * (W - pl - pr);
const sy = (y: number) => H - pb - ((y - lo) / (hi - lo)) * (H - pt - pb);
const step = tMax > 48 * 3600 ? 12 : tMax > 24 * 3600 ? 6 : 3;
const hourTicks: number[] = [];
for (let h = 0; h * 3600 <= tMax; h += step) hourTicks.push(h * 3600);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 'var(--fs-xs)' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{(['node', 'link'] as const).map((k) => (
<button key={k} className={`btn btn-secondary ${kind === k ? 'btn-primary' : ''}`} style={{ height: 22, fontSize: 11 }}
onClick={() => { setKind(k); setVarKey((k === 'node' ? NODE_VARS : LINK_VARS)[0].key); }}>
{k === 'node' ? '절점' : '링크'}
</button>
))}
<select value={v.key} onChange={(e) => setVarKey(e.target.value)} style={{ height: 22, fontSize: 11 }} aria-label="변수">
{vars.map((x) => <option key={x.key} value={x.key}>{x.label}</option>)}
</select>
<button className={`btn btn-secondary ${view === 'chart' ? 'btn-primary' : ''}`} style={{ height: 22, fontSize: 11 }} onClick={() => setView('chart')}>차트</button>
<button className={`btn btn-secondary ${view === 'table' ? 'btn-primary' : ''}`} style={{ height: 22, fontSize: 11 }} onClick={() => setView('table')}>테이블</button>
</div>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11 }} disabled={!selected || selected.type !== kind} onClick={() => selected && add(selected)}>
<Crosshair size={11} />선택 객체 추가
</button>
<input value={idInput} onChange={(e) => setIdInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addById()} placeholder={kind === 'node' ? '절점 ID' : '링크 ID'} style={{ width: 80, height: 22, fontSize: 11 }} />
<button className="btn btn-ghost" style={{ height: 22, padding: '0 4px' }} onClick={addById} aria-label="ID로 추가"><Plus size={12} /></button>
{mine.length > 0 && <button className="btn btn-ghost" style={{ height: 22, fontSize: 11 }} onClick={() => setItems(items.filter((i) => i.type !== kind))}>모두 지우기</button>}
</div>
{shown.length === 0 ? (
<div style={{ color: 'var(--text-secondary)', padding: 8 }}>지도에서 {kind === 'node' ? '절점' : '관로'}을 선택해 추가하면 여러 개를 한 그래프에 겹쳐 봅니다.</div>
) : view === 'chart' ? (
<>
<svg width={W} height={H} role="img" aria-label={`${v.label} 시계열`} style={{ border: '1px solid var(--border)', borderRadius: 4, background: 'var(--bg-panel)' }}
onClick={(e) => {
const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
const t = ((e.clientX - r.left - pl) / (W - pl - pr)) * tMax;
let best = 0;
steps.forEach((s, i) => { if (Math.abs(s - t) < Math.abs(steps[best] - t)) best = i; });
onTimeStepIndexChange(best);
}}>
{[0, 0.5, 1].map((f) => {
const y = lo + f * (hi - lo);
return <g key={f} fontSize={9} fill="#64748b"><line x1={pl} x2={W - pr} y1={sy(y)} y2={sy(y)} stroke="#e2e8f0" /><text x={pl - 4} y={sy(y) + 3} textAnchor="end">{fmt(y)}</text></g>;
})}
{hourTicks.map((t) => <text key={t} x={sx(t)} y={H - 10} fontSize={9} textAnchor="middle" fill="#64748b">{hhmm(t)}</text>)}
<line x1={sx(steps[timeStepIndex] ?? 0)} x2={sx(steps[timeStepIndex] ?? 0)} y1={pt} y2={H - pb} stroke="#94a3b8" strokeDasharray="3 3" />
{data.map((d, k) => hidden.has(shown[k].id) ? null : (
<path key={shown[k].id} d={d.map((y, i) => `${i ? 'L' : 'M'}${sx(steps[i] ?? 0).toFixed(1)},${sy(y).toFixed(1)}`).join('')} fill="none" stroke={COLORS[k % COLORS.length]} strokeWidth={1.8} />
))}
</svg>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{shown.map((it, k) => (
<label key={it.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<input type="checkbox" checked={!hidden.has(it.id)} onChange={() => { const n = new Set(hidden); n.has(it.id) ? n.delete(it.id) : n.add(it.id); setHidden(n); }} />
<span style={{ width: 10, height: 3, background: COLORS[k % COLORS.length], display: 'inline-block' }} />
<a onClick={() => onFocus(it)} style={{ cursor: 'pointer' }}>{it.id}</a>
{mine.length > 0 && <X size={10} style={{ cursor: 'pointer' }} onClick={() => setItems(items.filter((x) => !(x.type === it.type && x.id === it.id)))} aria-label={`${it.id} 제거`} />}
</label>
))}
<span style={{ color: 'var(--text-muted)' }}>{v.label} ({v.unit(displayUnits) || '-'}) · 그래프를 누르면 그 시각으로 이동</span>
</div>
</>
) : (
<div style={{ maxHeight: 300, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ borderCollapse: 'collapse', width: '100%', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
<thead><tr><th style={{ position: 'sticky', top: 0, background: 'var(--bg-subtle)', padding: '3px 6px', textAlign: 'left' }}>시각</th>
{shown.map((it) => <th key={it.id} style={{ position: 'sticky', top: 0, background: 'var(--bg-subtle)', padding: '3px 6px', textAlign: 'right' }}>{it.id}</th>)}</tr></thead>
<tbody>
{steps.map((t, i) => (
<tr key={i} style={{ background: i === timeStepIndex ? 'var(--accent-soft, #e0ecff)' : undefined, cursor: 'pointer' }} onClick={() => onTimeStepIndexChange(i)}>
<td style={{ padding: '2px 6px' }}>{hhmm(t)}</td>
{data.map((d, k) => <td key={k} style={{ padding: '2px 6px', textAlign: 'right' }}>{d[i] !== undefined ? fmt(d[i]) : '-'}</td>)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
// ---------------------------------------------------------------- frequency
interface FreqVar { key: string; label: string; kind: 'node' | 'link'; unit: (u: DisplayUnits) => string; get: (m: NetworkModel, r: SimulationResult | null, id: string, c: Converter, t: number | 'all') => number[] }
const dyn = (kind: 'node' | 'link', key: string, abs: boolean, conv: (c: Converter) => (v: number) => number) =>
(m: NetworkModel, r: SimulationResult | null, id: string, c: Converter, t: number | 'all') => {
if (!r) return [];
const s = seriesOf(r, { type: kind, id }, key).map((v) => conv(c)(abs ? Math.abs(v) : v));
return t === 'all' ? s : s[t] !== undefined ? [s[t]] : [];
};
const FREQ_VARS: FreqVar[] = [
{ key: 'pressure', label: '압력', kind: 'node', unit: (u) => u.pressure, get: dyn('node', 'pressure', false, (c) => c.pressure) },
{ key: 'head', label: '총수두', kind: 'node', unit: (u) => u.head, get: dyn('node', 'head', false, (c) => c.head) },
{ key: 'demand', label: '실제 수요량', kind: 'node', unit: (u) => u.flow, get: dyn('node', 'demand', false, (c) => c.flow) },
{ key: 'quality', label: '수질', kind: 'node', unit: () => '', get: dyn('node', 'quality', false, () => (v) => v) },
{ key: 'elevation', label: '표고', kind: 'node', unit: (u) => u.head, get: (m, _r, id, c) => [c.head(m.nodes[id]?.elevation ?? 0)] },
{ key: 'flow', label: '유량(절댓값)', kind: 'link', unit: (u) => u.flow, get: dyn('link', 'flow', true, (c) => c.flow) },
{ key: 'velocity', label: '유속(절댓값)', kind: 'link', unit: (u) => u.velocity, get: dyn('link', 'velocity', true, (c) => c.velocity) },
{ key: 'headloss', label: '손실수두(총)', kind: 'link', unit: (u) => u.head, get: dyn('link', 'headloss', true, (c) => c.head) },
{ key: 'length', label: '관로길이', kind: 'link', unit: (u) => u.length, get: (m, _r, id, c) => (m.pipes[id] ? [c.length(m.pipes[id].length)] : []) },
{ key: 'diameter', label: '관경', kind: 'link', unit: (u) => u.diameter, get: (m, _r, id, c) => (m.pipes[id] ? [c.diameter(m.pipes[id].diameter)] : []) },
{ key: 'roughness', label: '조도계수', kind: 'link', unit: () => '', get: (m, _r, id) => (m.pipes[id] ? [m.pipes[id].roughness] : []) },
];
export const FrequencyPanel: React.FC<{
model: NetworkModel;
result: SimulationResult | null;
timeStepIndex: number;
selectedNodes: string[];
selectedLinks: string[];
units: Converter;
displayUnits: DisplayUnits;
}> = ({ model, result, timeStepIndex, selectedNodes, selectedLinks, units, displayUnits }) => {
const [varKey, setVarKey] = useState('pressure');
const [time, setTime] = useState<'current' | 'all'>('current');
const [scope, setScope] = useState<'all' | 'selected'>('all');
const v = FREQ_VARS.find((x) => x.key === varKey)!;
const values = useMemo(() => {
const ids = scope === 'selected'
? (v.kind === 'node' ? selectedNodes : selectedLinks)
: v.kind === 'node'
? Object.values(model.nodes).filter((n) => n.type === 'JUNCTION').map((n) => n.id)
: Object.keys(model.pipes);
const t = time === 'all' ? 'all' : timeStepIndex;
return ids.flatMap((id) => v.get(model, result, id, units, t)).filter(Number.isFinite).sort((a, b) => a - b);
}, [v, model, result, time, scope, timeStepIndex, selectedNodes, selectedLinks, units]);
const W = 320, H = 210, pl = 40, pr = 10, pt = 10, pb = 30;
const lo = values[0] ?? 0;
const hi = values[values.length - 1] ?? 1;
const span = hi - lo || 1;
const sx = (x: number) => pl + ((x - lo) / span) * (W - pl - pr);
const sy = (p: number) => H - pb - (p / 100) * (H - pt - pb);
const pathD = values.map((x, i) => `${i ? 'L' : 'M'}${sx(x).toFixed(1)},${sy(((i + 1) / values.length) * 100).toFixed(1)}`).join('');
const pct = (q: number) => values[Math.min(values.length - 1, Math.floor(q * values.length))];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 'var(--fs-xs)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 6, alignItems: 'center' }}>
<span>변수</span>
<select value={varKey} onChange={(e) => setVarKey(e.target.value)} style={{ height: 22, fontSize: 11 }}>
<optgroup label="절점">{FREQ_VARS.filter((x) => x.kind === 'node').map((x) => <option key={x.key} value={x.key}>{x.label}</option>)}</optgroup>
<optgroup label="링크">{FREQ_VARS.filter((x) => x.kind === 'link').map((x) => <option key={x.key} value={x.key}>{x.label}</option>)}</optgroup>
</select>
<span>시간</span>
<select value={time} onChange={(e) => setTime(e.target.value as 'current' | 'all')} style={{ height: 22, fontSize: 11 }}>
<option value="current">현재 시간</option>
<option value="all">전체 시간</option>
</select>
<span>범위</span>
<select value={scope} onChange={(e) => setScope(e.target.value as 'all' | 'selected')} style={{ height: 22, fontSize: 11 }}>
<option value="all">전체 관망</option>
<option value="selected">선택 관망 ({v.kind === 'node' ? selectedNodes.length : selectedLinks.length})</option>
</select>
</div>
{values.length < 2 ? (
<div style={{ color: 'var(--text-secondary)' }}>값이 부족합니다{!result && ' (해석 결과가 필요한 변수입니다)'}.</div>
) : (
<>
<svg width={W} height={H} role="img" aria-label={`${v.label} 누적 빈도`} style={{ border: '1px solid var(--border)', borderRadius: 4, background: 'var(--bg-panel)' }}>
{[0, 25, 50, 75, 100].map((p) => <g key={p} fontSize={9} fill="#64748b"><line x1={pl} x2={W - pr} y1={sy(p)} y2={sy(p)} stroke="#e2e8f0" /><text x={pl - 4} y={sy(p) + 3} textAnchor="end">{p}%</text></g>)}
{[0, 0.5, 1].map((f) => <text key={f} x={sx(lo + f * span)} y={H - 14} fontSize={9} textAnchor="middle" fill="#64748b">{fmt(lo + f * span)}</text>)}
<text x={(W + pl) / 2} y={H - 3} fontSize={9} textAnchor="middle" fill="#64748b">{v.label} ({v.unit(displayUnits) || '-'})</text>
<path d={pathD} fill="none" stroke="#2563eb" strokeWidth={2} />
</svg>
<div style={{ color: 'var(--text-secondary)' }}>
값 {values.length.toLocaleString()}개 · 최소 {fmt(lo)} · 10% {fmt(pct(0.1))} · 중앙 {fmt(pct(0.5))} · 90% {fmt(pct(0.9))} · 최대 {fmt(hi)} {v.unit(displayUnits)}
</div>
</>
)}
</div>
);
};
+118
View File
@@ -0,0 +1,118 @@
import React from 'react';
import { inspectSimulation } from './convergence';
import { computeScenarioMetrics } from './resultSync';
import { parseInp } from './inpModel';
import { type Scenario } from './types';
export interface ScenarioBatchPanelProps {
scenarios: Scenario[];
activeScenarioId: string;
batchRunProgress: { done: number; total: number } | null;
handleBatchRunAllScenarios: () => Promise<void>;
handleSwitchScenario: (scenId: string) => void;
}
/** Batch re-analysis of every scenario and common-metric comparison (P2-4). */
export const ScenarioBatchPanel: React.FC<ScenarioBatchPanelProps> = ({
scenarios,
activeScenarioId,
batchRunProgress,
handleBatchRunAllScenarios,
handleSwitchScenario,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)' }}>
각 대안의 저장된 INP를 독립적으로 재해석하여 공통 지표로 비교합니다. (현재 편집 중인 모델에는 영향을 주지 않습니다.)
</span>
<button
onClick={handleBatchRunAllScenarios}
disabled={batchRunProgress !== null}
className="btn btn-primary"
style={{ height: 'var(--h-btn-sm)', padding: '0 var(--s-4)', fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' }}
>
{batchRunProgress
? `해석 중... (${batchRunProgress.done}/${batchRunProgress.total})`
: '전체 대안 일괄 재해석'}
</button>
</div>
<div style={{ overflowX: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)' }}>
<thead>
<tr style={{ background: 'var(--bg-subtle)', borderBottom: '1px solid var(--border-strong)' }}>
<th style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'left' }}>대안</th>
<th style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'center' }}>상태</th>
<th style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'right' }}>최저압력</th>
<th style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'right' }}>평균압력</th>
<th style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'right' }}>최대유속</th>
<th style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'right' }}>총수요량</th>
<th style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'center' }}>수렴</th>
</tr>
</thead>
<tbody>
{scenarios.map((s) => {
let metrics: ReturnType<typeof computeScenarioMetrics> | null = null;
let parseError = false;
if (s.result) {
try {
metrics = computeScenarioMetrics(parseInp(s.inpStr), s.result);
} catch {
parseError = true;
}
}
return (
<tr
key={s.id}
onClick={() => handleSwitchScenario(s.id)}
style={{
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: s.id === activeScenarioId ? 'var(--accent-wash)' : 'transparent',
}}
title="클릭하여 이 대안으로 전환"
>
<td style={{ padding: 'var(--s-2) var(--s-3)', fontWeight: 'var(--fw-semi)' }}>{s.name}</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'center' }}>
{parseError ? (
<span style={{ color: 'var(--crit)' }}>파싱 오류</span>
) : !s.result ? (
<span style={{ color: 'var(--text-muted)' }}>미해석</span>
) : (
<span style={{ color: 'var(--good)' }}>해석됨</span>
)}
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{metrics ? metrics.minPressure.toFixed(2) : '-'}
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{metrics ? metrics.avgPressure.toFixed(2) : '-'}
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{metrics ? metrics.maxVelocity.toFixed(2) : '-'}
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
{metrics ? metrics.totalDemand.toFixed(1) : '-'}
</td>
<td style={{ padding: 'var(--s-2) var(--s-3)', textAlign: 'center' }}>
{s.result ? (
inspectSimulation(s.result).hasWarning ? (
<span style={{ color: 'var(--warn)' }}>경고</span>
) : (
<span style={{ color: 'var(--good)' }}>정상</span>
)
) : (
'-'
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<span style={{ fontSize: 'var(--fs-2xs)', color: 'var(--text-muted)' }}>
지표는 결과가 표시된 시점(최초 해석 시각) 기준이며, 원본 EPANET 단위입니다. 행을 클릭하면 해당 대안으로 전환됩니다.
</span>
</div>
);
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import type { SessionSnapshot } from './sessionStore';
interface SessionRecoveryModalProps {
recoverySnapshot: SessionSnapshot;
handleRestoreSession: () => void;
handleDiscardSession: () => Promise<void> | void;
}
/** Asks whether to restore the last autosaved session (never overwrites silently). */
export const SessionRecoveryModal: React.FC<SessionRecoveryModalProps> = ({
recoverySnapshot,
handleRestoreSession,
handleDiscardSession,
}) => (
<div className="modal-overlay">
<div
className="modal-content"
role="dialog"
aria-modal="true"
aria-labelledby="session-recovery-title"
style={{ width: 'min(460px, calc(100vw - 32px))', padding: 'var(--s-6)' }}
>
<h3
id="session-recovery-title"
style={{ margin: '0 0 var(--s-4)', fontSize: 'var(--fs-lg)', color: 'var(--text)' }}
>
이전 작업을 복구하시겠습니까?
</h3>
<p style={{ margin: '0 0 var(--s-4)', color: 'var(--text-muted)', lineHeight: 1.6 }}>
{new Date(recoverySnapshot.savedAt).toLocaleString('ko-KR')}에 자동 저장된 모델이 있습니다.
자동으로 덮어쓰지 않으며, 복구 여부를 직접 선택할 수 있습니다.
</p>
<div
style={{
padding: 'var(--s-4)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
marginBottom: 'var(--s-5)',
fontSize: 'var(--fs-sm)',
lineHeight: 1.7,
}}
>
<div>파일: {recoverySnapshot.fileName ?? '이름 없는 모델'}</div>
<div>
절점 {Object.keys(recoverySnapshot.networkModel.nodes).length} · 관로{' '}
{Object.keys(recoverySnapshot.networkModel.pipes).length}
</div>
<div style={{ color: 'var(--text-muted)' }}>
{/* The v2 snapshot does store results (wasmResult/serverResult and
every scenario's result); the old text said it never did. */}
{recoverySnapshot.wasmResult || recoverySnapshot.serverResult
? `해석 결과와 대안 ${recoverySnapshot.scenarios?.length ?? 0}개도 함께 복구됩니다.`
: '저장 당시 해석 결과가 없었으므로 복구 후 해석을 실행해야 합니다.'}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--s-3)' }}>
<button className="btn btn-secondary" onClick={() => void handleDiscardSession()}>
저장본 삭제
</button>
<button className="btn btn-primary" onClick={handleRestoreSession} autoFocus>
이전 작업 복구
</button>
</div>
</div>
</div>
);
+131
View File
@@ -0,0 +1,131 @@
import React from 'react';
export type AutoSaveStatus = 'idle' | 'waiting' | 'saving' | 'saved' | 'error';
const AUTOSAVE_LABEL: Record<AutoSaveStatus, string> = {
idle: '준비',
waiting: '대기',
saving: '저장 중',
saved: '완료',
error: '실패',
};
interface StatusBarProps {
fileLabel: string;
crsLabel: string;
onFocusCrs: () => void;
flowUnit: string;
headUnit: string;
headlossFormula: string;
hasResult: boolean;
isResultStale: boolean;
lastAnalysisTimestamp: string | null;
showAutoSave: boolean;
autoSaveStatus: AutoSaveStatus;
autoSaveSavedAt: string | null;
nodeCount: number;
pipeCount: number;
cursor: { x: number; y: number } | null;
selectedNodes: number;
selectedLinks: number;
}
/** Bottom status line of the map pane (read-only summary of the session). */
export const StatusBar: React.FC<StatusBarProps> = ({
fileLabel,
crsLabel,
onFocusCrs,
flowUnit,
headUnit,
headlossFormula,
hasResult,
isResultStale,
lastAnalysisTimestamp,
showAutoSave,
autoSaveStatus,
autoSaveSavedAt,
nodeCount,
pipeCount,
cursor,
selectedNodes,
selectedLinks,
}) => (
<div
className="map-status-bar"
role="status"
aria-live="polite"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
fontSize: '11px',
overflowX: 'auto',
whiteSpace: 'nowrap',
}}
>
<span style={{ fontWeight: 600, color: 'var(--text)' }} title="현재 파일 / 시나리오">
{fileLabel}
</span>
<span
onClick={onFocusCrs}
style={{ cursor: 'pointer', textDecoration: 'underline', color: 'var(--accent)' }}
title="클릭하여 좌표계 선택으로 이동"
>
좌표계: {crsLabel}
</span>
<span>
단위: {flowUnit} / {headUnit} · 공식: {headlossFormula}
</span>
<span style={{ color: 'var(--text-muted)' }}>엔진: OWA-EPANET 2.3.5</span>
<span>
{!hasResult ? (
<span style={{ color: 'var(--text-muted)' }}>해석 안 함</span>
) : isResultStale ? (
<span
style={{
color: '#c2410c',
background: '#ffedd5',
border: '1px solid #fed7aa',
padding: '1px 6px',
borderRadius: '3px',
fontWeight: 700,
}}
title="모델이 수정되었습니다. 상단 '해석 실행'을 클릭하세요."
>
모델 변경됨 — 재해석 필요
</span>
) : (
<span style={{ color: 'var(--good)', fontWeight: 600 }}>
해석 완료{lastAnalysisTimestamp ? ` (${lastAnalysisTimestamp})` : ''}
</span>
)}
</span>
{showAutoSave && (
<span
data-testid="autosave-status"
style={{ color: autoSaveStatus === 'error' ? 'var(--danger)' : 'var(--text-muted)' }}
title={autoSaveSavedAt ? `마지막 자동 저장: ${new Date(autoSaveSavedAt).toLocaleString('ko-KR')}` : '변경 후 5초 뒤 IndexedDB에 저장'}
>
자동 저장: {AUTOSAVE_LABEL[autoSaveStatus] ?? '준비'}
</span>
)}
<span>
절점 {nodeCount} · 관로 {pipeCount}
</span>
<span style={{ fontFamily: 'var(--font-mono)' }} title="현재 마우스 위치 (현재 좌표계 기준)">
{cursor ? `X: ${cursor.x.toFixed(2)}, Y: ${cursor.y.toFixed(2)}` : 'X: -, Y: -'}
</span>
<span>
선택: 절점 {selectedNodes} · 관로 {selectedLinks}
</span>
</div>
);
+223
View File
@@ -0,0 +1,223 @@
import React from 'react';
import { Sliders, X, RotateCcw, Eye, ArrowRight } from 'lucide-react';
export interface MapStyleSettings {
nodeOpacity: number; // 0 ~ 100 (%)
nodeRadius: number; // 2 ~ 10 (px)
pipeOpacity: number; // 0 ~ 100 (%)
pipeWidth: number; // 1 ~ 5 (px)
showFlowArrows: boolean;
basemapOpacity: number; // 0 ~ 100 (%)
}
export const DEFAULT_MAP_STYLE_SETTINGS: MapStyleSettings = {
nodeOpacity: 100,
nodeRadius: 4.5,
pipeOpacity: 90,
pipeWidth: 2.0,
showFlowArrows: true,
basemapOpacity: 100,
};
interface StyleSettingsPanelProps {
isOpen: boolean;
onClose: () => void;
settings: MapStyleSettings;
onChange: (updater: (prev: MapStyleSettings) => MapStyleSettings) => void;
onReset: () => void;
}
export const StyleSettingsPanel: React.FC<StyleSettingsPanelProps> = ({
isOpen,
onClose,
settings,
onChange,
onReset,
}) => {
if (!isOpen) return null;
return (
<div
style={{
position: 'absolute',
top: '56px',
left: '56px',
zIndex: 100,
width: '320px',
background: 'var(--bg-panel)',
borderRadius: 'var(--r-md)',
boxShadow: 'var(--shadow-lg)',
border: '1px solid var(--border)',
overflow: 'hidden',
fontSize: 'var(--fs-xs)',
color: 'var(--text)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 'var(--s-3) var(--s-4)',
background: 'var(--bg-subtle)',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', fontWeight: 600, fontSize: 'var(--fs-sm)' }}>
<Sliders size={14} style={{ color: 'var(--accent)' }} aria-hidden="true" />
<span>지도 스타일 설정</span>
</div>
<button
onClick={onClose}
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
color: 'var(--text-muted)',
display: 'flex',
alignItems: 'center',
padding: '2px',
}}
title="닫기"
>
<X size={14} />
</button>
</div>
{/* Body */}
<div style={{ padding: 'var(--s-4)', display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
{/* Node Settings */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>절점 (Junction / Tank / Res)</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>투명도</span>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 500 }}>{settings.nodeOpacity}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={5}
value={settings.nodeOpacity}
onChange={(e) => {
const val = Number(e.target.value);
onChange((prev) => ({ ...prev, nodeOpacity: val }));
}}
style={{ width: '100%', cursor: 'pointer' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'var(--s-1)' }}>
<span>크기 (반경)</span>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 500 }}>{settings.nodeRadius} px</span>
</div>
<input
type="range"
min={2}
max={10}
step={0.5}
value={settings.nodeRadius}
onChange={(e) => {
const val = Number(e.target.value);
onChange((prev) => ({ ...prev, nodeRadius: val }));
}}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
<div style={{ height: '1px', background: 'var(--border)' }} />
{/* Pipe Settings */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>관로 (Pipe / Pump / Valve)</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>투명도</span>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 500 }}>{settings.pipeOpacity}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={5}
value={settings.pipeOpacity}
onChange={(e) => {
const val = Number(e.target.value);
onChange((prev) => ({ ...prev, pipeOpacity: val }));
}}
style={{ width: '100%', cursor: 'pointer' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'var(--s-1)' }}>
<span>굵기</span>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 500 }}>{settings.pipeWidth} px</span>
</div>
<input
type="range"
min={1}
max={5}
step={0.5}
value={settings.pipeWidth}
onChange={(e) => {
const val = Number(e.target.value);
onChange((prev) => ({ ...prev, pipeWidth: val }));
}}
style={{ width: '100%', cursor: 'pointer' }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', marginTop: 'var(--s-2)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={settings.showFlowArrows}
onChange={(e) => {
const checked = e.target.checked;
onChange((prev) => ({ ...prev, showFlowArrows: checked }));
}}
/>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--s-1)' }}>
<ArrowRight size={12} /> 유향 화살표 표시
</span>
</label>
</div>
<div style={{ height: '1px', background: 'var(--border)' }} />
{/* Basemap Opacity (Q3) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>배경 지도 (VWorld / OSM)</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>투명도</span>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 500 }}>{settings.basemapOpacity}%</span>
</div>
<input
type="range"
min={0}
max={100}
step={5}
value={settings.basemapOpacity}
onChange={(e) => {
const val = Number(e.target.value);
onChange((prev) => ({ ...prev, basemapOpacity: val }));
}}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
{/* Footer actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 'var(--s-2)' }}>
<button
onClick={onReset}
className="btn btn-secondary"
style={{ padding: 'var(--s-1) var(--s-3)', fontSize: 'var(--fs-xs)', display: 'inline-flex', alignItems: 'center', gap: 'var(--s-1)' }}
title="모든 스타일 설정을 기본값으로 되돌립니다"
>
<RotateCcw size={12} />
기본값 복원
</button>
</div>
</div>
</div>
);
};
+223
View File
@@ -0,0 +1,223 @@
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>
);
};
+207
View File
@@ -0,0 +1,207 @@
import React from 'react';
import { Activity, MousePointer, CircleDot, GitCommit, Magnet, ArrowRight, Trash2, Table, Layers, Scan, Sliders } from 'lucide-react';
import { type EditMode, type SimulationResult } from './types';
import { type InpNode } from './inpModel';
import { type BottomPanelTab } from './BottomPanel';
export interface ToolRailProps {
appMode: 'explore' | 'edit' | 'result';
setAppMode: React.Dispatch<React.SetStateAction<'explore' | 'edit' | 'result'>>;
editMode: EditMode;
setEditMode: React.Dispatch<React.SetStateAction<EditMode>>;
setNodeCreationType: React.Dispatch<React.SetStateAction<InpNode['type']>>;
isNodeTypeMenuOpen: boolean;
setIsNodeTypeMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
boxSelectionEnabled: boolean;
setBoxSelectionEnabled: React.Dispatch<React.SetStateAction<boolean>>;
snapEnabled: boolean;
setSnapEnabled: React.Dispatch<React.SetStateAction<boolean>>;
showFlowArrows: boolean;
setShowFlowArrows: React.Dispatch<React.SetStateAction<boolean>>;
isLayerPanelOpen: boolean;
setIsLayerPanelOpen: React.Dispatch<React.SetStateAction<boolean>>;
layerPanelWidth: number;
isStyleSettingsOpen: boolean;
setIsStyleSettingsOpen: React.Dispatch<React.SetStateAction<boolean>>;
bottomPanelTab: BottomPanelTab | null;
toggleBottomPanel: (tab: BottomPanelTab) => void;
selectionCounts: { nodes: number; links: number; gisFeatures: number; total: number };
handleDeleteSelected: () => void;
wasmResult: SimulationResult | null;
}
/** Left 44px tool rail: selection/edit tools, snapping, layer/table/style toggles (DESIGN.md § 3.1). */
export const ToolRail: React.FC<ToolRailProps> = ({
appMode,
setAppMode,
editMode,
setEditMode,
setNodeCreationType,
isNodeTypeMenuOpen,
setIsNodeTypeMenuOpen,
boxSelectionEnabled,
setBoxSelectionEnabled,
snapEnabled,
setSnapEnabled,
showFlowArrows,
setShowFlowArrows,
isLayerPanelOpen,
setIsLayerPanelOpen,
layerPanelWidth,
isStyleSettingsOpen,
setIsStyleSettingsOpen,
bottomPanelTab,
toggleBottomPanel,
selectionCounts,
handleDeleteSelected,
wasmResult,
}) => (
<aside className="tool-rail" title="도구 레일 (단축키: 1 탐색 / 2 편집 / 3 결과)">
<button
onClick={() => {
setIsLayerPanelOpen((v) => {
const next = !v;
localStorage.setItem('gispipe_layer_panel_open', String(next));
return next;
});
}}
className={`tool-btn ${isLayerPanelOpen ? 'active' : ''}`}
title="레이어 패널 토글 (단축키 [)"
>
<Layers size={16} />
</button>
<div className="tool-divider" />
<button
onClick={() => {
setEditMode('select');
setIsNodeTypeMenuOpen(false);
setBoxSelectionEnabled(false);
}}
className={`tool-btn ${editMode === 'select' && !boxSelectionEnabled ? 'active' : ''}`}
title="선택 / 이동 (단축키 V)"
>
<MousePointer size={16} />
</button>
<button
onClick={() => {
setEditMode('select');
setIsNodeTypeMenuOpen(false);
setBoxSelectionEnabled((enabled) => !enabled);
}}
className={`tool-btn ${boxSelectionEnabled ? 'active' : ''}`}
title="박스 선택: 좌→우 완전 포함, 우→좌 교차 선택"
>
<Scan size={16} />
</button>
<div className="tool-menu-anchor">
<button
onClick={() => setIsNodeTypeMenuOpen((open) => !open)}
className={`tool-btn ${appMode === 'edit' && editMode === 'add-node' ? 'active' : ''}`}
title="지도에 시설 추가"
aria-haspopup="menu"
aria-expanded={isNodeTypeMenuOpen}
>
<CircleDot size={16} />
</button>
{isNodeTypeMenuOpen && (
<div
className="tool-flyout"
role="menu"
aria-label="추가할 시설 선택"
style={{ left: isLayerPanelOpen ? 40 + layerPanelWidth : 40 }}
>
<div className="tool-flyout-title">추가할 시설</div>
{([
['JUNCTION', '절점', 'Junction'],
['RESERVOIR', '저수지', 'Reservoir'],
['TANK', '탱크', 'Tank'],
] as const).map(([type, label, sublabel]) => (
<button
key={type}
type="button"
role="menuitem"
className="tool-flyout-item"
onClick={() => {
setNodeCreationType(type);
setIsNodeTypeMenuOpen(false);
if (appMode !== 'edit') setAppMode('edit');
setEditMode('add-node');
setBoxSelectionEnabled(false);
}}
>
<CircleDot size={14} aria-hidden="true" />
<span>{label}</span>
<small>{sublabel}</small>
</button>
))}
</div>
)}
</div>
<button
onClick={() => {
if (appMode !== 'edit') setAppMode('edit');
setEditMode('add-pipe');
setIsNodeTypeMenuOpen(false);
setBoxSelectionEnabled(false);
}}
className={`tool-btn ${appMode === 'edit' && editMode === 'add-pipe' ? 'active' : ''}`}
title="관로 연결 (단축키 P)"
>
<GitCommit size={16} />
</button>
<button
onClick={() => setSnapEnabled((v) => !v)}
className={`tool-btn ${snapEnabled ? 'active' : ''}`}
title={`절점 자동 스냅 (${snapEnabled ? '켜짐' : '꺼짐'})`}
>
<Magnet size={16} />
</button>
<button
onClick={() => setShowFlowArrows((v) => !v)}
className={`tool-btn ${showFlowArrows ? 'active' : ''}`}
title={`유향 화살표 (${showFlowArrows ? '켜짐' : '꺼짐'})`}
>
<ArrowRight size={16} />
</button>
<button
onClick={() => setIsStyleSettingsOpen((v) => !v)}
className={`tool-btn ${isStyleSettingsOpen ? 'active' : ''}`}
title="지도 스타일 설정 (절점/관로 크기·투명도, 배경지도 투명도)"
>
<Sliders size={16} />
</button>
<button
onClick={() => toggleBottomPanel('table')}
className={`tool-btn ${bottomPanelTab === 'table' ? 'active' : ''}`}
title="요소 테이블 열기/닫기 (Ctrl+T)"
>
<Table size={16} />
</button>
{wasmResult?.timeSteps && wasmResult.timeSteps.length > 1 && (
<button
onClick={() => toggleBottomPanel('eps')}
className={`tool-btn ${bottomPanelTab === 'eps' ? 'active' : ''}`}
title="EPS 시간 컨트롤러 열기/닫기"
>
<Activity size={16} />
</button>
)}
<div className="tool-divider" />
{selectionCounts.total > 0 && (
<button
onClick={handleDeleteSelected}
className="tool-btn"
style={{ color: 'var(--crit)' }}
title={`선택 요소 ${selectionCounts.total}개 삭제 (Delete 키)`}
>
<Trash2 size={16} />
</button>
)}
</aside>
);
+187
View File
@@ -0,0 +1,187 @@
import React from 'react';
import { CheckCircle2, Settings } from 'lucide-react';
import { type TopologyIssue } from './inpModel';
import { type SelectionTarget } from './types';
import type { MapFocusRequest } from './Map';
export interface TopologyPanelProps {
topologyIssues: TopologyIssue[];
hydraulicViolations: { lowPressureNodes: Array<{ id: string; pressure: number }>; highVelocityLinks: Array<{ id: string; velocity: number }> };
error: string | null;
minPressureThreshold: number;
setMinPressureThreshold: (value: number) => void;
maxVelocityThreshold: number;
setMaxVelocityThreshold: (value: number) => void;
requestMapFocus: (request: Omit<MapFocusRequest, 'id'>) => void;
selectPathToSource: (nodeId: string) => void;
setSelectedTarget: React.Dispatch<React.SetStateAction<SelectionTarget>>;
setActiveTab: React.Dispatch<React.SetStateAction<import('./types').RightTab | null>>;
showToast: (msg: string) => void;
}
/** Topology issues and hydraulic criteria violations with navigation (verification tab). */
export const TopologyPanel: React.FC<TopologyPanelProps> = ({
topologyIssues,
hydraulicViolations,
error,
minPressureThreshold,
setMinPressureThreshold,
maxVelocityThreshold,
setMaxVelocityThreshold,
requestMapFocus,
selectPathToSource,
setSelectedTarget,
setActiveTab,
showToast,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)' }}>
<h4 style={{ margin: 0, fontSize: 'var(--fs-md)', color: 'var(--text)' }}>위상 무결성 검증 리포트</h4>
{topologyIssues.length === 0 ? (
<div style={{ padding: 'var(--s-4)', background: 'var(--good-wash)', color: 'var(--good)', borderRadius: 'var(--r-sm)', border: '1px solid var(--good)', fontSize: 'var(--fs-sm)', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<CheckCircle2 size={14} aria-hidden="true" />
발견된 위상 오류가 없습니다. (해석 가능한 상태)
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-3)' }}>
{topologyIssues.map((issue, idx) => (
<div
key={idx}
onClick={() => {
if (issue.target) {
setSelectedTarget(issue.target);
setActiveTab('inspector');
}
}}
style={{
padding: 'var(--s-4) var(--s-5)',
borderRadius: 'var(--r-sm)',
fontSize: 'var(--fs-sm)',
background: issue.type === 'error' ? 'var(--crit-wash)' : 'var(--warn-wash)',
border: issue.type === 'error' ? '1px solid var(--crit)' : '1px solid var(--warn)',
color: issue.type === 'error' ? 'var(--crit)' : 'var(--warn)',
cursor: issue.target ? 'pointer' : 'default',
}}
>
<div>{issue.message}</div>
{issue.target && (
<div style={{ fontSize: 'var(--fs-xs)', textDecoration: 'underline', marginTop: 'var(--s-1)' }}>
클릭하여 대상({issue.target.type} {issue.target.id}) 확인
</div>
)}
</div>
))}
</div>
)}
{/* Criteria Threshold Settings: always visible so the user can find and
adjust the judgment values, independent of whether any violation exists. */}
<div
style={{
marginTop: 'var(--s-4)',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 'var(--s-4)',
padding: 'var(--s-3) var(--s-4)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
fontSize: 'var(--fs-xs)',
}}
>
<strong style={{ color: 'var(--text)' }}>판정 기준</strong>
<label
style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}
title="상수도시설기준 급수관 분기점 최소동수압: 150 kPa ≒ 1.53 kgf/cm² ≒ 15.3 mAq (반올림 15 m). 필요 시 현장 기준에 맞춰 조정하세요."
>
최저 수압 기준(≥)
<input
type="number"
step="0.5"
min={0}
value={minPressureThreshold}
onChange={(e) => setMinPressureThreshold(Number(e.target.value) || 0)}
style={{ width: '64px', padding: '2px 6px', border: '1px solid var(--border-strong)', borderRadius: '4px' }}
/>
m
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
최고 유속 기준(≤)
<input
type="number"
step="0.1"
min={0}
value={maxVelocityThreshold}
onChange={(e) => setMaxVelocityThreshold(Number(e.target.value) || 0)}
style={{ width: '64px', padding: '2px 6px', border: '1px solid var(--border-strong)', borderRadius: '4px' }}
/>
m/s
</label>
<span style={{ color: 'var(--text-muted)' }}>
기본값 15 m는 상수도시설기준 최소동수압(150 kPa ≒ 1.53 kgf/cm²)을 따릅니다.
</span>
</div>
{/* Criteria Violations Section */}
{(hydraulicViolations.lowPressureNodes.length > 0 || hydraulicViolations.highVelocityLinks.length > 0) && (
<div style={{ marginTop: 'var(--s-4)', display: 'flex', flexDirection: 'column', gap: 'var(--s-3)' }}>
<h4 style={{ margin: 0, fontSize: 'var(--fs-md)', color: 'var(--text)' }}>
시설기준 미달 요소 목록 ({hydraulicViolations.lowPressureNodes.length + hydraulicViolations.highVelocityLinks.length}건)
</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 'var(--s-2)' }}>
{hydraulicViolations.lowPressureNodes.map((n) => (
<div
key={`low-p-${n.id}`}
onClick={() => {
setSelectedTarget({ type: 'node', id: n.id });
requestMapFocus({ target: { type: 'node', id: n.id } });
selectPathToSource(n.id);
showToast(`저수압 절점 [${n.id}]로 이동했습니다 (수원까지 경로 표시).`);
}}
style={{
padding: 'var(--s-3) var(--s-4)',
borderRadius: 'var(--r-sm)',
fontSize: 'var(--fs-xs)',
background: 'var(--crit-wash)',
border: '1px solid var(--crit)',
color: 'var(--crit)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
title="클릭하여 지도 위치로 이동"
>
<span><strong>저수압 절점 [{n.id}]</strong></span>
<span style={{ fontFamily: 'var(--font-mono)' }}>수압 {n.pressure.toFixed(1)} m (기준: &ge; {minPressureThreshold} m)</span>
</div>
))}
{hydraulicViolations.highVelocityLinks.map((l) => (
<div
key={`high-v-${l.id}`}
onClick={() => {
setSelectedTarget({ type: 'link', id: l.id });
requestMapFocus({ target: { type: 'link', id: l.id } });
showToast(`과속 관로 [${l.id}]로 이동했습니다.`);
}}
style={{
padding: 'var(--s-3) var(--s-4)',
borderRadius: 'var(--r-sm)',
fontSize: 'var(--fs-xs)',
background: 'var(--warn-wash)',
border: '1px solid var(--warn)',
color: 'var(--warn)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
title="클릭하여 지도 위치로 이동"
>
<span><strong>과속 관로 [{l.id}]</strong></span>
<span style={{ fontFamily: 'var(--font-mono)' }}>유속 {l.velocity.toFixed(2)} m/s (기준: &le; {maxVelocityThreshold} m/s)</span>
</div>
))}
</div>
</div>
)}
</div>
);
+277
View File
@@ -0,0 +1,277 @@
// Pure analyses on a model + result for the 분석 tab: flow-direction changes,
// interpolated contours (IDW + marching squares) and the pipe statistics report.
import type { NetworkModel } from './inpModel';
import type { SimulationResult } from './types';
// ---------------------------------------------------------------- flow direction
export interface FlowDirectionRow {
id: string;
/** Number of sign changes of the flow over the run (near-zero flows ignored). */
reversals: number;
/** % of non-zero periods flowing against the dominant direction. */
reversedShare: number;
/** +1 node1→node2, -1 the other way, 0 no flow at all. */
dominant: 1 | -1 | 0;
maxAbsFlow: number;
}
export function analyzeFlowDirection(result: SimulationResult, relEps = 0.001): FlowDirectionRow[] {
const rows: FlowDirectionRow[] = [];
for (const [id, link] of Object.entries(result.links)) {
const series = link.timeSeries?.flow?.length ? link.timeSeries.flow : [link.flow];
const maxAbs = series.reduce((a, q) => Math.max(a, Math.abs(q)), 0);
const eps = Math.max(1e-9, relEps * maxAbs);
let pos = 0;
let neg = 0;
let last = 0;
let reversals = 0;
for (const q of series) {
const s = q > eps ? 1 : q < -eps ? -1 : 0;
if (s === 0) continue;
if (s > 0) pos++;
else neg++;
if (last !== 0 && s !== last) reversals++;
last = s;
}
const dominant: 1 | -1 | 0 = pos === 0 && neg === 0 ? 0 : pos >= neg ? 1 : -1;
const nonzero = pos + neg;
rows.push({ id, reversals, reversedShare: nonzero ? (100 * Math.min(pos, neg)) / nonzero : 0, dominant, maxAbsFlow: maxAbs });
}
return rows.sort((a, b) => b.reversals - a.reversals || b.reversedShare - a.reversedShare);
}
// ---------------------------------------------------------------- contours
export interface ScatterPoint {
x: number;
y: number;
v: number;
}
export interface Grid {
x0: number;
y0: number;
dx: number;
dy: number;
nx: number;
ny: number;
/** Row-major [j * nx + i]; NaN = outside the network (masked). */
values: Float64Array;
}
/**
* Inverse-distance-weighted grid over the points' bounding box. Cells farther
* than `maskFactor` × the typical point spacing from every point are masked,
* so contours stay inside the network instead of filling its convex hull.
*/
export function idwGrid(points: ScatterPoint[], maxCells = 120, power = 2, maskFactor = 2.5): Grid {
const pts = points.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.v));
if (pts.length < 3) throw new Error('값이 있는 절점이 3개 이상 필요합니다.');
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
}
const w = maxX - minX || 1;
const h = maxY - minY || 1;
const cell = Math.max(w, h) / (maxCells - 1);
const nx = Math.max(2, Math.round(w / cell) + 1);
const ny = Math.max(2, Math.round(h / cell) + 1);
// Typical spacing = median nearest-neighbour distance (a sample on big models).
const step = Math.max(1, Math.floor(pts.length / 1500));
const nn: number[] = [];
for (let a = 0; a < pts.length; a += step) {
let best = Infinity;
for (let b = 0; b < pts.length; b++) {
if (b === a) continue;
const d2 = (pts[a].x - pts[b].x) ** 2 + (pts[a].y - pts[b].y) ** 2;
if (d2 > 0 && d2 < best) best = d2;
}
if (Number.isFinite(best)) nn.push(Math.sqrt(best));
}
nn.sort((a, b) => a - b);
const spacing = nn.length ? nn[Math.floor(nn.length / 2)] : Math.max(w, h);
const mask2 = (maskFactor * Math.max(spacing, cell)) ** 2;
const values = new Float64Array(nx * ny);
for (let j = 0; j < ny; j++) {
const y = minY + j * cell;
for (let i = 0; i < nx; i++) {
const x = minX + i * cell;
let num = 0, den = 0, near = Infinity, exact = NaN;
for (const p of pts) {
const d2 = (p.x - x) ** 2 + (p.y - y) ** 2;
if (d2 < near) near = d2;
if (d2 < 1e-18) { exact = p.v; break; }
const wgt = 1 / Math.pow(d2, power / 2);
num += wgt * p.v;
den += wgt;
}
values[j * nx + i] = !Number.isNaN(exact) ? exact : near > mask2 ? NaN : num / den;
}
}
return { x0: minX, y0: minY, dx: cell, dy: cell, nx, ny, values };
}
/** Round-number contour levels covering [min, max] at `interval`. */
export function contourLevels(min: number, max: number, interval: number): number[] {
if (!(interval > 0) || !Number.isFinite(min) || !Number.isFinite(max)) return [];
const out: number[] = [];
for (let v = Math.ceil(min / interval) * interval; v <= max + 1e-9 && out.length < 500; v += interval) out.push(Math.round(v * 1e6) / 1e6);
return out;
}
/** A sensible interval giving ~8-12 levels. */
export function niceInterval(min: number, max: number): number {
const span = max - min;
if (!(span > 0)) return 1;
const raw = span / 10;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const n = raw / mag;
return (n < 1.5 ? 1 : n < 3.5 ? 2 : n < 7.5 ? 5 : 10) * mag;
}
type Seg = [[number, number], [number, number]];
/** Marching squares: line segments of the `level` isoline (masked cells skipped). */
export function isolineSegments(g: Grid, level: number): Seg[] {
const segs: Seg[] = [];
const at = (i: number, j: number) => g.values[j * g.nx + i];
const X = (i: number) => g.x0 + i * g.dx;
const Y = (j: number) => g.y0 + j * g.dy;
const lerp = (a: number, b: number) => (level - a) / (b - a);
for (let j = 0; j < g.ny - 1; j++) {
for (let i = 0; i < g.nx - 1; i++) {
const v0 = at(i, j), v1 = at(i + 1, j), v2 = at(i + 1, j + 1), v3 = at(i, j + 1);
if ([v0, v1, v2, v3].some(Number.isNaN)) continue;
const code = (v0 >= level ? 1 : 0) | (v1 >= level ? 2 : 0) | (v2 >= level ? 4 : 0) | (v3 >= level ? 8 : 0);
if (code === 0 || code === 15) continue;
// Edge crossing points: bottom (0-1), right (1-2), top (3-2), left (0-3).
const b: [number, number] = [X(i) + lerp(v0, v1) * g.dx, Y(j)];
const r: [number, number] = [X(i + 1), Y(j) + lerp(v1, v2) * g.dy];
const t: [number, number] = [X(i) + lerp(v3, v2) * g.dx, Y(j + 1)];
const l: [number, number] = [X(i), Y(j) + lerp(v0, v3) * g.dy];
const center = (v0 + v1 + v2 + v3) / 4 >= level;
switch (code) {
case 1: case 14: segs.push([l, b]); break;
case 2: case 13: segs.push([b, r]); break;
case 3: case 12: segs.push([l, r]); break;
case 4: case 11: segs.push([r, t]); break;
case 6: case 9: segs.push([b, t]); break;
case 7: case 8: segs.push([l, t]); break;
case 5: if (center) { segs.push([l, t], [b, r]); } else { segs.push([l, b], [r, t]); } break;
case 10: if (center) { segs.push([l, b], [r, t]); } else { segs.push([l, t], [b, r]); } break;
}
}
}
return segs;
}
/** Join touching segments into polylines (fewer, smoother features). */
export function joinSegments(segs: Seg[]): Array<Array<[number, number]>> {
const key = (p: [number, number]) => `${p[0].toFixed(6)},${p[1].toFixed(6)}`;
const ends = new Map<string, number[]>();
segs.forEach((s, k) => {
for (const p of s) {
const id = key(p);
const list = ends.get(id);
if (list) list.push(k);
else ends.set(id, [k]);
}
});
const used = new Uint8Array(segs.length);
const lines: Array<Array<[number, number]>> = [];
const extend = (line: Array<[number, number]>, atEnd: boolean) => {
for (;;) {
const tip = atEnd ? line[line.length - 1] : line[0];
const next = (ends.get(key(tip)) ?? []).find((k) => !used[k]);
if (next === undefined) return;
used[next] = 1;
const [a, b] = segs[next];
const other = key(a) === key(tip) ? b : a;
if (atEnd) line.push(other);
else line.unshift(other);
}
};
segs.forEach((s, k) => {
if (used[k]) return;
used[k] = 1;
const line: Array<[number, number]> = [s[0], s[1]];
extend(line, true);
extend(line, false);
lines.push(line);
});
return lines;
}
// ---------------------------------------------------------------- pipe report
export type PipeGroupKey = 'diameter' | 'material' | 'installDecade' | 'block' | 'pipeKind' | 'roughness';
export interface PipeGroupRow {
key: string;
count: number;
length: number;
share: number;
/** Highest |velocity| of the group over the run (result needed). */
maxVelocity?: number;
/** Mean age in years from installYear (assets needed). */
meanAge?: number;
}
/** Group label of a pipe for the report / calibration groups. */
export function pipeGroupKey(model: NetworkModel, pipeId: string, by: PipeGroupKey): string {
const p = model.pipes[pipeId];
const a = model.assets?.links[pipeId] ?? {};
const yr = Number(a.installYear);
switch (by) {
case 'diameter': return String(p.diameter);
case 'roughness': return String(p.roughness);
case 'installDecade': return Number.isFinite(yr) && yr > 1800 ? `${Math.floor(yr / 10) * 10}년대` : '(미입력)';
default: return a[by] !== undefined && String(a[by]).trim() !== '' ? String(a[by]) : '(미입력)';
}
}
/** Pipe IDs per group ('all' = one group). */
export function pipeGroups(model: NetworkModel, by: PipeGroupKey | 'all'): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const id of Object.keys(model.pipes)) (out[by === 'all' ? '전체' : pipeGroupKey(model, id, by)] ??= []).push(id);
return out;
}
export function pipeReport(model: NetworkModel, by: PipeGroupKey, result?: SimulationResult | null, year = new Date().getFullYear()): { rows: PipeGroupRow[]; totalLength: number; count: number } {
const groups = new Map<string, { count: number; length: number; vmax: number; hasV: boolean; ageSum: number; ageN: number }>();
let total = 0;
const pipes = Object.values(model.pipes);
for (const p of pipes) {
const a = model.assets?.links[p.id] ?? {};
const yr = Number(a.installYear);
const key = pipeGroupKey(model, p.id, by);
const g = groups.get(key) ?? { count: 0, length: 0, vmax: 0, hasV: false, ageSum: 0, ageN: 0 };
g.count++;
g.length += p.length;
total += p.length;
const lr = result?.links[p.id];
if (lr) {
const vs = lr.timeSeries?.velocity?.length ? lr.timeSeries.velocity : [lr.velocity];
for (const v of vs) g.vmax = Math.max(g.vmax, Math.abs(v));
g.hasV = true;
}
if (Number.isFinite(yr) && yr > 1800 && yr <= year) {
g.ageSum += year - yr;
g.ageN++;
}
groups.set(key, g);
}
const rows = [...groups.entries()].map(([key, g]) => ({
key,
count: g.count,
length: g.length,
share: total > 0 ? (100 * g.length) / total : 0,
maxVelocity: g.hasV ? g.vmax : undefined,
meanAge: g.ageN ? g.ageSum / g.ageN : undefined,
}));
const numeric = by === 'diameter' || by === 'roughness';
rows.sort((a, b) => (numeric ? Number(a.key) - Number(b.key) : a.key === '(미입력)' ? 1 : b.key === '(미입력)' ? -1 : a.key.localeCompare(b.key, 'ko')));
return { rows, totalLength: total, count: pipes.length };
}
+198
View File
@@ -0,0 +1,198 @@
import { SimulationResult } from './types';
import { decodeColumnarResult, isColumnarResult } from './columnar';
// Slightly above the backend's SPIPE_ANALYZE_TIMEOUT_S (120 s) so the server's
// own 504 message normally wins; this only catches a hung connection.
const ANALYZE_TIMEOUT_MS = 150_000;
export async function analyzeOnServer(fileStr: string): Promise<SimulationResult> {
const formData = new FormData();
// Wrap string into a Blob
formData.append('file', new Blob([fileStr], { type: 'text/plain' }), 'net.inp');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ANALYZE_TIMEOUT_MS);
let res: Response;
try {
// Compact packed time series (~40% smaller, full float64 precision);
// decoded back into the regular SimulationResult below.
res = await fetch('/api/analyze?format=columnar', {
method: 'POST',
body: formData,
signal: controller.signal,
});
} catch (err) {
if (controller.signal.aborted) {
throw new Error(`서버 해석이 ${ANALYZE_TIMEOUT_MS / 1000}초 안에 끝나지 않았습니다.`);
}
throw err;
} finally {
clearTimeout(timer);
}
if (!res.ok) {
const text = await res.text();
throw new Error(`Server returned ${res.status}: ${text}`);
}
const raw: unknown = await res.json();
const data: unknown = isColumnarResult(raw) ? decodeColumnarResult(raw) : raw;
if (!isSimulationResult(data)) {
throw new Error('서버 응답 형식이 올바르지 않습니다 (nodes/links/units 누락).');
}
return data;
}
// Minimal shape check so a changed/partial server response fails here with a
// clear message instead of deep inside rendering code.
function isSimulationResult(data: unknown): data is SimulationResult {
if (!data || typeof data !== 'object') return false;
const d = data as Record<string, unknown>;
return (
typeof d.nodes === 'object' && d.nodes !== null &&
typeof d.links === 'object' && d.links !== null &&
typeof d.units === 'object' && d.units !== null &&
typeof d.engine === 'object' && d.engine !== null
);
}
// Batch analyses (backend/jobs.py). Slightly above SPIPE_JOB_TIMEOUT_S (900 s).
const JOB_TIMEOUT_MS = 960_000;
/** POST /api/jobs/{name} with the given INP text and params; throws a readable error. */
export async function runJob<T>(name: string, inp: string, params: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
const form = new FormData();
form.append('file', new Blob([inp], { type: 'text/plain' }), 'net.inp');
form.append('params', JSON.stringify(params));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), JOB_TIMEOUT_MS);
signal?.addEventListener('abort', () => controller.abort());
let res: Response;
try {
res = await fetch(`/api/jobs/${encodeURIComponent(name)}`, { method: 'POST', body: form, signal: controller.signal });
} catch (err) {
if (controller.signal.aborted) throw new Error(signal?.aborted ? '분석을 취소했습니다.' : '분석 시간이 초과되었습니다.');
throw new Error('해석 서버에 연결할 수 없습니다. 서버가 켜져 있는지 확인하세요.');
} finally {
clearTimeout(timer);
}
if (!res.ok) {
let detail = `${res.status}`;
try {
const body = await res.json();
detail = typeof body?.detail === 'string' ? body.detail : JSON.stringify(body?.detail ?? body);
} catch {
detail = await res.text().catch(() => detail);
}
throw new Error(detail);
}
return (await res.json()) as T;
}
async function jsonOrThrow<T>(res: Response): Promise<T> {
if (!res.ok) {
let detail = `${res.status}`;
try {
const body = await res.json();
detail = typeof body?.detail === 'string' ? body.detail : JSON.stringify(body?.detail ?? body);
} catch {
/* keep status */
}
throw new Error(detail);
}
return (await res.json()) as T;
}
async function demFetch(url: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(url, init);
} catch {
throw new Error('해석 서버에 연결할 수 없습니다. 서버가 켜져 있는지 확인하세요.');
}
}
// ---------------------------------------------------------------- GIS layers
export interface VectorReadResult {
layers: string[];
layer: string;
crs: string | null;
reprojected: boolean;
crsAssumed?: boolean;
geometryType: string | null;
count: number;
fields: string[];
featureCollection: import('geojson').FeatureCollection;
}
/** Any GDAL vector file -> WGS84 GeoJSON; `defaultCrs` is used when the file has no CRS. */
export async function readVectorOnServer(file: File, defaultCrs?: string | null, layer?: string): Promise<VectorReadResult> {
const form = new FormData();
form.append('file', file, file.name);
if (layer) form.append('layer', layer);
if (defaultCrs) form.append('defaultCrs', defaultCrs);
return jsonOrThrow<VectorReadResult>(await demFetch('/api/gis/vector', { method: 'POST', body: form }));
}
async function postJson<T>(url: string, body: unknown): Promise<T> {
return jsonOrThrow<T>(await demFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }));
}
export const gisOp = (op: string, params: Record<string, unknown>) => postJson<import('geojson').FeatureCollection>('/api/gis/op', { op, ...params });
export const reprojectLayer = (featureCollection: import('geojson').FeatureCollection, from: string, to: string) =>
postJson<import('geojson').FeatureCollection>('/api/gis/reproject', { featureCollection, from, to });
/** Download a layer as SHP (zip) / GPKG / DXF / GeoJSON / KML in `crs`. */
export async function exportLayerFile(featureCollection: import('geojson').FeatureCollection, crs: string | null, format: string, name: string): Promise<void> {
const res = await demFetch('/api/gis/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ featureCollection, crs, format, name }) });
if (!res.ok) await jsonOrThrow(res);
const blob = await res.blob();
const cd = res.headers.get('content-disposition') ?? '';
const m = /filename\*=UTF-8''([^;]+)/.exec(cd);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = m ? decodeURIComponent(m[1]) : `${name}.${format === 'shp' ? 'zip' : format}`;
a.click();
URL.revokeObjectURL(a.href);
}
// ---------------------------------------------------------------- DEM
export interface DemInfo {
id: string;
name: string;
crs: string;
width: number;
height: number;
resolution: [number, number];
bounds: [number, number, number, number];
corners: [number, number][];
min: number | null;
max: number | null;
}
export async function uploadDem(file: File): Promise<DemInfo> {
const form = new FormData();
form.append('file', file, file.name);
return jsonOrThrow<DemInfo>(await demFetch('/api/dem', { method: 'POST', body: form }));
}
export const demPreviewUrl = (id: string) => `/api/dem/${id}/preview.png`;
/** DEM values at [x, y] points given in `crs` (proj4 / EPSG); null outside the DEM. */
export async function sampleDem(id: string, points: [number, number][], crs: string): Promise<(number | null)[]> {
const res = await demFetch(`/api/dem/${id}/sample`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ points, crs }) });
return (await jsonOrThrow<{ values: (number | null)[] }>(res)).values;
}
/** 유역·하천: stream lines, or catchments of pour points (WGS84 GeoJSON). */
export async function demHydro(id: string, body: { op: 'streams'; minAreaKm2: number } | { op: 'watershed'; points: [number, number][]; crs: string; snap?: number }) {
const res = await demFetch(`/api/dem/${id}/hydro`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return jsonOrThrow<import('geojson').FeatureCollection>(res);
}
export async function demFromPoints(points: [number, number, number][], crs: string, cell: number, name: string, targetCrs?: string): Promise<DemInfo> {
const res = await demFetch('/api/dem/from-points', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ points, crs, cell, name, targetCrs }) });
return jsonOrThrow<DemInfo>(res);
}
+78
View File
@@ -0,0 +1,78 @@
// Asset attributes that are not part of EPANET's INP (pipe material, install
// year, burial depth, small block, customer number, management number ...).
// Kept on NetworkModel.assets keyed by element ID, carried through renames,
// deletes, project files and GIS/SHP/Excel exports. Values are free-form.
export type AssetValue = string | number;
export type AssetRecord = Record<string, AssetValue>;
export interface AssetStore {
nodes: Record<string, AssetRecord>;
links: Record<string, AssetRecord>;
}
/** Well-known keys (anything else is kept as-is). */
export const ASSET_FIELDS: Array<{ key: string; label: string; applies: 'node' | 'link' | 'both' }> = [
{ key: 'mgmtNo', label: '관리번호', applies: 'both' },
{ key: 'material', label: '관재질', applies: 'link' },
{ key: 'installYear', label: '설치년도', applies: 'both' },
{ key: 'depth', label: '매설깊이(m)', applies: 'both' },
{ key: 'block', label: '소블록', applies: 'both' },
{ key: 'customerId', label: '수용가번호', applies: 'node' },
{ key: 'pipeKind', label: '관로 구분', applies: 'link' }, // 도·송수 / 배수 / 급수
];
export function emptyAssets(): AssetStore {
return { nodes: {}, links: {} };
}
function renameKey<T>(rec: Record<string, T>, oldId: string, newId: string): Record<string, T> {
if (!(oldId in rec)) return rec;
const out: Record<string, T> = {};
for (const [k, v] of Object.entries(rec)) out[k === oldId ? newId : k] = v;
return out;
}
function dropKeys<T>(rec: Record<string, T>, ids: Set<string>): Record<string, T> {
if (![...ids].some((id) => id in rec)) return rec;
const out: Record<string, T> = {};
for (const [k, v] of Object.entries(rec)) if (!ids.has(k)) out[k] = v;
return out;
}
export function renameAssetNode(a: AssetStore | undefined, oldId: string, newId: string): AssetStore | undefined {
return a && { ...a, nodes: renameKey(a.nodes, oldId, newId) };
}
export function renameAssetLink(a: AssetStore | undefined, oldId: string, newId: string): AssetStore | undefined {
return a && { ...a, links: renameKey(a.links, oldId, newId) };
}
export function dropAssets(a: AssetStore | undefined, nodeIds: Set<string>, linkIds: Set<string>): AssetStore | undefined {
return a && { nodes: dropKeys(a.nodes, nodeIds), links: dropKeys(a.links, linkIds) };
}
/** Immutable update; an empty value removes the key. */
export function withAsset(a: AssetStore | undefined, kind: 'node' | 'link', id: string, key: string, value: AssetValue | '' | null): AssetStore {
const base = a ?? emptyAssets();
const bucket = kind === 'node' ? base.nodes : base.links;
const rec = { ...(bucket[id] ?? {}) };
if (value === '' || value === null || (typeof value === 'number' && !Number.isFinite(value))) delete rec[key];
else rec[key] = value;
const nextBucket = { ...bucket };
if (Object.keys(rec).length) nextBucket[id] = rec;
else delete nextBucket[id];
return kind === 'node' ? { ...base, nodes: nextBucket } : { ...base, links: nextBucket };
}
/** Every key present anywhere in a bucket (for table columns / exports). */
export function assetKeys(bucket: Record<string, AssetRecord>): string[] {
const keys = new Set<string>();
for (const rec of Object.values(bucket)) for (const k of Object.keys(rec)) keys.add(k);
const known = ASSET_FIELDS.map((f) => f.key).filter((k) => keys.has(k));
return [...known, ...[...keys].filter((k) => !known.includes(k)).sort()];
}
export function assetLabel(key: string): string {
return ASSET_FIELDS.find((f) => f.key === key)?.label ?? key;
}
+213
View File
@@ -0,0 +1,213 @@
import type { NetworkModel } from './inpModel';
export interface ColumnDef {
key: string;
label: string;
readOnly?: boolean;
isNumeric?: boolean;
width?: string;
align?: 'left' | 'right' | 'center';
description?: string;
}
/**
* Which physical-unit conversion (if any) a junction/pipe column's raw model
* value needs for Korean/metric display. The single source of truth for
* "elevation is a head-length quantity, diameter is a pipe-diameter
* quantity, etc" -- shared by every editor (bottom attribute table, full
* attribute table) so they never independently decide the wrong conversion
* for the same column key.
*/
export const UNIT_FIELD_KINDS: Record<string, 'head' | 'demand' | 'length' | 'diameter'> = {
elevation: 'head',
invertElev: 'head',
demand: 'demand',
length: 'length',
diameter: 'diameter',
};
export const JUNCTION_COLUMNS: ColumnDef[] = [
{ key: 'id', label: '절점 ID', width: '85px', align: 'left', description: 'EPANET 절점 고유 식별자' },
{ key: 'type', label: '시설 유형', readOnly: true, width: '85px', align: 'center', description: 'EPANET 시설 유형 (JUNCTION)' },
{ key: 'elevation', label: '지반고 (m)', isNumeric: true, width: '95px', align: 'right', description: '화면 표시 단위는 m이며 원본 모델 값은 변경하지 않습니다.' },
{ key: 'demand', label: '기본수요량 (m³/day)', isNumeric: true, width: '120px', align: 'right', description: '화면 표시 단위는 m³/day이며 원본 EPANET 유량 단위로 저장됩니다.' },
{ key: 'pattern', label: '수요 패턴', width: '85px', align: 'left', description: '시간변화 수요 패턴 ID' },
{ key: 'x', label: 'X 좌표', isNumeric: true, width: '100px', align: 'right', description: '모델 X 좌표' },
{ key: 'y', label: 'Y 좌표', isNumeric: true, width: '100px', align: 'right', description: '모델 Y 좌표' },
{ key: 'tag', label: '태그', width: '80px', align: 'left', description: '사용자 태그' },
{ key: 'invertElev', label: '관저고 (m)', isNumeric: true, width: '95px', align: 'right', description: '지자체 설계 보조 필드' },
];
export const PIPE_COLUMNS: ColumnDef[] = [
{ key: 'id', label: '관로 ID', width: '85px', align: 'left', description: 'EPANET 관로 고유 식별자' },
{ key: 'node1', label: '시작절점', width: '100px', align: 'left', description: 'EPANET Node1. 실제 흐름 방향은 해석 유량 부호에 따라 달라질 수 있습니다.' },
{ key: 'node2', label: '끝절점', width: '100px', align: 'left', description: 'EPANET Node2. 실제 흐름 방향은 해석 유량 부호에 따라 달라질 수 있습니다.' },
{ key: 'length', label: '관로길이 (m)', isNumeric: true, width: '100px', align: 'right', description: '화면 표시 단위는 m이며 원본 모델 단위로 저장됩니다.' },
{ key: 'diameter', label: '관경 (mm)', isNumeric: true, width: '95px', align: 'right', description: '화면 표시 단위는 mm이며 원본 모델 단위로 저장됩니다.' },
{ key: 'roughness', label: '조도계수', isNumeric: true, width: '90px', align: 'right', description: '마찰손실식에 따른 조도계수' },
{ key: 'minorLoss', label: '미소손실계수', isNumeric: true, width: '100px', align: 'right', description: '국부손실계수 K' },
{ key: 'status', label: '상태', width: '80px', align: 'center', description: '개폐 상태 (Open / Closed / CV)' },
{ key: 'bulkCoeff', label: '수체반응계수', isNumeric: true, width: '105px', align: 'right', description: 'EPANET [REACTIONS] BULK 관로별 재정의 값. 반응 차수에 따라 단위가 달라질 수 있으며 빈 값은 전역값을 따릅니다.' },
{ key: 'wallCoeff', label: '관벽반응계수', isNumeric: true, width: '105px', align: 'right', description: 'EPANET [REACTIONS] WALL 관로별 재정의 값. 반응 차수에 따라 단위가 달라질 수 있으며 빈 값은 전역값을 따릅니다.' },
{ key: 'tag', label: '태그', width: '80px', align: 'left', description: 'EPANET [TAGS] LINK 태그' },
{ key: 'description', label: '설명', width: '140px', align: 'left', description: 'PIPES 행의 세미콜론 뒤 설명' },
];
export const RESERVOIR_COLUMNS: ColumnDef[] = [
{ key: 'id', label: 'ID', width: '90px', align: 'left', description: '저수지/배수지 고유 식별자' },
{ key: 'head', label: '총수두 (Head)', isNumeric: true, width: '100px', align: 'right', description: '상수원 총수두 (Elevation + Pressure Head)' },
{ key: 'pattern', label: 'Head Pattern', width: '100px', align: 'left', description: '수두 변화 시간 패턴 ID' },
{ key: 'initQuality', label: '초기수질', isNumeric: true, readOnly: true, width: '90px', align: 'right', description: '초기 수질 농도' },
{ key: 'sourceQuality', label: 'Source Quality', readOnly: true, width: '105px', align: 'left', description: '상수원 수질 조건' },
{ key: 'tag', label: 'Tag', width: '80px', align: 'left', description: '사용자 태그' },
{ key: 'x', label: 'X', isNumeric: true, width: '105px', align: 'right', description: 'X 좌표' },
{ key: 'y', label: 'Y', isNumeric: true, width: '105px', align: 'right', description: 'Y 좌표' },
];
export const TANK_COLUMNS: ColumnDef[] = [
{ key: 'id', label: 'ID', width: '85px', align: 'left', description: '탱크 고유 식별자' },
{ key: 'elevation', label: 'Elevation', isNumeric: true, width: '95px', align: 'right', description: '바닥 지반 표고' },
{ key: 'initLevel', label: 'Initial Level', isNumeric: true, width: '95px', align: 'right', description: '초기 수위' },
{ key: 'minLevel', label: 'Minimum Level', isNumeric: true, width: '95px', align: 'right', description: '최저 허용 수위' },
{ key: 'maxLevel', label: 'Maximum Level', isNumeric: true, width: '95px', align: 'right', description: '최고 허용 수위' },
{ key: 'diameter', label: 'Diameter', isNumeric: true, width: '90px', align: 'right', description: '탱크 직경' },
{ key: 'minVol', label: 'Min Volume', isNumeric: true, readOnly: true, width: '90px', align: 'right', description: '최저 저수용량' },
{ key: 'volCurve', label: 'Volume Curve', readOnly: true, width: '95px', align: 'left', description: '수위-용량 곡선 ID' },
{ key: 'overflow', label: 'Overflow', readOnly: true, width: '80px', align: 'center', description: '월류 허용 여부' },
{ key: 'initQuality', label: '초기수질', isNumeric: true, readOnly: true, width: '85px', align: 'right', description: '탱크 초기 수질' },
{ key: 'tag', label: 'Tag', width: '80px', align: 'left', description: '사용자 태그' },
{ key: 'x', label: 'X', isNumeric: true, width: '100px', align: 'right', description: 'X 좌표' },
{ key: 'y', label: 'Y', isNumeric: true, width: '100px', align: 'right', description: 'Y 좌표' },
];
export const PUMP_COLUMNS: ColumnDef[] = [
{ key: 'id', label: 'ID', width: '85px', align: 'left', description: '펌프 고유 식별자' },
{ key: 'node1', label: '상류절점 (Node1)', width: '110px', align: 'left', description: '흡입측 상류 절점' },
{ key: 'node2', label: '하류절점 (Node2)', width: '110px', align: 'left', description: '토출측 하류 절점' },
{ key: 'parameters', label: 'Pump Curve', width: '120px', align: 'left', description: '펌프 특성 곡선 (HEAD Curve ID 또는 POWER/SPEED)' },
// pattern/status/tag/description are not yet parsed into any structured
// pump field (status lives only in the raw-preserved [STATUS] section, and
// GISPipe never parses a per-pump pattern/tag/description at all), so
// editing them here would silently do nothing -- read-only until that
// parsing exists.
{ key: 'pattern', label: 'Pattern', width: '85px', align: 'left', readOnly: true, description: '가동 스케줄 패턴 ID (읽기 전용: 현재 미지원)' },
{ key: 'status', label: 'Status', width: '80px', align: 'center', readOnly: true, description: '상태 (읽기 전용: [STATUS] 섹션에서 직접 편집)' },
{ key: 'tag', label: 'Tag', width: '80px', align: 'left', readOnly: true, description: '사용자 태그 (읽기 전용: 현재 미지원)' },
{ key: 'description', label: '설명', width: '120px', align: 'left', readOnly: true, description: '펌프 상세 설명 (읽기 전용: 현재 미지원)' },
];
export const VALVE_COLUMNS: ColumnDef[] = [
{ key: 'id', label: 'ID', readOnly: true, width: '85px', align: 'left', description: '밸브 고유 식별자' },
{ key: 'node1', label: '상류절점 (Node1)', width: '110px', align: 'left', description: '유입측 상류 절점' },
{ key: 'node2', label: '하류절점 (Node2)', width: '110px', align: 'left', description: '유출측 하류 절점' },
{ key: 'valveType', label: 'Type', width: '80px', align: 'center', description: '밸브 유형 (PRV, PSV, PBV, FCV, TCV, GPV, PCV)' },
{ key: 'setting', label: 'Setting', isNumeric: true, width: '95px', align: 'right', description: '설정값 (압력, 유량, 손실계수)' },
{ key: 'diameter', label: 'Diameter', isNumeric: true, width: '90px', align: 'right', description: '밸브 구경 (mm / in)' },
{ key: 'minorLoss', label: 'MinorLoss', isNumeric: true, width: '90px', align: 'right', description: '미소손실계수' },
{ key: 'status', label: 'Status', width: '80px', align: 'center', description: '상태 (None / Open / Closed)' },
{ key: 'tag', label: 'Tag', width: '80px', align: 'left', description: '사용자 태그' },
{ key: 'description', label: '설명', width: '120px', align: 'left', description: '밸브 상세 설명' },
];
export interface PasteDiffItem {
rowId: string;
field: string;
oldValue: any;
newValue: any;
parsedValue?: any;
isValid: boolean;
error?: string;
}
export function validateSingleField(
targetType: 'junction' | 'pipe',
field: string,
rawVal: any,
rowId: string,
model: NetworkModel
): { valid: boolean; parsedValue?: any; error?: string } {
const strVal = String(rawVal ?? '').trim();
if (field === 'type') {
return { valid: true, parsedValue: 'JUNCTION' };
}
if (field === 'id') {
if (!strVal) return { valid: false, error: 'ID는 필수입니다.' };
if (strVal !== rowId) {
if (targetType === 'junction' && model.nodes[strVal]) {
return { valid: false, error: `이미 존재하는 절점 ID입니다: ${strVal}` };
}
if (
targetType === 'pipe' &&
(model.pipes[strVal] || model.pumps[strVal] || model.valves?.[strVal])
) {
return { valid: false, error: `이미 존재하는 관로 ID입니다: ${strVal}` };
}
}
return { valid: true, parsedValue: strVal };
}
if (targetType === 'junction') {
if (field === 'elevation' || field === 'invertElev' || field === 'demand' || field === 'x' || field === 'y') {
const n = parseFloat(strVal);
if (isNaN(n)) return { valid: false, error: '유효한 숫자를 입력하세요.' };
return { valid: true, parsedValue: n };
}
if (field === 'pattern') {
return { valid: true, parsedValue: strVal || undefined };
}
}
if (targetType === 'pipe') {
if (field === 'node1' || field === 'node2') {
if (!strVal) return { valid: false, error: '연결 절점 ID는 필수입니다.' };
if (!model.nodes[strVal]) {
return { valid: false, error: `존재하지 않는 절점 ID입니다: ${strVal}` };
}
const pipe = model.pipes[rowId];
if (pipe) {
const otherNode = field === 'node1' ? pipe.node2 : pipe.node1;
if (strVal === otherNode) {
return { valid: false, error: '시작과 끝 절점은 달라야 합니다 (자기순환 불가).' };
}
}
return { valid: true, parsedValue: strVal };
}
if (field === 'length') {
const n = parseFloat(strVal);
if (isNaN(n) || n <= 0) return { valid: false, error: '연장은 0보다 큰 숫자여야 합니다.' };
return { valid: true, parsedValue: n };
}
if (field === 'diameter') {
const n = parseFloat(strVal);
if (isNaN(n) || n <= 0) return { valid: false, error: '관경은 0보다 큰 숫자여야 합니다.' };
return { valid: true, parsedValue: n };
}
if (field === 'roughness') {
const n = parseFloat(strVal);
if (isNaN(n) || n <= 0) return { valid: false, error: '조도계수는 0보다 큰 숫자여야 합니다.' };
return { valid: true, parsedValue: n };
}
if (field === 'minorLoss') {
const n = parseFloat(strVal);
if (isNaN(n) || n < 0) return { valid: false, error: '미소손실은 0 이상의 숫자여야 합니다.' };
return { valid: true, parsedValue: n };
}
if (field === 'bulkCoeff' || field === 'wallCoeff') {
if (!strVal) return { valid: true, parsedValue: undefined };
const n = Number(strVal);
if (!Number.isFinite(n)) return { valid: false, error: '반응계수는 숫자 또는 빈 값이어야 합니다.' };
return { valid: true, parsedValue: n };
}
if (field === 'status') {
const upper = strVal.toUpperCase();
if (!['OPEN', 'CLOSED', 'CV'].includes(upper)) {
return { valid: false, error: '상태는 Open, Closed, CV 중 하나여야 합니다.' };
}
const formatted = upper === 'OPEN' ? 'Open' : upper === 'CLOSED' ? 'Closed' : 'CV';
return { valid: true, parsedValue: formatted };
}
}
return { valid: true, parsedValue: strVal };
}
+69
View File
@@ -0,0 +1,69 @@
// 자동 백업: versioned INP (+ asset attributes) copies of the model kept in a
// separate IndexedDB, so a bad edit hours ago can still be undone after the
// autosave (which keeps only the latest state) has overwritten it.
import type { AssetStore } from './assets';
const DB_NAME = 'spipe-backups';
const STORE = 'backups';
export const MAX_BACKUPS = 30;
export interface BackupRecord {
id: string;
savedAt: string;
fileName: string;
reason: 'auto' | 'manual';
inp: string;
assets?: AssetStore;
nodes: number;
links: number;
}
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('백업 저장소를 열 수 없습니다.'));
});
}
async function withStore<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T> | void): Promise<T | undefined> {
const db = await open();
try {
return await new Promise<T | undefined>((resolve, reject) => {
const tx = db.transaction(STORE, mode);
const req = fn(tx.objectStore(STORE));
tx.oncomplete = () => resolve(req ? req.result : undefined);
tx.onerror = () => reject(tx.error ?? new Error('백업 저장소 오류'));
tx.onabort = () => reject(tx.error ?? new Error('백업 저장 중단'));
});
} finally {
db.close();
}
}
export async function listBackups(): Promise<BackupRecord[]> {
const all = ((await withStore<BackupRecord[]>('readonly', (s) => s.getAll())) ?? []) as BackupRecord[];
return all.sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1));
}
/** Save a backup unless it is identical to the newest one; trims to MAX_BACKUPS. */
export async function saveBackup(rec: Omit<BackupRecord, 'id' | 'savedAt'>): Promise<boolean> {
const existing = await listBackups();
const last = existing[0];
if (last && last.inp === rec.inp && JSON.stringify(last.assets ?? null) === JSON.stringify(rec.assets ?? null)) return false;
const savedAt = new Date().toISOString();
await withStore('readwrite', (s) => {
s.put({ ...rec, id: `${savedAt}_${Math.random().toString(36).slice(2, 7)}`, savedAt });
for (const old of existing.slice(MAX_BACKUPS - 1)) s.delete(old.id);
});
return true;
}
export async function deleteBackup(id: string): Promise<void> {
await withStore('readwrite', (s) => {
s.delete(id);
});
}
+151
View File
@@ -0,0 +1,151 @@
// 소블록 고립확인: for every small block, the links that cross its boundary.
// A well-formed 블록 has a single open inflow point (usually metered); every
// other boundary connection should be closed (EPANET models gate valves as a
// pipe's CLOSED status, so open boundary pipes are counted, not judged).
import type { NetworkModel } from './inpModel';
import type { SimulationResult } from './types';
import type { FeatureCollection, Geometry, Position } from 'geojson';
export interface BoundaryLink {
id: string;
kind: 'pipe' | 'valve' | 'pump';
/** The neighbouring block ('' = node without a block). */
otherBlock: string;
closed: boolean;
/** Largest |flow| over the result (model units), if a result is given. */
maxFlow?: number;
/** Net direction relative to the block over the result. */
direction?: 'in' | 'out' | 'both' | 'none';
}
export type BlockStatus = 'single-inlet' | 'multi-open' | 'closed-off';
export interface BlockReport {
block: string;
nodeCount: number;
hasSource: boolean;
boundary: BoundaryLink[];
openCount: number;
openPipes: number;
activeFlows: number;
status: BlockStatus;
}
const FLOW_EPS = 1e-6;
export function blocksFromAssets(model: NetworkModel, key = 'block'): Record<string, string> {
const out: Record<string, string> = {};
for (const [id, rec] of Object.entries(model.assets?.nodes ?? {})) {
const v = rec[key];
if (v !== undefined && String(v).trim()) out[id] = String(v).trim();
}
return out;
}
function ringContains(ring: Position[], x: number, y: number): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi || 1e-300) + xi) inside = !inside;
}
return inside;
}
function polygonContains(rings: Position[][], x: number, y: number): boolean {
if (!rings.length || !ringContains(rings[0], x, y)) return false;
for (let h = 1; h < rings.length; h++) if (ringContains(rings[h], x, y)) return false; // hole
return true;
}
function geometryContains(g: Geometry | null, x: number, y: number): boolean {
if (!g) return false;
if (g.type === 'Polygon') return polygonContains(g.coordinates, x, y);
if (g.type === 'MultiPolygon') return g.coordinates.some((p) => polygonContains(p, x, y));
if (g.type === 'GeometryCollection') return g.geometries.some((s) => geometryContains(s, x, y));
return false;
}
/**
* Block of each node from a polygon layer (WGS84 GeoJSON). `toLngLat` converts
* model coordinates; `nameField` picks the block name (feature index if empty).
*/
export function blocksFromPolygons(
model: NetworkModel,
fc: FeatureCollection,
toLngLat: (x: number, y: number) => [number, number],
nameField?: string
): Record<string, string> {
const polys = fc.features
.map((f, i) => ({ f, name: String((nameField && f.properties?.[nameField]) ?? f.properties?.name ?? `블록${i + 1}`) }))
.filter(({ f }) => f.geometry && (f.geometry.type === 'Polygon' || f.geometry.type === 'MultiPolygon'));
const out: Record<string, string> = {};
for (const n of Object.values(model.nodes)) {
const [lon, lat] = toLngLat(n.x, n.y);
const hit = polys.find(({ f }) => geometryContains(f.geometry, lon, lat));
if (hit) out[n.id] = hit.name;
}
return out;
}
function linkClosed(model: NetworkModel, id: string, own?: string): boolean {
return (model.extras?.statusOverrides[id] ?? own ?? '').toUpperCase() === 'CLOSED';
}
export function checkBlockIsolation(model: NetworkModel, blockOf: Record<string, string>, result?: SimulationResult | null): BlockReport[] {
const reports = new Map<string, BlockReport>();
const get = (b: string) => {
let r = reports.get(b);
if (!r) {
r = { block: b, nodeCount: 0, hasSource: false, boundary: [], openCount: 0, openPipes: 0, activeFlows: 0, status: 'closed-off' };
reports.set(b, r);
}
return r;
};
for (const [id, b] of Object.entries(blockOf)) {
const node = model.nodes[id];
if (!node) continue;
const r = get(b);
r.nodeCount++;
if (node.type !== 'JUNCTION') r.hasSource = true;
}
const links: Array<{ id: string; kind: BoundaryLink['kind']; a: string; b: string; own?: string }> = [
...Object.values(model.pipes).map((p) => ({ id: p.id, kind: 'pipe' as const, a: p.node1, b: p.node2, own: p.status })),
...Object.values(model.valves || {}).map((v) => ({ id: v.id, kind: 'valve' as const, a: v.node1, b: v.node2, own: v.status })),
...Object.values(model.pumps).map((p) => ({ id: p.id, kind: 'pump' as const, a: p.node1, b: p.node2 })),
];
for (const l of links) {
const ba = blockOf[l.a] ?? '';
const bb = blockOf[l.b] ?? '';
if (ba === bb) continue;
const series = result?.links[l.id]?.timeSeries?.flow ?? (result?.links[l.id] ? [result.links[l.id].flow] : undefined);
const closed = linkClosed(model, l.id, l.own);
// Flow is positive node1 -> node2; seen from block X it is inflow when X owns node2.
for (const [mine, other, sign] of [[ba, bb, -1], [bb, ba, 1]] as const) {
if (!mine) continue;
const entry: BoundaryLink = { id: l.id, kind: l.kind, otherBlock: other, closed };
if (series) {
const signed = series.map((q) => q * sign);
entry.maxFlow = Math.max(...series.map((q) => Math.abs(q)));
const anyIn = signed.some((q) => q > FLOW_EPS);
const anyOut = signed.some((q) => q < -FLOW_EPS);
entry.direction = anyIn && anyOut ? 'both' : anyIn ? 'in' : anyOut ? 'out' : 'none';
}
get(mine).boundary.push(entry);
}
}
for (const r of reports.values()) {
const open = r.boundary.filter((x) => !x.closed);
r.openCount = open.length;
r.openPipes = open.filter((x) => x.kind === 'pipe').length;
r.activeFlows = open.filter((x) => x.direction && x.direction !== 'none').length;
r.status = r.openCount === 0 ? 'closed-off' : r.openCount === 1 ? 'single-inlet' : 'multi-open';
}
return [...reports.values()].sort((a, b) => a.block.localeCompare(b.block, 'ko', { numeric: true }));
}
export const BLOCK_STATUS_LABEL: Record<BlockStatus, string> = {
'single-inlet': '단일 유입 (정상)',
'multi-open': '다중 연결 (고립 미흡)',
'closed-off': '경계 모두 폐쇄 (공급 확인)',
};
+224
View File
@@ -0,0 +1,224 @@
// Calibration against field measurements, following EPANET's calibration
// file + calibration report conventions.
//
// File format (EPANET .dat, also accepted as .csv/.txt):
// ;Location Time Value <- ';' starts a comment
// N1 0 52.3 <- Time in decimal hours or h:mm[:ss]
// 6 48.1 <- a line without a location continues
// N2 1:30 61.0 the previous location
// Separators may be spaces, tabs or commas. Values are read in the units
// currently shown on screen (display units).
import type { SimulationResult } from './types';
export type CalibrationParam = 'pressure' | 'head' | 'demand' | 'quality' | 'flow' | 'velocity';
export const CALIBRATION_PARAMS: Array<{ id: CalibrationParam; label: string; element: 'node' | 'link' }> = [
{ id: 'pressure', label: '수압', element: 'node' },
{ id: 'head', label: '수두', element: 'node' },
{ id: 'demand', label: '수요량', element: 'node' },
{ id: 'quality', label: '수질', element: 'node' },
{ id: 'flow', label: '유량', element: 'link' },
{ id: 'velocity', label: '유속', element: 'link' },
];
export interface Observation {
location: string;
timeSec: number;
value: number;
}
export interface ParseResult {
observations: Observation[];
/** 1-based line numbers that could not be read. */
skippedLines: number[];
}
/** "6", "6.5", "1:30", "1:30:15" -> seconds. NaN if unreadable. */
export function parseTimeToSeconds(token: string): number {
if (token.includes(':')) {
const parts = token.split(':').map(Number);
if (parts.some((p) => !Number.isFinite(p))) return NaN;
const [h, m = 0, s = 0] = parts;
return h * 3600 + m * 60 + s;
}
const hours = Number(token);
return Number.isFinite(hours) ? hours * 3600 : NaN;
}
export function parseCalibrationFile(text: string): ParseResult {
const observations: Observation[] = [];
const skippedLines: number[] = [];
let current = '';
text.split(/\r?\n/).forEach((raw, i) => {
const line = raw.replace(/;.*$/, '').trim();
if (!line) return;
const tokens = line.split(/[\s,]+/).filter(Boolean);
let location: string;
let timeTok: string;
let valueTok: string;
if (tokens.length >= 3) {
[location, timeTok, valueTok] = tokens;
} else if (tokens.length === 2 && current) {
location = current;
[timeTok, valueTok] = tokens;
} else {
skippedLines.push(i + 1);
return;
}
const timeSec = parseTimeToSeconds(timeTok);
const value = Number(valueTok);
// A header row such as "ID,Time,Value" lands here and is skipped.
if (!Number.isFinite(timeSec) || !Number.isFinite(value)) {
skippedLines.push(i + 1);
return;
}
current = location;
observations.push({ location, timeSec, value });
});
return { observations, skippedLines };
}
/** Linear interpolation of a reported series at time t; null outside the simulated period. */
export function interpolateAt(timeSteps: number[], values: number[], t: number): number | null {
const n = Math.min(timeSteps.length, values.length);
if (n === 0 || t < timeSteps[0] || t > timeSteps[n - 1]) return null;
for (let i = 0; i < n - 1; i++) {
const t0 = timeSteps[i];
const t1 = timeSteps[i + 1];
if (t >= t0 && t <= t1) {
if (t1 === t0) return values[i + 1];
return values[i] + ((values[i + 1] - values[i]) * (t - t0)) / (t1 - t0);
}
}
return values[n - 1];
}
export interface CalibrationPoint {
location: string;
timeSec: number;
observed: number;
computed: number;
}
export interface LocationStats {
location: string;
count: number;
observedMean: number;
computedMean: number;
meanError: number;
rmsError: number;
}
export interface CalibrationReport {
param: CalibrationParam;
points: CalibrationPoint[];
locations: LocationStats[];
network: LocationStats;
/** EPANET "Correlation Between Means" (observed vs computed location means). */
correlationOfMeans: number | null;
/** Correlation over every observed/computed pair. */
correlationAll: number | null;
/** Locations in the file that are not elements of the right kind in the model. */
unknownLocations: string[];
/** Observations outside the simulated period. */
outOfRange: number;
}
function pearson(xs: number[], ys: number[]): number | null {
const n = xs.length;
if (n < 2) return null;
const mx = xs.reduce((a, b) => a + b, 0) / n;
const my = ys.reduce((a, b) => a + b, 0) / n;
let sxy = 0, sxx = 0, syy = 0;
for (let i = 0; i < n; i++) {
sxy += (xs[i] - mx) * (ys[i] - my);
sxx += (xs[i] - mx) ** 2;
syy += (ys[i] - my) ** 2;
}
if (sxx === 0 || syy === 0) return null;
return sxy / Math.sqrt(sxx * syy);
}
function stats(location: string, pts: CalibrationPoint[]): LocationStats {
const n = pts.length;
const obsMean = pts.reduce((a, p) => a + p.observed, 0) / n;
const compMean = pts.reduce((a, p) => a + p.computed, 0) / n;
const meanError = pts.reduce((a, p) => a + Math.abs(p.computed - p.observed), 0) / n;
const rmsError = Math.sqrt(pts.reduce((a, p) => a + (p.computed - p.observed) ** 2, 0) / n);
return { location, count: n, observedMean: obsMean, computedMean: compMean, meanError, rmsError };
}
/**
* Compares observations with the simulation. `toDisplay` converts a raw
* engine value of `param` into the display units the observations are in.
*/
export function buildCalibrationReport(
observations: Observation[],
param: CalibrationParam,
result: SimulationResult,
toDisplay: (v: number) => number
): CalibrationReport {
const element = CALIBRATION_PARAMS.find((p) => p.id === param)!.element;
const timeSteps = result.timeSteps ?? [0];
const points: CalibrationPoint[] = [];
const unknown = new Set<string>();
let outOfRange = 0;
for (const obs of observations) {
let series: number[] | undefined;
let single: number | undefined;
if (element === 'node') {
const node = result.nodes[obs.location];
if (!node) { unknown.add(obs.location); continue; }
series = node.timeSeries?.[param as 'pressure' | 'head' | 'demand' | 'quality'];
single = param === 'quality' ? node.quality : node[param as 'pressure' | 'head' | 'demand'];
} else {
const link = result.links[obs.location];
if (!link) { unknown.add(obs.location); continue; }
series = link.timeSeries?.[param as 'flow' | 'velocity'];
single = link[param as 'flow' | 'velocity'];
}
const raw = series && series.length > 0 ? interpolateAt(timeSteps, series, obs.timeSec) : (single ?? null);
if (raw === null || raw === undefined) { outOfRange++; continue; }
// Flow sign is only the direction relative to the link; meters read magnitude.
const computed = toDisplay(param === 'flow' || param === 'velocity' ? Math.abs(raw) : raw);
points.push({ location: obs.location, timeSec: obs.timeSec, observed: obs.value, computed });
}
const byLoc = new Map<string, CalibrationPoint[]>();
for (const p of points) {
if (!byLoc.has(p.location)) byLoc.set(p.location, []);
byLoc.get(p.location)!.push(p);
}
const locations = [...byLoc.entries()].map(([loc, pts]) => stats(loc, pts));
return {
param,
points,
locations,
network: points.length ? stats('전체', points) : { location: '전체', count: 0, observedMean: 0, computedMean: 0, meanError: 0, rmsError: 0 },
correlationOfMeans: pearson(locations.map((l) => l.observedMean), locations.map((l) => l.computedMean)),
correlationAll: pearson(points.map((p) => p.observed), points.map((p) => p.computed)),
unknownLocations: [...unknown],
outOfRange,
};
}
/** Plain-text report in the layout of EPANET's calibration report. */
export function formatCalibrationReport(r: CalibrationReport, paramLabel: string, unit: string): string {
const f = (v: number) => v.toFixed(3).padStart(10);
const lines = [
`보정 리포트 - ${paramLabel} (${unit})`,
'',
`${'위치'.padEnd(14)}${'관측 수'.padStart(8)}${'관측 평균'.padStart(11)}${'계산 평균'.padStart(11)}${'평균 오차'.padStart(11)}${'RMS 오차'.padStart(11)}`,
'-'.repeat(68),
...r.locations.map((l) => `${l.location.padEnd(14)}${String(l.count).padStart(8)}${f(l.observedMean)}${f(l.computedMean)}${f(l.meanError)}${f(l.rmsError)}`),
'-'.repeat(68),
`${'전체'.padEnd(14)}${String(r.network.count).padStart(8)}${f(r.network.observedMean)}${f(r.network.computedMean)}${f(r.network.meanError)}${f(r.network.rmsError)}`,
'',
`평균값 상관계수 (Correlation Between Means): ${r.correlationOfMeans === null ? '-' : r.correlationOfMeans.toFixed(3)}`,
`전체 관측값 상관계수: ${r.correlationAll === null ? '-' : r.correlationAll.toFixed(3)}`,
];
if (r.unknownLocations.length) lines.push('', `모델에 없는 위치 (제외): ${r.unknownLocations.join(', ')}`);
if (r.outOfRange) lines.push(`해석 기간 밖의 관측값 (제외): ${r.outOfRange}개`);
return lines.join('\n');
}
+137
View File
@@ -0,0 +1,137 @@
import type { LinkResult, NodeResult, SimulationResult } from './types';
/**
* Decoder for the compact /api/analyze?format=columnar response
* (backend/columnar.py). Rebuilds the regular SimulationResult so every screen
* keeps working unchanged; only the wire format differs.
*
* Series are base64 little-endian float64, element-major: element i's values
* are data[i * steps .. (i + 1) * steps). A null series means "not available
* for every element" (e.g. no water-quality run) and decodes to [].
*/
export const COLUMNAR_FORMAT = 'spipe-columnar-1';
interface ColumnarSide {
ids: string[];
types: number[];
series: Record<string, string | null>;
}
interface ColumnarResult extends Omit<SimulationResult, 'nodes' | 'links'> {
format: string;
nodes: ColumnarSide & { coordinates: Array<{ x: number; y: number } | null> };
links: ColumnarSide & {
fromNodes: string[];
toNodes: string[];
vertices: Array<{ x: number; y: number }[]>;
pumps: Record<string, {
energySeries: number[];
efficiencySeries: number[];
energy?: number;
efficiency?: number;
totalKwh?: number;
peakKw?: number;
avgKw?: number;
utilization?: number;
}>;
};
}
export function isColumnarResult(data: unknown): data is ColumnarResult {
return !!data && typeof data === 'object' && (data as { format?: unknown }).format === COLUMNAR_FORMAT;
}
function decodeFloat64(b64: string): Float64Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
// Wire format is little-endian; read through a DataView so a big-endian
// host would still decode correctly.
const view = new DataView(bytes.buffer);
const out = new Float64Array(bytes.length / 8);
for (let i = 0; i < out.length; i++) out[i] = view.getFloat64(i * 8, true);
return out;
}
function unpack(side: ColumnarSide, steps: number): Record<string, (index: number) => number[]> {
const out: Record<string, (index: number) => number[]> = {};
for (const [key, b64] of Object.entries(side.series)) {
if (!b64) {
out[key] = () => [];
continue;
}
const data = decodeFloat64(b64);
if (data.length !== side.ids.length * steps) {
throw new Error(`서버 응답의 ${key} 시계열 길이가 맞지 않습니다 (${data.length} ≠ ${side.ids.length}×${steps}).`);
}
// Plain arrays (not typed-array views): results are JSON-serialised for
// autosave, where typed arrays would turn into {"0": ...} objects.
out[key] = (i) => Array.from(data.subarray(i * steps, (i + 1) * steps));
}
return out;
}
export function decodeColumnarResult(data: ColumnarResult): SimulationResult {
const steps = data.timeSteps?.length ?? 0;
const { format: _format, nodes: cn, links: cl, ...rest } = data;
const nodeSeries = unpack(cn, steps);
const nodes: Record<string, NodeResult> = {};
cn.ids.forEach((id, i) => {
const demand = nodeSeries.demand(i);
const head = nodeSeries.head(i);
const pressure = nodeSeries.pressure(i);
const quality = nodeSeries.quality(i);
nodes[id] = {
id,
type: cn.types[i],
coordinates: cn.coordinates[i],
demand: demand[0] ?? 0,
head: head[0] ?? 0,
pressure: pressure[0] ?? 0,
quality: quality.length ? quality[0] : undefined,
timeSeries: { demand, head, pressure, quality },
};
});
const linkSeries = unpack(cl, steps);
const links: Record<string, LinkResult> = {};
cl.ids.forEach((id, i) => {
const flow = linkSeries.flow(i);
const velocity = linkSeries.velocity(i);
const headloss = linkSeries.headloss(i);
const quality = linkSeries.quality(i);
const pump = cl.pumps[id];
links[id] = {
id,
type: cl.types[i],
fromNode: cl.fromNodes[i],
toNode: cl.toNodes[i],
vertices: cl.vertices[i],
flow: flow[0] ?? 0,
velocity: velocity[0] ?? 0,
headloss: headloss[0] ?? 0,
quality: quality.length ? quality[0] : undefined,
...(pump
? {
energy: pump.energy ?? pump.energySeries[0] ?? 0,
efficiency: pump.efficiency ?? pump.efficiencySeries[0] ?? 0,
totalKwh: pump.totalKwh,
peakKw: pump.peakKw,
avgKw: pump.avgKw,
utilization: pump.utilization,
}
: {}),
timeSeries: {
flow,
velocity,
headloss,
quality,
energy: pump ? pump.energySeries : [],
efficiency: pump ? pump.efficiencySeries : [],
},
};
});
return { ...rest, nodes, links };
}
+330
View File
@@ -0,0 +1,330 @@
import type {
SimulationResult,
ComparisonResult,
ComparisonVariable,
ToleranceConfig,
ToleranceViolation,
} from './types';
export const DEFAULT_TOLERANCES: Record<ComparisonVariable, ToleranceConfig> = {
head: { absTol: 1e-4, relTol: 1e-8 },
pressure: { absTol: 1e-4, relTol: 1e-8 },
demand: { absTol: 1e-3, relTol: 1e-8 },
flow: { absTol: 1e-3, relTol: 1e-8 },
velocity: { absTol: 1e-5, relTol: 1e-8 },
headloss: { absTol: 1e-5, relTol: 1e-8 },
quality: { absTol: 1e-3, relTol: 1e-8 },
};
// How many model flow units make up 1 LPS. The default flow/demand absTol
// (1e-3) is meant in LPS; applied verbatim to e.g. CMD it was 86x stricter,
// so near-zero flows in real CMD networks failed on solver round-off alone.
const FLOW_UNITS_PER_LPS: Record<string, number> = {
LPS: 1, LPM: 60, CMH: 3.6, CMD: 86.4, MLD: 0.0864, CMS: 0.001,
CFS: 0.0353147, GPM: 15.8503, MGD: 0.0228245, IMGD: 0.0190053, AFD: 0.0700456,
};
function flowScaledTolerances(
base: Record<ComparisonVariable, ToleranceConfig>,
flowUnit: string,
maxAbsFlow: number
): Record<ComparisonVariable, ToleranceConfig> {
const perLps = FLOW_UNITS_PER_LPS[flowUnit.toUpperCase().trim()] ?? 1;
// Also never demand more than ~1e-6 of the largest flow in the network.
const scaled = (tol: ToleranceConfig): ToleranceConfig => ({
...tol,
absTol: Math.max(tol.absTol * perLps, 1e-6 * maxAbsFlow),
});
return { ...base, flow: scaled(base.flow), demand: scaled(base.demand) };
}
function maxAbsSeriesValue(result: SimulationResult, kind: 'flow' | 'demand'): number {
let max = 0;
const items = kind === 'flow' ? Object.values(result.links) : Object.values(result.nodes);
for (const item of items) {
const series = (item.timeSeries as Record<string, number[] | undefined> | undefined)?.[kind];
const values = series ?? [(item as unknown as Record<string, number>)[kind] ?? 0];
for (const v of values) if (Number.isFinite(v) && Math.abs(v) > max) max = Math.abs(v);
}
return max;
}
function checkPoint(
variable: ComparisonVariable,
id: string,
time: number,
valServer: number | undefined | null,
valWasm: number | undefined | null,
maxDiffs: Record<ComparisonVariable, number>,
maxRelativeDiffs: Record<ComparisonVariable, number>,
maxSignificantRelativeDiffs: Record<ComparisonVariable, number>,
nearZeroCounts: Record<ComparisonVariable, number>,
toleranceViolations: ToleranceViolation[],
tolerances: Record<ComparisonVariable, ToleranceConfig>,
missingIds: string[]
) {
if (
valServer === undefined ||
valServer === null ||
valWasm === undefined ||
valWasm === null
) {
missingIds.push(`MissingValue:${variable}:${id}@${time}s`);
toleranceViolations.push({
variable,
id,
time,
diff: Number.POSITIVE_INFINITY,
tolerance: 0,
});
return;
}
if (!Number.isFinite(valServer) || !Number.isFinite(valWasm)) {
missingIds.push(`NonFiniteValue:${variable}:${id}@${time}s`);
toleranceViolations.push({
variable,
id,
time,
diff: Number.NaN,
tolerance: 0,
});
return;
}
const diff = Math.abs(valServer - valWasm);
const scale = Math.max(Math.abs(valServer), Math.abs(valWasm));
const relDiff = scale === 0 ? 0 : diff / scale;
const tol = tolerances[variable];
const allowed = tol.absTol + tol.relTol * scale;
if (diff > maxDiffs[variable]) {
maxDiffs[variable] = diff;
}
if (relDiff > maxRelativeDiffs[variable]) {
maxRelativeDiffs[variable] = relDiff;
}
// Denominator near-zero check: when scale <= absTol, relative error can be
// mathematically exaggerated (e.g. 1e-6 diff on a 1e-6 flow gives 100% relative error,
// yet is well within hydraulic tolerance).
if (scale <= tol.absTol) {
nearZeroCounts[variable]++;
} else {
if (relDiff > maxSignificantRelativeDiffs[variable]) {
maxSignificantRelativeDiffs[variable] = relDiff;
}
}
if (diff > allowed) {
toleranceViolations.push({
variable,
id,
time,
diff,
tolerance: allowed,
});
}
}
/**
* Computes the Native vs WASM comparison for a simulation result pair.
*
* Evaluation is strictly based on:
* allowedTolerance = absTol + relTol * max(|a|, |b|)
*
* Relative error is computed across all points, but points where the denominator
* is near zero (scale <= absTol) are counted separately so that zero-flow/velocity
* mathematical artifacts (e.g. 1.0~1.64 relative diff on 1e-6 flow) do not mislead users.
*/
export function computeComparison(
server: SimulationResult,
wasm: SimulationResult,
customTolerances?: Partial<Record<ComparisonVariable, ToleranceConfig>>
): ComparisonResult {
const unitAware = flowScaledTolerances(
DEFAULT_TOLERANCES,
server.units?.flow || wasm.units?.flow || 'LPS',
Math.max(maxAbsSeriesValue(server, 'flow'), maxAbsSeriesValue(server, 'demand'))
);
const tolerances: Record<ComparisonVariable, ToleranceConfig> = {
...unitAware,
...customTolerances,
};
// When BOTH engines' quality runs failed there is nothing to compare: report
// that once (qualityInfo.status) instead of one MissingValue per node/step.
const bothQualityFailed =
server.qualityInfo?.status === 'failed' && wasm.qualityInfo?.status === 'failed';
const hasQualitySimulation = !bothQualityFailed && Boolean(
(server.qualityInfo && server.qualityInfo.qualType > 0) ||
(wasm.qualityInfo && wasm.qualityInfo.qualType > 0)
);
const variables: ComparisonVariable[] = hasQualitySimulation
? ['demand', 'head', 'pressure', 'flow', 'velocity', 'headloss', 'quality']
: ['demand', 'head', 'pressure', 'flow', 'velocity', 'headloss'];
const maxDiffs = {} as Record<ComparisonVariable, number>;
const maxRelativeDiffs = {} as Record<ComparisonVariable, number>;
const maxSignificantRelativeDiffs = {} as Record<ComparisonVariable, number>;
const nearZeroCounts = {} as Record<ComparisonVariable, number>;
for (const v of variables) {
maxDiffs[v] = 0;
maxRelativeDiffs[v] = 0;
maxSignificantRelativeDiffs[v] = 0;
nearZeroCounts[v] = 0;
}
const toleranceViolations: ToleranceViolation[] = [];
const missingIds: string[] = [];
// 0. Strict unit metadata match
const serverFlow = (server.units?.flow || '').toUpperCase().trim();
const wasmFlow = (wasm.units?.flow || '').toUpperCase().trim();
if (serverFlow && wasmFlow && serverFlow !== wasmFlow) {
missingIds.push(`UnitMismatch:flow(${serverFlow}!=${wasmFlow})`);
}
const serverPress = (server.units?.pressure || '').toUpperCase().trim();
const wasmPress = (wasm.units?.pressure || '').toUpperCase().trim();
if (serverPress && wasmPress && serverPress !== wasmPress) {
missingIds.push(`UnitMismatch:pressure(${serverPress}!=${wasmPress})`);
}
const serverQInfo = server.qualityInfo;
const wasmQInfo = wasm.qualityInfo;
if (serverQInfo && wasmQInfo) {
if (serverQInfo.qualType !== wasmQInfo.qualType) {
missingIds.push(`QualityMismatch:qualType(${serverQInfo.qualType}!=${wasmQInfo.qualType})`);
}
const sqUnits = (serverQInfo.chemUnits || '').trim().toUpperCase();
const wqUnits = (wasmQInfo.chemUnits || '').trim().toUpperCase();
if (sqUnits && wqUnits && sqUnits !== wqUnits) {
missingIds.push(`QualityMismatch:chemUnits(${sqUnits}!=${wqUnits})`);
}
}
const serverSteps = server.timeSteps || [0];
const wasmSteps = wasm.timeSteps || [0];
const wasmIndexByTime = new Map<number, number>();
wasmSteps.forEach((t, idx) => wasmIndexByTime.set(t, idx));
const serverTimeSet = new Set(serverSteps);
const missingTimestamps = new Set<number>();
// Compare Nodes
for (const id in server.nodes) {
if (!wasm.nodes[id]) {
missingIds.push(`Node:${id}`);
continue;
}
const s = server.nodes[id];
const w = wasm.nodes[id];
if (s.timeSeries && w.timeSeries) {
for (let si = 0; si < serverSteps.length; si++) {
const t = serverSteps[si];
const wi = wasmIndexByTime.get(t);
if (wi === undefined) {
missingTimestamps.add(t);
continue;
}
const sd = si < s.timeSeries.demand.length ? s.timeSeries.demand[si] : undefined;
const wd = wi < w.timeSeries.demand.length ? w.timeSeries.demand[wi] : undefined;
const sh = si < s.timeSeries.head.length ? s.timeSeries.head[si] : undefined;
const wh = wi < w.timeSeries.head.length ? w.timeSeries.head[wi] : undefined;
const sp = si < s.timeSeries.pressure.length ? s.timeSeries.pressure[si] : undefined;
const wp = wi < w.timeSeries.pressure.length ? w.timeSeries.pressure[wi] : undefined;
checkPoint('demand', id, t, sd, wd, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('head', id, t, sh, wh, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('pressure', id, t, sp, wp, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
if (hasQualitySimulation) {
const sq = s.timeSeries.quality ? (si < s.timeSeries.quality.length ? s.timeSeries.quality[si] : undefined) : s.quality;
const wq = w.timeSeries.quality ? (wi < w.timeSeries.quality.length ? w.timeSeries.quality[wi] : undefined) : w.quality;
checkPoint('quality', id, t, sq, wq, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
}
}
} else {
checkPoint('demand', id, server.timeSeconds, s.demand, w.demand, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('head', id, server.timeSeconds, s.head, w.head, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('pressure', id, server.timeSeconds, s.pressure, w.pressure, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
if (hasQualitySimulation) {
checkPoint('quality', id, server.timeSeconds, s.quality, w.quality, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
}
}
}
for (const id in wasm.nodes) {
if (!server.nodes[id] && !missingIds.includes(`Node:${id}`)) missingIds.push(`Node:${id}`);
}
// Compare Links
for (const id in server.links) {
if (!wasm.links[id]) {
missingIds.push(`Link:${id}`);
continue;
}
const s = server.links[id];
const w = wasm.links[id];
if (s.timeSeries && w.timeSeries) {
for (let si = 0; si < serverSteps.length; si++) {
const t = serverSteps[si];
const wi = wasmIndexByTime.get(t);
if (wi === undefined) {
missingTimestamps.add(t);
continue;
}
const sf = si < s.timeSeries.flow.length ? s.timeSeries.flow[si] : undefined;
const wf = wi < w.timeSeries.flow.length ? w.timeSeries.flow[wi] : undefined;
const sv = si < s.timeSeries.velocity.length ? s.timeSeries.velocity[si] : undefined;
const wv = wi < w.timeSeries.velocity.length ? w.timeSeries.velocity[wi] : undefined;
const shl = si < s.timeSeries.headloss.length ? s.timeSeries.headloss[si] : undefined;
const whl = wi < w.timeSeries.headloss.length ? w.timeSeries.headloss[wi] : undefined;
checkPoint('flow', id, t, sf, wf, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('velocity', id, t, sv, wv, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('headloss', id, t, shl, whl, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
if (hasQualitySimulation) {
const slq = s.timeSeries.quality ? (si < s.timeSeries.quality.length ? s.timeSeries.quality[si] : undefined) : s.quality;
const wlq = w.timeSeries.quality ? (wi < w.timeSeries.quality.length ? w.timeSeries.quality[wi] : undefined) : w.quality;
checkPoint('quality', id, t, slq, wlq, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
}
}
} else {
checkPoint('flow', id, server.timeSeconds, s.flow, w.flow, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('velocity', id, server.timeSeconds, s.velocity, w.velocity, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
checkPoint('headloss', id, server.timeSeconds, s.headloss, w.headloss, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
if (hasQualitySimulation) {
checkPoint('quality', id, server.timeSeconds, s.quality, w.quality, maxDiffs, maxRelativeDiffs, maxSignificantRelativeDiffs, nearZeroCounts, toleranceViolations, tolerances, missingIds);
}
}
}
for (const id in wasm.links) {
if (!server.links[id] && !missingIds.includes(`Link:${id}`)) missingIds.push(`Link:${id}`);
}
// Missing Timestamps check
for (const t of wasmSteps) {
if (!serverTimeSet.has(t)) missingTimestamps.add(t);
}
for (const t of missingTimestamps) {
missingIds.push(`Timestamp:${t}s`);
}
const passed = missingIds.length === 0 && toleranceViolations.length === 0;
return {
passed,
maxDiffs,
maxRelativeDiffs,
maxSignificantRelativeDiffs,
nearZeroCounts,
toleranceViolations,
missingIds,
};
}
+195
View File
@@ -0,0 +1,195 @@
export interface ConvergenceInspection {
status: string;
color: string;
note: string;
hasWarning: boolean;
}
export interface EngineIssueTarget {
type: 'node' | 'link';
id: string;
}
export interface EngineIssue {
message: string;
severity: 'error' | 'warning';
target: EngineIssueTarget | null;
}
const ENGINE_ISSUE_LINE_PATTERN =
/WARNING|Error\s+\d+|disconnected|unbalanced|cannot deliver|negative pressure/i;
/**
* Extracts individual warning/error lines from an EPANET .rpt report body and
* links each one to the specific node/link it concerns, when identifiable --
* so "왜 실패했는지" doesn't require manually reading the raw report text and
* cross-referencing IDs by hand. Rather than trying to match every phrasing
* EPANET might use for a message ("Node %s disconnected", "Pump %s shut
* down", etc, which vary across engine versions), this scans each flagged
* line's own tokens against the model's actual node/link IDs -- so it finds
* the referenced object regardless of the surrounding sentence, at the cost
* of occasionally missing a reference that isn't a whole token (rare in
* EPANET's report format, which always writes IDs as standalone words).
*/
export function extractEngineIssues(
reportText: string | undefined | null,
nodeIds: Iterable<string>,
linkIds: Iterable<string>
): EngineIssue[] {
if (!reportText) return [];
const nodeIdSet = new Set(nodeIds);
const linkIdSet = new Set(linkIds);
if (nodeIdSet.size === 0 && linkIdSet.size === 0) return [];
const issues: EngineIssue[] = [];
for (const rawLine of reportText.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || !ENGINE_ISSUE_LINE_PATTERN.test(line)) continue;
let target: EngineIssueTarget | null = null;
for (const token of line.split(/[\s,;:()]+/)) {
if (!token) continue;
if (nodeIdSet.has(token)) {
target = { type: 'node', id: token };
break;
}
if (linkIdSet.has(token)) {
target = { type: 'link', id: token };
break;
}
}
issues.push({
message: line,
severity: /Error\s+\d+/i.test(line) ? 'error' : 'warning',
target,
});
}
return issues;
}
export interface EngineWarning {
time: number;
code: number;
}
// EPANET warning codes returned by EN_runH (src/errors.dat, "WARNING" block).
const WARNING_TEXT: Record<number, string> = {
1: 'System hydraulically unbalanced (수리방정식 미수렴)',
2: 'Pumps cannot deliver head or flow (펌프 양정/유량 부족)',
3: 'Valves cannot deliver flow or pressure (밸브 제어 불가)',
4: 'Pumps/valves cannot deliver (펌프·밸브 제어 불가)',
5: 'Network disconnected (관망 단절)',
6: 'Negative pressures (음압 발생)',
};
// Pressures this far from any real network (in psi, m, kPa, bar or ft) only
// come out of a singular/disconnected solve, never from a physical answer.
const IMPLAUSIBLE_PRESSURE = 1e5;
function formatClock(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}:${String(m).padStart(2, '0')}`;
}
/**
* Turns warning codes collected directly from the engine (not from the report
* text, which loses them under "[REPORT] MESSAGES NO") into report-style lines.
*/
export function engineWarningLines(warnings: EngineWarning[] | undefined | null): string[] {
if (!warnings || warnings.length === 0) return [];
return warnings.map((w) => `WARNING ${w.code}: ${WARNING_TEXT[w.code] ?? 'Engine warning'} at ${formatClock(w.time)} hrs.`);
}
export function findImplausiblePressureNodes(
nodes: Record<string, { type?: number; timeSeries?: { pressure?: number[] }; pressure?: number }> | undefined
): string[] {
if (!nodes) return [];
const ids: string[] = [];
for (const [id, node] of Object.entries(nodes)) {
const series = node.timeSeries?.pressure ?? [node.pressure ?? 0];
if (series.some((p) => Math.abs(p) > IMPLAUSIBLE_PRESSURE)) ids.push(id);
}
return ids;
}
/**
* Verdict for a whole simulation result: the report text, plus the warning
* codes the engine returned directly, plus a physical-plausibility check on
* pressures. Prefer this over inspectConvergence whenever a result is at hand.
*/
export function inspectSimulation(
result: {
reportText?: string;
engineWarnings?: EngineWarning[];
nodes?: Record<string, { type?: number; timeSeries?: { pressure?: number[] }; pressure?: number }>;
} | null | undefined
): ConvergenceInspection {
if (!result) return inspectConvergence(null);
const base = inspectConvergence(result.reportText);
if (base.hasWarning) return base;
const warningLines = engineWarningLines(result.engineWarnings);
const implausible = findImplausiblePressureNodes(result.nodes);
if (warningLines.length > 0 || implausible.length > 0) {
const parts: string[] = [];
if (warningLines.length > 0) parts.push(`엔진 경고 ${warningLines.length}건 (보고서에는 기록되지 않음)`);
if (implausible.length > 0) parts.push(`비물리적 압력 절점 ${implausible.length}개 (${implausible.slice(0, 5).join(', ')}${implausible.length > 5 ? ' 외' : ''})`);
return {
status: '경고/불균형 발생 (엔진 로그 확인 필요)',
color: '#dc2626',
note: parts.join(' · '),
hasWarning: true,
};
}
return base;
}
/**
* Inspects an EPANET .rpt report body for genuine engine warnings/errors.
* Never hardcodes "정상 수렴" (normal convergence) - the verdict always derives
* from the actual report text produced by the engine for the current analysis run.
*/
export function inspectConvergence(reportText?: string | null): ConvergenceInspection {
if (!reportText || reportText.trim().length === 0) {
return {
status: '해석 미실행 (확인 불가)',
color: '#64748b',
note: '엔진 보고서가 생성되지 않았습니다.',
hasWarning: false,
};
}
const hasWarning =
reportText.includes('WARNING') ||
reportText.includes('unbalanced') ||
reportText.includes('System unbalanced') ||
reportText.includes('Negative pressures') ||
reportText.includes('cannot be met') ||
/Error\s+\d+/.test(reportText);
if (hasWarning) {
return {
status: '경고/불균형 발생 (엔진 로그 확인 필요)',
color: '#dc2626',
note: '엔진 보고서에 경고 또는 오류가 포함되어 있습니다.',
hasWarning: true,
};
}
if (reportText.length > 50 && (reportText.includes('Hydraulic Status') || reportText.includes('Analysis begun'))) {
return {
status: '수리방정식 정상 수렴 확인',
color: '#16a34a',
note: '엔진 보고서에 경고 없이 해석이 완료되었습니다.',
hasWarning: false,
};
}
return {
status: '해석 완료 (상세 로그 참조)',
color: '#2563eb',
note: '보고서 내용이 짧아 수렴 여부를 완전히 판별할 수 없습니다. 상세 로그를 확인하세요.',
hasWarning: false,
};
}
+372
View File
@@ -0,0 +1,372 @@
// 정의 출처: pyproj/EPSG DB, 검증일: 2026-09-09
import proj4 from 'proj4';
export type CRS =
| 'LOCAL'
| 'EPSG:4326'
| 'EPSG:3857'
| 'EPSG:5179'
| 'EPSG:5186'
| 'EPSG:5185'
| 'EPSG:5187'
| 'EPSG:5188'
| 'EPSG:5180'
| 'EPSG:5181'
| 'EPSG:5174'
| 'EPSG:5176'
| 'EPSG:5173'
| 'EPSG:5177'
| 'EPSG:5175'
| 'EPSG:102080'
| 'EPSG:102081'
| 'EPSG:102082'
| 'EPSG:102083';
export interface CrsDefinition {
code: CRS;
name: string;
category: 'standard' | 'grs80' | 'portal' | 'bessel' | 'kgd2002';
categoryLabel: string;
description: string;
proj4Def?: string;
}
export const SUPPORTED_CRS_DEFINITIONS: CrsDefinition[] = [
// Standard / Global
{
code: 'LOCAL',
name: '로컬 평면 (Local Planar)',
category: 'standard',
categoryLabel: '기본 및 글로벌 좌표계',
description: '임의 2D 평면 좌표계 (Net1 등 상대 좌표 관망)',
},
{
code: 'EPSG:4326',
name: 'WGS 84',
category: 'standard',
categoryLabel: '기본 및 글로벌 좌표계',
description: 'GPS 및 글로벌 표준 위경도 좌표계',
proj4Def: '+proj=longlat +datum=WGS84 +no_defs +type=crs',
},
{
code: 'EPSG:3857',
name: 'WGS 84 / Pseudo-Mercator',
category: 'standard',
categoryLabel: '기본 및 글로벌 좌표계',
description: 'Google Maps, OSM, 브이월드 웹 타일 표준 투영 좌표계',
proj4Def: '+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs +type=crs',
},
// National Standard GRS80 (Korea 2000)
{
code: 'EPSG:5179',
name: 'KGD2002 / Unified CS',
category: 'grs80',
categoryLabel: '국가표준 GRS80 (Korea 2000)',
description: '국토교통부 표준 도로명주소·공공데이터 좌표계 (통합원점 GRS80)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +units=m +no_defs +type=crs',
},
{
code: 'EPSG:5186',
name: 'KGD2002 / Central Belt 2010',
category: 'grs80',
categoryLabel: '국가표준 GRS80 (Korea 2000)',
description: '수도권·충청도 현행 법정 기준계 (GRS80 Y+600,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs +type=crs',
},
{
code: 'EPSG:5185',
name: 'KGD2002 / West Belt 2010',
category: 'grs80',
categoryLabel: '국가표준 GRS80 (Korea 2000)',
description: '전라도·충남 서해안 현행 법정 기준계 (GRS80 Y+600,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=125 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs +type=crs',
},
{
code: 'EPSG:5187',
name: 'KGD2002 / East Belt 2010',
category: 'grs80',
categoryLabel: '국가표준 GRS80 (Korea 2000)',
description: '강원도·경상도 영남권 현행 법정 기준계 (GRS80 Y+600,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=129 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs +type=crs',
},
{
code: 'EPSG:5188',
name: 'KGD2002 / East Sea Belt 2010',
category: 'grs80',
categoryLabel: '국가표준 GRS80 (Korea 2000)',
description: '울릉도·독도 현행 법정 기준계 (GRS80 Y+600,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=131 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs +type=crs',
},
{
code: 'EPSG:5180',
name: 'KGD2002 / West Belt',
category: 'grs80',
categoryLabel: '국가표준 GRS80 (Korea 2000)',
description: 'KGD2002 서부원점 구 가산수치계 (GRS80 Y+500,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=125 +k=1 +x_0=200000 +y_0=500000 +ellps=GRS80 +units=m +no_defs +type=crs',
},
{
code: 'EPSG:5181',
name: 'KGD2002 / Central Belt',
category: 'grs80',
categoryLabel: '국가표준 GRS80 (Korea 2000)',
description: 'KGD2002 중부원점 구 가산수치계 (공식 EPSG: GRS80 Y+500,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=500000 +ellps=GRS80 +units=m +no_defs +type=crs',
},
// Old Cadastral / Bessel (공식 EPSG 기준: Korean 1985 Modified 시리즈)
{
code: 'EPSG:5174',
name: 'Korean 1985 / Modified Central Belt',
category: 'bessel',
categoryLabel: '구 지적 / 구 Bessel 좌표계 (지자체 구 GIS)',
description: 'Korean 1985 Modified Central Belt (공식 EPSG 정의, Bessel)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=127.002890277778 +k=1 +x_0=200000 +y_0=500000 +ellps=bessel +towgs84=-115.80,474.99,674.11,1.16,-2.31,-1.63,6.43 +units=m +no_defs',
},
{
code: 'EPSG:5176',
name: 'Korean 1985 / Modified East Belt',
category: 'bessel',
categoryLabel: '구 지적 / 구 Bessel 좌표계 (지자체 구 GIS)',
description: 'Korean 1985 Modified East Belt (공식 EPSG 정의, Bessel)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=129.002890277778 +k=1 +x_0=200000 +y_0=500000 +ellps=bessel +towgs84=-115.80,474.99,674.11,1.16,-2.31,-1.63,6.43 +units=m +no_defs',
},
{
code: 'EPSG:5173',
name: 'Korean 1985 / Modified West Belt',
category: 'bessel',
categoryLabel: '구 지적 / 구 Bessel 좌표계 (지자체 구 GIS)',
description: 'Korean 1985 Modified West Belt (공식 EPSG 정의, Bessel)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=125.002890277778 +k=1 +x_0=200000 +y_0=500000 +ellps=bessel +towgs84=-115.80,474.99,674.11,1.16,-2.31,-1.63,6.43 +units=m +no_defs',
},
{
code: 'EPSG:5177',
name: 'Korean 1985 / Modified East Sea Belt',
category: 'bessel',
categoryLabel: '구 지적 / 구 Bessel 좌표계 (지자체 구 GIS)',
description: 'Korean 1985 Modified East Sea Belt (공식 EPSG 정의, Bessel)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=131.002890277778 +k=1 +x_0=200000 +y_0=500000 +ellps=bessel +towgs84=-115.80,474.99,674.11,1.16,-2.31,-1.63,6.43 +units=m +no_defs',
},
{
code: 'EPSG:5175',
name: 'Korean 1985 / Modified Central Belt Jeju',
category: 'bessel',
categoryLabel: '구 지적 / 구 Bessel 좌표계 (지자체 구 GIS)',
description: 'Korean 1985 Modified Central Belt Jeju (공식 EPSG 정의, Bessel)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=127.002890277778 +k=1 +x_0=200000 +y_0=550000 +ellps=bessel +towgs84=-115.80,474.99,674.11,1.16,-2.31,-1.63,6.43 +units=m +no_defs',
},
// KGD2002 과도기 좌표계 (ESRI 코드 - 출처 확인 필요)
{
code: 'EPSG:102080',
name: 'ESRI:102080 (출처 확인 필요)',
category: 'kgd2002',
categoryLabel: 'KGD2002 과도기 좌표계 (출처 확인 필요)',
description: 'Korea 2000 과도기 중부원점 (GRS80 Y+500,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=500000 +ellps=GRS80 +units=m +no_defs',
},
{
code: 'EPSG:102081',
name: 'ESRI:102081 (출처 확인 필요)',
category: 'kgd2002',
categoryLabel: 'KGD2002 과도기 좌표계 (출처 확인 필요)',
description: 'Korea 2000 과도기 서부원점 (GRS80 Y+500,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=125 +k=1 +x_0=200000 +y_0=500000 +ellps=GRS80 +units=m +no_defs',
},
{
code: 'EPSG:102082',
name: 'ESRI:102082 (출처 확인 필요)',
category: 'kgd2002',
categoryLabel: 'KGD2002 과도기 좌표계 (출처 확인 필요)',
description: 'Korea 2000 과도기 동부원점 (GRS80 Y+500,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=129 +k=1 +x_0=200000 +y_0=500000 +ellps=GRS80 +units=m +no_defs',
},
{
code: 'EPSG:102083',
name: 'ESRI:102083 (출처 확인 필요)',
category: 'kgd2002',
categoryLabel: 'KGD2002 과도기 좌표계 (출처 확인 필요)',
description: 'Korea 2000 과도기 동해원점 (GRS80 Y+500,000)',
proj4Def: '+proj=tmerc +lat_0=38 +lon_0=131 +k=1 +x_0=200000 +y_0=500000 +ellps=GRS80 +units=m +no_defs',
},
];
// Register all projections with proj4
for (const def of SUPPORTED_CRS_DEFINITIONS) {
if (def.proj4Def) {
proj4.defs(def.code, def.proj4Def);
}
}
export interface Extents {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
/**
* Automatically detects plausible CRS from coordinate ranges.
*/
export function detectCRS(extents: Extents): CRS {
const { minX, maxX, minY, maxY } = extents;
// EPSG:5179 (UTM-K): X roughly 700,000 ~ 1,300,000, Y roughly 1,400,000 ~ 2,200,000
if (minX > 500000 && maxX < 1500000 && minY > 1300000 && maxY < 2300000) {
return 'EPSG:5179';
}
// EPSG:5186 (Middle Origin): X roughly 100,000 ~ 350,000, Y roughly 300,000 ~ 700,000
if (minX > 50000 && maxX < 400000 && minY > 250000 && maxY < 750000) {
return 'EPSG:5186';
}
// EPSG:4326 (Korea WGS84): Lon roughly 124 ~ 132, Lat roughly 33 ~ 39
if (minX >= 120 && maxX <= 135 && minY >= 30 && maxY <= 43) {
return 'EPSG:4326';
}
// Default to LOCAL for arbitrary planar coordinates like Net1 (0~100)
return 'LOCAL';
}
/**
* Converts model coordinates (X, Y) to map longitude/latitude.
*/
// proj4(from, to, point) re-parses both definitions on every call (29 ms per
// time step for a 2,000-node network vs 4.6 ms cached), so keep one
// converter per CRS pair.
const converterCache = new Map<string, proj4.Converter>();
function converter(from: string, to: string): proj4.Converter {
const key = from + '>' + to;
let conv = converterCache.get(key);
if (!conv) {
conv = proj4(from, to);
converterCache.set(key, conv);
}
return conv;
}
export function modelToLngLat(x: number, y: number, crs: CRS, extents: Extents): [number, number] {
if (crs === 'LOCAL') {
const { minX, minY, maxX, maxY } = extents;
const dx = maxX - minX || 1;
const dy = maxY - minY || 1;
// Map arbitrary coords to stable 0.1 degree box around lat/lon 0,0
const lon = -0.05 + ((x - minX) / dx) * 0.1;
const lat = -0.05 + ((y - minY) / dy) * 0.1;
return [lon, lat];
}
if (crs === 'EPSG:4326') {
return [x, y];
}
try {
const [lon, lat] = converter(crs, 'EPSG:4326').forward([x, y]);
return [lon, lat];
} catch {
return [0, 0];
}
}
/**
* Converts map longitude/latitude to model coordinates (X, Y).
*/
export function lngLatToModel(lon: number, lat: number, crs: CRS, extents: Extents): [number, number] {
if (crs === 'LOCAL') {
const { minX, minY, maxX, maxY } = extents;
const dx = maxX - minX || 1;
const dy = maxY - minY || 1;
const x = minX + ((lon + 0.05) / 0.1) * dx;
const y = minY + ((lat + 0.05) / 0.1) * dy;
return [Math.round(x * 100) / 100, Math.round(y * 100) / 100];
}
if (crs === 'EPSG:4326') {
return [Math.round(lon * 1000000) / 1000000, Math.round(lat * 1000000) / 1000000];
}
try {
const [x, y] = converter('EPSG:4326', crs).forward([lon, lat]);
return [Math.round(x * 100) / 100, Math.round(y * 100) / 100];
} catch {
return [0, 0];
}
}
/**
* Great-circle distance in meters between two geographic points (degrees).
*/
export function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000; // mean Earth radius, meters
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
export interface GeoAwareDistance {
distance: number;
isPhysical: boolean;
}
/**
* Computes a physically meaningful pipe length between two model coordinates,
* accounting for the coordinate reference system (geographic degrees vs
* projected meters vs arbitrary local units) and the network's length unit
* system (SI meters vs US feet). Never invoked automatically on node drag -
* callers must only use this for an explicit user "recalculate length" action.
*/
export function calculateGeoAwareDistance(
n1: { x: number; y: number },
n2: { x: number; y: number },
crs: CRS,
lengthUnitSystem: 'SI' | 'US'
): GeoAwareDistance {
let meters: number;
let isPhysical = true;
if (crs === 'LOCAL') {
// LOCAL: arbitrary planar units with no established real-world scale.
const dx = n1.x - n2.x;
const dy = n1.y - n2.y;
return { distance: Math.sqrt(dx * dx + dy * dy), isPhysical: false };
} else if (crs === 'EPSG:3857') {
// EPSG:3857 (Web Mercator) is distorted by ~26% in Korea. Convert to EPSG:5179
// to compute true projected metric distance without scale distortion.
try {
const [p1x, p1y] = converter('EPSG:3857', 'EPSG:5179').forward([n1.x, n1.y]);
const [p2x, p2y] = converter('EPSG:3857', 'EPSG:5179').forward([n2.x, n2.y]);
meters = Math.hypot(p1x - p2x, p1y - p2y);
} catch {
meters = Math.hypot(n1.x - n2.x, n1.y - n2.y);
}
} else if (crs === 'EPSG:4326') {
// WGS84 geographic degrees: in Korea (lat 30~43, lon 120~135), project via EPSG:5179
// to use true GRS80 ellipsoid planar distance (difference with spherical haversine is ~1.99m/km).
const inKorea = n1.x >= 120 && n1.x <= 135 && n1.y >= 30 && n1.y <= 43 &&
n2.x >= 120 && n2.x <= 135 && n2.y >= 30 && n2.y <= 43;
if (inKorea) {
try {
const [p1x, p1y] = converter('EPSG:4326', 'EPSG:5179').forward([n1.x, n1.y]);
const [p2x, p2y] = converter('EPSG:4326', 'EPSG:5179').forward([n2.x, n2.y]);
meters = Math.hypot(p1x - p2x, p1y - p2y);
} catch {
meters = haversineMeters(n1.y, n1.x, n2.y, n2.x);
}
} else {
meters = haversineMeters(n1.y, n1.x, n2.y, n2.x);
}
} else {
// All Korean projected coordinate systems (EPSG:5179, 5186, 5181, 5187, 5174, 102080, etc.)
// have metric units (meters) on their projection plane.
const dx = n1.x - n2.x;
const dy = n1.y - n2.y;
meters = Math.sqrt(dx * dx + dy * dy);
}
const distance = lengthUnitSystem === 'US' ? meters * 3.280839895013123 : meters;
return { distance, isPhysical };
}
+81
View File
@@ -0,0 +1,81 @@
import type { SimulationResult } from './types';
/** Triggers a browser download of in-memory content. */
export function downloadBlob(content: BlobPart, fileName: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
/** UTF-8 BOM + CRLF so Excel (Korean Windows) opens the CSV correctly. */
export function downloadCsv(lines: string[], fileName: string): void {
downloadBlob('' + lines.join('\r\n'), fileName, 'text/csv;charset=utf-8');
}
function clock(secs: number): string {
const hh = String(Math.floor(secs / 3600)).padStart(2, '0');
const mm = String(Math.floor((secs % 3600) / 60)).padStart(2, '0');
return `${hh}:${mm}`;
}
/** Every node/link value at every hydraulic step, in the engine's own units. */
export function buildEpsResultsCsv(result: SimulationResult): string[] {
const timeSteps = result.timeSteps || [0];
const lines: string[] = [];
lines.push('--- 절점(Node) 24시간 수리해석 결과 ---');
lines.push('Node_ID,Time_Sec,Time_HHMM,Demand,Head,Pressure');
for (const [nid, node] of Object.entries(result.nodes)) {
for (let ti = 0; ti < timeSteps.length; ti++) {
const secs = timeSteps[ti];
const d = node.timeSeries ? (node.timeSeries.demand[ti] ?? node.demand) : node.demand;
const h = node.timeSeries ? (node.timeSeries.head[ti] ?? node.head) : node.head;
const p = node.timeSeries ? (node.timeSeries.pressure[ti] ?? node.pressure) : node.pressure;
lines.push(`${nid},${secs},${clock(secs)},${d.toFixed(3)},${h.toFixed(3)},${p.toFixed(3)}`);
}
}
lines.push('');
lines.push('--- 관로(Link) 24시간 수리해석 결과 ---');
lines.push('Link_ID,Time_Sec,Time_HHMM,Flow,Velocity,Headloss');
for (const [lid, link] of Object.entries(result.links)) {
for (let ti = 0; ti < timeSteps.length; ti++) {
const secs = timeSteps[ti];
const f = link.timeSeries ? (link.timeSeries.flow[ti] ?? link.flow) : link.flow;
const v = link.timeSeries ? (link.timeSeries.velocity[ti] ?? link.velocity) : link.velocity;
const hl = link.timeSeries ? (link.timeSeries.headloss[ti] ?? link.headloss) : link.headloss;
lines.push(`${lid},${secs},${clock(secs)},${f.toFixed(3)},${v.toFixed(3)},${hl.toFixed(3)}`);
}
}
return lines;
}
/** Base-vs-current scenario differences (initial time step). */
export function buildScenarioDiffCsv(base: SimulationResult, current: SimulationResult): string[] {
const lines: string[] = [];
lines.push('--- 절점(Node) 시나리오 수압 비교 ---');
lines.push('Node_ID,원안_Pressure,대안_Pressure,변화량_Diff,원안_Head,대안_Head');
for (const [id, node] of Object.entries(current.nodes)) {
const bP = base.nodes[id]?.pressure ?? 0;
const cP = node.pressure;
const bH = base.nodes[id]?.head ?? 0;
const cH = node.head;
lines.push(`${id},${bP.toFixed(3)},${cP.toFixed(3)},${(cP - bP).toFixed(3)},${bH.toFixed(3)},${cH.toFixed(3)}`);
}
lines.push('');
lines.push('--- 관로(Link) 시나리오 유량 비교 ---');
lines.push('Link_ID,원안_Flow,대안_Flow,변화량_Diff,원안_Velocity,대안_Velocity');
for (const [id, link] of Object.entries(current.links)) {
const bF = base.links[id]?.flow ?? 0;
const cF = link.flow;
const bV = base.links[id]?.velocity ?? 0;
const cV = link.velocity;
lines.push(`${id},${bF.toFixed(3)},${cF.toFixed(3)},${(cF - bF).toFixed(3)},${bV.toFixed(3)},${cV.toFixed(3)}`);
}
return lines;
}
+58
View File
@@ -0,0 +1,58 @@
// Curve usage and type for the curve editor (곡선 편집).
import type { NetworkModel } from './inpModel';
import { curveTypeOf, type CurveType } from './inpExtras.ts';
/** Where a curve is used (human-readable), so it is not deleted while referenced. */
export function curveReferences(model: NetworkModel, id: string): string[] {
const refs: string[] = [];
for (const p of Object.values(model.pumps)) {
const t = p.parameters.split(/\s+/);
t.forEach((tok, i) => {
if (tok.toUpperCase() === 'HEAD' && t[i + 1] === id) refs.push(`펌프 ${p.id} 양정곡선`);
});
}
for (const n of Object.values(model.nodes)) if (n.type === 'TANK' && n.volCurve === id) refs.push(`탱크 ${n.id} 체적곡선`);
for (const v of Object.values(model.valves || {})) {
if (v.valveType.toUpperCase() === 'GPV' && String(v.setting) === id) refs.push(`밸브 ${v.id} (GPV) 손실곡선`);
if (v.curve === id) refs.push(`밸브 ${v.id} (PCV) 곡선`);
}
for (const [pid, pe] of Object.entries(model.extras?.energy.pumps ?? {})) if (pe.efficiency === id) refs.push(`펌프 ${pid} 효율곡선`);
for (const kv of model.extras?.energy.global ?? []) {
if (/effic/i.test(kv.key) && kv.value === id) refs.push('전역 펌프 효율곡선');
}
return refs;
}
/** Declared type (";PUMP: ..." description) or inferred from where the curve is used. */
export function inferCurveType(model: NetworkModel, id: string): CurveType {
const declared = curveTypeOf(model.curves[id]?.description);
if (declared) return declared;
const refs = curveReferences(model, id).join(' ');
if (refs.includes('체적')) return 'VOLUME';
if (refs.includes('효율')) return 'EFFICIENCY';
if (refs.includes('손실') || refs.includes('PCV')) return 'HEADLOSS';
return 'PUMP';
}
/** Problems a curve of this type should not have (shown as warnings, not blocking). */
export function curveWarnings(type: CurveType, points: { x: number; y: number }[]): string[] {
const w: string[] = [];
if (points.length === 0) return ['점이 없습니다.'];
for (let i = 1; i < points.length; i++) {
if (!(points[i].x > points[i - 1].x)) {
w.push('X 값은 점마다 커져야 합니다.');
break;
}
}
if (type === 'PUMP' && points.length > 1) {
for (let i = 1; i < points.length; i++) {
if (!(points[i].y < points[i - 1].y)) {
w.push('펌프 곡선은 유량이 늘수록 양정이 줄어야 합니다.');
break;
}
}
}
if (type === 'EFFICIENCY' && points.some((p) => p.y <= 0 || p.y > 100)) w.push('효율은 0~100% 사이여야 합니다.');
if (type === 'VOLUME' && points.some((p, i) => i > 0 && p.y < points[i - 1].y)) w.push('체적은 수위가 높아질수록 커져야 합니다.');
return w;
}
+93
View File
@@ -0,0 +1,93 @@
// DEM -> node elevations: ground (지반고) or pipe invert (관저고 = 지반고 - 매설깊이),
// with US-unit conversion, plus the elevation check against the DEM.
import type { NetworkModel } from './inpModel';
import { getUnitSystem } from './unitSystem.ts';
import { SUPPORTED_CRS_DEFINITIONS, type CRS } from './crs.ts';
export type ElevationMode = 'ground' | 'invert';
export interface ElevationOptions {
mode: ElevationMode;
/** Burial depth (m) used where a node has no asset depth. */
defaultDepth: number;
/** Prefer each node's asset attribute "depth" (m) over defaultDepth. */
useAssetDepth: boolean;
}
const M_TO_FT = 3.280839895;
/** proj4 string of a model CRS for the server (null for LOCAL / unknown). */
export function crsProj4(crs: CRS): string | null {
if (crs === 'LOCAL') return null;
return SUPPORTED_CRS_DEFINITIONS.find((d) => d.code === crs)?.proj4Def ?? null;
}
function depthOf(model: NetworkModel, id: string, o: ElevationOptions): number {
const a = Number(model.assets?.nodes[id]?.depth);
return o.useAssetDepth && Number.isFinite(a) && a >= 0 ? a : o.defaultDepth;
}
/** Elevation the node should have, in model length units (ground metres in). */
export function expectedElevation(model: NetworkModel, id: string, groundM: number, o: ElevationOptions): number {
const m = o.mode === 'invert' ? groundM - depthOf(model, id, o) : groundM;
const v = getUnitSystem(model.options.units) === 'US' ? m * M_TO_FT : m;
return Math.round(v * 1000) / 1000;
}
/**
* Write DEM-derived elevations into nodes. Junctions and tanks get the
* elevation; reservoirs are skipped (their "elevation" is the water head).
*/
export function applyGroundElevations(
model: NetworkModel,
ground: Record<string, number | null | undefined>,
o: ElevationOptions,
onlyZero = false
): { model: NetworkModel; changed: string[]; missing: string[] } {
const nodes = { ...model.nodes };
const changed: string[] = [];
const missing: string[] = [];
for (const [id, g] of Object.entries(ground)) {
const n = nodes[id];
if (!n || n.type === 'RESERVOIR') continue;
if (onlyZero && n.elevation !== 0) continue;
if (g === null || g === undefined || !Number.isFinite(g)) {
missing.push(id);
continue;
}
const v = expectedElevation(model, id, g, o);
if (v !== n.elevation) {
nodes[id] = { ...n, elevation: v };
changed.push(id);
}
}
return { model: changed.length ? { ...model, nodes } : model, changed, missing };
}
export interface ElevationCheckRow {
id: string;
model: number;
expected: number;
diff: number;
}
/** Nodes whose elevation differs from the DEM-derived value by more than `tol` (model units). */
export function checkElevations(model: NetworkModel, ground: Record<string, number | null | undefined>, o: ElevationOptions, tol: number): { rows: ElevationCheckRow[]; checked: number; missing: number } {
const rows: ElevationCheckRow[] = [];
let checked = 0;
let missing = 0;
for (const [id, g] of Object.entries(ground)) {
const n = model.nodes[id];
if (!n || n.type === 'RESERVOIR') continue;
if (g === null || g === undefined || !Number.isFinite(g)) {
missing++;
continue;
}
checked++;
const expected = expectedElevation(model, id, g, o);
const diff = Math.round((n.elevation - expected) * 1000) / 1000;
if (Math.abs(diff) > tol) rows.push({ id, model: n.elevation, expected, diff });
}
rows.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff));
return { rows, checked, missing };
}
+178
View File
@@ -0,0 +1,178 @@
import type { NetworkModel } from './inpModel';
import type { SimulationResult } from './types';
interface DxfExportOptions {
model: NetworkModel;
simulationResult?: SimulationResult | null;
scenarioName?: string;
}
/**
* Generates an AutoCAD ASCII DXF (R12 / AC1009) format drawing file
* with organized CAD layers for pipes, nodes, text labels, and hydraulic results.
*/
export function generateDxf({
model,
simulationResult,
scenarioName = 'Baseline',
}: DxfExportOptions): string {
const lines: string[] = [];
const add = (...tokens: (string | number)[]) => {
for (const t of tokens) {
lines.push(String(t));
}
};
// Compute model extents to determine appropriate text height and circle radius
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(model.nodes)) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
if (minX === Infinity) {
minX = 0; minY = 0; maxX = 100; maxY = 100;
}
const modelSpan = Math.max(maxX - minX, maxY - minY) || 100;
const textHeight = Math.max(0.5, Math.round((modelSpan * 0.015) * 10) / 10);
const nodeRadius = Math.max(0.6, Math.round((modelSpan * 0.018) * 10) / 10);
// 1. HEADER SECTION
add('0', 'SECTION');
add('2', 'HEADER');
add('9', '$ACADVER', '1', 'AC1009'); // AutoCAD Release 12 DXF standard
add('9', '$INSUNITS', '70', '6'); // Meters (or Unitless)
add('0', 'ENDSEC');
// 2. TABLES SECTION (Standard Engineering Layer Definitions)
add('0', 'SECTION');
add('2', 'TABLES');
add('0', 'TABLE');
add('2', 'LAYER');
add('70', '7');
const defineLayer = (name: string, color: number) => {
add('0', 'LAYER');
add('2', name);
add('70', '0');
add('62', color); // AutoCAD ACI Color
add('6', 'CONTINUOUS');
};
defineLayer('PIPE', 4); // Cyan
defineLayer('PIPE_TEXT', 3); // Green (ID, Dia, Len)
defineLayer('PIPE_FLOW', 2); // Yellow (Q, V)
defineLayer('NODE', 1); // Red
defineLayer('NODE_TEXT', 7); // White/Black (ID, Elev)
defineLayer('NODE_PRESS', 5); // Blue (P)
defineLayer('FACILITY', 6); // Magenta (Reservoir, Tank)
add('0', 'ENDTAB');
add('0', 'ENDSEC');
// 3. BLOCKS SECTION (Empty but required)
add('0', 'SECTION');
add('2', 'BLOCKS');
add('0', 'ENDSEC');
// 4. ENTITIES SECTION
add('0', 'SECTION');
add('2', 'ENTITIES');
// Title Text entity
add('0', 'TEXT');
add('8', 'NODE_TEXT');
add('10', minX, '20', maxY + textHeight * 3, '30', '0.0');
add('40', textHeight * 1.5);
add('1', `spipe Network Drawing - ${scenarioName}`);
add('50', '0.0');
// A. Export Nodes (Junctions, Reservoirs, Tanks)
for (const node of Object.values(model.nodes)) {
const isFacility = node.type === 'RESERVOIR' || node.type === 'TANK';
const layer = isFacility ? 'FACILITY' : 'NODE';
const r = isFacility ? nodeRadius * 1.6 : nodeRadius;
// Node Circle entity
add('0', 'CIRCLE');
add('8', layer);
add('10', node.x.toFixed(3), '20', node.y.toFixed(3), '30', '0.0');
add('40', r.toFixed(3));
// Node ID and Elevation Text entity
const elevStr = `EL.${node.elevation.toFixed(1)}`;
add('0', 'TEXT');
add('8', 'NODE_TEXT');
add('10', (node.x + r * 1.2).toFixed(3), '20', (node.y + r * 0.4).toFixed(3), '30', '0.0');
add('40', textHeight.toFixed(3));
add('1', `${node.id} (${elevStr})`);
add('50', '0.0');
// Hydraulic Pressure Text entity
if (simulationResult) {
const res = simulationResult.nodes[node.id];
if (res) {
const pressUnit = simulationResult.units.pressure;
const pressStr = `P=${res.pressure.toFixed(1)}${pressUnit}`;
add('0', 'TEXT');
add('8', 'NODE_PRESS');
add('10', (node.x + r * 1.2).toFixed(3), '20', (node.y - r * 1.0).toFixed(3), '30', '0.0');
add('40', (textHeight * 0.9).toFixed(3));
add('1', pressStr);
add('50', '0.0');
}
}
}
// B. Export Pipes and Pumps
for (const pipe of Object.values(model.pipes)) {
const n1 = model.nodes[pipe.node1];
const n2 = model.nodes[pipe.node2];
if (!n1 || !n2) continue;
// Pipe Line entity
add('0', 'LINE');
add('8', 'PIPE');
add('10', n1.x.toFixed(3), '20', n1.y.toFixed(3), '30', '0.0');
add('11', n2.x.toFixed(3), '21', n2.y.toFixed(3), '31', '0.0');
// Pipe Midpoint and Angle
const midX = (n1.x + n2.x) / 2;
const midY = (n1.y + n2.y) / 2;
let angleDeg = Math.atan2(n2.y - n1.y, n2.x - n1.x) * (180 / Math.PI);
if (angleDeg < -90) angleDeg += 180;
if (angleDeg > 90) angleDeg -= 180;
// Pipe Annotation Text: ID, Diameter, Length
const diaStr = `D${pipe.diameter}`;
const lenStr = `L${Math.round(pipe.length)}`;
add('0', 'TEXT');
add('8', 'PIPE_TEXT');
add('10', midX.toFixed(3), '20', (midY + textHeight * 0.4).toFixed(3), '30', '0.0');
add('40', (textHeight * 0.85).toFixed(3));
add('1', `${pipe.id}:${diaStr}-${lenStr}`);
add('50', angleDeg.toFixed(1));
// Hydraulic Flow and Velocity Text entity
if (simulationResult) {
const res = simulationResult.links[pipe.id];
if (res) {
const flowStr = `Q=${res.flow.toFixed(1)} V=${res.velocity.toFixed(2)}`;
add('0', 'TEXT');
add('8', 'PIPE_FLOW');
add('10', midX.toFixed(3), '20', (midY - textHeight * 1.2).toFixed(3), '30', '0.0');
add('40', (textHeight * 0.75).toFixed(3));
add('1', flowStr);
add('50', angleDeg.toFixed(1));
}
}
}
add('0', 'ENDSEC');
add('0', 'EOF');
return lines.join('\r\n');
}
+391
View File
@@ -0,0 +1,391 @@
import { NetworkModel, splitPipe, InpNode, updateNodeAttributes, InpPipe, updatePipeAttributes, InpValve, updateValveAttributes, convertPipeToValve, reversePipeDirection, getNextNodeId, addNode, getNextPipeId, addPipe, deletePipe, deleteNode } from './inpModel';
import { getUnitSystem } from './unitSystem';
import { calculateGeoAwareDistance } from './crs';
import { loadEditOptions, nearestNodeElevation, pipesAtNode, recalcPipeLengths } from './networkTools';
export interface EditActionsContext {
appMode: "explore" | "edit" | "result";
clearSelection: () => void;
crs: import("./crs").CRS;
multiSelection: import("./types").MultiSelectionState;
networkModel: import("./inpModel").NetworkModel | null;
nodeCreationType: "JUNCTION" | "RESERVOIR" | "TANK";
pipeConversionSetting: string;
pipeConversionValveType: string;
redoStack: import("./inpModel").NetworkModel[];
selectedTarget: import("./types").SelectionTarget;
setEditMode: React.Dispatch<React.SetStateAction<import("./types").EditMode>>;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
setNetworkModel: React.Dispatch<React.SetStateAction<import("./inpModel").NetworkModel | null>>;
setRedoStack: React.Dispatch<React.SetStateAction<import("./inpModel").NetworkModel[]>>;
setSelectedTarget: React.Dispatch<React.SetStateAction<import("./types").SelectionTarget>>;
setUndoStack: React.Dispatch<React.SetStateAction<import("./inpModel").NetworkModel[]>>;
showToast: (msg: string) => void;
undoStack: import("./inpModel").NetworkModel[];
}
/**
* Network edit handlers: node/pipe/valve updates, split/reverse/convert, add/delete, batch edits, drag, undo/redo.
* Extracted from App.tsx; App passes its state/handlers in as `ctx`.
*/
export function createEditActions(ctx: EditActionsContext) {
const {
appMode,
clearSelection,
crs,
multiSelection,
networkModel,
nodeCreationType,
pipeConversionSetting,
pipeConversionValveType,
redoStack,
selectedTarget,
setEditMode,
setIsModified,
setNetworkModel,
setRedoStack,
setSelectedTarget,
setUndoStack,
showToast,
undoStack,
} = ctx;
const pushUndo = (modelToSave: NetworkModel | null) => {
if (!modelToSave) return;
setUndoStack((prev) => [...prev.slice(-29), modelToSave]);
setRedoStack([]);
};
const handleUndo = () => {
if (undoStack.length === 0 || !networkModel) return;
const prevModel = undoStack[undoStack.length - 1];
setRedoStack((prev) => [...prev.slice(-29), networkModel]);
setUndoStack((prev) => prev.slice(0, -1));
setNetworkModel(prevModel);
setIsModified(true);
showToast('실행 취소 (Undo) 완료');
};
const handleRedo = () => {
if (redoStack.length === 0 || !networkModel) return;
const nextModel = redoStack[redoStack.length - 1];
setUndoStack((prev) => [...prev.slice(-29), networkModel]);
setRedoStack((prev) => prev.slice(0, -1));
setNetworkModel(nextModel);
setIsModified(true);
showToast('다시 실행 (Redo) 완료');
};
// Split pipe into two segments at midpoint with a new junction
const handleSplitPipe = (pipeId: string) => {
if (!networkModel || !networkModel.pipes[pipeId]) return;
pushUndo(networkModel);
try {
const res = splitPipe(networkModel, pipeId, 0.5);
setNetworkModel(res.model);
setIsModified(true);
setSelectedTarget({ type: 'node', id: res.newJunctionId });
showToast(`관로 [${pipeId}]를 분할하여 절점 [${res.newJunctionId}]와 신규 관로 [${res.newPipeId}]를 생성했습니다.`);
} catch (err: unknown) {
showToast(`관로 분할 실패: ${err instanceof Error ? err.message : String(err)}`);
}
};
// Node Drag End Handler
const handleNodeDragEnd = (id: string, newX: number, newY: number) => {
if (appMode !== 'edit') return;
if (!networkModel) return;
const current = networkModel.nodes[id];
if (!current) return;
pushUndo(networkModel);
// LOCAL coordinates have no real scale: never overwrite real pipe lengths from them.
const autoLength = loadEditOptions().autoLength && crs !== 'LOCAL';
setNetworkModel((prev) => {
if (!prev) return null;
const moved = {
...prev,
nodes: {
...prev.nodes,
[id]: {
...prev.nodes[id],
x: newX,
y: newY,
},
},
};
// 관로길이 자동계산: pipes attached to the moved node follow its new position.
return autoLength ? recalcPipeLengths(moved, crs, pipesAtNode(moved, id)).model : moved;
});
setIsModified(true);
setSelectedTarget({ type: 'node', id });
};
// Node Attribute Update -- routed through the same common mutator as the
// bottom/full attribute tables (updateNodeAttributes), so an id edit here
// renames via renameNode and a demand edit is rejected when the node has a
// preserved [DEMANDS] override, exactly like every other edit surface.
const handleUpdateNode = (id: string, updates: Partial<InpNode>) => {
if (!networkModel) return;
try {
pushUndo(networkModel);
setNetworkModel(updateNodeAttributes(networkModel, id, updates));
setIsModified(true);
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : '절점 수정 오류');
}
};
// Pipe Attribute Update -- routed through updatePipeAttributes (see above).
const handleUpdatePipe = (id: string, updates: Partial<InpPipe>) => {
if (!networkModel) return;
try {
pushUndo(networkModel);
setNetworkModel(updatePipeAttributes(networkModel, id, updates));
setIsModified(true);
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : '관로 수정 오류');
}
};
const handleUpdateValve = (id: string, updates: Partial<InpValve>) => {
if (!networkModel) return;
try {
pushUndo(networkModel);
setNetworkModel(updateValveAttributes(networkModel, id, updates));
setIsModified(true);
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : '밸브 수정 오류');
}
};
const handleConvertPipeToValve = (pipeId: string) => {
if (!networkModel?.pipes[pipeId]) return;
if (!window.confirm(
`관로 [${pipeId}]를 ${pipeConversionValveType} 밸브로 변경하시겠습니까?\n관로 연장·조도계수는 밸브 속성에 포함되지 않습니다.`
)) return;
const numericSetting = Number(pipeConversionSetting);
const setting = pipeConversionSetting.trim() !== '' && Number.isFinite(numericSetting)
? numericSetting
: pipeConversionSetting.trim();
try {
pushUndo(networkModel);
setNetworkModel(convertPipeToValve(networkModel, pipeId, pipeConversionValveType, setting));
setIsModified(true);
setSelectedTarget({ type: 'link', id: pipeId });
showToast(`관로 [${pipeId}]를 ${pipeConversionValveType} 밸브로 변경했습니다.`);
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : '관로를 밸브로 변경하지 못했습니다.');
}
};
// Recalculate pipe length from connected node coordinates
const handleRecalculateLength = (pipeId: string) => {
if (!networkModel) return;
pushUndo(networkModel);
const pipe = networkModel.pipes[pipeId];
if (!pipe) return;
const n1 = networkModel.nodes[pipe.node1];
const n2 = networkModel.nodes[pipe.node2];
if (n1 && n2) {
const lengthUnitSystem = getUnitSystem(networkModel.options.units);
const { distance, isPhysical } = calculateGeoAwareDistance(n1, n2, crs, lengthUnitSystem);
const dist = Math.round(distance * 100) / 100;
if (!isPhysical) {
showToast(`로컬 평면 기준 연장(${dist})으로 계산되었습니다.`);
} else {
showToast(`관로 [${pipeId}] 연장이 좌표 거리(${dist})로 갱신되었습니다.`);
}
handleUpdatePipe(pipeId, { length: dist });
}
};
const handleReversePipe = (pipeId: string) => {
if (!networkModel?.pipes[pipeId]) return;
setNetworkModel(reversePipeDirection(networkModel, pipeId));
pushUndo(networkModel);
setIsModified(true);
setSelectedTarget({ type: 'link', id: pipeId });
showToast(`관로 [${pipeId}]의 시작절점과 끝절점을 교환했습니다. 재해석이 필요합니다.`);
};
// Add a point asset at the exact map coordinate selected by the user.
const handleAddNodeAtCoords = (
x: number,
y: number,
nodeType: InpNode['type'] = nodeCreationType
) => {
if (!networkModel) return;
const newId = getNextNodeId(networkModel);
// 가까운 노드 고도로 입력 (default on); otherwise 0 -- never an arbitrary constant.
const elevation = loadEditOptions().nearestElevation ? nearestNodeElevation(networkModel, x, y) ?? 0 : 0;
const baseNode = {
id: newId,
type: nodeType,
x,
y,
elevation,
} as const;
const newNode: InpNode =
nodeType === 'RESERVOIR'
? { ...baseNode, type: 'RESERVOIR', head: elevation }
: nodeType === 'TANK'
? {
...baseNode,
type: 'TANK',
initLevel: 5,
minLevel: 0,
maxLevel: 10,
diameter: 10,
minVol: 0,
}
: { ...baseNode, type: 'JUNCTION', demand: 0 };
pushUndo(networkModel);
setNetworkModel((prev) => (prev ? addNode(prev, newNode) : null));
setIsModified(true);
setSelectedTarget({ type: 'node', id: newId });
setEditMode('select');
const typeLabel =
nodeType === 'RESERVOIR' ? '저수지' : nodeType === 'TANK' ? '탱크' : '절점';
showToast(`${typeLabel} [${newId}]을(를) 선택한 지도 위치에 추가했습니다.`);
};
// Add Pipe between two nodes
const handleAddPipeBetweenNodes = (node1Id: string, node2Id: string) => {
if (!networkModel) return;
const n1 = networkModel.nodes[node1Id];
const n2 = networkModel.nodes[node2Id];
if (!n1 || !n2) return;
const newId = getNextPipeId(networkModel);
const lengthUnitSystem = getUnitSystem(networkModel.options.units);
const dist = Math.round(calculateGeoAwareDistance(n1, n2, crs, lengthUnitSystem).distance * 100) / 100 || 1000;
const newPipe: InpPipe = {
id: newId,
node1: node1Id,
node2: node2Id,
length: dist,
// 150 mm (SI) / 6 in (US): a common distribution main, in the model's own unit.
diameter: lengthUnitSystem === 'US' ? 6 : 150,
roughness: 100,
minorLoss: 0,
status: 'Open',
};
pushUndo(networkModel);
setNetworkModel((prev) => (prev ? addPipe(prev, newPipe) : null));
setIsModified(true);
setSelectedTarget({ type: 'link', id: newId });
setEditMode('select');
};
// Delete the unified single/multi selection in one confirmed transaction.
const handleDeleteSelected = () => {
if (!networkModel) return;
const nodeIds = new Set(multiSelection.nodes);
const linkIds = new Set(multiSelection.links);
if (selectedTarget?.type === 'node') nodeIds.add(selectedTarget.id);
if (selectedTarget?.type === 'link') linkIds.add(selectedTarget.id);
if (nodeIds.size === 0 && linkIds.size === 0) return;
const confirmMessage =
`선택한 절점 ${nodeIds.size}개와 관로/링크 ${linkIds.size}개를 삭제하시겠습니까?` +
(nodeIds.size > 0 ? '\\n절점에 연결된 관로도 함께 삭제됩니다.' : '');
if (!window.confirm(confirmMessage)) return;
pushUndo(networkModel);
setNetworkModel((previous) => {
if (!previous) return null;
let next = previous;
for (const linkId of linkIds) {
if (next.pipes[linkId]) {
next = deletePipe(next, linkId);
continue;
}
if (next.pumps[linkId] || next.valves?.[linkId]) {
const pumps = { ...next.pumps };
const valves = { ...(next.valves || {}) };
delete pumps[linkId];
delete valves[linkId];
next = {
...next,
pumps,
valves,
controls: next.controls.filter((control) => control.linkId !== linkId),
};
}
}
for (const nodeId of nodeIds) next = deleteNode(next, nodeId);
return next;
});
clearSelection();
setIsModified(true);
showToast(`절점 ${nodeIds.size}개, 관로/링크 ${linkIds.size}개를 삭제했습니다.`);
};
// Batch update handlers for multi-selection -- routed through the same
// updateNodeAttributes/updatePipeAttributes common mutator as every other
// edit surface (see handleUpdateNode/handleUpdatePipe above), applied one
// element at a time so a rejected edit (e.g. a demand change on a node with
// a preserved [DEMANDS] override) aborts the whole batch instead of
// silently applying to some elements and not others.
const handleBatchUpdatePipes = (pipeIds: string[], updates: Partial<InpPipe>) => {
if (!networkModel || pipeIds.length === 0) return;
try {
pushUndo(networkModel);
let next = networkModel;
for (const id of pipeIds) {
if (next.pipes[id]) {
next = updatePipeAttributes(next, id, updates);
}
}
setNetworkModel(next);
setIsModified(true);
showToast(`관로 ${pipeIds.length}개 속성 일괄 변경 완료`);
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : '관로 일괄 수정 오류');
}
};
const handleBatchUpdateNodes = (
nodeIds: string[],
updater: (node: InpNode) => Partial<InpNode>
) => {
if (!networkModel || nodeIds.length === 0) return;
try {
pushUndo(networkModel);
let next = networkModel;
for (const id of nodeIds) {
if (next.nodes[id]) {
next = updateNodeAttributes(next, id, updater(next.nodes[id]));
}
}
setNetworkModel(next);
setIsModified(true);
showToast(`절점 ${nodeIds.length}개 속성 일괄 변경 완료`);
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : '절점 일괄 수정 오류');
}
};
const handleBatchMoveNodes = (nodeIds: string[], dx: number, dy: number) => {
if (!networkModel || nodeIds.length === 0 || (dx === 0 && dy === 0)) return;
pushUndo(networkModel);
const newNodes = { ...networkModel.nodes };
for (const id of nodeIds) {
if (newNodes[id]) {
newNodes[id] = {
...newNodes[id],
x: Math.round((newNodes[id].x + dx) * 100) / 100,
y: Math.round((newNodes[id].y + dy) * 100) / 100,
};
}
}
let newModel = { ...networkModel, nodes: newNodes };
if (loadEditOptions().autoLength && crs !== 'LOCAL') {
const touched = new Set(nodeIds.flatMap((n) => pipesAtNode(newModel, n)));
newModel = recalcPipeLengths(newModel, crs, touched).model;
}
setNetworkModel(newModel);
setIsModified(true);
showToast(`절점 ${nodeIds.length}개 위치 일괄 이동 완료 (ΔX: ${dx}, ΔY: ${dy})`);
};
/** Replace the model with an already-computed edit (tools), with undo and a toast. */
const handleApplyModel = (next: NetworkModel, message: string) => {
if (!networkModel || next === networkModel) {
showToast(message);
return;
}
pushUndo(networkModel);
setNetworkModel(next);
setIsModified(true);
showToast(message);
};
return { pushUndo, handleApplyModel, handleUndo, handleRedo, handleSplitPipe, handleNodeDragEnd, handleUpdateNode, handleUpdatePipe, handleUpdateValve, handleConvertPipeToValve, handleRecalculateLength, handleReversePipe, handleAddNodeAtCoords, handleAddPipeBetweenNodes, handleDeleteSelected, handleBatchUpdatePipes, handleBatchUpdateNodes, handleBatchMoveNodes };
}
+288
View File
@@ -0,0 +1,288 @@
// Geometry / topology editing tools (편집 도구): transform, equal-interval and
// connection split, node dissolve / merge, pipe auto-check and flow tracing.
// Pure functions returning a new model; the UI applies them with undo.
import type { NetworkModel, VertexPoint } from './inpModel';
import { deletePipe, splitPipe } from './inpModel.ts';
import { getDemandCategories, setDemandCategories } from './extrasActions.ts';
import type { SimulationResult } from './types';
// ---------------------------------------------------------------- transform
export interface Transform {
dx?: number;
dy?: number;
/** Degrees, counter-clockwise, about `origin`. */
rotate?: number;
scale?: number;
origin?: VertexPoint;
}
/** Similarity transform (scale + rotation + shift) mapping a1->b1 and a2->b2. */
export function similarityFrom2Points(a1: VertexPoint, a2: VertexPoint, b1: VertexPoint, b2: VertexPoint): (p: VertexPoint) => VertexPoint {
const ax = a2.x - a1.x, ay = a2.y - a1.y;
const bx = b2.x - b1.x, by = b2.y - b1.y;
const den = ax * ax + ay * ay;
if (den === 0) throw new Error('기준점 두 개가 같은 위치입니다.');
// b = s*R*a: complex division (bx + i by) / (ax + i ay)
const c = (bx * ax + by * ay) / den;
const s = (by * ax - bx * ay) / den;
return (p) => ({ x: b1.x + c * (p.x - a1.x) - s * (p.y - a1.y), y: b1.y + s * (p.x - a1.x) + c * (p.y - a1.y) });
}
export function transformFn(t: Transform): (p: VertexPoint) => VertexPoint {
const o = t.origin ?? { x: 0, y: 0 };
const k = t.scale ?? 1;
const a = ((t.rotate ?? 0) * Math.PI) / 180;
const cos = Math.cos(a), sin = Math.sin(a);
return (p) => {
const x = (p.x - o.x) * k, y = (p.y - o.y) * k;
return { x: o.x + x * cos - y * sin + (t.dx ?? 0), y: o.y + x * sin + y * cos + (t.dy ?? 0) };
};
}
const round = (v: number) => Math.round(v * 1e6) / 1e6;
/**
* Apply `fn` to the given nodes (default all) and to the vertices of links
* whose both ends move. Lengths are not changed (use 관로길이 재계산 after).
*/
export function transformNetwork(model: NetworkModel, fn: (p: VertexPoint) => VertexPoint, nodeIds?: string[]): { model: NetworkModel; moved: number } {
const ids = new Set(nodeIds ?? Object.keys(model.nodes));
const nodes = { ...model.nodes };
for (const id of ids) {
const n = nodes[id];
if (!n) continue;
const q = fn(n);
nodes[id] = { ...n, x: round(q.x), y: round(q.y) };
}
let vertices = model.vertices;
if (vertices) {
const links = [...Object.values(model.pipes), ...Object.values(model.pumps), ...Object.values(model.valves ?? {})];
const next = { ...vertices };
for (const l of links) {
if (next[l.id] && ids.has(l.node1) && ids.has(l.node2)) next[l.id] = next[l.id].map((v) => { const q = fn(v); return { x: round(q.x), y: round(q.y) }; });
}
vertices = next;
}
const labels = model.extras?.labels;
const extras = labels?.length && !nodeIds ? { ...model.extras!, labels: labels.map((l) => { const q = fn(l); return { ...l, x: round(q.x), y: round(q.y) }; }) } : model.extras;
return { model: { ...model, nodes, vertices, extras }, moved: [...ids].filter((id) => model.nodes[id]).length };
}
export function centroid(model: NetworkModel, nodeIds?: string[]): VertexPoint {
const pts = (nodeIds ?? Object.keys(model.nodes)).map((id) => model.nodes[id]).filter(Boolean);
if (!pts.length) return { x: 0, y: 0 };
return { x: pts.reduce((a, p) => a + p.x, 0) / pts.length, y: pts.reduce((a, p) => a + p.y, 0) / pts.length };
}
// ---------------------------------------------------------------- splitting
/** Split a pipe into `parts` equal-length pieces (n-1 new junctions). */
export function splitPipeEqual(model: NetworkModel, pipeId: string, parts: number): { model: NetworkModel; junctions: string[]; pipes: string[] } {
const n = Math.floor(parts);
if (!(n >= 2 && n <= 200)) throw new Error('2~200 사이로 나누세요.');
let m = model;
let current = pipeId;
const junctions: string[] = [];
const pipes = [pipeId];
for (let k = n; k >= 2; k--) {
const r = splitPipe(m, current, 1 / k);
m = r.model;
junctions.push(r.newJunctionId);
pipes.push(r.newPipeId);
current = r.newPipeId;
}
return { model: m, junctions, pipes };
}
/** Split a pipe into pieces no longer than `maxLength` (model length units). */
export function splitPipeByLength(model: NetworkModel, pipeId: string, maxLength: number) {
const pipe = model.pipes[pipeId];
if (!pipe) throw new Error(`관로 ${pipeId}가 없습니다.`);
if (!(maxLength > 0)) throw new Error('간격은 0보다 커야 합니다.');
const parts = Math.ceil(pipe.length / maxLength - 1e-9);
return parts >= 2 ? splitPipeEqual(model, pipeId, parts) : { model, junctions: [], pipes: [pipeId] };
}
/**
* 접속 분할: split `pipeId` at the point nearest to `nodeId` and connect the
* node there (the split junction is merged into the node).
*/
export function connectNodeToPipe(model: NetworkModel, nodeId: string, pipeId: string): { model: NetworkModel; newPipeId: string } {
const node = model.nodes[nodeId];
const pipe = model.pipes[pipeId];
if (!node || !pipe) throw new Error('절점 또는 관로가 없습니다.');
if (pipe.node1 === nodeId || pipe.node2 === nodeId) throw new Error('이미 이 관로의 끝 절점입니다.');
const r = splitPipe(model, pipeId, 0.5, { x: node.x, y: node.y });
return { model: mergeNodes(r.model, nodeId, r.newJunctionId).model, newPipeId: r.newPipeId };
}
// ---------------------------------------------------------------- dissolve / merge
/**
* 절점 제거: a junction joining exactly two pipes is removed and the pipes are
* joined into one (lengths and minor losses add, the node becomes a vertex).
* Refused when the node carries demand/emitter/quality data or the pipes differ
* in diameter/roughness (that would change the hydraulics).
*/
export function dissolveNode(model: NetworkModel, nodeId: string): NetworkModel {
const node = model.nodes[nodeId];
if (!node || node.type !== 'JUNCTION') throw new Error(`${nodeId}는 접합점이 아닙니다.`);
const links = [...Object.values(model.pumps), ...Object.values(model.valves ?? {})].filter((l) => l.node1 === nodeId || l.node2 === nodeId);
if (links.length) throw new Error('펌프·밸브가 연결된 절점은 제거할 수 없습니다.');
const pipes = Object.values(model.pipes).filter((p) => p.node1 === nodeId || p.node2 === nodeId);
if (pipes.length !== 2) throw new Error(`관로 2개가 만나는 절점만 제거할 수 있습니다 (현재 ${pipes.length}개).`);
const [a, b] = pipes;
if (getDemandCategories(model, nodeId).some((c) => c.demand !== 0) || model.extras?.emitters[nodeId] || model.extras?.sources[nodeId]) {
throw new Error('수요·이미터·수원 수질이 있는 절점은 제거할 수 없습니다. 먼저 옮기거나 지우세요.');
}
if (a.diameter !== b.diameter || a.roughness !== b.roughness) throw new Error('양쪽 관로의 관경·조도가 달라 합칠 수 없습니다.');
if (model.controls.some((c) => c.nodeId === nodeId)) throw new Error('컨트롤이 이 절점을 참조합니다.');
// Orient: a runs other->node, b runs node->other.
const va = model.vertices?.[a.id] ?? [];
const vb = model.vertices?.[b.id] ?? [];
const aStart = a.node1 === nodeId ? a.node2 : a.node1;
const aVerts = a.node2 === nodeId ? va : [...va].reverse();
const bEnd = b.node1 === nodeId ? b.node2 : b.node1;
const bVerts = b.node1 === nodeId ? vb : [...vb].reverse();
if (aStart === bEnd) throw new Error('두 관로가 같은 절점을 잇고 있어 합치면 자기 자신으로 돌아오는 관로가 됩니다.');
let m = deletePipe(model, b.id);
m = {
...m,
pipes: { ...m.pipes, [a.id]: { ...a, node1: aStart, node2: bEnd, length: Math.round((a.length + b.length) * 1e4) / 1e4, minorLoss: Math.round((a.minorLoss + b.minorLoss) * 1e4) / 1e4 } },
vertices: { ...(m.vertices ?? {}), [a.id]: [...aVerts, { x: node.x, y: node.y }, ...bVerts] },
};
const nodes = { ...m.nodes };
delete nodes[nodeId];
return { ...m, nodes, extras: m.extras ? { ...m.extras, quality: omit(m.extras.quality, nodeId), demands: omit(m.extras.demands, nodeId) } : m.extras };
}
function omit<T>(rec: Record<string, T>, id: string): Record<string, T> {
if (!(id in rec)) return rec;
const out = { ...rec };
delete out[id];
return out;
}
/**
* 절점 병합: reconnect every link of `dropId` to `keepId`, move its demand
* categories over, delete links that would join keep to itself, then delete it.
*/
export function mergeNodes(model: NetworkModel, keepId: string, dropId: string): { model: NetworkModel; removedLinks: string[] } {
if (keepId === dropId) throw new Error('같은 절점입니다.');
const keep = model.nodes[keepId];
const drop = model.nodes[dropId];
if (!keep || !drop) throw new Error('절점이 없습니다.');
if (drop.type !== 'JUNCTION') throw new Error(`${dropId}는 ${drop.type === 'TANK' ? '탱크' : '배수지'}입니다. 남길 절점으로 지정하세요.`);
if (model.controls.some((c) => c.nodeId === dropId) || model.rules.some((r) => r.split(/\s+/).includes(dropId))) {
throw new Error(`컨트롤·규칙이 ${dropId}를 참조합니다. 먼저 고치세요.`);
}
const removed: string[] = [];
const relink = <T extends { id: string; node1: string; node2: string }>(rec: Record<string, T>): Record<string, T> => {
const out: Record<string, T> = {};
for (const [id, l] of Object.entries(rec)) {
const n1 = l.node1 === dropId ? keepId : l.node1;
const n2 = l.node2 === dropId ? keepId : l.node2;
if (n1 === n2 && (l.node1 === dropId || l.node2 === dropId)) { removed.push(id); continue; }
out[id] = n1 === l.node1 && n2 === l.node2 ? l : { ...l, node1: n1, node2: n2 };
}
return out;
};
let m: NetworkModel = { ...model, pipes: relink(model.pipes), pumps: relink(model.pumps), valves: relink(model.valves ?? {}) };
if (keep.type === 'JUNCTION' && drop.type === 'JUNCTION') {
const cats = [...getDemandCategories(m, keepId), ...getDemandCategories(m, dropId)].filter((c) => c.demand !== 0 || c.category);
m = setDemandCategories(m, keepId, cats.length ? cats : [{ demand: 0 }]);
}
m = {
...m,
controls: m.controls.filter((c) => !removed.includes(c.linkId)),
vertices: m.vertices ? Object.fromEntries(Object.entries(m.vertices).filter(([id]) => !removed.includes(id))) : m.vertices,
};
const nodes = { ...m.nodes };
delete nodes[dropId];
m = { ...m, nodes };
if (m.extras) m = { ...m, extras: { ...m.extras, demands: omit(m.extras.demands, dropId), emitters: omit(m.extras.emitters, dropId), quality: omit(m.extras.quality, dropId), sources: omit(m.extras.sources, dropId) } };
if (m.assets?.nodes[dropId]) m = { ...m, assets: { ...m.assets, nodes: omit(m.assets.nodes, dropId) } };
return { model: m, removedLinks: removed };
}
// ---------------------------------------------------------------- auto check
export interface PipeIssues {
selfLoops: string[];
nonPositiveLength: string[];
zeroDiameter: string[];
/** Groups of pipes joining the same node pair. */
parallel: string[][];
/** Junctions without any link. */
orphanNodes: string[];
/** Pipes whose end nodes are missing. */
dangling: string[];
}
export function findPipeIssues(model: NetworkModel): PipeIssues {
const out: PipeIssues = { selfLoops: [], nonPositiveLength: [], zeroDiameter: [], parallel: [], orphanNodes: [], dangling: [] };
const pairs = new Map<string, string[]>();
const used = new Set<string>();
for (const l of [...Object.values(model.pumps), ...Object.values(model.valves ?? {})]) used.add(l.node1).add(l.node2);
for (const p of Object.values(model.pipes)) {
used.add(p.node1).add(p.node2);
if (!model.nodes[p.node1] || !model.nodes[p.node2]) out.dangling.push(p.id);
if (p.node1 === p.node2) { out.selfLoops.push(p.id); continue; }
if (!(p.length > 0)) out.nonPositiveLength.push(p.id);
if (!(p.diameter > 0)) out.zeroDiameter.push(p.id);
const key = [p.node1, p.node2].sort().join('\u0000');
pairs.set(key, [...(pairs.get(key) ?? []), p.id]);
}
out.parallel = [...pairs.values()].filter((g) => g.length > 1);
out.orphanNodes = Object.values(model.nodes).filter((n) => n.type === 'JUNCTION' && !used.has(n.id)).map((n) => n.id);
return out;
}
/** Safe automatic fixes: drop self loops, dangling pipes and orphan junctions without demand. */
export function autoFixPipes(model: NetworkModel): { model: NetworkModel; removedPipes: string[]; removedNodes: string[] } {
const issues = findPipeIssues(model);
let m = model;
const removedPipes = [...new Set([...issues.selfLoops, ...issues.dangling])];
for (const id of removedPipes) m = deletePipe(m, id);
const removedNodes = issues.orphanNodes.filter((id) => getDemandCategories(m, id).every((c) => c.demand === 0) && !m.extras?.emitters[id] && !m.controls.some((c) => c.nodeId === id));
if (removedNodes.length) {
const nodes = { ...m.nodes };
for (const id of removedNodes) delete nodes[id];
m = { ...m, nodes };
}
return { model: m, removedPipes, removedNodes };
}
// ---------------------------------------------------------------- tracing
/**
* 상류/하류 추적 along the mean flow direction of a result (links with no flow
* stop the trace). Returns the reached nodes and links, start node included.
*/
export function traceFlow(model: NetworkModel, result: SimulationResult, startId: string, dir: 'up' | 'down'): { nodes: string[]; links: string[] } {
const adj = new Map<string, Array<{ link: string; to: string }>>();
const links = [...Object.values(model.pipes), ...Object.values(model.pumps), ...Object.values(model.valves ?? {})];
for (const l of links) {
const r = result.links[l.id];
if (!r) continue;
const s = r.timeSeries?.flow?.length ? r.timeSeries.flow : [r.flow];
const mean = s.reduce((a, b) => a + b, 0) / s.length;
const eps = 1e-6 * Math.max(1, ...s.map(Math.abs));
if (Math.abs(mean) <= eps) continue;
const [from, to] = mean > 0 ? [l.node1, l.node2] : [l.node2, l.node1];
const [a, b] = dir === 'down' ? [from, to] : [to, from];
adj.set(a, [...(adj.get(a) ?? []), { link: l.id, to: b }]);
}
const seen = new Set([startId]);
const seenLinks = new Set<string>();
const queue = [startId];
while (queue.length) {
const cur = queue.shift()!;
for (const e of adj.get(cur) ?? []) {
seenLinks.add(e.link);
if (!seen.has(e.to)) { seen.add(e.to); queue.push(e.to); }
}
}
return { nodes: [...seen], links: [...seenLinks] };
}
+261
View File
@@ -0,0 +1,261 @@
import * as XLSX from 'xlsx';
import type { NetworkModel } from './inpModel';
import type { SimulationResult } from './types';
import { inspectSimulation } from './convergence';
import { computeUnitHeadloss, resolveNativeThresholds } from './unitSystem';
import { formatTimeLabel, seriesValue } from './resultSync';
interface ExcelExportParams {
model: NetworkModel;
simulationResult: SimulationResult;
scenarioName?: string;
crs?: string;
/**
* The time-series index currently selected on screen. Every value pulled
* from simulationResult falls back to timeSeries[timeStepIndex] before the
* result's flat (first-step) field, so the export matches whatever time
* the user was looking at -- not always the very first analysis instant.
* Omit for a steady-state (non-EPS) result, where there is only one value.
*/
timeStepIndex?: number;
/**
* Project-wide judgement criteria in Korean/metric units (m / m/s) -- the
* same user-adjustable setting the map's violation highlighting uses (see
* App.tsx). Converted below into the model's native units, since this
* report's node/link rows are in raw EPANET units. Without this, the
* report defined its own separate min-pressure/max-velocity standard that
* silently disagreed with the map's (e.g. 2.5 m/s here vs 3 m/s there).
*/
minPressureThreshold?: number;
maxVelocityThreshold?: number;
}
/**
* Generates and downloads an Excel Hydraulic Calculation Workbook (.xlsx)
* as a preliminary engineering review report based on EPANET engine outputs.
*/
export function exportHydraulicCalculationXlsx({
model,
simulationResult,
scenarioName = '원안',
crs = 'LOCAL',
timeStepIndex,
minPressureThreshold = 15,
maxVelocityThreshold = 2.5,
}: ExcelExportParams): void {
const wb = XLSX.utils.book_new();
const pressUnit = simulationResult.units.pressure;
const flowUnit = simulationResult.units.flow;
const timeLabel = formatTimeLabel(simulationResult.timeSteps, timeStepIndex);
// Converted once into the model's native units so each row's raw (unconverted)
// pressure/velocity value can be compared directly against them.
const { minPressureNative, maxVelocityNative } = resolveNativeThresholds(
minPressureThreshold,
maxVelocityThreshold,
pressUnit,
flowUnit,
model.options?.specificGravity ?? 1
);
// -------------------------------------------------------------
// Sheet 1: 1.절점_수리계산서 (Node Hydraulic Calculations)
// -------------------------------------------------------------
const nodeRows = Object.values(model.nodes).map((node, idx) => {
const res = simulationResult.nodes[node.id];
const elev = Math.round(node.elevation * 100) / 100;
const baseDemand = Math.round((node.demand ?? 0) * 100) / 100;
const rawHead = res ? seriesValue(res.timeSeries?.head, timeStepIndex, res.head) : elev;
const rawPressure = res ? seriesValue(res.timeSeries?.pressure, timeStepIndex, res.pressure) : 0;
const head = Math.round(rawHead * 100) / 100;
const pressure = Math.round(rawPressure * 100) / 100;
let evaluation = '적정 수압';
if (node.type === 'JUNCTION') {
const minThreshold = minPressureNative;
const maxThreshold = pressUnit === 'psi' ? 100 : 70;
if (pressure < minThreshold) evaluation = '수압 부족 (기준 미달)';
else if (pressure > maxThreshold) evaluation = '과압 (감압밸브 필요)';
} else if (node.type === 'RESERVOIR') {
evaluation = '배수지 (상수원)';
} else if (node.type === 'TANK') {
evaluation = '조절지 (탱크)';
}
return {
순번: idx + 1,
절점_ID: node.id,
시설_유형: node.type,
X_좌표: node.x,
Y_좌표: node.y,
지반고: elev,
기준_수요량: baseDemand,
수리동수두: head,
동수압: pressure,
수압_판정: evaluation,
};
});
const wsNodes = XLSX.utils.json_to_sheet(nodeRows);
wsNodes['!cols'] = [
{ wch: 6 }, // 순번
{ wch: 12 }, // 절점_ID
{ wch: 12 }, // 시설_유형
{ wch: 14 }, // X_좌표
{ wch: 14 }, // Y_좌표
{ wch: 12 }, // 지반고
{ wch: 14 }, // 기준_수요량
{ wch: 14 }, // 수리동수두
{ wch: 14 }, // 동수압
{ wch: 24 }, // 수압_판정
];
XLSX.utils.book_append_sheet(wb, wsNodes, '1.절점_수리계산서');
// -------------------------------------------------------------
// Sheet 2: 2.관로_수리계산서 (Link Hydraulic Calculations)
// -------------------------------------------------------------
const linkRows = Object.values(model.pipes).map((pipe, idx) => {
const res = simulationResult.links[pipe.id];
const length = Math.round(pipe.length * 100) / 100;
const diameter = pipe.diameter;
const roughness = pipe.roughness;
const flow = res ? Math.round(seriesValue(res.timeSeries?.flow, timeStepIndex, res.flow) * 100) / 100 : 0;
const velocity = res ? Math.round(seriesValue(res.timeSeries?.velocity, timeStepIndex, res.velocity) * 100) / 100 : 0;
const headloss = res ? Math.round(seriesValue(res.timeSeries?.headloss, timeStepIndex, res.headloss) * 100) / 100 : 0;
// Hydraulic gradient (I = 1000 * hL / L) in per mil (‰) -- length-normalized,
// unlike the raw total headloss above, so it stays comparable across pipes.
const gradient = Math.round(computeUnitHeadloss(headloss, length) * 100) / 100;
let evaluation = '적정 유속';
const isUsSystem = pressUnit === 'psi';
const minVel = isUsSystem ? 1.0 : 0.3;
const maxVel = maxVelocityNative;
if (Math.abs(flow) < 0.001) {
evaluation = '정체 / 무유량';
} else if (velocity < minVel) {
evaluation = '유속 미달 (정체 우려)';
} else if (velocity > maxVel) {
evaluation = '유속 과다 (마모 우려)';
}
return {
순번: idx + 1,
관로_ID: pipe.id,
시작_절점: pipe.node1,
끝_절점: pipe.node2,
관경: diameter,
관로_연장: length,
조도계수_C: roughness,
유량: flow,
유속: velocity,
마찰손실: headloss,
동수경사_1000I: gradient,
유속_판정: evaluation,
개폐_상태: pipe.status,
};
});
const wsLinks = XLSX.utils.json_to_sheet(linkRows);
wsLinks['!cols'] = [
{ wch: 6 }, // 순번
{ wch: 12 }, // 관로_ID
{ wch: 12 }, // 시작_절점
{ wch: 12 }, // 끝_절점
{ wch: 12 }, // 관경
{ wch: 14 }, // 관로_연장
{ wch: 12 }, // 조도계수
{ wch: 14 }, // 유량
{ wch: 12 }, // 유속
{ wch: 14 }, // 마찰손실
{ wch: 16 }, // 동수경사
{ wch: 22 }, // 유속_판정
{ wch: 12 }, // 개폐_상태
];
XLSX.utils.book_append_sheet(wb, wsLinks, '2.관로_수리계산서');
// -------------------------------------------------------------
// Sheet 3: 3.총괄_수리요약 (Executive Summary)
// -------------------------------------------------------------
const isUsSystem = pressUnit === 'psi';
const totalLength = Object.values(model.pipes).reduce((a, p) => a + p.length, 0);
const totalDemand = Object.values(model.nodes).reduce((a, n) => a + (n.demand ?? 0), 0);
const pressures = Object.values(simulationResult.nodes).map((n) =>
seriesValue(n.timeSeries?.pressure, timeStepIndex, n.pressure)
);
const velocities = Object.values(simulationResult.links).map((l) =>
seriesValue(l.timeSeries?.velocity, timeStepIndex, l.velocity)
);
const minPress = pressures.length ? Math.min(...pressures) : 0;
const maxPress = pressures.length ? Math.max(...pressures) : 0;
const avgPress = pressures.length ? pressures.reduce((a, b) => a + b, 0) / pressures.length : 0;
const minVel = velocities.length ? Math.min(...velocities) : 0;
const maxVel = velocities.length ? Math.max(...velocities) : 0;
const avgVel = velocities.length ? velocities.reduce((a, b) => a + b, 0) / velocities.length : 0;
const summaryRows = [
{ 구분: '프로젝트 및 시나리오', 항목: '시나리오 명칭', 값: scenarioName, 비고: '설계 대안 구분' },
{ 구분: '프로젝트 및 시나리오', 항목: '적용 좌표계', 값: crs, 비고: '국가표준/로컬' },
// Which instant this entire workbook's node/link values were taken from --
// without this, a report exported at, say, 08:00 is indistinguishable
// from one exported at the very first analysis instant.
{ 구분: '프로젝트 및 시나리오', 항목: '반출 기준 시각', 값: timeLabel, 비고: '화면에서 선택된 시각 기준' },
{ 구분: '수리해석 엔진', 항목: '코어 엔진 버전', 값: `OWA-EPANET ${simulationResult.engine.version}`, 비고: '국제 공인 단일 C 코어' },
{ 구분: '수리해석 설정', 항목: '마찰손실 공식', 값: model.options?.headloss || 'H-W', 비고: 'Hazen-Williams / Darcy-Weisbach' },
{ 구분: '수리해석 설정', 항목: '유량 단위계', 값: flowUnit, 비고: '설계 표준 단위계' },
{ 구분: '수리해석 설정', 항목: '수압 단위계', 값: pressUnit, 비고: '설계 표준 수압계' },
{ 구분: '수리해석 설정', 항목: '판정 기준 (최저 동수압)', 값: `${minPressureThreshold} m`, 비고: '지도 화면과 동일한 프로젝트 공통 기준' },
{ 구분: '수리해석 설정', 항목: '판정 기준 (최대 유속)', 값: `${maxVelocityThreshold} m/s`, 비고: '지도 화면과 동일한 프로젝트 공통 기준' },
{ 구분: '관망 제원 집계', 항목: '총 절점 수', 값: Object.keys(model.nodes).length, 비고: '개소' },
{ 구분: '관망 제원 집계', 항목: '총 관로 수', 값: Object.keys(model.pipes).length, 비고: '구간' },
{ 구분: '관망 제원 집계', 항목: '총 배관 연장', 값: Math.round(totalLength * 100) / 100, 비고: isUsSystem ? 'ft' : 'm' },
{ 구분: '수리해석 결과 요약', 항목: '총 급수 수요량', 값: Math.round(totalDemand * 100) / 100, 비고: flowUnit },
{ 구분: '수리해석 결과 요약', 항목: '관망 최저 동수압', 값: Math.round(minPress * 100) / 100, 비고: pressUnit },
{ 구분: '수리해석 결과 요약', 항목: '관망 평균 동수압', 값: Math.round(avgPress * 100) / 100, 비고: pressUnit },
{ 구분: '수리해석 결과 요약', 항목: '관망 최고 동수압', 값: Math.round(maxPress * 100) / 100, 비고: pressUnit },
{ 구분: '수리해석 결과 요약', 항목: '관망 평균 동수압', 값: Math.round(avgPress * 100) / 100, 비고: pressUnit },
{ 구분: '수리해석 결과 요약', 항목: '관로 평균 유속', 값: Math.round(avgVel * 100) / 100, 비고: isUsSystem ? 'ft/s' : 'm/s' },
{ 구분: '수리해석 결과 요약', 항목: '관로 최대 유속', 값: Math.round(maxVel * 100) / 100, 비고: isUsSystem ? 'ft/s' : 'm/s' },
];
const { status: convergenceText, note: convergenceNote } = inspectSimulation(simulationResult);
summaryRows.push({ 구분: '해석 수렴 상태', 항목: '수렴 성공 여부', 값: convergenceText, 비고: convergenceNote });
if (simulationResult.energySummary) {
summaryRows.push(
{ 구분: '펌프 에너지 분석', 항목: '해석기간 총 소비전력', 값: simulationResult.energySummary.totalKwh, 비고: 'kWh (해석 기간 적분)' },
{ 구분: '펌프 에너지 분석', 항목: '펌프 피크 전력', 값: simulationResult.energySummary.peakKw, 비고: 'kW (동시 가동 최대)' }
);
for (const [pid, p] of Object.entries(simulationResult.energySummary.pumps)) {
summaryRows.push({
구분: '펌프 에너지 분석',
항목: `펌프 ${pid} 가동 지표`,
값: `${p.totalKwh} kWh (효율 ${p.avgEfficiency}%, 가동률 ${p.utilization}%)`,
비고: `피크 ${p.peakKw} kW`,
});
}
}
const wsSummary = XLSX.utils.json_to_sheet(summaryRows);
wsSummary['!cols'] = [
{ wch: 22 },
{ wch: 20 },
{ wch: 26 },
{ wch: 30 },
];
XLSX.utils.book_append_sheet(wb, wsSummary, '3.총괄_수리요약');
// Generate binary XLSX workbook and trigger browser download
const safeName = scenarioName.replace(/[^\w\uAC00-\uD7A3]/g, '_');
const fileName = `수리계산서_${safeName}.xlsx`;
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([wbout], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
+155
View File
@@ -0,0 +1,155 @@
import { downloadCsv, buildEpsResultsCsv, buildScenarioDiffCsv, downloadBlob } from './csvExport';
import { drawLegendOnCanvas } from './Legend';
import { generateDxf } from './dxfExport';
import { generateInp } from './inpModel';
import { exportNetworkShapefileZip } from './shapefileExport';
export interface ExportActionsContext {
activeScenarioId: string;
crs: import("./crs").CRS;
displayUnits: { readonly pressure: string; readonly head: "m" | "ft"; readonly flow: string; readonly demand: string; readonly velocity: "m/s" | "ft/s"; readonly length: "m" | "ft"; readonly diameter: "mm" | "in"; };
initialResult: import("./types").SimulationResult | null;
isResultStale: boolean;
maxVelocityThreshold: number;
minPressureThreshold: number;
networkModel: import("./inpModel").NetworkModel | null;
scenarios: import("./types").Scenario[];
showToast: (msg: string) => void;
timeStepIndex: number;
wasmResult: import("./types").SimulationResult | null;
}
/**
* Export handlers: INP/CSV/DXF/XLSX/Shapefile/PNG and the stale-result guard.
* Extracted from App.tsx; App passes its state/handlers in as `ctx`.
*/
export function createExportActions(ctx: ExportActionsContext) {
const {
activeScenarioId,
crs,
displayUnits,
initialResult,
isResultStale,
maxVelocityThreshold,
minPressureThreshold,
networkModel,
scenarios,
showToast,
timeStepIndex,
wasmResult,
} = ctx;
// Guards result-based exports (CSV/DXF/XLSX/Diff-CSV) against silently mixing an
// outdated simulation result with a network model that has since been edited.
const confirmStaleExport = (): boolean => {
if (!isResultStale) return true;
return window.confirm(
'관망이 수정된 후 아직 재해석되지 않았습니다. 이전(수정 전) 해석 결과를 그대로 내보내시겠습니까?\n' +
'취소를 누르고 "관망 재해석"을 먼저 실행하면 최신 결과를 반출할 수 있습니다.'
);
};
// Export 24h EPS results to CSV
const handleExportCSV = () => {
if (!wasmResult) return;
if (!confirmStaleExport()) return;
downloadCsv(buildEpsResultsCsv(wasmResult), 'epanet_eps_results.csv');
};
// Capture Map as PNG Image
// Capture Map as PNG Image with Composited Legend (Q4)
const handleCaptureMap = () => {
const canvas = document.querySelector('canvas.maplibregl-canvas') as HTMLCanvasElement | null;
if (!canvas) {
alert('지도 캔버스를 찾을 수 없습니다.');
return;
}
try {
const compositeCanvas = document.createElement('canvas');
compositeCanvas.width = canvas.width;
compositeCanvas.height = canvas.height;
const ctx = compositeCanvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas 2D context creation failed');
}
// Draw base map canvas
ctx.drawImage(canvas, 0, 0);
// Composite the legend currently shown on the map onto the exported image (Q4)
// Reuses the component-level displayUnits (already resolved against the
// engine's actual reported pressure unit), rather than re-deriving a
// separate flow-unit-based guess here.
const dpr = window.devicePixelRatio || 1;
drawLegendOnCanvas(
ctx,
canvas.width,
canvas.height,
dpr
);
const dataUrl = compositeCanvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
a.download = `spipe_map_${new Date().toISOString().slice(0, 10)}.png`;
a.click();
showToast('도면 및 범례가 합성된 지도 이미지가 저장되었습니다.');
} catch (err: unknown) {
showToast('지도 캡처 실패');
}
};
// Export Scenario Comparison CSV
const handleExportDiffCSV = () => {
const base = scenarios.find((s) => s.id === 'base')?.result || initialResult;
if (!base || !wasmResult) return;
if (!confirmStaleExport()) return;
downloadCsv(buildScenarioDiffCsv(base, wasmResult), `scenario_comparison_${activeScenarioId}.csv`);
};
// Export AutoCAD DXF Drawing
const handleExportDXF = () => {
if (!networkModel) return;
if (wasmResult && !confirmStaleExport()) return;
const dxfContent = generateDxf({
model: networkModel,
simulationResult: wasmResult,
scenarioName: activeScenarioId,
});
downloadBlob(dxfContent, `spipe_network_${activeScenarioId}.dxf`, 'application/dxf;charset=ascii');
};
// Export Standard Hydraulic Calculation Workbook (.xlsx)
const handleExportXlsx = async () => {
if (!networkModel || !wasmResult) return;
if (!confirmStaleExport()) return;
// SheetJS is ~400 kB; load it only when a workbook is actually exported.
const { exportHydraulicCalculationXlsx } = await import('./excelExport');
exportHydraulicCalculationXlsx({
model: networkModel,
simulationResult: wasmResult,
scenarioName: activeScenarioId,
crs,
timeStepIndex,
minPressureThreshold,
maxVelocityThreshold,
});
};
// Export direct EPANET .inp file
const handleExportINP = () => {
if (!networkModel) return;
const content = generateInp(networkModel);
downloadBlob(content, `spipe_network_${activeScenarioId}.inp`, 'text/plain;charset=utf-8');
};
// Export GIS Shapefile ZIP (.shp, .shx, .dbf, .prj)
const handleExportShapefile = () => {
if (!networkModel) return;
if (wasmResult && !confirmStaleExport()) return;
const zipBytes = exportNetworkShapefileZip(networkModel, wasmResult, crs);
downloadBlob(zipBytes, `spipe_shapefile_${activeScenarioId}.zip`, 'application/zip');
showToast('관망 Shapefile (.zip) 내보내기 완료 (nodes + pipes)');
};
// Honest notice for .net export
const handleExportNET = () => {
alert(
'※ 정식 .net 바이너리 내보내기는 현재 지원되지 않습니다.\n' +
'데스크톱 EPANET에서 바로 열 수 있는 정식 [.inp 내보내기] 버튼을 사용해주세요.'
);
};
return { confirmStaleExport, handleExportCSV, handleCaptureMap, handleExportDiffCSV, handleExportDXF, handleExportXlsx, handleExportINP, handleExportShapefile, handleExportNET };
}
+212
View File
@@ -0,0 +1,212 @@
// Immutable edits of NetworkModel.extras (structured INP sections) for the
// property editors and the demand table. Every function returns a new model.
import type { NetworkModel } from './inpModel';
import {
emptyExtras,
type DemandCategory,
type KeyValue,
type MapLabel,
type MixingDef,
type ModelExtras,
type PumpEnergy,
type SourceDef,
} from './inpExtras.ts';
function withExtras(model: NetworkModel, fn: (ex: ModelExtras) => ModelExtras): NetworkModel {
return { ...model, extras: fn(model.extras ?? emptyExtras()) };
}
function setKey<T>(rec: Record<string, T>, id: string, value: T | null | undefined): Record<string, T> {
const out = { ...rec };
if (value === null || value === undefined) delete out[id];
else out[id] = value;
return out;
}
/** Demand categories of a junction: [DEMANDS] rows, or its single base demand. */
export function getDemandCategories(model: NetworkModel, nodeId: string): DemandCategory[] {
const cats = model.extras?.demands[nodeId];
if (cats?.length) return cats;
const n = model.nodes[nodeId];
return n ? [{ demand: n.demand ?? 0, pattern: n.pattern || undefined }] : [];
}
/** Total base demand (sum of categories). */
export function totalBaseDemand(model: NetworkModel, nodeId: string): number {
return getDemandCategories(model, nodeId).reduce((a, c) => a + c.demand, 0);
}
/**
* Replace a junction's demand categories. One unnamed category is written back
* as the plain [JUNCTIONS] demand (no [DEMANDS] rows); otherwise [DEMANDS]
* holds them all and the junction line mirrors the first (primary) one.
*/
export function setDemandCategories(model: NetworkModel, nodeId: string, cats: DemandCategory[]): NetworkModel {
const node = model.nodes[nodeId];
if (!node || node.type !== 'JUNCTION') throw new Error(`접합점 ${nodeId}가 없습니다.`);
const clean = cats.filter((c) => Number.isFinite(c.demand)).map((c) => ({
demand: c.demand,
pattern: c.pattern?.trim() || undefined,
category: c.category?.trim() || undefined,
}));
const simple = clean.length <= 1 && !clean[0]?.category;
const first = clean[0] ?? { demand: 0 };
const next = {
...model,
nodes: { ...model.nodes, [nodeId]: { ...node, demand: first.demand, pattern: first.pattern ?? '' } },
};
return withExtras(next, (ex) => ({ ...ex, demands: setKey(ex.demands, nodeId, simple ? null : clean) }));
}
export const setEmitter = (m: NetworkModel, id: string, c: number | null) =>
withExtras(m, (ex) => ({ ...ex, emitters: setKey(ex.emitters, id, c !== null && c > 0 ? c : null) }));
export const setInitialQuality = (m: NetworkModel, id: string, q: number | null) =>
withExtras(m, (ex) => ({ ...ex, quality: setKey(ex.quality, id, q !== null && q !== 0 ? q : null) }));
export const setSource = (m: NetworkModel, id: string, s: SourceDef | null) =>
withExtras(m, (ex) => ({ ...ex, sources: setKey(ex.sources, id, s) }));
export const setMixing = (m: NetworkModel, id: string, mix: MixingDef | null) =>
withExtras(m, (ex) => ({ ...ex, mixing: setKey(ex.mixing, id, mix && mix.model !== 'MIXED' ? mix : null) }));
export const setTankReaction = (m: NetworkModel, id: string, k: number | null) =>
withExtras(m, (ex) => ({ ...ex, tankReactions: setKey(ex.tankReactions, id, k) }));
export const setStatusOverride = (m: NetworkModel, id: string, v: string | null) =>
withExtras(m, (ex) => ({ ...ex, statusOverrides: setKey(ex.statusOverrides, id, v?.trim() ? v.trim().toUpperCase() : null) }));
export function setPumpEnergy(m: NetworkModel, id: string, pe: PumpEnergy | null): NetworkModel {
const empty = !pe || (!pe.efficiency && pe.price === undefined && !pe.pattern);
return withExtras(m, (ex) => ({ ...ex, energy: { ...ex.energy, pumps: setKey(ex.energy.pumps, id, empty ? null : pe) } }));
}
/** Set/remove one `KEY value` entry, matching the key case-insensitively. */
export function upsertKeyValue(list: KeyValue[], key: string, value: string | null): KeyValue[] {
const i = list.findIndex((kv) => kv.key.toUpperCase() === key.toUpperCase());
if (value === null || value.trim() === '') return i < 0 ? list : list.filter((_, k) => k !== i);
if (i < 0) return [...list, { key, value }];
return list.map((kv, k) => (k === i ? { key: kv.key, value } : kv));
}
export function getKeyValue(list: KeyValue[] | undefined, key: string): string | undefined {
return list?.find((kv) => kv.key.toUpperCase() === key.toUpperCase())?.value;
}
export const setEnergyGlobal = (m: NetworkModel, key: string, value: string | null) =>
withExtras(m, (ex) => ({ ...ex, energy: { ...ex.energy, global: upsertKeyValue(ex.energy.global, key, value) } }));
export const setReactionGlobal = (m: NetworkModel, key: string, value: string | null) =>
withExtras(m, (ex) => ({ ...ex, reactionsGlobal: upsertKeyValue(ex.reactionsGlobal, key, value) }));
export const setLabels = (m: NetworkModel, labels: MapLabel[]) => withExtras(m, (ex) => ({ ...ex, labels }));
// ---------------------------------------------------------------- bulk demand tools
/**
* Parse rows pasted from Excel (tab separated) or a CSV: node ID, demand,
* [pattern], [category]. A header row is skipped. Returns rows + bad line numbers.
*/
export function parseDemandRows(text: string): { rows: Array<{ id: string; demand: number; pattern?: string; category?: string }>; bad: number[] } {
const rows: Array<{ id: string; demand: number; pattern?: string; category?: string }> = [];
const bad: number[] = [];
text.split(/\r?\n/).forEach((line, i) => {
if (!line.trim()) return;
const cells = (line.includes('\t') ? line.split('\t') : line.split(',')).map((c) => c.trim().replace(/^"|"$/g, ''));
const [id, d, pattern, category] = cells;
const demand = Number(d);
if (!id || !Number.isFinite(demand)) {
if (i > 0 || Number.isFinite(Number(cells[1]))) bad.push(i + 1);
return; // first non-numeric row = header
}
rows.push({ id, demand, pattern: pattern || undefined, category: category || undefined });
});
return { rows, bad };
}
/**
* Apply parsed rows. mode 'replace': each listed node's categories become
* exactly its rows; 'add': rows are appended as extra categories.
*/
export function applyDemandRows(
model: NetworkModel,
rows: Array<{ id: string; demand: number; pattern?: string; category?: string }>,
mode: 'replace' | 'add',
toModelFlow: (v: number) => number
): { model: NetworkModel; updated: string[]; unknown: string[] } {
const byNode = new Map<string, DemandCategory[]>();
const unknown: string[] = [];
for (const r of rows) {
if (model.nodes[r.id]?.type !== 'JUNCTION') {
unknown.push(r.id);
continue;
}
const list = byNode.get(r.id) ?? [];
list.push({ demand: toModelFlow(r.demand), pattern: r.pattern, category: r.category });
byNode.set(r.id, list);
}
let m = model;
for (const [id, cats] of byNode) {
const next = mode === 'add' ? [...getDemandCategories(m, id).filter((c) => c.demand !== 0 || c.category), ...cats] : cats;
m = setDemandCategories(m, id, next);
}
return { model: m, updated: [...byNode.keys()], unknown };
}
/**
* 수요 배분: spread `total` (model flow units) over junctions as a new
* category, proportionally to their current demand ('demand') or equally.
*/
export function distributeDemand(
model: NetworkModel,
nodeIds: string[],
total: number,
by: 'demand' | 'equal',
category: string,
pattern?: string
): NetworkModel {
const ids = nodeIds.filter((id) => model.nodes[id]?.type === 'JUNCTION');
if (!ids.length) throw new Error('배분할 접합점이 없습니다.');
const weights = ids.map((id) => (by === 'equal' ? 1 : Math.max(0, totalBaseDemand(model, id))));
const sum = weights.reduce((a, b) => a + b, 0);
if (by === 'demand' && sum <= 0) throw new Error('기존 수요가 모두 0이라 수요 비례 배분을 할 수 없습니다. 균등 배분을 쓰세요.');
let m = model;
ids.forEach((id, k) => {
const share = (total * weights[k]) / (by === 'equal' ? ids.length : sum);
const cats = getDemandCategories(m, id).filter((c) => c.demand !== 0 || c.category);
m = setDemandCategories(m, id, [...(cats.length ? cats : [{ demand: 0 }]), { demand: Math.round(share * 1e6) / 1e6, pattern, category }]);
});
return m;
}
/**
* 이미터(누수) 배분: emitter coefficients whose total outflow at `refPressure`
* equals `totalLeak`, split by the pipe length each junction serves (half of
* every attached pipe) -- leakage is commonly taken as proportional to length.
* C_i = Q_i / P_ref^exponent.
*/
export function distributeEmitters(
model: NetworkModel,
nodeIds: string[],
totalLeak: number,
refPressure: number,
exponent = model.options.emitterExponent ?? 0.5
): { model: NetworkModel; coefficients: Record<string, number> } {
if (!(refPressure > 0)) throw new Error('기준 압력은 0보다 커야 합니다.');
const ids = new Set(nodeIds.filter((id) => model.nodes[id]?.type === 'JUNCTION'));
const served: Record<string, number> = {};
for (const p of Object.values(model.pipes)) {
for (const n of [p.node1, p.node2]) if (ids.has(n)) served[n] = (served[n] ?? 0) + p.length / 2;
}
const total = Object.values(served).reduce((a, b) => a + b, 0);
if (total <= 0) throw new Error('선택한 절점에 연결된 관로 연장이 없습니다.');
const coefficients: Record<string, number> = {};
let m = model;
for (const [id, len] of Object.entries(served)) {
const q = (totalLeak * len) / total;
const c = Math.round((q / Math.pow(refPressure, exponent)) * 1e6) / 1e6;
coefficients[id] = c;
m = setEmitter(m, id, c);
}
return { model: m, coefficients };
}
+341
View File
@@ -0,0 +1,341 @@
import { generateInp, parseInp, NetworkModel, findOverlongIds, EPANET_MAX_ID_BYTES } from './inpModel';
import { computeComparison } from './comparisonEngine';
import { detectCRS } from './crs';
import React from 'react';
import { decodeInpBytes } from './inpEncoding';
export interface FileAnalysisActionsContext {
analyzeWithBothEngines: (fileStr: string) => Promise<import("./resultSync").DualEngineOutcome>;
clearSelection: () => void;
exampleNetwork: "Net1" | "Net2" | "Net3";
hasTopologyError: boolean;
lastOpenedInp: { name: string; buffer: ArrayBuffer; } | null;
networkModel: import("./inpModel").NetworkModel | null;
rawInp: string | null;
setActiveScenarioId: React.Dispatch<React.SetStateAction<string>>;
setActiveTab: React.Dispatch<React.SetStateAction<import("./types").RightTab | null>>;
setBasemap: React.Dispatch<React.SetStateAction<import("./types").BasemapType>>;
setComparison: React.Dispatch<React.SetStateAction<import("./types").ComparisonResult | null>>;
setCrs: React.Dispatch<React.SetStateAction<import("./crs").CRS>>;
setEditMode: React.Dispatch<React.SetStateAction<import("./types").EditMode>>;
setError: React.Dispatch<React.SetStateAction<string | null>>;
setInitialResult: React.Dispatch<React.SetStateAction<import("./types").SimulationResult | null>>;
setInpEncodingNotice: React.Dispatch<React.SetStateAction<string | null>>;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
setLastAnalysisTimestamp: React.Dispatch<React.SetStateAction<string | null>>;
setLastOpenedInp: React.Dispatch<React.SetStateAction<{ name: string; buffer: ArrayBuffer; } | null>>;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
setNetworkModel: React.Dispatch<React.SetStateAction<import("./inpModel").NetworkModel | null>>;
setRawInp: React.Dispatch<React.SetStateAction<string | null>>;
setRedoStack: React.Dispatch<React.SetStateAction<import("./inpModel").NetworkModel[]>>;
setResultInpSnapshot: React.Dispatch<React.SetStateAction<string | null>>;
setScenarios: React.Dispatch<React.SetStateAction<import("./types").Scenario[]>>;
setServerResult: React.Dispatch<React.SetStateAction<import("./types").SimulationResult | null>>;
setShowProjectMenu: React.Dispatch<React.SetStateAction<boolean>>;
setUndoStack: React.Dispatch<React.SetStateAction<import("./inpModel").NetworkModel[]>>;
setWasmResult: React.Dispatch<React.SetStateAction<import("./types").SimulationResult | null>>;
showToast: (msg: string) => void;
switchTab: (tab: "inspector" | "results" | "verification" | "topology" | "diff" | "options" | "patterns" | "controls" | "tables") => void;
}
/**
* File loading (INP upload, CP949 retry, examples, GIS import) and running the dual-engine analysis.
* Extracted from App.tsx; App passes its state/handlers in as `ctx`.
*/
export function createFileAnalysisActions(ctx: FileAnalysisActionsContext) {
const {
analyzeWithBothEngines,
clearSelection,
exampleNetwork,
hasTopologyError,
lastOpenedInp,
networkModel,
rawInp,
setActiveScenarioId,
setActiveTab,
setBasemap,
setComparison,
setCrs,
setEditMode,
setError,
setInitialResult,
setInpEncodingNotice,
setIsModified,
setLastAnalysisTimestamp,
setLastOpenedInp,
setLoading,
setNetworkModel,
setRawInp,
setRedoStack,
setResultInpSnapshot,
setScenarios,
setServerResult,
setShowProjectMenu,
setUndoStack,
setWasmResult,
showToast,
switchTab,
} = ctx;
// Normalizes arbitrary INP text (raw upload, sample file, or already-regenerated
// text) through parse+regenerate so it can be compared byte-for-byte against
// generateInp(networkModel) output. Without this, isResultStale would always
// report stale immediately after loading any file, because generateInp reformats
// whitespace/headers differently from an arbitrary source file. Idempotent:
// normalizeInp(generateInp(m)) === generateInp(m).
const normalizeInp = (inpStr: string): string => generateInp(parseInp(inpStr));
const runAnalysis = async (fileStr: string, isInitial = false) => {
setLoading(true);
setError(null);
setWasmResult(null);
setServerResult(null);
setComparison(null);
try {
const { server, wasm, failureMessages } = await analyzeWithBothEngines(fileStr);
setServerResult(server);
setWasmResult(wasm);
if (isInitial && wasm) {
setInitialResult(wasm);
}
// Timestamp-aware Native vs WASM comparison (never assumes identical array
// index alignment) -- only meaningful when both engines actually ran.
setComparison(server && wasm ? computeComparison(server, wasm) : null);
if (server || wasm) {
// At least one engine produced a result matching the model text that
// was submitted (fileStr), so the live model is no longer considered
// modified/stale relative to this result.
setResultInpSnapshot(normalizeInp(fileStr));
setIsModified(false);
setLastAnalysisTimestamp(
new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
);
}
if (failureMessages.length > 0) {
setError(failureMessages.join('\n'));
if (server || wasm) {
// One engine still succeeded -- don't let the other's failure read
// as a total analysis failure.
showToast(
`${failureMessages.length === 2 ? '두 엔진 모두' : '한 엔진에서'} 오류가 발생했지만, ${
server ? 'Native' : 'WASM'
} 엔진 결과로 계속 진행합니다.`
);
}
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
} finally {
setLoading(false);
}
};
const loadInpText = (str: string, encodingNotice?: string) => {
setRawInp(str);
setInpEncodingNotice(encodingNotice || null);
try {
const model = parseInp(str);
setNetworkModel(model);
setIsModified(false);
clearSelection();
setEditMode('select');
// A newly loaded file is a different network; any saved scenarios/results
// belonged to the previous network and must not be reused or overwritten.
setScenarios([]);
setActiveScenarioId('base');
setResultInpSnapshot(null);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(model.nodes)) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
if (minX !== Infinity) {
const detected = detectCRS({ minX, minY, maxX, maxY });
setCrs(detected);
if (detected !== 'LOCAL') setBasemap('osm');
}
if (encodingNotice) showToast(encodingNotice);
setWasmResult(null);
setServerResult(null);
setUndoStack([]);
setRedoStack([]);
setResultInpSnapshot(null);
} catch (parseErr: unknown) {
const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
setError(`INP 파싱 실패: ${msg}`);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const buffer = await file.arrayBuffer();
const decoded = decodeInpBytes(buffer);
setLastOpenedInp({ name: file.name, buffer });
loadInpText(decoded.text, decoded.warning);
} catch (readErr: unknown) {
const msg = readErr instanceof Error ? readErr.message : String(readErr);
setError(`파일 읽기 오류: ${msg}`);
} finally {
e.target.value = '';
}
};
const handleRetryCp949 = () => {
if (!lastOpenedInp) return;
const decoded = decodeInpBytes(lastOpenedInp.buffer, 'cp949');
loadInpText(decoded.text, `${lastOpenedInp.name}: ${decoded.warning}`);
setShowProjectMenu(false);
};
// Loads one of the official OWA-EPANET example networks (Net1/Net2/Net3).
// Source and license: see public/EXAMPLE_NETWORKS.md.
const loadOfficialExample = async (name: 'Net1' | 'Net2' | 'Net3' = exampleNetwork) => {
try {
const res = await fetch(`/${name}.inp`);
if (!res.ok) throw new Error(`${name}.inp 예제 파일 로드 실패`);
const str = await res.text();
setRawInp(str);
// The status bar / backups show this name; a previously opened file's must not linger.
setLastOpenedInp({ name: `${name}.inp`, buffer: new TextEncoder().encode(str).buffer as ArrayBuffer });
const model = parseInp(str);
setNetworkModel(model);
setIsModified(false);
clearSelection();
setEditMode('select');
setScenarios([]);
setActiveScenarioId('base');
setResultInpSnapshot(null);
setCrs('LOCAL');
setBasemap('blank');
setWasmResult(null);
setServerResult(null);
setResultInpSnapshot(null);
setUndoStack([]);
setRedoStack([]);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
}
};
// Load Korea EPSG:5179 Real Network Sample
const loadKoreaExample = async () => {
try {
const res = await fetch('/sample_pressure_network.inp');
if (!res.ok) throw new Error('실무 예제 파일 로드 실패');
const str = await res.text();
setRawInp(str);
setLastOpenedInp({ name: 'sample_pressure_network.inp', buffer: new TextEncoder().encode(str).buffer as ArrayBuffer });
const model = parseInp(str);
setNetworkModel(model);
setIsModified(false);
clearSelection();
setEditMode('select');
setScenarios([]);
setActiveScenarioId('base');
setResultInpSnapshot(null);
setCrs('EPSG:5179');
setBasemap('osm');
setWasmResult(null);
setServerResult(null);
setResultInpSnapshot(null);
setUndoStack([]);
setRedoStack([]);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
}
};
// Handle complete from GIS Import Wizard
const handleGISImportComplete = (wizardModel: NetworkModel, inpStr: string) => {
// Use the model as parsed from the generated INP (not the wizard's raw
// object): staleness compares generateInp(model) against the analyzed
// text, and the wizard object regenerates with different formatting, so
// the fresh result was immediately flagged "모델 변경됨 — 재해석 필요".
const model = parseInp(generateInp(wizardModel));
setRawInp(inpStr);
setNetworkModel(model);
setIsModified(false);
clearSelection();
setEditMode('select');
setScenarios([]);
setActiveScenarioId('base');
setResultInpSnapshot(null);
// Auto-detect CRS
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(model.nodes)) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
if (minX !== Infinity) {
const detected = detectCRS({ minX, minY, maxX, maxY });
setCrs(detected);
if (detected !== 'LOCAL') {
setBasemap('osm');
}
}
setWasmResult(null);
setServerResult(null);
setResultInpSnapshot(null);
setUndoStack([]);
setRedoStack([]);
// The wizard's final button is "관망 모델 생성 및 수리해석": actually run it
// (previously only the model was created and no analysis followed).
void runAnalysis(generateInp(model), true);
};
// Run Re-analysis on Modified Network
const handleRunModifiedAnalysis = async () => {
if (!networkModel) return;
if (hasTopologyError) {
alert('위상 무결성 오류가 있습니다. 위상 검사 탭을 확인해주세요.');
switchTab('topology');
return;
}
const overlongIds = findOverlongIds(networkModel);
if (overlongIds.length > 0) {
setError(
`EPANET ID 길이 한도(UTF-8 ${EPANET_MAX_ID_BYTES}바이트)를 넘는 ID ${overlongIds.length}개가 있어 해석할 수 없습니다 ` +
`(Error 252): ${overlongIds.slice(0, 5).join(', ')}${overlongIds.length > 5 ? ' 외' : ''}. 한글은 글자당 3바이트이므로 10자 이하로 줄여 주세요.`
);
return;
}
try {
const regeneratedInp = generateInp(networkModel);
await runAnalysis(regeneratedInp, false);
setActiveTab('verification');
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
setError(`INP 재생성 또는 분석 실패: ${msg}`);
}
};
// Reset to original INP
const handleResetToOriginal = () => {
if (!rawInp) return;
try {
const model = parseInp(rawInp);
setNetworkModel(model);
setIsModified(false);
clearSelection();
setEditMode('select');
setWasmResult(null);
setServerResult(null);
setResultInpSnapshot(null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
}
};
return { normalizeInp, runAnalysis, loadInpText, handleFileUpload, handleRetryCp949, loadOfficialExample, loadKoreaExample, handleGISImportComplete, handleRunModifiedAnalysis, handleResetToOriginal };
}
+401
View File
@@ -0,0 +1,401 @@
import type { FeatureCollection, LineString, MultiLineString, Geometry, GeoJsonProperties } from 'geojson';
import type { InpNode, InpPipe, InpPattern, InpOptions, NetworkModel } from './inpModel';
export interface FieldMappingConfig {
pipeIdField?: string;
diameterField?: string;
lengthField?: string;
roughnessField?: string;
materialField?: string;
defaultDiameter: number;
defaultRoughness: number;
sourceNodeId?: string;
sourceHead: number;
}
export interface DetectedFields {
pipeIdField?: string;
diameterField?: string;
lengthField?: string;
roughnessField?: string;
materialField?: string;
availableFields: string[];
}
/**
* Discloses which values in a GIS-converted model were auto-generated or
* estimated by this import (rather than sourced from the GIS attribute
* data), so the conversion summary screen can flag them for review instead
* of presenting a placeholder like "10m elevation" or a proximity-based
* endpoint match as if it were surveyed data.
*/
export interface GisImportSummary {
totalNodeCount: number;
totalPipeCount: number;
/** Every imported junction: this import has no elevation/demand field mapping at all. */
placeholderElevationCount: number;
placeholderDemandCount: number;
/** Pipes whose diameter/roughness had to be estimated (flat default, or --
* for roughness -- inferred from a material name) rather than read directly
* from a numeric GIS field. */
defaultDiameterPipeCount: number;
defaultRoughnessPipeCount: number;
/** Segment endpoints merged into an existing node by proximity (not an exact
* coordinate match) -- a real connection, or two distinct points close enough
* to be mistaken for one, so it needs a human to confirm which. */
autoSnappedEndpointCount: number;
}
/**
* Auto-detects candidate fields from a sample feature properties object,
* recognizing standard Korean municipal GIS schemas (KLIS, UPIS, 국가공간정보포털).
*/
export function autoDetectFields(props: Record<string, unknown>): DetectedFields {
const availableFields = Object.keys(props);
const findMatch = (candidates: string[]) => {
return availableFields.find((f) =>
candidates.some((c) => f.toUpperCase().includes(c.toUpperCase()))
);
};
const pipeIdField = findMatch(['PIP_ID', 'PIPE_ID', 'ID', 'LINK_ID', '관로번호', '관번호', 'FTR_IDN', 'OBJECTID', 'FID']);
const diameterField = findMatch(['PIP_DIA', 'DIAMETER', 'DIA', '관경', '구경', 'PIP_DIP', 'DIA_VAL', 'DIP']);
const lengthField = findMatch(['PIP_LEN', 'LENGTH', 'LEN', '연장', '길이', 'SHAPE_LEN', 'SHAPE_LENGTH']);
const roughnessField = findMatch(['ROUGHNESS', 'ROUGH', '조도', 'C_VALUE', 'C_VAL']);
const materialField = findMatch(['PIP_MAT', 'MOP_NAM', 'MAT_NAM', 'MTR_NAM', '재질', '관종', 'MATERIAL']);
return {
pipeIdField,
diameterField,
lengthField,
roughnessField,
materialField,
availableFields,
};
}
/**
* Infers Hazen-Williams C roughness coefficient from standard pipe material strings.
*/
export function inferRoughnessFromMaterial(material: string, defaultC = 120): number {
const m = material.toUpperCase();
if (m.includes('DIP') || m.includes('덕타일') || m.includes('주철') || m.includes('CIP')) return 110;
if (m.includes('PE') || m.includes('폴리에틸렌') || m.includes('HDPE')) return 130;
if (m.includes('PVC') || m.includes('염화비닐') || m.includes('플라스틱')) return 140;
if (m.includes('ST') || m.includes('강관') || m.includes('STEEL') || m.includes('SP')) return 100;
if (m.includes('CONC') || m.includes('콘크리트') || m.includes('흄관')) return 110;
if (m.includes('HI-VP') || m.includes('내충격')) return 140;
return defaultC;
}
/**
* Calculates approximate Euclidean or geographic distance between two points in meters.
*/
function approximateDistance(p1: [number, number], p2: [number, number]): number {
const [x1, y1] = p1;
const [x2, y2] = p2;
// If geographic coordinates (degrees in Korean peninsula range)
if (x1 > 120 && x1 < 140 && y1 > 30 && y1 < 45) {
const dLon = (x2 - x1) * (Math.PI / 180);
const dLat = (y2 - y1) * (Math.PI / 180);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(y1 * (Math.PI / 180)) *
Math.cos(y2 * (Math.PI / 180)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(6371000 * c); // Earth radius ~6371km
}
// Planar Euclidean (meters in EPSG:5179 / EPSG:5186)
const dx = x2 - x1;
const dy = y2 - y1;
return Math.round(Math.hypot(dx, dy) * 100) / 100;
}
/**
* Converts a pipeline GeoJSON FeatureCollection into a valid EPANET NetworkModel
* by performing coordinate-based topological node matching with adaptive snapping
* and MultiLineString decomposition.
*/
export function buildNetworkFromGeoJSON(
geojson: FeatureCollection<Geometry, GeoJsonProperties>,
config: FieldMappingConfig
): { model: NetworkModel; summary: GisImportSummary } {
const nodes: Record<string, InpNode> = {};
const pipes: Record<string, InpPipe> = {};
let autoSnappedEndpointCount = 0;
let defaultDiameterPipeCount = 0;
let defaultRoughnessPipeCount = 0;
// Sample the first available coordinate to determine coordinate system scale
let sampleCoord: [number, number] | null = null;
for (const feat of geojson.features) {
if (feat.geometry.type === 'LineString') {
const c = (feat.geometry as LineString).coordinates;
if (c.length > 0) { sampleCoord = c[0] as [number, number]; break; }
} else if (feat.geometry.type === 'MultiLineString') {
const mc = (feat.geometry as MultiLineString).coordinates;
if (mc.length > 0 && mc[0].length > 0) { sampleCoord = mc[0][0] as [number, number]; break; }
}
}
// Adaptive snapping threshold:
// - Projected meters (e.g. EPSG:5179 X ~ 950,000m): snap within 0.5m to merge close GIS endpoints.
// - Geographic degrees (e.g. EPSG:4326 Lon ~ 127.0): snap within 0.00005 deg (~5m).
// - Planar arbitrary: snap within 0.1 units.
const isMeterProjected = sampleCoord ? Math.abs(sampleCoord[0]) > 10000 : false;
const isGeographic = sampleCoord ? Math.abs(sampleCoord[0]) <= 180 && Math.abs(sampleCoord[1]) <= 90 : false;
const snapThreshold = isMeterProjected ? 0.5 : isGeographic ? 0.00005 : 0.1;
// Spatial hash grid for endpoint snapping: a naive "compare against every
// existing node" scan is O(n) per lookup and O(n^2) overall, which gets
// slow fast on a large GIS import (thousands of pipes -> tens of thousands
// of endpoint lookups). Bucketing nodes into cells sized exactly to the
// snap threshold means any point within that threshold (Chebyshev/L-inf
// distance, matching the dx<=threshold && dy<=threshold check below) must
// fall in the same cell or one of its 8 neighbors, so each lookup only
// examines a small, roughly constant number of candidates instead of every
// node -- without changing which matches are found.
const grid = new Map<string, string[]>();
const cellIndex = (v: number) => Math.floor(v / snapThreshold);
const cellKeyOf = (cx: number, cy: number) => `${cx},${cy}`;
const addToGrid = (id: string, x: number, y: number) => {
const key = cellKeyOf(cellIndex(x), cellIndex(y));
const bucket = grid.get(key);
if (bucket) bucket.push(id);
else grid.set(key, [id]);
};
let nodeCounter = 1;
const findOrCreateNode = (coords: [number, number]): string => {
const [x, y] = coords;
const cx = cellIndex(x);
const cy = cellIndex(y);
for (let ox = -1; ox <= 1; ox++) {
for (let oy = -1; oy <= 1; oy++) {
const bucket = grid.get(cellKeyOf(cx + ox, cy + oy));
if (!bucket) continue;
for (const id of bucket) {
const n = nodes[id];
const dx = Math.abs(n.x - x);
const dy = Math.abs(n.y - y);
if (dx <= snapThreshold && dy <= snapThreshold) {
// A non-exact match was pulled together only because it fell within
// the snap tolerance, not because the coordinates actually agree --
// this could be a real shared endpoint, or two distinct points close
// enough to be merged by mistake, so it's flagged for review.
if (dx > 0 || dy > 0) autoSnappedEndpointCount++;
return n.id;
}
}
}
}
const newId = `J${nodeCounter++}`;
const roundDecimals = isGeographic ? 1000000 : 100;
const roundedX = Math.round(x * roundDecimals) / roundDecimals;
const roundedY = Math.round(y * roundDecimals) / roundDecimals;
nodes[newId] = {
id: newId,
type: 'JUNCTION',
x: roundedX,
y: roundedY,
elevation: 10,
demand: 10,
};
addToGrid(newId, roundedX, roundedY);
return newId;
};
let pipeCounter = 1;
for (const feature of geojson.features) {
const geomType = feature.geometry?.type;
if (geomType !== 'LineString' && geomType !== 'MultiLineString') continue;
const lineSegments: Array<Position[]> =
geomType === 'LineString'
? [(feature.geometry as LineString).coordinates]
: (feature.geometry as MultiLineString).coordinates;
const props = feature.properties || {};
// Extract common pipe properties
const rawId = config.pipeIdField ? String(props[config.pipeIdField] || '') : '';
const rawDiam = config.diameterField ? parseFloat(String(props[config.diameterField])) : NaN;
const diameter = !isNaN(rawDiam) && rawDiam > 0 ? rawDiam : config.defaultDiameter;
const rawLen = config.lengthField ? parseFloat(String(props[config.lengthField])) : NaN;
// Roughness: explicit roughness field > material-inferred roughness > default roughness.
// Only an explicit numeric field is real GIS-sourced data; the material
// inference is itself a rule-of-thumb guess, not a measured value, so it
// counts toward the "estimated" total the same as the flat default does.
let roughness = config.defaultRoughness;
const rawRough = config.roughnessField ? parseFloat(String(props[config.roughnessField])) : NaN;
const roughnessIsEstimated = isNaN(rawRough) || rawRough <= 0;
if (!roughnessIsEstimated) {
roughness = rawRough;
} else if (config.materialField && props[config.materialField]) {
roughness = inferRoughnessFromMaterial(String(props[config.materialField]), config.defaultRoughness);
}
const diameterIsEstimated = isNaN(rawDiam) || rawDiam <= 0;
// Process each line segment (supports MultiLineString decomposition)
lineSegments.forEach((coords, segIdx) => {
if (coords.length < 2) return;
const startCoord = coords[0] as [number, number];
const endCoord = coords[coords.length - 1] as [number, number];
const n1 = findOrCreateNode(startCoord);
const n2 = findOrCreateNode(endCoord);
if (n1 === n2) return; // Skip degenerate loop
// Determine unique ID for each segment
const segmentSuffix = lineSegments.length > 1 ? `_${segIdx + 1}` : '';
const pipeId = rawId ? `${rawId}${segmentSuffix}` : `P${pipeCounter++}`;
// Length: length property (apportioned if MultiLineString) or calculated distance
const segmentLen = !isNaN(rawLen) && rawLen > 0
? Math.round((rawLen / lineSegments.length) * 100) / 100
: Math.max(10, approximateDistance(startCoord, endCoord));
if (diameterIsEstimated) defaultDiameterPipeCount++;
if (roughnessIsEstimated) defaultRoughnessPipeCount++;
pipes[pipeId] = {
id: pipeId,
node1: n1,
node2: n2,
length: segmentLen,
diameter,
roughness,
minorLoss: 0,
status: 'Open',
};
});
}
// Ensure at least one water source (Reservoir) so EPANET simulation can run
const nodeKeys = Object.keys(nodes);
if (nodeKeys.length > 0) {
const targetSourceId = config.sourceNodeId && nodes[config.sourceNodeId] ? config.sourceNodeId : nodeKeys[0];
nodes[targetSourceId] = {
...nodes[targetSourceId],
type: 'RESERVOIR',
elevation: config.sourceHead || 60,
demand: 0,
};
}
// Default SI options (LPS, Hazen-Williams) compliant with Korean water distribution design standards
const defaultOptions: InpOptions = {
units: 'LPS',
headloss: 'H-W',
trials: 40,
accuracy: 0.001,
specificGravity: 1.0,
viscosity: 1.0,
rawEntries: [
{ key: 'Units', value: 'LPS' },
{ key: 'Headloss', value: 'H-W' },
{ key: 'Trials', value: '40' },
{ key: 'Accuracy', value: '0.001' },
],
timeEntries: [
{ key: 'Duration', value: '24:00' },
{ key: 'Hydraulic Timestep', value: '1:00' },
{ key: 'Pattern Timestep', value: '1:00' },
{ key: 'Report Timestep', value: '1:00' },
],
};
const defaultPatterns: Record<string, InpPattern> = {
'1': {
id: '1',
description: 'Default 24h Pattern',
multipliers: [0.8, 0.7, 0.6, 1.2, 1.5, 1.3, 1.1, 1.0, 1.2, 1.4, 1.1, 0.9],
},
};
const rawLines: string[] = [
'[TITLE]',
' Generated by spipe GeoJSON Import Wizard',
'',
'[OPTIONS]',
' Units LPS',
' Headloss H-W',
'',
'[COORDINATES]',
';Node X Y',
'',
'[JUNCTIONS]',
';ID Elev Demand Pattern',
'',
'[RESERVOIRS]',
';ID Head Pattern',
'',
'[PIPES]',
';ID Node1 Node2 Length Diameter Roughness MinorLoss Status',
'',
'[PATTERNS]',
';ID Multipliers',
'',
'[END]',
];
const sectionIndices: Record<string, { start: number; end: number }> = {
TITLE: { start: 0, end: 2 },
OPTIONS: { start: 3, end: 6 },
COORDINATES: { start: 7, end: 9 },
JUNCTIONS: { start: 10, end: 12 },
RESERVOIRS: { start: 13, end: 15 },
PIPES: { start: 16, end: 18 },
PATTERNS: { start: 19, end: 21 },
// Without an END entry, generateInp's "append section the source never
// had" step (which runs when it reaches the [END] section) never fires
// for a GIS-imported model, so e.g. the 24h [TIMES] settings this import
// always populates in options.timeEntries were silently never written
// out to the exported INP.
END: { start: 22, end: 22 },
};
const model: NetworkModel = {
rawLines,
sectionIndices,
nodes,
pipes,
pumps: {},
valves: {},
patterns: defaultPatterns,
curves: {},
controls: [],
rules: [],
options: defaultOptions,
};
const totalNodeCount = Object.keys(nodes).length;
// Every imported junction gets the same placeholder elevation/demand --
// this import has no field mapping for either, unlike diameter/roughness/
// length -- so ALL of them (one less than totalNodeCount, since the source
// reservoir's demand=0/elevation=sourceHead aren't placeholders) need review.
const junctionCount = Math.max(0, totalNodeCount - (totalNodeCount > 0 ? 1 : 0));
const summary: GisImportSummary = {
totalNodeCount,
totalPipeCount: Object.keys(pipes).length,
placeholderElevationCount: junctionCount,
placeholderDemandCount: junctionCount,
defaultDiameterPipeCount,
defaultRoughnessPipeCount,
autoSnappedEndpointCount,
};
return { model, summary };
}
type Position = number[];
+149
View File
@@ -0,0 +1,149 @@
import { parseShapefileZip, parseGeoJsonString } from './gisLayers';
import { UserGisLayer } from './types';
import { readVectorOnServer } from './api';
import { crsProj4 } from './demTools';
/** Formats only the GIS server (GDAL) reads. Zips that are not shapefiles also go there. */
const SERVER_FORMATS = ['.dxf', '.dgn', '.mif', '.tab', '.gml', '.kml', '.gpkg', '.e00', '.csv'];
const LAYER_COLORS = ['#7c3aed', '#0891b2', '#ca8a04', '#db2777', '#16a34a', '#ea580c'];
export interface GisLayerActionsContext {
networkModel: import("./inpModel").NetworkModel | null;
crs?: import("./crs").CRS;
selectedTarget: import("./types").SelectionTarget;
setIsLayerPanelOpen: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedTarget: React.Dispatch<React.SetStateAction<import("./types").SelectionTarget>>;
setShowImportWizard: React.Dispatch<React.SetStateAction<boolean>>;
setUserLayers: React.Dispatch<React.SetStateAction<import("./types").UserGisLayer[]>>;
setWizardInitialData: React.Dispatch<React.SetStateAction<{ geojson: GeoJSON.FeatureCollection<GeoJSON.Geometry, GeoJSON.GeoJsonProperties>; fileName: string; } | null>>;
showToast: (msg: string) => void;
userLayers: import("./types").UserGisLayer[];
}
/**
* User GIS layer handlers: add (GeoJSON/Shapefile), visibility, opacity, delete, zoom, convert to network.
* Extracted from App.tsx; App passes its state/handlers in as `ctx`.
*/
export function createGisLayerActions(ctx: GisLayerActionsContext) {
const {
networkModel,
crs = 'LOCAL',
selectedTarget,
setIsLayerPanelOpen,
setSelectedTarget,
setShowImportWizard,
setUserLayers,
setWizardInitialData,
showToast,
userLayers,
} = ctx;
// User GIS Layers & Context Menu Handlers
const handleAddUserLayer = async (file: File) => {
let modelExtents = null;
if (networkModel && Object.keys(networkModel.nodes).length > 0) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(networkModel.nodes)) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
if (minX !== Infinity) modelExtents = { minX, minY, maxX, maxY };
}
try {
const lower = file.name.toLowerCase();
// Shapefile (.zip with .shp/.dbf/.prj, or a bare .shp) went through
// parseShapefileZip, which existed but was never wired to any upload.
const viaServer = async (): Promise<UserGisLayer> => {
const defaultCrs = crsProj4(crs);
const r = await readVectorOnServer(file, defaultCrs);
const geomTypes = [...new Set(r.featureCollection.features.map((f) => f.geometry?.type).filter((t): t is NonNullable<typeof t> => !!t))];
if (r.crsAssumed) showToast(`[${file.name}] 좌표계 정보가 없어 관망 좌표계(${crs})로 읽었습니다. 다르면 분석 > GIS 도구에서 다시 지정하세요.`);
else if (!r.reprojected) showToast(`[${file.name}] 좌표계를 알 수 없어 경위도로 읽었습니다. 분석 > GIS 도구에서 좌표계를 지정하세요.`);
return {
id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name: file.name.replace(/\.[^.]+$/, '') + (r.layers.length > 1 ? ` (${r.layer})` : ''),
fileName: file.name,
featureCollection: r.featureCollection,
featureCount: r.count,
geomTypes,
estimatedCrs: r.crs ?? (r.crsAssumed ? crs : '알 수 없음'),
visible: true,
opacity: 0.8,
color: LAYER_COLORS[userLayers.length % LAYER_COLORS.length],
createdAt: new Date().toISOString(),
sourceCrs: r.crsAssumed ? defaultCrs ?? undefined : r.crs ?? undefined,
};
};
const ext = lower.slice(lower.lastIndexOf('.'));
let layer: UserGisLayer;
if (SERVER_FORMATS.includes(ext)) layer = await viaServer();
else if (lower.endsWith('.zip') || lower.endsWith('.shp')) {
try {
layer = await parseShapefileZip(file, modelExtents);
} catch (clientErr) {
// Zipped TAB/MIF/GPKG ... or a shapefile the browser reader cannot handle.
if (!lower.endsWith('.zip')) throw clientErr;
layer = await viaServer();
}
} else layer = parseGeoJsonString(await file.text(), file.name, modelExtents);
setUserLayers((prev) => [...prev, layer]);
setIsLayerPanelOpen(true);
showToast(`GIS 레이어 [${layer.name}] 추가됨 (${layer.featureCount}개 피처)`);
} catch (err: unknown) {
showToast(`GIS 레이어 추가 실패: ${err instanceof Error ? err.message : String(err)}`);
}
};
const handleToggleUserLayerVisibility = (layerId: string) => {
setUserLayers((prev) =>
prev.map((l) => (l.id === layerId ? { ...l, visible: !l.visible } : l))
);
};
const handleUserLayerOpacityChange = (layerId: string, opacity: number) => {
setUserLayers((prev) =>
prev.map((l) => (l.id === layerId ? { ...l, opacity } : l))
);
};
const handleDeleteUserLayer = (layerId: string) => {
setUserLayers((prev) => prev.filter((l) => l.id !== layerId));
if (selectedTarget?.type === 'gis-feature' && selectedTarget.layerId === layerId) {
setSelectedTarget(null);
}
showToast('GIS 레이어가 삭제되었습니다.');
};
const handleConvertGisLayerToNetwork = (layer: UserGisLayer) => {
setWizardInitialData({
geojson: layer.featureCollection,
fileName: layer.fileName,
});
setShowImportWizard(true);
};
const handleCopyText = (text: string, label: string) => {
navigator.clipboard.writeText(text).then(
() => showToast(`${label} 복사 완료: ${text}`),
() => showToast(`${label} 복사 실패`)
);
};
const handleZoomToLayer = (layerId: string) => {
const layer = userLayers.find((l) => l.id === layerId);
if (layer && layer.featureCollection.features.length > 0) {
const firstFeat = layer.featureCollection.features[0];
setSelectedTarget({
type: 'gis-feature',
layerId: layer.id,
featureId: firstFeat.id ?? 1,
properties: (firstFeat.properties as Record<string, unknown>) || {},
geometryType: firstFeat.geometry?.type,
});
showToast(`레이어 [${layer.name}] 피처로 이동했습니다.`);
}
};
const handleOpenGisWizardForLayer = (layerId: string) => {
const layer = userLayers.find((l) => l.id === layerId);
if (layer) {
handleConvertGisLayerToNetwork(layer);
}
};
return { handleAddUserLayer, handleToggleUserLayerVisibility, handleUserLayerOpacityChange, handleDeleteUserLayer, handleConvertGisLayerToNetwork, handleCopyText, handleZoomToLayer, handleOpenGisWizardForLayer };
}
+642
View File
@@ -0,0 +1,642 @@
import type { FeatureCollection, Feature, Geometry, GeoJsonProperties } from 'geojson';
import proj4 from 'proj4';
import type { UserGisLayer } from './types';
export interface Extents {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
function detectCrsFromExtents(extents: Extents): string {
const { minX, maxX, minY, maxY } = extents;
if (minX > 500000 && maxX < 1500000 && minY > 1300000 && maxY < 2300000) {
return 'EPSG:5179';
}
if (minX > 50000 && maxX < 400000 && minY > 250000 && maxY < 750000) {
return 'EPSG:5186';
}
if (minX >= 120 && maxX <= 135 && minY >= 30 && maxY <= 43) {
return 'EPSG:4326';
}
return 'LOCAL';
}
const LAYER_COLOR_PALETTE = [
'#0284c7', // Sky 600
'#16a34a', // Green 600
'#d97706', // Amber 600
'#9333ea', // Purple 600
'#0d9488', // Teal 600
'#e11d48', // Rose 600
'#4f46e5', // Indigo 600
'#ca8a04', // Yellow 600
];
let colorIndex = 0;
function getNextLayerColor(): string {
const color = LAYER_COLOR_PALETTE[colorIndex % LAYER_COLOR_PALETTE.length];
colorIndex += 1;
return color;
}
/**
* Computes bounding box [minX, minY, maxX, maxY] from any GeoJSON FeatureCollection.
*/
export function computeGeoJsonExtents(fc: FeatureCollection): Extents | null {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const traverseCoords = (coords: unknown): void => {
if (!Array.isArray(coords)) return;
if (typeof coords[0] === 'number' && typeof coords[1] === 'number') {
const x = coords[0];
const y = coords[1];
if (Number.isFinite(x) && Number.isFinite(y)) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
} else {
for (const sub of coords) {
traverseCoords(sub);
}
}
};
for (const feature of fc.features) {
const geom = feature?.geometry;
if (geom && typeof geom === 'object' && 'coordinates' in geom) {
traverseCoords(geom.coordinates);
}
}
if (minX === Infinity || !Number.isFinite(minX)) {
return null;
}
return { minX, minY, maxX, maxY };
}
/**
* Projects coordinates from a detected CRS (e.g. EPSG:5179) to WGS84 (EPSG:4326)
* so MapLibre GL can render them properly.
*/
export function normalizeGeoJsonToWgs84(
fc: FeatureCollection,
detectedCrs: string,
modelExtents?: Extents | null
): FeatureCollection {
if (detectedCrs === 'EPSG:4326') {
return fc;
}
const transformCoords = (coords: unknown): unknown => {
if (!Array.isArray(coords)) return coords;
if (typeof coords[0] === 'number' && typeof coords[1] === 'number') {
const x = coords[0];
const y = coords[1];
if (detectedCrs !== 'EPSG:4326' && detectedCrs !== 'LOCAL') {
try {
const [lon, lat] = proj4(detectedCrs, 'EPSG:4326', [x, y]);
return [lon, lat];
} catch {
return [x, y];
}
}
if (detectedCrs === 'LOCAL' && modelExtents) {
const dx = modelExtents.maxX - modelExtents.minX || 1;
const dy = modelExtents.maxY - modelExtents.minY || 1;
const lon = -0.05 + ((x - modelExtents.minX) / dx) * 0.1;
const lat = -0.05 + ((y - modelExtents.minY) / dy) * 0.1;
return [lon, lat];
}
return [x, y];
}
return coords.map(transformCoords);
};
const newFeatures: Feature[] = fc.features.map((f, idx) => {
const geom = f?.geometry;
if (!geom || typeof geom !== 'object' || !('coordinates' in geom)) {
return f;
}
return {
...f,
id: f.id !== undefined ? f.id : idx + 1,
geometry: {
...geom,
coordinates: transformCoords(geom.coordinates),
} as Geometry,
};
});
return {
type: 'FeatureCollection',
features: newFeatures,
};
}
/**
* Parses a raw string as GeoJSON, validates its structure,
* detects geometries, counts features, and estimates CRS.
*/
export function parseGeoJsonString(
rawText: string,
fileName: string,
modelExtents?: Extents | null
): UserGisLayer {
let parsed: unknown;
try {
parsed = JSON.parse(rawText);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`JSON 구문 분석 오류: 유효한 GeoJSON 형식이 아닙니다 (${msg})`);
}
if (!parsed || typeof parsed !== 'object') {
throw new Error('올바르지 않은 GeoJSON: 루트가 객체가 아닙니다.');
}
const obj = parsed as Record<string, unknown>;
let fc: FeatureCollection;
if (obj.type === 'FeatureCollection' && Array.isArray(obj.features)) {
fc = obj as unknown as FeatureCollection;
} else if (obj.type === 'Feature') {
fc = {
type: 'FeatureCollection',
features: [obj as unknown as Feature],
};
} else if (typeof obj.type === 'string' && 'coordinates' in obj) {
// Single geometry
fc = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: obj as unknown as Geometry,
properties: {},
},
],
};
} else if (Array.isArray(obj)) {
// Array of features
fc = {
type: 'FeatureCollection',
features: obj as Feature[],
};
} else {
throw new Error('GeoJSON 규격(FeatureCollection 또는 Feature)을 찾을 수 없습니다.');
}
if (!fc.features || fc.features.length === 0) {
throw new Error('GeoJSON에 포함된 공간 객체(features)가 없습니다.');
}
// Detect geometry types
const geomTypeSet = new Set<string>();
fc.features.forEach((f) => {
if (f && f.geometry && f.geometry.type) {
geomTypeSet.add(f.geometry.type);
}
});
const geomTypes = Array.from(geomTypeSet);
if (geomTypes.length === 0) {
throw new Error('GeoJSON 공간 객체에 유효한 지오메트리가 없습니다.');
}
// Estimate CRS from coordinates
const extents = computeGeoJsonExtents(fc);
let estimatedCrs = 'WGS84 (EPSG:4326)';
let detectedCrsKey = 'EPSG:4326';
if (extents) {
const detected = detectCrsFromExtents(extents);
detectedCrsKey = detected;
if (detected === 'EPSG:5179') {
estimatedCrs = '한국 UTM-K (EPSG:5179)';
} else if (detected === 'EPSG:5186') {
estimatedCrs = '중부원점 (EPSG:5186)';
} else if (detected === 'EPSG:4326') {
estimatedCrs = 'WGS84 (EPSG:4326)';
} else {
estimatedCrs = '로컬 평면 (Local)';
}
}
// Normalize coordinates to WGS84 for MapLibre rendering
const normalizedFc = normalizeGeoJsonToWgs84(fc, detectedCrsKey, modelExtents);
// Assign clean feature ids for selection if not present
normalizedFc.features.forEach((f, idx) => {
if (f.id === undefined) {
f.id = idx + 1;
}
});
const cleanName = fileName.replace(/\.[^/.]+$/, '');
const id = `user_layer_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
return {
id,
name: cleanName,
fileName,
featureCollection: normalizedFc,
featureCount: fc.features.length,
geomTypes,
estimatedCrs,
visible: true,
opacity: 0.85,
color: getNextLayerColor(),
createdAt: new Date().toLocaleTimeString(),
};
}
/* ---------------------------------------------------------------------------
Pure-JS Shapefile (.zip / .shp) Parser
--------------------------------------------------------------------------- */
/**
* Unpacks a standard ZIP file using the browser native DecompressionStream('deflate-raw')
* without requiring any third-party npm packages.
*/
export async function unzipArchive(buffer: ArrayBuffer): Promise<Record<string, Uint8Array>> {
const files: Record<string, Uint8Array> = {};
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
let offset = 0;
while (offset + 30 <= buffer.byteLength) {
const signature = view.getUint32(offset, true);
if (signature !== 0x04034b50) {
// End of local file headers (reached central directory 0x02014b50 or 0x06054b50)
break;
}
const compressionMethod = view.getUint16(offset + 8, true);
const compressedSize = view.getUint32(offset + 18, true);
const fileNameLength = view.getUint16(offset + 26, true);
const extraFieldLength = view.getUint16(offset + 28, true);
const nameBytes = bytes.subarray(offset + 30, offset + 30 + fileNameLength);
const fileName = new TextDecoder('utf-8').decode(nameBytes);
const dataStart = offset + 30 + fileNameLength + extraFieldLength;
const compressedBytes = bytes.subarray(dataStart, dataStart + compressedSize);
if (compressedSize > 0) {
if (compressionMethod === 0) {
// Uncompressed (Stored)
files[fileName.toLowerCase()] = new Uint8Array(compressedBytes);
} else if (compressionMethod === 8) {
// Deflated — use browser native DecompressionStream
try {
const ds = new DecompressionStream('deflate-raw');
const writer = ds.writable.getWriter();
writer.write(compressedBytes);
writer.close();
const decompressedChunks: Uint8Array[] = [];
const reader = ds.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) decompressedChunks.push(value);
}
const totalLen = decompressedChunks.reduce((acc, c) => acc + c.length, 0);
const out = new Uint8Array(totalLen);
let pos = 0;
for (const c of decompressedChunks) {
out.set(c, pos);
pos += c.length;
}
files[fileName.toLowerCase()] = out;
} catch {
// Fallback or skip corrupted member
}
}
}
offset = dataStart + compressedSize;
}
return files;
}
/**
* Parses dBase III (.dbf) binary buffer into array of record objects.
*/
export function parseDbfBinary(dbfBytes: Uint8Array): Array<Record<string, unknown>> {
const records: Array<Record<string, unknown>> = [];
if (dbfBytes.length < 32) return records;
const view = new DataView(dbfBytes.buffer, dbfBytes.byteOffset, dbfBytes.byteLength);
const numRecords = view.getUint32(4, true);
const headerLength = view.getUint16(8, true);
const recordLength = view.getUint16(10, true);
// Read field descriptors (each 32 bytes between offset 32 and headerLength - 1)
interface FieldDesc {
name: string;
type: string;
length: number;
}
const fields: FieldDesc[] = [];
let fieldOffset = 32;
const decoder = new TextDecoder('euc-kr'); // Standard for Korean municipal GIS shp (also handles ASCII)
while (fieldOffset + 32 <= headerLength) {
const firstByte = dbfBytes[fieldOffset];
if (firstByte === 0x0d) break; // Header terminator
let name = '';
for (let i = 0; i < 11; i++) {
const b = dbfBytes[fieldOffset + i];
if (b === 0) break;
name += String.fromCharCode(b);
}
name = name.trim();
const type = String.fromCharCode(dbfBytes[fieldOffset + 11]);
const length = dbfBytes[fieldOffset + 16];
if (name) {
fields.push({ name, type, length });
}
fieldOffset += 32;
}
// Read data records
let recPos = headerLength;
for (let r = 0; r < numRecords && recPos + recordLength <= dbfBytes.length; r++) {
const isDeleted = dbfBytes[recPos] === 0x2a;
if (!isDeleted) {
const rowProps: Record<string, unknown> = {};
let colPos = recPos + 1;
for (const f of fields) {
const rawColBytes = dbfBytes.subarray(colPos, colPos + f.length);
let str = '';
try {
str = decoder.decode(rawColBytes).trim();
} catch {
str = new TextDecoder('utf-8').decode(rawColBytes).trim();
}
if (f.type === 'N' || f.type === 'F') {
const num = parseFloat(str);
rowProps[f.name] = Number.isFinite(num) ? num : str;
} else {
rowProps[f.name] = str;
}
colPos += f.length;
}
records.push(rowProps);
}
recPos += recordLength;
}
return records;
}
/**
* Parses ESRI Shapefile (.shp) binary buffer into GeoJSON features.
*/
export function parseShpBinary(
shpBytes: Uint8Array,
dbfRecords: Array<Record<string, unknown>> = []
): { features: Feature[]; geomTypes: string[] } {
const features: Feature[] = [];
const geomTypeSet = new Set<string>();
if (shpBytes.length < 100) return { features, geomTypes: [] };
const view = new DataView(shpBytes.buffer, shpBytes.byteOffset, shpBytes.byteLength);
const fileCode = view.getInt32(0, false); // Big endian 9994
if (fileCode !== 9994) {
throw new Error('유효한 ESRI Shapefile 형식이 아닙니다 (File Code mismatch).');
}
let offset = 100; // Header size
let recIdx = 0;
while (offset + 8 <= shpBytes.byteLength) {
const recordLength = view.getInt32(offset + 4, false) * 2; // 16-bit words -> bytes
const recordEnd = offset + 8 + recordLength;
if (recordEnd > shpBytes.byteLength) break;
const shapeType = view.getInt32(offset + 8, true);
const properties = dbfRecords[recIdx] || { FID: recIdx + 1 };
recIdx += 1;
if (shapeType === 1) {
// Point
const x = view.getFloat64(offset + 12, true);
const y = view.getFloat64(offset + 20, true);
geomTypeSet.add('Point');
features.push({
type: 'Feature',
id: recIdx,
properties,
geometry: {
type: 'Point',
coordinates: [x, y],
},
});
} else if (shapeType === 3 || shapeType === 5) {
// Polyline (3) or Polygon (5)
const numParts = view.getInt32(offset + 44, true);
const numPoints = view.getInt32(offset + 48, true);
const partIndices: number[] = [];
let partOffset = offset + 52;
for (let p = 0; p < numParts; p++) {
partIndices.push(view.getInt32(partOffset, true));
partOffset += 4;
}
partIndices.push(numPoints); // Sentinel end
const pointsStart = offset + 52 + numParts * 4;
const partsCoords: Array<[number, number][]> = [];
for (let p = 0; p < numParts; p++) {
const startPt = partIndices[p];
const endPt = partIndices[p + 1];
const lineCoords: [number, number][] = [];
for (let pt = startPt; pt < endPt; pt++) {
const ptPos = pointsStart + pt * 16;
const x = view.getFloat64(ptPos, true);
const y = view.getFloat64(ptPos + 8, true);
lineCoords.push([x, y]);
}
if (lineCoords.length > 0) {
partsCoords.push(lineCoords);
}
}
if (shapeType === 3) {
geomTypeSet.add('LineString');
if (partsCoords.length === 1) {
features.push({
type: 'Feature',
id: recIdx,
properties,
geometry: {
type: 'LineString',
coordinates: partsCoords[0],
},
});
} else if (partsCoords.length > 1) {
geomTypeSet.add('MultiLineString');
features.push({
type: 'Feature',
id: recIdx,
properties,
geometry: {
type: 'MultiLineString',
coordinates: partsCoords,
},
});
}
} else if (shapeType === 5) {
geomTypeSet.add('Polygon');
features.push({
type: 'Feature',
id: recIdx,
properties,
geometry: {
type: 'Polygon',
coordinates: partsCoords,
},
});
}
}
offset = recordEnd;
}
return { features, geomTypes: Array.from(geomTypeSet) };
}
/**
* Parses an uploaded Shapefile (.zip or .shp) into a UserGisLayer,
* ensuring the layer name matches the uploaded file name (minus extension).
*/
export async function parseShapefileZip(
file: File,
modelExtents?: Extents | null
): Promise<UserGisLayer> {
const buffer = await file.arrayBuffer();
let shpBytes: Uint8Array | null = null;
let dbfBytes: Uint8Array | undefined = undefined;
let prjText: string | undefined = undefined;
if (file.name.toLowerCase().endsWith('.zip')) {
const files = await unzipArchive(buffer);
const shpKey = Object.keys(files).find((k) => k.endsWith('.shp'));
if (!shpKey || !files[shpKey]) {
throw new Error('ZIP 파일 내에 .shp Shapefile 파일이 포함되어 있지 않습니다.');
}
shpBytes = files[shpKey];
const dbfKey = Object.keys(files).find((k) => k.endsWith('.dbf'));
if (dbfKey) dbfBytes = files[dbfKey];
const prjKey = Object.keys(files).find((k) => k.endsWith('.prj'));
if (prjKey && files[prjKey]) {
prjText = new TextDecoder('utf-8').decode(files[prjKey]);
}
} else if (file.name.toLowerCase().endsWith('.shp')) {
shpBytes = new Uint8Array(buffer);
} else {
throw new Error('지원하지 않는 파일 형식입니다. .geojson, .json 또는 .zip (Shapefile) 파일을 선택하세요.');
}
const dbfRecords = dbfBytes ? parseDbfBinary(dbfBytes) : [];
const { features, geomTypes } = parseShpBinary(shpBytes, dbfRecords);
if (features.length === 0) {
throw new Error('Shapefile에 포함된 유효한 지오메트리 객체가 없습니다.');
}
const rawFc: FeatureCollection = {
type: 'FeatureCollection',
features,
};
// Estimate CRS from .prj or coordinate values
let estimatedCrs = 'WGS84 (EPSG:4326)';
let detectedCrsKey = 'EPSG:4326';
if (prjText) {
const upperPrj = prjText.toUpperCase();
if (upperPrj.includes('5179') || upperPrj.includes('UTM-K')) {
estimatedCrs = '한국 UTM-K (EPSG:5179)';
detectedCrsKey = 'EPSG:5179';
} else if (upperPrj.includes('5186')) {
estimatedCrs = '중부원점 GRS80 (EPSG:5186)';
detectedCrsKey = 'EPSG:5186';
} else if (upperPrj.includes('5181')) {
estimatedCrs = '카카오맵 중부 (EPSG:5181)';
detectedCrsKey = 'EPSG:5181';
} else if (upperPrj.includes('5187')) {
estimatedCrs = '동부원점 GRS80 (EPSG:5187)';
detectedCrsKey = 'EPSG:5187';
} else if (upperPrj.includes('5185')) {
estimatedCrs = '서부원점 GRS80 (EPSG:5185)';
detectedCrsKey = 'EPSG:5185';
} else if (upperPrj.includes('5174')) {
estimatedCrs = '구 지적 중부 (EPSG:5174)';
detectedCrsKey = 'EPSG:5174';
} else if (upperPrj.includes('5176')) {
estimatedCrs = '구 기준점 중부 (EPSG:5176)';
detectedCrsKey = 'EPSG:5176';
} else if (upperPrj.includes('102080')) {
estimatedCrs = 'KGD2002 중부 (EPSG:102080)';
detectedCrsKey = 'EPSG:102080';
} else if (upperPrj.includes('102082')) {
estimatedCrs = 'KGD2002 동부 (EPSG:102082)';
detectedCrsKey = 'EPSG:102082';
} else if (upperPrj.includes('3857') || upperPrj.includes('900913') || upperPrj.includes('PSEUDO_MERCATOR')) {
estimatedCrs = 'Web Mercator (EPSG:3857)';
detectedCrsKey = 'EPSG:3857';
} else if (upperPrj.includes('WGS_1984') || upperPrj.includes('4326')) {
estimatedCrs = 'WGS84 (EPSG:4326)';
detectedCrsKey = 'EPSG:4326';
} else {
estimatedCrs = `PRJ: ${prjText.slice(0, 30)}...`;
}
} else {
const extents = computeGeoJsonExtents(rawFc);
if (extents) {
const detected = detectCrsFromExtents(extents);
detectedCrsKey = detected;
if (detected === 'EPSG:5179') estimatedCrs = '한국 UTM-K (EPSG:5179)';
else if (detected === 'EPSG:5186') estimatedCrs = '중부원점 (EPSG:5186)';
else if (detected === 'EPSG:4326') estimatedCrs = 'WGS84 (EPSG:4326)';
else estimatedCrs = 'CRS 미확인 (로컬)';
} else {
estimatedCrs = 'CRS 미확인';
}
}
// Normalize coordinates to WGS84 for MapLibre rendering
const normalizedFc = normalizeGeoJsonToWgs84(rawFc, detectedCrsKey, modelExtents);
// Layer name MUST be the uploaded file name without extension
const cleanName = file.name.replace(/\.[^/.]+$/, '');
const id = `user_layer_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
return {
id,
name: cleanName,
fileName: file.name,
featureCollection: normalizedFc,
featureCount: features.length,
geomTypes: geomTypes.length > 0 ? geomTypes : ['LineString'],
estimatedCrs,
visible: true,
opacity: 0.85,
color: getNextLayerColor(),
createdAt: new Date().toLocaleTimeString(),
};
}
+197
View File
@@ -0,0 +1,197 @@
// GIS layer -> network tools: valves from a point layer, meter (customer)
// assignment, small-block mapping and bulk asset attribute tables.
// Points arrive in model coordinates (the caller converts from WGS84).
import type { NetworkModel, VertexPoint } from './inpModel';
import { getNextNodeId, getNextValveId, splitPipe } from './inpModel.ts';
import { getDemandCategories, setDemandCategories, setStatusOverride } from './extrasActions.ts';
import { ASSET_FIELDS, withAsset } from './assets.ts';
// ---------------------------------------------------------------- nearest pipe
export interface PipeHit {
pipeId: string;
distance: number;
/** Closest point on the pipe's polyline. */
point: VertexPoint;
}
export function nearestPipe(model: NetworkModel, p: VertexPoint, maxDistance = Infinity): PipeHit | null {
let best: PipeHit | null = null;
for (const pipe of Object.values(model.pipes)) {
const a = model.nodes[pipe.node1];
const b = model.nodes[pipe.node2];
if (!a || !b) continue;
const pts = [a, ...(model.vertices?.[pipe.id] ?? []), b];
for (let k = 0; k < pts.length - 1; k++) {
const s = pts[k], e = pts[k + 1];
const L2 = (e.x - s.x) ** 2 + (e.y - s.y) ** 2;
const t = L2 > 0 ? Math.max(0, Math.min(1, ((p.x - s.x) * (e.x - s.x) + (p.y - s.y) * (e.y - s.y)) / L2)) : 0;
const q = { x: s.x + (e.x - s.x) * t, y: s.y + (e.y - s.y) * t };
const d = Math.hypot(q.x - p.x, q.y - p.y);
if (d <= maxDistance && (!best || d < best.distance)) best = { pipeId: pipe.id, distance: d, point: q };
}
}
return best;
}
export function nearestJunction(model: NetworkModel, p: VertexPoint, maxDistance = Infinity): { id: string; distance: number } | null {
let best: { id: string; distance: number } | null = null;
for (const n of Object.values(model.nodes)) {
if (n.type !== 'JUNCTION') continue;
const d = Math.hypot(n.x - p.x, n.y - p.y);
if (d <= maxDistance && (!best || d < best.distance)) best = { id: n.id, distance: d };
}
return best;
}
// ---------------------------------------------------------------- valves
export interface ValveSpec {
x: number;
y: number;
id?: string;
/** TCV (default, open = loss 0), PRV, PSV, FCV, PBV, GPV. */
type?: string;
setting?: number;
closed?: boolean;
}
/**
* Insert valves on the nearest pipe (within `maxDistance`): the pipe is split
* at the valve point and a zero-length valve link joins the two halves.
*/
export function insertValves(model: NetworkModel, specs: ValveSpec[], maxDistance: number): { model: NetworkModel; added: string[]; skipped: number } {
let m = model;
const added: string[] = [];
let skipped = 0;
for (const spec of specs) {
const hit = nearestPipe(m, spec, maxDistance);
if (!hit) { skipped++; continue; }
const pipe = m.pipes[hit.pipeId];
const a = m.nodes[pipe.node1], b = m.nodes[pipe.node2];
// Too close to an end: splitting would leave a ~0 length piece.
if (Math.hypot(hit.point.x - a.x, hit.point.y - a.y) < 1e-6 || Math.hypot(hit.point.x - b.x, hit.point.y - b.y) < 1e-6) { skipped++; continue; }
const r = splitPipe(m, hit.pipeId, 0.5, hit.point);
m = r.model;
const j1 = m.nodes[r.newJunctionId];
const j2 = getNextNodeId(m, 'J');
m = { ...m, nodes: { ...m.nodes, [j2]: { ...j1, id: j2, demand: 0 } } };
const second = m.pipes[r.newPipeId];
m = { ...m, pipes: { ...m.pipes, [r.newPipeId]: { ...second, node1: j2 } } };
let vid = spec.id?.trim() || getNextValveId(m, 'V');
if (m.valves?.[vid] || m.pipes[vid] || m.pumps[vid]) vid = getNextValveId(m, 'V');
const type = (spec.type ?? 'TCV').toUpperCase();
m = { ...m, valves: { ...(m.valves ?? {}), [vid]: { id: vid, node1: r.newJunctionId, node2: j2, diameter: pipe.diameter, valveType: type, setting: spec.setting ?? 0, minorLoss: 0 } } };
if (spec.closed) m = setStatusOverride(m, vid, 'CLOSED');
added.push(vid);
}
return { model: m, added, skipped };
}
// ---------------------------------------------------------------- meters
export interface MeterPoint {
x: number;
y: number;
/** Consumption in model flow units. */
flow: number;
id?: string;
}
/**
* 계량기 배정: each meter goes to the nearest junction (within maxDistance) and
* the sums are written as one demand category per junction.
*/
export function assignMeters(model: NetworkModel, meters: MeterPoint[], maxDistance: number, category = '계량기', mode: 'replace' | 'add' = 'add'): { model: NetworkModel; nodes: Record<string, { count: number; flow: number }>; unassigned: number } {
const sums: Record<string, { count: number; flow: number }> = {};
let unassigned = 0;
for (const mt of meters) {
if (!Number.isFinite(mt.flow)) { unassigned++; continue; }
const hit = nearestJunction(model, mt, maxDistance);
if (!hit) { unassigned++; continue; }
const s = (sums[hit.id] ??= { count: 0, flow: 0 });
s.count++;
s.flow += mt.flow;
}
let m = model;
for (const [id, s] of Object.entries(sums)) {
const kept = mode === 'add' ? getDemandCategories(m, id).filter((c) => (c.demand !== 0 || c.category) && c.category !== category) : [];
m = setDemandCategories(m, id, [...kept, { demand: Math.round(s.flow * 1e6) / 1e6, category }]);
}
return { model: m, nodes: sums, unassigned };
}
// ---------------------------------------------------------------- blocks
/** Write node blocks into assets; a link gets the block when both ends share it. */
export function writeBlocks(model: NetworkModel, nodeBlock: Record<string, string>, key = 'block'): { model: NetworkModel; nodes: number; links: number } {
let assets = model.assets;
let nodes = 0, links = 0;
for (const [id, b] of Object.entries(nodeBlock)) {
if (!model.nodes[id]) continue;
assets = withAsset(assets, 'node', id, key, b);
nodes++;
}
for (const l of [...Object.values(model.pipes), ...Object.values(model.pumps), ...Object.values(model.valves ?? {})]) {
const a = nodeBlock[l.node1], b = nodeBlock[l.node2];
if (a && a === b) {
assets = withAsset(assets, 'link', l.id, key, a);
links++;
}
}
return { model: { ...model, assets }, nodes, links };
}
// ---------------------------------------------------------------- asset table
/**
* 자산 속성 일괄 입력: first row = headers ("ID" then field labels or keys),
* tab or comma separated. Known labels (관재질, 설치년도 ...) map to their keys.
*/
export function applyAssetTable(model: NetworkModel, text: string, kind: 'node' | 'link'): { model: NetworkModel; updated: number; unknown: string[]; fields: string[] } {
const lines = text.split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) throw new Error('머리글 행과 자료 행이 필요합니다.');
const split = (l: string) => (l.includes('\t') ? l.split('\t') : l.split(',')).map((c) => c.trim().replace(/^"|"$/g, ''));
const header = split(lines[0]);
const keys = header.slice(1).map((h) => ASSET_FIELDS.find((f) => f.label === h || f.key === h || f.label.replace(/\(.*\)/, '') === h)?.key ?? h);
if (!keys.length) throw new Error('ID 다음에 속성 열이 있어야 합니다.');
const exists = (id: string) => (kind === 'node' ? !!model.nodes[id] : !!(model.pipes[id] || model.pumps[id] || model.valves?.[id]));
let assets = model.assets;
let updated = 0;
const unknown: string[] = [];
for (const line of lines.slice(1)) {
const cells = split(line);
const id = cells[0];
if (!id) continue;
if (!exists(id)) { unknown.push(id); continue; }
keys.forEach((k, i) => {
const raw = cells[i + 1];
if (raw === undefined) return;
const num = Number(raw);
assets = withAsset(assets, kind, id, k, raw === '' ? null : Number.isFinite(num) && /^-?\d+(\.\d+)?$/.test(raw) ? num : raw);
});
updated++;
}
return { model: { ...model, assets }, updated, unknown, fields: keys };
}
// ---------------------------------------------------------------- image overlays
/**
* World file (.pgw/.jgw/.tfw: A, D, B, E, C, F) -> model-coordinate corners of
* an image of width x height pixels, in top-left, top-right, bottom-right,
* bottom-left order. C/F address the *centre* of the top-left pixel.
*/
export function worldFileCorners(text: string, width: number, height: number): VertexPoint[] {
const v = text.split(/\s+/).filter(Boolean).map(Number);
if (v.length < 6 || v.slice(0, 6).some((n) => !Number.isFinite(n))) throw new Error('월드 파일에는 숫자 6개가 있어야 합니다.');
const [A, D, B, E, C, F] = v;
const at = (col: number, row: number) => ({ x: A * col + B * row + C - (A + B) / 2, y: D * col + E * row + F - (D + E) / 2 });
return [at(0, 0), at(width, 0), at(width, height), at(0, height)];
}
/** Axis-aligned corners from an extent (minX, minY, maxX, maxY). */
export function extentCorners(minX: number, minY: number, maxX: number, maxY: number): VertexPoint[] {
if (!(maxX > minX && maxY > minY)) throw new Error('범위가 올바르지 않습니다 (최대가 최소보다 커야 합니다).');
return [{ x: minX, y: maxY }, { x: maxX, y: maxY }, { x: maxX, y: minY }, { x: minX, y: minY }];
}
+852
View File
@@ -0,0 +1,852 @@
@import './styles/tokens.css';
/* =============================================================================
GISPipe Workstation UI Styles
High-density QGIS / Figma / Rhino / Linear style layout
Uses tokens from src/styles/tokens.css
============================================================================= */
/* 1. Global App Shell */
*, *::before, *::after {
box-sizing: border-box;
}
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
background: var(--bg-app);
color: var(--text);
font-family: var(--font-sans);
font-size: var(--fs-base);
line-height: var(--lh-ui);
letter-spacing: var(--ls-ko);
word-break: keep-all;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background: var(--bg-app);
overflow: hidden;
position: relative;
}
/* 2. Top Bar (Header) - 48px QGIS/Linear style */
.header {
flex: none;
height: var(--h-topbar);
padding: 0 var(--s-5);
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
z-index: var(--z-topbar);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.header h1 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semi);
color: var(--text);
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: var(--s-3);
white-space: nowrap;
}
.header-buttons {
display: flex;
align-items: center;
gap: var(--s-2);
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.header-buttons::-webkit-scrollbar {
display: none;
}
/* 3. Button System - High density (28px height, 4px radius) */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s-2);
height: var(--h-btn);
padding: 0 var(--s-4);
font-family: var(--font-sans);
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
letter-spacing: var(--ls-ko);
border-radius: var(--r-sm);
border: 1px solid var(--border-strong);
background: var(--bg-panel);
color: var(--text);
cursor: pointer;
white-space: nowrap;
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease), color var(--dur) var(--ease);
user-select: none;
}
.btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-strong);
}
.btn:active:not(:disabled) {
background: var(--bg-active);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
color: var(--text-disabled);
border-color: var(--border);
}
.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* Secondary Button: Subtle neutral chrome */
.btn-secondary {
background: var(--bg-subtle);
border: 1px solid var(--border);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-strong);
color: var(--text);
}
/* Primary Action Button: Accent (화면당 1개 원칙) */
.btn-primary {
background: var(--accent);
border: 1px solid var(--accent);
color: var(--text-on-accent);
font-weight: var(--fw-semi);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: var(--text-on-accent);
}
/* Danger Button: Reversal or destructive actions */
.btn-danger {
background: var(--bg-panel);
border: 1px solid var(--crit);
color: var(--crit);
}
.btn-danger:hover:not(:disabled) {
background: var(--crit-wash);
}
/* 4. Main Workspace Layout */
.main-content {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
background: var(--bg-app);
}
/* 5. Left: Map Pane */
.map-pane {
flex: 1;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
background: var(--bg-app);
}
.map-header {
height: 36px;
padding: 0 var(--s-3);
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
position: relative;
/* Above map controls, below the top bar: a hard-coded 100 here covered the
top bar's dropdowns (export/project menus) where they overlap this row. */
z-index: var(--z-dock);
flex-shrink: 0;
}
.map-container {
flex: 1;
position: relative;
overflow: hidden;
background: #cbd5e1;
}
.map-status-bar {
flex: none;
min-height: 24px;
padding: 3px var(--s-3);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
background: var(--bg-panel);
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: var(--fs-2xs);
font-family: var(--font-mono);
white-space: nowrap;
}
/* Map Overlays & Notices */
.map-notice {
position: absolute;
top: var(--s-3);
left: var(--s-3);
z-index: var(--z-map-ctrl);
background: var(--bg-panel);
padding: var(--s-2) var(--s-4);
font-size: var(--fs-xs);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--r-sm);
box-shadow: var(--shadow-map);
}
.map-hover {
/* Follows the cursor; left/top are set from Map.tsx (placeHoverBox). */
position: fixed;
left: -9999px;
top: 0;
pointer-events: none;
max-width: 320px;
background: var(--bg-panel);
padding: var(--s-4) var(--s-5);
border: 1px solid var(--border);
border-radius: var(--r-sm);
z-index: var(--z-popover);
font-size: var(--fs-sm);
color: var(--text);
box-shadow: var(--shadow-pop);
}
/* 6. Right: Inspector & Tool Panel (Dock) */
.panel {
width: var(--w-dock-right);
min-width: var(--w-dock-min);
max-width: var(--w-dock-max);
padding: var(--s-4);
overflow-y: auto;
background: var(--bg-panel);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: var(--s-3);
z-index: var(--z-dock);
}
.panel-collapsed {
width: 68px;
min-width: 68px;
max-width: 68px;
padding: 4px;
overflow: hidden;
}
.right-dock-tabs {
display: flex;
gap: 2px;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 10px;
background: var(--bg-subtle);
padding: 2px 4px 0;
border-radius: var(--r-sm) var(--r-sm) 0 0;
}
.right-dock-tab {
flex: 1;
min-width: 0;
height: 30px;
padding: 0 8px;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.right-dock-tab:hover {
background: var(--accent-wash);
color: var(--text);
}
.right-dock-tab.active {
border-bottom-color: var(--accent);
color: var(--text);
font-weight: 600;
}
.right-dock-tab.error {
color: var(--crit);
}
.right-dock-close {
flex: none;
width: 26px;
height: 30px;
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.right-dock-close:hover {
background: var(--crit-wash);
color: var(--crit);
}
.panel-collapsed .right-dock-close {
display: none;
}
.panel-collapsed .right-dock-tabs {
flex-direction: column;
gap: 4px;
width: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
}
.panel-collapsed .right-dock-tab {
flex: none;
width: 58px;
height: 46px;
padding: 4px 2px;
border: none;
border-radius: var(--r-sm);
flex-direction: column;
gap: 2px;
}
.panel-collapsed .right-dock-tab-label {
font-size: 10px;
line-height: 1;
}
/* 7. Forms, Inputs, and Selects */
input[type="text"],
input[type="number"],
select,
textarea {
height: var(--h-input);
padding: 0 var(--s-3);
font-family: var(--font-sans);
font-size: var(--fs-sm);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
background: var(--bg-panel);
color: var(--text);
transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
textarea {
height: auto;
padding: var(--s-2) var(--s-3);
}
input[type="number"] {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
text-align: right;
}
input[type="text"]:focus,
input[type="number"]:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
select {
cursor: pointer;
padding-right: var(--s-5);
}
/* 8. Tables - Dense Engineering Spec */
table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-xs);
font-variant-numeric: tabular-nums;
}
th {
background: var(--bg-subtle);
color: var(--text-secondary);
font-weight: var(--fw-semi);
text-align: left;
padding: var(--s-2) var(--s-3);
border-bottom: 1px solid var(--border-strong);
white-space: nowrap;
}
td {
padding: var(--s-2) var(--s-3);
border-bottom: 1px solid var(--border);
color: var(--text);
white-space: nowrap;
}
tr:hover td {
background: var(--bg-hover);
}
/* 9. Feedback & Verification Cards */
.error-box {
padding: var(--s-3) var(--s-4);
background: var(--crit-wash);
color: var(--crit);
border: 1px solid var(--crit);
border-radius: var(--r-sm);
font-size: var(--fs-xs);
}
.diff-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-3);
}
.result-card {
padding: var(--s-3);
border-radius: var(--r-sm);
font-size: var(--fs-xs);
border: 1px solid var(--border);
background: var(--bg-panel);
}
.card-server {
background: var(--bg-subtle);
border-left: 3px solid var(--text-secondary);
}
.card-wasm {
background: var(--accent-wash);
border-left: 3px solid var(--accent);
}
.diff-row {
display: flex;
justify-content: space-between;
border-bottom: 1px dashed var(--border);
padding-bottom: var(--s-1);
margin-bottom: var(--s-2);
}
/* 10. Status Badges */
.badge-good {
background: var(--good-wash);
color: var(--good);
border: 1px solid var(--good);
border-radius: var(--r-xs);
padding: 1px 6px;
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
}
.badge-warn {
background: var(--warn-wash);
color: var(--warn);
border: 1px solid var(--warn);
border-radius: var(--r-xs);
padding: 1px 6px;
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
}
.badge-crit {
background: var(--crit-wash);
color: var(--crit);
border: 1px solid var(--crit);
border-radius: var(--r-xs);
padding: 1px 6px;
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
}
.badge-neutral {
background: var(--bg-subtle);
color: var(--text-secondary);
border: 1px solid var(--border-strong);
border-radius: var(--r-xs);
padding: 1px 6px;
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
}
/* 11. Modal Overlays (No blur per DESIGN.md § 2) */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--bg-overlay);
display: flex;
align-items: center;
justify-content: center;
padding: var(--s-5);
z-index: var(--z-modal);
}
.modal-content {
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
box-shadow: var(--shadow-modal);
overflow: hidden;
}
/* 12. Mobile & Small Screen Adaptation */
@media (max-width: 1024px) {
.main-content {
flex-direction: column;
}
.layer-panel {
position: absolute !important;
left: var(--w-rail);
top: 0;
bottom: 0;
z-index: 50;
box-shadow: var(--shadow-pop);
}
/* In the stacked (column) layout the tool rail becomes a horizontal strip;
as a vertical column it took a full ~330px row above the map. */
/* doubled class: the base .tool-rail rule appears later in this file */
.tool-rail.tool-rail {
width: 100%;
height: auto;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
padding: 0 var(--s-2);
border-right: none;
border-bottom: 1px solid var(--border);
}
/* flex:1 (basis 0) overrode height:55vh in the column layout, so the map
shrank to ~2px once the side panel opened. Give it a fixed share. */
.map-pane {
flex: 0 0 55vh;
height: 55vh;
min-height: 280px;
border-right: none;
border-bottom: 1px solid var(--border);
}
.panel {
width: 100%;
max-width: 100%;
flex: 1 1 auto;
height: auto;
min-height: 0;
border-left: none;
}
.panel.panel-collapsed {
width: 100%;
min-width: 100%;
max-width: 100%;
height: 50px;
min-height: 50px;
}
.panel.panel-collapsed .right-dock-tabs {
flex-direction: row;
}
.panel.panel-collapsed .right-dock-tab {
width: auto;
height: 40px;
padding: 0 10px;
flex-direction: row;
gap: 4px;
}
}
@media (max-width: 768px) {
.header {
height: auto;
padding: var(--s-2) var(--s-3);
flex-wrap: wrap;
gap: var(--s-2);
}
.header h1 {
font-size: var(--fs-sm);
}
.btn {
height: var(--h-btn-sm);
font-size: var(--fs-2xs);
padding: 0 var(--s-2);
}
}
/* 13. Edit Stripe (DESIGN.md § 4) */
.edit-stripe {
height: 4px;
width: 100%;
background: var(--edit-stripe);
flex-shrink: 0;
z-index: 40;
}
/* 14. Mode Segment Control (DESIGN.md § 4) */
.mode-segment {
display: inline-flex;
align-items: center;
background: var(--bg-subtle);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
padding: 2px;
gap: 2px;
}
.mode-btn {
display: inline-flex;
align-items: center;
gap: var(--s-2);
height: 24px;
padding: 0 var(--s-3);
font-family: var(--font-sans);
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
color: var(--text-secondary);
background: transparent;
border: none;
border-radius: var(--r-xs);
cursor: pointer;
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
user-select: none;
white-space: nowrap;
}
.mode-btn:hover:not(:disabled) {
color: var(--text);
}
.mode-btn.active {
background: var(--bg-panel);
color: var(--text);
font-weight: var(--fw-semi);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.mode-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 15. Dropdown Menus (DESIGN.md § 7) */
.dropdown-container {
position: relative;
display: inline-block;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 200px;
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
padding: 4px;
z-index: var(--z-popover);
display: flex;
flex-direction: column;
gap: 1px;
}
.dropdown-menu.align-right {
left: auto;
right: 0;
}
.dropdown-header {
font-size: var(--fs-2xs);
font-weight: var(--fw-semi);
color: var(--text-muted);
padding: 4px 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dropdown-item {
display: flex;
align-items: center;
gap: var(--s-3);
width: 100%;
height: var(--h-row-compact);
padding: 0 var(--s-3);
font-family: var(--font-sans);
font-size: var(--fs-xs);
color: var(--text);
background: transparent;
border: none;
border-radius: var(--r-xs);
cursor: pointer;
text-align: left;
transition: background var(--dur-fast) var(--ease);
white-space: nowrap;
}
.dropdown-item:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.dropdown-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dropdown-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
/* 16. Left Tool Rail (44px vertical QGIS/Figma bar) */
.tool-rail {
width: var(--w-rail);
background: var(--bg-panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding: var(--s-2) 0;
gap: var(--s-2);
flex-shrink: 0;
z-index: 15;
}
.tool-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--r-sm);
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.tool-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.tool-btn.active {
background: var(--accent-wash);
color: var(--accent);
border-color: var(--accent-border);
}
.tool-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tool-menu-anchor {
position: relative;
}
.tool-flyout {
position: absolute;
left: calc(100% + var(--s-2));
top: 0;
width: 180px;
padding: var(--s-2);
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
z-index: 30;
}
.tool-flyout-title {
padding: var(--s-1) var(--s-2) var(--s-2);
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: var(--fw-semi);
}
.tool-flyout-item {
width: 100%;
min-height: 32px;
display: grid;
grid-template-columns: 18px 1fr auto;
align-items: center;
gap: var(--s-2);
padding: var(--s-1) var(--s-2);
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.tool-flyout-item:hover {
background: var(--bg-hover);
}
.tool-flyout-item small {
color: var(--text-muted);
font-size: var(--fs-2xs);
}
.tool-divider {
width: 24px;
height: 1px;
background: var(--border);
margin: 2px 0;
}
+75
View File
@@ -0,0 +1,75 @@
export type InpTextEncoding = 'utf-8' | 'cp949';
export interface DecodedInpText {
text: string;
encoding: InpTextEncoding;
warning?: string;
}
function decode(bytes: Uint8Array, label: string, fatal: boolean): string {
return new TextDecoder(label, { fatal }).decode(bytes);
}
function corruptionScore(text: string): number {
const replacements = (text.match(/\uFFFD/g) || []).length;
const controls = (text.match(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g) || []).length;
const commonMojibake = (text.match(/[ÃÂìëíê][\u0080-\uFFFF]?/g) || []).length;
return replacements * 100 + controls * 20 + commonMojibake * 5;
}
export function decodeInpBytes(
buffer: ArrayBuffer,
forceEncoding?: InpTextEncoding
): DecodedInpText {
const bytes = new Uint8Array(buffer);
if (forceEncoding === 'utf-8') {
return { text: decode(bytes, 'utf-8', false), encoding: 'utf-8' };
}
if (forceEncoding === 'cp949') {
return {
text: decode(bytes, 'euc-kr', false),
encoding: 'cp949',
warning: '사용자 요청에 따라 CP949로 다시 읽었습니다.',
};
}
let utf8Text: string | null = null;
try {
utf8Text = decode(bytes, 'utf-8', true);
} catch {
// Invalid UTF-8 is expected for many legacy Korean EPANET files.
}
let cp949Text: string | null = null;
try {
cp949Text = decode(bytes, 'euc-kr', true);
} catch {
// Keep a lossy UTF-8 fallback below if neither strict decoder succeeds.
}
if (utf8Text !== null) {
if (cp949Text !== null && corruptionScore(cp949Text) < corruptionScore(utf8Text)) {
return {
text: cp949Text,
encoding: 'cp949',
warning: 'UTF-8 텍스트에서 깨짐 징후를 감지하여 CP949로 읽었습니다.',
};
}
return { text: utf8Text, encoding: 'utf-8' };
}
if (cp949Text !== null) {
return {
text: cp949Text,
encoding: 'cp949',
warning: 'UTF-8 검증에 실패하여 CP949로 읽었습니다.',
};
}
return {
text: decode(bytes, 'utf-8', false),
encoding: 'utf-8',
warning: 'UTF-8과 CP949 검증에 실패했습니다. 파일의 문자 인코딩을 확인하세요.',
};
}
+333
View File
@@ -0,0 +1,333 @@
// Structured model for the INP sections that used to be preserved only as
// raw text: [DEMANDS] [EMITTERS] [QUALITY] [SOURCES] [MIXING] [STATUS]
// [ENERGY] [LABELS] and the global/tank lines of [REACTIONS].
//
// parseInp fills NetworkModel.extras from these sections; generateInp writes
// them back from extras (so edits, renames and deletions are reflected), and
// appends a section that the source INP did not have once it holds data.
//
// EPANET semantics this module relies on (checked against the 2.3.5 DLL):
// - [DEMANDS] rows for a junction REPLACE its [JUNCTIONS] base demand
// (J1 base 5 + DEMANDS 10, 20 solves with demand 30, not 35). The first
// category is the junction's primary demand, as in the EPANET GUI.
// - [STATUS] overrides the status column of [PIPES]/[PUMPS]/[VALVES].
export interface DemandCategory {
demand: number;
pattern?: string;
category?: string;
}
export type SourceType = 'CONCEN' | 'MASS' | 'SETPOINT' | 'FLOWPACED';
export const SOURCE_TYPES: SourceType[] = ['CONCEN', 'MASS', 'SETPOINT', 'FLOWPACED'];
export interface SourceDef {
type: SourceType;
strength: number;
pattern?: string;
}
export type MixingModel = 'MIXED' | '2COMP' | 'FIFO' | 'LIFO';
export const MIXING_MODELS: MixingModel[] = ['MIXED', '2COMP', 'FIFO', 'LIFO'];
export interface MixingDef {
model: MixingModel;
fraction?: number;
}
/** One `KEY value` line, kept as written so unknown keywords survive a round trip. */
export interface KeyValue {
key: string;
value: string;
}
export interface PumpEnergy {
efficiency?: string; // efficiency curve ID
price?: number;
pattern?: string;
}
export interface EnergyDef {
/** GLOBAL EFFICIENCY / GLOBAL PRICE / GLOBAL PATTERN / DEMAND CHARGE lines. */
global: KeyValue[];
pumps: Record<string, PumpEnergy>;
}
export interface MapLabel {
x: number;
y: number;
text: string;
anchor?: string;
}
export interface ModelExtras {
demands: Record<string, DemandCategory[]>;
emitters: Record<string, number>;
quality: Record<string, number>;
sources: Record<string, SourceDef>;
mixing: Record<string, MixingDef>;
/** Link ID -> OPEN | CLOSED | ACTIVE | numeric setting, as written. */
statusOverrides: Record<string, string>;
energy: EnergyDef;
/** ORDER / GLOBAL / LIMITING / ROUGHNESS lines of [REACTIONS]. */
reactionsGlobal: KeyValue[];
/** TANK reaction coefficients from [REACTIONS]. */
tankReactions: Record<string, number>;
labels: MapLabel[];
}
export const EXTRA_SECTIONS = ['DEMANDS', 'EMITTERS', 'QUALITY', 'SOURCES', 'MIXING', 'STATUS', 'ENERGY', 'LABELS'] as const;
export function emptyExtras(): ModelExtras {
return {
demands: {},
emitters: {},
quality: {},
sources: {},
mixing: {},
statusOverrides: {},
energy: { global: [], pumps: {} },
reactionsGlobal: [],
tankReactions: {},
labels: [],
};
}
type SectionIndices = Record<string, { start: number; end: number }>;
function sectionLines(rawLines: string[], sectionIndices: SectionIndices, name: string): string[] {
const r = sectionIndices[name];
if (!r) return [];
return rawLines.slice(r.start + 1, r.end + 1);
}
const num = (s: string | undefined) => (s === undefined ? NaN : Number(s));
export function parseExtras(rawLines: string[], sectionIndices: SectionIndices): ModelExtras {
const ex = emptyExtras();
const data = (name: string) =>
sectionLines(rawLines, sectionIndices, name)
.map((line) => {
const semi = line.indexOf(';');
return { body: (semi >= 0 ? line.slice(0, semi) : line).trim(), comment: semi >= 0 ? line.slice(semi + 1).trim() : '' };
})
.filter((l) => l.body);
for (const { body, comment } of data('DEMANDS')) {
const [id, d, pattern] = body.split(/\s+/);
if (!id || !Number.isFinite(num(d))) continue;
(ex.demands[id] ??= []).push({ demand: num(d), pattern: pattern || undefined, category: comment || undefined });
}
for (const { body } of data('EMITTERS')) {
const [id, c] = body.split(/\s+/);
if (id && Number.isFinite(num(c))) ex.emitters[id] = num(c);
}
for (const { body } of data('QUALITY')) {
const [id, q] = body.split(/\s+/);
if (id && Number.isFinite(num(q))) ex.quality[id] = num(q);
}
for (const { body } of data('SOURCES')) {
const [id, type, strength, pattern] = body.split(/\s+/);
const t = type?.toUpperCase() as SourceType;
if (id && SOURCE_TYPES.includes(t) && Number.isFinite(num(strength))) {
ex.sources[id] = { type: t, strength: num(strength), pattern: pattern || undefined };
}
}
for (const { body } of data('MIXING')) {
const [id, model, fraction] = body.split(/\s+/);
const m = model?.toUpperCase() as MixingModel;
if (id && MIXING_MODELS.includes(m)) ex.mixing[id] = { model: m, fraction: Number.isFinite(num(fraction)) ? num(fraction) : undefined };
}
for (const { body } of data('STATUS')) {
const [id, value] = body.split(/\s+/);
if (id && value) ex.statusOverrides[id] = value;
}
for (const { body } of data('ENERGY')) {
const t = body.split(/\s+/);
const k0 = t[0]?.toUpperCase();
if (k0 === 'PUMP' && t.length >= 4) {
const pe = (ex.energy.pumps[t[1]] ??= {});
const k = t[2].toUpperCase();
if (k.startsWith('EFFIC')) pe.efficiency = t[3];
else if (k.startsWith('PRICE') && Number.isFinite(num(t[3]))) pe.price = num(t[3]);
else if (k.startsWith('PATT')) pe.pattern = t[3];
} else if (t.length >= 2) {
ex.energy.global.push({ key: t.slice(0, -1).join(' '), value: t[t.length - 1] });
}
}
for (const { body } of data('REACTIONS')) {
const t = body.split(/\s+/);
const k0 = t[0]?.toUpperCase();
if (k0 === 'BULK' || k0 === 'WALL') continue; // per-pipe, kept on InpPipe
if (k0 === 'TANK' && t.length >= 3 && Number.isFinite(num(t[2]))) {
ex.tankReactions[t[1]] = num(t[2]);
} else if (t.length >= 2) {
ex.reactionsGlobal.push({ key: t.slice(0, -1).join(' '), value: t[t.length - 1] });
}
}
for (const line of sectionLines(rawLines, sectionIndices, 'LABELS')) {
const m = line.match(/^\s*(-?[\d.eE+-]+)\s+(-?[\d.eE+-]+)\s+"([^"]*)"\s*([^\s;]+)?/);
if (m) ex.labels.push({ x: Number(m[1]), y: Number(m[2]), text: m[3], anchor: m[4] || undefined });
}
return ex;
}
/** Section body lines for one extras section (null = not an extras section). */
export function extrasSectionLines(name: string, ex: ModelExtras, validNodes: Set<string>, validLinks: Set<string>): string[] | null {
const pad = (s: string, n: number) => s.padEnd(n);
switch (name) {
case 'DEMANDS': {
const out = [';Junction Demand Pattern Category'];
for (const [id, cats] of Object.entries(ex.demands)) {
if (!validNodes.has(id)) continue;
for (const c of cats) {
out.push(` ${pad(id, 15)} ${pad(String(c.demand), 11)} ${pad(c.pattern ?? '', 15)}${c.category ? ` ;${c.category}` : ''}`.trimEnd());
}
}
return out;
}
case 'EMITTERS':
return [';Junction Coefficient', ...Object.entries(ex.emitters).filter(([id]) => validNodes.has(id)).map(([id, c]) => ` ${pad(id, 15)} ${c}`)];
case 'QUALITY':
return [';Node InitQual', ...Object.entries(ex.quality).filter(([id]) => validNodes.has(id)).map(([id, q]) => ` ${pad(id, 15)} ${q}`)];
case 'SOURCES':
return [
';Node Type Quality Pattern',
...Object.entries(ex.sources)
.filter(([id]) => validNodes.has(id))
.map(([id, s]) => ` ${pad(id, 15)} ${pad(s.type, 11)} ${pad(String(s.strength), 11)} ${s.pattern ?? ''}`.trimEnd()),
];
case 'MIXING':
return [
';Tank Model',
...Object.entries(ex.mixing)
.filter(([id]) => validNodes.has(id))
.map(([id, m]) => ` ${pad(id, 15)} ${m.model}${m.fraction !== undefined ? ` ${m.fraction}` : ''}`),
];
case 'STATUS':
return [';ID Status/Setting', ...Object.entries(ex.statusOverrides).filter(([id]) => validLinks.has(id)).map(([id, v]) => ` ${pad(id, 15)} ${v}`)];
case 'ENERGY': {
const out = ex.energy.global.map((kv) => ` ${pad(kv.key, 17)} ${kv.value}`);
for (const [id, pe] of Object.entries(ex.energy.pumps)) {
if (!validLinks.has(id)) continue;
if (pe.efficiency) out.push(` Pump ${pad(id, 15)} Efficiency ${pe.efficiency}`);
if (pe.price !== undefined) out.push(` Pump ${pad(id, 15)} Price ${pe.price}`);
if (pe.pattern) out.push(` Pump ${pad(id, 15)} Pattern ${pe.pattern}`);
}
return out;
}
case 'LABELS':
return [
';X-Coord Y-Coord Label & Anchor Node',
...ex.labels.map((l) => ` ${pad(String(l.x), 17)} ${pad(String(l.y), 16)} "${l.text.replace(/"/g, "'")}"${l.anchor && validNodes.has(l.anchor) ? ` ${l.anchor}` : ''}`),
];
default:
return null;
}
}
/** Global + tank lines of [REACTIONS] (pipe BULK/WALL lines are written by generateInp). */
export function reactionsExtraLines(ex: ModelExtras, validNodes: Set<string>): string[] {
return [
...ex.reactionsGlobal.map((kv) => ` ${kv.key.padEnd(20)} ${kv.value}`),
...Object.entries(ex.tankReactions).filter(([id]) => validNodes.has(id)).map(([id, v]) => ` TANK ${id.padEnd(15)} ${v}`),
];
}
/** True when the section would carry any data (used to append a section the source INP lacked). */
export function extrasSectionHasData(name: string, ex: ModelExtras): boolean {
switch (name) {
case 'DEMANDS': return Object.keys(ex.demands).length > 0;
case 'EMITTERS': return Object.keys(ex.emitters).length > 0;
case 'QUALITY': return Object.keys(ex.quality).length > 0;
case 'SOURCES': return Object.keys(ex.sources).length > 0;
case 'MIXING': return Object.keys(ex.mixing).length > 0;
case 'STATUS': return Object.keys(ex.statusOverrides).length > 0;
case 'ENERGY': return ex.energy.global.length > 0 || Object.keys(ex.energy.pumps).length > 0;
case 'LABELS': return ex.labels.length > 0;
default: return false;
}
}
// ---------- rename / delete propagation ----------
function renameKey<T>(rec: Record<string, T>, oldId: string, newId: string): Record<string, T> {
if (!(oldId in rec)) return rec;
const out: Record<string, T> = {};
for (const [k, v] of Object.entries(rec)) out[k === oldId ? newId : k] = v;
return out;
}
function dropKeys<T>(rec: Record<string, T>, ids: Set<string>): Record<string, T> {
if (![...ids].some((id) => id in rec)) return rec;
const out: Record<string, T> = {};
for (const [k, v] of Object.entries(rec)) if (!ids.has(k)) out[k] = v;
return out;
}
export function renameNodeInExtras(ex: ModelExtras | undefined, oldId: string, newId: string): ModelExtras | undefined {
if (!ex) return ex;
return {
...ex,
demands: renameKey(ex.demands, oldId, newId),
emitters: renameKey(ex.emitters, oldId, newId),
quality: renameKey(ex.quality, oldId, newId),
sources: renameKey(ex.sources, oldId, newId),
mixing: renameKey(ex.mixing, oldId, newId),
tankReactions: renameKey(ex.tankReactions, oldId, newId),
labels: ex.labels.map((l) => (l.anchor === oldId ? { ...l, anchor: newId } : l)),
};
}
export function renameLinkInExtras(ex: ModelExtras | undefined, oldId: string, newId: string): ModelExtras | undefined {
if (!ex) return ex;
return {
...ex,
statusOverrides: renameKey(ex.statusOverrides, oldId, newId),
energy: { ...ex.energy, pumps: renameKey(ex.energy.pumps, oldId, newId) },
};
}
export function dropFromExtras(ex: ModelExtras | undefined, nodeIds: Set<string>, linkIds: Set<string>): ModelExtras | undefined {
if (!ex) return ex;
return {
...ex,
demands: dropKeys(ex.demands, nodeIds),
emitters: dropKeys(ex.emitters, nodeIds),
quality: dropKeys(ex.quality, nodeIds),
sources: dropKeys(ex.sources, nodeIds),
mixing: dropKeys(ex.mixing, nodeIds),
tankReactions: dropKeys(ex.tankReactions, nodeIds),
labels: ex.labels.map((l) => (l.anchor && nodeIds.has(l.anchor) ? { ...l, anchor: undefined } : l)),
statusOverrides: dropKeys(ex.statusOverrides, linkIds),
energy: { ...ex.energy, pumps: dropKeys(ex.energy.pumps, linkIds) },
};
}
// ---------- unit conversion ----------
/** Scale every flow-dimensioned value in extras (demands, emitters within one unit system). */
export function scaleExtrasFlow(ex: ModelExtras | undefined, flowMultiplier: number): ModelExtras | undefined {
if (!ex) return ex;
const r = (v: number) => Math.round(v * flowMultiplier * 1e6) / 1e6;
const demands: Record<string, DemandCategory[]> = {};
for (const [id, cats] of Object.entries(ex.demands)) demands[id] = cats.map((c) => ({ ...c, demand: r(c.demand) }));
const emitters: Record<string, number> = {};
for (const [id, c] of Object.entries(ex.emitters)) emitters[id] = r(c);
return { ...ex, demands, emitters };
}
// ---------- curve type (EPANET GUI convention: ";PUMP: description") ----------
export type CurveType = 'PUMP' | 'EFFICIENCY' | 'VOLUME' | 'HEADLOSS';
export const CURVE_TYPES: CurveType[] = ['PUMP', 'EFFICIENCY', 'VOLUME', 'HEADLOSS'];
export function curveTypeOf(description: string | undefined): CurveType | null {
const m = description?.trim().match(/^(PUMP|EFFICIENCY|VOLUME|HEADLOSS)\s*:/i);
return m ? (m[1].toUpperCase() as CurveType) : null;
}
export function withCurveType(description: string | undefined, type: CurveType): string {
const rest = (description ?? '').replace(/^(PUMP|EFFICIENCY|VOLUME|HEADLOSS)\s*:\s*/i, '');
return `${type}: ${rest}`.trim();
}
+2368
View File
File diff suppressed because it is too large. Load diff
+95
View File
@@ -0,0 +1,95 @@
// Pure legend/scale logic (no JSX) so it can be unit-tested; Legend.tsx re-exports it.
/**
* One color scale shown on the map. The map paints it as a MapLibre `step`
* expression built from the same object (see `stepColor`), so the legend bands
* and the drawn colors are always identical: value < breaks[1] -> colors[0],
* breaks[i] <= value < breaks[i+1] -> colors[i], value >= breaks[4] -> colors[4].
* breaks[0] is only the nominal lower bound and is not a class edge.
*/
export interface LegendScale {
title: string;
unit: string;
colors: readonly string[];
breaks: readonly number[];
/** Compare |value| (flow/velocity sign is only the direction). */
absolute?: boolean;
/** Draw node radius / pipe width in proportion to the value (범례 설정). */
sizeByValue?: boolean;
}
/** User edit of one legend (keyed by scale title), in the units it was made in. */
export interface LegendOverride {
unit: string;
breaks?: number[];
colors?: string[];
sizeByValue?: boolean;
}
/** A scale with the user's override applied (ignored if the display unit changed since). */
export function applyLegendOverride(scale: LegendScale, ov: LegendOverride | undefined): LegendScale {
if (!ov || ov.unit !== scale.unit) return scale;
const ok = (a?: unknown[]) => Array.isArray(a) && a.length === scale.colors.length;
return {
...scale,
breaks: ok(ov.breaks) ? ov.breaks! : scale.breaks,
colors: ok(ov.colors) ? ov.colors! : scale.colors,
sizeByValue: ov.sizeByValue ?? false,
};
}
/** Class edges for `n` classes over values: equal interval or equal count (quantile). */
export function classBreaks(values: number[], n: number, method: 'equal' | 'quantile'): number[] | null {
const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
if (v.length < 2 || v[0] === v[v.length - 1]) return null;
const nice = (x: number) => Number(x.toPrecision(3));
if (method === 'equal') {
const lo = v[0];
const step = (v[v.length - 1] - lo) / n;
return Array.from({ length: n }, (_, i) => nice(lo + step * i));
}
return Array.from({ length: n }, (_, i) => nice(v[Math.min(v.length - 1, Math.floor((i / n) * v.length))]));
}
/** MapLibre size expression (radius/width) growing with the value over the scale's edges. */
export function sizeExpression(property: string, scale: LegendScale, base: number): unknown {
const value: unknown[] = ['to-number', ['get', property], 0];
const input = scale.absolute ? ['abs', value] : value;
const lo = scale.breaks[1];
const hi = scale.breaks[scale.breaks.length - 1];
if (!(hi > lo)) return base;
return ['interpolate', ['linear'], input, lo, base * 0.6, hi, base * 2.4];
}
export const PRESSURE_COLORS = ['#86b4e8', '#5e99da', '#387dc6', '#1a63a9', '#124a81'] as const;
// All bands are clearly red so pipes stay readable on the basemap: most pipes
// run at 0-1 m/s, and pale first stops (#fee2e2, #f59e9e) made them vanish.
// Saturation reduced 20% from Tailwind red-400..900 so the red reads calmer.
export const VELOCITY_COLORS = ['#ea7e7e', '#de5555', '#ca3838', '#a92c2c', '#752727'] as const;
export const DIVERGING_COLORS = ['#0b5ca3', '#80ade0', '#d4d6da', '#db948b', '#98342e'] as const;
export const DEFAULT_PRESSURE_BREAKS = [0, 25, 50, 75, 100] as const;
// Upper bound 3 m/s (shown as "3+"): a 5 m/s top squeezed nearly every pipe
// into the two palest bands.
export const DEFAULT_VELOCITY_BREAKS = [0, 0.5, 1, 2, 3] as const;
/** MapLibre color expression for a scale -- the single source of map colors. */
export function stepColor(property: string, scale: LegendScale): unknown {
const value: unknown[] = ['to-number', ['get', property], 0];
const input = scale.absolute ? ['abs', value] : value;
const expr: unknown[] = ['step', input, scale.colors[0]];
for (let i = 1; i < scale.colors.length; i++) expr.push(scale.breaks[i], scale.colors[i]);
return expr;
}
// Compact so five band labels fit the legend width (10000 -> 10k).
const num = (v: number) =>
Math.abs(v) >= 1000 ? `${Number((v / 1000).toPrecision(3))}k` : `${Number(v.toPrecision(3))}`;
/** Per-band labels matching `stepColor`: "<0.5", "0.5–1", ..., "3+". */
export function bandLabels(breaks: readonly number[]): string[] {
const n = breaks.length;
return breaks.map((_, i) =>
i === 0 ? `<${num(breaks[1])}` : i === n - 1 ? `${num(breaks[i])}+` : `${num(breaks[i])}–${num(breaks[i + 1])}`
);
}
+10
View File
@@ -0,0 +1,10 @@
import './index.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+123
View File
@@ -0,0 +1,123 @@
export interface ScreenPoint {
x: number;
y: number;
}
export interface ScreenBox {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface ScreenNode {
id: string;
point: ScreenPoint;
}
export interface ScreenLink {
id: string;
points: ScreenPoint[];
}
export interface BoxSelectionResult {
nodes: Set<string>;
links: Set<string>;
crossing: boolean;
}
export function normalizeScreenBox(start: ScreenPoint, end: ScreenPoint): ScreenBox {
return {
minX: Math.min(start.x, end.x),
minY: Math.min(start.y, end.y),
maxX: Math.max(start.x, end.x),
maxY: Math.max(start.y, end.y),
};
}
export function pointInScreenBox(point: ScreenPoint, box: ScreenBox): boolean {
return point.x >= box.minX && point.x <= box.maxX && point.y >= box.minY && point.y <= box.maxY;
}
function orientation(a: ScreenPoint, b: ScreenPoint, c: ScreenPoint): number {
const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
if (Math.abs(value) < 1e-9) return 0;
return value > 0 ? 1 : 2;
}
function pointOnSegment(a: ScreenPoint, b: ScreenPoint, point: ScreenPoint): boolean {
return (
point.x <= Math.max(a.x, b.x) + 1e-9 &&
point.x >= Math.min(a.x, b.x) - 1e-9 &&
point.y <= Math.max(a.y, b.y) + 1e-9 &&
point.y >= Math.min(a.y, b.y) - 1e-9
);
}
export function segmentsIntersect(
firstStart: ScreenPoint,
firstEnd: ScreenPoint,
secondStart: ScreenPoint,
secondEnd: ScreenPoint
): boolean {
const o1 = orientation(firstStart, firstEnd, secondStart);
const o2 = orientation(firstStart, firstEnd, secondEnd);
const o3 = orientation(secondStart, secondEnd, firstStart);
const o4 = orientation(secondStart, secondEnd, firstEnd);
if (o1 !== o2 && o3 !== o4) return true;
if (o1 === 0 && pointOnSegment(firstStart, firstEnd, secondStart)) return true;
if (o2 === 0 && pointOnSegment(firstStart, firstEnd, secondEnd)) return true;
if (o3 === 0 && pointOnSegment(secondStart, secondEnd, firstStart)) return true;
if (o4 === 0 && pointOnSegment(secondStart, secondEnd, firstEnd)) return true;
return false;
}
export function lineIntersectsScreenBox(points: ScreenPoint[], box: ScreenBox): boolean {
if (points.some((point) => pointInScreenBox(point, box))) return true;
if (points.length < 2) return false;
const topLeft = { x: box.minX, y: box.minY };
const topRight = { x: box.maxX, y: box.minY };
const bottomRight = { x: box.maxX, y: box.maxY };
const bottomLeft = { x: box.minX, y: box.maxY };
const edges: Array<[ScreenPoint, ScreenPoint]> = [
[topLeft, topRight],
[topRight, bottomRight],
[bottomRight, bottomLeft],
[bottomLeft, topLeft],
];
for (let index = 1; index < points.length; index += 1) {
const start = points[index - 1];
const end = points[index];
if (edges.some(([edgeStart, edgeEnd]) => segmentsIntersect(start, end, edgeStart, edgeEnd))) {
return true;
}
}
return false;
}
export function selectNetworkFeaturesInBox(
nodes: ScreenNode[],
links: ScreenLink[],
start: ScreenPoint,
end: ScreenPoint
): BoxSelectionResult {
const box = normalizeScreenBox(start, end);
const crossing = end.x < start.x;
const selectedNodes = new Set(
nodes.filter((node) => pointInScreenBox(node.point, box)).map((node) => node.id)
);
const selectedLinks = new Set(
links
.filter((link) =>
crossing
? lineIntersectsScreenBox(link.points, box)
: link.points.length > 0 && link.points.every((point) => pointInScreenBox(point, box))
)
.map((link) => link.id)
);
return { nodes: selectedNodes, links: selectedLinks, crossing };
}
+250
View File
@@ -0,0 +1,250 @@
// Keys come from the build environment (.env.local, never committed; see .env.example).
// They still end up in the built JS -- restrict them by domain in each console.
// `import.meta.env` is absent when Node tests import this module.
const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env ?? {};
export const VWORLD_API_KEY = env.VITE_VWORLD_API_KEY ?? '';
// NOTE: despite the name this is the app's JavaScript key (Kakao console:
// 플랫폼 키 > JavaScript 키). Kakao accepts a JS key on the REST Local API only
// with a `KA` header naming the calling origin, which must be listed under the
// key's "JavaScript SDK 도메인" (e.g. http://localhost:5173, http://10.12.21.14:8080).
export const KAKAO_REST_API_KEY = env.VITE_KAKAO_JS_KEY ?? '';
function kakaoHeaders(): HeadersInit {
if (!KAKAO_REST_API_KEY) throw new MapServiceError('카카오 키가 설정되지 않았습니다. 서버의 .env.local에 VITE_KAKAO_JS_KEY를 넣고 다시 빌드하세요.', 'authorization');
return {
Authorization: `KakaoAK ${KAKAO_REST_API_KEY}`,
KA: `sdk/1.0 os/javascript lang/ko-KR origin/${encodeURIComponent(window.location.origin)}`,
};
}
export type VWorldTileLayer = 'Base' | 'white' | 'midnight' | 'Hybrid' | 'Satellite';
export function getVWorldTileUrl(layer: VWorldTileLayer): string {
const extension = layer === 'Satellite' ? 'jpeg' : 'png';
return `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/${layer}/{z}/{y}/{x}.${extension}`;
}
export const VWORLD_BASE_TILE_URL = getVWorldTileUrl('Base');
export const VWORLD_WHITE_TILE_URL = getVWorldTileUrl('white');
export const VWORLD_MIDNIGHT_TILE_URL = getVWorldTileUrl('midnight');
export const VWORLD_SATELLITE_TILE_URL = getVWorldTileUrl('Satellite');
export const VWORLD_HYBRID_TILE_URL = getVWorldTileUrl('Hybrid');
export type MapServiceErrorType =
| 'authorization'
| 'domain'
| 'quota'
| 'network'
| 'service'
| 'unknown';
export class MapServiceError extends Error {
readonly type: MapServiceErrorType;
constructor(message: string, type: MapServiceErrorType) {
super(message);
this.name = 'MapServiceError';
this.type = type;
}
}
export function formatMapServiceError(error: unknown): string {
if (error instanceof MapServiceError) {
switch (error.type) {
case 'authorization':
return 'API 키 인증에 실패했습니다. 발급 키 상태를 확인하세요.';
case 'domain':
return '등록 도메인 제한으로 요청이 거부되었습니다. API 콘솔의 웹 플랫폼 도메인을 확인하세요.';
case 'quota':
return 'API 일일 또는 분당 호출 한도를 초과했습니다.';
case 'network':
return '지도 서비스 네트워크 요청에 실패했습니다. CORS·도메인 등록 또는 연결 상태를 확인하세요.';
case 'service':
return error.message || '지도 서비스가 오류 응답을 반환했습니다.';
default:
return error.message || '지도 서비스에서 알 수 없는 오류가 발생했습니다.';
}
}
return error instanceof Error ? error.message : '지도 서비스에서 알 수 없는 오류가 발생했습니다.';
}
export interface KakaoAddressResult {
id: string;
addressName: string;
roadAddressName: string;
parcelAddressName: string;
placeName: string;
longitude: number;
latitude: number;
}
async function handleKakaoResponse(response: Response): Promise<{ documents?: KakaoDocument[] }> {
if (!response.ok) {
const detail = await response.text().catch(() => '');
const normalizedDetail = detail.toLowerCase();
if (response.status === 401) {
if (/origin|domain|platform|ka header/.test(normalizedDetail)) {
throw new MapServiceError('카카오 API 웹 플랫폼 또는 Origin 등록이 필요합니다.', 'domain');
}
throw new MapServiceError('카카오 REST API 키 인증에 실패했습니다.', 'authorization');
}
if (response.status === 403) {
throw new MapServiceError('카카오 API 도메인 또는 앱 권한 제한입니다.', 'domain');
}
if (response.status === 429) {
throw new MapServiceError('카카오 API 호출 한도를 초과했습니다.', 'quota');
}
throw new MapServiceError(`카카오 API가 HTTP ${response.status} 오류를 반환했습니다.`, 'service');
}
return response.json() as Promise<{ documents?: KakaoDocument[] }>;
}
interface KakaoDocument {
id?: string;
address_name?: string;
road_address_name?: string;
place_name?: string;
x?: string;
y?: string;
address?: {
address_name?: string;
b_code?: string;
} | null;
road_address?: {
address_name?: string;
building_name?: string;
} | null;
}
export async function searchKakaoAddress(query: string): Promise<KakaoAddressResult[]> {
const trimmed = query.trim();
if (!trimmed) return [];
try {
const url = new URL('https://dapi.kakao.com/v2/local/search/address.json');
url.searchParams.set('query', trimmed);
url.searchParams.set('analyze_type', 'similar');
const response = await fetch(url.toString(), { headers: kakaoHeaders() });
const data = await handleKakaoResponse(response);
return (data.documents ?? [])
.map((doc, index) => {
const longitude = Number(doc.x);
const latitude = Number(doc.y);
return {
id: doc.id || doc.address?.b_code || `${longitude}-${latitude}-${index}`,
addressName: doc.address_name || doc.road_address?.address_name || doc.address?.address_name || '',
roadAddressName: doc.road_address?.address_name || doc.road_address_name || '',
parcelAddressName: doc.address?.address_name || doc.address_name || '',
placeName: doc.road_address?.building_name || doc.place_name || '',
longitude,
latitude,
};
})
.filter((result) => Number.isFinite(result.longitude) && Number.isFinite(result.latitude));
} catch (error) {
if (error instanceof MapServiceError) throw error;
throw new MapServiceError('카카오 주소 검색 네트워크 요청에 실패했습니다.', 'network');
}
}
export async function reverseGeocodeKakao(longitude: number, latitude: number): Promise<KakaoAddressResult | null> {
try {
const url = new URL('https://dapi.kakao.com/v2/local/geo/coord2address.json');
url.searchParams.append('x', longitude.toString());
url.searchParams.append('y', latitude.toString());
url.searchParams.set('input_coord', 'WGS84');
const response = await fetch(url.toString(), { headers: kakaoHeaders() });
const data = await handleKakaoResponse(response);
if (data.documents && data.documents.length > 0) {
const doc = data.documents[0] as KakaoDocument;
return {
id: `${longitude}-${latitude}`,
addressName: doc.road_address?.address_name || doc.address?.address_name || '',
roadAddressName: doc.road_address?.address_name || '',
parcelAddressName: doc.address?.address_name || '',
placeName: doc.road_address?.building_name || '',
longitude,
latitude,
};
}
return null;
} catch (error) {
if (error instanceof MapServiceError) throw error;
throw new MapServiceError('카카오 좌표-주소 변환 네트워크 요청에 실패했습니다.', 'network');
}
}
export interface ElevationResult {
elevationMeters: number;
source: string;
resolution: string;
}
export async function lookupApproxElevation(longitude: number, latitude: number): Promise<ElevationResult> {
try {
const url = new URL('https://api.open-meteo.com/v1/elevation');
url.searchParams.append('latitude', latitude.toString());
url.searchParams.append('longitude', longitude.toString());
const response = await fetch(url.toString());
if (!response.ok) {
if (response.status === 429) {
throw new MapServiceError('Open-Meteo API rate limit exceeded', 'quota');
}
throw new MapServiceError(`Open-Meteo API error: ${response.status}`, 'service');
}
const data = await response.json();
const elevation = Array.isArray(data.elevation)
? Number(data.elevation[0])
: Number(data.elevation);
if (Number.isFinite(elevation)) {
return {
elevationMeters: elevation,
source: 'Open-Meteo / Copernicus DEM GLO-90 (비공식 한국 자료)',
resolution: '90 m',
};
}
throw new MapServiceError('해당 좌표의 근사 표고가 반환되지 않았습니다.', 'service');
} catch (error) {
if (error instanceof MapServiceError) throw error;
throw new MapServiceError('Open-Meteo 표고 네트워크 요청에 실패했습니다.', 'network');
}
}
/**
* 인터넷 표고 일괄 조회 (Open-Meteo, Copernicus GLO-90): 100 points per request.
* Coarse (90 m) -- use a DEM file for design work.
*/
export async function lookupApproxElevations(points: Array<[number, number]>, onProgress?: (done: number) => void): Promise<(number | null)[]> {
const out: (number | null)[] = [];
for (let i = 0; i < points.length; i += 100) {
const chunk = points.slice(i, i + 100);
const url = new URL('https://api.open-meteo.com/v1/elevation');
url.searchParams.append('latitude', chunk.map((p) => p[1].toFixed(6)).join(','));
url.searchParams.append('longitude', chunk.map((p) => p[0].toFixed(6)).join(','));
let response: Response | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
response = await fetch(url.toString());
} catch {
throw new MapServiceError('Open-Meteo 표고 네트워크 요청에 실패했습니다.', 'network');
}
if (response.status !== 429) break;
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
}
if (!response || !response.ok) throw new MapServiceError(`Open-Meteo API 오류: ${response?.status}`, response?.status === 429 ? 'quota' : 'service');
const data = await response.json();
const values: unknown[] = Array.isArray(data.elevation) ? data.elevation : [];
for (let k = 0; k < chunk.length; k++) {
const v = Number(values[k]);
out.push(Number.isFinite(v) ? v : null);
}
onProgress?.(out.length);
}
return out;
}
+300
View File
@@ -0,0 +1,300 @@
import type {
InpNode,
InpPipe,
InpPump,
InpValve,
NetworkModel,
} from './inpModel';
import type { MultiSelectionState, SelectionTarget } from './types';
export interface NetworkSelectionPayload {
source: 'GISPipe';
version: 1;
kind: 'network-selection';
units: string;
nodes: InpNode[];
pipes: InpPipe[];
pumps: InpPump[];
valves: InpValve[];
tanks: InpNode[];
reservoirs: InpNode[];
}
export interface PreparedPasteResult {
valid: boolean;
error?: string;
nodesToInsert: InpNode[];
pipesToInsert: InpPipe[];
pumpsToInsert: InpPump[];
valvesToInsert: InpValve[];
renamedIdMap: Record<string, string>;
}
/**
* Creates dual clipboard payloads:
* 1. TSV string for text/plain (Excel / QGIS compatibility)
* 2. Structured JSON for application/json (cross-project & cross-tab network restoration)
*/
export function createNetworkClipboardPayload(
model: NetworkModel,
selectedTarget: SelectionTarget,
multiSelection?: MultiSelectionState
): { tsv: string; json: NetworkSelectionPayload; jsonString: string } | null {
const nodeIds = new Set<string>();
const linkIds = new Set<string>();
// Single selection
if (selectedTarget?.type === 'node') {
nodeIds.add(selectedTarget.id);
} else if (selectedTarget?.type === 'link') {
linkIds.add(selectedTarget.id);
}
// Multi selection
if (multiSelection) {
multiSelection.nodes.forEach((id: string) => nodeIds.add(id));
multiSelection.links.forEach((id: string) => linkIds.add(id));
}
if (nodeIds.size === 0 && linkIds.size === 0) {
return null;
}
const nodes: InpNode[] = [];
const tanks: InpNode[] = [];
const reservoirs: InpNode[] = [];
nodeIds.forEach((id) => {
const n = model.nodes[id];
if (!n) return;
if (n.type === 'TANK') tanks.push({ ...n });
else if (n.type === 'RESERVOIR') reservoirs.push({ ...n });
else nodes.push({ ...n });
});
const pipes: InpPipe[] = [];
const pumps: InpPump[] = [];
const valves: InpValve[] = [];
linkIds.forEach((id) => {
if (model.pipes[id]) pipes.push({ ...model.pipes[id] });
else if (model.pumps[id]) pumps.push({ ...model.pumps[id] });
else if (model.valves?.[id]) valves.push({ ...model.valves[id] });
});
const payload: NetworkSelectionPayload = {
source: 'GISPipe',
version: 1,
kind: 'network-selection',
units: model.options?.units || 'GPM',
nodes,
pipes,
pumps,
valves,
tanks,
reservoirs,
};
// Build TSV text representation
const tsvLines: string[] = [];
tsvLines.push('Type\tID\tX\tY\tElevation/Head\tDemand/Length\tStatus/Pattern');
nodes.forEach((n) => {
tsvLines.push(`JUNCTION\t${n.id}\t${n.x}\t${n.y}\t${n.elevation}\t${n.demand ?? 0}\t${n.pattern || ''}`);
});
tanks.forEach((t) => {
tsvLines.push(`TANK\t${t.id}\t${t.x}\t${t.y}\t${t.elevation}\t-\t-`);
});
reservoirs.forEach((r) => {
tsvLines.push(`RESERVOIR\t${r.id}\t${r.x}\t${r.y}\t${r.elevation}\t-\t-`);
});
pipes.forEach((p) => {
tsvLines.push(`PIPE\t${p.id}\t${p.node1}\t${p.node2}\t${p.diameter}\t${p.length}\t${p.status}`);
});
pumps.forEach((p) => {
tsvLines.push(`PUMP\t${p.id}\t${p.node1}\t${p.node2}\t-\t-\t${p.parameters || ''}`);
});
valves.forEach((v) => {
tsvLines.push(`VALVE\t${v.id}\t${v.node1}\t${v.node2}\t${v.diameter}\t${v.setting}\t${v.valveType}`);
});
const tsv = tsvLines.join('\n');
const jsonString = JSON.stringify(payload, null, 2);
return { tsv, json: payload, jsonString };
}
/**
* Validates clipboard payload, verifies topological connectivity,
* handles ID collision avoidance, and applies spatial offset.
*/
export function validateAndPreparePaste(
rawPayload: unknown,
currentModel: NetworkModel,
offset: { dx: number; dy: number } = { dx: 10, dy: 10 }
): PreparedPasteResult {
if (!rawPayload || typeof rawPayload !== 'object') {
return { valid: false, error: '유효한 데이터 형식이 아닙니다.', nodesToInsert: [], pipesToInsert: [], pumpsToInsert: [], valvesToInsert: [], renamedIdMap: {} };
}
const p = rawPayload as Record<string, unknown>;
if (p.source !== 'GISPipe' || p.version !== 1 || p.kind !== 'network-selection') {
return {
valid: false,
error: 'SPipe 네트워크 선택 데이터 규격과 일치하지 않습니다.',
nodesToInsert: [],
pipesToInsert: [],
pumpsToInsert: [],
valvesToInsert: [],
renamedIdMap: {},
};
}
const payload = rawPayload as NetworkSelectionPayload;
const allNodesInPayload = [
...(payload.nodes || []),
...(payload.tanks || []),
...(payload.reservoirs || []),
];
const payloadNodeIdSet = new Set(allNodesInPayload.map((n) => n.id));
// 1. Connectivity check: Every pipe, pump, and valve must connect to nodes that either
// exist in current model OR are present in the clipboard payload.
const allLinks = [
...(payload.pipes || []),
...(payload.pumps || []),
...(payload.valves || []),
];
for (const link of allLinks) {
const node1Available = currentModel.nodes[link.node1] || payloadNodeIdSet.has(link.node1);
const node2Available = currentModel.nodes[link.node2] || payloadNodeIdSet.has(link.node2);
if (!node1Available || !node2Available) {
const missing = [];
if (!node1Available) missing.push(`시작 절점[${link.node1}]`);
if (!node2Available) missing.push(`끝 절점[${link.node2}]`);
return {
valid: false,
error: `관로 [${link.id}]의 연결 절점(${missing.join(', ')})이 현재 모델에 없고 클립보드에도 함께 복사되지 않았습니다. 관로를 복사할 때는 연결 절점을 함께 선택해주세요.`,
nodesToInsert: [],
pipesToInsert: [],
pumpsToInsert: [],
valvesToInsert: [],
renamedIdMap: {},
};
}
}
// 2. ID Collision Resolution and spatial offset
const renamedIdMap: Record<string, string> = {};
const existingNodeIds = new Set(Object.keys(currentModel.nodes));
const existingPipeIds = new Set(Object.keys(currentModel.pipes));
const existingPumpIds = new Set(Object.keys(currentModel.pumps));
const existingValveIds = new Set(Object.keys(currentModel.valves || {}));
const getUniqueId = (baseId: string, set: Set<string>): string => {
if (!set.has(baseId)) {
set.add(baseId);
return baseId;
}
let counter = 1;
let candidate = `${baseId}_copy`;
while (set.has(candidate)) {
counter += 1;
candidate = `${baseId}_copy${counter}`;
}
set.add(candidate);
return candidate;
};
// Map nodes
const nodesToInsert: InpNode[] = allNodesInPayload.map((n) => {
const uniqueId = getUniqueId(n.id, existingNodeIds);
if (uniqueId !== n.id) {
renamedIdMap[n.id] = uniqueId;
}
return {
...n,
id: uniqueId,
x: n.x + offset.dx,
y: n.y + offset.dy,
};
});
// Map pipes
const pipesToInsert: InpPipe[] = (payload.pipes || []).map((pipe) => {
const uniqueId = getUniqueId(pipe.id, existingPipeIds);
return {
...pipe,
id: uniqueId,
node1: renamedIdMap[pipe.node1] || pipe.node1,
node2: renamedIdMap[pipe.node2] || pipe.node2,
};
});
// Map pumps
const pumpsToInsert: InpPump[] = (payload.pumps || []).map((pump) => {
const uniqueId = getUniqueId(pump.id, existingPumpIds);
return {
...pump,
id: uniqueId,
node1: renamedIdMap[pump.node1] || pump.node1,
node2: renamedIdMap[pump.node2] || pump.node2,
};
});
// Map valves
const valvesToInsert: InpValve[] = (payload.valves || []).map((valve) => {
const uniqueId = getUniqueId(valve.id, existingValveIds);
return {
...valve,
id: uniqueId,
node1: renamedIdMap[valve.node1] || valve.node1,
node2: renamedIdMap[valve.node2] || valve.node2,
};
});
return {
valid: true,
nodesToInsert,
pipesToInsert,
pumpsToInsert,
valvesToInsert,
renamedIdMap,
};
}
/**
* Merges prepared paste data into current network model.
*/
export function applyPasteToModel(
currentModel: NetworkModel,
pasteResult: PreparedPasteResult
): NetworkModel {
const newNodes = { ...currentModel.nodes };
const newPipes = { ...currentModel.pipes };
const newPumps = { ...currentModel.pumps };
const newValves = { ...(currentModel.valves || {}) };
pasteResult.nodesToInsert.forEach((n) => {
newNodes[n.id] = n;
});
pasteResult.pipesToInsert.forEach((p) => {
newPipes[p.id] = p;
});
pasteResult.pumpsToInsert.forEach((pump) => {
newPumps[pump.id] = pump;
});
pasteResult.valvesToInsert.forEach((valve) => {
newValves[valve.id] = valve;
});
return {
...currentModel,
nodes: newNodes,
pipes: newPipes,
pumps: newPumps,
valves: newValves,
};
}
+73
View File
@@ -0,0 +1,73 @@
// 관망 그룹: connected components of the network graph. With
// `excludeClosed`, links whose initial status is CLOSED (pipe status,
// [STATUS] override, valve status) do not connect their nodes -- so a group
// without a source is water that cannot reach any reservoir or tank.
import type { NetworkModel } from './inpModel';
export interface NetworkGroup {
index: number;
nodeIds: string[];
linkIds: string[];
junctions: number;
sources: number; // reservoirs + tanks
pipeLength: number; // model length units
baseDemand: number; // model flow units (primary/structured demands)
}
function isClosed(model: NetworkModel, linkId: string, own?: string): boolean {
const override = model.extras?.statusOverrides[linkId];
const s = (override ?? own ?? '').toUpperCase();
return s === 'CLOSED';
}
export function findNetworkGroups(model: NetworkModel, excludeClosed = true): NetworkGroup[] {
const parent = new Map<string, string>();
const find = (x: string): string => {
let r = x;
while (parent.get(r) !== r) r = parent.get(r)!;
let c = x;
while (parent.get(c) !== r) {
const n = parent.get(c)!;
parent.set(c, r);
c = n;
}
return r;
};
for (const id of Object.keys(model.nodes)) parent.set(id, id);
const links: Array<{ id: string; a: string; b: string; length: number }> = [];
for (const p of Object.values(model.pipes)) links.push({ id: p.id, a: p.node1, b: p.node2, length: isClosed(model, p.id, p.status) && excludeClosed ? -1 : p.length });
for (const p of Object.values(model.pumps)) links.push({ id: p.id, a: p.node1, b: p.node2, length: isClosed(model, p.id) && excludeClosed ? -1 : 0 });
for (const v of Object.values(model.valves || {})) links.push({ id: v.id, a: v.node1, b: v.node2, length: isClosed(model, v.id, v.status) && excludeClosed ? -1 : 0 });
for (const l of links) {
if (l.length < 0 || !parent.has(l.a) || !parent.has(l.b)) continue;
const ra = find(l.a);
const rb = find(l.b);
if (ra !== rb) parent.set(ra, rb);
}
const groups = new Map<string, NetworkGroup>();
for (const node of Object.values(model.nodes)) {
const r = find(node.id);
let g = groups.get(r);
if (!g) {
g = { index: 0, nodeIds: [], linkIds: [], junctions: 0, sources: 0, pipeLength: 0, baseDemand: 0 };
groups.set(r, g);
}
g.nodeIds.push(node.id);
if (node.type === 'JUNCTION') {
g.junctions++;
const cats = model.extras?.demands[node.id];
g.baseDemand += cats?.length ? cats.reduce((a, c) => a + c.demand, 0) : node.demand ?? 0;
} else g.sources++;
}
for (const l of links) {
if (!parent.has(l.a)) continue;
const g = groups.get(find(l.a));
if (!g) continue;
// A closed link is listed with the group of its first node.
g.linkIds.push(l.id);
if (l.length > 0) g.pipeLength += l.length;
}
return [...groups.values()]
.sort((x, y) => y.nodeIds.length - x.nodeIds.length)
.map((g, i) => ({ ...g, index: i + 1 }));
}
+143
View File
@@ -0,0 +1,143 @@
// Whole-network editing tools (관망 도구): pipe length recalculation from
// geometry and elevation filling from the nearest node. Pure functions; the
// UI wraps them with undo.
import type { NetworkModel, InpNode } from './inpModel';
import { calculateGeoAwareDistance, type CRS } from './crs.ts';
import { getUnitSystem } from './unitSystem.ts';
export interface Point {
x: number;
y: number;
}
/** Link ID -> intermediate vertices, read from the raw [VERTICES] section. */
export function parseVertices(model: NetworkModel): Record<string, Point[]> {
if (model.vertices) return model.vertices;
const out: Record<string, Point[]> = {};
const r = model.sectionIndices['VERTICES'];
if (!r) return out;
for (let i = r.start + 1; i <= r.end; i++) {
const body = model.rawLines[i]?.split(';')[0].trim();
if (!body) continue;
const [id, xs, ys] = body.split(/\s+/);
const x = Number(xs);
const y = Number(ys);
if (id && Number.isFinite(x) && Number.isFinite(y)) (out[id] ??= []).push({ x, y });
}
return out;
}
/** Polyline length (node1 -> vertices -> node2) in the model's length units. */
export function linkGeometryLength(model: NetworkModel, node1: string, node2: string, vertices: Point[], crs: CRS): { length: number; isPhysical: boolean } | null {
const a = model.nodes[node1];
const b = model.nodes[node2];
if (!a || !b) return null;
const system = getUnitSystem(model.options.units);
const pts: Point[] = [a, ...vertices, b];
let total = 0;
let isPhysical = true;
for (let i = 1; i < pts.length; i++) {
const d = calculateGeoAwareDistance(pts[i - 1], pts[i], crs, system);
total += d.distance;
isPhysical = isPhysical && d.isPhysical;
}
return { length: total, isPhysical };
}
export interface LengthChange {
id: string;
before: number;
after: number;
}
/**
* Recalculate pipe lengths from coordinates (and [VERTICES]); `ids` limits the
* set. Lengths are rounded to 0.01 and a zero-length result keeps the old value.
*/
export function recalcPipeLengths(model: NetworkModel, crs: CRS, ids?: Iterable<string>): { model: NetworkModel; changes: LengthChange[]; isPhysical: boolean } {
const vertices = parseVertices(model);
const target = ids ? new Set(ids) : null;
const pipes = { ...model.pipes };
const changes: LengthChange[] = [];
let isPhysical = true;
for (const p of Object.values(model.pipes)) {
if (target && !target.has(p.id)) continue;
const g = linkGeometryLength(model, p.node1, p.node2, vertices[p.id] ?? [], crs);
if (!g) continue;
isPhysical = isPhysical && g.isPhysical;
const after = Math.round(g.length * 100) / 100;
if (after > 0 && Math.abs(after - p.length) >= 0.005) {
pipes[p.id] = { ...p, length: after };
changes.push({ id: p.id, before: p.length, after });
}
}
return { model: changes.length ? { ...model, pipes } : model, changes, isPhysical };
}
/** Pipes touching a node (for re-measuring after the node moved). */
export function pipesAtNode(model: NetworkModel, nodeId: string): string[] {
return Object.values(model.pipes)
.filter((p) => p.node1 === nodeId || p.node2 === nodeId)
.map((p) => p.id);
}
/** Elevation of the nearest other node (by planar coordinate distance), or null. */
export function nearestNodeElevation(model: NetworkModel, x: number, y: number, exclude?: Set<string>, onlyNonZero = false): number | null {
let best: InpNode | null = null;
let bestD = Infinity;
for (const n of Object.values(model.nodes)) {
if (exclude?.has(n.id)) continue;
if (onlyNonZero && !n.elevation) continue;
const d = (n.x - x) ** 2 + (n.y - y) ** 2;
if (d < bestD) {
bestD = d;
best = n;
}
}
return best ? best.elevation : null;
}
/** Junctions/tanks with elevation 0 take the nearest non-zero node's elevation. */
export function fillZeroElevations(model: NetworkModel): { model: NetworkModel; filled: string[] } {
const zero = Object.values(model.nodes).filter((n) => n.type !== 'RESERVOIR' && !n.elevation);
if (!zero.length) return { model, filled: [] };
const nodes = { ...model.nodes };
const filled: string[] = [];
const zeroIds = new Set(zero.map((n) => n.id));
for (const n of zero) {
const e = nearestNodeElevation(model, n.x, n.y, zeroIds, true);
if (e !== null) {
nodes[n.id] = { ...n, elevation: e };
filled.push(n.id);
}
}
return { model: filled.length ? { ...model, nodes } : model, filled };
}
// ---- edit options (persisted per browser) ----
export interface EditOptions {
/** Re-measure connected pipe lengths when a node moves (new pipes are always measured). */
autoLength: boolean;
/** New nodes take the nearest existing node's elevation instead of 0. */
nearestElevation: boolean;
}
const EDIT_OPTIONS_KEY = 'spipe_edit_options';
export const DEFAULT_EDIT_OPTIONS: EditOptions = { autoLength: true, nearestElevation: true };
export function loadEditOptions(): EditOptions {
try {
return { ...DEFAULT_EDIT_OPTIONS, ...JSON.parse(localStorage.getItem(EDIT_OPTIONS_KEY) || '{}') };
} catch {
return DEFAULT_EDIT_OPTIONS;
}
}
export function saveEditOptions(o: EditOptions): void {
try {
localStorage.setItem(EDIT_OPTIONS_KEY, JSON.stringify(o));
} catch {
// storage unavailable: options just won't persist
}
}
+58
View File
@@ -0,0 +1,58 @@
// Path between two nodes for the longitudinal profile (ProfilePanel).
import type { NetworkModel } from './inpModel';
export interface PathResult {
nodeIds: string[];
linkIds: string[];
/** Model length of each link (pumps/valves: 0). */
linkLengths: number[];
}
/** Shortest path by length (Dijkstra) over pipes, pumps and valves. */
export function findProfilePath(model: NetworkModel, from: string, to: string): PathResult | null {
if (!model.nodes[from] || !model.nodes[to]) return null;
const adj: Record<string, Array<{ node: string; link: string; len: number }>> = {};
const add = (a: string, b: string, link: string, len: number) => {
(adj[a] ??= []).push({ node: b, link, len });
(adj[b] ??= []).push({ node: a, link, len });
};
for (const p of Object.values(model.pipes)) add(p.node1, p.node2, p.id, Math.max(p.length, 0));
for (const p of Object.values(model.pumps)) add(p.node1, p.node2, p.id, 0);
for (const v of Object.values(model.valves || {})) add(v.node1, v.node2, v.id, 0);
const dist: Record<string, number> = { [from]: 0 };
const prev: Record<string, { node: string; link: string; len: number }> = {};
const done = new Set<string>();
// The networks here are a few thousand nodes: a linear-scan queue is fine.
const open = new Set<string>([from]);
while (open.size) {
let cur = '';
let best = Infinity;
for (const n of open) if (dist[n] < best) { best = dist[n]; cur = n; }
open.delete(cur);
if (cur === to) break;
done.add(cur);
for (const e of adj[cur] ?? []) {
if (done.has(e.node)) continue;
const d = best + e.len + 1e-6; // tiny per-hop cost prefers fewer links on ties
if (d < (dist[e.node] ?? Infinity)) {
dist[e.node] = d;
prev[e.node] = { node: cur, link: e.link, len: e.len };
open.add(e.node);
}
}
}
if (from !== to && !prev[to]) return null;
const nodeIds = [to];
const linkIds: string[] = [];
const linkLengths: number[] = [];
let n = to;
while (n !== from) {
const p = prev[n];
linkIds.unshift(p.link);
linkLengths.unshift(p.len);
nodeIds.unshift(p.node);
n = p.node;
}
return { nodeIds, linkIds, linkLengths };
}
+85
View File
@@ -0,0 +1,85 @@
// SPipe project file (.spipe): one zip holding the whole working state --
// the same SessionSnapshot the browser autosave keeps, plus view settings
// and calibration data -- so work can be saved to disk, moved between PCs
// and shared, instead of living only in one browser's IndexedDB.
//
// project.json { format, version, savedAt, snapshot, view }
// model.inp the current network as plain INP (readable by EPANET)
// README.txt
import { createZipArchive } from './shapefileExport.ts';
import { unzipArchive } from './gisLayers.ts';
import { generateInp } from './inpModel.ts';
import type { SessionSnapshot } from './sessionStore';
import type { LayerLabelConfig } from './LayerLabelModal';
import type { LayerStyleConfig } from './LayerStyleModal';
import type { CalibrationData } from './CalibrationPanel';
export const PROJECT_FORMAT = 'spipe-project';
export const PROJECT_VERSION = 1;
export interface ProjectViewState {
layerStyles?: Record<string, LayerStyleConfig>;
layerLabels?: Record<string, LayerLabelConfig>;
calibrationData?: CalibrationData | null;
legendOverrides?: Record<string, import('./Legend').LegendOverride>;
}
export interface ProjectFile {
format: typeof PROJECT_FORMAT;
version: number;
savedAt: string;
snapshot: SessionSnapshot;
view: ProjectViewState;
}
/** JSON.stringify drops typed arrays to {"0":..}; write them as plain arrays. */
function replacer(_key: string, value: unknown) {
if (ArrayBuffer.isView(value) && !(value instanceof DataView)) return Array.from(value as unknown as ArrayLike<number>);
return value;
}
export function buildProjectZip(snapshot: SessionSnapshot, view: ProjectViewState, includeResults: boolean): Uint8Array {
const snap: SessionSnapshot = includeResults
? snapshot
: {
...snapshot,
wasmResult: null,
serverResult: null,
resultInpSnapshot: null,
scenarios: snapshot.scenarios.map((s) => ({ ...s, result: null })),
};
const file: ProjectFile = { format: PROJECT_FORMAT, version: PROJECT_VERSION, savedAt: snapshot.savedAt, snapshot: snap, view };
const enc = new TextEncoder();
return createZipArchive({
'project.json': enc.encode(JSON.stringify(file, replacer)),
'model.inp': enc.encode(generateInp(snapshot.networkModel)),
'README.txt': enc.encode(
'SPipe 프로젝트 파일입니다. SPipe의 프로젝트 > 프로젝트 열기로 엽니다.\r\n' +
'model.inp는 현재 관망을 EPANET INP로 저장한 사본입니다.\r\n'
),
});
}
export async function readProjectZip(buffer: ArrayBuffer): Promise<ProjectFile> {
let files: Record<string, Uint8Array>;
try {
files = await unzipArchive(buffer);
} catch {
throw new Error('SPipe 프로젝트 파일(.spipe)이 아니거나 손상되었습니다.');
}
const json = files['project.json'];
if (!json) throw new Error('project.json이 없습니다. SPipe 프로젝트 파일이 아닙니다.');
let parsed: ProjectFile;
try {
parsed = JSON.parse(new TextDecoder().decode(json));
} catch {
throw new Error('project.json을 읽을 수 없습니다.');
}
if (parsed?.format !== PROJECT_FORMAT) throw new Error('SPipe 프로젝트 파일 형식이 아닙니다.');
if (typeof parsed.version !== 'number' || parsed.version > PROJECT_VERSION) {
throw new Error(`이 프로젝트 파일은 더 새로운 SPipe(형식 v${parsed.version})에서 저장되었습니다.`);
}
const snap = parsed.snapshot;
if (!snap?.networkModel?.nodes || !snap.networkModel.rawLines) throw new Error('프로젝트에 관망 모델이 없습니다.');
return { ...parsed, view: parsed.view ?? {} };
}
+76
View File
@@ -0,0 +1,76 @@
import type { PumpEnergyResult, PumpEnergySummary } from './types';
export interface PumpSeries {
id: string;
fromNode: string;
toNode: string;
/** kW at each hydraulic step (same length as timeSteps). */
energy: number[];
/** Efficiency in percent at each step. */
efficiency: number[];
}
/**
* Pump energy metrics from per-step power, the same way EPANET accumulates
* energy (hydraul.c addenergy): each step's kW holds until the next step, so
* kWh = sum(kW_i * dt_i) and the terminal reading carries no duration.
*
* Kept as one pure function so the browser engine and tests share it.
* backend/main.py must mirror this logic (Python cannot import it); the
* dual-engine comparison would flag any drift between the two.
*/
export function computePumpEnergy(timeSteps: number[], pumps: PumpSeries[]): PumpEnergySummary | undefined {
if (pumps.length === 0) return undefined;
const totalDurationSec = timeSteps.length > 1 ? timeSteps[timeSteps.length - 1] - timeSteps[0] : 0;
const perPump: Record<string, PumpEnergyResult> = {};
for (const pump of pumps) {
const { energy, efficiency } = pump;
let kwh = 0;
let activeSec = 0;
if (timeSteps.length > 1) {
for (let i = 0; i < timeSteps.length - 1; i++) {
const dt = Math.max(0, timeSteps[i + 1] - timeSteps[i]);
const kw = energy[i] ?? 0;
kwh += (kw * dt) / 3600;
if (kw > 0) activeSec += dt;
}
} else if (energy.length > 0) {
kwh = energy[0];
if (energy[0] > 0) activeSec = 3600;
}
const activeEffics = efficiency.filter((e) => e > 0);
perPump[pump.id] = {
id: pump.id,
fromNode: pump.fromNode,
toNode: pump.toNode,
totalKwh: round(kwh, 2),
peakKw: energy.length > 0 ? round(Math.max(...energy), 2) : 0,
// Time-weighted: kWh over hours actually running (steps are not uniform).
avgKw: activeSec > 0 ? round(kwh / (activeSec / 3600), 2) : 0,
avgEfficiency: activeEffics.length > 0 ? round(activeEffics.reduce((a, b) => a + b, 0) / activeEffics.length, 1) : 0,
utilization: totalDurationSec > 0
? round((activeSec / totalDurationSec) * 100, 1)
: (energy.length > 0 && energy[0] > 0 ? 100 : 0),
};
}
// System peak = max over steps of the simultaneous sum of all pumps' kW.
let systemPeakKw = 0;
for (let i = 0; i < timeSteps.length; i++) {
const stepSum = pumps.reduce((sum, p) => sum + (p.energy[i] ?? 0), 0);
if (stepSum > systemPeakKw) systemPeakKw = stepSum;
}
return {
pumps: perPump,
totalKwh: round(Object.values(perPump).reduce((sum, p) => sum + p.totalKwh, 0), 2),
peakKw: round(systemPeakKw, 2),
};
}
function round(value: number, digits: number): number {
const f = 10 ** digits;
return Math.round(value * f) / f;
}
+72
View File
@@ -0,0 +1,72 @@
import type { LinkResult, PumpEnergyResult, PumpEnergySummary } from './types';
/**
* Pump energy metrics from raw per-step power (kW) and efficiency (%) series.
* Single TS implementation used by the WASM worker; backend/main.py mirrors the
* same definitions in Python (keep both in sync -- tests/fixes.test.ts and the
* regression suite compare them on Net1/Net3).
*
* - totalKwh: each step's kW holds until the next step (EPANET's addenergy
* convention); the final reading has no duration.
* - avgKw: time-weighted, = totalKwh / hours the pump was running.
* - utilization: running time / simulated duration.
* - system peakKw: max over steps of the simultaneous sum of all pumps.
*/
export function computePumpEnergy(
timeSteps: number[],
pumpSeries: Record<string, { energy: number[]; efficiency: number[] }>,
links: Record<string, Pick<LinkResult, 'fromNode' | 'toNode'>>
): { perPump: Record<string, PumpEnergyResult>; summary: PumpEnergySummary | undefined } {
const pumpIds = Object.keys(pumpSeries);
const perPump: Record<string, PumpEnergyResult> = {};
const totalDurationSec = timeSteps.length > 1 ? timeSteps[timeSteps.length - 1] - timeSteps[0] : 0;
const round = (v: number, digits: number) => Math.round(v * 10 ** digits) / 10 ** digits;
for (const id of pumpIds) {
const energies = pumpSeries[id].energy || [];
const effics = pumpSeries[id].efficiency || [];
const activeEffics = effics.filter((ef) => ef > 0);
let kwh = 0;
let activeSec = 0;
if (timeSteps.length > 1) {
for (let i = 0; i < timeSteps.length - 1; i++) {
const dt = Math.max(0, timeSteps[i + 1] - timeSteps[i]);
const kw = energies[i] ?? 0;
kwh += (kw * dt) / 3600;
if (kw > 0) activeSec += dt;
}
} else if (energies.length > 0) {
kwh = energies[0];
if (energies[0] > 0) activeSec = 3600;
}
perPump[id] = {
id,
fromNode: links[id]?.fromNode ?? '',
toNode: links[id]?.toNode ?? '',
totalKwh: round(kwh, 2),
peakKw: energies.length > 0 ? round(Math.max(...energies), 2) : 0,
avgKw: activeSec > 0 ? round(kwh / (activeSec / 3600), 2) : 0,
avgEfficiency: activeEffics.length > 0 ? round(activeEffics.reduce((a, b) => a + b, 0) / activeEffics.length, 1) : 0,
utilization: totalDurationSec > 0
? round((activeSec / totalDurationSec) * 100, 1)
: (energies.length > 0 && energies[0] > 0 ? 100 : 0),
};
}
let systemPeakKw = 0;
for (let i = 0; i < timeSteps.length; i++) {
const stepSum = pumpIds.reduce((sum, id) => sum + (pumpSeries[id].energy?.[i] ?? 0), 0);
if (stepSum > systemPeakKw) systemPeakKw = stepSum;
}
const summary = pumpIds.length > 0
? {
pumps: perPump,
totalKwh: round(Object.values(perPump).reduce((sum, p) => sum + p.totalKwh, 0), 2),
peakKw: round(systemPeakKw, 2),
}
: undefined;
return { perPump, summary };
}
+273
View File
@@ -0,0 +1,273 @@
import type { SimulationResult, Scenario } from './types';
import type { NetworkModel } from './inpModel';
/**
* Determines whether the currently-analyzed simulation result is stale relative
* to the network model's live (regenerated) INP text. A result is stale whenever
* the exact model text that produced it no longer matches the live model text,
* or when no analysis has ever been run for the live model.
*/
export function computeIsStale(currentInpStr: string | null, resultInpSnapshot: string | null): boolean {
if (!currentInpStr) return false;
if (!resultInpSnapshot) return true;
return currentInpStr !== resultInpSnapshot;
}
/**
* Persists the live edited model (and, only if not stale, its matching result)
* back into the scenario list before switching the active scenario away.
* In-progress edits are never silently discarded, and a stale result is never
* carried forward mislabeled as belonging to the saved model text - it is
* cleared to null instead, forcing re-analysis when the scenario is revisited.
*/
export function saveActiveScenarioState(
scenarios: Scenario[],
activeScenarioId: string,
currentInpStr: string,
currentResult: SimulationResult | null,
isStale: boolean
): Scenario[] {
return scenarios.map((s) =>
s.id === activeScenarioId
? { ...s, inpStr: currentInpStr, result: isStale ? null : currentResult }
: s
);
}
/**
* Decides what result (if any) a freshly-created 'base' scenario -- the
* pristine, unedited original network -- may carry.
*
* The 'base' scenario's INP text is always the original file, but a running
* app only tracks ONE live result/snapshot pair at a time (currentResult,
* paired with resultInpSnapshot -- the exact INP text that produced it, per
* computeIsStale). That pair only belongs to 'base' when it was actually
* computed from the original text; if the user edited the model (even before
* ever creating a scenario) and re-analyzed, currentResult reflects the EDITED
* model, not the original one, and must never be attached to 'base' just
* because no other result was available (the currentResult || fallback bug
* this closes).
*/
export function deriveBaseScenarioResult(
currentResult: SimulationResult | null,
isStale: boolean,
resultInpSnapshot: string | null,
normalizedOriginalInp: string
): SimulationResult | null {
if (isStale || resultInpSnapshot === null) return null;
return resultInpSnapshot === normalizedOriginalInp ? currentResult : null;
}
/**
* Looks up a per-time-step series value at the currently selected time step,
* falling back to a result's flat (first-step) field when there's no series,
* no selected step, or the index is out of range. This is the single lookup
* every screen (and export) that shows a time-varying quantity should use,
* so a value shown/exported always matches whichever time step is selected
* instead of silently always reflecting the first analysis instant.
*/
export function seriesValue(
timeSeries: number[] | undefined,
timeStepIndex: number | undefined,
fallback: number
): number {
if (timeSeries && timeStepIndex !== undefined) {
const v = timeSeries[timeStepIndex];
if (v !== undefined) return v;
}
return fallback;
}
/** Human-readable label (HH:MM (Nsec)) for a selected time step, for report metadata. */
/**
* EPANET hydraulic steps are not evenly spaced (control/tank events insert
* short steps such as 45154 s in Net1), so a plain mean over-weights those.
* Each value holds until the next step; the final value has no duration.
*/
export function timeWeightedAverage(values: number[], timeSteps: number[]): number {
if (values.length === 0) return 0;
if (values.length === 1 || timeSteps.length !== values.length) {
return values.reduce((a, b) => a + b, 0) / values.length;
}
const span = timeSteps[timeSteps.length - 1] - timeSteps[0];
if (span <= 0) return values.reduce((a, b) => a + b, 0) / values.length;
let sum = 0;
for (let i = 0; i < values.length - 1; i++) {
sum += values[i] * Math.max(0, timeSteps[i + 1] - timeSteps[i]);
}
return sum / span;
}
/**
* Indices into result.timeSteps that are EPANET reporting periods
* (t >= REPORT START and on the REPORT TIMESTEP grid). The hydraulic steps
* also contain intermediate event times (e.g. 45154 s in Net1) that EPANET's
* own report and .out file never list; tables and exports meant to match
* EPANET's report should iterate these indices. Falls back to every index
* when the result carries no reporting parameters (older results).
*/
export function reportingStepIndices(result: Pick<SimulationResult, 'timeSteps' | 'reportStart' | 'reportStep'>): number[] {
const steps = result.timeSteps ?? [];
const start = result.reportStart;
const step = result.reportStep;
if (start === undefined || !step || step <= 0) return steps.map((_, i) => i);
const indices: number[] = [];
steps.forEach((t, i) => {
if (t >= start && (t - start) % step === 0) indices.push(i);
});
return indices.length > 0 ? indices : steps.map((_, i) => i);
}
/**
* Next reporting-period index (a timeSteps index) after moving `delta`
* reporting periods from `current`. If `current` is an intermediate event
* step, it first snaps to the nearest reporting period at or before it.
* With `wrap`, moving past either end wraps around (EPS playback).
*/
export function stepReportingIndex(indices: number[], current: number, delta: number, wrap = false): number {
if (indices.length === 0) return current;
let pos = indices.indexOf(current);
if (pos < 0) {
pos = 0;
for (let i = 0; i < indices.length; i++) if (indices[i] <= current) pos = i;
}
let next = pos + delta;
if (wrap) next = ((next % indices.length) + indices.length) % indices.length;
else next = Math.min(Math.max(next, 0), indices.length - 1);
return indices[next];
}
export function formatTimeLabel(timeSteps: number[] | undefined, timeStepIndex: number | undefined): string {
if (!timeSteps || timeStepIndex === undefined) return '정상상태(Steady-State)';
const secs = timeSteps[timeStepIndex];
if (secs === undefined) return '정상상태(Steady-State)';
const hh = String(Math.floor(secs / 3600)).padStart(2, '0');
const mm = String(Math.floor((secs % 3600) / 60)).padStart(2, '0');
return `${hh}:${mm} (${secs}초)`;
}
export interface DualEngineOutcome {
server: SimulationResult | null;
wasm: SimulationResult | null;
failureMessages: string[];
}
/**
* Extracts each engine's result independently from Promise.allSettled
* outcomes, so a rejection in one never withholds the other's successful
* result -- the server (Native DLL) and WASM engines must never be coupled
* such that one's failure blocks the other's already-computed result.
* Cross-validation (comparing the two) is only possible/meaningful once both
* are non-null; callers should treat a single non-null result as a valid
* (if unvalidated) single-engine result rather than a total failure.
*/
export function resolveDualEngineOutcome(
serverOutcome: PromiseSettledResult<SimulationResult>,
wasmOutcome: PromiseSettledResult<SimulationResult>
): DualEngineOutcome {
const server = serverOutcome.status === 'fulfilled' ? serverOutcome.value : null;
const wasm = wasmOutcome.status === 'fulfilled' ? wasmOutcome.value : null;
const failureMessages: string[] = [];
if (serverOutcome.status === 'rejected') {
const e = serverOutcome.reason;
failureMessages.push(`서버(Native) 분석 오류: ${e instanceof Error ? e.message : String(e)}`);
}
if (wasmOutcome.status === 'rejected') {
const e = wasmOutcome.reason;
failureMessages.push(`WASM 분석 오류: ${e instanceof Error ? e.message : String(e)}`);
}
return { server, wasm, failureMessages };
}
// -----------------------------------------------------------------------
// Compound numeric result filters (P2-1): "pressure < 15 AND velocity > 2"
// -----------------------------------------------------------------------
export type FilterOperator = '<' | '<=' | '>' | '>=' | '==';
export interface FilterCondition {
field: string;
operator: FilterOperator;
value: number;
}
export function evaluateFilterCondition(fieldValue: number, operator: FilterOperator, threshold: number): boolean {
switch (operator) {
case '<': return fieldValue < threshold;
case '<=': return fieldValue <= threshold;
case '>': return fieldValue > threshold;
case '>=': return fieldValue >= threshold;
case '==': return Math.abs(fieldValue - threshold) < 1e-9;
}
}
/**
* True when a result row satisfies EVERY condition (AND), e.g. "pressure < 15
* AND velocity > 2". A row missing or with a non-numeric value for a
* condition's field never matches (rather than throwing or silently passing),
* so an unrelated row can't slip through a filter it doesn't have the field for.
*/
export function rowMatchesAllConditions(
row: Record<string, unknown>,
conditions: FilterCondition[]
): boolean {
return conditions.every((c) => {
const v = row[c.field];
return typeof v === 'number' && Number.isFinite(v) && evaluateFilterCondition(v, c.operator, c.value);
});
}
// -----------------------------------------------------------------------
// Common per-scenario metrics (P2-4): batch-running several scenarios and
// comparing them needs the SAME summary numbers already computed for the
// Excel hydraulic report, not a second, independently-defined set of stats.
// -----------------------------------------------------------------------
export interface ScenarioMetrics {
minPressure: number;
maxPressure: number;
avgPressure: number;
minVelocity: number;
maxVelocity: number;
avgVelocity: number;
totalDemand: number;
}
/**
* Summary statistics for one scenario's result, at a given time step (or the
* flat/first-step field when omitted). Node/link raw values are used as-is
* (native EPANET units) -- callers wanting Korean/metric units should convert
* the returned numbers themselves, the same way every other screen does.
*/
export function computeScenarioMetrics(
model: NetworkModel,
result: SimulationResult,
timeStepIndex?: number
): ScenarioMetrics {
// Only junctions carry a meaningful service pressure (reservoirs always
// report 0 and tanks report their level), and only pipes have a velocity
// worth summarising (pumps/valves report 0) -- including them made the
// minimum pressure 0 for every network with a reservoir.
const JUNCTION = 0;
const CVPIPE = 0;
const PIPE = 1;
const pressures = Object.values(result.nodes)
.filter((n) => n.type === JUNCTION)
.map((n) => seriesValue(n.timeSeries?.pressure, timeStepIndex, n.pressure));
const velocities = Object.values(result.links)
.filter((l) => l.type === CVPIPE || l.type === PIPE)
.map((l) => seriesValue(l.timeSeries?.velocity, timeStepIndex, l.velocity));
const totalDemand = Object.values(model.nodes).reduce((a, n) => a + (n.demand ?? 0), 0);
return {
minPressure: pressures.length ? Math.min(...pressures) : 0,
maxPressure: pressures.length ? Math.max(...pressures) : 0,
avgPressure: pressures.length ? pressures.reduce((a, b) => a + b, 0) / pressures.length : 0,
minVelocity: velocities.length ? Math.min(...velocities) : 0,
maxVelocity: velocities.length ? Math.max(...velocities) : 0,
avgVelocity: velocities.length ? velocities.reduce((a, b) => a + b, 0) / velocities.length : 0,
totalDemand,
};
}
+207
View File
@@ -0,0 +1,207 @@
import type React from 'react';
import { generateInp, parseInp, type NetworkModel } from './inpModel';
import { deriveBaseScenarioResult, saveActiveScenarioState } from './resultSync';
import type { Scenario, SimulationResult } from './types';
import type { DualEngineOutcome } from './useDualEngine';
export interface ScenarioActionContext {
networkModel: NetworkModel | null;
setNetworkModel: React.Dispatch<React.SetStateAction<NetworkModel | null>>;
rawInp: string | null;
setRawInp: React.Dispatch<React.SetStateAction<string | null>>;
scenarios: Scenario[];
setScenarios: React.Dispatch<React.SetStateAction<Scenario[]>>;
activeScenarioId: string;
setActiveScenarioId: React.Dispatch<React.SetStateAction<string>>;
wasmResult: SimulationResult | null;
setWasmResult: React.Dispatch<React.SetStateAction<SimulationResult | null>>;
setServerResult: React.Dispatch<React.SetStateAction<SimulationResult | null>>;
isResultStale: boolean;
resultInpSnapshot: string | null;
setResultInpSnapshot: React.Dispatch<React.SetStateAction<string | null>>;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
setUndoStack: React.Dispatch<React.SetStateAction<NetworkModel[]>>;
setRedoStack: React.Dispatch<React.SetStateAction<NetworkModel[]>>;
setBatchRunProgress: React.Dispatch<React.SetStateAction<{ done: number; total: number } | null>>;
clearSelection: () => void;
normalizeInp: (inpStr: string) => string;
analyzeWithBothEngines: (fileStr: string) => Promise<DualEngineOutcome>;
showToast: (msg: string) => void;
}
/**
* Scenario (대안) actions: branch a new alternative, switch between them while
* saving the active one's edits, and batch re-analyze every scenario (P2-4).
* Plain functions over the App state passed in -- re-created each render.
*/
export function createScenarioActions(ctx: ScenarioActionContext) {
const {
networkModel,
setNetworkModel,
rawInp,
setRawInp,
scenarios,
setScenarios,
activeScenarioId,
setActiveScenarioId,
wasmResult,
setWasmResult,
setServerResult,
isResultStale,
resultInpSnapshot,
setResultInpSnapshot,
setIsModified,
setUndoStack,
setRedoStack,
setBatchRunProgress,
clearSelection,
normalizeInp,
analyzeWithBothEngines,
showToast,
} = ctx;
// Create new Scenario from current state
const handleCreateScenario = () => {
if (!networkModel) return;
// Number alternatives by their own sequence, not scenarios.length: the
// 'base' (원안) entry is added on the first creation, so length-based
// numbering produced "대안 1, 대안 3, ..." (대안 2 skipped).
const usedNums = scenarios
.map((s) => /^scen_(\d+)$/.exec(s.id))
.filter((m): m is RegExpExecArray => m !== null)
.map((m) => Number(m[1]));
const scenNum = (usedNums.length > 0 ? Math.max(...usedNums) : 0) + 1;
const newScenId = `scen_${scenNum}`;
const currentInp = generateInp(networkModel);
// Always persist the currently active scenario's latest edits first (a
// no-op if it doesn't exist yet, e.g. the very first scenario ever
// created) -- see saveActiveScenarioState. Every scenario creation/switch
// path must go through this, never skip it.
let updated = saveActiveScenarioState(scenarios, activeScenarioId, currentInp, wasmResult, isResultStale);
if (updated.length === 0) {
const normalizedOriginal = rawInp ? normalizeInp(rawInp) : currentInp;
// The 'base' scenario represents the ORIGINAL, unedited network, so its
// result must be paired with an analysis of that exact original text --
// never with whatever wasmResult currently holds, which may reflect
// edits made before this first scenario was ever created.
updated.push({
id: 'base',
name: '원안 (기존 관망)',
inpStr: normalizedOriginal,
result: deriveBaseScenarioResult(wasmResult, isResultStale, resultInpSnapshot, normalizedOriginal),
createdAt: '기준 모델',
});
}
const newScen: Scenario = {
id: newScenId,
name: `대안 ${scenNum} (시나리오)`,
inpStr: currentInp,
// Never attach a result computed from a different (pre-edit) model text.
result: isResultStale ? null : wasmResult,
createdAt: new Date().toLocaleTimeString(),
};
updated.push(newScen);
setScenarios(updated);
setActiveScenarioId(newScenId);
setResultInpSnapshot(newScen.result ? currentInp : resultInpSnapshot);
// A new scenario is a new editing branch -- carrying over undo/redo
// history from whatever was being edited before would let "undo" jump
// into a different scenario's past state.
setUndoStack([]);
setRedoStack([]);
alert(`'${newScen.name}' 시나리오가 생성되었습니다. 관경이나 펌프 제어를 수정한 뒤 재해석하세요.`);
};
// Switch active scenario. Auto-saves the currently active scenario's live edits
// (model text always, result only when not stale) before loading the target,
// so in-progress edits are never silently discarded.
const handleSwitchScenario = (scenId: string) => {
if (scenId === activeScenarioId) return;
const target = scenarios.find((s) => s.id === scenId);
if (!target) return;
try {
if (networkModel) {
const currentInp = generateInp(networkModel);
if (!scenarios.some((s) => s.id === activeScenarioId)) {
// Should never happen (scenarios/activeScenarioId are reset together on
// every new file load), but if it ever does, surface it instead of
// silently discarding the user's in-progress edits.
console.error(`활성 시나리오 '${activeScenarioId}'를 시나리오 목록에서 찾을 수 없어 편집 내용을 저장하지 못했습니다.`);
}
setScenarios((prev) =>
saveActiveScenarioState(prev, activeScenarioId, currentInp, wasmResult, isResultStale)
);
}
const model = parseInp(target.inpStr);
setNetworkModel(model);
setRawInp(target.inpStr);
setActiveScenarioId(scenId);
setIsModified(false);
clearSelection();
if (target.result) {
setWasmResult(target.result);
setServerResult(target.result);
setResultInpSnapshot(normalizeInp(target.inpStr));
} else {
setWasmResult(null);
setServerResult(null);
setResultInpSnapshot(null);
}
// Undo/redo history belongs to the scenario being edited -- carrying it
// across a switch would let "undo" step into a different scenario's
// past states.
setUndoStack([]);
setRedoStack([]);
} catch (err: unknown) {
alert('시나리오 전환 실패');
}
};
// Runs every saved scenario's own INP text through both engines and stores
// each one's result back onto that scenario (never touching the currently
// active model/result), so scenarios can be compared side by side without
// manually switching to each one and re-running analysis by hand.
const handleBatchRunAllScenarios = async () => {
if (scenarios.length === 0) {
showToast('일괄 실행할 대안이 없습니다. 먼저 대안을 1개 이상 생성하세요.');
return;
}
// Persist the currently active scenario's latest edits first (same rule
// as every other scenario entry point), so the batch run doesn't operate
// on a stale copy of what's being edited right now.
let base = scenarios;
if (networkModel) {
const currentInp = generateInp(networkModel);
base = saveActiveScenarioState(scenarios, activeScenarioId, currentInp, wasmResult, isResultStale);
}
setBatchRunProgress({ done: 0, total: base.length });
let updated = base;
setScenarios(updated);
let failureCount = 0;
for (let i = 0; i < base.length; i++) {
const scen = base[i];
const { server, wasm, failureMessages } = await analyzeWithBothEngines(scen.inpStr);
const result = wasm ?? server;
if (!result) failureCount++;
if (failureMessages.length > 0 && !result) {
console.error(`시나리오 [${scen.name}] 해석 실패: ${failureMessages.join('; ')}`);
}
updated = updated.map((s) => (s.id === scen.id ? { ...s, result } : s));
setScenarios(updated);
setBatchRunProgress({ done: i + 1, total: base.length });
}
setBatchRunProgress(null);
showToast(
failureCount > 0
? `대안 일괄 해석 완료: ${base.length - failureCount}/${base.length}개 성공 (${failureCount}개 실패)`
: `대안 ${base.length}개 일괄 해석이 모두 완료되었습니다.`
);
};
return { handleCreateScenario, handleSwitchScenario, handleBatchRunAllScenarios };
}
+183
View File
@@ -0,0 +1,183 @@
import type { NetworkModel } from './inpModel';
import type { BasemapType, Scenario, SimulationResult, UserGisLayer } from './types';
import type { CRS } from './crs';
import type { DisplayUnitMode } from './unitSystem';
import type { MapStyleSettings } from './StyleSettingsPanel';
// Legacy "GISPipe" prefix kept on purpose (here and in the gispipe_* localStorage
// keys): renaming storage keys would drop every user's saved session/settings.
const DATABASE_NAME = 'gispipe-session';
const DATABASE_VERSION = 1;
const STORE_NAME = 'autosave';
const LATEST_KEY = 'latest';
const TAB_KEY_STORAGE_NAME = 'gispipe_tab_session_key';
// Autosave keeps one record per browser tab (see getTabSessionKey), so the
// store is trimmed to the most recently-saved tabs to avoid growing forever
// as tabs are opened and closed over time.
const MAX_RETAINED_SNAPSHOTS = 10;
export interface SessionSnapshot {
version: 2;
savedAt: string;
fileName: string | null;
networkModel: NetworkModel;
crs: CRS;
basemap: BasemapType;
displayUnitMode: DisplayUnitMode;
styleSettings: MapStyleSettings;
isModified: boolean;
// Full project state -- without these, restoring a session silently lost
// every alternative scenario, its results, and any GIS reference layers.
rawInp: string | null;
scenarios: Scenario[];
activeScenarioId: string;
wasmResult: SimulationResult | null;
serverResult: SimulationResult | null;
resultInpSnapshot: string | null;
userLayers: UserGisLayer[];
}
/** Pre-P0-8 snapshot shape, kept only so an old autosave record from a
* previous session doesn't hard-fail to load; it restores with no
* scenarios/results/layers rather than being discarded outright. */
interface LegacySessionSnapshotV1 {
version: 1;
savedAt: string;
fileName: string | null;
networkModel: NetworkModel;
crs: CRS;
basemap: BasemapType;
displayUnitMode: DisplayUnitMode;
styleSettings: MapStyleSettings;
isModified: boolean;
}
type StoredSnapshot = (SessionSnapshot | LegacySessionSnapshotV1) & { id: string };
/**
* A stable id for this browser TAB (sessionStorage, unlike localStorage/
* IndexedDB, is private per tab and survives reloads of that same tab but
* not a fresh tab/window). Using it as the IndexedDB record key means
* multiple tabs autosaving concurrently each get their own record instead of
* clobbering a single shared 'latest' record.
*/
function getTabSessionKey(): string {
try {
let key = sessionStorage.getItem(TAB_KEY_STORAGE_NAME);
if (!key) {
key = `tab_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
sessionStorage.setItem(TAB_KEY_STORAGE_NAME, key);
}
return key;
} catch {
// sessionStorage unavailable (e.g. private-mode restrictions): fall back
// to the old shared key rather than failing autosave entirely.
return LATEST_KEY;
}
}
function openSessionDatabase(): Promise<IDBDatabase> {
const { promise, resolve, reject } = Promise.withResolvers<IDBDatabase>();
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 열기 실패'));
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
return promise;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
const { promise, resolve, reject } = Promise.withResolvers<T>();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 요청 실패'));
return promise;
}
export async function saveSessionSnapshot(snapshot: SessionSnapshot): Promise<void> {
const database = await openSessionDatabase();
try {
const key = getTabSessionKey();
const { promise, resolve, reject } = Promise.withResolvers<void>();
const transaction = database.transaction(STORE_NAME, 'readwrite');
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error('자동 저장 실패'));
transaction.onabort = () => reject(transaction.error ?? new Error('자동 저장 중단'));
const store = transaction.objectStore(STORE_NAME);
const stored: StoredSnapshot = { ...snapshot, id: key };
store.put(stored);
// Trim to the most recently-saved MAX_RETAINED_SNAPSHOTS records so
// autosaving from many tabs over time doesn't grow the store forever.
const allRequest = store.getAll();
allRequest.onsuccess = () => {
const all = (allRequest.result as StoredSnapshot[]) || [];
if (all.length > MAX_RETAINED_SNAPSHOTS) {
const sorted = [...all].sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1));
for (const stale of sorted.slice(MAX_RETAINED_SNAPSHOTS)) {
store.delete(stale.id);
}
}
};
await promise;
} finally {
database.close();
}
}
/** Exported for unit testing the v1 -> v2 migration without a real IndexedDB. */
export function normalizeStoredSnapshot(stored: StoredSnapshot): SessionSnapshot | null {
if (!stored || !stored.networkModel) return null;
if (stored.version === 2) {
const { id: _id, ...snapshot } = stored;
return snapshot;
}
if (stored.version === 1) {
const { id: _id, ...legacy } = stored;
return {
...legacy,
version: 2,
rawInp: null,
scenarios: [],
activeScenarioId: 'base',
wasmResult: null,
serverResult: null,
resultInpSnapshot: null,
userLayers: [],
};
}
return null;
}
export async function loadSessionSnapshot(): Promise<SessionSnapshot | null> {
const database = await openSessionDatabase();
try {
const key = getTabSessionKey();
const transaction = database.transaction(STORE_NAME, 'readonly');
const stored = (await requestToPromise(
transaction.objectStore(STORE_NAME).get(key)
)) as StoredSnapshot | undefined;
if (!stored) return null;
return normalizeStoredSnapshot(stored);
} finally {
database.close();
}
}
export async function clearSessionSnapshot(): Promise<void> {
const database = await openSessionDatabase();
try {
const key = getTabSessionKey();
const { promise, resolve, reject } = Promise.withResolvers<void>();
const transaction = database.transaction(STORE_NAME, 'readwrite');
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error ?? new Error('자동 저장본 삭제 실패'));
transaction.objectStore(STORE_NAME).delete(key);
await promise;
} finally {
database.close();
}
}
+498
View File
@@ -0,0 +1,498 @@
import type { NetworkModel, InpNode, InpPipe } from './inpModel';
import type { SimulationResult } from './types';
import type { CRS } from './crs';
// Basic WKT mappings for supported CRS
const CRS_WKT_MAP: Record<string, string> = {
'EPSG:5179':
'PROJCS["Korea 2000 / Unified CS",GEOGCS["Korea 2000",DATUM["Korea_2000",SPHEROID["GRS 1980",6378137,298.257222101]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],PARAMETER["central_meridian",127.5],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",1000000],PARAMETER["false_northing",2000000],UNIT["metre",1]]',
'EPSG:5186':
'PROJCS["Korea 2000 / Central Belt 2010",GEOGCS["Korea 2000",DATUM["Korea_2000",SPHEROID["GRS 1980",6378137,298.257222101]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],PARAMETER["central_meridian",127],PARAMETER["scale_factor",1],PARAMETER["false_easting",200000],PARAMETER["false_northing",600000],UNIT["metre",1]]',
'EPSG:5181':
'PROJCS["Korea 1985 / Central Belt - Kakao",GEOGCS["Korea 1985",DATUM["Korea_1985",SPHEROID["Bessel 1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],PARAMETER["central_meridian",127],PARAMETER["scale_factor",1],PARAMETER["false_easting",200000],PARAMETER["false_northing",500000],UNIT["metre",1]]',
'EPSG:5174':
'PROJCS["Korean 1985 / Modified Central Belt",GEOGCS["Korean 1985",DATUM["Korean_Datum_1985",SPHEROID["Bessel 1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],PARAMETER["central_meridian",127.0028902777778],PARAMETER["scale_factor",1],PARAMETER["false_easting",200000],PARAMETER["false_northing",500000],UNIT["metre",1]]',
'EPSG:4326':
'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]',
'EPSG:3857':
'PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Mercator_1SP"],PARAMETER["central_meridian",0],PARAMETER["scale_factor",1],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1]]',
};
interface DbfField {
name: string;
type: 'C' | 'N';
length: number;
decimals: number;
}
function createDbf(fields: DbfField[], rows: Array<Record<string, unknown>>): Uint8Array {
const numRecords = rows.length;
const headerLength = 32 + 32 * fields.length + 1;
const recordLength = 1 + fields.reduce((sum, f) => sum + f.length, 0);
const totalLength = headerLength + numRecords * recordLength + 1;
const buffer = new ArrayBuffer(totalLength);
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
// DBF Header
view.setUint8(0, 0x03); // dBase III
const now = new Date();
view.setUint8(1, now.getFullYear() - 1900);
view.setUint8(2, now.getMonth() + 1);
view.setUint8(3, now.getDate());
view.setUint32(4, numRecords, true);
view.setUint16(8, headerLength, true);
view.setUint16(10, recordLength, true);
// Field Descriptors
let fieldOffset = 32;
for (const f of fields) {
// Name (up to 11 bytes, 0-padded)
for (let i = 0; i < 11; i++) {
if (i < f.name.length) {
bytes[fieldOffset + i] = f.name.charCodeAt(i);
} else {
bytes[fieldOffset + i] = 0;
}
}
bytes[fieldOffset + 11] = f.type.charCodeAt(0);
bytes[fieldOffset + 16] = f.length;
bytes[fieldOffset + 17] = f.decimals;
fieldOffset += 32;
}
bytes[fieldOffset] = 0x0d; // Header terminator
// Data Records
let rowPos = headerLength;
for (const row of rows) {
bytes[rowPos] = 0x20; // Active record
let colPos = rowPos + 1;
for (const f of fields) {
const val = row[f.name];
let strVal = '';
if (val !== undefined && val !== null) {
if (f.type === 'N') {
strVal = typeof val === 'number' ? val.toFixed(f.decimals) : String(val);
} else {
strVal = String(val);
}
}
// Fixed width, left-padded for numbers, right-padded for strings
if (f.type === 'N') {
strVal = strVal.padStart(f.length, ' ');
} else {
strVal = strVal.padEnd(f.length, ' ');
}
strVal = strVal.slice(0, f.length);
for (let i = 0; i < f.length; i++) {
bytes[colPos + i] = strVal.charCodeAt(i);
}
colPos += f.length;
}
rowPos += recordLength;
}
bytes[totalLength - 1] = 0x1a; // EOF
return bytes;
}
// 1. Build Point Shapefile (.shp and .shx)
function createPointShp(nodes: InpNode[]): { shp: Uint8Array; shx: Uint8Array } {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of nodes) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
if (!Number.isFinite(minX)) {
minX = minY = maxX = maxY = 0;
}
// Point record content is 20 bytes (10 words) + 8 bytes record header = 28 bytes per record
const recordCount = nodes.length;
const shpTotalLength = 100 + recordCount * 28;
const shxTotalLength = 100 + recordCount * 8;
const shpBuffer = new ArrayBuffer(shpTotalLength);
const shpView = new DataView(shpBuffer);
const shxBuffer = new ArrayBuffer(shxTotalLength);
const shxView = new DataView(shxBuffer);
// Common Header (100 bytes)
const writeHeader = (view: DataView, fileLengthWords: number, shapeType: number) => {
view.setUint32(0, 9994, false); // File code Big Endian
view.setUint32(24, fileLengthWords, false);
view.setUint32(28, 1000, true); // Version 1000 Little Endian
view.setUint32(32, shapeType, true); // ShapeType
view.setFloat64(36, minX, true);
view.setFloat64(44, minY, true);
view.setFloat64(52, maxX, true);
view.setFloat64(60, maxY, true);
};
writeHeader(shpView, shpTotalLength / 2, 1);
writeHeader(shxView, shxTotalLength / 2, 1);
let shpPos = 100;
let shxPos = 100;
for (let i = 0; i < recordCount; i++) {
const node = nodes[i];
const recNum = i + 1;
const offsetWords = shpPos / 2;
// SHX index entry
shxView.setUint32(shxPos, offsetWords, false);
shxView.setUint32(shxPos + 4, 10, false); // 10 words
shxPos += 8;
// SHP record header
shpView.setUint32(shpPos, recNum, false);
shpView.setUint32(shpPos + 4, 10, false); // 10 words (20 bytes)
shpPos += 8;
// SHP point content
shpView.setUint32(shpPos, 1, true); // Point type
shpView.setFloat64(shpPos + 4, node.x, true);
shpView.setFloat64(shpPos + 12, node.y, true);
shpPos += 20;
}
return {
shp: new Uint8Array(shpBuffer),
shx: new Uint8Array(shxBuffer),
};
}
// 2. Build PolyLine Shapefile (.shp and .shx)
function createPolyLineShp(pipes: InpPipe[], nodes: Record<string, InpNode>): { shp: Uint8Array; shx: Uint8Array } {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
interface PipeLineEntry {
pipe: InpPipe;
points: Array<[number, number]>;
box: [number, number, number, number];
contentBytes: number;
contentWords: number;
}
const entries: PipeLineEntry[] = [];
for (const pipe of pipes) {
const n1 = nodes[pipe.node1];
const n2 = nodes[pipe.node2];
const points: Array<[number, number]> = [
[n1.x, n1.y],
[n2.x, n2.y],
];
let pMinX = Infinity, pMinY = Infinity, pMaxX = -Infinity, pMaxY = -Infinity;
for (const [x, y] of points) {
if (x < pMinX) pMinX = x;
if (y < pMinY) pMinY = y;
if (x > pMaxX) pMaxX = x;
if (y > pMaxY) pMaxY = y;
}
if (pMinX < minX) minX = pMinX;
if (pMinY < minY) minY = pMinY;
if (pMaxX > maxX) maxX = pMaxX;
if (pMaxY > maxY) maxY = pMaxY;
// PolyLine record length:
// ShapeType (4) + Box (32) + NumParts (4) + NumPoints (4) + Parts[0] (4) + Points (16 * N)
const contentBytes = 48 + 16 * points.length;
const contentWords = contentBytes / 2;
entries.push({
pipe,
points,
box: [pMinX, pMinY, pMaxX, pMaxY],
contentBytes,
contentWords,
});
}
if (!Number.isFinite(minX)) {
minX = minY = maxX = maxY = 0;
}
const totalShpLength = 100 + entries.reduce((sum, e) => sum + 8 + e.contentBytes, 0);
const totalShxLength = 100 + entries.length * 8;
const shpBuffer = new ArrayBuffer(totalShpLength);
const shpView = new DataView(shpBuffer);
const shxBuffer = new ArrayBuffer(totalShxLength);
const shxView = new DataView(shxBuffer);
const writeHeader = (view: DataView, fileLengthWords: number, shapeType: number) => {
view.setUint32(0, 9994, false);
view.setUint32(24, fileLengthWords, false);
view.setUint32(28, 1000, true);
view.setUint32(32, shapeType, true);
view.setFloat64(36, minX, true);
view.setFloat64(44, minY, true);
view.setFloat64(52, maxX, true);
view.setFloat64(60, maxY, true);
};
writeHeader(shpView, totalShpLength / 2, 3); // Type 3 PolyLine
writeHeader(shxView, totalShxLength / 2, 3);
let shpPos = 100;
let shxPos = 100;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const recNum = i + 1;
const offsetWords = shpPos / 2;
// SHX entry
shxView.setUint32(shxPos, offsetWords, false);
shxView.setUint32(shxPos + 4, entry.contentWords, false);
shxPos += 8;
// SHP record header
shpView.setUint32(shpPos, recNum, false);
shpView.setUint32(shpPos + 4, entry.contentWords, false);
shpPos += 8;
// SHP PolyLine content
shpView.setUint32(shpPos, 3, true); // ShapeType 3
shpView.setFloat64(shpPos + 4, entry.box[0], true);
shpView.setFloat64(shpPos + 12, entry.box[1], true);
shpView.setFloat64(shpPos + 20, entry.box[2], true);
shpView.setFloat64(shpPos + 28, entry.box[3], true);
shpView.setUint32(shpPos + 36, 1, true); // NumParts = 1
shpView.setUint32(shpPos + 40, entry.points.length, true); // NumPoints
shpView.setUint32(shpPos + 44, 0, true); // Parts[0] = 0
let ptOffset = shpPos + 48;
for (const [px, py] of entry.points) {
shpView.setFloat64(ptOffset, px, true);
shpView.setFloat64(ptOffset + 8, py, true);
ptOffset += 16;
}
shpPos += entry.contentBytes;
}
return {
shp: new Uint8Array(shpBuffer),
shx: new Uint8Array(shxBuffer),
};
}
// 3. Simple pure-JS ZIP Archive Builder (Method 0 Stored)
export function createZipArchive(files: Record<string, Uint8Array>): Uint8Array {
interface LocalFileEntry {
name: string;
nameBytes: Uint8Array;
data: Uint8Array;
crc32: number;
localHeaderOffset: number;
}
// CRC32 table
const crcTable = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
crcTable[i] = c >>> 0;
}
function computeCrc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (let i = 0; i < data.length; i++) {
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
}
return (crc ^ 0xffffffff) >>> 0;
}
const entries: LocalFileEntry[] = [];
let currentOffset = 0;
const localHeaderChunks: Uint8Array[] = [];
for (const [fileName, fileData] of Object.entries(files)) {
const nameBytes = new TextEncoder().encode(fileName);
const crc = computeCrc32(fileData);
const localHeaderOffset = currentOffset;
const header = new Uint8Array(30 + nameBytes.length);
const view = new DataView(header.buffer);
view.setUint32(0, 0x04034b50, true); // Local header signature
view.setUint16(4, 20, true); // Version needed
view.setUint16(6, 0, true); // Flags
view.setUint16(8, 0, true); // Method 0 (Stored)
view.setUint16(10, 0, true); // Time
view.setUint16(12, 0, true); // Date
view.setUint32(14, crc, true); // CRC32
view.setUint32(18, fileData.length, true); // Compressed size
view.setUint32(22, fileData.length, true); // Uncompressed size
view.setUint16(26, nameBytes.length, true); // Name length
view.setUint16(28, 0, true); // Extra length
header.set(nameBytes, 30);
localHeaderChunks.push(header);
localHeaderChunks.push(fileData);
entries.push({
name: fileName,
nameBytes,
data: fileData,
crc32: crc,
localHeaderOffset,
});
currentOffset += header.length + fileData.length;
}
// Central Directory
const centralDirStart = currentOffset;
const centralChunks: Uint8Array[] = [];
for (const entry of entries) {
const cdHeader = new Uint8Array(46 + entry.nameBytes.length);
const view = new DataView(cdHeader.buffer);
view.setUint32(0, 0x02014b50, true); // Central directory signature
view.setUint16(4, 20, true);
view.setUint16(6, 20, true);
view.setUint16(8, 0, true);
view.setUint16(10, 0, true); // Method 0
view.setUint16(12, 0, true);
view.setUint16(14, 0, true);
view.setUint32(16, entry.crc32, true);
view.setUint32(20, entry.data.length, true);
view.setUint32(24, entry.data.length, true);
view.setUint16(28, entry.nameBytes.length, true);
view.setUint16(30, 0, true);
view.setUint16(32, 0, true);
view.setUint16(34, 0, true);
view.setUint16(36, 0, true);
view.setUint32(38, 0, true);
view.setUint32(42, entry.localHeaderOffset, true);
cdHeader.set(entry.nameBytes, 46);
centralChunks.push(cdHeader);
currentOffset += cdHeader.length;
}
const centralDirSize = currentOffset - centralDirStart;
// End of Central Directory Record (22 bytes)
const eocd = new Uint8Array(22);
const eocdView = new DataView(eocd.buffer);
eocdView.setUint32(0, 0x06054b50, true);
eocdView.setUint16(4, 0, true);
eocdView.setUint16(6, 0, true);
eocdView.setUint16(8, entries.length, true);
eocdView.setUint16(10, entries.length, true);
eocdView.setUint32(12, centralDirSize, true);
eocdView.setUint32(16, centralDirStart, true);
eocdView.setUint16(20, 0, true);
// Combine into final byte array
const totalZipSize = currentOffset + eocd.length;
const finalZip = new Uint8Array(totalZipSize);
let writePos = 0;
for (const chunk of localHeaderChunks) {
finalZip.set(chunk, writePos);
writePos += chunk.length;
}
for (const chunk of centralChunks) {
finalZip.set(chunk, writePos);
writePos += chunk.length;
}
finalZip.set(eocd, writePos);
return finalZip;
}
/**
* Exports the current network model as an ESRI Shapefile ZIP bundle containing:
* - pipes.shp, pipes.shx, pipes.dbf, pipes.prj
* - nodes.shp, nodes.shx, nodes.dbf, nodes.prj
*/
export function exportNetworkShapefileZip(
model: NetworkModel,
simulationResult: SimulationResult | null,
crs: CRS = 'EPSG:5179'
): Uint8Array {
const prjText = CRS_WKT_MAP[crs] || CRS_WKT_MAP['EPSG:5179'];
const prjBytes = new TextEncoder().encode(prjText);
// 1. Export Nodes
const nodeRows: Array<Record<string, unknown>> = [];
const nodesList = Object.values(model.nodes);
for (const n of nodesList) {
const sim = simulationResult?.nodes[n.id];
nodeRows.push({
ID: n.id,
TYPE: n.type,
ELEV: n.elevation,
DEMAND: n.demand ?? 0,
PRESSURE: sim ? Math.round(sim.pressure * 100) / 100 : 0,
HEAD: sim ? Math.round(sim.head * 100) / 100 : 0,
});
}
const nodeFields: DbfField[] = [
{ name: 'ID', type: 'C', length: 16, decimals: 0 },
{ name: 'TYPE', type: 'C', length: 12, decimals: 0 },
{ name: 'ELEV', type: 'N', length: 10, decimals: 2 },
{ name: 'DEMAND', type: 'N', length: 12, decimals: 2 },
{ name: 'PRESSURE', type: 'N', length: 10, decimals: 2 },
{ name: 'HEAD', type: 'N', length: 10, decimals: 2 },
];
const nodeShpData = createPointShp(nodesList);
const nodeDbfData = createDbf(nodeFields, nodeRows);
// 2. Export Pipes
const pipeRows: Array<Record<string, unknown>> = [];
const pipesList = Object.values(model.pipes);
for (const p of pipesList) {
const sim = simulationResult?.links[p.id];
pipeRows.push({
ID: p.id,
NODE1: p.node1,
NODE2: p.node2,
LENGTH: p.length,
DIAMETER: p.diameter,
ROUGHNESS: p.roughness,
STATUS: p.status || 'OPEN',
FLOW: sim ? Math.round(sim.flow * 100) / 100 : 0,
VELOCITY: sim ? Math.round(sim.velocity * 100) / 100 : 0,
HEADLOSS: sim ? Math.round(sim.headloss * 100) / 100 : 0,
});
}
const pipeFields: DbfField[] = [
{ name: 'ID', type: 'C', length: 16, decimals: 0 },
{ name: 'NODE1', type: 'C', length: 16, decimals: 0 },
{ name: 'NODE2', type: 'C', length: 16, decimals: 0 },
{ name: 'LENGTH', type: 'N', length: 12, decimals: 2 },
{ name: 'DIAMETER', type: 'N', length: 10, decimals: 2 },
{ name: 'ROUGHNESS', type: 'N', length: 8, decimals: 1 },
{ name: 'STATUS', type: 'C', length: 8, decimals: 0 },
{ name: 'FLOW', type: 'N', length: 12, decimals: 2 },
{ name: 'VELOCITY', type: 'N', length: 8, decimals: 2 },
{ name: 'HEADLOSS', type: 'N', length: 10, decimals: 2 },
];
const pipeShpData = createPolyLineShp(pipesList, model.nodes);
const pipeDbfData = createDbf(pipeFields, pipeRows);
// 3. Assemble Files into ZIP
const files: Record<string, Uint8Array> = {
'pipes.shp': pipeShpData.shp,
'pipes.shx': pipeShpData.shx,
'pipes.dbf': pipeDbfData,
'pipes.prj': prjBytes,
'nodes.shp': nodeShpData.shp,
'nodes.shx': nodeShpData.shx,
'nodes.dbf': nodeDbfData,
'nodes.prj': prjBytes,
};
return createZipArchive(files);
}
+274
View File
@@ -0,0 +1,274 @@
/* =============================================================================
관망해석 웹앱 디자인 토큰
-----------------------------------------------------------------------------
src/styles/tokens.css 로 넣고 main.tsx 또는 index.css 에서 최초 1회 import.
모든 색·치수는 여기서만 정의한다. 컴포넌트에서 하드코딩 금지.
구조: QGIS (도크 + 툴 레일 + 상태바 + 시간 컨트롤러)
표면: 중성 크롬 / 고밀도 / 색은 데이터에만
전 색상 조합은 WCAG 대비비 계산으로 검증됨 (DESIGN.md § 12)
============================================================================= */
:root {
color-scheme: light;
/* ---------------------------------------------------------------------------
1. 크롬 (UI 표면) — 무채색. 여기에 색을 넣지 말 것.
--------------------------------------------------------------------------- */
--bg-app: #f5f7f9; /* 앱 바탕 (독 사이 틈, 상태바) */
--bg-panel: #ffffff; /* 패널·팝오버·모달 면 */
--bg-subtle: #f0f2f4; /* 테이블 헤더, 접힌 섹션, 짝수 행 */
--bg-hover: #e9ebee;
--bg-active: #dfe2e6; /* 눌림 / 선택된 행 */
--bg-overlay: rgba(21, 23, 26, 0.32); /* 모달 뒤 */
--border: #e3e5e9; /* 기본 1px 경계 */
--border-strong: #c8ccd2; /* 인풋 테두리, 분할자 */
--text: #1f2329; /* 15.1:1 */
--text-secondary: #5f656d; /* 5.6:1 */
--text-muted: #6f757c; /* 4.7:1 — 10~11px 라벨에도 안전 */
--text-disabled: #a5aab0; /* 2.2:1 — 비활성 전용, 정보 전달 금지 */
--text-on-accent: #ffffff;
/* ---------------------------------------------------------------------------
2. 액센트 — 딱 하나. 포커스링·활성 툴·주 버튼에만.
지도 위 데이터에는 절대 쓰지 않는다 (컬러맵과 충돌).
--------------------------------------------------------------------------- */
--accent: #007188; /* 5.7:1 */
--accent-hover: #005f75;
--accent-wash: #e1f4fa; /* 활성 툴 배경, 선택 행 */
--accent-border: #7fc4d4;
/* ---------------------------------------------------------------------------
3. 선택 강조 (지도 전용) — 어떤 컬러맵과도 겹치지 않는 마젠타.
이 색은 오직 "지금 선택된 요소"만 의미한다.
--------------------------------------------------------------------------- */
--select: #dc2093; /* 4.5:1 */
--select-halo: #ffffff;
/* ---------------------------------------------------------------------------
4. 상태 — 의미 고정. 데이터 계열 색으로 재사용 금지.
항상 아이콘 + 텍스트와 함께. 색만으로 의미를 전달하지 않는다.
--------------------------------------------------------------------------- */
--good: #198044; /* 5.0:1 적정 / 수렴 성공 */
--good-wash: #e6f4ec;
--warn: #a86500; /* 4.6:1 경고 / 기준 근접 */
--warn-wash: #fbf0dd;
--crit: #c02c29; /* 5.8:1 오류 / 기준 위반 / 수렴 실패 */
--crit-wash: #fceceb;
/* 편집 모드 표시색 (상단 스트라이프 전용) */
--edit-stripe: #a85400;
--edit-wash: #fdf1e6;
/* ---------------------------------------------------------------------------
5. 데이터 컬러맵 — 5구간 분류 기준.
A: 압력 / 전수두 / 잔류염소 (파랑 단일 색상)
B: 유속 / 유량 / 단위손실수두 (앰버 단일 색상)
두 개를 동시에 지도에 올리지 않는다 (하나는 절점, 하나는 관로면 허용).
--------------------------------------------------------------------------- */
--seq-a-1: #86b4e8; --seq-a-2: #5e99da; --seq-a-3: #387dc6;
--seq-a-4: #1a63a9; --seq-a-5: #124a81;
--seq-b-1: #d9a873; --seq-b-2: #c68941; --seq-b-3: #b06b00;
--seq-b-4: #935100; --seq-b-5: #713d00;
/* 발산형 — 기준 대비 여유(±), 시나리오 간 차이. 중앙은 반드시 무채색. */
--div-1: #0b5ca3; --div-2: #4183ca; --div-3: #80ade0;
--div-0: #e5e6e8; /* 중립 = 차이 없음 */
--div-4: #db948b; --div-5: #c15e55; --div-6: #98342e;
/* ---------------------------------------------------------------------------
6. 지도 요소 기본색 (결과 미표시 상태)
요소 종류는 '형태'로 구분한다. 색으로 구분하지 않는다.
--------------------------------------------------------------------------- */
--map-ink: #454b53; /* 절점 테두리 / 관로 기본선 */
--map-fill: #ffffff; /* 절점 내부 */
--map-halo: #ffffff; /* 라벨·심볼 외곽 */
--map-closed: #9aa0a7; /* 폐쇄 상태 요소 */
--map-ghost: #c8ccd2; /* 비활성 레이어 */
/* ---------------------------------------------------------------------------
7. 타이포
Pretendard: npm i pretendard → import "pretendard/dist/web/variable/pretendardvariable.css"
--------------------------------------------------------------------------- */
--font-sans: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Noto Sans KR", "Apple SD Gothic Neo", sans-serif;
--font-mono: "JetBrains Mono", "D2Coding", ui-monospace, SFMono-Regular,
"Cascadia Mono", Consolas, monospace;
--fs-2xs: 10px; /* 상태바, 범례 눈금, 축약 배지 */
--fs-xs: 11px; /* 보조 라벨, 단위, 캡션 */
--fs-sm: 12px; /* 테이블 셀, 폼 라벨, 툴팁 */
--fs-base: 13px; /* 기본 UI 본문 — 대부분 여기 */
--fs-md: 14px; /* 패널 제목, 강조 값 */
--fs-lg: 16px; /* 모달 제목 */
--fs-xl: 20px; /* 결과 요약 수치. 그 이상 크기는 쓰지 않는다. */
--lh-tight: 1.25;
--lh-ui: 1.45;
--lh-prose: 1.65;
--fw-normal: 400;
--fw-medium: 500;
--fw-semi: 600;
/* 700 이상은 쓰지 않는다 — 고밀도 UI에서 뭉친다 */
--ls-ko: -0.01em; /* 한글 자간 보정 */
/* ---------------------------------------------------------------------------
8. 스페이싱 — 4px 그리드
--------------------------------------------------------------------------- */
--s-1: 2px; --s-2: 4px; --s-3: 6px; --s-4: 8px;
--s-5: 12px; --s-6: 16px; --s-7: 20px; --s-8: 24px; --s-9: 32px;
/* ---------------------------------------------------------------------------
9. 치수 — 고밀도 규격. 임의로 키우지 말 것.
--------------------------------------------------------------------------- */
--h-topbar: 48px;
--h-statusbar: 24px;
--h-timebar: 40px; /* EPS 시간 컨트롤러 */
--h-panel-head: 32px;
--h-row: 28px; /* 레이어 트리 / 테이블 행 */
--h-row-compact: 24px;
--h-input: 28px;
--h-btn: 28px;
--h-btn-sm: 24px;
--h-btn-lg: 32px;
--w-rail: 44px; /* 좌측 툴 레일 */
--w-dock-left: 264px;
--w-dock-right: 360px;
--w-dock-min: 200px;
--w-dock-max: 480px;
--icon: 16px;
--icon-lg: 20px; /* 툴 레일 */
--icon-sm: 14px;
--hit-min: 28px; /* 최소 클릭 영역 */
/* --------------------------------------------------------------------------
10. 모서리 / 테두리 / 그림자
8px 초과 radius 금지. 그림자는 부동 요소에만, 1단계만.
-------------------------------------------------------------------------- */
--r-xs: 2px; /* 배지, 체크박스 */
--r-sm: 4px; /* 버튼, 인풋, 행 — 기본값 */
--r-md: 6px; /* 패널, 팝오버, 모달 */
--r-full: 999px; /* 원형 요소에만 */
--bw: 1px;
--shadow-pop: 0 2px 8px rgba(21, 23, 26, 0.10), 0 0 0 1px rgba(21, 23, 26, 0.06);
--shadow-modal: 0 8px 32px rgba(21, 23, 26, 0.16), 0 0 0 1px rgba(21, 23, 26, 0.08);
--shadow-map: 0 1px 4px rgba(21, 23, 26, 0.14); /* 지도 위 부동 컨트롤 */
--focus-ring: 0 0 0 2px var(--bg-panel), 0 0 0 4px var(--accent);
/* --------------------------------------------------------------------------
11. 모션 — 짧고 절제되게. 200ms 초과 금지. 장식용 애니메이션 금지.
-------------------------------------------------------------------------- */
--dur-fast: 90ms;
--dur: 140ms;
--dur-panel: 180ms;
--ease: cubic-bezier(0.2, 0, 0.13, 1);
/* --------------------------------------------------------------------------
12. z-index
-------------------------------------------------------------------------- */
--z-map-ctrl: 10;
--z-dock: 20;
--z-topbar: 30;
--z-popover: 40;
--z-modal: 50;
--z-toast: 60;
}
/* =============================================================================
다크 — 토큰만 교체. 구조·치수는 동일.
============================================================================= */
:root[data-theme="dark"] {
color-scheme: dark;
--bg-app: #0f1113;
--bg-panel: #15171a;
--bg-subtle: #1d1f22;
--bg-hover: #24272b;
--bg-active: #303337;
--bg-overlay: rgba(0, 0, 0, 0.56);
--border: #282b2f;
--border-strong: #3e4146;
--text: #e8ebef; /* 15.0:1 */
--text-secondary: #a6abb2; /* 7.8:1 */
--text-muted: #7e848b; /* 4.8:1 */
--text-disabled: #595e64;
--text-on-accent: #0f1113;
--accent: #4fb4ce; /* 7.5:1 */
--accent-hover: #6ac6e0;
--accent-wash: #123038;
--accent-border: #2c6d7f;
--select: #f250ab; /* 5.6:1 */
--select-halo: #15171a;
--good: #55c179; --good-wash: #10281a;
--warn: #e8ab3e; --warn-wash: #2a1f0c;
--crit: #f66d62; --crit-wash: #2d1412;
--edit-stripe: #e08a3c;
--edit-wash: #2a1c0d;
--seq-a-1: #477db8; --seq-a-2: #5096e0; --seq-a-3: #69affb;
--seq-a-4: #8cc9ff; --seq-a-5: #b3e3ff;
--seq-b-1: #a46d29; --seq-b-2: #c68125; --seq-b-3: #e19a43;
--seq-b-4: #f5b66f; --seq-b-5: #ffd39d;
--div-1: #357dc7; --div-2: #5a97db; --div-3: #84b2e6;
--div-0: #333638;
--div-4: #e1988f; --div-5: #d3756b; --div-6: #bd564d;
--map-ink: #c2c8cf;
--map-fill: #15171a;
--map-halo: #0f1113;
--map-closed: #6b7178;
--map-ghost: #3e4146;
--shadow-pop: 0 2px 8px rgba(0, 0, 0, 0.40), 0 0 0 1px rgba(255, 255, 255, 0.07);
--shadow-modal: 0 8px 32px rgba(0, 0, 0, 0.56), 0 0 0 1px rgba(255, 255, 255, 0.09);
--shadow-map: 0 1px 4px rgba(0, 0, 0, 0.50);
}
/* OS 설정 추종 (사용자가 명시 선택하지 않은 경우) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bg-app: #0f1113; --bg-panel: #15171a; --bg-subtle: #1d1f22;
--bg-hover: #24272b; --bg-active: #303337; --bg-overlay: rgba(0,0,0,.56);
--border: #282b2f; --border-strong: #3e4146;
--text: #e8ebef; --text-secondary: #a6abb2;
--text-muted: #7e848b; --text-disabled: #595e64; --text-on-accent: #0f1113;
--accent: #4fb4ce; --accent-hover: #6ac6e0;
--accent-wash: #123038; --accent-border: #2c6d7f;
--select: #f250ab; --select-halo: #15171a;
--good: #55c179; --good-wash: #10281a;
--warn: #e8ab3e; --warn-wash: #2a1f0c;
--crit: #f66d62; --crit-wash: #2d1412;
--edit-stripe: #e08a3c; --edit-wash: #2a1c0d;
--seq-a-1: #477db8; --seq-a-2: #5096e0; --seq-a-3: #69affb;
--seq-a-4: #8cc9ff; --seq-a-5: #b3e3ff;
--seq-b-1: #a46d29; --seq-b-2: #c68125; --seq-b-3: #e19a43;
--seq-b-4: #f5b66f; --seq-b-5: #ffd39d;
--div-1: #357dc7; --div-2: #5a97db; --div-3: #84b2e6; --div-0: #333638;
--div-4: #e1988f; --div-5: #d3756b; --div-6: #bd564d;
--map-ink: #c2c8cf; --map-fill: #15171a; --map-halo: #0f1113;
--map-closed: #6b7178; --map-ghost: #3e4146;
--shadow-pop: 0 2px 8px rgba(0,0,0,.40), 0 0 0 1px rgba(255,255,255,.07);
--shadow-modal: 0 8px 32px rgba(0,0,0,.56), 0 0 0 1px rgba(255,255,255,.09);
--shadow-map: 0 1px 4px rgba(0,0,0,.50);
}
}
+266
View File
@@ -0,0 +1,266 @@
import type { FeatureCollection } from 'geojson';
export interface NodeTimeSeries {
demand: number[];
head: number[];
pressure: number[];
quality: number[];
}
export interface LinkTimeSeries {
flow: number[];
velocity: number[];
headloss: number[];
quality: number[];
energy?: number[];
efficiency?: number[];
}
export interface NodeResult {
id: string;
type: number;
coordinates: { x: number; y: number } | null;
demand: number;
head: number;
pressure: number;
quality?: number;
timeSeries?: NodeTimeSeries;
}
export interface LinkResult {
id: string;
type: number;
fromNode: string;
toNode: string;
vertices: { x: number; y: number }[];
flow: number;
velocity: number;
headloss: number;
quality?: number;
energy?: number;
efficiency?: number;
totalKwh?: number;
peakKw?: number;
avgKw?: number;
utilization?: number;
timeSeries?: LinkTimeSeries;
}
export interface SimulationResult {
engine: { runtime: "native" | "wasm"; version: "2.3.5" };
timeSeconds: number;
timeSteps?: number[];
units: { flow: string; pressure: string };
nodes: Record<string, NodeResult>;
links: Record<string, LinkResult>;
reportText?: string;
qualityInfo?: QualityInfo;
energySummary?: PumpEnergySummary;
/** Warning codes (1-6) returned directly by EN_runH, independent of [REPORT] MESSAGES. */
engineWarnings?: { time: number; code: number }[];
/** [TIMES] REPORT START / REPORT TIMESTEP in seconds. timeSteps also holds
* intermediate event times (tank fills, controls); use reportingStepIndices
* for the periods EPANET itself reports. */
reportStart?: number;
reportStep?: number;
}
export interface PumpEnergyResult {
id: string;
fromNode: string;
toNode: string;
totalKwh: number;
peakKw: number;
avgKw: number;
avgEfficiency: number;
utilization: number;
}
export interface PumpEnergySummary {
pumps: Record<string, PumpEnergyResult>;
totalKwh: number;
peakKw: number;
}
export interface QualityInfo {
qualType: number;
typeName: 'NONE' | 'CHEMICAL' | 'AGE' | 'TRACE';
chemName: string;
chemUnits: string;
traceNode?: string;
status: 'none' | 'success' | 'failed';
errorMessage?: string;
}
export type ComparisonVariable = 'demand' | 'head' | 'pressure' | 'flow' | 'velocity' | 'headloss' | 'quality';
export interface ToleranceConfig {
absTol: number;
relTol: number;
}
export interface ToleranceViolation {
variable: ComparisonVariable;
id: string;
time: number;
diff: number;
tolerance: number;
}
export interface ComparisonResult {
passed: boolean;
maxDiffs: Record<ComparisonVariable, number>;
maxRelativeDiffs: Record<ComparisonVariable, number>;
maxSignificantRelativeDiffs: Record<ComparisonVariable, number>;
nearZeroCounts: Record<ComparisonVariable, number>;
toleranceViolations: ToleranceViolation[];
missingIds: string[];
}
export type SelectionTarget =
| { type: 'node'; id: string }
| { type: 'link'; id: string }
| {
type: 'gis-feature';
layerId: string;
featureId: string | number;
properties: Record<string, unknown>;
geometryType?: string;
}
| null;
export interface MultiSelectionState {
nodes: Set<string>;
links: Set<string>;
gisFeatures: Array<{ layerId: string; featureId: string | number }>;
}
export type EditMode = 'select' | 'add-node' | 'add-pipe';
/** Text shown next to a node / at a link midpoint (calibration values, analysis results ...). */
export interface MapAnnotation {
type: 'node' | 'link';
id: string;
text: string;
color?: string;
}
/** 3D 보기: tilted map, optional DEM terrain and node columns. */
export interface View3D {
enabled: boolean;
/** DEM id for terrain (server terrain tiles); none = flat ground. */
demId?: string;
exaggeration: number;
/** Node columns: height from this display value (m). */
columns: 'none' | 'head' | 'pressure' | 'elevation';
columnScale: number;
}
/** Image draped on the map by its four corners (DEM shading, aerial photo, scanned drawing). */
export interface RasterOverlay {
id: string;
name: string;
url: string;
/** [lng, lat] of top-left, top-right, bottom-right, bottom-left. */
corners: [number, number][];
opacity: number;
visible: boolean;
}
/** Right dock tab. */
export type RightTab = 'inspector' | 'results' | 'verification' | 'analysis';
export type DisplayMode =
| 'pressure'
| 'flow'
| 'head'
| 'velocity'
| 'headloss'
| 'elevation'
| 'diff-pressure'
| 'diff-flow'
| 'quality';
export interface Scenario {
id: string;
name: string;
description?: string;
inpStr: string;
result: SimulationResult | null;
createdAt: string;
}
export type BasemapType =
| 'blank'
| 'osm'
| 'vworld'
| 'vworld-white'
| 'vworld-midnight'
| 'vworld-satellite'
| 'vworld-satellite-hybrid';
/* ---------------------------------------------------------------------------
User GIS Reference Layers
--------------------------------------------------------------------------- */
export interface UserGisLayer {
id: string;
name: string;
fileName: string;
featureCollection: FeatureCollection;
featureCount: number;
geomTypes: string[];
estimatedCrs: string;
visible: boolean;
opacity: number; // 0.0 ~ 1.0
color: string;
createdAt: string;
/** CRS the source coordinates were read as (EPSG code / proj4); lets the
* layer be reassigned when that guess was wrong. Absent = WGS84 / unknown. */
sourceCrs?: string;
}
/* ---------------------------------------------------------------------------
Layer Visibility State
--------------------------------------------------------------------------- */
export interface LayerVisibilityState {
// 관망 모델 요소별
nodes: boolean;
pipes: boolean;
pumps: boolean;
valves: boolean;
tanksReservoirs: boolean;
// 레이어별 투명도 (0.0 ~ 1.0)
networkOpacity: {
nodes: number;
pipes: number;
pumps: number;
valves: number;
tanksReservoirs: number;
};
// 결과 표시
results: {
visible: boolean;
opacity: number;
};
}
/* ---------------------------------------------------------------------------
Map Context Menu Event
--------------------------------------------------------------------------- */
export type ContextMenuTarget =
| { type: 'canvas'; lngLat: [number, number]; modelCoords: [number, number] }
| { type: 'node'; id: string; lngLat: [number, number]; modelCoords?: [number, number] }
| { type: 'link'; id: string; lngLat: [number, number] }
| {
type: 'gis-feature';
layerId: string;
featureId: string | number;
properties: Record<string, unknown>;
lngLat: [number, number];
};
export interface MapContextMenuEvent {
x: number;
y: number;
target: ContextMenuTarget;
}
+783
View File
@@ -0,0 +1,783 @@
import { scaleExtrasFlow } from './inpExtras.ts';
import type { NetworkModel, InpNode, InpPipe, InpOptions } from './inpModel';
export type UnitSystem = 'SI' | 'US';
export type DisplayUnitMode = 'korean' | 'original';
export type SIFlowUnit = 'LPS' | 'LPM' | 'MLD' | 'CMH' | 'CMD' | 'CMS';
export type USFlowUnit = 'CFS' | 'GPM' | 'MGD' | 'IMGD' | 'AFD';
export type FlowUnit = SIFlowUnit | USFlowUnit;
export const SI_FLOW_UNITS: readonly SIFlowUnit[] = ['LPS', 'LPM', 'MLD', 'CMH', 'CMD', 'CMS'];
export const US_FLOW_UNITS: readonly USFlowUnit[] = ['CFS', 'GPM', 'MGD', 'IMGD', 'AFD'];
/**
* Returns 'SI' or 'US' based on the EPANET flow unit.
*/
export function getUnitSystem(flowUnit: string): UnitSystem {
const u = flowUnit.toUpperCase().trim();
if (SI_FLOW_UNITS.includes(u as SIFlowUnit)) {
return 'SI';
}
return 'US';
}
/**
* Conversion factors to standard base units:
* - Flow base: Liters per second (LPS)
* - Length/Elevation base: meters (m)
* - Diameter base: millimeters (mm)
*/
const FLOW_TO_LPS: Record<string, number> = {
LPS: 1.0,
LPM: 1 / 60,
MLD: 1000000 / 86400, // ~11.574074 LPS
CMH: 1000 / 3600, // ~0.277778 LPS
CMD: 1000 / 86400, // ~0.011574 LPS
CMS: 1000.0, // 1 m^3/s = 1000 L/s
CFS: 28.316846592, // 1 ft^3/s = 28.316846592 L/s
GPM: 0.0630901964, // 1 US gal = 3.785411784 L / 60
MGD: 43.8126363889, // 10^6 gal / 86400
IMGD: 52.6167823529, // Imperial gal / 86400
AFD: 14.276410486, // 1 acre-foot / 86400
};
// Length conversions: meters (m) <-> feet (ft)
const FT_PER_M = 1 / 0.3048; // 3.280839895013123
const M_PER_FT = 0.3048;
// Diameter conversions: millimeters (mm) <-> inches (in)
const INCH_PER_MM = 1 / 25.4; // 0.03937007874015748
const MM_PER_INCH = 25.4;
// Physical pressure <-> head-of-water conversion constants (water, SG = 1),
// derived from EPANET's own internal unit-conversion factors:
// PSIperFT = 0.4333 (psi per foot of head), MperFT = 0.3048 (m per ft).
// EPANET relates a node's reported pressure to its head via
// P(psi) = (H - z) * SpGr * PSIperFT in US units, so converting a *pressure*
// quantity (psi <-> m) for a fluid whose specific gravity differs from 1.0
// must explicitly account for that ratio, not assume water.
const PSI_PER_FT = 0.4333;
const M_PER_FT_HEAD = 0.3048;
const PSI_TO_M_WATER = M_PER_FT_HEAD / PSI_PER_FT; // ~0.703439 m per psi
const M_WATER_TO_PSI = PSI_PER_FT / M_PER_FT_HEAD; // ~1.421588 psi per m
function pressureUStoSI(psi: number, specificGravity: number): number {
const sg = specificGravity > 0 ? specificGravity : 1.0;
return (psi * PSI_TO_M_WATER) / sg;
}
function pressureSItoUS(meters: number, specificGravity: number): number {
const sg = specificGravity > 0 ? specificGravity : 1.0;
return meters * M_WATER_TO_PSI * sg;
}
/**
* EPANET 2.2+ lets a project's *pressure* report unit be chosen independently
* of its flow unit (psi/kPa/m/bar/ft -- EN_PRESSUNITS), so the pressure unit
* a running analysis actually reports (SimulationResult.units.pressure, read
* from the engine) can differ from what the flow unit's US/SI system would
* suggest. kPa and bar are true pressure quantities (force/area) and so, like
* psi, must be scaled by the fluid's specific gravity to convert to a head-of
* -fluid length; ft, like m, is already a head-of-fluid length and needs only
* a unit conversion, never a specific-gravity correction.
*/
export type PressureUnit = 'PSI' | 'KPA' | 'M' | 'BAR' | 'FT';
export const PRESSURE_UNITS: readonly PressureUnit[] = ['PSI', 'KPA', 'M', 'BAR', 'FT'];
// kgf/(m*s^2) per meter of water column, i.e. rho*g for water (rho=1000 kg/m^3, g=9.80665 m/s^2).
const KPA_PER_M_WATER = 9.80665;
/** Converts a value reported in `pressureUnit` (psi/kPa/m/bar/ft) to meters of fluid head. */
function pressureUnitToMeters(value: number, pressureUnit: string, specificGravity: number): number {
const sg = specificGravity > 0 ? specificGravity : 1.0;
switch (pressureUnit.toUpperCase().trim()) {
case 'PSI':
return pressureUStoSI(value, sg);
case 'KPA':
return value / (KPA_PER_M_WATER * sg);
case 'BAR':
return (value * 100) / (KPA_PER_M_WATER * sg);
case 'FT':
return value * M_PER_FT_HEAD;
case 'M':
default:
return value;
}
}
/** Converts meters of fluid head to a value expressed in `pressureUnit` (psi/kPa/m/bar/ft). */
function metersToPressureUnit(meters: number, pressureUnit: string, specificGravity: number): number {
const sg = specificGravity > 0 ? specificGravity : 1.0;
switch (pressureUnit.toUpperCase().trim()) {
case 'PSI':
return pressureSItoUS(meters, sg);
case 'KPA':
return meters * KPA_PER_M_WATER * sg;
case 'BAR':
return (meters * KPA_PER_M_WATER * sg) / 100;
case 'FT':
return meters * FT_PER_M;
case 'M':
default:
return meters;
}
}
/**
* The engine's own pressure-unit label (psi/kPa/m/bar/ft) as reported on a
* SimulationResult, falling back to the US/SI default for the model's flow
* unit only when no result is available yet (e.g. before the first analysis
* run) -- never a flow-unit-based guess once a real result exists.
*/
export function resolvePressureUnit(
reportedPressureUnit: string | undefined | null,
flowUnit: string
): string {
const label = reportedPressureUnit?.trim();
if (label && PRESSURE_UNITS.includes(label.toUpperCase() as PressureUnit)) {
return label;
}
return getUnitSystem(flowUnit) === 'US' ? 'psi' : 'm';
}
/**
* Converts the project's shared judgement criteria -- always expressed in
* Korean/metric units (m minimum pressure, m/s maximum velocity), the same
* user-adjustable setting the map's violation highlighting uses -- into the
* model's native units, so a report or table working in raw EPANET units can
* compare against the SAME standard instead of defining its own separate one
* (e.g. a map default of 3 m/s vs a report's independently hardcoded 2.5 m/s).
*/
export function resolveNativeThresholds(
minPressureThresholdMeters: number,
maxVelocityThresholdMps: number,
reportedPressureUnit: string | undefined | null,
flowUnit: string,
specificGravity: number
): { minPressureNative: number; maxVelocityNative: number } {
const pressureUnit = resolvePressureUnit(reportedPressureUnit, flowUnit);
return {
minPressureNative: metersToPressure(minPressureThresholdMeters, pressureUnit, specificGravity),
maxVelocityNative: metersPerSecondToVelocity(maxVelocityThresholdMps, flowUnit),
};
}
export const KOREAN_DISPLAY_UNITS = {
pressure: 'm',
head: 'm',
flow: 'm³/day',
demand: 'm³/day',
velocity: 'm/s',
length: 'm',
diameter: 'mm',
} as const;
export interface DisplayUnits {
readonly pressure: string;
readonly head: string;
readonly flow: string;
readonly demand: string;
readonly velocity: string;
readonly length: string;
readonly diameter: string;
}
const PRESSURE_UNIT_LABELS: Record<PressureUnit, string> = {
PSI: 'psi',
KPA: 'kPa',
M: 'm',
BAR: 'bar',
FT: 'ft',
};
/**
* `actualPressureUnit`, when given, is the engine's real reported pressure
* unit (SimulationResult.units.pressure, via resolvePressureUnit) and wins
* over the flow-unit-based guess for 'original' mode's `pressure` label --
* EPANET 2.2+ allows the pressure unit to be set independently of the flow
* unit, so the guess is only a placeholder for when no result exists yet.
*/
export type DisplayUnitLabels = ReturnType<typeof getDisplayUnits>;
export function getDisplayUnits(
flowUnit: string,
mode: DisplayUnitMode = 'korean',
actualPressureUnit?: string
) {
if (mode === 'korean') return KOREAN_DISPLAY_UNITS;
const isUs = getUnitSystem(flowUnit) === 'US';
const resolved = actualPressureUnit?.trim().toUpperCase();
const pressure =
resolved && PRESSURE_UNITS.includes(resolved as PressureUnit)
? PRESSURE_UNIT_LABELS[resolved as PressureUnit]
: isUs
? 'psi'
: 'm';
return {
pressure,
head: isUs ? 'ft' : 'm',
flow: flowUnit.toUpperCase(),
demand: flowUnit.toUpperCase(),
velocity: isUs ? 'ft/s' : 'm/s',
length: isUs ? 'ft' : 'm',
diameter: isUs ? 'in' : 'mm',
} as const;
}
/** Display-only conversion. The underlying EPANET model/result values stay unchanged. */
export function flowToM3Day(value: number, flowUnit: string): number {
const lpsFactor = FLOW_TO_LPS[flowUnit.toUpperCase().trim()] ?? 1;
return value * lpsFactor * 86.4;
}
/**
* Unit (hydraulic-gradient) headloss, in per-mille -- loss per 1000
* length-units -- from a TOTAL headloss and a pipe length expressed in the
* SAME unit (both raw model units, or both already display-converted).
* EN_HEADLOSS is the total head difference across the whole pipe, so it
* scales with pipe length; unlike that total, this ratio is length-
* independent and is the correct metric for comparing pipes of different
* lengths (e.g. for a map color scale or a replacement-priority ranking).
*/
export function computeUnitHeadloss(totalHeadloss: number, length: number): number {
return length > 0 ? (1000 * totalHeadloss) / length : 0;
}
/**
* Converts EPANET-reported pressure to metres of fluid head for presentation.
*
* `pressureUnit` must be the engine's ACTUAL pressure-report unit (psi/kPa/m/
* bar/ft, from SimulationResult.units.pressure via resolvePressureUnit) --
* never a flow unit. EPANET 2.2+ lets pressure units be chosen independently
* of flow units, so a flow-unit-based guess (e.g. "US flow unit -> psi") can
* be wrong whenever the project overrides EN_PRESSUNITS; callers without a
* live result yet should go through resolvePressureUnit's flow-unit fallback
* explicitly rather than passing a flow unit in here.
*/
export function pressureToMeters(value: number, pressureUnit: string, specificGravity = 1): number {
return pressureUnitToMeters(value, pressureUnit, specificGravity);
}
export function metersToPressure(value: number, pressureUnit: string, specificGravity = 1): number {
return metersToPressureUnit(value, pressureUnit, specificGravity);
}
/** EPANET reports head/elevation in feet for US flow units and metres for SI units. */
export function headToMeters(value: number, flowUnit: string): number {
return getUnitSystem(flowUnit) === 'US' ? value * M_PER_FT : value;
}
export function lengthToMeters(value: number, flowUnit: string): number {
return getUnitSystem(flowUnit) === 'US' ? value * M_PER_FT : value;
}
export function metersPerSecondToVelocity(value: number, flowUnit: string): number {
return getUnitSystem(flowUnit) === 'US' ? value * FT_PER_M : value;
}
export function diameterToMillimeters(value: number, flowUnit: string): number {
return getUnitSystem(flowUnit) === 'US' ? value * MM_PER_INCH : value;
}
export function velocityToMetersPerSecond(value: number, flowUnit: string): number {
return getUnitSystem(flowUnit) === 'US' ? value * M_PER_FT : value;
}
export function formatDisplayNumber(value: number, maximumFractionDigits = 2): string {
return new Intl.NumberFormat('ko-KR', {
maximumFractionDigits,
}).format(value);
}
export function m3DayToFlow(value: number, flowUnit: string): number {
const lpsFactor = FLOW_TO_LPS[flowUnit.toUpperCase().trim()] ?? 1;
return value / (lpsFactor * 86.4);
}
export function metersToLength(value: number, flowUnit: string): number {
return getUnitSystem(flowUnit) === 'US' ? value * FT_PER_M : value;
}
export function millimetersToDiameter(value: number, flowUnit: string): number {
return getUnitSystem(flowUnit) === 'US' ? value * INCH_PER_MM : value;
}
/**
* Single source of model<->display conversions for one screen. Every view used
* to re-implement `displayUnitMode === 'korean' ? xToMeters(v) : v` (41 copies
* across App, Map, tables); in 'original' mode all functions are identity.
*/
export interface DisplayConverter {
flow: (v: number) => number;
pressure: (v: number) => number;
head: (v: number) => number;
length: (v: number) => number;
diameter: (v: number) => number;
velocity: (v: number) => number;
toFlow: (v: number) => number;
toPressure: (v: number) => number;
toLength: (v: number) => number;
toDiameter: (v: number) => number;
toVelocity: (v: number) => number;
}
export function createDisplayConverter(
mode: DisplayUnitMode,
flowUnit: string,
pressureUnit?: string,
specificGravity?: number
): DisplayConverter {
const korean = mode === 'korean';
const id = (v: number) => v;
const pUnit = pressureUnit || (getUnitSystem(flowUnit) === 'US' ? 'psi' : 'm');
const sg = specificGravity ?? 1;
if (!korean) {
return { flow: id, pressure: id, head: id, length: id, diameter: id, velocity: id, toFlow: id, toPressure: id, toLength: id, toDiameter: id, toVelocity: id };
}
return {
flow: (v) => flowToM3Day(v, flowUnit),
pressure: (v) => pressureToMeters(v, pUnit, sg),
head: (v) => headToMeters(v, flowUnit),
length: (v) => lengthToMeters(v, flowUnit),
diameter: (v) => diameterToMillimeters(v, flowUnit),
velocity: (v) => velocityToMetersPerSecond(v, flowUnit),
toFlow: (v) => m3DayToFlow(v, flowUnit),
toPressure: (v) => metersToPressure(v, pUnit, sg),
toLength: (v) => metersToLength(v, flowUnit),
toDiameter: (v) => millimetersToDiameter(v, flowUnit),
toVelocity: (v) => metersPerSecondToVelocity(v, flowUnit),
};
}
/**
* Rounds away floating-point residue from unit conversion for display
* (e.g. 216.40800000000002 -> 216.408) without truncating real precision.
*/
export function formatEditableNumber(value: number, significantDigits = 10): string {
if (!Number.isFinite(value)) return '';
return String(Number(value.toPrecision(significantDigits)));
}
export interface UnitConversionCheck {
canConvert: boolean;
reason?: string;
sourceSystem: UnitSystem;
targetSystem: UnitSystem;
sourceFlowUnit: string;
targetFlowUnit: string;
}
function sectionHasData(model: NetworkModel, secName: string): boolean {
const range = model.sectionIndices[secName];
if (!range) return false;
const lines = model.rawLines.slice(range.start + 1, range.end + 1);
return lines.some((l) => {
const t = l.trim();
return t && !t.startsWith(';');
});
}
/**
* Verifies if the network model can be safely converted to the target unit.
* Never allows a partial conversion: any construct this module cannot itself
* rewrite (because GISPipe does not parse it into the model) must block the
* entire conversion with an explicit reason, rather than silently leaving it
* un-converted while other values are scaled.
*/
export function checkUnitConversion(
model: NetworkModel,
targetFlowUnit: string
): UnitConversionCheck {
const currentFlowUnit = model.options?.units?.toUpperCase() || 'GPM';
const targetU = targetFlowUnit.toUpperCase().trim();
const sourceSystem = getUnitSystem(currentFlowUnit);
const targetSystem = getUnitSystem(targetU);
// Check if target is a valid EPANET flow unit
if (!FLOW_TO_LPS[targetU]) {
return {
canConvert: false,
reason: `지원하지 않는 유량 단위입니다: ${targetFlowUnit}`,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
const fromLpsFactor = FLOW_TO_LPS[currentFlowUnit] || 1.0;
const toLpsFactor = FLOW_TO_LPS[targetU] || 1.0;
const flowChanges = Math.abs(fromLpsFactor / toLpsFactor - 1.0) > 1e-9;
const isSystemSwitch = sourceSystem !== targetSystem;
// If switching systems (US <-> SI), check for unit-dependent unmodeled constructs
// that only matter across a dimensional system change (power units, reaction
// kinetics constants defined per the project's unit system).
if (isSystemSwitch) {
// Check for POWER pumps (hp vs kW)
for (const pump of Object.values(model.pumps)) {
if (pump.parameters.toUpperCase().includes('POWER')) {
return {
canConvert: false,
reason: `펌프 ${pump.id}에 동력(POWER) 설정이 포함되어 있어 수동 변환이 필요합니다 (1 hp = 0.7457 kW).`,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
}
if (sectionHasData(model, 'REACTIONS')) {
return {
canConvert: false,
reason: `[REACTIONS] 섹션의 단위 의존 계수가 포함되어 있어 자동 변환이 제한됩니다.`,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
}
// [CURVES] (pump/volume curve X/Y data) and [EMITTERS] (flow-per-pressure^gamma
// coefficients) are flow-unit dependent even WITHIN the same SI/US system (e.g.
// LPS -> CMH), not only across a US<->SI switch. Block on any flow-scale change.
if (flowChanges && sectionHasData(model, 'CURVES')) {
return {
canConvert: false,
reason: `[CURVES] 섹션(펌프/용적 곡선)이 포함되어 있습니다. 단위계 변환 시 곡선 X/Y 단위 왜곡을 방지하기 위해 자동 변환이 제한됩니다.`,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
// Structured emitters (model.extras) scale with flow inside one unit system;
// across SI<->US the pressure^exponent term changes too, so that stays blocked.
if (flowChanges && sectionHasData(model, 'EMITTERS') && (!model.extras || isSystemSwitch)) {
return {
canConvert: false,
reason: `[EMITTERS] 섹션의 유량 의존 계수가 포함되어 있어 자동 변환이 제한됩니다.`,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
// [DEMANDS] holds per-category flow values that this module does not parse
// or rewrite. Any flow-unit change (even within the same SI/US system, e.g.
// LPS -> CMH) would silently desynchronize those values from the converted
// network, so the entire conversion is blocked rather than run partially.
if (flowChanges && sectionHasData(model, 'DEMANDS') && !model.extras) {
return {
canConvert: false,
reason: `[DEMANDS] 섹션(다중 수요 패턴)의 유량 값이 파싱·변환되지 않으므로, 데이터 손실을 방지하기 위해 자동 변환이 중단되었습니다. INP를 직접 확인해 주세요.`,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
// [RULES] (rule-based controls) may reference pressure/level/flow/time
// thresholds. This module does not parse rule bodies, so any conversion
// that changes flow scale or dimensional units is blocked entirely.
if ((flowChanges || isSystemSwitch) && sectionHasData(model, 'RULES')) {
return {
canConvert: false,
reason: `[RULES] 섹션(규칙 기반 제어)의 조건 값이 파싱·변환되지 않으므로, 데이터 손실을 방지하기 위해 자동 변환이 중단되었습니다. INP를 직접 확인해 주세요.`,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
// [STATUS] numeric (non OPEN/CLOSED/ACTIVE) entries represent unit-dependent
// initial valve/pump settings that this module does not rewrite.
if ((flowChanges || isSystemSwitch) && (model.extras || model.sectionIndices['STATUS'])) {
const range = model.sectionIndices['STATUS'];
const lines = model.extras
? Object.entries(model.extras.statusOverrides).map(([id, v]) => `${id} ${v}`)
: model.rawLines.slice(range.start + 1, range.end + 1);
for (const l of lines) {
const t = l.split(';')[0].trim();
if (!t) continue;
const tokens = t.split(/\s+/);
if (tokens.length >= 2) {
const settingToken = tokens[1].toUpperCase();
if (!['OPEN', 'CLOSED', 'ACTIVE'].includes(settingToken) && !isNaN(parseFloat(settingToken))) {
return {
canConvert: false,
reason: `[STATUS] 섹션에 숫자형 초기 설정값(${tokens[0]} ${tokens[1]})이 포함되어 있어 안전한 자동 단위 변환이 제한됩니다.`,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
}
}
}
// If there are numeric control settings (e.g. FCV target flow, PRV target pressure, pump speed ratio),
// any dimensional system switch or flow-scale change requires blocking unless safely modeled and rewritten.
// Numeric control settings on flow-controlled links (FCV) or pressure-controlled links are unit-dependent.
for (const ctrl of model.controls) {
if (ctrl.status !== 'OPEN' && ctrl.status !== 'CLOSED') {
const numVal = parseFloat(ctrl.status);
if (!isNaN(numVal) && (flowChanges || isSystemSwitch)) {
return {
canConvert: false,
reason: `링크 ${ctrl.linkId}의 단위 의존 숫자형 제어 설정(${ctrl.status})이 포함되어 있어 안전한 자동 단위 변환이 제한됩니다. INP를 직접 확인해 주세요.`,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
}
}
return {
canConvert: true,
sourceSystem,
targetSystem,
sourceFlowUnit: currentFlowUnit,
targetFlowUnit: targetU,
};
}
/**
* Converts a NetworkModel between unit systems (SI <-> US) or within the same system (e.g. LPS <-> CMH).
* Dimensionless parameters (C-factor, minor loss K, pattern multipliers) are strictly conserved.
* GIS coordinates (COORDINATES) are never modified.
* Pressure-quantity conversions (PDA minimum/required pressure, PRV/PSV/PBV settings,
* junction pressure control thresholds) honor the model's Specific Gravity option.
*/
export function convertNetworkUnits(
model: NetworkModel,
targetFlowUnit: string
): { model: NetworkModel; log: string[] } {
// Refuse here too, not only in the UI: curves, emitters, rules etc. are not
// rewritten below, so converting anyway would silently corrupt the model
// (e.g. a GPM pump curve read as LPS shifts pressures by metres).
const check = checkUnitConversion(model, targetFlowUnit);
if (!check.canConvert) {
throw new Error(`단위 변환 불가: ${check.reason}`);
}
const currentFlowUnit = model.options?.units?.toUpperCase() || 'GPM';
const targetU = targetFlowUnit.toUpperCase().trim();
const sourceSystem = getUnitSystem(currentFlowUnit);
const targetSystem = getUnitSystem(targetU);
const specificGravity = model.options?.specificGravity ?? 1.0;
const log: string[] = [];
log.push(`[단위 변환 시작] ${currentFlowUnit} (${sourceSystem}) -> ${targetU} (${targetSystem})`);
// 1. Flow conversion multiplier
const fromLpsFactor = FLOW_TO_LPS[currentFlowUnit] || 1.0;
const toLpsFactor = FLOW_TO_LPS[targetU] || 1.0;
const flowMultiplier = fromLpsFactor / toLpsFactor;
// 2. Dimensional multipliers if switching between SI and US
const isSystemSwitch = sourceSystem !== targetSystem;
const lengthMultiplier = isSystemSwitch
? sourceSystem === 'US'
? M_PER_FT
: FT_PER_M
: 1.0;
const diameterMultiplier = isSystemSwitch
? sourceSystem === 'US'
? MM_PER_INCH
: INCH_PER_MM
: 1.0;
// 3. Convert Nodes
const updatedNodes: Record<string, InpNode> = {};
for (const [id, node] of Object.entries(model.nodes)) {
const newElevation = isSystemSwitch
? Math.round(node.elevation * lengthMultiplier * 100) / 100
: node.elevation;
const newDemand = node.demand !== undefined
? Math.round(node.demand * flowMultiplier * 1000) / 1000
: undefined;
const newHead = node.head !== undefined && isSystemSwitch
? Math.round(node.head * lengthMultiplier * 100) / 100
: node.head;
// Tank dimensions
const newInitL = node.initLevel !== undefined && isSystemSwitch
? Math.round(node.initLevel * lengthMultiplier * 100) / 100
: node.initLevel;
const newMinL = node.minLevel !== undefined && isSystemSwitch
? Math.round(node.minLevel * lengthMultiplier * 100) / 100
: node.minLevel;
const newMaxL = node.maxLevel !== undefined && isSystemSwitch
? Math.round(node.maxLevel * lengthMultiplier * 100) / 100
: node.maxLevel;
const newDiam = node.diameter !== undefined && isSystemSwitch
? Math.round(node.diameter * lengthMultiplier * 100) / 100
: node.diameter;
// Tank volume (cubic meters <-> cubic feet: 1 m^3 = 35.3147 cu ft)
const volMultiplier = isSystemSwitch
? sourceSystem === 'US'
? 0.0283168466
: 35.3146667
: 1.0;
const newMinVol = node.minVol !== undefined && isSystemSwitch
? Math.round(node.minVol * volMultiplier * 10) / 10
: node.minVol;
updatedNodes[id] = {
...node,
elevation: newElevation,
demand: newDemand,
head: newHead,
initLevel: newInitL,
minLevel: newMinL,
maxLevel: newMaxL,
diameter: newDiam,
minVol: newMinVol,
// GIS coordinates (x, y, rawX, rawY) are strictly preserved!
};
}
// 4. Convert Pipes
const isDarcyWeisbach = model.options?.headloss?.toUpperCase().includes('D-W');
const updatedPipes: Record<string, InpPipe> = {};
for (const [id, pipe] of Object.entries(model.pipes)) {
const newLength = isSystemSwitch
? Math.round(pipe.length * lengthMultiplier * 100) / 100
: pipe.length;
const newDiameter = isSystemSwitch
? Math.round(pipe.diameter * diameterMultiplier * 100) / 100
: pipe.diameter;
// Roughness: H-W C and C-M n are dimensionless.
// D-W roughness is millifeet (US) <-> mm (SI): 1 mm = 3.28084 millifeet.
let newRoughness = pipe.roughness;
if (isDarcyWeisbach && isSystemSwitch) {
newRoughness =
sourceSystem === 'US'
? Math.round((pipe.roughness * 0.3048) * 1000) / 1000
: Math.round((pipe.roughness * 3.28084) * 1000) / 1000;
}
updatedPipes[id] = {
...pipe,
length: newLength,
diameter: newDiameter,
roughness: newRoughness,
// Minor loss K is dimensionless, conserved!
};
}
// 5. Convert Valves
const updatedValves = { ...(model.valves || {}) };
for (const [id, valve] of Object.entries(updatedValves)) {
const newDiam = isSystemSwitch
? Math.round(valve.diameter * diameterMultiplier * 100) / 100
: valve.diameter;
let newSetting = valve.setting;
// If valve setting is pressure/head (PRV, PSV, PBV):
if (typeof valve.setting === 'number') {
const vType = valve.valveType.toUpperCase();
if (['PRV', 'PSV', 'PBV'].includes(vType) && isSystemSwitch) {
newSetting =
sourceSystem === 'US'
? Math.round(pressureUStoSI(valve.setting, specificGravity) * 100) / 100
: Math.round(pressureSItoUS(valve.setting, specificGravity) * 100) / 100;
} else if (vType === 'FCV' && Math.abs(flowMultiplier - 1.0) > 1e-6) {
newSetting = Math.round(valve.setting * flowMultiplier * 1000) / 1000;
}
}
updatedValves[id] = {
...valve,
diameter: newDiam,
setting: newSetting,
};
}
// 6. Convert Controls threshold values
const updatedControls = model.controls.map((ctrl) => {
if (ctrl.value !== undefined && isSystemSwitch) {
// Threshold is level/head/pressure
const targetNode = model.nodes[ctrl.nodeId || ''];
const isTankOrReservoir =
targetNode && (targetNode.type === 'TANK' || targetNode.type === 'RESERVOIR');
// Tank/reservoir threshold is a level (feet <-> meters).
// Junction threshold is a pressure, which depends on the fluid's specific gravity.
const convertedVal = isTankOrReservoir
? Math.round(ctrl.value * lengthMultiplier * 100) / 100
: sourceSystem === 'US'
? Math.round(pressureUStoSI(ctrl.value, specificGravity) * 100) / 100
: Math.round(pressureSItoUS(ctrl.value, specificGravity) * 100) / 100;
return {
...ctrl,
value: convertedVal,
rawText: `LINK ${ctrl.linkId} ${ctrl.status} IF NODE ${ctrl.nodeId} ${ctrl.condition} ${convertedVal}`,
};
}
return ctrl;
});
// 7. Update Options
const updatedOptions: InpOptions = {
...model.options,
units: targetU,
rawEntries: model.options.rawEntries.map((e) => {
if (e.key.toUpperCase() === 'UNITS') {
return { key: e.key, value: targetU };
}
return e;
}),
};
// Convert PDA pressure options if switching systems (specific-gravity-aware)
if (isSystemSwitch) {
if (updatedOptions.minimumPressure !== undefined) {
updatedOptions.minimumPressure =
sourceSystem === 'US'
? Math.round(pressureUStoSI(updatedOptions.minimumPressure, specificGravity) * 100) / 100
: Math.round(pressureSItoUS(updatedOptions.minimumPressure, specificGravity) * 100) / 100;
}
if (updatedOptions.requiredPressure !== undefined) {
updatedOptions.requiredPressure =
sourceSystem === 'US'
? Math.round(pressureUStoSI(updatedOptions.requiredPressure, specificGravity) * 100) / 100
: Math.round(pressureSItoUS(updatedOptions.requiredPressure, specificGravity) * 100) / 100;
}
}
log.push(
`[변환 완료] 노드 ${Object.keys(updatedNodes).length}개, 관로 ${
Object.keys(updatedPipes).length
}개 물리량 변환 완료. (GIS 좌표 보존됨, 비중 SG=${specificGravity})`
);
return {
model: {
...model,
nodes: updatedNodes,
pipes: updatedPipes,
valves: updatedValves,
controls: updatedControls,
options: updatedOptions,
extras: scaleExtrasFlow(model.extras, flowMultiplier),
},
log,
};
}
+51
View File
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useRef } from 'react';
import { analyzeOnServer } from './api';
import { resolveDualEngineOutcome } from './resultSync';
import type { SimulationResult } from './types';
export type DualEngineOutcome = ReturnType<typeof resolveDualEngineOutcome>;
/**
* Owns the WASM worker and runs one INP text through both engines.
*
* The server (Native EPANET DLL) and WASM engines are independent: a failure
* in one must never withhold a result the other engine produced successfully.
* Cross-validation (Native-vs-WASM comparison) only happens when BOTH succeed;
* either one alone still yields a usable single-engine result.
*
* Pure with respect to React state (no setState here), so it serves both the
* live analysis and the batch run over every scenario (P2-4), which must
* analyze each scenario's INP independently of the active one.
*/
export function useDualEngine(): (fileStr: string) => Promise<DualEngineOutcome> {
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
workerRef.current = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
return () => {
workerRef.current?.terminate();
workerRef.current = null;
};
}, []);
return useCallback(async (fileStr: string): Promise<DualEngineOutcome> => {
const serverPromise = analyzeOnServer(fileStr);
const wasmPromise = new Promise<SimulationResult>((resolve, reject) => {
const worker = workerRef.current;
if (!worker) return reject(new Error('워커가 초기화되지 않았습니다.'));
const id = Math.random().toString();
const handler = (e: MessageEvent<{ id: string; error?: string; result?: SimulationResult }>) => {
if (e.data.id !== id) return;
worker.removeEventListener('message', handler);
if (e.data.error) reject(new Error(e.data.error));
else if (e.data.result) resolve(e.data.result);
};
worker.addEventListener('message', handler);
worker.postMessage({ id, fileStr });
});
const [serverOutcome, wasmOutcome] = await Promise.allSettled([serverPromise, wasmPromise]);
return resolveDualEngineOutcome(serverOutcome, wasmOutcome);
}, []);
}
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more