SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
commit
21ebb16fb7
181 files changed
+57207
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)' }}>
|
||||
소블록이 지정된 절점이 없습니다. 소블록 폴리곤 레이어를 추가하거나(프로젝트 > 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>
|
||||
);
|
||||
};
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
실측값(결과 > 실측 보정에서 불러온 파일)에 가장 잘 맞도록 관로 그룹별 조도 배율 또는 관벽 반응계수를 최소제곱법으로 찾습니다.
|
||||
실측 지점이 그룹 수보다 충분히 많아야 결과를 믿을 수 있습니다.
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }} /> 피크 (>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 }} /> 야간/저수요 (<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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 (기준: ≥ {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 (기준: ≤ {maxVelocityThreshold} m/s)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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': '경계 모두 폐쇄 (공급 확인)',
|
||||
};
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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[];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 검증에 실패했습니다. 파일의 문자 인코딩을 확인하세요.',
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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])}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 ?? {} };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user