Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Panel.ts
T
eomsangdon 16010da5a6 feat(B05): 구조물 선택지 재편·배관 부속 옵션 — B06 이관 보류·리셋 빈값
- 레지스트리: 바닥막이·생태연못 삭제(임도 미사용 확정), B·F·G군 10종
  enabled:false 보존(B06 횡단도 옵션 재사용), 배관 부속 13종(유형/관경 기본
  1000/집수정/기슭막이/돌붙임)·BOX암거 본체+날개벽(유입·유출 개별) 옵션 정의
- FacilityEditor 재작성: 유형 제안(자동 배치 source 승격), 집수정 유무 토글로
  유입부 옵션 전환, 기슭막이 길이 총/앞/뒤 상호 연계로 범위(시작·종료) 자동
- 구조물 배치: 군·종류 빈값 옵션+리셋 시 초기화, 사용법 설명 문단 제거
- tmp/tests 98건·ruff·prettier·typecheck·build 통과 (2026-08-17 사용자 확정)
2026-08-17 14:05:25 +09:00

732 lines
28 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Structures_Panel.ts
* 「구조물 배치」 사이드바 섹션 — 구조물군(A~G) → 타입 → 위치(측점) → B05 옵션.
*
* 구 「구조물 배치」(비정규 측점, 자유 텍스트)를 흡수한 단일 섹션이다(2026-08-17
* 컨테이너 병합). 위치 입력은 측점번호+잔여거리 두 칸이고, 순서는 시작 측점 →
* 기준 측점 → 종료 측점이다(점형은 기준 측점만). 저장은 누가거리(m)로 한다.
*
* 계곡 통과 시설(배관/BOX암거/물넘이/세월교, A군 managed_by)은 관 지점 정본
* (`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 } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { chainageToStation, 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;
}
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;
/** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). */
onPipeAdd: (chainageM: number, facility: PipeFacility) => void;
/** 계곡 통과 시설 삭제. */
onPipeRemove: (chainageM: number) => void;
/** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). */
onPipeSelect: (chainageM: number) => void;
}
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
return wrapper;
}
function numberInput(step = "0.1", min = "0"): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = min;
return input;
}
function select(options: ReadonlyArray<[string, string]>): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...options.map(([value, label]) => new Option(label, value)));
return element;
}
/** 측점번호 + 잔여거리 두 칸 묶음 — 구 비정규 측점 UI와 같은 입력 방식(2026-08-17). */
interface StationFields {
wrap: HTMLElement;
station: HTMLInputElement;
remainder: HTMLInputElement;
/** 두 칸을 누가거리로 읽는다. 비었으면 null, 형식 오류면 붉히고 null. */
read: (intervalM: number, required: boolean) => number | null;
/** 누가거리를 두 칸에 나눠 싣는다. null이면 비운다. */
write: (chainageM: number | null, intervalM: number) => void;
clearInvalid: () => void;
}
function stationFields(labelText: string): StationFields {
const station = numberInput("1", "0");
station.placeholder = "측점";
const remainder = numberInput("0.1", "0");
remainder.placeholder = "+m";
const pair = document.createElement("div");
pair.className = "b05-structure__station-pair";
pair.append(station, remainder);
const wrap = field(labelText, pair);
const markInvalid = (bad: boolean): void => {
station.classList.toggle("is-invalid", bad);
remainder.classList.toggle("is-invalid", bad);
};
return {
wrap,
station,
remainder,
read(intervalM, required) {
const stationText = station.value.trim();
const remainderText = remainder.value.trim();
if (!stationText && !remainderText) {
markInvalid(required);
return null;
}
const stationNo = stationText ? Number(stationText) : 0;
const rest = remainderText ? Number(remainderText) : 0;
const valid =
Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0;
markInvalid(!valid);
return valid ? stationNo * intervalM + rest : null;
},
write(chainageM, intervalM) {
if (chainageM === null) {
station.value = "";
remainder.value = "";
return;
}
const parts = chainageToStation(chainageM, intervalM);
station.value = String(parts.station);
remainder.value = parts.remainder.toFixed(1);
},
clearInvalid: () => markInvalid(false),
};
}
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 = "메모(선택)";
const positionRow = document.createElement("div");
positionRow.className = "b05-route__irregular-row";
positionRow.append(startFields.wrap, anchorFields.wrap, endFields.wrap);
const sideRow = document.createElement("div");
sideRow.className = "b05-route__irregular-row";
sideRow.append(field("설치측", sideSelect), field("이격 (m)", offsetField));
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
const optionRow = document.createElement("div");
optionRow.className = "b05-route__irregular-row";
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 사용자 지시,
// 필요 시 별도 설명 페이지로).
body.append(
field("구조물군", groupSelect),
field("종류", typeSelect),
positionRow,
sideRow,
optionRow,
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 사용자 지시). */
function syncPlacementFields(): void {
const type = currentType();
const isInterval = !type?.managed_by && type?.placement === "interval";
positionRow.hidden = !type;
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
sideRow.hidden = !type || !!type.managed_by;
primary.disabled = !type;
anchorFields.wrap.querySelector("span")!.textContent = isInterval
? "기준 측점 (비우면 시작)"
: "기준 측점";
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07
* 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */
function renderOptionFields(values: Record<string, string | number> = {}): void {
const type = currentType();
optionInputs = [];
optionRow.replaceChildren();
const visible = type && !type.managed_by ? 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-16 크로스체크 지적 2).
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
input = select(option.required ? [["", "선택하세요"], ...choices] : choices);
input.value = String(preset || (option.required ? "" : (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();
}
function syncButtons(): void {
primary.textContent = editingId ? "수정" : "추가";
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;
const step = interval();
const position =
pipe.start_m !== undefined && pipe.end_m !== undefined && pipe.end_m > pipe.start_m
? `${formatStation(pipe.start_m, step)}~${formatStation(pipe.end_m, step)}`
: formatStation(pipe.chainage_m, step);
return `${position} · ${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", () => {
loadForm(null);
selectedPipeChainage = pipe.chainage_m;
syncButtons();
renderList();
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);
} else {
anchorFields.write(null, step);
startFields.write(null, step);
endFields.write(null, step);
offsetField.value = "0";
memoField.value = "";
renderOptionFields();
}
[anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid());
syncPlacementFields();
syncButtons();
renderList();
callbacks.onSelect(target);
}
function readOptions(): Record<string, string | number> {
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;
}
callbacks.onPipeAdd(anchor, type.type_id as PipeFacility);
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();
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;
syncButtons();
}
renderList();
},
selectPipeByChainage(chainageM) {
selectedPipeChainage = chainageM;
if (chainageM !== null && editingId) {
editingId = null;
callbacks.onSelect(null);
}
syncButtons();
renderList();
},
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;
// 계곡 통과 시설은 관 지점 정본으로 바로 보낸다.
if (type.managed_by) {
callbacks.onPipeAdd(chainageM, typeId as PipeFacility);
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;
},
};
}