From 7500f37acd1408e9f8b959a7bb7d1ccf77fda5ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:37:46 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=ED=8C=A8=EB=84=90=20700=EC=A4=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(=EB=8F=99=EC=9E=91=20=EB=B6=88=EB=B3=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05_Profile_UI_Structures_Panel.ts 1024 -> 669줄. 조각 셋 모두 700줄 이하. - _Structures_Panel_Types.ts(94줄) — GROUP_LABELS · PipeFacilityItem · StructuresSection · StructuresCallbacks · 기본 구간 길이 상수. 패널이 다시 export 해 옛 임포트 경로 유지. - _Structures_Panel_Commit.ts(312줄) — 폼 불러오기(구조물·계곡 통과 시설)와 [추가]/[수정] 저장, 목록 알림. 검증 순서·분기·수치는 그대로이고 패널 클로저 값만 ctx 로 받음. - _Structures_Panel_Events.ts(146줄) — 드롭다운·버튼·측점 입력 배선. 리스너 본문 그대로. StructuresEventContext 가 StructuresCommitContext 를 상속해 패널은 컨텍스트 하나만 만듦. 가변 상태 4개(editingId · selectedPipeChainage · tempPipeChainage · positionConfirmed)의 주인은 그대로 패널이고, 읽기 함수 + set* 로만 넘김. 검증: 분리 전 export 4개가 진입 파일에서 그대로 보임, tsc --noEmit 통과, prettier 적용, tmp/tests 183 passed. 공용 브라우저(5174)에서 실제 조작으로 저장 경로까지 확인 — C 사면안정/옹벽을 기준측점 5+0 에 [추가] -> 목록 반영, [저장] -> 서버 정본 2건(revision 0->1), 새로고침 후 목록에서 골라 [삭제] -> [저장] -> 서버 0건(revision 4). 시험 프로젝트는 원래 상태(구조물 0건)로 되돌려 둠. 참고(분리와 무관한 기존 동작): 방금 [추가]해 아직 저장 전인 항목은 structure_id 가 없어 [삭제]가 듣지 않음. 저장 뒤에는 정상 삭제됨. 분리 전 코드와 같은 분기라 이번 변경과 무관. Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_UI_Structures_Panel.ts | 527 +++--------------- .../B05_Profile_UI_Structures_Panel_Commit.ts | 312 +++++++++++ .../B05_Profile_UI_Structures_Panel_Events.ts | 146 +++++ .../B05_Profile_UI_Structures_Panel_Types.ts | 94 ++++ 4 files changed, 638 insertions(+), 441 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 892cde77..fabeafe0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -21,98 +21,33 @@ import { type StructurePlacement, type StructureType, } from "./B05_Profile_Api_Structures"; -import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { - type FacilityAttributes, - type FacilityOptionsForm, -} from "./B05_Profile_UI_Drainage_Facility"; +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field, numberInput, select } 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 { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { formatStation } from "./B05_Profile_Util_Station"; -/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. - * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ -export const GROUP_LABELS: Record = { - A: "A 횡단배수", - B: "B 종단배수", - C: "C 사면안정", - D: "D 계류·사방", - E: "E 안전·부대·용지", - F: "F 생태·녹화", - G: "G 노면공", - 호환: "기타", -}; - -/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ -const DEFAULT_INTERVAL_LENGTH_M = 15; - -/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시·폼 편집용 항목. */ -export interface PipeFacilityItem { - chainage_m: number; - facility: PipeFacility; - start_m?: number; - end_m?: number; - /** 자동 배치 출처(stream/spacing/user) — 배관 유형(계곡부형/보완형) 제안 근거. */ - source?: PipeSource; - /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면 계산의 입력. */ - design_flow_m3s?: number | null; - /** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */ - options?: Record; -} - -export interface StructuresSection { - root: HTMLElement; - /** 계곡 통과 시설 부속 옵션 서브폼 — B06이 조정창 행을 이 폼의 유입구·유출구 - * 그룹으로 옮겨 붙이고 유입측 형식을 맞춘다(2026-08-29 일원화). */ - facility: FacilityOptionsForm; - /** 배치된 구조물 목록(
    ) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다 - * (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */ - listRoot: HTMLElement; - /** 서버에서 받은 타입 목록을 채운다(최초 1회). */ - setTypes: (types: StructureType[]) => void; - /** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */ - setStructures: (structures: StructureInstance[]) => void; - getStructures: () => StructureInstance[]; - /** 계곡 통과 시설 목록을 병합 표시한다(정본 = pipe_points, 배수유역 패널 경유). */ - setPipeFacilities: (pipes: PipeFacilityItem[]) => void; - /** 그래프·3D에서 고른 계곡 통과 시설을 목록에서 강조한다(null = 해제). */ - selectPipeByChainage: (chainageM: number | null) => void; - /** 지금 폼이 **어떤 항목을 고쳐 쓰는 중**인가 — 선택이 없으면 폼 값은 아무 데도 - * 가지 않는다(B06에서 그 상태를 안내하는 데 쓴다, 2026-08-30). */ - hasSelection: () => boolean; - /** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */ - selectById: (structureId: string | null) => void; - /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ - addAt: (chainageM: number, typeId: string) => void; - /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ - moveById: (structureId: string, toChainageM: number) => boolean; - removeById: (structureId: string) => boolean; -} - -interface StructuresCallbacks { - /** 목록이 바뀔 때(추가·수정·삭제) 전체 목록을 넘긴다. */ - onChange: (structures: StructureInstance[]) => void; - /** 목록에서 고르거나 해제할 때. */ - onSelect: (structure: StructureInstance | null) => void; - /** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다. */ - getInterval: () => number; - /** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). - * attributes에 시설 종류·구간·부속 옵션이 담긴다. */ - onPipeAdd: (chainageM: number, attributes: FacilityAttributes) => void; - /** 계곡 통과 시설 수정 — 기준점 이동·구간·부속 옵션 반영(관 지점 정본 경유). */ - onPipeUpdate: ( - fromChainageM: number, - toChainageM: number, - attributes: FacilityAttributes, - ) => void; - /** 계곡 통과 시설 삭제. */ - onPipeRemove: (chainageM: number) => void; - /** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). - * null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */ - onPipeSelect: (chainageM: number | null) => void; -} +/** 공개 타입·표시 상수는 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): StructuresSection { // 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만. @@ -514,361 +449,71 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }); } - function loadForm(target: StructureInstance | null): void { - // 다른 항목으로 넘어가면 임시 배치(A군 미리보기)는 취소된다. - cancelTempPipe(); - editingId = target?.structure_id ?? null; - selectedPipeChainage = null; - // 위치가 실려 열리는 폼(기존 항목)은 확정 상태, 빈 폼은 미확정으로 시작한다. - positionConfirmed = target !== null; - const step = interval(); - if (target) { - const type = typeMap().get(target.type_id); - if (type) { - groupSelect.value = type.group; - syncTypeOptions(target.type_id); - } - anchorFields.write(structureAnchorM(target), step); - startFields.write(target.start_m ?? null, step); - endFields.write(target.end_m ?? null, step); - memoField.value = target.memo ?? ""; - renderOptionFields(target.options); - // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 - // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). - if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { - const lengthEntry = optionInputs.find((entry) => entry.key === "length_m"); - if (lengthEntry && lengthEntry.isEmpty()) { - lengthEntry.input.value = (target.end_m - target.start_m).toFixed(1); - } - const beforeEntry = optionInputs.find((entry) => entry.key === "before_m"); - if (beforeEntry && target.options.before_m === undefined) { - // 옵션이 없던 시절 항목은 기본값(5)이 아니라 저장된 기준−시작을 그대로 쓴다 - // — 0이어도 명시해야 기본값이 범위를 옆으로 밀지 않는다. - const before = Math.max(structureAnchorM(target) - target.start_m, 0); - beforeEntry.input.value = before.toFixed(1); - } - const afterEntry = optionInputs.find((entry) => entry.key === "after_m"); - if (afterEntry && target.options.after_m === undefined) { - const after = Math.max(target.end_m - structureAnchorM(target), 0); - afterEntry.input.value = after.toFixed(1); - } - } - syncFacilityForm(target.options); - } else { - anchorFields.write(null, step); - startFields.write(null, step); - endFields.write(null, step); - memoField.value = ""; - renderOptionFields(); - syncFacilityForm(); - } - [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); - syncPlacementFields(); - syncButtons(); - renderList(); - callbacks.onSelect(target); - } + /* 폼 ↔ 정본 왕복(불러오기·저장)은 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); - /** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정). - * 정본이 관 지점이라 editingId 대신 선택 누가거리로 추적한다. */ - function loadPipeForm(pipe: PipeFacilityItem): void { - // 임시 배치 중인 그 관이 재계산을 거쳐 되돌아온 경우 — 사용자가 폼에 입력하는 - // 중이므로 폼을 다시 그리지 않는다(입력이 지워진다, 2026-08-18 사용자 보고). - // 재계산으로 갱신된 설계유량만 살짝 반영하고 선택 표시를 맞춘다. - // 관은 계획선에 스냅되며 입력 누가거리와 살짝 어긋날 수 있다 — 0.51m까지 같은 - // 관으로 본다(다른 곳의 관 매칭 기준과 동일). - const isTemp = tempPipeChainage !== null && Math.abs(pipe.chainage_m - tempPipeChainage) < 0.51; - if (isTemp) { - tempPipeChainage = pipe.chainage_m; - selectedPipeChainage = pipe.chainage_m; - facilityOptions.setDesignFlow(pipe.design_flow_m3s ?? null); - syncButtons(); - renderList(); - return; - } - // 다른 항목으로 넘어가는 것이므로 임시 배치를 취소한다. - cancelTempPipe(); - const hadStructure = editingId !== null; - editingId = null; - selectedPipeChainage = pipe.chainage_m; - positionConfirmed = true; // 기존 관 — 위치가 실려 열린다. - 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(null, step); - endFields.write(null, step); - memoField.value = ""; - syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null); - [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); - syncPlacementFields(); - syncButtons(); - renderList(); - if (hadStructure) callbacks.onSelect(null); - } - - function readOptions(): Record { - // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. - if (facilityFormKind(currentType())) return facilityOptions.readOptions(); - const values: Record = {}; - optionInputs.forEach((input) => { - // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. - if (input.isEmpty()) return; - values[input.key] = input.read(); - }); - return values; - } - - function emit(): void { - renderList(); - callbacks.onChange([...structures]); - } - - async function commit(live = false): Promise { - const type = currentType(); - if (!type) return; - const step = interval(); - - // 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다 - // (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다. - if (type.managed_by) { - const anchor = anchorFields.read(step, true); - if (anchor === null) { - anchorFields.station.focus(); - return; - } - // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). - const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; - const options = facilityOptions.readOptions(); - if (Object.keys(options).length) attributes.options = options; - if (tempPipeChainage !== null) { - // 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영. - const from = tempPipeChainage; - tempPipeChainage = null; - callbacks.onPipeUpdate(from, anchor, attributes); - } else if (selectedPipeChainage !== null) { - callbacks.onPipeUpdate(selectedPipeChainage, anchor, attributes); - } else { - callbacks.onPipeAdd(anchor, attributes); - } - // 실시간 반영 중에는 폼을 닫지 않는다 — 이어서 다른 칸도 고쳐야 한다. - if (!live) loadForm(null); - return; - } - - const placement = type.placement; - let anchor: number | null; - let start: number | null = null; - let end: number | null = null; - if (placement === "interval" && hasComputedRange(type)) { - // 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이 - // (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절. - anchor = anchorFields.read(step, true); - if (anchor === null) { - anchorFields.station.focus(); - return; - } - const length = readLengthM(); - if (length === null) { - optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); - return; - } - const before = readBeforeM(); - if (anchor - before < -0.005) { - const beforeInput = optionInputs.find((entry) => entry.key === "before_m")?.input; - beforeInput?.classList.add("is-invalid"); - beforeInput?.focus(); - return; - } - start = Math.max(anchor - before, 0); - end = start + length; - } else if (placement === "interval") { - start = startFields.read(step, true); - end = endFields.read(step, true); - if (start === null || end === null || end <= start) { - (start === null ? startFields.station : endFields.station).focus(); - return; - } - // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. - anchor = anchorFields.read(step, false) ?? start; - if (anchor < start || anchor > end) { - anchorFields.station.classList.add("is-invalid"); - anchorFields.station.focus(); - return; - } - } else { - anchor = anchorFields.read(step, true); - if (anchor === null) { - anchorFields.station.focus(); - return; - } - } - // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. - // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). - const missing = optionInputs.find((entry) => entry.required && entry.isEmpty()); - if (missing) { - missing.input.focus(); - return; - } - - const base = { - type_id: type.type_id, - placement, - chainage_m: anchor, - start_m: start, - end_m: end, - options: readOptions(), - memo: memoField.value.trim(), - placement_source: "manual" as const, - status: "draft" as const, - revision: 0, - geometry: null, - }; - - // 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다 - // (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다. - const spans = - type.type_id === "pavement_concrete" && start !== null && end !== null - ? await splitPavementRange(start, end, pipeFacilities) - : [{ start, end }]; - if (!spans) return; - const records = spans.map((span) => ({ - ...base, - start_m: span.start, - end_m: span.end, - chainage_m: - span.start === null || span.end === null - ? anchor - : Math.min(Math.max(anchor as number, span.start), span.end), - })); - - if (editingId) { - const index = structures.findIndex((entry) => entry.structure_id === editingId); - if (index >= 0) structures[index] = { ...structures[index], ...records[0] }; - records.slice(1).forEach((record) => structures.push({ ...record, structure_id: null })); - } else { - records.forEach((record) => structures.push({ ...record, structure_id: null })); - } - if (!live) loadForm(null); - emit(); - } - - groupSelect.addEventListener("change", () => { - cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). - syncTypeOptions(); - // 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다). - if (editingId) { - editingId = null; - syncButtons(); - renderList(); - callbacks.onSelect(null); - } - }); - typeSelect.addEventListener("change", () => { - cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). - syncPlacementFields(); - renderOptionFields(); - // 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 — - // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: - // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). - syncFacilityForm(); - if (editingId) { - editingId = null; - syncButtons(); - renderList(); - callbacks.onSelect(null); - } - }); - primary.addEventListener("click", () => void commit()); - removeButton.addEventListener("click", () => { - // 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18). - if (tempPipeChainage !== null) { - resetForm(); - return; - } - // 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유). - if (selectedPipeChainage !== null) { - callbacks.onPipeRemove(selectedPipeChainage); - selectedPipeChainage = null; - syncButtons(); - renderList(); - return; - } - if (!editingId) return; - const index = structures.findIndex((entry) => entry.structure_id === editingId); - if (index >= 0) structures.splice(index, 1); - loadForm(null); - emit(); - }); - // 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시). - // 임시 배치 취소도 겸한다(2026-08-18). - resetButton.addEventListener("click", resetForm); - // 측점 입력 감시 — 옵션 잠금 해제 판정(2026-08-18). - [anchorFields, startFields, endFields].forEach((fields) => - [fields.station, fields.remainder].forEach((input) => - input.addEventListener("change", handleStationInput), - ), - ); - // 측점번호+잔여거리가 **둘 다 확정된 순간** 임시 배치·옵션 활성화가 바로 나간다 - // (2026-08-18 사용자 지시 — 입력군을 떠나기 전이라도). 잔여거리 값이 확정되면 - // 즉시, 측점번호 수정은 잔여거리가 이미 있을 때만 즉시. - anchorFields.remainder.addEventListener("change", () => commitPosition()); - anchorFields.station.addEventListener("change", () => { - if (anchorFields.remainder.value.trim() !== "") commitPosition(); - }); - // 그 외(잔여거리를 안 쓰는 정측점 입력 등)는 입력군에서 포커스가 완전히 빠져나간 - // 뒤에 확정한다 — 측점번호만 넣고 잔여거리 칸으로 옮기는 중간에는 나가지 않는다. - // Enter로도 바로 나갈 수 있다. - positionRow.addEventListener("focusout", () => { - // 다음 포커스 대상이 확정된 다음 프레임에 판단한다(relatedTarget은 브라우저에 - // 따라 비어 온다). - window.requestAnimationFrame(() => { - if (positionRow.contains(document.activeElement)) return; - commitPosition(); - }); - }); - /** 지금 보이는 위치 입력 칸들(화면 순서). Tab 흐름 판정에 쓴다. */ - function visiblePositionInputs(): HTMLInputElement[] { - return [startFields, anchorFields, endFields] - .filter((fields) => !fields.wrap.hidden) - .flatMap((fields) => [fields.station, fields.remainder]); - } - - /** 상세 옵션의 첫 입력 칸으로 포커스 — 없으면 메모로. */ - function focusFirstDetailInput(): void { - const target = - optionInputs.find((entry) => !entry.input.disabled && !optionRow.hidden)?.input ?? - facilityOptions.root.querySelector( - "input:not(:disabled), select:not(:disabled)", - ) ?? - memoField; - target.focus(); - } - - positionRow.addEventListener("keydown", (event) => { - // Enter = 입력 확정 — 포커스를 빼서 focusout 경로 하나로 처리한다(측점번호+ - // 잔여거리 합산값이 그대로 임시 배치 좌표가 된다). - if (event.key === "Enter" && document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - // Tab = 화면 순서(위→아래)대로 다음 입력인 상세 옵션 첫 칸으로. 위치 확정으로 - // 옵션 잠금이 풀리는 시점이 브라우저 기본 탭 계산보다 늦어, disabled 상태를 본 - // 탭이 메모까지 건너뛰던 문제(2026-08-19 사용자 보고). - if (event.key === "Tab" && !event.shiftKey) { - const inputs = visiblePositionInputs(); - if (document.activeElement === inputs[inputs.length - 1]) { - event.preventDefault(); - (document.activeElement as HTMLElement).blur(); // change → 위치 확정 → 잠금 해제 - window.requestAnimationFrame(focusFirstDetailInput); - } - } - }); - - memoField.addEventListener("change", () => liveCommit()); + /* 입력 배선은 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(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts new file mode 100644 index 00000000..f9fd343e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -0,0 +1,312 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Panel_Commit.ts + * 구조물 배치 패널의 **폼 ↔ 정본 왕복** — 폼 불러오기(구조물·계곡 통과 시설)와 + * [추가]/[수정] 저장, 목록 알림. + * + * `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 검증 순서·값·분기는 옮기기 전 그대로이고, 패널 클로저가 쥐던 값만 `ctx` 로 받는다 + * (가변 상태 4개는 읽기 함수 + set* 로 넘긴다 — 주인은 그대로 패널이다). + * ========================================================================== */ + +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "./B05_Profile_Api_Structures"; +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; +import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; +import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; +import type { + PipeFacilityItem, + StructuresCallbacks, +} from "./B05_Profile_UI_Structures_Panel_Types"; + +/** 패널이 쥔 값 중 폼 왕복·저장에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ +export interface StructuresCommitContext { + callbacks: StructuresCallbacks; + structures: StructureInstance[]; + groupSelect: HTMLSelectElement; + memoField: HTMLInputElement; + anchorFields: StationFields; + startFields: StationFields; + endFields: StationFields; + facilityOptions: FacilityOptionsForm; + optionInputs: Array<{ + key: string; + required: boolean; + input: HTMLInputElement | HTMLSelectElement; + read: () => string | number; + isEmpty: () => boolean; + }>; + /** 계곡 통과 시설 목록 — 포장 구간이 물넘이와 겹치는지 판정할 때 쓴다. */ + pipeFacilities: () => PipeFacilityItem[]; + interval: () => number; + typeMap: () => Map; + currentType: () => StructureType | null; + facilityFormKind: (type: StructureType | null) => PipeFacility | null; + hasComputedRange: (type: StructureType | null) => boolean; + readLengthM: () => number | null; + readBeforeM: () => number; + cancelTempPipe: () => void; + renderList: () => void; + renderOptionFields: (values?: Record) => void; + syncButtons: () => void; + syncFacilityForm: (options?: Record, designFlow?: number | null) => void; + syncPlacementFields: () => void; + syncTypeOptions: (keepTypeId?: string) => void; + /* 가변 상태 — 주인은 패널이다. */ + editingId: () => string | null; + setEditingId: (value: string | null) => void; + selectedPipeChainage: () => number | null; + setSelectedPipeChainage: (value: number | null) => void; + positionConfirmed: () => boolean; + setPositionConfirmed: (value: boolean) => void; + tempPipeChainage: () => number | null; + setTempPipeChainage: (value: number | null) => void; +} + +export function loadForm(ctx: StructuresCommitContext, target: StructureInstance | null): void { + // 다른 항목으로 넘어가면 임시 배치(A군 미리보기)는 취소된다. + ctx.cancelTempPipe(); + ctx.setEditingId(target?.structure_id ?? null); + ctx.setSelectedPipeChainage(null); + // 위치가 실려 열리는 폼(기존 항목)은 확정 상태, 빈 폼은 미확정으로 시작한다. + ctx.setPositionConfirmed(target !== null); + const step = ctx.interval(); + if (target) { + const type = ctx.typeMap().get(target.type_id); + if (type) { + ctx.groupSelect.value = type.group; + ctx.syncTypeOptions(target.type_id); + } + ctx.anchorFields.write(structureAnchorM(target), step); + ctx.startFields.write(target.start_m ?? null, step); + ctx.endFields.write(target.end_m ?? null, step); + ctx.memoField.value = target.memo ?? ""; + ctx.renderOptionFields(target.options); + // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 + // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). + if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { + const lengthEntry = ctx.optionInputs.find((entry) => entry.key === "length_m"); + if (lengthEntry && lengthEntry.isEmpty()) { + lengthEntry.input.value = (target.end_m - target.start_m).toFixed(1); + } + const beforeEntry = ctx.optionInputs.find((entry) => entry.key === "before_m"); + if (beforeEntry && target.options.before_m === undefined) { + // 옵션이 없던 시절 항목은 기본값(5)이 아니라 저장된 기준−시작을 그대로 쓴다 + // — 0이어도 명시해야 기본값이 범위를 옆으로 밀지 않는다. + const before = Math.max(structureAnchorM(target) - target.start_m, 0); + beforeEntry.input.value = before.toFixed(1); + } + const afterEntry = ctx.optionInputs.find((entry) => entry.key === "after_m"); + if (afterEntry && target.options.after_m === undefined) { + const after = Math.max(target.end_m - structureAnchorM(target), 0); + afterEntry.input.value = after.toFixed(1); + } + } + ctx.syncFacilityForm(target.options); + } else { + ctx.anchorFields.write(null, step); + ctx.startFields.write(null, step); + ctx.endFields.write(null, step); + ctx.memoField.value = ""; + ctx.renderOptionFields(); + ctx.syncFacilityForm(); + } + [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); + ctx.syncPlacementFields(); + ctx.syncButtons(); + ctx.renderList(); + ctx.callbacks.onSelect(target); +} + +/** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정). + * 정본이 관 지점이라 ctx.editingId() 대신 선택 누가거리로 추적한다. */ +export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityItem): void { + // 임시 배치 중인 그 관이 재계산을 거쳐 되돌아온 경우 — 사용자가 폼에 입력하는 + // 중이므로 폼을 다시 그리지 않는다(입력이 지워진다, 2026-08-18 사용자 보고). + // 재계산으로 갱신된 설계유량만 살짝 반영하고 선택 표시를 맞춘다. + // 관은 계획선에 스냅되며 입력 누가거리와 살짝 어긋날 수 있다 — 0.51m까지 같은 + // 관으로 본다(다른 곳의 관 매칭 기준과 동일). + const isTemp = + ctx.tempPipeChainage() !== null && + Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < 0.51; + if (isTemp) { + ctx.setTempPipeChainage(pipe.chainage_m); + ctx.setSelectedPipeChainage(pipe.chainage_m); + ctx.facilityOptions.setDesignFlow(pipe.design_flow_m3s ?? null); + ctx.syncButtons(); + ctx.renderList(); + return; + } + // 다른 항목으로 넘어가는 것이므로 임시 배치를 취소한다. + ctx.cancelTempPipe(); + const hadStructure = ctx.editingId() !== null; + ctx.setEditingId(null); + ctx.setSelectedPipeChainage(pipe.chainage_m); + ctx.setPositionConfirmed(true); // 기존 관 — 위치가 실려 열린다. + const step = ctx.interval(); + const type = ctx.typeMap().get(pipe.facility); + if (type) { + ctx.groupSelect.value = type.group; + ctx.syncTypeOptions(pipe.facility); + } + ctx.anchorFields.write(pipe.chainage_m, step); + // 계곡 통과 시설은 점형 — 시작·종료 칸은 감춰지므로 비워 둔다. + ctx.startFields.write(null, step); + ctx.endFields.write(null, step); + ctx.memoField.value = ""; + ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null); + [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); + ctx.syncPlacementFields(); + ctx.syncButtons(); + ctx.renderList(); + if (hadStructure) ctx.callbacks.onSelect(null); +} + +export function readOptions(ctx: StructuresCommitContext): Record { + // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. + if (ctx.facilityFormKind(ctx.currentType())) return ctx.facilityOptions.readOptions(); + const values: Record = {}; + ctx.optionInputs.forEach((input) => { + // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. + if (input.isEmpty()) return; + values[input.key] = input.read(); + }); + return values; +} + +export function emit(ctx: StructuresCommitContext): void { + ctx.renderList(); + ctx.callbacks.onChange([...ctx.structures]); +} + +export async function commit(ctx: StructuresCommitContext, live = false): Promise { + const type = ctx.currentType(); + if (!type) return; + const step = ctx.interval(); + + // 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다 + // (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다. + if (type.managed_by) { + const anchor = ctx.anchorFields.read(step, true); + if (anchor === null) { + ctx.anchorFields.station.focus(); + return; + } + // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). + const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; + const options = ctx.facilityOptions.readOptions(); + if (Object.keys(options).length) attributes.options = options; + if (ctx.tempPipeChainage() !== null) { + // 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영. + const from = ctx.tempPipeChainage() as number; + ctx.setTempPipeChainage(null); + ctx.callbacks.onPipeUpdate(from, anchor, attributes); + } else if (ctx.selectedPipeChainage() !== null) { + ctx.callbacks.onPipeUpdate(ctx.selectedPipeChainage() as number, anchor, attributes); + } else { + ctx.callbacks.onPipeAdd(anchor, attributes); + } + // 실시간 반영 중에는 폼을 닫지 않는다 — 이어서 다른 칸도 고쳐야 한다. + if (!live) loadForm(ctx, null); + return; + } + + const placement = type.placement; + let anchor: number | null; + let start: number | null = null; + let end: number | null = null; + if (placement === "interval" && ctx.hasComputedRange(type)) { + // 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이 + // (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절. + anchor = ctx.anchorFields.read(step, true); + if (anchor === null) { + ctx.anchorFields.station.focus(); + return; + } + const length = ctx.readLengthM(); + if (length === null) { + ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); + return; + } + const before = ctx.readBeforeM(); + if (anchor - before < -0.005) { + const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input; + beforeInput?.classList.add("is-invalid"); + beforeInput?.focus(); + return; + } + start = Math.max(anchor - before, 0); + end = start + length; + } else if (placement === "interval") { + start = ctx.startFields.read(step, true); + end = ctx.endFields.read(step, true); + if (start === null || end === null || end <= start) { + (start === null ? ctx.startFields.station : ctx.endFields.station).focus(); + return; + } + // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. + anchor = ctx.anchorFields.read(step, false) ?? start; + if (anchor < start || anchor > end) { + ctx.anchorFields.station.classList.add("is-invalid"); + ctx.anchorFields.station.focus(); + return; + } + } else { + anchor = ctx.anchorFields.read(step, true); + if (anchor === null) { + ctx.anchorFields.station.focus(); + return; + } + } + // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. + // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). + const missing = ctx.optionInputs.find((entry) => entry.required && entry.isEmpty()); + if (missing) { + missing.input.focus(); + return; + } + + const base = { + type_id: type.type_id, + placement, + chainage_m: anchor, + start_m: start, + end_m: end, + options: readOptions(ctx), + memo: ctx.memoField.value.trim(), + placement_source: "manual" as const, + status: "draft" as const, + revision: 0, + geometry: null, + }; + + // 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다 + // (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다. + const spans = + type.type_id === "pavement_concrete" && start !== null && end !== null + ? await splitPavementRange(start, end, ctx.pipeFacilities()) + : [{ start, end }]; + if (!spans) return; + const records = spans.map((span) => ({ + ...base, + start_m: span.start, + end_m: span.end, + chainage_m: + span.start === null || span.end === null + ? anchor + : Math.min(Math.max(anchor as number, span.start), span.end), + })); + + if (ctx.editingId()) { + const index = ctx.structures.findIndex((entry) => entry.structure_id === ctx.editingId()); + if (index >= 0) ctx.structures[index] = { ...ctx.structures[index], ...records[0] }; + records.slice(1).forEach((record) => ctx.structures.push({ ...record, structure_id: null })); + } else { + records.forEach((record) => ctx.structures.push({ ...record, structure_id: null })); + } + if (!live) loadForm(ctx, null); + emit(ctx); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts new file mode 100644 index 00000000..163e45ed --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Panel_Events.ts + * 구조물 배치 패널의 **입력 배선** — 구조물군·종류 드롭다운, [추가]/[삭제]/[리셋], + * 측점 입력 확정(change·focusout·Enter·Tab), 메모 변경. + * + * `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 리스너 본문·순서는 옮기기 전 그대로이고, 패널이 쥔 값만 `ctx` 로 받는다. + * 처리 함수(commit·loadForm 등)는 그대로 패널 소유다 — 여기서는 이어 붙이기만 한다. + * ========================================================================== */ + +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import type { StructuresCommitContext } from "./B05_Profile_UI_Structures_Panel_Commit"; + +/** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */ +/** 배선에 필요한 값 — 저장 쪽(`StructuresCommitContext`)과 겹치는 것은 그대로 물려받는다. */ +export interface StructuresEventContext extends StructuresCommitContext { + typeSelect: HTMLSelectElement; + primary: HTMLButtonElement; + removeButton: HTMLButtonElement; + resetButton: HTMLButtonElement; + positionRow: HTMLElement; + types: () => StructureType[]; + commit: (live?: boolean) => Promise; + resetForm: () => void; + handleStationInput: () => void; + commitPosition: () => void; + liveCommit: () => void; + loadForm: (target: StructureInstance | null) => void; + emit: () => void; + optionRow: HTMLElement; +} + +export function bindStructuresEvents(ctx: StructuresEventContext): void { + ctx.groupSelect.addEventListener("change", () => { + ctx.cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). + ctx.syncTypeOptions(); + // 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다). + if (ctx.editingId()) { + ctx.setEditingId(null); + ctx.syncButtons(); + ctx.renderList(); + ctx.callbacks.onSelect(null); + } + }); + ctx.typeSelect.addEventListener("change", () => { + ctx.cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). + ctx.syncPlacementFields(); + ctx.renderOptionFields(); + // 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 — + // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: + // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). + ctx.syncFacilityForm(); + if (ctx.editingId()) { + ctx.setEditingId(null); + ctx.syncButtons(); + ctx.renderList(); + ctx.callbacks.onSelect(null); + } + }); + ctx.primary.addEventListener("click", () => void ctx.commit()); + ctx.removeButton.addEventListener("click", () => { + // 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18). + if (ctx.tempPipeChainage() !== null) { + ctx.resetForm(); + return; + } + // 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유). + if (ctx.selectedPipeChainage() !== null) { + ctx.callbacks.onPipeRemove(ctx.selectedPipeChainage() as number); + ctx.setSelectedPipeChainage(null); + ctx.syncButtons(); + ctx.renderList(); + return; + } + if (!ctx.editingId()) return; + const index = ctx.structures.findIndex((entry) => entry.structure_id === ctx.editingId()); + if (index >= 0) ctx.structures.splice(index, 1); + ctx.loadForm(null); + ctx.emit(); + }); + // 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시). + // 임시 배치 취소도 겸한다(2026-08-18). + ctx.resetButton.addEventListener("click", ctx.resetForm); + // 측점 입력 감시 — 옵션 잠금 해제 판정(2026-08-18). + [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => + [fields.station, fields.remainder].forEach((input) => + input.addEventListener("change", ctx.handleStationInput), + ), + ); + // 측점번호+잔여거리가 **둘 다 확정된 순간** 임시 배치·옵션 활성화가 바로 나간다 + // (2026-08-18 사용자 지시 — 입력군을 떠나기 전이라도). 잔여거리 값이 확정되면 + // 즉시, 측점번호 수정은 잔여거리가 이미 있을 때만 즉시. + ctx.anchorFields.remainder.addEventListener("change", () => ctx.commitPosition()); + ctx.anchorFields.station.addEventListener("change", () => { + if (ctx.anchorFields.remainder.value.trim() !== "") ctx.commitPosition(); + }); + // 그 외(잔여거리를 안 쓰는 정측점 입력 등)는 입력군에서 포커스가 완전히 빠져나간 + // 뒤에 확정한다 — 측점번호만 넣고 잔여거리 칸으로 옮기는 중간에는 나가지 않는다. + // Enter로도 바로 나갈 수 있다. + ctx.positionRow.addEventListener("focusout", () => { + // 다음 포커스 대상이 확정된 다음 프레임에 판단한다(relatedTarget은 브라우저에 + // 따라 비어 온다). + window.requestAnimationFrame(() => { + if (ctx.positionRow.contains(document.activeElement)) return; + ctx.commitPosition(); + }); + }); + /** 지금 보이는 위치 입력 칸들(화면 순서). Tab 흐름 판정에 쓴다. */ + function visiblePositionInputs(): HTMLInputElement[] { + return [ctx.startFields, ctx.anchorFields, ctx.endFields] + .filter((fields) => !fields.wrap.hidden) + .flatMap((fields) => [fields.station, fields.remainder]); + } + + /** 상세 옵션의 첫 입력 칸으로 포커스 — 없으면 메모로. */ + function focusFirstDetailInput(): void { + const target = + ctx.optionInputs.find((entry) => !entry.input.disabled && !ctx.optionRow.hidden)?.input ?? + ctx.facilityOptions.root.querySelector( + "input:not(:disabled), select:not(:disabled)", + ) ?? + ctx.memoField; + target.focus(); + } + + ctx.positionRow.addEventListener("keydown", (event) => { + // Enter = 입력 확정 — 포커스를 빼서 focusout 경로 하나로 처리한다(측점번호+ + // 잔여거리 합산값이 그대로 임시 배치 좌표가 된다). + if (event.key === "Enter" && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + // Tab = 화면 순서(위→아래)대로 다음 입력인 상세 옵션 첫 칸으로. 위치 확정으로 + // 옵션 잠금이 풀리는 시점이 브라우저 기본 탭 계산보다 늦어, disabled 상태를 본 + // 탭이 메모까지 건너뛰던 문제(2026-08-19 사용자 보고). + if (event.key === "Tab" && !event.shiftKey) { + const inputs = visiblePositionInputs(); + if (document.activeElement === inputs[inputs.length - 1]) { + event.preventDefault(); + (document.activeElement as HTMLElement).blur(); // change → 위치 확정 → 잠금 해제 + window.requestAnimationFrame(focusFirstDetailInput); + } + } + }); + + ctx.memoField.addEventListener("change", () => ctx.liveCommit()); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts new file mode 100644 index 00000000..7ee04506 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -0,0 +1,94 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Panel_Types.ts + * 구조물 배치 패널의 공개 타입과 표시 상수. + * + * `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 **타입·상수만** 떼어낸 조각이다 + * (2026-09-04). 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 + * 그대로 동작한다. + * ========================================================================== */ + +import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; + +/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. + * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ +export const GROUP_LABELS: Record = { + A: "A 횡단배수", + B: "B 종단배수", + C: "C 사면안정", + D: "D 계류·사방", + E: "E 안전·부대·용지", + F: "F 생태·녹화", + G: "G 노면공", + 호환: "기타", +}; + +/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ +export const DEFAULT_INTERVAL_LENGTH_M = 15; + +/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시·폼 편집용 항목. */ +export interface PipeFacilityItem { + chainage_m: number; + facility: PipeFacility; + start_m?: number; + end_m?: number; + /** 자동 배치 출처(stream/spacing/user) — 배관 유형(계곡부형/보완형) 제안 근거. */ + source?: PipeSource; + /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면 계산의 입력. */ + design_flow_m3s?: number | null; + /** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */ + options?: Record; +} + +export interface StructuresSection { + root: HTMLElement; + /** 계곡 통과 시설 부속 옵션 서브폼 — B06이 조정창 행을 이 폼의 유입구·유출구 + * 그룹으로 옮겨 붙이고 유입측 형식을 맞춘다(2026-08-29 일원화). */ + facility: FacilityOptionsForm; + /** 배치된 구조물 목록(
      ) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다 + * (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */ + listRoot: HTMLElement; + /** 서버에서 받은 타입 목록을 채운다(최초 1회). */ + setTypes: (types: StructureType[]) => void; + /** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */ + setStructures: (structures: StructureInstance[]) => void; + getStructures: () => StructureInstance[]; + /** 계곡 통과 시설 목록을 병합 표시한다(정본 = pipe_points, 배수유역 패널 경유). */ + setPipeFacilities: (pipes: PipeFacilityItem[]) => void; + /** 그래프·3D에서 고른 계곡 통과 시설을 목록에서 강조한다(null = 해제). */ + selectPipeByChainage: (chainageM: number | null) => void; + /** 지금 폼이 **어떤 항목을 고쳐 쓰는 중**인가 — 선택이 없으면 폼 값은 아무 데도 + * 가지 않는다(B06에서 그 상태를 안내하는 데 쓴다, 2026-08-30). */ + hasSelection: () => boolean; + /** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */ + selectById: (structureId: string | null) => void; + /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ + addAt: (chainageM: number, typeId: string) => void; + /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ + moveById: (structureId: string, toChainageM: number) => boolean; + removeById: (structureId: string) => boolean; +} + +export interface StructuresCallbacks { + /** 목록이 바뀔 때(추가·수정·삭제) 전체 목록을 넘긴다. */ + onChange: (structures: StructureInstance[]) => void; + /** 목록에서 고르거나 해제할 때. */ + onSelect: (structure: StructureInstance | null) => void; + /** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다. */ + getInterval: () => number; + /** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). + * attributes에 시설 종류·구간·부속 옵션이 담긴다. */ + onPipeAdd: (chainageM: number, attributes: FacilityAttributes) => void; + /** 계곡 통과 시설 수정 — 기준점 이동·구간·부속 옵션 반영(관 지점 정본 경유). */ + onPipeUpdate: ( + fromChainageM: number, + toChainageM: number, + attributes: FacilityAttributes, + ) => void; + /** 계곡 통과 시설 삭제. */ + onPipeRemove: (chainageM: number) => void; + /** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). + * null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */ + onPipeSelect: (chainageM: number | null) => void; +}