외부 AI 교차검증 미통과 지적을 전부 수정한다. 1. 마이그레이션: 확정 저장분은 structure 문자열뿐(structureType 없음) — 라벨 파싱 판별 추가(기성막이/대피로 X.Xm/관종 D직경). 명시 필드가 라벨 파싱보다 우선. 2. 서버 검증 강화(_validate_types 확장): 레지스트리 배치형태 대조, 미정의 옵션 거절, number 옵션 유한·0 이상, select 선택지 검사, required 옵션 누락 거절, structure_id 중복 거절, 노선 연장 범위 검증(라우터가 get_latest_route로 총연장 주입, 없으면 생략). 3. 기본값 원칙: 법정 명시값(별표2 측구 30cm·대피소 5/15m 등)·사용자 기확정값(골막이)만 default 유지. 옹벽·돌쌓기 높이, 사토장·토취장 면적/용량, 포장·쇄석 두께 등 미확정 수치는 default 제거 + required (화면 placeholder "필수 입력"+빈 값 추가 차단, 서버도 거절). 4. 404: get_project_storage_relative_path는 없는 프로젝트에서 LookupError를 던짐 — _project_root에서 잡아 None, 라우터 예외 사다리에도 LookupError→404 분기. 5. STALE 정합: _invalidate_downstream이 성공 여부 반환 — invalidated_downstream은 실제 전파 성공 시에만 true. pytest 42건(tmp/tests) 통과 · tsc 0 · ruff 통과. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
486 lines
18 KiB
TypeScript
486 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Structures_Panel.ts
|
|
* 구조물 배치 사이드바 섹션 — 구조물군(A~G) → 타입 → 배치형태별 위치 → 옵션 입력.
|
|
*
|
|
* 타입 목록과 옵션 칸은 화면에 박아 두지 않고 서버 레지스트리(`GET /structure-types`)에서
|
|
* 받아 그린다. 구조물 종류가 늘어도 이 파일을 고치지 않게 하려는 것이다.
|
|
*
|
|
* 배관(구조물군 A)은 배수유역도의 관 지점이 정본이라 이 목록에서 다루지 않는다 —
|
|
* 기존 「구조물 배치」 섹션(비정규 측점)이 그대로 담당한다.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
defaultOptions,
|
|
structureAnchorM,
|
|
type StructureInstance,
|
|
type StructurePlacement,
|
|
type StructureSide,
|
|
type StructureType,
|
|
} from "./B05_Profile_Api_Structures";
|
|
|
|
/** 구조물군 표시 이름. 리스트 기호(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;
|
|
|
|
export interface StructuresSection {
|
|
root: HTMLElement;
|
|
/** 서버에서 받은 타입 목록을 채운다(최초 1회). */
|
|
setTypes: (types: StructureType[]) => void;
|
|
/** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */
|
|
setStructures: (structures: StructureInstance[]) => void;
|
|
getStructures: () => StructureInstance[];
|
|
/** 종단도·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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
const startField = numberInput();
|
|
startField.placeholder = "시점";
|
|
const endField = numberInput();
|
|
endField.placeholder = "종점";
|
|
const offsetField = numberInput();
|
|
offsetField.value = "0";
|
|
const memoField = document.createElement("input");
|
|
memoField.type = "text";
|
|
memoField.placeholder = "메모(선택)";
|
|
|
|
const startWrap = field("위치 (m)", startField);
|
|
const endWrap = field("종점 (m)", endField);
|
|
const positionRow = document.createElement("div");
|
|
positionRow.className = "b05-route__irregular-row";
|
|
positionRow.append(startWrap, endWrap);
|
|
|
|
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";
|
|
const help = document.createElement("p");
|
|
help.className = "b05-route__note";
|
|
help.textContent =
|
|
"구조물군과 종류를 고르고 위치를 입력해 추가합니다. 구간형은 시점~종점을 받고, " +
|
|
"종단도에는 시점 위치에 표시됩니다. 배관은 위 배수유역 연동 항목에서 관리합니다.";
|
|
|
|
body.append(
|
|
field("구조물군", groupSelect),
|
|
field("종류", typeSelect),
|
|
positionRow,
|
|
sideRow,
|
|
optionRow,
|
|
actions,
|
|
list,
|
|
help,
|
|
);
|
|
|
|
let types: StructureType[] = [];
|
|
let structures: StructureInstance[] = [];
|
|
let editingId: string | 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";
|
|
}
|
|
|
|
/** 배치형태에 맞춰 위치 칸을 바꾼다 — 구간형만 종점을 받는다. */
|
|
function syncPlacementFields(): void {
|
|
const placement = currentType()?.placement ?? "point";
|
|
endWrap.hidden = placement !== "interval";
|
|
startWrap.querySelector("span")!.textContent =
|
|
placement === "interval" ? "시점 (m)" : "위치 (m)";
|
|
}
|
|
|
|
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. */
|
|
function renderOptionFields(values: Record<string, string | number> = {}): void {
|
|
const type = currentType();
|
|
optionInputs = [];
|
|
optionRow.replaceChildren();
|
|
if (!type || !type.options.length) {
|
|
optionRow.hidden = true;
|
|
return;
|
|
}
|
|
optionRow.hidden = false;
|
|
type.options.forEach((option) => {
|
|
const preset = values[option.key] ?? option.default ?? "";
|
|
let input: HTMLInputElement | HTMLSelectElement;
|
|
if (option.input === "select") {
|
|
input = select(option.choices.map((choice) => [choice, choice] as [string, string]));
|
|
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() === "",
|
|
});
|
|
});
|
|
}
|
|
|
|
/** 고른 구조물군의 타입만 종류 목록에 채운다. */
|
|
function syncTypeOptions(keepTypeId?: string): void {
|
|
const group = groupSelect.value;
|
|
const candidates = types.filter((type) => type.group === group && !type.managed_by);
|
|
typeSelect.replaceChildren(...candidates.map((type) => new Option(type.name, type.type_id)));
|
|
if (keepTypeId && candidates.some((type) => type.type_id === keepTypeId)) {
|
|
typeSelect.value = keepTypeId;
|
|
}
|
|
syncPlacementFields();
|
|
renderOptionFields();
|
|
}
|
|
|
|
function syncButtons(): void {
|
|
primary.textContent = editingId ? "수정" : "추가";
|
|
removeButton.disabled = editingId === null;
|
|
}
|
|
|
|
function labelOf(structure: StructureInstance): string {
|
|
const type = typeMap().get(structure.type_id);
|
|
const name = type?.name ?? structure.type_id;
|
|
const position =
|
|
structure.placement === "interval"
|
|
? `${(structure.start_m ?? 0).toFixed(1)}~${(structure.end_m ?? 0).toFixed(1)}m`
|
|
: `${(structure.chainage_m ?? 0).toFixed(1)}m`;
|
|
return `${position} · ${name}`;
|
|
}
|
|
|
|
function renderList(): void {
|
|
list.replaceChildren();
|
|
if (!structures.length) {
|
|
const empty = document.createElement("li");
|
|
empty.className = "b05-route__irregular-empty";
|
|
empty.textContent = "추가된 구조물이 없습니다.";
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
[...structures]
|
|
.sort((left, right) => structureAnchorM(left) - structureAnchorM(right))
|
|
.forEach((structure) => {
|
|
const item = document.createElement("li");
|
|
item.className = "b05-route__irregular-item";
|
|
item.classList.toggle("is-selected", structure.structure_id === editingId);
|
|
const name = document.createElement("strong");
|
|
name.textContent = labelOf(structure);
|
|
const info = document.createElement("span");
|
|
const sideLabel = SIDE_LABELS.find(([value]) => value === structure.side)?.[1] ?? "";
|
|
info.textContent = [sideLabel, structure.memo].filter(Boolean).join(" · ");
|
|
item.append(name, info);
|
|
item.addEventListener("click", () =>
|
|
loadForm(structure.structure_id === editingId ? null : structure),
|
|
);
|
|
list.append(item);
|
|
if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" });
|
|
});
|
|
}
|
|
|
|
function loadForm(target: StructureInstance | null): void {
|
|
editingId = target?.structure_id ?? null;
|
|
if (target) {
|
|
const type = typeMap().get(target.type_id);
|
|
if (type) {
|
|
groupSelect.value = type.group;
|
|
syncTypeOptions(target.type_id);
|
|
}
|
|
startField.value = String(
|
|
target.placement === "interval" ? (target.start_m ?? 0) : (target.chainage_m ?? 0),
|
|
);
|
|
endField.value = String(target.end_m ?? "");
|
|
sideSelect.value = target.side;
|
|
offsetField.value = String(target.offset_m ?? 0);
|
|
memoField.value = target.memo ?? "";
|
|
renderOptionFields(target.options);
|
|
} else {
|
|
startField.value = "";
|
|
endField.value = "";
|
|
offsetField.value = "0";
|
|
memoField.value = "";
|
|
renderOptionFields();
|
|
}
|
|
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 start = Number.parseFloat(startField.value);
|
|
if (!Number.isFinite(start) || start < 0) {
|
|
startField.focus();
|
|
return;
|
|
}
|
|
const placement = type.placement;
|
|
let end: number | null = null;
|
|
if (placement === "interval") {
|
|
end = Number.parseFloat(endField.value);
|
|
if (!Number.isFinite(end) || end <= start) {
|
|
endField.focus();
|
|
return;
|
|
}
|
|
}
|
|
// 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다.
|
|
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: placement === "interval" ? null : start,
|
|
start_m: placement === "interval" ? start : null,
|
|
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 (!editingId) return;
|
|
const index = structures.findIndex((entry) => entry.structure_id === editingId);
|
|
if (index >= 0) structures.splice(index, 1);
|
|
loadForm(null);
|
|
emit();
|
|
});
|
|
resetButton.addEventListener("click", () => loadForm(null));
|
|
|
|
body.insertBefore(field("메모", memoField), actions);
|
|
syncButtons();
|
|
renderList();
|
|
|
|
return {
|
|
root,
|
|
setTypes(next) {
|
|
types = next;
|
|
const groups = [...new Set(next.filter((type) => !type.managed_by).map((t) => t.group))];
|
|
groupSelect.replaceChildren(
|
|
...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)),
|
|
);
|
|
// 배관(A군)은 배수유역 정본이 관리하므로 기본 선택은 그다음 군으로 둔다.
|
|
groupSelect.value = groups.includes("B") ? "B" : (groups[0] ?? "");
|
|
syncTypeOptions();
|
|
},
|
|
setStructures(next) {
|
|
// 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어
|
|
// 저장→재조회→주입→저장의 무한 고리가 된다(변경 알림은 사용자 조작에서만).
|
|
structures = next.map((entry) => ({ ...entry }));
|
|
loadForm(null);
|
|
renderList();
|
|
},
|
|
getStructures: () => [...structures],
|
|
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 || type.managed_by) return;
|
|
const placement = placementOf(typeId);
|
|
structures.push({
|
|
structure_id: null,
|
|
type_id: typeId,
|
|
placement,
|
|
chainage_m: placement === "interval" ? null : 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);
|
|
structures[index] = { ...target, start_m: toChainageM, end_m: toChainageM + 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;
|
|
},
|
|
};
|
|
}
|