feat(B05): 사이드 패널 입력 로직·레이아웃 개편 (PLAN 2-1~2-5)
- 2-1 선택 해제 복구: 목록 재클릭 = 해제(계곡 통과 시설 포함 — 기존엔 시설이 항상 재선택됨). 해제는 리셋 버튼과 같은 전체 초기화(resetForm — 구조물군·종류· 측점·옵션)이며, 시설 해제는 onPipeSelect(null)로 그래프·3D·배수유역도까지 푼다. - 2-2 입력 순서 가이드: 측점(점형 기준, 구간형 시작·종료) 미입력이면 옵션 칸·부속 서브폼 잠금(disabled+inert+is-locked 흐림). 입력 즉시 해제. - 2-3 월류폭·월류 높이: 기본값 세월교 10m·물넘이 5m. 월류 높이 칸 신설 — fordSection(Manning 역산) 필요 수심을 자동 기입, 계산값 미만 입력은 되돌림. 저장 키 ford_height_m(0.01m 정밀도). - 2-3b A군 임시 배치: 기준 측점 입력 순간 관 지점에 임시로 넣어 세부유역·설계유량 미리보기. [추가]로 확정, 삭제·리셋·다른 항목 선택·종류 변경·외부 해제 시 관을 물려 직전 상태 복원. - 2-3c 우클릭 추가 개편: 종단도·테이블·배수유역도 [배관/구조물 추가]가 즉시 추가 대신 사이드 폼에 종류·측점을 자동 입력(A군은 임시 배치까지). BOX암거 기본 제원은 폼에 실려 유지. - 2-4 컨테이너 병합: 등고선 간격+시작 측점·샘플링+종단 설계 기준 → 「페이지 설정」. 측점 간격 입력 삭제(STATION_INTERVAL_M=20 고정, 서버 스키마 불변). 순서 = 구조물 배치 > 페이지 설정 > 경로 계산 설정. - 2-5 경로 계산 설정 비활성화: 사용여부 미결정 — 본문 inert(조작 불가) + is-disabled 흐림. 제목·접기는 유지. 검증: tsc --noEmit 무오류, vite build 성공. 상세는 PLAN.md 2-6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -259,33 +259,66 @@ export function createFacilityOptionsForm(
|
||||
const fordCount = numberInput("1", "1", "련");
|
||||
const fordRow = grid(labeled("수량 (련)", fordCount));
|
||||
|
||||
// ── 물넘이·세월교 개략 단면 — 지정할 옵션이 거의 없으니 계산값이라도 보여 준다
|
||||
// (2026-08-17 사용자 지시). 폭만 받고 설계유량으로 수심·통수능을 되짚는다.
|
||||
// ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시).
|
||||
// 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음).
|
||||
// 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는
|
||||
// 있으나 계산값(필요 최소 수심) 미만은 되돌린다.
|
||||
const fordWidth = numberInput("0.1");
|
||||
const fordWidthRow = grid(labeled("월류 폭 (m)", fordWidth));
|
||||
const fordHeight = numberInput("0.01");
|
||||
const fordWidthRow = grid(
|
||||
labeled("월류 폭 (m)", fordWidth),
|
||||
labeled("월류 높이 (m)", fordHeight),
|
||||
);
|
||||
const fordSummary = document.createElement("p");
|
||||
fordSummary.className = "b05-drainage__facility-note";
|
||||
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
|
||||
let designFlowM3s: number | null = null;
|
||||
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
|
||||
let fordMinDepthM: number | null = null;
|
||||
|
||||
function syncFordSummary(): void {
|
||||
const section =
|
||||
designFlowM3s !== null
|
||||
? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value))
|
||||
: null;
|
||||
fordMinDepthM = section ? section.depthM : null;
|
||||
if (section) {
|
||||
// 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나 그보다 작으면 계산값으로 채운다.
|
||||
const min = Number(section.depthM.toFixed(2));
|
||||
const current = Number.parseFloat(fordHeight.value);
|
||||
if (!Number.isFinite(current) || current < min) fordHeight.value = min.toFixed(2);
|
||||
}
|
||||
if (designFlowM3s === null) {
|
||||
fordSummary.textContent =
|
||||
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
|
||||
return;
|
||||
}
|
||||
const section = fordSection(designFlowM3s, Number.parseFloat(fordWidth.value));
|
||||
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
|
||||
if (!section) {
|
||||
fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
|
||||
return;
|
||||
}
|
||||
// 필요 최소 수심이다 — 실제 설계 수심은 여기에 여유를 더해 정한다.
|
||||
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
|
||||
fordSummary.textContent =
|
||||
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
|
||||
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s`;
|
||||
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
|
||||
`월류 높이는 필요 수심 이상만 입력됩니다.`;
|
||||
}
|
||||
|
||||
// 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다.
|
||||
fordHeight.addEventListener("change", () => {
|
||||
if (fordMinDepthM !== null) {
|
||||
const min = Number(fordMinDepthM.toFixed(2));
|
||||
const value = Number.parseFloat(fordHeight.value);
|
||||
if (!Number.isFinite(value) || value < min) {
|
||||
fordHeight.value = min.toFixed(2);
|
||||
fordHeight.classList.add("is-invalid");
|
||||
window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900);
|
||||
}
|
||||
}
|
||||
emit();
|
||||
});
|
||||
|
||||
root.append(
|
||||
pipeRow,
|
||||
inletGroup.root,
|
||||
@@ -337,6 +370,12 @@ export function createFacilityOptionsForm(
|
||||
}
|
||||
}
|
||||
|
||||
/** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */
|
||||
function putFordHeight(options: Record<string, string | number>): void {
|
||||
const value = Number.parseFloat(fordHeight.value);
|
||||
if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
syncVisibility();
|
||||
callbacks.onChange?.();
|
||||
@@ -404,6 +443,11 @@ export function createFacilityOptionsForm(
|
||||
wingOutFields.write(options);
|
||||
fordCount.value = isFord ? text("pipe_count") : "";
|
||||
fordWidth.value = text("ford_width_m");
|
||||
fordHeight.value = text("ford_height_m");
|
||||
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정).
|
||||
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
|
||||
fordWidth.value = facility === "ford_bridge" ? "10" : "5";
|
||||
}
|
||||
syncVisibility();
|
||||
},
|
||||
readOptions() {
|
||||
@@ -430,12 +474,14 @@ export function createFacilityOptionsForm(
|
||||
wingOutFields.read(options);
|
||||
} else if (current === "ford_pavement") {
|
||||
putNumber(options, "ford_width_m", fordWidth.value);
|
||||
putFordHeight(options);
|
||||
} else if (current === "ford_bridge") {
|
||||
options.pipe_kind = pipeMaterial.value;
|
||||
options.pipe_diameter_mm = Number(pipeDiameter.value);
|
||||
const count = Number.parseInt(fordCount.value, 10);
|
||||
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
|
||||
putNumber(options, "ford_width_m", fordWidth.value);
|
||||
putFordHeight(options);
|
||||
}
|
||||
return options;
|
||||
},
|
||||
|
||||
@@ -118,6 +118,9 @@ export interface DrainagePanelCallbacks {
|
||||
onPipeSelected?: (chainageM: number | null) => void;
|
||||
/** 배수유역도 우클릭으로 배관 외 구조물을 넣을 때(누가거리는 계획선 투영값). */
|
||||
onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void;
|
||||
/** 배수유역도 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 측점을 실어 준다
|
||||
* (2026-08-18 사용자 지시). */
|
||||
onPipeAddRequest?: (chainageM: number) => void;
|
||||
}
|
||||
|
||||
export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel {
|
||||
@@ -530,8 +533,13 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
}
|
||||
}
|
||||
|
||||
bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView, (chainage, type) =>
|
||||
callbacks.onStructureAdd?.(chainage, type),
|
||||
bindPipeContextMenu(
|
||||
viewport,
|
||||
contextMenu,
|
||||
pipeEditor,
|
||||
currentView,
|
||||
(chainage, type) => callbacks.onStructureAdd?.(chainage, type),
|
||||
callbacks.onPipeAddRequest ? (chainage) => callbacks.onPipeAddRequest?.(chainage) : undefined,
|
||||
);
|
||||
|
||||
bindDrainageInteractions({
|
||||
|
||||
@@ -200,6 +200,9 @@ export function bindPipeContextMenu(
|
||||
editor: PipeEditor,
|
||||
viewOf: () => ViewState,
|
||||
onAddStructure?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void,
|
||||
/** [배관 추가] 위임 — 즉시 추가 대신 사이드 폼에 측점을 실어 준다(2026-08-18).
|
||||
* 없으면 옛 동작(즉시 추가)으로 돌아간다. */
|
||||
onAddPipe?: (chainageM: number) => void,
|
||||
): void {
|
||||
viewport.addEventListener("contextmenu", (event) => {
|
||||
if (menu.contains(event.target)) {
|
||||
@@ -230,7 +233,14 @@ export function bindPipeContextMenu(
|
||||
// 배관 + 배관 외 구조물(기성막이/대피로/기타) 추가(2026-08-05 사용자 지시).
|
||||
const chainage = editor.chainageAt(view, x, y);
|
||||
menu.open(x, y, [
|
||||
[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)],
|
||||
[
|
||||
L("B05_Drainage_Menu_Add"),
|
||||
() => {
|
||||
// 즉시 추가 폐지(2026-08-18) — 사이드 폼 경유. 위임이 없을 때만 옛 동작.
|
||||
if (onAddPipe && chainage !== null) onAddPipe(chainage);
|
||||
else void editor.addAt(view, x, y);
|
||||
},
|
||||
],
|
||||
...(onAddStructure && chainage !== null
|
||||
? (["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [
|
||||
`${type === "기타" ? "기타 구조물" : type} 추가`,
|
||||
|
||||
@@ -111,7 +111,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
onStructureRemove: (station) => {
|
||||
if (isPipeStation(station)) profilePanel.drainage.removePipe(station.chainage_m);
|
||||
},
|
||||
onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage),
|
||||
// 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 종류·측점을 실어 주고,
|
||||
// A군 임시 배치(세부유역 미리보기)로 이어진다. [추가]로 확정(2026-08-18).
|
||||
onPipeAdd: (chainage) => panel.structures.addAt(chainage, "pipe"),
|
||||
// 우클릭 메뉴의 구 항목(기성막이·대피로·기타) — 구조물 정본 타입으로 넣는다.
|
||||
onStructureAdd: (chainage, type) =>
|
||||
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
|
||||
@@ -252,9 +254,13 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
if (selectionSyncing) return;
|
||||
selectionSyncing = true;
|
||||
try {
|
||||
const station = bridge
|
||||
.irregularStations()
|
||||
.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05);
|
||||
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
|
||||
const station =
|
||||
chainage === null
|
||||
? undefined
|
||||
: bridge
|
||||
.irregularStations()
|
||||
.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05);
|
||||
const id = station ? irregularStationId(station.id) : null;
|
||||
viewer.markers.selectStation(id);
|
||||
profilePanel.setSelectedStation(id);
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface RoutePanelValues {
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */
|
||||
const STATION_INTERVAL_M = 20;
|
||||
|
||||
const PROFILE_CRITERIA: Record<
|
||||
RoutePanelValues["gradeClass"],
|
||||
{ speed: number; grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
|
||||
@@ -77,7 +80,8 @@ interface PanelCallbacks {
|
||||
attributes: FacilityAttributes,
|
||||
) => void;
|
||||
onPipeFacilityRemove: (chainageM: number) => void;
|
||||
onPipeFacilitySelect: (chainageM: number) => void;
|
||||
/** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */
|
||||
onPipeFacilitySelect: (chainageM: number | null) => void;
|
||||
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
|
||||
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
|
||||
}
|
||||
@@ -192,8 +196,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
button("뷰 초기화", callbacks.onResetView, "glass"),
|
||||
);
|
||||
|
||||
const contour = section("등고선 간격");
|
||||
contour.root.classList.add("is-collapsed");
|
||||
// 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합).
|
||||
const contourInterval = numberField("간격 (m), 최소 0.5m", "1");
|
||||
const contourRow = document.createElement("div");
|
||||
contourRow.className = "b05-route__contour-row";
|
||||
@@ -201,7 +204,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
contourInterval.wrapper,
|
||||
button("재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)),
|
||||
);
|
||||
contour.body.append(contourRow);
|
||||
|
||||
// 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에
|
||||
// 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다
|
||||
@@ -286,20 +288,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
solveButton,
|
||||
);
|
||||
|
||||
const sectionOptions = section(L("B05_Route_Group_SectionOptions"));
|
||||
// 「페이지 설정」 — 등고선 간격·시작 측점 및 샘플링·종단 설계 기준 세 섹션 병합
|
||||
// (2026-08-18 사용자 확정). 측점 간격 입력은 삭제 — 20m 고정.
|
||||
const sectionOptions = section("페이지 설정");
|
||||
sectionOptions.root.classList.add("is-collapsed");
|
||||
const stationInterval = numberField(L("B05_Route_Field_StationInterval"));
|
||||
const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample"));
|
||||
const longSampleInterval = numberField(L("B05_Route_Field_LongSample"));
|
||||
sectionOptions.body.append(
|
||||
stationInterval.wrapper,
|
||||
crossSampleInterval.wrapper,
|
||||
longSampleInterval.wrapper,
|
||||
);
|
||||
|
||||
// 종단 설계 기준은 한 번 정하면 자주 손대지 않는다 — 기본은 접힌 상태
|
||||
// (2026-08-17 사용자 지시).
|
||||
const gradeLine = section("종단 설계 기준", true);
|
||||
sectionOptions.body.append(crossSampleInterval.wrapper, longSampleInterval.wrapper);
|
||||
const terrainField = createSelectField({
|
||||
label: "지형 구분",
|
||||
options: [
|
||||
@@ -328,7 +323,18 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
startElevationOffset.wrapper,
|
||||
endElevationOffset.wrapper,
|
||||
);
|
||||
gradeLine.body.append(terrainField.root, criteriaNote, gradeAdvanced);
|
||||
// 병합 구획 구분선 — 샘플링 / 등고선 / 종단 설계 기준.
|
||||
const settingsDivider1 = document.createElement("hr");
|
||||
settingsDivider1.className = "b05-structure__divider";
|
||||
const settingsDivider2 = settingsDivider1.cloneNode() as HTMLHRElement;
|
||||
sectionOptions.body.append(
|
||||
settingsDivider1,
|
||||
contourRow,
|
||||
settingsDivider2,
|
||||
terrainField.root,
|
||||
criteriaNote,
|
||||
gradeAdvanced,
|
||||
);
|
||||
|
||||
// 공사 시작 기준 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다.
|
||||
// (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0.
|
||||
@@ -359,7 +365,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
const structures = createStructuresSection({
|
||||
onChange: callbacks.onStructuresChange,
|
||||
onSelect: callbacks.onStructureSelect,
|
||||
getInterval: () => Number(stationInterval.value) || 20,
|
||||
getInterval: () => STATION_INTERVAL_M,
|
||||
onPipeAdd: callbacks.onPipeFacilityAdd,
|
||||
onPipeUpdate: callbacks.onPipeFacilityUpdate,
|
||||
onPipeRemove: callbacks.onPipeFacilityRemove,
|
||||
@@ -409,7 +415,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
maxDownhillGrade,
|
||||
minUphillGrade,
|
||||
minDownhillGrade,
|
||||
stationInterval,
|
||||
crossSampleInterval,
|
||||
longSampleInterval,
|
||||
terrainType,
|
||||
@@ -420,15 +425,16 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
endElevationOffset,
|
||||
];
|
||||
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
|
||||
root.append(
|
||||
gradeLine.root,
|
||||
contour.root,
|
||||
sectionOptions.root,
|
||||
structures.root,
|
||||
routeCalc.root,
|
||||
selected.root,
|
||||
actionDock,
|
||||
);
|
||||
|
||||
// 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자
|
||||
// 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다.
|
||||
// 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다.
|
||||
routeCalc.root.classList.add("is-disabled");
|
||||
routeCalc.body.inert = true;
|
||||
|
||||
// 배치 순서: 구조물 배치 > 페이지 설정 > 경로 계산 설정(비활성) > 선택 포인트
|
||||
// (평소 숨김) > 하단 고정 dock (2026-08-18 사용자 확정).
|
||||
root.append(structures.root, sectionOptions.root, routeCalc.root, selected.root, actionDock);
|
||||
|
||||
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
|
||||
// 접힘 항목은 손대지 않는다.
|
||||
@@ -453,7 +459,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
minUphillGrade: parseOptional(minUphillGrade),
|
||||
minDownhillGrade: parseOptional(minDownhillGrade),
|
||||
allowAvoidPassThrough: avoidPass.checked,
|
||||
stationInterval: parseOptional(stationInterval),
|
||||
stationInterval: STATION_INTERVAL_M,
|
||||
crossSampleInterval: parseOptional(crossSampleInterval),
|
||||
longSampleInterval: parseOptional(longSampleInterval),
|
||||
terrainType: terrainType.value as RoutePanelValues["terrainType"],
|
||||
@@ -475,7 +481,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade);
|
||||
if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade);
|
||||
if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough;
|
||||
if (values.stationInterval != null) stationInterval.value = String(values.stationInterval);
|
||||
// stationInterval은 복원하지 않는다 — 20m 고정(2026-08-18).
|
||||
if (values.crossSampleInterval != null)
|
||||
crossSampleInterval.value = String(values.crossSampleInterval);
|
||||
if (values.longSampleInterval != null)
|
||||
|
||||
@@ -182,6 +182,8 @@ export function createRouteProfilePanel(
|
||||
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
|
||||
onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM),
|
||||
onStructureAdd: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type),
|
||||
// 배수유역도 [배관 추가]도 사이드 폼 경유(2026-08-18) — 테이블 우클릭과 같은 경로.
|
||||
onPipeAddRequest: (chainage) => callbacks?.onPipeAdd?.(chainage),
|
||||
});
|
||||
content.append(bodyWrap, drainagePanel.root);
|
||||
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
||||
|
||||
@@ -99,8 +99,9 @@ interface StructuresCallbacks {
|
||||
) => void;
|
||||
/** 계곡 통과 시설 삭제. */
|
||||
onPipeRemove: (chainageM: number) => void;
|
||||
/** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). */
|
||||
onPipeSelect: (chainageM: number) => void;
|
||||
/** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫).
|
||||
* null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */
|
||||
onPipeSelect: (chainageM: number | null) => void;
|
||||
}
|
||||
|
||||
export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection {
|
||||
@@ -187,6 +188,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
let editingId: string | null = null;
|
||||
/** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */
|
||||
let selectedPipeChainage: number | null = null;
|
||||
/** 임시 배치 중인 A군 시설의 누가거리(2026-08-18) — 측점을 넣는 순간 관 지점에
|
||||
* 미리 넣어 세부유역·설계유량 미리보기를 얻고, [추가]로만 확정한다. 취소(삭제·
|
||||
* 리셋·다른 항목 선택·종류 변경)하면 관을 물려 추가 직전 상태로 되돌린다. */
|
||||
let tempPipeChainage: number | null = null;
|
||||
let optionInputs: Array<{
|
||||
key: string;
|
||||
required: boolean;
|
||||
@@ -232,6 +237,54 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
designFlow: number | null = null,
|
||||
): void {
|
||||
facilityOptions.setFacility(facilityFormKind(currentType()), options, designFlow);
|
||||
syncOptionLock();
|
||||
}
|
||||
|
||||
/** 위치(측점)가 채워졌는지 — 상세 옵션을 여는 조건(2026-08-18 입력 순서 가이드). */
|
||||
function hasPosition(): boolean {
|
||||
const type = currentType();
|
||||
if (!type) return false;
|
||||
const step = interval();
|
||||
if (!type.managed_by && type.placement === "interval") {
|
||||
return startFields.read(step, false) !== null && endFields.read(step, false) !== null;
|
||||
}
|
||||
return anchorFields.read(step, false) !== null;
|
||||
}
|
||||
|
||||
/** 측점 미입력 상태에서는 상세 옵션(레지스트리 옵션 칸·부속 서브폼)을 잠근다 —
|
||||
* 입력 순서 가이드(2026-08-18 사용자 지시). 자동 배치 시설은 측점을 갖고 폼에
|
||||
* 올라오므로 항상 열려 있다. */
|
||||
function syncOptionLock(): void {
|
||||
const locked = !hasPosition();
|
||||
optionInputs.forEach((entry) => {
|
||||
entry.input.disabled = locked;
|
||||
});
|
||||
optionRow.classList.toggle("is-locked", locked);
|
||||
facilityOptions.root.inert = locked;
|
||||
facilityOptions.root.classList.toggle("is-locked", locked);
|
||||
}
|
||||
|
||||
/** 측점 입력 변경 — A군이면 임시 배치를 만들거나 옮기고(세부유역 미리보기,
|
||||
* 2026-08-18), 어느 군이든 옵션 잠금을 다시 판정한다. */
|
||||
function handleStationInput(): void {
|
||||
const type = currentType();
|
||||
if (type?.managed_by) {
|
||||
const anchor = anchorFields.read(interval(), false);
|
||||
// 기존 관(임시 아님)을 수정 중일 때 기준점 이동은 [수정]으로만 반영한다.
|
||||
if (anchor !== null && (selectedPipeChainage === null || tempPipeChainage !== null)) {
|
||||
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
|
||||
if (tempPipeChainage === null) {
|
||||
tempPipeChainage = anchor;
|
||||
callbacks.onPipeAdd(anchor, attributes);
|
||||
} else if (Math.abs(tempPipeChainage - anchor) > 0.005) {
|
||||
const from = tempPipeChainage;
|
||||
tempPipeChainage = anchor;
|
||||
callbacks.onPipeUpdate(from, anchor, attributes);
|
||||
}
|
||||
syncButtons();
|
||||
}
|
||||
}
|
||||
syncOptionLock();
|
||||
}
|
||||
|
||||
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07
|
||||
@@ -244,6 +297,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : [];
|
||||
if (!visible.length) {
|
||||
optionRow.hidden = true;
|
||||
syncOptionLock();
|
||||
return;
|
||||
}
|
||||
optionRow.hidden = false;
|
||||
@@ -275,6 +329,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
isEmpty: () => input.value.trim() === "",
|
||||
});
|
||||
});
|
||||
syncOptionLock();
|
||||
}
|
||||
|
||||
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
|
||||
@@ -294,9 +349,38 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
syncFacilityForm();
|
||||
}
|
||||
|
||||
/** 임시 배치를 물리고 추가 직전 상태로 되돌린다(관 제거 → 세부유역 재분할). */
|
||||
function cancelTempPipe(): void {
|
||||
if (tempPipeChainage === null) return;
|
||||
const chainage = tempPipeChainage;
|
||||
tempPipeChainage = null;
|
||||
if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < 0.05) {
|
||||
selectedPipeChainage = null;
|
||||
}
|
||||
callbacks.onPipeRemove(chainage);
|
||||
}
|
||||
|
||||
/** 폼 전체 초기화 — 리셋 버튼과 선택 해제가 같은 상태를 공유한다(2026-08-18
|
||||
* 사용자 지시: 해제 시 구조물군·종류·측점까지 리셋과 동일하게 비운다). */
|
||||
function resetForm(): void {
|
||||
const hadPipe = selectedPipeChainage !== null || tempPipeChainage !== null;
|
||||
cancelTempPipe();
|
||||
groupSelect.value = "";
|
||||
syncTypeOptions();
|
||||
loadForm(null);
|
||||
// 시설이 골라져 있었으면 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다.
|
||||
if (hadPipe) callbacks.onPipeSelect(null);
|
||||
}
|
||||
|
||||
function syncButtons(): void {
|
||||
primary.textContent = editingId || selectedPipeChainage !== null ? "수정" : "추가";
|
||||
removeButton.disabled = editingId === null && selectedPipeChainage === null;
|
||||
primary.textContent =
|
||||
tempPipeChainage !== null
|
||||
? "추가"
|
||||
: editingId || selectedPipeChainage !== null
|
||||
? "수정"
|
||||
: "추가";
|
||||
removeButton.disabled =
|
||||
editingId === null && selectedPipeChainage === null && tempPipeChainage === null;
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
@@ -308,16 +392,27 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
intervalM: interval(),
|
||||
editingId,
|
||||
selectedPipeChainage,
|
||||
onSelectStructure: (structure) =>
|
||||
loadForm(structure.structure_id === editingId ? null : structure),
|
||||
// 재선택 = 해제(2026-08-18 복구) — 해제는 리셋과 같은 전체 초기화다.
|
||||
onSelectStructure: (structure) => {
|
||||
if (structure.structure_id !== null && structure.structure_id === editingId) resetForm();
|
||||
else loadForm(structure);
|
||||
},
|
||||
onSelectPipe: (pipe) => {
|
||||
loadPipeForm(pipe);
|
||||
callbacks.onPipeSelect(pipe.chainage_m);
|
||||
const isSame =
|
||||
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05;
|
||||
if (isSame) {
|
||||
resetForm();
|
||||
} else {
|
||||
loadPipeForm(pipe);
|
||||
callbacks.onPipeSelect(pipe.chainage_m);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function loadForm(target: StructureInstance | null): void {
|
||||
// 다른 항목으로 넘어가면 임시 배치(A군 미리보기)는 취소된다.
|
||||
cancelTempPipe();
|
||||
editingId = target?.structure_id ?? null;
|
||||
selectedPipeChainage = null;
|
||||
const step = interval();
|
||||
@@ -351,6 +446,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
/** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정).
|
||||
* 정본이 관 지점이라 editingId 대신 선택 누가거리로 추적한다. */
|
||||
function loadPipeForm(pipe: PipeFacilityItem): void {
|
||||
// 임시 배치 중인 그 관이 재계산을 거쳐 되돌아온 경우가 아니면, 다른 항목으로
|
||||
// 넘어가는 것이므로 임시 배치를 취소한다.
|
||||
const isTemp = tempPipeChainage !== null && Math.abs(pipe.chainage_m - tempPipeChainage) < 0.05;
|
||||
if (isTemp) tempPipeChainage = pipe.chainage_m;
|
||||
else cancelTempPipe();
|
||||
const hadStructure = editingId !== null;
|
||||
editingId = null;
|
||||
selectedPipeChainage = pipe.chainage_m;
|
||||
@@ -407,7 +507,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
|
||||
const options = facilityOptions.readOptions();
|
||||
if (Object.keys(options).length) attributes.options = options;
|
||||
if (selectedPipeChainage !== null) {
|
||||
if (tempPipeChainage !== null) {
|
||||
// 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영.
|
||||
const from = tempPipeChainage;
|
||||
tempPipeChainage = null;
|
||||
callbacks.onPipeUpdate(from, anchor, attributes);
|
||||
} else if (selectedPipeChainage !== null) {
|
||||
callbacks.onPipeUpdate(selectedPipeChainage, anchor, attributes);
|
||||
} else {
|
||||
callbacks.onPipeAdd(anchor, attributes);
|
||||
@@ -474,6 +579,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
}
|
||||
|
||||
groupSelect.addEventListener("change", () => {
|
||||
cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18).
|
||||
syncTypeOptions();
|
||||
// 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다).
|
||||
if (editingId) {
|
||||
@@ -484,6 +590,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
}
|
||||
});
|
||||
typeSelect.addEventListener("change", () => {
|
||||
cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18).
|
||||
syncPlacementFields();
|
||||
renderOptionFields();
|
||||
// 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 —
|
||||
@@ -499,6 +606,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
});
|
||||
primary.addEventListener("click", commit);
|
||||
removeButton.addEventListener("click", () => {
|
||||
// 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18).
|
||||
if (tempPipeChainage !== null) {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
// 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유).
|
||||
if (selectedPipeChainage !== null) {
|
||||
callbacks.onPipeRemove(selectedPipeChainage);
|
||||
@@ -513,12 +625,15 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
loadForm(null);
|
||||
emit();
|
||||
});
|
||||
resetButton.addEventListener("click", () => {
|
||||
// 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시).
|
||||
groupSelect.value = "";
|
||||
syncTypeOptions();
|
||||
loadForm(null);
|
||||
});
|
||||
// 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시).
|
||||
// 임시 배치 취소도 겸한다(2026-08-18).
|
||||
resetButton.addEventListener("click", resetForm);
|
||||
// 측점 입력 감시 — 옵션 잠금 해제 판정 + A군 임시 배치 생성·이동(2026-08-18).
|
||||
[anchorFields, startFields, endFields].forEach((fields) =>
|
||||
[fields.station, fields.remainder].forEach((input) =>
|
||||
input.addEventListener("change", handleStationInput),
|
||||
),
|
||||
);
|
||||
|
||||
body.insertBefore(field("메모", memoField), actions);
|
||||
syncButtons();
|
||||
@@ -548,6 +663,14 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
getStructures: () => [...structures],
|
||||
setPipeFacilities(pipes) {
|
||||
pipeFacilities = pipes.map((pipe) => ({ ...pipe }));
|
||||
if (
|
||||
tempPipeChainage !== null &&
|
||||
!pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < 0.05)
|
||||
) {
|
||||
// 임시 배치 관이 재계산에서 사라졌다 — 임시 상태만 정리(관은 이미 없다).
|
||||
tempPipeChainage = null;
|
||||
syncButtons();
|
||||
}
|
||||
if (
|
||||
selectedPipeChainage !== null &&
|
||||
!pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05)
|
||||
@@ -561,6 +684,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
},
|
||||
selectPipeByChainage(chainageM) {
|
||||
if (chainageM === null) {
|
||||
// 임시 배치 중 외부 해제(다른 곳 클릭) = 취소 — 추가 직전 상태로(2026-08-18).
|
||||
if (tempPipeChainage !== null) {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
if (selectedPipeChainage === null) return;
|
||||
selectedPipeChainage = null;
|
||||
facilityOptions.setFacility(null);
|
||||
@@ -584,58 +712,34 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
addAt(chainageM, typeId) {
|
||||
const type = typeMap().get(typeId);
|
||||
if (!type) return;
|
||||
// 계곡 통과 시설은 관 지점 정본으로 바로 보낸다(옵션은 추가 후 폼에서).
|
||||
// BOX암거만 예외로 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을
|
||||
// 함께 싣는다 — 폼을 열지 않고 넣어도 하류가 제원을 받는다(2026-08-17 사용자
|
||||
// 확정). 배관·물넘이·세월교는 부속을 설계자가 고를 몫이라 빈 채로 둔다.
|
||||
if (type.managed_by) {
|
||||
const attributes: FacilityAttributes = { facility: typeId as PipeFacility };
|
||||
if (typeId === "box_culvert") attributes.options = defaultOptions(type);
|
||||
callbacks.onPipeAdd(chainageM, attributes);
|
||||
return;
|
||||
}
|
||||
// 우클릭 추가 = 즉시 추가 폐지(2026-08-18 사용자 지시 — 옵션 확대로 바로 넣을
|
||||
// 수 없는 타입이 생겼다). 폼에 종류·측점을 자동으로 실어 옵션을 활성화하고,
|
||||
// [추가]로 확정한다. A군은 측점이 실리는 순간 임시 배치(세부유역 미리보기)까지
|
||||
// 자동으로 진행된다.
|
||||
cancelTempPipe();
|
||||
groupSelect.value = type.group;
|
||||
syncTypeOptions(typeId);
|
||||
typeSelect.value = typeId;
|
||||
editingId = null;
|
||||
selectedPipeChainage = null;
|
||||
const placement = placementOf(typeId);
|
||||
const step = interval();
|
||||
|
||||
// B05 단계 필수 옵션이 있는 타입만 폼을 연다 — 상세(detail) 필수는 B06/B07
|
||||
// 몫이라 바로 추가해도 서버가 받는다(2026-08-17 phase 분리).
|
||||
if (type.options.some((option) => option.required && isB05Option(option))) {
|
||||
groupSelect.value = type.group;
|
||||
syncTypeOptions(typeId);
|
||||
typeSelect.value = typeId;
|
||||
editingId = null;
|
||||
anchorFields.write(chainageM, step);
|
||||
startFields.write(placement === "interval" ? chainageM : null, step);
|
||||
endFields.write(
|
||||
placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null,
|
||||
step,
|
||||
);
|
||||
memoField.value = "";
|
||||
syncPlacementFields();
|
||||
renderOptionFields();
|
||||
syncButtons();
|
||||
renderList();
|
||||
root.scrollIntoView({ block: "nearest" });
|
||||
const firstRequired = optionInputs.find((entry) => entry.required);
|
||||
(firstRequired?.input ?? anchorFields.station).focus();
|
||||
return;
|
||||
}
|
||||
|
||||
structures.push({
|
||||
structure_id: null,
|
||||
type_id: typeId,
|
||||
placement,
|
||||
chainage_m: chainageM,
|
||||
start_m: placement === "interval" ? chainageM : null,
|
||||
end_m: placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null,
|
||||
options: defaultOptions(type),
|
||||
memo: "",
|
||||
placement_source: "manual",
|
||||
status: "draft",
|
||||
revision: 0,
|
||||
geometry: null,
|
||||
});
|
||||
emit();
|
||||
const isInterval = !type.managed_by && placement === "interval";
|
||||
anchorFields.write(chainageM, step);
|
||||
startFields.write(isInterval ? chainageM : null, step);
|
||||
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
|
||||
memoField.value = "";
|
||||
syncPlacementFields();
|
||||
renderOptionFields(type.managed_by ? undefined : defaultOptions(type));
|
||||
// BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을
|
||||
// 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지).
|
||||
syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {});
|
||||
if (type.managed_by) handleStationInput();
|
||||
syncButtons();
|
||||
renderList();
|
||||
root.scrollIntoView({ block: "nearest" });
|
||||
const firstRequired = optionInputs.find((entry) => entry.required);
|
||||
(firstRequired?.input ?? anchorFields.station).focus();
|
||||
},
|
||||
moveById(structureId, toChainageM) {
|
||||
const index = structures.findIndex((entry) => entry.structure_id === structureId);
|
||||
|
||||
@@ -233,6 +233,18 @@
|
||||
}
|
||||
|
||||
/* 제목 행 클릭 접기/펼치기는 공용 collapsible(ui_template_theme.css)에서 처리한다. */
|
||||
/* 사용여부 미결정 컨테이너(경로 계산 설정) — 숨기지 않고 "조작 안 되는 항목"임이
|
||||
보이게 글자·배경을 가라앉힌다(2026-08-18 사용자 지시). 입력 차단은 본문 inert가
|
||||
맡고, 여기서는 시각만 담당한다. 제목(접기)은 살아 있으므로 흐림에서 제외하지
|
||||
않는다 — 컨테이너 전체가 한 덩어리로 비활성으로 읽혀야 한다. */
|
||||
.b05-route__panel-section.is-disabled {
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 60%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.b05-route__panel-section.is-disabled > * {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.b05-route__panel-section h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -165,6 +165,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 측점 미입력 상태의 상세 옵션 잠금 — 입력 순서 가이드(2026-08-18 사용자 지시).
|
||||
조작 차단은 disabled·inert가 맡고 여기서는 흐림만 그린다. */
|
||||
.b05-structure__grid.is-locked,
|
||||
.b05-drainage__facility.is-locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 측점 그룹과 그 아래 옵션 나열을 가르는 선(2026-08-17 사용자 지시 2). */
|
||||
.b05-structure__divider {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user