Files
SPipe/src/attributeTableValidation.ts
T
bjkim1 14f07181db 예전 프로그램 이름 표기 정리: 실행 파일 이름을 start-spipe.bat, stop-spipe.bat로 변경
- README, 예제 설명, 코드 주석, CSS 머리말, 시험 임시 폴더 접두어의 예전 이름 정리
- 브라우저 저장 키·세션 DB 이름은 저장된 설정 보존을 위해 유지
2026-10-30 11:07:14 +09:00

214 lines
13 KiB
TypeScript

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
// SPipe 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 };
}