feat(structure): 고른 구조물 군 바꿈 = 새로 놓기 · 같은 군 종류 바꿈 = 새 종류 기본값 + 종류별 캐시 되살림 · 저장 · 확정 뒤 캐시 비움(54-32)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-06 13:53:07 +09:00
co-authored by Claude Opus 5.5
parent a65e6a4198
commit 65cf6e108c
5 changed files with 248 additions and 17 deletions
@@ -10,10 +10,16 @@
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;
@@ -31,34 +37,103 @@ export interface StructuresEventContext extends StructuresCommitContext {
}
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).
ctx.syncTypeOptions();
// 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다).
if (ctx.editingId()) {
ctx.setEditingId(null);
ctx.syncButtons();
ctx.renderList();
ctx.callbacks.onSelect(null);
}
// 고른 항목에서 군을 바꿈 = 새 항목 추가(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-4) — 실시간 반영으로 목록 · 알약 · 횡단도가 바로 따라옴
if (ctx.selectedPipeChainage() !== null && ctx.currentType()?.managed_by) ctx.liveCommit();
if (ctx.editingId()) {
ctx.setEditingId(null);
ctx.syncButtons();
ctx.renderList();
ctx.callbacks.onSelect(null);
}
});
/** 고른 항목의 종류를 같은 군 안에서 바꿈(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<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).