Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts
T
eomsangdonandClaude Opus 5.5 6744524669 fix(구조물표): 323-6 고침 — 지우고 새로 바꾸는 종류 바꿈 = 새 종류가 놓인 뒤에만 옛 것을 지움(못 놓으면 옛 것 · 옛 폼 그대로 + 알림) · [추가] 무반응 = 막힌 칸 이름 알림
- 반사경 → 가드레일(길이 빈칸)이 옛 것을 먼저 지우고 새 것은 못 놓아 반사경이 사라지던 것 — 패널 addAt 이 놓였는지 돌려줌 · Events replaceAt = 먼저 놓고 확인 뒤 옛 것 지움 · 못 놓으면 옛 기록 폼(관은 관 폼) + 「반사경 그대로 — 가드레일(으)로 못 바꿈」
- commit 의 조용한 return 여섯 곳 = 막힌 칸으로 커서 + [추가] 때 「「길이」 칸이 비었거나 맞지 않아 못 놓음」(실시간 반영은 알림 없음)
- 패널 줄 수 지킴(setter 한 줄로 · 697)
- 시험: test_319 바꿈 막힘 둘(구조물 · 관) · 비동기 바꿈 · test_323_6 막힌 칸 알림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:32:46 +09:00

293 lines
15 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Structures_Panel_Events.ts
* 구조물 배치 패널의 **입력 배선** — 구조물군·종류 드롭다운, [추가]/[삭제]/[리셋],
* 측점 입력 확정(change·focusout·Enter·Tab), 메모 변경.
*
* `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 떼어낸 조각이다(2026-09-04).
* 리스너 본문·순서는 옮기기 전 그대로이고, 패널이 쥔 값만 `ctx` 로 받는다.
* 처리 함수(commit·loadForm 등)는 그대로 패널 소유다 — 여기서는 이어 붙이기만 한다.
* ========================================================================== */
import {
structureAnchorM,
type StructureInstance,
type 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 { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "../A00_Common/design/design_selection";
import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item";
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { swapsInPlace } from "../common_util/common_util_structure_catalog";
import { forgetStationAdjusts } from "../B06_Section/B06_Section_Api_Culvert_Options";
/** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */
/** 배선에 필요한 값 — 저장 쪽(`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<void>;
resetForm: () => void;
handleStationInput: () => void;
commitPosition: () => void;
liveCommit: () => void;
loadForm: (target: StructureInstance | null) => void;
/** 우클릭 추가와 같은 길 — 그 측점에 그 종류를 놓고 고름(패널 `addAt` · 놓였으면 true). */
addAt: (chainageM: number, typeId: string) => Promise<boolean> | void;
/** 관 하나를 폼에 엶(패널 `selectPipeByChainage`). */
selectPipe: (chainageM: number) => void;
emit: () => void;
optionRow: HTMLElement;
}
export function bindStructuresEvents(ctx: StructuresEventContext): {
startNew: (group: string, typeId?: string) => void;
clearAll: () => Promise<void>;
} {
/** 새로 놓기(54-32) — 고름 · 측점 · 강조를 풀고 군(· 종류)만 남긴 빈 기본값 폼. */
function startNew(group: string, typeId?: string): void {
ctx.resetForm();
// 횡단 카드 · 부재 강조와 넘김 선택도 풂 — B05 → B06 넘김에서 되얹지 않게(54-38)
ctx.callbacks.onListRelease?.();
selectDesign(ctx.projectId, NO_SELECTION, { from: "b05" });
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) return startNew(ctx.groupSelect.value, next?.type_id); // 빈칸 = 새로 놓기
// 표 규칙(323-6 · `swapsInPlace`) — 정본이 다르거나(관 지점 ↔ 구조물) 구조물 자리꼴이 다르면(E · F 점형 ↔
// 구간형) 옛 것을 지워 「없음」을 거친 뒤 같은 기준 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류
// 기본값 · 구간 = 기본 길이 · 요구 217) · 옛 조정값은 318-4 대로 버림
if (!swapsInPlace(prev.type_id, next.type_id)) {
const at = record ? structureAnchorM(record) : pipeItem!.chainage_m;
return void replaceAt(at, prev, next, record);
}
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, pipeItem?.design_flow_m3s ?? null); // 유량 = 월류 높이(65-4)
ctx.liveCommit();
// 관 지점 종류 바꿈 — 그 측점 옛 조정값(세션)은 버리고 [저장] 때 정본에서도 비움(318-4)
if (pipeItem)
forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id, next.type_id);
// 반영이 막히면(측점 · 필수 칸 등) 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰 · 323-6)
if (record && draft && ctx.structures[index] === draft) {
ctx.structures[index] = record;
ctx.loadForm(record);
showToast(`${next.name}(으)로 못 바꿈 — 폼 칸(측점 · 필수 칸)을 확인하세요.`, "error");
}
}
/** 지우고 같은 기준 측점에 새로 — 새 종류가 기본값으로 놓인 뒤에만 옛 것을 지움. 못 놓으면(빈 칸 ·
* [추가] 가 막음) 옛 것 · 옛 폼 그대로 + 알림(323-6 고침 · 조용히 사라지던 것). */
async function replaceAt(
at: number,
prev: { type_id: string; name: string },
next: { type_id: string; name: string },
record: StructureInstance | null,
): Promise<void> {
if (!(await ctx.addAt(at, next.type_id))) {
if (record) ctx.loadForm(record);
else ctx.selectPipe(at);
showToast(
`${prev.name} 그대로 — ${next.name}(으)로 못 바꿈(빈 칸 때문에 바로 못 놓음)`,
"error",
);
return;
}
if (record) {
const index = ctx.structures.findIndex((e) => e.structure_id === record.structure_id);
if (index >= 0) ctx.structures.splice(index, 1);
ctx.emit();
} else ctx.callbacks.onPipeRemove(at);
forgetStationAdjusts(ctx.projectId, at, prev.type_id, next.type_id);
}
type TypeValues = {
options?: Record<string, string | number>;
summary_item?: unknown;
group_items?: unknown;
};
type TypeCache = Record<string, Record<string, TypeValues>>;
/** 관 지점은 재계산 스냅으로 누가거리가 살짝 옮겨짐 — 관 매칭 기준 안이면 같은 관. */
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<TypeCache>("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<TypeCache>("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),
),
);
// 측점번호+잔여거리가 **둘 다 확정된 순간** 옵션 활성화가 바로 나간다(입력군을 떠나기
// 전이라도 · 새 구조물은 [추가] 뒤에만 생김 · 291-1). 잔여거리 값이 확정되면
// 즉시, 측점번호 수정은 잔여거리가 이미 있을 때만 즉시.
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<HTMLElement>(
"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);
}
}
});
/** [구조물 전체 삭제](313) — 확인 한 번 · 목록 전부(자동 횡단배수 · 손 배치) · 하나씩 지우기와 같은 길
* (관 = 관 빼기 · 구조물 = 정본 배열 비우고 알림) · 캐시에만 · 작업본은 [저장] 때. */
async function clearAll(): Promise<void> {
const count = ctx.structures.length + ctx.pipeFacilities().length;
if (!count || !(await showConfirmDialog(`구조물 ${count}개를 모두 지울까요?`, "모두 삭제")))
return;
ctx.resetForm(); // 임시 배치 취소 · 폼 · 고름 풂
ctx.callbacks.onListRelease?.();
for (const pipe of [...ctx.pipeFacilities()]) ctx.callbacks.onPipeRemove(pipe.chainage_m);
if (!ctx.structures.length) return;
ctx.structures.splice(0, ctx.structures.length);
ctx.emit();
}
ctx.memoField.addEventListener("change", () => ctx.liveCommit());
return { startNew, clearAll }; // 우클릭 추가도 새로 놓기에서 시작(116-1)
}