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 = { 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 }; }