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>
This commit is contained in:
@@ -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<string, string> = {
|
||||
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<string, string | number>;
|
||||
}
|
||||
|
||||
export interface StructuresSection {
|
||||
root: HTMLElement;
|
||||
/** 계곡 통과 시설 부속 옵션 서브폼 — B06이 조정창 행을 이 폼의 유입구·유출구
|
||||
* 그룹으로 옮겨 붙이고 유입측 형식을 맞춘다(2026-08-29 일원화). */
|
||||
facility: FacilityOptionsForm;
|
||||
/** 배치된 구조물 목록(<ul>) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다
|
||||
* (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<void> => 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<string, string | number> {
|
||||
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다.
|
||||
if (facilityFormKind(currentType())) return facilityOptions.readOptions();
|
||||
const values: Record<string, string | number> = {};
|
||||
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<void> {
|
||||
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<HTMLElement>(
|
||||
"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();
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ctx.memoField.addEventListener("change", () => ctx.liveCommit());
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, string | number>;
|
||||
}
|
||||
|
||||
export interface StructuresSection {
|
||||
root: HTMLElement;
|
||||
/** 계곡 통과 시설 부속 옵션 서브폼 — B06이 조정창 행을 이 폼의 유입구·유출구
|
||||
* 그룹으로 옮겨 붙이고 유입측 형식을 맞춘다(2026-08-29 일원화). */
|
||||
facility: FacilityOptionsForm;
|
||||
/** 배치된 구조물 목록(<ul>) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다
|
||||
* (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;
|
||||
}
|
||||
Reference in New Issue
Block a user