// "추가 속성 (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; type DisplayUnits = ReturnType; 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 = { CONCEN: '농도', MASS: '질량 부스터', SETPOINT: '설정점 부스터', FLOWPACED: '유량 비례 부스터' }; const MIX_LABEL: Record = { MIXED: '완전 혼합', '2COMP': '2구획 혼합', FIFO: 'FIFO 플러그', LIFO: 'LIFO 플러그' }; export const ElementExtrasEditor: React.FC = (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 (
{open && ( <> {node?.type === 'JUNCTION' && } {node?.type === 'JUNCTION' && } {node?.type === 'TANK' && } {node && } {pump && } {(pipe || valve || pump) && } )}
); }; const DemandCategories: React.FC = ({ 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 (
수요량 범주 ({displayUnits.flow}) 범주가 둘 이상이면 [DEMANDS]로 저장되고, 첫 범주가 기본 수요량이 됩니다. 합계 {Math.round(total * 1000) / 1000} {rows.map((r, i) => ( ))}
수요량패턴범주
setRows(rows.map((x, k) => (k === i ? { ...x, demand: e.target.value } : x)))} style={{ ...inp, width: 70 }} aria-label={`${i + 1}번째 수요량`} /> setRows(rows.map((x, k) => (k === i ? { ...x, category: e.target.value } : x)))} style={{ ...inp, width: 80 }} placeholder="예: 가정용" aria-label={`${i + 1}번째 범주`} />
); }; const EmitterBox: React.FC = ({ 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 (
이미터 (누수·스프링클러)
유출 계수
setV(e.target.value)} placeholder="없음" style={inp} />
원본 모델 단위 ({model.options.units}, 지수 {model.options.emitterExponent ?? 0.5}). 여러 절점에 누수량을 나눌 때는 수요량 테이블의 "누수(이미터) 배분"을 쓰세요.
); }; const QualityBox: React.FC = ({ 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(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 (
수질
초기 수질 setInit(e.target.value)} placeholder="0" style={inp} />
수원 수질 형식
{type && ( <>
수원 농도/질량 setStrength(e.target.value)} style={inp} />
수원 패턴
)} 모델 설정의 수질 모드가 "화학물질"일 때 해석에 쓰입니다. 질량 부스터는 질량 유량(mg/min)입니다.
); }; const TankBox: React.FC = ({ model, target, onApply }) => { const mix = model.extras?.mixing[target.id]; const k = model.extras?.tankReactions[target.id]; const [mode, setMode] = useState(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 (
탱크 혼합·반응
혼합 모델
{mode === '2COMP' &&
혼합 구획 비율 setFrac(e.target.value)} style={inp} />
}
반응 계수 setReact(e.target.value)} placeholder="전역값 사용" style={inp} />
); }; const PumpEnergyBox: React.FC = ({ 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 (
펌프 에너지
효율 곡선
전력 단가 (/kWh) setPrice(e.target.value)} placeholder="전역 단가" style={inp} />
단가 패턴
효율 곡선은 "곡선 편집"의 효율 탭에서 만듭니다.
); }; const StatusBox: React.FC = ({ 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 (
초기 상태 재정의 [STATUS]
상태
{isNumeric && setV(e.target.value)} style={{ ...inp, width: 60 }} />}
[STATUS] 값은 관로·밸브·펌프 자체의 상태보다 우선합니다.
); };