/* ============================================================================= * B05_Profile_UI_Structures_Panel.ts * 「구조물 배치」 사이드바 섹션 — 구조물군(A~G) → 타입 → 위치(측점) → B05 옵션. * * 구 「구조물 배치」(비정규 측점, 자유 텍스트)를 흡수한 단일 섹션이다(2026-08-17 * 컨테이너 병합). 위치 입력은 측점번호+잔여거리 두 칸이고, 순서는 시작 측점 → * 기준 측점 → 종료 측점이다(점형은 기준 측점만). 저장은 누가거리(m)로 한다. * * 계곡 통과 시설(배관/BOX암거/물넘이/세월교, A군 managed_by)도 **같은 폼**에서 * 편집한다 — 자동 배치 배관이든 수동 배관이든 같은 구조물이라 별도 UI를 두지 * 않는다(2026-08-17 사용자 지시). 저장소만 관 지점 정본(`pipe_points.json`)이라 * 추가·수정·삭제를 콜백으로 배수유역 패널에 넘긴다. 부속 옵션(유형·집수정· * 기슭막이·돌붙임·날개벽)은 옵션 자리의 서브폼이 그린다. 상세 치수는 B06/B07 몫. * ========================================================================== */ import { isB05Option, structureAnchorM, type StructureInstance, type StructuresSectionOptions, type StructurePlacement, type StructureType, } 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 { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { bindStructuresEvents, type StructuresEventContext, } from "./B05_Profile_UI_Structures_Panel_Events"; import { commit as commitInto, emit as emitFrom, loadForm as loadFormInto, loadPipeForm as loadPipeFormInto, } from "./B05_Profile_UI_Structures_Panel_Commit"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; import { formatStation } from "./B05_Profile_Util_Station"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types"; export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; import { DEFAULT_INTERVAL_LENGTH_M, GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types"; import type { PipeFacilityItem, StructuresCallbacks, StructuresSection, } from "./B05_Profile_UI_Structures_Panel_Types"; export function createStructuresSection( callbacks: StructuresCallbacks, sectionOptions: StructuresSectionOptions = {}, ): StructuresSection { // 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만. const form = buildStructuresForm({ getInterval: () => callbacks.getInterval(), onFacilityChange: () => liveCommit(), }); const { root, body, groupSelect, typeSelect, ownerNote, startFields, anchorFields, endFields, memoField, rangeValue, rangeWrap, positionRow, positionDivider, optionRow, actions, facilityOptions, primary, removeButton, resetButton, list, } = form; /** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신 * 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션 * 방식(측점 범위 + 기준측점 전/후)으로 옮겼다(2026-08-19 사용자 지시 3). */ function facilityFormKind(type: StructureType | null): PipeFacility | null { if (!type) return null; return type.managed_by ? (type.type_id as PipeFacility) : null; } /** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이 * 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만 * 준다(2026-08-19 사용자 지시). */ function hasComputedRange(type: StructureType | null): boolean { return ( !!type && !type.managed_by && type.placement === "interval" && type.options.some((option) => option.key === "length_m" && isB05Option(option)) ); } /** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */ function readLengthM(): number | null { const entry = optionInputs.find((input) => input.key === "length_m"); if (!entry || entry.isEmpty()) return null; const length = Number(entry.read()); return Number.isFinite(length) && length > 0 ? length : null; } /** 기준측점 전(before_m, m) — 기준 측점에서 시점 쪽으로 물러나는 몫. 비우면 0 * (= 기준이 곧 시작). 총길이를 넘길 수 없다 — 넘치면 총길이로 자른다(후 0). */ function readBeforeM(): number { const entry = optionInputs.find((input) => input.key === "before_m"); if (!entry || entry.isEmpty()) return 0; const before = Number(entry.read()); if (!Number.isFinite(before) || before < 0) return 0; const length = readLengthM(); return length !== null && before > length ? length : before; } /** 기준측점 전·후 중 나중에 고친 쪽 — 길이가 바뀔 때 이쪽 값을 지키며 재배분한다. */ let lastSplitEdit: "before_m" | "after_m" = "before_m"; /** 길이·전·후 연동(2026-08-19 사용자 지시): 전 + 후 = 길이. 나중에 입력된 쪽이 * 살아남고 반대쪽이 길이 − 그 값으로 맞춰진다. `normalizeSource`는 입력 확정 * (change) 때만 true — 타이핑(input) 중에 그 칸을 다시 쓰면 입력이 끊긴다. */ function syncSplitInputs( source: "length" | "before_m" | "after_m", normalizeSource: boolean, ): void { const before = optionInputs.find((entry) => entry.key === "before_m"); const after = optionInputs.find((entry) => entry.key === "after_m"); if (before && after) { if (source !== "length") lastSplitEdit = source; const length = readLengthM() ?? 0; const keepEntry = source === "after_m" ? after : source === "before_m" ? before : null; const keep = keepEntry ?? (lastSplitEdit === "after_m" ? after : before); const raw = keep.isEmpty() ? 0 : Number(keep.read()); const kept = Math.min(Math.max(Number.isFinite(raw) ? raw : 0, 0), length); if (normalizeSource) keep.input.value = kept.toFixed(1); (keep === before ? after : before).input.value = Math.max(length - kept, 0).toFixed(1); } syncRangeDisplay(); } /** 측점 범위 표시 갱신 — 시작 = 기준 − 전길이, 종료 = 시작 + 길이(2026-08-19 * 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—". */ function syncRangeDisplay(): void { const computed = hasComputedRange(currentType()); rangeWrap.hidden = !computed; if (!computed) return; const step = interval(); const anchor = anchorFields.read(step, false); const length = readLengthM(); const start = anchor === null ? null : anchor - readBeforeM(); rangeValue.textContent = start !== null && length !== null && start > -0.005 ? `${formatStation(Math.max(start, 0), step)} ~ ${formatStation(Math.max(start, 0) + length, step)}` : "—"; } let types: StructureType[] = []; // 배열 자체를 바꾸지 않는다(조각들이 참조로 들고 있음) — 내용만 갈아끼운다. const structures: StructureInstance[] = []; let pipeFacilities: PipeFacilityItem[] = []; let editingId: string | null = null; /** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */ let selectedPipeChainage: number | null = null; /** 임시 배치 중인 A군 시설의 누가거리(2026-08-18) — 측점을 넣는 순간 관 지점에 * 미리 넣어 세부유역·설계유량 미리보기를 얻고, [추가]로만 확정한다. 취소(삭제· * 리셋·다른 항목 선택·종류 변경)하면 관을 물려 추가 직전 상태로 되돌린다. */ let tempPipeChainage: number | null = null; /** 위치 입력이 "확정"됐는지 — 측점번호+잔여거리를 다 받고 입력군을 빠져나간 * 순간(또는 폼에 위치를 실어 연 순간)에만 true. 옵션 활성화 시점도 임시 배치와 * 같은 이 순간이다(2026-08-18 사용자 지시). */ let positionConfirmed = false; const optionInputs: Array<{ key: string; required: boolean; input: HTMLInputElement | HTMLSelectElement; read: () => string | number; isEmpty: () => boolean; }> = []; function typeMap(): Map { return new Map(types.map((type) => [type.type_id, type])); } function currentType(): StructureType | null { return typeMap().get(typeSelect.value) ?? null; } function placementOf(typeId: string): StructurePlacement { return typeMap().get(typeId)?.placement ?? "point"; } const interval = (): number => callbacks.getInterval(); /** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설(배수관 등)은 점형이라 * 기준 측점 하나만 받는다(2026-08-17 사용자 지시 2 — 시작·종료 불필요). * 범위 계산 타입(C군 등)도 기준 측점만 받고 범위는 표시 행이 대신한다(2026-08-19). * 종류 미선택(빈값)이면 입력 칸 전체를 숨긴다 — 리셋 직후 상태. */ function syncPlacementFields(): void { const type = currentType(); const managed = !!type?.managed_by; const isInterval = !!type && !managed && type.placement === "interval" && !hasComputedRange(type); positionRow.hidden = !type; positionDivider.hidden = !type; startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; primary.disabled = !type; // 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면 // 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자). ownerNote.textContent = type?.design_owner ? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.` : ""; ownerNote.hidden = !type?.design_owner; anchorFields.wrap.querySelector("span")!.textContent = isInterval ? "기준 측점 (비우면 시작)" : "기준 측점"; syncRangeDisplay(); } /** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */ function syncFacilityForm( options: Record = {}, designFlow: number | null = null, ): void { facilityOptions.setFacility(facilityFormKind(currentType()), options, designFlow); syncOptionLock(); } /** 위치(측점)가 채워졌는지 — 상세 옵션을 여는 조건(2026-08-18 입력 순서 가이드). * 범위 계산 타입은 기준 측점 하나가 곧 위치다(2026-08-19). */ function hasPosition(): boolean { const type = currentType(); if (!type) return false; const step = interval(); if (!type.managed_by && type.placement === "interval" && !hasComputedRange(type)) { 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 = !(positionConfirmed && 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). 측점 두 칸을 **다 쓰고 빠져나온 뒤**에만 * 부른다 — 측점번호만 넣고 거리 칸으로 넘어가는 순간 나가면 재계산 동기화가 * 입력을 끊는다(2026-08-18 사용자 보고). */ function commitTempPipe(): void { const type = currentType(); if (!type?.managed_by) return; const anchor = anchorFields.read(interval(), false); // 기존 관(임시 아님)을 수정 중일 때 기준점 이동은 [수정]으로만 반영한다. if (anchor === null || (selectedPipeChainage !== null && tempPipeChainage === null)) return; 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(); } /** 측점 입력 변경 — 값을 지우면 확정이 풀려 옵션이 즉시 다시 잠긴다. * 확정(활성화)은 입력군 이탈 시 commitPosition이 한다. */ function handleStationInput(): void { if (!hasPosition()) positionConfirmed = false; syncOptionLock(); syncRangeDisplay(); } /** 위치 입력 확정 — 측점번호+잔여거리를 다 받고 입력군을 빠져나온 순간. * 옵션 활성화와 A군 임시 배치가 이 한 지점에서 같이 나간다(2026-08-18). */ function commitPosition(): void { positionConfirmed = hasPosition(); commitTempPipe(); syncOptionLock(); syncRangeDisplay(); } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면 * (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */ function renderOptionFields(values: Record = {}): void { const type = currentType(); // 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로 // 바꾸면 옛 칸 목록을 읽어 저장값이 어긋난다(2026-09-04). optionInputs.length = 0; optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. const all = sectionOptions.includeDetail === true; const visible = type && !facilityFormKind(type) ? type.options.filter((o) => all || isB05Option(o)) : []; if (!visible.length) { optionRow.hidden = true; syncOptionLock(); return; } optionRow.hidden = false; const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> = []; visible.forEach((option) => { // ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은 // 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석). const input = optionControl(option, values[option.key]); suggestions.push({ input, suggested: String(option.default ?? "") }); const label = option.unit ? `${option.label} (${option.unit})` : option.label; // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다 // (고를 수 없는 칸이라 제안이 아니라 프로그램 값). const locked = option.enabled === false; if (locked && !input.value) input.value = String(option.default ?? ""); if (locked) { input.disabled = true; input.classList.add("is-locked"); input.title = "지금은 고를 수 없는 항목입니다."; } optionRow.append(field(label, input)); input.addEventListener("change", () => liveCommit()); optionInputs.push({ key: option.key, required: !locked && !!option.required, input, read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), isEmpty: () => input.value.trim() === "", }); // 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을 // 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다. if (option.key === "face_slope_ratio") { const warn = (): void => { const value = Number(input.value); const outside = input.value.trim() !== "" && Number.isFinite(value) && (value < 0.2 || value > 0.5); input.title = outside ? "품셈 표준경사 표 범위(0.20~0.50) 밖 값입니다 — 근거를 적어 두시기 바랍니다." : "비워 두면 품셈 표대로 자동 판정합니다."; input.classList.toggle("is-outside-standard", outside); }; warn(); input.addEventListener("input", warn); input.addEventListener("change", warn); } // 길이·기준측점 전/후가 바뀌면 서로 연동하고 측점 범위 표시가 즉시 따라온다 // (범위 계산 타입, 2026-08-19). 전·후는 **나중에 고친 쪽**이 살아남고 반대쪽이 // 길이 − 그 값으로 맞춰진다. 길이를 고치면 나중에 고쳤던 쪽을 지키며 재배분. if (option.key === "length_m" || option.key === "before_m" || option.key === "after_m") { const source = (option.key === "length_m" ? "length" : option.key) as "length" | "before_m" | "after_m"; const refresh = (normalize: boolean): void => { input.classList.remove("is-invalid"); syncSplitInputs(source, normalize); }; input.addEventListener("input", () => refresh(false)); input.addEventListener("change", () => refresh(true)); } }); if (suggestions.some((entry) => entry.suggested)) { optionRow.append(suggestButton(suggestions)); } syncOptionLock(); syncRangeDisplay(); } /** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도 * 포함한다(추가 시 관 지점 정본으로 간다, 2026-08-17 통합). 첫 항목은 빈칸 — * 초기·리셋 상태를 "안 고름"으로 드러낸다. */ function syncTypeOptions(keepTypeId?: string): void { const group = groupSelect.value; const candidates = group ? types.filter((type) => type.group === group) : []; typeSelect.replaceChildren( new Option("— 선택 —", ""), ...candidates.map((type) => new Option(type.name, type.type_id)), ); typeSelect.value = keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : ""; syncPlacementFields(); renderOptionFields(); syncFacilityForm(); } /** 임시 배치를 물리고 추가 직전 상태로 되돌린다(관 제거 → 세부유역 재분할). */ function cancelTempPipe(): void { if (tempPipeChainage === null) return; const chainage = tempPipeChainage; tempPipeChainage = null; if (selectedPipeChainage !== null && Math.abs(selectedPipeChainage - chainage) < 0.51) { 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); } /** 정본 주입(setStructures) 되먹임 중인가 — 실시간 반영으로 목록이 돌아올 때 * 폼을 리셋하지 않기 위한 표시(2026-08-29 사용자 지시 2). */ let applyingLive = false; /** * 실시간 반영 — 이미 고른 항목(구조물·계곡 통과 시설)을 고치는 중이면 값이 바뀔 * 때마다 곧바로 반영한다(2026-08-29 사용자 지시 2). 새 항목은 종전대로 [추가]로만 * 확정한다 — 입력이 끝나기 전에 반쪽짜리 항목이 생기면 안 된다. */ function liveCommit(): void { if (!editingId && selectedPipeChainage === null) return; if (tempPipeChainage !== null) return; // 임시 배치는 [추가]가 확정한다 applyingLive = true; void Promise.resolve(commit(true)).finally(() => { applyingLive = false; }); } function syncButtons(): void { primary.textContent = tempPipeChainage !== null ? "추가" : editingId || selectedPipeChainage !== null ? "수정" : "추가"; removeButton.disabled = editingId === null && selectedPipeChainage === null && tempPipeChainage === null; // 고치는 중에는 [수정] 버튼을 쓰지 않는다(실시간 반영) — 자리는 남겨 삭제·리셋의 // 위치·크기가 흔들리지 않게 한다(2026-08-29 사용자 지시 2). const editing = (editingId !== null || selectedPipeChainage !== null) && tempPipeChainage === null; // visibility만 끈다 — 자리는 남고 클릭도 먹지 않는다(disabled는 건드리지 않는다, // 다른 곳에서 종류 선택 여부로 이미 관리한다). primary.style.visibility = editing ? "hidden" : "visible"; } function renderList(): void { renderStructureList({ list, structures, pipeFacilities, typeMap: typeMap(), intervalM: interval(), editingId, selectedPipeChainage, // 재선택 = 해제(2026-08-18 복구) — 해제는 리셋과 같은 전체 초기화다. onSelectStructure: (structure) => { if (structure.structure_id !== null && structure.structure_id === editingId) resetForm(); else loadForm(structure); }, onSelectPipe: (pipe) => { const isSame = selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05; if (isSame) { resetForm(); } else { loadPipeForm(pipe); callbacks.onPipeSelect(pipe.chainage_m); } }, }); } /* 폼 ↔ 정본 왕복(불러오기·저장)은 700줄 제한으로 `_Structures_Panel_Commit` 로 옮겼다. */ const loadForm = (target: StructureInstance | null): void => loadFormInto(panelContext, target); const loadPipeForm = (pipe: PipeFacilityItem): void => loadPipeFormInto(panelContext, pipe); const emit = (): void => emitFrom(panelContext); const commit = (live = false): Promise => commitInto(panelContext, live); /* 입력 배선은 700줄 제한으로 `_Structures_Panel_Events` 로 옮겼다 — 처리 함수는 여기 그대로다. */ /* 저장·배선이 함께 보는 패널 컨텍스트 — 가변 상태의 주인은 그대로 패널이다. */ const panelContext: StructuresEventContext = { groupSelect, typeSelect, primary, removeButton, resetButton, positionRow, memoField, anchorFields, startFields, endFields, facilityOptions, callbacks, structures, types: () => types, typeMap, currentType, interval, commit, resetForm: () => resetForm(), handleStationInput: () => handleStationInput(), commitPosition: () => commitPosition(), liveCommit: () => liveCommit(), loadForm, cancelTempPipe: () => cancelTempPipe(), syncTypeOptions: (keepTypeId) => syncTypeOptions(keepTypeId), syncPlacementFields: () => syncPlacementFields(), syncButtons: () => syncButtons(), renderList: () => renderList(), renderOptionFields: (values) => renderOptionFields(values), emit, optionRow, optionInputs, pipeFacilities: () => pipeFacilities, facilityFormKind, hasComputedRange, readLengthM, readBeforeM, syncFacilityForm: (options, designFlow) => syncFacilityForm(options, designFlow), editingId: () => editingId, setEditingId: (value) => { editingId = value; }, selectedPipeChainage: () => selectedPipeChainage, setSelectedPipeChainage: (value) => { selectedPipeChainage = value; }, tempPipeChainage: () => tempPipeChainage, setTempPipeChainage: (value) => { tempPipeChainage = value; }, positionConfirmed: () => positionConfirmed, setPositionConfirmed: (value) => { positionConfirmed = value; }, }; bindStructuresEvents(panelContext); body.insertBefore(field("메모", memoField), actions); syncButtons(); renderList(); return { root, facility: facilityOptions, listRoot: list, hasSelection: () => editingId !== null || selectedPipeChainage !== null, setTypes(next) { types = next; const groups = [...new Set(next.map((t) => t.group))]; // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. groupSelect.replaceChildren( new Option("— 선택 —", ""), ...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)), ); groupSelect.value = ""; syncTypeOptions(); }, setStructures(next) { // 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어 // 저장→재조회→주입→저장의 무한 고리가 된다(변경 알림은 사용자 조작에서만). // 배열을 **갈아끼우지 않고 안을 채운다** — 저장·배선 조각(`_Commit`·`_Events`)이 // 이 배열을 참조로 받아 두므로, 새 배열로 바꾸면 그쪽이 옛 배열을 고쳐 // 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인). structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry }))); // 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다. if (applyingLive) renderList(); else { loadForm(null); renderList(); } }, getStructures: () => [...structures], setPipeFacilities(pipes) { pipeFacilities = pipes.map((pipe) => ({ ...pipe })); if (tempPipeChainage !== null) { // 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다(0.51m 기준). const hit = pipeFacilities.find( (pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < 0.51, ); if (hit) { tempPipeChainage = hit.chainage_m; if (selectedPipeChainage !== null) selectedPipeChainage = hit.chainage_m; // 재계산이 끝나 도착한 담당 유역 설계유량을 입력 중인 폼에 반영한다 — // 물넘이·세월교 월류 높이가 이 값으로 계산된다(2026-08-18 진단: 재계산 // 후 선택 통지가 조용히 끝나 폼이 유량을 못 받던 문제). facilityOptions.setDesignFlow(hit.design_flow_m3s ?? null); } else { // 임시 배치 관이 재계산에서 사라졌다 — 임시 상태만 정리(관은 이미 없다). tempPipeChainage = null; syncButtons(); } } if ( selectedPipeChainage !== null && !pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05) ) { // 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다. selectedPipeChainage = null; facilityOptions.setFacility(null); syncButtons(); } renderList(); }, selectPipeByChainage(chainageM) { if (chainageM !== null) callbacks.onReveal?.(); if (chainageM === null) { // 외부 해제(빈 공간 클릭 등) — 임시 배치는 취소, 선택 중이던 시설도 구조물군· // 종류까지 완전 리셋(2026-08-18 사용자 지시). 아무것도 안 골랐으면 무시. if (tempPipeChainage !== null || selectedPipeChainage !== null) resetForm(); return; } // 그래프·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) callbacks.onReveal?.(); if (structureId === null) { // 그래프·3D 빈 공간 클릭 등 외부 해제 — 선택 중이었으면 구조물군·종류까지 // 완전 리셋(2026-08-18 사용자 지시). 선택 없는 신규 작성 중에는 두지 않는다. if (editingId !== null || selectedPipeChainage !== null || tempPipeChainage !== null) { resetForm(); } return; } loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null); }, addAt(chainageM, typeId) { const type = typeMap().get(typeId); if (!type) return; // 우클릭 추가 = 즉시 추가 폐지(2026-08-18 사용자 지시 — 옵션 확대로 바로 넣을 // 수 없는 타입이 생겼다). 폼에 종류·측점을 자동으로 실어 옵션을 활성화하고, // [추가]로 확정한다. A군은 측점이 실리는 순간 임시 배치(세부유역 미리보기)까지 // 자동으로 진행된다. cancelTempPipe(); groupSelect.value = type.group; syncTypeOptions(typeId); typeSelect.value = typeId; editingId = null; selectedPipeChainage = null; positionConfirmed = true; // 우클릭은 측점이 확정돼 들어온다 — 옵션도 바로 연다. const placement = placementOf(typeId); const step = interval(); // 범위 계산 타입은 시작·종료를 쓰지 않는다 — 길이 옵션이 정한다(2026-08-19). const isInterval = !type.managed_by && placement === "interval" && !hasComputedRange(type); anchorFields.write(chainageM, step); startFields.write(isInterval ? chainageM : null, step); endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step); memoField.value = ""; syncPlacementFields(); // 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②). renderOptionFields(); // BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음). // 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함. syncFacilityForm({}); // 임시 배치도 즉시 진행(A군). if (type.managed_by) commitTempPipe(); 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); if (index < 0) return false; const target = structures[index]; if (target.placement === "interval") { // 구간은 길이를 유지한 채 통째로 옮긴다 — 기준점을 끌면 시작·종료가 따라온다. const length = (target.end_m ?? 0) - (target.start_m ?? 0); const anchorOffset = structureAnchorM(target) - (target.start_m ?? 0); const start = toChainageM - anchorOffset; structures[index] = { ...target, chainage_m: toChainageM, start_m: start, end_m: start + length, }; } else { structures[index] = { ...target, chainage_m: toChainageM }; } emit(); return true; }, removeById(structureId) { const index = structures.findIndex((entry) => entry.structure_id === structureId); if (index < 0) return false; structures.splice(index, 1); if (editingId === structureId) loadForm(null); emit(); return true; }, }; }