- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
270 lines
16 KiB
TypeScript
270 lines
16 KiB
TypeScript
// "추가 속성 (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>
|
|
);
|
|
};
|