SPipe 데모 v2 (2026-10-30) 서버 배포본

- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
bjkim1 committed 2026-10-30 11:00:46 +09:00
commit 21ebb16fb7
181 files changed
+57207

No files matched your search

+734
View File
@@ -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>
);
};