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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
/** 테이블에서 구조물 라인을 끌어 옮김. */
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user