refactor(B05): 배관 편집을 구조물 배치 폼으로 단일화 — 별도 시설 폼 폐지

- 자동 배관·수동 배관은 같은 구조물(2026-08-17 사용자 지시) — 별도 편집 UI
  (createFacilityEditor) 폐지. 배관/BOX암거/물넘이/세월교도 일반 구조물과
  같은 흐름(구조물군 → 종류 → 시작·기준·종료 측점 → 옵션 → 추가/수정/삭제)
- createFacilityOptionsForm: 부속 옵션 서브폼이 구조물 폼 옵션 자리에 삽입.
  시설 종류는 종류 드롭다운, 위치는 측점 칸이 담당
- loadPipeForm: 목록·그래프·3D·배수유역도 어디서 골라도 같은 폼에 로드,
  버튼 [수정]. onPipeUpdate 신설(기준점 이동=관 이동, 옵션만=재계산)
- 기슭막이 총/앞/뒤 → 시작·종료 측점 칸 자동 채움. onPipesChanged에
  source·options 추가. 배수유역 패널은 지도·정본 보관·재계산만 담당
- 700줄 대응: 입력 조각을 B05_Profile_UI_Structures_Fields.ts로 분리
  (패널 685줄). tmp/tests 98건·typecheck·build 통과
This commit is contained in:
2026-08-17 15:02:45 +09:00
parent 1e4da21dbc
commit 38ea71e15b
8 changed files with 364 additions and 279 deletions
+89 -116
View File
@@ -1,23 +1,22 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility.ts
* 계곡 통과 시설 편집 소구획 — 선택한 관 지점의 시설 종류·구간·부속 옵션.
* 계곡 통과 시설(배관/BOX암거/물넘이포장/세월교)의 부속 옵션 서브폼 + 시설 보관소.
*
* 같은 계곡 교차점에서 유량·지형에 따라 배관/BOX암거/물넘이포장/세월교를 택일한다
* (2026-08-17 사용자 확정 — 교량은 임도용이 아니라 없다). 정본은 `pipe_points.json`
* 하나이고, 세부유역 계산은 기준점(chainage)만 읽으므로 종류를 바꿔도 계산은 같다.
* 자동 배관이든 수동 배관이든 같은 구조물이다 — 별도 편집 UI를 두지 않고 사이드
* 「구조물 배치」 폼의 옵션 자리에 이 서브폼이 끼워진다(2026-08-17 사용자 지시).
* 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치
* 칸이 담당하므로 여기에는 부속 옵션 필드만 있다.
*
* 배관 부속(2026-08-17 확정): 유형(계곡부형/보완형 — 자동 배치 source로 제안),
* 집수정 유무(토글 시 유입부 옵션 전환), 관경(기본 1000), 기슭막이(길이 총/앞/뒤
* 상호 연계 — 범위를 만든다), 돌붙임. 형식·치수 제원은 B06/B07 필수 승격 대상이라
* 여기서는 보이되 비필수다. 시설 확장 정보는 패널이 chainage 기준으로 들고 다니며
* 요청에 되붙인다(`FacilityStore`).
* 상호 연계 — 범위를 만든다 → onSpanSuggest로 위치 칸에 반영), 돌붙임. 형식·치수
* 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다.
* ========================================================================== */
import {
PIPE_FACILITY_LABELS,
type DetailPipeInput,
type PipeFacility,
type PipeSource,
import type {
DetailPipeInput,
PipeFacility,
PipeSource,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
@@ -98,19 +97,25 @@ export function createFacilityStore() {
export type FacilityStore = ReturnType<typeof createFacilityStore>;
interface FacilityEditorCallbacks {
/** 입력이 확정될 때(종류·구간·옵션 change) — 패널이 저장소 갱신·재계산을 맡는다. */
onApply: (chainageM: number, attributes: FacilityAttributes | null) => void;
interface FacilityOptionsFormCallbacks {
/** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */
onChange?: () => void;
/** 기슭막이 총/앞/뒤가 정해졌을 때 — 위치 칸(시작·종료 측점) 자동 채움 제안.
* 범위 = 기준 앞 ~ 기준 + 뒤 (2026-08-17 확정). */
onSpanSuggest?: (frontM: number, backM: number) => void;
}
export interface FacilityEditor {
export interface FacilityOptionsForm {
root: HTMLElement;
/** 선택된 관 지점을 폼에 올린다. null이면 숨긴다. source는 유형 제안에 쓴다. */
sync: (
chainageM: number | null,
attributes: FacilityAttributes | null,
/** 시설 종류에 맞는 옵션 필드를 보여주고 값을 채운다. null이면 통째로 숨긴다.
* source는 배관 유형(계곡부형/보완형) 제안 근거다. */
setFacility: (
facility: PipeFacility | null,
options?: Record<string, string | number>,
source?: PipeSource,
) => void;
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */
readOptions: () => Record<string, string | number>;
}
function labeled(text: string, input: HTMLElement): HTMLLabelElement {
@@ -156,22 +161,14 @@ function suggestFlowType(source: PipeSource | undefined): string {
return "";
}
/** 선택한 관 지점 아래 붙는 시설 편집 폼. */
export function createFacilityEditor(callbacks: FacilityEditorCallbacks): FacilityEditor {
/** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */
export function createFacilityOptionsForm(
callbacks: FacilityOptionsFormCallbacks = {},
): FacilityOptionsForm {
const root = document.createElement("div");
root.className = "b05-drainage__facility";
root.hidden = true;
const facilitySelect = document.createElement("select");
facilitySelect.replaceChildren(
...PIPE_FACILITY_LABELS.map(([value, label]) => new Option(label, value)),
);
// 구간 — 기준점 앞뒤로 유입·유출 부속이 차지하는 범위(m, 누가거리). 비우면 폭 0.
// 기슭막이 길이를 넣으면 자동으로 채워지고, 직접 고치면 그 값이 우선한다.
const startInput = numberInput("0.1", "0", "비우면 기준점");
const endInput = numberInput("0.1", "0", "비우면 기준점");
// ── 배관 부속 (2026-08-17 확정) ──────────────────────────────────────────
const flowSelect = optionalSelect("미지정", ["계곡부형", "보완형"]);
const catchBasinSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]);
@@ -220,7 +217,6 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili
const fordDiameterSelect = optionalSelect("선택", ["800", "1000", "1200", "1500"]);
const fordCount = numberInput("1", "1", "련");
const spanRow = row(labeled("시작 (m)", startInput), labeled("종료 (m)", endInput));
const pipeTypeRow = row(
labeled("유형", flowSelect),
labeled("관경", pipeDiameterSelect),
@@ -258,8 +254,6 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili
);
root.append(
labeled("시설 종류", facilitySelect),
spanRow,
pipeTypeRow,
pipeInletRow,
revetRow,
@@ -270,7 +264,7 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili
fordRow,
);
let currentChainage: number | null = null;
let currentFacility: PipeFacility | null = null;
let currentSource: PipeSource | undefined;
let syncing = false;
@@ -304,21 +298,10 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili
revetBack.value = String(Number(back.toFixed(1)));
}
/** 기슭막이 길이로 구간을 자동 채운다(범위 = 기준 − 앞 ~ 기준 + 뒤).
* 시작·종료를 직접 고친 경우는 건드리지 않도록, 기슭막이 입력 change에서만 부른다. */
function applyRevetSpan(): void {
if (currentChainage === null) return;
const length = Number.parseFloat(revetLength.value);
const front = Number.parseFloat(revetFront.value);
const back = Number.parseFloat(revetBack.value);
if (!Number.isFinite(length) || length <= 0) return;
if (!Number.isFinite(front) || !Number.isFinite(back)) return;
startInput.value = (currentChainage - front).toFixed(1);
endInput.value = (currentChainage + back).toFixed(1);
}
function syncVisibility(): void {
const facility = facilitySelect.value as PipeFacility;
const facility = currentFacility;
root.hidden = facility === null;
if (facility === null) return;
const isPipe = facility === "pipe";
const isBox = facility === "box_culvert";
pipeTypeRow.hidden = !isPipe;
@@ -347,75 +330,25 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
}
function readAttributes(): FacilityAttributes {
const facility = facilitySelect.value as PipeFacility;
const attributes: FacilityAttributes = { facility };
const start = Number.parseFloat(startInput.value);
const end = Number.parseFloat(endInput.value);
if (Number.isFinite(start) && Number.isFinite(end)) {
attributes.start_m = Math.min(start, end);
attributes.end_m = Math.max(start, end);
}
const options: Record<string, string | number> = {};
if (facility === "pipe") {
if (flowSelect.value) options.flow_type = flowSelect.value;
options.pipe_diameter_mm = Number(pipeDiameterSelect.value);
if (pipeMaterialSelect.value) options.pipe_kind = pipeMaterialSelect.value;
if (catchBasinSelect.value) options.catch_basin = catchBasinSelect.value;
if (catchBasinFormSelect.value && effectiveCatchBasin() === "있음")
options.catch_basin_form = catchBasinFormSelect.value;
if (revetSelect.value) options.revetment = revetSelect.value;
if (revetSelect.value !== "없음") {
putNumber(options, "revet_length_m", revetLength.value);
putNumber(options, "revet_front_m", revetFront.value);
if (revetFormSelect.value) options.revet_form = revetFormSelect.value;
}
if (pitchSelect.value) options.stone_pitching = pitchSelect.value;
if (pitchTypeSelect.value && pitchSelect.value !== "없음")
options.stone_pitching_type = pitchTypeSelect.value;
} else if (facility === "box_culvert") {
putNumber(options, "body_width_m", boxWidth.value);
putNumber(options, "body_height_m", boxHeight.value);
if (wingIn.value) options.wing_in = wingIn.value;
if (wingIn.value === "있음") {
putNumber(options, "wing_in_height_m", wingInHeight.value);
putNumber(options, "wing_in_length_m", wingInLength.value);
putNumber(options, "wing_in_angle_deg", wingInAngle.value);
}
if (wingOut.value) options.wing_out = wingOut.value;
if (wingOut.value === "있음") {
putNumber(options, "wing_out_height_m", wingOutHeight.value);
putNumber(options, "wing_out_length_m", wingOutLength.value);
putNumber(options, "wing_out_angle_deg", wingOutAngle.value);
}
} else if (facility === "ford_bridge") {
if (fordKindSelect.value) options.pipe_kind = fordKindSelect.value;
if (fordDiameterSelect.value) options.pipe_diameter_mm = Number(fordDiameterSelect.value);
const count = Number.parseInt(fordCount.value, 10);
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
}
if (Object.keys(options).length) attributes.options = options;
return attributes;
}
function emit(): void {
if (syncing || currentChainage === null) return;
if (syncing || currentFacility === null) return;
syncVisibility();
callbacks.onApply(currentChainage, readAttributes());
callbacks.onChange?.();
}
[revetLength, revetFront, revetBack].forEach((input, index) =>
input.addEventListener("change", () => {
if (syncing) return;
linkRevet(index === 2 ? "back" : index === 1 ? "front" : "length");
applyRevetSpan();
const front = Number.parseFloat(revetFront.value);
const back = Number.parseFloat(revetBack.value);
if (Number.isFinite(front) && Number.isFinite(back)) {
callbacks.onSpanSuggest?.(front, back);
}
emit();
}),
);
[
facilitySelect,
startInput,
endInput,
flowSelect,
pipeDiameterSelect,
pipeMaterialSelect,
@@ -442,19 +375,17 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili
return {
root,
sync(chainageM, attributes, source) {
currentChainage = chainageM;
setFacility(facility, options = {}, source) {
currentFacility = facility;
currentSource = source;
root.hidden = chainageM === null;
if (chainageM === null) return;
if (facility === null) {
syncVisibility();
return;
}
syncing = true;
const options = attributes?.options ?? {};
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
facilitySelect.value = attributes?.facility ?? "pipe";
startInput.value = attributes?.start_m !== undefined ? String(attributes.start_m) : "";
endInput.value = attributes?.end_m !== undefined ? String(attributes.end_m) : "";
const isFord = attributes?.facility === "ford_bridge";
const isFord = facility === "ford_bridge";
flowSelect.value = text("flow_type");
pipeDiameterSelect.value = (!isFord && text("pipe_diameter_mm")) || "1000";
pipeMaterialSelect.value = isFord ? "" : text("pipe_kind");
@@ -489,5 +420,47 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili
syncing = false;
syncVisibility();
},
readOptions() {
const facility = currentFacility;
const options: Record<string, string | number> = {};
if (facility === "pipe") {
if (flowSelect.value) options.flow_type = flowSelect.value;
options.pipe_diameter_mm = Number(pipeDiameterSelect.value);
if (pipeMaterialSelect.value) options.pipe_kind = pipeMaterialSelect.value;
if (catchBasinSelect.value) options.catch_basin = catchBasinSelect.value;
if (catchBasinFormSelect.value && effectiveCatchBasin() === "있음")
options.catch_basin_form = catchBasinFormSelect.value;
if (revetSelect.value) options.revetment = revetSelect.value;
if (revetSelect.value !== "없음") {
putNumber(options, "revet_length_m", revetLength.value);
putNumber(options, "revet_front_m", revetFront.value);
if (revetFormSelect.value) options.revet_form = revetFormSelect.value;
}
if (pitchSelect.value) options.stone_pitching = pitchSelect.value;
if (pitchTypeSelect.value && pitchSelect.value !== "없음")
options.stone_pitching_type = pitchTypeSelect.value;
} else if (facility === "box_culvert") {
putNumber(options, "body_width_m", boxWidth.value);
putNumber(options, "body_height_m", boxHeight.value);
if (wingIn.value) options.wing_in = wingIn.value;
if (wingIn.value === "있음") {
putNumber(options, "wing_in_height_m", wingInHeight.value);
putNumber(options, "wing_in_length_m", wingInLength.value);
putNumber(options, "wing_in_angle_deg", wingInAngle.value);
}
if (wingOut.value) options.wing_out = wingOut.value;
if (wingOut.value === "있음") {
putNumber(options, "wing_out_height_m", wingOutHeight.value);
putNumber(options, "wing_out_length_m", wingOutLength.value);
putNumber(options, "wing_out_angle_deg", wingOutAngle.value);
}
} else if (facility === "ford_bridge") {
if (fordKindSelect.value) options.pipe_kind = fordKindSelect.value;
if (fordDiameterSelect.value) options.pipe_diameter_mm = Number(fordDiameterSelect.value);
const count = Number.parseInt(fordCount.value, 10);
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
}
return options;
},
};
}
+41 -32
View File
@@ -29,7 +29,7 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
import { buildStrengthArray, createFlowLegend } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityEditor, createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { createFacilityStore, type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
@@ -80,20 +80,23 @@ export interface DrainagePanel {
/** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우).
* 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */
setPipeChainages: (chainages: ReadonlyArray<number>) => void;
/** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣거나 지울 때.
* facility를 주면 그 종류로 추가한다(기본 배관). */
addPipe: (chainageM: number, facility?: PipeFacility) => void;
/** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣 때.
* attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */
addPipe: (chainageM: number, attributes?: FacilityAttributes) => void;
/** 사이드 폼 [수정] — 기준점 이동·구간·부속 옵션을 정본에 반영하고 재계산한다. */
updatePipeFacility: (
fromChainageM: number,
toChainageM: number,
attributes: FacilityAttributes,
) => void;
removePipe: (chainageM: number) => void;
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
savePipes: () => Promise<number>;
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
selectBasinByChainage: (chainageM: number | null) => void;
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·시설 폼·유역 강조까지 연다.
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
* 이미 같으면 무시(2026-08-17 전역 선택 동기화). */
selectPipeAtChainage: (chainageM: number | null) => void;
/** 부속 옵션 폼(시설 편집) DOM — 사이드 「구조물 배치」 섹션이 가져다 붙인다
* (구조물 편집은 사이드 패널이 정위치, 2026-08-17 사용자 지시). */
facilityForm: HTMLElement;
/** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */
markStation: (chainageM: number | null) => void;
dispose: () => void;
@@ -109,6 +112,8 @@ export interface DrainagePanelCallbacks {
facility: PipeFacility;
start_m?: number;
end_m?: number;
source?: PipeSource;
options?: Record<string, string | number>;
}>,
) => void;
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
@@ -198,18 +203,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
storageKey: WIDTH_KEY,
});
// 계곡 통과 시설(배관/BOX암거/물넘이/세월교) 확장 정보 — 계산기는 chainage만 다루므로
// 여기 보관해 두고 요청마다 되붙인다(2026-08-17 컨테이너 병합).
// 여기 보관해 두고 요청마다 되붙인다(2026-08-17 컨테이너 병합). 편집 폼은 사이드
// 「구조물 배치」 하나뿐이다(자동·수동 배관 = 같은 구조물, 별도 UI 없음 —
// 2026-08-17 사용자 지시). 여기는 정본 보관·재계산만 맡는다.
const facilityStore = createFacilityStore();
const facilityEditor = createFacilityEditor({
onApply: (chainageM, attributes) => {
facilityStore.set(chainageM, attributes);
void analyze(); // 즉시 재계산·응답 반영 — 계산은 같지만 시설 표기가 정본 경로를 탄다.
},
});
root.append(panelHandle.root, widthResizer.root, header, viewport, summary, basinList);
// 시설 폼은 여기 두지 않는다 — 사이드 「구조물 배치」 섹션이 `facilityForm`으로
// 가져다 붙인다(구조물 편집은 사이드가 정위치, 2026-08-17 사용자 지시).
let projectId: string | null = null;
let meta: VWorldMeta | null = null;
@@ -363,17 +362,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage);
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼·시설 편집 폼 갱신. */
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */
function syncPipeSelection(): void {
const index = pipeEditor.selected();
deleteButton.disabled = index === null;
const picked = index === null ? null : pipeEditor.pipes()[index];
facilityEditor.sync(
picked ? picked.chainage_m : null,
picked ? facilityStore.get(picked.chainage_m) : null,
// 자동 배치 source — 배관 유형(계곡부형/보완형) 제안 근거 (2026-08-17).
picked ? ((picked.reason || "user") as PipeSource) : undefined,
);
selectBasin(basinIndexOfPipe(basins, picked ?? null));
// 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은
// Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply)
@@ -446,8 +439,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
// 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
// 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
// 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조).
// 시설 종류·구간도 함께 — 통합 목록·그래프 라벨이 세월교/BOX암거를 구분한다.
pipeEditor.chainages().map((chainage) => {
// 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
// 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합).
pipeEditor.pipes().map((pipe) => {
const chainage = Math.round(pipe.chainage_m * 100) / 100;
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
const attributes = facilityStore.get(chainage);
return {
@@ -457,6 +452,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
facility: attributes?.facility ?? "pipe",
start_m: attributes?.start_m,
end_m: attributes?.end_m,
source: (pipe.reason || "user") as PipeSource,
options: attributes?.options,
};
}),
);
@@ -742,13 +739,26 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
markedChainage = chainageM;
scheduleDraw();
},
addPipe(chainageM, facility) {
// 시설 종류를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
if (facility && facility !== "pipe") {
facilityStore.set(chainageM, { facility });
}
addPipe(chainageM, attributes) {
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
facilityStore.set(chainageM, attributes ?? null);
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, attributes);
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다.
const index = pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51);
if (index >= 0) pipeEditor.moveTo(index, toChainageM);
else void analyze();
} else {
void analyze(); // 위치는 그대로 — 옵션·구간만 정본 경로로 재반영.
}
},
removePipe(chainageM) {
const index = pipeEditor
.pipes()
@@ -769,7 +779,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
syncPipeSelection();
scheduleDraw();
},
facilityForm: facilityEditor.root,
async savePipes() {
if (!projectId) return 0;
const response = await saveDetailPipePoints(
+6 -6
View File
@@ -230,6 +230,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
facility: pipe.facility,
start_m: pipe.start_m,
end_m: pipe.end_m,
source: pipe.source,
options: pipe.options,
})),
);
},
@@ -359,7 +361,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset),
onStructuresChange: (next) => applyStructures(next),
// 계곡 통과 시설(A군) — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합).
onPipeFacilityAdd: (chainage, facility) => profilePanel.drainage.addPipe(chainage, facility),
onPipeFacilityAdd: (chainage, attributes) =>
profilePanel.drainage.addPipe(chainage, attributes),
onPipeFacilityUpdate: (from, to, attributes) =>
profilePanel.drainage.updatePipeFacility(from, to, attributes),
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
onPipeFacilitySelect: (chainage) => {
if (selectionSyncing) return;
@@ -396,11 +401,6 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
// 부속 옵션 폼(시설 편집)은 사이드 「구조물 배치」 섹션 안에 붙인다 — 구조물
// 편집은 사이드가 정위치(2026-08-17 사용자 지시). 폼 데이터는 배수유역 패널이
// 정본(pipe_points)과 동기화한다.
panel.structures.mountFacilityForm(profilePanel.drainage.facilityForm);
viewer.markers.onChange(markStale);
viewer.markers.onSelectionChange(panel.setSelected);
viewer.markers.onStationSelectionChange((stationId) => {
+9 -3
View File
@@ -1,6 +1,6 @@
import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers";
import type { StructureInstance } from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { createStructuresSection, type StructuresSection } from "./B05_Profile_UI_Structures_Panel";
import { type ButtonVariant, createButton, createSelectField } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
@@ -69,8 +69,13 @@ interface PanelCallbacks {
onStructuresChange: (structures: StructureInstance[]) => void;
/** 구조물을 목록에서 선택/해제할 때 해당 구조물(또는 null). */
onStructureSelect: (structure: StructureInstance | null) => void;
/** 계곡 통과 시설 추가·삭제·선택 — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). */
onPipeFacilityAdd: (chainageM: number, facility: PipeFacility) => void;
/** 계곡 통과 시설 추가·수정·삭제·선택 — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). */
onPipeFacilityAdd: (chainageM: number, attributes: FacilityAttributes) => void;
onPipeFacilityUpdate: (
fromChainageM: number,
toChainageM: number,
attributes: FacilityAttributes,
) => void;
onPipeFacilityRemove: (chainageM: number) => void;
onPipeFacilitySelect: (chainageM: number) => void;
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
@@ -353,6 +358,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
onSelect: callbacks.onStructureSelect,
getInterval: () => Number(stationInterval.value) || 20,
onPipeAdd: callbacks.onPipeFacilityAdd,
onPipeUpdate: callbacks.onPipeFacilityUpdate,
onPipeRemove: callbacks.onPipeFacilityRemove,
onPipeSelect: callbacks.onPipeFacilitySelect,
});
+3 -1
View File
@@ -22,7 +22,7 @@ import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import { mountStructureMarks } from "./B05_Profile_UI_Structures_Marks";
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
@@ -218,6 +218,8 @@ export interface RouteProfilePanelCallbacks {
facility: PipeFacility;
start_m?: number;
end_m?: number;
source?: PipeSource;
options?: Record<string, string | number>;
}>,
) => void;
/** 테이블에서 구조물 라인을 끌어 옮김. */
+3 -2
View File
@@ -30,8 +30,9 @@ export interface SelectionSyncPorts {
selectBasin: (chainageM: number | null) => void;
/** 배수유역도 계획선 위 측점 마킹(누가거리 기준). 유역 없는 구조물도 위치를 보여 준다. */
markStation?: (chainageM: number | null) => void;
/** + ( )
* (2026-08-17 ). */
/** ( )
* · (2026-08-17 ).
* selectSidebar . */
selectPipeForm?: (chainageM: number | null) => void;
}
@@ -0,0 +1,90 @@
/* =============================================================================
* B05_Profile_UI_Structures_Fields.ts
* ···+ .
*
* (B05_Profile_UI_Structures_Panel) 700 .
* UI와 (2026-08-17 )
* (m), +.
* ========================================================================== */
import { chainageToStation } from "./B05_Profile_Util_Station";
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
return wrapper;
}
export function numberInput(step = "0.1", min = "0"): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = min;
return input;
}
export function select(options: ReadonlyArray<[string, string]>): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...options.map(([value, label]) => new Option(label, value)));
return element;
}
/** 측점번호 + 잔여거리 두 칸 묶음. */
export interface StationFields {
wrap: HTMLElement;
station: HTMLInputElement;
remainder: HTMLInputElement;
/** 두 칸을 누가거리로 읽는다. 비었으면 null, 형식 오류면 붉히고 null. */
read: (intervalM: number, required: boolean) => number | null;
/** 누가거리를 두 칸에 나눠 싣는다. null이면 비운다. */
write: (chainageM: number | null, intervalM: number) => void;
clearInvalid: () => void;
}
export function stationFields(labelText: string): StationFields {
const station = numberInput("1", "0");
station.placeholder = "측점";
const remainder = numberInput("0.1", "0");
remainder.placeholder = "+m";
const pair = document.createElement("div");
pair.className = "b05-structure__station-pair";
pair.append(station, remainder);
const wrap = field(labelText, pair);
const markInvalid = (bad: boolean): void => {
station.classList.toggle("is-invalid", bad);
remainder.classList.toggle("is-invalid", bad);
};
return {
wrap,
station,
remainder,
read(intervalM, required) {
const stationText = station.value.trim();
const remainderText = remainder.value.trim();
if (!stationText && !remainderText) {
markInvalid(required);
return null;
}
const stationNo = stationText ? Number(stationText) : 0;
const rest = remainderText ? Number(remainderText) : 0;
const valid =
Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0;
markInvalid(!valid);
return valid ? stationNo * intervalM + rest : null;
},
write(chainageM, intervalM) {
if (chainageM === null) {
station.value = "";
remainder.value = "";
return;
}
const parts = chainageToStation(chainageM, intervalM);
station.value = String(parts.station);
remainder.value = parts.remainder.toFixed(1);
},
clearInvalid: () => markInvalid(false),
};
}
+123 -119
View File
@@ -6,9 +6,11 @@
* ). + ,
* ( ). (m) .
*
* (/BOX암거//, A군 managed_by)
* (`pipe_points.json`) ··
* . B06/B07 .
* (/BOX암거//, A군 managed_by) ** **
* UI를
* (2026-08-17 ). (`pipe_points.json`)
* ·· . (··
* ··) . B06/B07 .
* ========================================================================== */
import {
@@ -20,8 +22,13 @@ import {
type StructureSide,
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { chainageToStation, formatStation } from "./B05_Profile_Util_Station";
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createFacilityOptionsForm,
type FacilityAttributes,
} from "./B05_Profile_UI_Drainage_Facility";
import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields";
import { formatStation } from "./B05_Profile_Util_Station";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */
const GROUP_LABELS: Record<string, string> = {
@@ -45,12 +52,16 @@ const SIDE_LABELS: Array<[StructureSide, string]> = [
/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */
const DEFAULT_INTERVAL_LENGTH_M = 15;
/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시용 항목. */
/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시·폼 편집용 항목. */
export interface PipeFacilityItem {
chainage_m: number;
facility: PipeFacility;
start_m?: number;
end_m?: number;
/** 자동 배치 출처(stream/spacing/user) — 배관 유형(계곡부형/보완형) 제안 근거. */
source?: PipeSource;
/** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */
options?: Record<string, string | number>;
}
export interface StructuresSection {
@@ -62,10 +73,6 @@ export interface StructuresSection {
getStructures: () => StructureInstance[];
/** 계곡 통과 시설 목록을 병합 표시한다(정본 = pipe_points, 배수유역 패널 경유). */
setPipeFacilities: (pipes: PipeFacilityItem[]) => void;
/** ( DOM)
* (2026-08-17 ). /
* ( hidden) . */
mountFacilityForm: (element: HTMLElement) => void;
/** 그래프·3D에서 고른 계곡 통과 시설을 목록에서 강조한다(null = 해제). */
selectPipeByChainage: (chainageM: number | null) => void;
/** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */
@@ -84,94 +91,21 @@ interface StructuresCallbacks {
onSelect: (structure: StructureInstance | null) => void;
/** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다. */
getInterval: () => number;
/** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). */
onPipeAdd: (chainageM: number, facility: PipeFacility) => void;
/** ( ).
* attributes에 ·· . */
onPipeAdd: (chainageM: number, attributes: FacilityAttributes) => void;
/** 계곡 통과 시설 수정 — 기준점 이동·구간·부속 옵션 반영(관 지점 정본 경유). */
onPipeUpdate: (
fromChainageM: number,
toChainageM: number,
attributes: FacilityAttributes,
) => void;
/** 계곡 통과 시설 삭제. */
onPipeRemove: (chainageM: number) => void;
/** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). */
onPipeSelect: (chainageM: number) => void;
}
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
return wrapper;
}
function numberInput(step = "0.1", min = "0"): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = min;
return input;
}
function select(options: ReadonlyArray<[string, string]>): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...options.map(([value, label]) => new Option(label, value)));
return element;
}
/** 측점번호 + 잔여거리 두 칸 묶음 — 구 비정규 측점 UI와 같은 입력 방식(2026-08-17). */
interface StationFields {
wrap: HTMLElement;
station: HTMLInputElement;
remainder: HTMLInputElement;
/** 두 칸을 누가거리로 읽는다. 비었으면 null, 형식 오류면 붉히고 null. */
read: (intervalM: number, required: boolean) => number | null;
/** 누가거리를 두 칸에 나눠 싣는다. null이면 비운다. */
write: (chainageM: number | null, intervalM: number) => void;
clearInvalid: () => void;
}
function stationFields(labelText: string): StationFields {
const station = numberInput("1", "0");
station.placeholder = "측점";
const remainder = numberInput("0.1", "0");
remainder.placeholder = "+m";
const pair = document.createElement("div");
pair.className = "b05-structure__station-pair";
pair.append(station, remainder);
const wrap = field(labelText, pair);
const markInvalid = (bad: boolean): void => {
station.classList.toggle("is-invalid", bad);
remainder.classList.toggle("is-invalid", bad);
};
return {
wrap,
station,
remainder,
read(intervalM, required) {
const stationText = station.value.trim();
const remainderText = remainder.value.trim();
if (!stationText && !remainderText) {
markInvalid(required);
return null;
}
const stationNo = stationText ? Number(stationText) : 0;
const rest = remainderText ? Number(remainderText) : 0;
const valid =
Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0;
markInvalid(!valid);
return valid ? stationNo * intervalM + rest : null;
},
write(chainageM, intervalM) {
if (chainageM === null) {
station.value = "";
remainder.value = "";
return;
}
const parts = chainageToStation(chainageM, intervalM);
station.value = String(parts.station);
remainder.value = parts.remainder.toFixed(1);
},
clearInvalid: () => markInvalid(false),
};
}
export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection {
const root = document.createElement("section");
root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section";
@@ -208,6 +142,20 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
const optionRow = document.createElement("div");
optionRow.className = "b05-route__irregular-row";
// 계곡 통과 시설(배관 등)의 부속 옵션 서브폼 — 일반 구조물의 옵션 칸과 같은
// 자리에서, 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17
// 사용자 지시 — 자동·수동 배관은 같은 구조물, 별도 UI 금지). 기슭막이 길이를
// 넣으면 시작·종료 측점이 기준 − 앞 ~ 기준 + 뒤로 자동 채워진다.
const facilityOptions = createFacilityOptionsForm({
onSpanSuggest: (frontM, backM) => {
const step = interval();
const anchor = anchorFields.read(step, false);
if (anchor === null) return;
startFields.write(Math.max(0, anchor - frontM), step);
endFields.write(anchor + backM, step);
},
});
const primary = document.createElement("button");
primary.type = "button";
primary.className = "b05-route__irregular-btn is-primary";
@@ -234,6 +182,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
positionRow,
sideRow,
optionRow,
facilityOptions.root,
actions,
list,
);
@@ -266,20 +215,34 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
const interval = (): number => callbacks.getInterval();
/** · .
* · ( ) . ()
* (2026-08-17 ). */
/** · . +
* ·(, ) , ·
* ( ) . ()
* (2026-08-17 ). */
function syncPlacementFields(): void {
const type = currentType();
const isInterval = !type?.managed_by && type?.placement === "interval";
const managed = !!type?.managed_by;
const isInterval = !!type && (managed || type.placement === "interval");
positionRow.hidden = !type;
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
sideRow.hidden = !type || !!type.managed_by;
sideRow.hidden = !type || managed;
primary.disabled = !type;
anchorFields.wrap.querySelector("span")!.textContent = isInterval
? "기준 측점 (비우면 시작)"
: "기준 측점";
anchorFields.wrap.querySelector("span")!.textContent =
isInterval && !managed ? "기준 측점 (비우면 시작)" : "기준 측점";
}
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설일 때만, 값은 인자로). */
function syncFacilityForm(
options: Record<string, string | number> = {},
source?: PipeSource,
): void {
const type = currentType();
facilityOptions.setFacility(
type?.managed_by ? (type.type_id as PipeFacility) : null,
options,
source,
);
}
/** (detail) B06/B07
@@ -338,10 +301,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : "";
syncPlacementFields();
renderOptionFields();
syncFacilityForm();
}
function syncButtons(): void {
primary.textContent = editingId ? "수정" : "추가";
primary.textContent = editingId || selectedPipeChainage !== null ? "수정" : "추가";
removeButton.disabled = editingId === null && selectedPipeChainage === null;
}
@@ -417,10 +381,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
name.textContent = labelOfPipe(pipe);
info.textContent = "배수유역 연동";
item.addEventListener("click", () => {
loadForm(null);
selectedPipeChainage = pipe.chainage_m;
syncButtons();
renderList();
loadPipeForm(pipe);
callbacks.onPipeSelect(pipe.chainage_m);
});
}
@@ -454,6 +415,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
memoField.value = "";
renderOptionFields();
}
syncFacilityForm();
[anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid());
syncPlacementFields();
syncButtons();
@@ -461,6 +423,30 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
callbacks.onSelect(target);
}
/** (···).
* editingId . */
function loadPipeForm(pipe: PipeFacilityItem): void {
const hadStructure = editingId !== null;
editingId = null;
selectedPipeChainage = pipe.chainage_m;
const step = interval();
const type = typeMap().get(pipe.facility);
if (type) {
groupSelect.value = type.group;
syncTypeOptions(pipe.facility);
}
anchorFields.write(pipe.chainage_m, step);
startFields.write(pipe.start_m ?? null, step);
endFields.write(pipe.end_m ?? null, step);
memoField.value = "";
syncFacilityForm(pipe.options ?? {}, pipe.source);
[anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid());
syncPlacementFields();
syncButtons();
renderList();
if (hadStructure) callbacks.onSelect(null);
}
function readOptions(): Record<string, string | number> {
const values: Record<string, string | number> = {};
optionInputs.forEach((input) => {
@@ -481,14 +467,28 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
if (!type) return;
const step = interval();
// 계곡 통과 시설 — 기준 측점만 받아 관 지점 정본으로 보낸다(시설 종류 = type_id).
// 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다
// (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다.
if (type.managed_by) {
const anchor = anchorFields.read(step, true);
if (anchor === null) {
anchorFields.station.focus();
return;
}
callbacks.onPipeAdd(anchor, type.type_id as PipeFacility);
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
const start = startFields.read(step, false);
const end = endFields.read(step, false);
if (start !== null && end !== null && end > start) {
attributes.start_m = start;
attributes.end_m = end;
}
const options = facilityOptions.readOptions();
if (Object.keys(options).length) attributes.options = options;
if (selectedPipeChainage !== null) {
callbacks.onPipeUpdate(selectedPipeChainage, anchor, attributes);
} else {
callbacks.onPipeAdd(anchor, attributes);
}
loadForm(null);
return;
}
@@ -620,29 +620,33 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
renderList();
},
getStructures: () => [...structures],
mountFacilityForm(element) {
// 목록 위, 액션 버튼 다음 — 계곡 시설을 고르면 그 자리에서 부속 옵션을 편집한다.
body.insertBefore(element, list);
},
setPipeFacilities(pipes) {
pipeFacilities = pipes.map((pipe) => ({ ...pipe }));
if (
selectedPipeChainage !== null &&
!pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05)
) {
// 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다.
selectedPipeChainage = null;
facilityOptions.setFacility(null);
syncButtons();
}
renderList();
},
selectPipeByChainage(chainageM) {
selectedPipeChainage = chainageM;
if (chainageM !== null && editingId) {
editingId = null;
callbacks.onSelect(null);
if (chainageM === null) {
if (selectedPipeChainage === null) return;
selectedPipeChainage = null;
facilityOptions.setFacility(null);
syncButtons();
renderList();
return;
}
syncButtons();
renderList();
// 그래프·3D·배수유역도에서 고른 관도 목록 강조 + 폼 로드까지 — 어느 경로든
// 같은 편집 화면이 열린다(2026-08-17 전역 선택 동기화).
const pipe = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.05);
if (!pipe) return;
loadPipeForm(pipe);
},
selectById(structureId) {
if (structureId === null) {
@@ -654,9 +658,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
addAt(chainageM, typeId) {
const type = typeMap().get(typeId);
if (!type) return;
// 계곡 통과 시설은 관 지점 정본으로 바로 보낸다.
// 계곡 통과 시설은 관 지점 정본으로 바로 보낸다(옵션은 추가 후 폼에서).
if (type.managed_by) {
callbacks.onPipeAdd(chainageM, typeId as PipeFacility);
callbacks.onPipeAdd(chainageM, { facility: typeId as PipeFacility });
return;
}
const placement = placementOf(typeId);