/* ============================================================================= * 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"; import { asRoad } from "./B05_Profile_UI_Structures_Define"; import { readState, writeState } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item"; /** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */ /** 배선에 필요한 값 — 저장 쪽(`StructuresCommitContext`)과 겹치는 것은 그대로 물려받는다. */ export interface StructuresEventContext extends StructuresCommitContext { /** 종류별 캐시(54-32) 세션 키의 프로젝트. */ projectId: string | null; 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 { /** 새로 놓기(54-32) — 고름 · 측점 · 강조를 풀고 군(· 종류)만 남긴 빈 기본값 폼. */ function startNew(group: string, typeId?: string): void { ctx.resetForm(); ctx.groupSelect.value = group; ctx.syncTypeOptions(typeId); } ctx.groupSelect.addEventListener("change", () => { ctx.cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). // 고른 항목에서 군을 바꿈 = 새 항목 추가(54-32) — 고른 항목 값은 그대로 if (ctx.editingId() || ctx.selectedPipeChainage() !== null) startNew(ctx.groupSelect.value); else ctx.syncTypeOptions(); }); ctx.typeSelect.addEventListener("change", () => { ctx.cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). if (ctx.editingId() || ctx.selectedPipeChainage() !== null) return switchSelectedType(); ctx.syncPlacementFields(); ctx.renderOptionFields(); // 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 — // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). ctx.syncFacilityForm(); }); /** 고른 항목의 종류를 같은 군 안에서 바꿈(54-32) — 그 항목이 새 종류가 됨. 옛 종류 값은 * 종류별 캐시에 담고, 담아 둔 종류로 돌아오면 그 값으로 · 처음이면 새 종류 기본값으로. * 관 지점 시설(배관 ↔ 박스 ↔ 세월교 ↔ 물넘이)도 같은 규칙 · 실시간 반영은 54-4 길 그대로. */ function switchSelectedType(): void { const next = ctx.currentType(); const id = ctx.editingId(); const pipe = ctx.selectedPipeChainage(); const index = id ? ctx.structures.findIndex((entry) => entry.structure_id === id) : -1; const record = index >= 0 ? ctx.structures[index] : null; const pipeItem = pipe === null ? null : ctx.pipeFacilities().find((entry) => Math.abs(entry.chainage_m - pipe) < PIPE_MATCH_M); const prev = ctx.typeMap().get(record ? asRoad(record).type_id : (pipeItem?.facility ?? "")); // 정본이 다른 종류(관 지점 ↔ 구조물)로 · 빈칸으로 = 새로 놓기 if (!next || !prev || !!next.managed_by !== !!prev.managed_by) return startNew(ctx.groupSelect.value, next?.type_id); const key = record ? `s:${id}` : `p:${pipe}`; // 구조물은 정본 기록(안 보이는 상세 칸까지) · 관은 아직 옛 종류로 그려진 폼에서 읽음 stashTypeValues( key, prev.type_id, record ? { options: record.options, summary_item: record.summary_item, group_items: record.group_items, } : { options: { ...ctx.facilityOptions.readOptions(), ...ctx.groupBoxes.shapes() }, summary_item: ctx.summary(), group_items: ctx.groupBoxes.get(), }, ); const back = recallTypeValues(key, next.type_id); // 기록을 새 종류로 갈아 둠 — 되살린 값의 안 보이는 칸이 [수정] 병합에서 살게 const draft = record && { ...record, type_id: next.type_id, options: back?.options ?? {} }; if (draft) ctx.structures[index] = draft; ctx.syncPlacementFields(); ctx.renderOptionFields(back?.options); ctx.setSummary(readSummaryItem(back?.summary_item)); ctx.groupBoxes.set(readGroupItems(back?.group_items)); ctx.groupBoxes.setShapes(back?.options ?? {}); ctx.syncFacilityForm(back?.options); ctx.liveCommit(); // 반영이 막혀(측점 등) 기록이 그대로면 옛 기록으로 되돌림 if (record && draft && ctx.structures[index] === draft) ctx.structures[index] = record; } type TypeValues = { options?: Record; summary_item?: unknown; group_items?: unknown; }; type TypeCache = Record>; /** 관 지점은 재계산 스냅으로 누가거리가 살짝 옮겨짐 — 관 매칭 기준 안이면 같은 관. */ function cacheKeyOf(cache: TypeCache, key: string): string { if (!key.startsWith("p:")) return key; const at = Number(key.slice(2)); const hit = Object.keys(cache).find( (k) => k.startsWith("p:") && Math.abs(Number(k.slice(2)) - at) < PIPE_MATCH_M, ); return hit ?? key; } function stashTypeValues(key: string, typeId: string, values: TypeValues): void { const cache = readState("structure-type-cache", ctx.projectId) ?? {}; const slot = cacheKeyOf(cache, key); cache[slot] = { ...cache[slot], [typeId]: values }; writeState("structure-type-cache", cache, ctx.projectId); } function recallTypeValues(key: string, typeId: string): TypeValues | null { const cache = readState("structure-type-cache", ctx.projectId) ?? {}; return cache[cacheKeyOf(cache, key)]?.[typeId] ?? 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()); }