- 본체 규격을 실무 관측 규격 프리셋(기본 2.0×2.0·3.0×3.0)으로 받고, 그 밖의 규격만 "사용자 지정"으로 폭·높이를 직접 입력한다. 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로 — 프리셋 전용 키를 만들지 않아야 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다 - 날개벽 유입·유출을 createWingFields 한 조각으로 묶고(기존 기슭막이 createRevetmentFields와 같은 패턴) 설치 있음·짧은쪽 높이 1m·길이 2m· 각도 45°를 프리필. 설치 "없음"이면 제원 칸을 접는다 - setFacility 빈값 폴백을 기본값으로 — 빈 문자열 대입으로 select가 selectedIndex=-1(공백 표시)되던 문제도 함께 해소 - 레지스트리 정본에 위 기본값 10건 등재하고 required·phase:detail 해제. 이 값들은 B05 서브폼이 실제로 받으므로 detail 표기가 정본과 어긋나 있었다 - addAt의 managed_by 분기에 BOX암거 한정 defaultOptions 동봉(방어선 — 현재 우클릭 메뉴는 managed_by 타입을 제외하므로 도달하지 않는다) 기본값 전부 2026-08-17 사용자 확정. 검증: tmp/tests 101 passed·7 skipped, npm run typecheck 무오류, prettier·ruff format 변경 없음. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
754 lines
31 KiB
TypeScript
754 lines
31 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Structures_Panel.ts
|
|
* 「구조물 배치」 사이드바 섹션 — 구조물군(A~G) → 타입 → 위치(측점) → B05 옵션.
|
|
*
|
|
* 구 「구조물 배치」(비정규 측점, 자유 텍스트)를 흡수한 단일 섹션이다(2026-08-17
|
|
* 컨테이너 병합). 위치 입력은 측점번호+잔여거리 두 칸이고, 순서는 시작 측점 →
|
|
* 기준 측점 → 종료 측점이다(점형은 기준 측점만). 저장은 누가거리(m)로 한다.
|
|
*
|
|
* 계곡 통과 시설(배관/BOX암거/물넘이/세월교, A군 managed_by)도 **같은 폼**에서
|
|
* 편집한다 — 자동 배치 배관이든 수동 배관이든 같은 구조물이라 별도 UI를 두지
|
|
* 않는다(2026-08-17 사용자 지시). 저장소만 관 지점 정본(`pipe_points.json`)이라
|
|
* 추가·수정·삭제를 콜백으로 배수유역 패널에 넘긴다. 부속 옵션(유형·집수정·
|
|
* 기슭막이·돌붙임·날개벽)은 옵션 자리의 서브폼이 그린다. 상세 치수는 B06/B07 몫.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
defaultOptions,
|
|
isB05Option,
|
|
structureAnchorM,
|
|
type StructureInstance,
|
|
type StructurePlacement,
|
|
type StructureSide,
|
|
type StructureType,
|
|
} from "./B05_Profile_Api_Structures";
|
|
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import {
|
|
createFacilityOptionsForm,
|
|
type FacilityAttributes,
|
|
} from "./B05_Profile_UI_Drainage_Facility";
|
|
import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields";
|
|
import { formatStation } from "./B05_Profile_Util_Station";
|
|
|
|
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */
|
|
const GROUP_LABELS: Record<string, string> = {
|
|
A: "A 횡단배수",
|
|
B: "B 종단배수",
|
|
C: "C 사면안정",
|
|
D: "D 계류·사방",
|
|
E: "E 안전·부대·용지",
|
|
F: "F 생태·녹화",
|
|
G: "G 노면공",
|
|
호환: "기타",
|
|
};
|
|
|
|
const SIDE_LABELS: Array<[StructureSide, string]> = [
|
|
["left", "좌측"],
|
|
["right", "우측"],
|
|
["center", "중심"],
|
|
["cross", "횡단"],
|
|
];
|
|
|
|
/** 구간형 신규 추가 시 기본 구간 길이(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;
|
|
/** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */
|
|
options?: Record<string, string | number>;
|
|
}
|
|
|
|
export interface StructuresSection {
|
|
root: 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;
|
|
/** 종단도·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;
|
|
/** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). */
|
|
onPipeSelect: (chainageM: number) => void;
|
|
}
|
|
|
|
export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection {
|
|
const root = document.createElement("section");
|
|
root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section";
|
|
const heading = document.createElement("h3");
|
|
heading.className = "ui-collapsible__title";
|
|
heading.textContent = "구조물 배치";
|
|
const body = document.createElement("div");
|
|
body.className = "b05-route__panel-body";
|
|
root.append(heading, body);
|
|
|
|
const groupSelect = select([]);
|
|
const typeSelect = select([]);
|
|
const sideSelect = select(SIDE_LABELS);
|
|
// 위치 입력 순서 = 시작 측점 → 기준 측점 → 종료 측점 (2026-08-17 사용자 확정).
|
|
// 점형은 기준 측점만 보인다.
|
|
const startFields = stationFields("시작 측점");
|
|
const anchorFields = stationFields("기준 측점");
|
|
const endFields = stationFields("종료 측점");
|
|
const offsetField = numberInput();
|
|
offsetField.value = "0";
|
|
const memoField = document.createElement("input");
|
|
memoField.type = "text";
|
|
memoField.placeholder = "메모(선택)";
|
|
|
|
// 측점은 3행 — 한 행이 [라벨][측점번호][잔여거리](2026-08-17 사용자 지시 2).
|
|
const positionRow = document.createElement("div");
|
|
positionRow.className = "b05-structure__position-row";
|
|
positionRow.append(startFields.wrap, anchorFields.wrap, endFields.wrap);
|
|
|
|
// 기본 인터페이스는 2열 격자(2026-08-17 사용자 지시 1).
|
|
const typeRow = document.createElement("div");
|
|
typeRow.className = "b05-structure__grid";
|
|
typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect));
|
|
|
|
const sideRow = document.createElement("div");
|
|
sideRow.className = "b05-structure__grid";
|
|
sideRow.append(field("설치측", sideSelect), field("이격 (m)", offsetField));
|
|
|
|
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
|
|
const optionRow = document.createElement("div");
|
|
optionRow.className = "b05-structure__grid";
|
|
|
|
// 계곡 통과 시설(배관 등)의 부속 옵션 서브폼 — 일반 구조물의 옵션 칸과 같은
|
|
// 자리에서, 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17
|
|
// 사용자 지시 — 자동·수동 배관은 같은 구조물, 별도 UI 금지). 기슭막이 길이를
|
|
// 넣으면 시작·종료 측점이 기준 − 앞 ~ 기준 + 뒤로 자동 채워진다.
|
|
const facilityOptions = createFacilityOptionsForm();
|
|
|
|
/** 이 타입이 서브폼(계곡 통과 시설 부속·독립 기슭막이)을 쓰는지 — 쓰면 레지스트리
|
|
* 옵션 칸 대신 서브폼이 그린다(2026-08-17 사용자 지시 12: 독립 기슭막이도 같은
|
|
* 인터페이스). */
|
|
function facilityFormKind(type: StructureType | null): PipeFacility | "revetment" | null {
|
|
if (!type) return null;
|
|
if (type.managed_by) return type.type_id as PipeFacility;
|
|
return type.type_id === "revetment" ? "revetment" : null;
|
|
}
|
|
|
|
const primary = document.createElement("button");
|
|
primary.type = "button";
|
|
primary.className = "b05-route__irregular-btn is-primary";
|
|
const removeButton = document.createElement("button");
|
|
removeButton.type = "button";
|
|
removeButton.className = "b05-route__irregular-btn is-danger";
|
|
removeButton.textContent = "삭제";
|
|
const resetButton = document.createElement("button");
|
|
resetButton.type = "button";
|
|
resetButton.className = "b05-route__irregular-btn";
|
|
resetButton.textContent = "리셋";
|
|
const actions = document.createElement("div");
|
|
actions.className = "b05-route__irregular-actions";
|
|
actions.append(primary, removeButton, resetButton);
|
|
|
|
const list = document.createElement("ul");
|
|
list.className = "b05-route__irregular-list";
|
|
// 사용법 설명 문단은 두지 않는다 — 공간 대비 의미 없음(2026-08-17 사용자 지시,
|
|
// 필요 시 별도 설명 페이지로).
|
|
|
|
// 측점 그룹과 옵션 나열 사이 구분선(2026-08-17 사용자 지시 2).
|
|
const positionDivider = document.createElement("hr");
|
|
positionDivider.className = "b05-structure__divider";
|
|
|
|
body.append(
|
|
typeRow,
|
|
positionRow,
|
|
positionDivider,
|
|
sideRow,
|
|
optionRow,
|
|
facilityOptions.root,
|
|
actions,
|
|
list,
|
|
);
|
|
|
|
let types: StructureType[] = [];
|
|
let structures: StructureInstance[] = [];
|
|
let pipeFacilities: PipeFacilityItem[] = [];
|
|
let editingId: string | null = null;
|
|
/** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */
|
|
let selectedPipeChainage: number | null = null;
|
|
let optionInputs: Array<{
|
|
key: string;
|
|
required: boolean;
|
|
input: HTMLInputElement | HTMLSelectElement;
|
|
read: () => string | number;
|
|
isEmpty: () => boolean;
|
|
}> = [];
|
|
|
|
function typeMap(): Map<string, StructureType> {
|
|
return new Map(types.map((type) => [type.type_id, type]));
|
|
}
|
|
|
|
function currentType(): StructureType | null {
|
|
return typeMap().get(typeSelect.value) ?? null;
|
|
}
|
|
|
|
function placementOf(typeId: string): StructurePlacement {
|
|
return typeMap().get(typeId)?.placement ?? "point";
|
|
}
|
|
|
|
const interval = (): number => callbacks.getInterval();
|
|
|
|
/** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설(배수관 등)은 점형이라
|
|
* 기준 측점 하나만 받고(2026-08-17 사용자 지시 2 — 시작·종료 불필요), 설치측·이격도
|
|
* 정본(관 지점) 소관이라 감춘다. 종류 미선택(빈값)이면 입력 칸 전체를 숨긴다 —
|
|
* 리셋 직후 상태. */
|
|
function syncPlacementFields(): void {
|
|
const type = currentType();
|
|
const managed = !!type?.managed_by;
|
|
const isInterval = !!type && !managed && type.placement === "interval";
|
|
positionRow.hidden = !type;
|
|
positionDivider.hidden = !type;
|
|
startFields.wrap.hidden = !isInterval;
|
|
endFields.wrap.hidden = !isInterval;
|
|
sideRow.hidden = !type || managed;
|
|
primary.disabled = !type;
|
|
anchorFields.wrap.querySelector("span")!.textContent = isInterval
|
|
? "기준 측점 (비우면 시작)"
|
|
: "기준 측점";
|
|
}
|
|
|
|
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */
|
|
function syncFacilityForm(options: Record<string, string | number> = {}): void {
|
|
facilityOptions.setFacility(facilityFormKind(currentType()), options);
|
|
}
|
|
|
|
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07
|
|
* 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */
|
|
function renderOptionFields(values: Record<string, string | number> = {}): void {
|
|
const type = currentType();
|
|
optionInputs = [];
|
|
optionRow.replaceChildren();
|
|
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
|
|
const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : [];
|
|
if (!visible.length) {
|
|
optionRow.hidden = true;
|
|
return;
|
|
}
|
|
optionRow.hidden = false;
|
|
visible.forEach((option) => {
|
|
const preset = values[option.key] ?? option.default ?? "";
|
|
let input: HTMLInputElement | HTMLSelectElement;
|
|
if (option.input === "select") {
|
|
// 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은
|
|
// 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1).
|
|
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
|
|
input = select(choices);
|
|
input.value = String(preset || (option.choices[0] ?? ""));
|
|
} else if (option.input === "number") {
|
|
input = numberInput("0.1", "0");
|
|
input.value = String(preset ?? "");
|
|
if (option.required) (input as HTMLInputElement).placeholder = "필수 입력";
|
|
} else {
|
|
input = document.createElement("input");
|
|
(input as HTMLInputElement).type = "text";
|
|
input.value = String(preset ?? "");
|
|
}
|
|
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
|
|
optionRow.append(field(label, input));
|
|
optionInputs.push({
|
|
key: option.key,
|
|
required: !!option.required,
|
|
input,
|
|
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
|
|
isEmpty: () => input.value.trim() === "",
|
|
});
|
|
});
|
|
}
|
|
|
|
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
|
|
* 포함한다(추가 시 관 지점 정본으로 간다, 2026-08-17 통합). 첫 항목은 빈칸 —
|
|
* 초기·리셋 상태를 "안 고름"으로 드러낸다. */
|
|
function syncTypeOptions(keepTypeId?: string): void {
|
|
const group = groupSelect.value;
|
|
const candidates = group ? types.filter((type) => type.group === group) : [];
|
|
typeSelect.replaceChildren(
|
|
new Option("— 선택 —", ""),
|
|
...candidates.map((type) => new Option(type.name, type.type_id)),
|
|
);
|
|
typeSelect.value =
|
|
keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : "";
|
|
syncPlacementFields();
|
|
renderOptionFields();
|
|
syncFacilityForm();
|
|
}
|
|
|
|
function syncButtons(): void {
|
|
primary.textContent = editingId || selectedPipeChainage !== null ? "수정" : "추가";
|
|
removeButton.disabled = editingId === null && selectedPipeChainage === null;
|
|
}
|
|
|
|
function labelOfStructure(structure: StructureInstance): string {
|
|
const type = typeMap().get(structure.type_id);
|
|
const name = type?.name ?? structure.type_id;
|
|
const step = interval();
|
|
const position =
|
|
structure.placement === "interval"
|
|
? `${formatStation(structure.start_m ?? 0, step)}~${formatStation(structure.end_m ?? 0, step)}`
|
|
: formatStation(structure.chainage_m ?? 0, step);
|
|
return `${position} · ${name}`;
|
|
}
|
|
|
|
function labelOfPipe(pipe: PipeFacilityItem): string {
|
|
const type = typeMap().get(pipe.facility);
|
|
const name = type?.name ?? pipe.facility;
|
|
// 계곡 통과 시설은 점형 — 기준 측점 하나로 표기한다(2026-08-17 지시 2).
|
|
return `${formatStation(pipe.chainage_m, interval())} · ${name}`;
|
|
}
|
|
|
|
function renderList(): void {
|
|
list.replaceChildren();
|
|
// 두 정본을 하나의 목록으로 — 기준점 오름차순 병합 (2026-08-17 통합 표시).
|
|
const rows: Array<
|
|
| { kind: "structure"; anchor: number; structure: StructureInstance }
|
|
| { kind: "pipe"; anchor: number; pipe: PipeFacilityItem }
|
|
> = [
|
|
...structures.map((structure) => ({
|
|
kind: "structure" as const,
|
|
anchor: structureAnchorM(structure),
|
|
structure,
|
|
})),
|
|
...pipeFacilities.map((pipe) => ({
|
|
kind: "pipe" as const,
|
|
anchor: pipe.chainage_m,
|
|
pipe,
|
|
})),
|
|
].sort((left, right) => left.anchor - right.anchor);
|
|
|
|
if (!rows.length) {
|
|
const empty = document.createElement("li");
|
|
empty.className = "b05-route__irregular-empty";
|
|
empty.textContent = "배치된 구조물이 없습니다.";
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
rows.forEach((row) => {
|
|
const item = document.createElement("li");
|
|
item.className = "b05-route__irregular-item";
|
|
const name = document.createElement("strong");
|
|
const info = document.createElement("span");
|
|
if (row.kind === "structure") {
|
|
const { structure } = row;
|
|
item.classList.toggle("is-selected", structure.structure_id === editingId);
|
|
name.textContent = labelOfStructure(structure);
|
|
const sideLabel = SIDE_LABELS.find(([value]) => value === structure.side)?.[1] ?? "";
|
|
info.textContent = [sideLabel, structure.memo].filter(Boolean).join(" · ");
|
|
item.addEventListener("click", () =>
|
|
loadForm(structure.structure_id === editingId ? null : structure),
|
|
);
|
|
if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" });
|
|
} else {
|
|
const { pipe } = row;
|
|
item.classList.toggle(
|
|
"is-selected",
|
|
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05,
|
|
);
|
|
name.textContent = labelOfPipe(pipe);
|
|
info.textContent = "배수유역 연동";
|
|
item.addEventListener("click", () => {
|
|
loadPipeForm(pipe);
|
|
callbacks.onPipeSelect(pipe.chainage_m);
|
|
});
|
|
}
|
|
item.append(name, info);
|
|
list.append(item);
|
|
});
|
|
}
|
|
|
|
function loadForm(target: StructureInstance | null): void {
|
|
editingId = target?.structure_id ?? null;
|
|
selectedPipeChainage = 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);
|
|
sideSelect.value = target.side;
|
|
offsetField.value = String(target.offset_m ?? 0);
|
|
memoField.value = target.memo ?? "";
|
|
renderOptionFields(target.options);
|
|
syncFacilityForm(target.options);
|
|
} else {
|
|
anchorFields.write(null, step);
|
|
startFields.write(null, step);
|
|
endFields.write(null, step);
|
|
offsetField.value = "0";
|
|
memoField.value = "";
|
|
renderOptionFields();
|
|
syncFacilityForm();
|
|
}
|
|
[anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid());
|
|
syncPlacementFields();
|
|
syncButtons();
|
|
renderList();
|
|
callbacks.onSelect(target);
|
|
}
|
|
|
|
/** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정).
|
|
* 정본이 관 지점이라 editingId 대신 선택 누가거리로 추적한다. */
|
|
function loadPipeForm(pipe: PipeFacilityItem): void {
|
|
const hadStructure = editingId !== null;
|
|
editingId = null;
|
|
selectedPipeChainage = pipe.chainage_m;
|
|
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 ?? {});
|
|
[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]);
|
|
}
|
|
|
|
function commit(): 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 (selectedPipeChainage !== null) {
|
|
callbacks.onPipeUpdate(selectedPipeChainage, anchor, attributes);
|
|
} else {
|
|
callbacks.onPipeAdd(anchor, attributes);
|
|
}
|
|
loadForm(null);
|
|
return;
|
|
}
|
|
|
|
const placement = type.placement;
|
|
let anchor: number | null;
|
|
let start: number | null = null;
|
|
let end: number | null = null;
|
|
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,
|
|
side: sideSelect.value as StructureSide,
|
|
offset_m: Number(offsetField.value) || 0,
|
|
options: readOptions(),
|
|
memo: memoField.value.trim(),
|
|
placement_source: "manual" as const,
|
|
status: "draft" as const,
|
|
revision: 0,
|
|
geometry: null,
|
|
};
|
|
|
|
if (editingId) {
|
|
const index = structures.findIndex((entry) => entry.structure_id === editingId);
|
|
if (index >= 0) structures[index] = { ...structures[index], ...base };
|
|
} else {
|
|
structures.push({ ...base, structure_id: null });
|
|
}
|
|
loadForm(null);
|
|
emit();
|
|
}
|
|
|
|
groupSelect.addEventListener("change", () => {
|
|
syncTypeOptions();
|
|
// 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다).
|
|
if (editingId) {
|
|
editingId = null;
|
|
syncButtons();
|
|
renderList();
|
|
callbacks.onSelect(null);
|
|
}
|
|
});
|
|
typeSelect.addEventListener("change", () => {
|
|
syncPlacementFields();
|
|
renderOptionFields();
|
|
// 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 —
|
|
// 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시:
|
|
// 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다).
|
|
syncFacilityForm();
|
|
if (editingId) {
|
|
editingId = null;
|
|
syncButtons();
|
|
renderList();
|
|
callbacks.onSelect(null);
|
|
}
|
|
});
|
|
primary.addEventListener("click", commit);
|
|
removeButton.addEventListener("click", () => {
|
|
// 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유).
|
|
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();
|
|
});
|
|
resetButton.addEventListener("click", () => {
|
|
// 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시).
|
|
groupSelect.value = "";
|
|
syncTypeOptions();
|
|
loadForm(null);
|
|
});
|
|
|
|
body.insertBefore(field("메모", memoField), actions);
|
|
syncButtons();
|
|
renderList();
|
|
|
|
return {
|
|
root,
|
|
setTypes(next) {
|
|
types = next;
|
|
const groups = [...new Set(next.map((t) => t.group))];
|
|
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다.
|
|
groupSelect.replaceChildren(
|
|
new Option("— 선택 —", ""),
|
|
...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)),
|
|
);
|
|
groupSelect.value = "";
|
|
syncTypeOptions();
|
|
},
|
|
setStructures(next) {
|
|
// 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어
|
|
// 저장→재조회→주입→저장의 무한 고리가 된다(변경 알림은 사용자 조작에서만).
|
|
structures = next.map((entry) => ({ ...entry }));
|
|
loadForm(null);
|
|
renderList();
|
|
},
|
|
getStructures: () => [...structures],
|
|
setPipeFacilities(pipes) {
|
|
pipeFacilities = pipes.map((pipe) => ({ ...pipe }));
|
|
if (
|
|
selectedPipeChainage !== null &&
|
|
!pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05)
|
|
) {
|
|
// 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다.
|
|
selectedPipeChainage = null;
|
|
facilityOptions.setFacility(null);
|
|
syncButtons();
|
|
}
|
|
renderList();
|
|
},
|
|
selectPipeByChainage(chainageM) {
|
|
if (chainageM === null) {
|
|
if (selectedPipeChainage === null) return;
|
|
selectedPipeChainage = null;
|
|
facilityOptions.setFacility(null);
|
|
syncButtons();
|
|
renderList();
|
|
return;
|
|
}
|
|
// 그래프·3D·배수유역도에서 고른 관도 목록 강조 + 폼 로드까지 — 어느 경로든
|
|
// 같은 편집 화면이 열린다(2026-08-17 전역 선택 동기화).
|
|
const pipe = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.05);
|
|
if (!pipe) return;
|
|
loadPipeForm(pipe);
|
|
},
|
|
selectById(structureId) {
|
|
if (structureId === null) {
|
|
loadForm(null);
|
|
return;
|
|
}
|
|
loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null);
|
|
},
|
|
addAt(chainageM, typeId) {
|
|
const type = typeMap().get(typeId);
|
|
if (!type) return;
|
|
// 계곡 통과 시설은 관 지점 정본으로 바로 보낸다(옵션은 추가 후 폼에서).
|
|
// BOX암거만 예외로 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을
|
|
// 함께 싣는다 — 폼을 열지 않고 넣어도 하류가 제원을 받는다(2026-08-17 사용자
|
|
// 확정). 배관·물넘이·세월교는 부속을 설계자가 고를 몫이라 빈 채로 둔다.
|
|
if (type.managed_by) {
|
|
const attributes: FacilityAttributes = { facility: typeId as PipeFacility };
|
|
if (typeId === "box_culvert") attributes.options = defaultOptions(type);
|
|
callbacks.onPipeAdd(chainageM, attributes);
|
|
return;
|
|
}
|
|
const placement = placementOf(typeId);
|
|
const step = interval();
|
|
|
|
// B05 단계 필수 옵션이 있는 타입만 폼을 연다 — 상세(detail) 필수는 B06/B07
|
|
// 몫이라 바로 추가해도 서버가 받는다(2026-08-17 phase 분리).
|
|
if (type.options.some((option) => option.required && isB05Option(option))) {
|
|
groupSelect.value = type.group;
|
|
syncTypeOptions(typeId);
|
|
typeSelect.value = typeId;
|
|
editingId = null;
|
|
anchorFields.write(chainageM, step);
|
|
startFields.write(placement === "interval" ? chainageM : null, step);
|
|
endFields.write(
|
|
placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null,
|
|
step,
|
|
);
|
|
sideSelect.value = "center";
|
|
offsetField.value = "0";
|
|
memoField.value = "";
|
|
syncPlacementFields();
|
|
renderOptionFields();
|
|
syncButtons();
|
|
renderList();
|
|
root.scrollIntoView({ block: "nearest" });
|
|
const firstRequired = optionInputs.find((entry) => entry.required);
|
|
(firstRequired?.input ?? anchorFields.station).focus();
|
|
return;
|
|
}
|
|
|
|
structures.push({
|
|
structure_id: null,
|
|
type_id: typeId,
|
|
placement,
|
|
chainage_m: chainageM,
|
|
start_m: placement === "interval" ? chainageM : null,
|
|
end_m: placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null,
|
|
side: "center",
|
|
offset_m: 0,
|
|
options: defaultOptions(type),
|
|
memo: "",
|
|
placement_source: "manual",
|
|
status: "draft",
|
|
revision: 0,
|
|
geometry: null,
|
|
});
|
|
emit();
|
|
},
|
|
moveById(structureId, toChainageM) {
|
|
const index = structures.findIndex((entry) => entry.structure_id === structureId);
|
|
if (index < 0) return false;
|
|
const target = structures[index];
|
|
if (target.placement === "interval") {
|
|
// 구간은 길이를 유지한 채 통째로 옮긴다 — 기준점을 끌면 시작·종료가 따라온다.
|
|
const length = (target.end_m ?? 0) - (target.start_m ?? 0);
|
|
const anchorOffset = structureAnchorM(target) - (target.start_m ?? 0);
|
|
const start = toChainageM - anchorOffset;
|
|
structures[index] = {
|
|
...target,
|
|
chainage_m: toChainageM,
|
|
start_m: start,
|
|
end_m: start + length,
|
|
};
|
|
} else {
|
|
structures[index] = { ...target, chainage_m: toChainageM };
|
|
}
|
|
emit();
|
|
return true;
|
|
},
|
|
removeById(structureId) {
|
|
const index = structures.findIndex((entry) => entry.structure_id === structureId);
|
|
if (index < 0) return false;
|
|
structures.splice(index, 1);
|
|
if (editingId === structureId) loadForm(null);
|
|
emit();
|
|
return true;
|
|
},
|
|
};
|
|
}
|