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