Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts
T
eomsangdonandClaude Opus 5 7500f37acd refactor(B05): 구조물 배치 패널 700줄 분리 (동작 불변)
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) <noreply@anthropic.com>
2026-09-04 11:37:46 +09:00

313 lines
14 KiB
TypeScript

/* =============================================================================
* 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<string, StructureType>;
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<string, string | number>) => void;
syncButtons: () => void;
syncFacilityForm: (options?: Record<string, string | number>, 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<string, string | number> {
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다.
if (ctx.facilityFormKind(ctx.currentType())) return ctx.facilityOptions.readOptions();
const values: Record<string, string | number> = {};
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<void> {
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);
}