refactor(B05): 구조물 설치측·이격 입력 폐지 — 데이터 필드까지 제거

사용자 지시로 두 값을 걷어낸다. 구조물별 옵션·정의는 앞으로 타입마다
따로 정한다. 어차피 소비처가 없어(B06~B08은 structures를 읽지 않는다)
화면에만 남아 있던 값이었다.

- 폼: 설치측 select·이격 칸 행 삭제, 목록 부제·종단 벌룬에서 측 표기 제거
- 계약: StructureSide 타입, StructureInstance.side/offset_m 삭제(프론트·스키마)
- 설계지문에서 두 값 제외 — 후속 단계 무효화 판정에서 빠진다
- 구 저장분은 읽을 때 두 키만 떨어낸다. extra="forbid"라 그냥 두면 정본이
  통째로 버려진다. 다른 낯선 키는 계속 거절한다
This commit is contained in:
2026-08-17 20:04:32 +09:00
parent 6c6a0c2773
commit b92ba4592e
8 changed files with 23 additions and 56 deletions
@@ -15,8 +15,6 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
export type StructurePlacement = "point" | "interval" | "site";
/** 노선 기준 설치 측. 횡단(B06) 연계에 쓴다. */
export type StructureSide = "left" | "right" | "center" | "cross";
export interface StructureOptionField {
key: string;
@@ -58,8 +56,6 @@ export interface StructureInstance {
chainage_m?: number | null;
start_m?: number | null;
end_m?: number | null;
side: StructureSide;
offset_m: number;
options: Record<string, string | number>;
memo: string;
placement_source: "manual" | "suggested" | "automatic";
@@ -94,7 +94,6 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> list[Struct
data: dict[str, Any] = {
"type_id": type_id,
"placement": definition.placement,
"side": "center",
"options": _options_for(type_id, entry),
"placement_source": "manual",
}
@@ -10,7 +10,7 @@
import json
import os
import uuid
from typing import Iterable
from typing import Any, Iterable
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
from common_util.common_util_json import atomic_write_json
@@ -31,6 +31,12 @@ class StructureRevisionConflict(RuntimeError):
self.actual = actual
# 2026-08-17 사용자 지시로 걷어낸 필드 — 설치측·이격은 받지 않는다. 스키마가
# extra="forbid"라 남아 있는 저장분을 그대로 넘기면 정본 전체가 통째로 버려지므로,
# 읽을 때 이 두 키만 떨어낸다. 다른 낯선 키는 계속 거절해 정본이 조용히 썩는 것을 막는다.
_DROPPED_KEYS = ("side", "offset_m")
def structures_file_path(project_root: str) -> str:
"""프로젝트 저장소 안의 구조물 정본 경로."""
return os.path.join(project_root, _STAGE_DIR, STRUCTURES_FILE_NAME)
@@ -50,13 +56,23 @@ def load_structures(project_root: str) -> tuple[int, list[StructureInstance]]:
payload = json.load(handle)
revision = int(payload.get("revision", 0))
structures = [
StructureInstance.model_validate(item) for item in payload.get("structures", [])
StructureInstance.model_validate(_without_dropped_keys(item))
for item in payload.get("structures", [])
]
except (OSError, ValueError, TypeError):
return 0, []
return revision, structures
def _without_dropped_keys(item: Any) -> Any:
"""폐지된 필드가 남아 있는 저장분을 지금 스키마로 읽을 수 있게 손질한다."""
if not isinstance(item, dict):
return item
if not any(key in item for key in _DROPPED_KEYS):
return item
return {key: value for key, value in item.items() if key not in _DROPPED_KEYS}
def save_structures(
project_root: str,
structures: Iterable[StructureInstance],
@@ -97,7 +113,7 @@ def requires_downstream_invalidation(
메모나 표시용 값만 바뀐 경우까지 후속 단계를 깨면, 사용자가 메모 한 줄 고칠 때마다
횡단·수량을 다시 돌려야 한다. 그래서 설계에 실제로 영향을 주는 값
(타입·위치·범위·측·오프셋·제원)만 비교한다. 목록 순서는 설계와 무관하므로 무시한다.
(타입·위치·범위·제원)만 비교한다. 목록 순서는 설계와 무관하므로 무시한다.
"""
return _design_fingerprint(previous) != _design_fingerprint(current)
@@ -112,8 +128,6 @@ def _design_fingerprint(items: Iterable[StructureInstance]) -> set[str]:
item.chainage_m,
item.start_m,
item.end_m,
item.side,
item.offset_m,
item.options,
item.geometry,
],
@@ -98,8 +98,6 @@ class StructureInstance(BaseModel):
chainage_m: float | None = Field(default=None, ge=0)
start_m: float | None = Field(default=None, ge=0)
end_m: float | None = Field(default=None, ge=0)
side: Literal["left", "right", "center", "cross"] = "center"
offset_m: float = 0.0
options: dict[str, Any] = Field(default_factory=dict)
memo: str = ""
placement_source: Literal["manual", "suggested", "automatic"] = "manual"
@@ -177,8 +177,6 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
chainage_m: pipe.chainage_m,
start_m: null,
end_m: null,
side: "cross",
offset_m: 0,
options: pipe.options ?? {},
memo: "",
placement_source: "automatic",
@@ -25,8 +25,6 @@ export interface StructureListParams {
typeMap: Map<string, StructureType>;
/** 측점 표기용 측점 간격(m). */
intervalM: number;
/** 설치측 표시 이름 — 패널과 같은 목록을 쓴다. */
sideLabels: ReadonlyArray<[string, string]>;
/** 지금 폼에 올라와 있는 구조물(없으면 null). */
editingId: string | null;
/** 지금 폼에 올라와 있는 계곡 통과 시설의 누가거리(없으면 null). */
@@ -96,8 +94,7 @@ export function renderStructureList(params: StructureListParams): void {
const { structure } = row;
item.classList.toggle("is-selected", structure.structure_id === editingId);
name.textContent = labelOfStructure(structure, typeMap, intervalM);
const sideLabel = params.sideLabels.find(([value]) => value === structure.side)?.[1] ?? "";
info.textContent = [sideLabel, structure.memo].filter(Boolean).join(" · ");
info.textContent = structure.memo ?? "";
item.addEventListener("click", () => params.onSelectStructure(structure));
if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" });
} else {
@@ -79,13 +79,6 @@ function positionText(structure: StructureInstance, intervalM: number): string {
: formatStation(structure.chainage_m ?? 0, intervalM);
}
const SIDE_TEXT: Record<string, string> = {
left: "좌측",
right: "우측",
center: "중심",
cross: "횡단",
};
/** 알약 세로 자리 배정 — 이웃과 겹치는 것만 두 줄로 나누고, 나머지는 가운데에 둔다
* (2026-08-17 사용자 지시 2). 값 0 = 윗줄, 1 = 아랫줄, null = 가운데. */
function rowAssignments(
@@ -215,7 +208,7 @@ function buildBalloon(
const title = document.createElement("strong");
title.textContent = type?.name ?? structure.type_id;
const position = document.createElement("span");
position.textContent = `${positionText(structure, stationIntervalM)} · ${SIDE_TEXT[structure.side] ?? ""}`;
position.textContent = positionText(structure, stationIntervalM);
balloon.append(title, position);
const summary = optionSummary(structure, type);
+2 -30
View File
@@ -19,7 +19,6 @@ import {
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";
@@ -42,13 +41,6 @@ const GROUP_LABELS: Record<string, string> = {
: "기타",
};
const SIDE_LABELS: Array<[StructureSide, string]> = [
["left", "좌측"],
["right", "우측"],
["center", "중심"],
["cross", "횡단"],
];
/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */
const DEFAULT_INTERVAL_LENGTH_M = 15;
@@ -120,14 +112,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
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 = "메모(선택)";
@@ -142,10 +131,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
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";
@@ -193,7 +178,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
typeRow,
positionRow,
positionDivider,
sideRow,
optionRow,
facilityOptions.root,
actions,
@@ -229,9 +213,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
const interval = (): number => callbacks.getInterval();
/** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설(배수관 등)은 점형이라
* 기준 측점 하나만 받(2026-08-17 사용자 지시 2 — 시작·종료 불필요), 설치측·이격도
* 정본(관 지점) 소관이라 감춘다. 종류 미선택(빈값)이면 입력 칸 전체를 숨긴다 —
* 리셋 직후 상태. */
* 기준 측점 하나만 받는다(2026-08-17 사용자 지시 2 — 시작·종료 불필요).
* 종류 미선택(빈값)이면 입력 칸 전체를 숨긴다 — 리셋 직후 상태. */
function syncPlacementFields(): void {
const type = currentType();
const managed = !!type?.managed_by;
@@ -240,7 +223,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
positionDivider.hidden = !type;
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
sideRow.hidden = !type || managed;
primary.disabled = !type;
anchorFields.wrap.querySelector("span")!.textContent = isInterval
? "기준 측점 (비우면 시작)"
@@ -327,7 +309,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
pipeFacilities,
typeMap: typeMap(),
intervalM: interval(),
sideLabels: SIDE_LABELS,
editingId,
selectedPipeChainage,
onSelectStructure: (structure) =>
@@ -352,8 +333,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
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);
@@ -361,7 +340,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
anchorFields.write(null, step);
startFields.write(null, step);
endFields.write(null, step);
offsetField.value = "0";
memoField.value = "";
renderOptionFields();
syncFacilityForm();
@@ -480,8 +458,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
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,
@@ -636,8 +612,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
placement === "interval" ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null,
step,
);
sideSelect.value = "center";
offsetField.value = "0";
memoField.value = "";
syncPlacementFields();
renderOptionFields();
@@ -656,8 +630,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
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",