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) => void; onUpdateNodes: (nodeIds: string[], updater: (node: InpNode) => Partial) => void; onMoveNodes: (nodeIds: string[], dx: number, dy: number) => void; onDeleteSelected: () => void; onClearSelection: () => void; onSelectTarget: (target: SelectionTarget) => void; onCopyClipboard?: () => void; } export const BatchPropertyEditor: React.FC = ({ 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(''); const [batchRoughness, setBatchRoughness] = useState(''); const [batchStatus, setBatchStatus] = useState('OPEN'); const [batchMinorLoss, setBatchMinorLoss] = useState(''); const [batchTag, setBatchTag] = useState(''); const [batchDesc, setBatchDesc] = useState(''); // Node batch state const [batchElevMode, setBatchElevMode] = useState<'set' | 'offset'>('offset'); const [batchElevValue, setBatchElevValue] = useState(''); const [batchDemandMode, setBatchDemandMode] = useState<'set' | 'mult'>('mult'); const [batchDemandValue, setBatchDemandValue] = useState('1.0'); const [batchPattern, setBatchPattern] = useState(''); // Move offset state const [moveDx, setMoveDx] = useState('0'); const [moveDy, setMoveDy] = useState('0'); // Success flash messages const [flashMsg, setFlashMsg] = useState(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 = {}; 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 (
{/* Header with Element Counts & Global Multi Actions */}
다중 선택 일괄 편집기 총 {selectedNodeIds.length + selectedPipeIds.length}개
{onCopyClipboard && ( )}
{/* Toast Flash Notice */} {flashMsg && (
{flashMsg}
)} {/* Selected Items ID Chips Preview */}
{selectedNodeIds.map((id) => ( 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} ))} {selectedPipeIds.map((id) => ( 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} ))}
{/* ======================================================== */} {/* 1. PIPE BATCH EDIT CARD */} {/* ======================================================== */} {selectedPipeIds.length > 0 && (
관로 일괄 속성 변경 ({selectedPipeIds.length}개 관로)
{/* Pipe Diameter */}
setBatchDiameter(e.target.value)} placeholder="예: 150" style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }} />
{[80, 100, 150, 200, 250, 300, 400, 500].map((d) => ( ))}
{/* Roughness Presets */}
setBatchRoughness(e.target.value)} placeholder="예: 130" style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }} />
{[ { name: '주철관 (100)', val: 100 }, { name: '강관 (110)', val: 110 }, { name: '닥타일주철 (130)', val: 130 }, { name: 'PVC/PE (140)', val: 140 }, ].map((preset) => ( ))}
{/* Pipe Status */}
{/* Minor Loss & Tag */}
setBatchMinorLoss(e.target.value)} placeholder="0.0" style={{ width: '100%', padding: '2px 4px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '3px' }} />
setBatchTag(e.target.value)} placeholder="배수관로" style={{ width: '100%', padding: '2px 4px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '3px' }} />
)} {/* ======================================================== */} {/* 2. NODE BATCH EDIT CARD */} {/* ======================================================== */} {selectedNodeIds.length > 0 && (
절점 일괄 속성 변경 ({selectedNodeIds.length}개 절점)
{/* Node Elevation */}
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' }} />
{/* Node Demand */}
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' }} />
{/* Demand Pattern */} {patterns.length > 0 && (
)} {/* CAD Move Offset */}
setMoveDx(e.target.value)} placeholder="ΔX (m)" style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }} />
setMoveDy(e.target.value)} placeholder="ΔY (m)" style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }} />
)}
); };