feat(구조물): 소단을 「구조물 배치」로 옮김 — C군 사면안정 신설 (계획서 3-9, 사용자 재확정)

사용자 확정(2026-09-07) — 소단은 좌측 별도 폼이 아니라 **다른 옹벽·기슭막이와 같은 자리**
(구조물 배치)에서 놓는 것으로 바뀜. 기하는 그대로 두고 **입구만 옮겼음.**

· 레지스트리에 **C군(사면안정) 「소단」** 신설. 옵션은 다른 C군 구간형과 같은 꼴 —
  길이·기준측점 전·후 + 폭·간격(사면길이)·안쪽 기울기.
· ⚠ **소단은 C군 구간형이지만 벽이 아님** — 기슭막이 제원 자리에 얹히지 않게 파이썬·TS
  양쪽 벽 필터에서 뺐음. 안 뺐으면 소단을 놓는 순간 「독립 기슭막이」로 그려졌을 것임.
· 구조물 목록이 바뀌면 소단 구간을 세션 사본(`berm`)으로 펴고, 달라졌을 때만 재계산함.
  계산 계통(브라우저·서버·`USER_TOUCHED_KEYS`·옹벽 의무 판정)은 **한 줄도 안 바꿨음**.
· ④에서 만든 좌측 별도 「소단」 패널은 걷어냈음(모듈 삭제).

**안쪽 기울기 기본값 2° → 0°** (사용자 재확정). 기울이는 것은 실무이나 법령·교본 근거가
없어 기본으로 넣지 않고 폼에서 받음. 주석·지식DB 방침도 그에 맞춰 고침.

**소단측구**(B군 종단배수)도 같은 꼴로 옵션을 붙였음 — 소단 위에 놓이는 시설이라 폭·간격이
소단을 따르고, 기울기만 **사면 쪽**(소단과 반대). 소단이 실제로 서면서
`PENDING_TYPE_IDS` 빈 칸이 풀려 연장 수량이 다른 구조물과 같은 길로 나옴.

자체검증 — 「근거 없는 기본값 금지」 시험에 두 타입 12개 기본값을 근거와 함께 등재.
소단측구 연장 시험을 「빈 칸」에서 「연장 100m」로 뒤집음. 전체 544 passed · 18 skipped.
TS 타입 검사·ruff 통과. 화면 확인은 다음 단계.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:29:50 +09:00
co-authored by Claude Opus 5
parent c7770b7529
commit 7833b9e677
9 changed files with 224 additions and 209 deletions
@@ -39,6 +39,10 @@ _FORM_BY_TYPE = {
}
# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다).
BERM_TYPE_ID = "berm"
def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
"""구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록."""
try:
@@ -48,6 +52,10 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
definition = types.get(structure.type_id)
if definition is None or definition.group != "C" or definition.placement != "interval":
continue
# 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
# 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9).
if structure.type_id == BERM_TYPE_ID:
continue
start, end = structure.start_m, structure.end_m
if start is None or end is None:
continue
+5 -1
View File
@@ -56,7 +56,11 @@ async function withDraftWalls(
const types = await fetchStructureTypes().catch(() => []);
const names = new Map(
types
.filter((type) => type.group === "C" && type.placement === "interval")
// 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
// 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9).
.filter(
(type) => type.group === "C" && type.placement === "interval" && type.type_id !== "berm",
)
.map((type) => [type.type_id, type.name] as const),
);
if (!names.size) return detail;
-185
View File
@@ -1,185 +0,0 @@
/* =============================================================================
* B06_Section_UI_Berm_Panel.ts
* 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9).
*
* 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려
* (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다
* 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지
* 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다.
*
* 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고,
* 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서
* 정본으로 나간다(CLAUDE.md 5장 데이터 3층).
* ========================================================================== */
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import {
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
} from "@util/common_util_cross_berm";
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
import { formatStation } from "../B05_Profile/B05_Profile_Util_Station";
import { buildGroup } from "./B06_Section_UI_Page_Common";
import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist";
/** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */
const DEFAULT_BEFORE_M = 5;
const DEFAULT_AFTER_M = 5;
export interface BermPanelDeps {
/** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */
getInterval: () => number;
/** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */
target: () => { projectId: string; routeId: number } | null;
/** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */
onChange: () => void;
}
export interface BermPanel {
root: HTMLElement;
/** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */
reload: () => void;
}
function numberField(
label: string,
value: number,
step: string,
): ReturnType<typeof createInputField> {
const field = createInputField({ label, type: "number" });
field.input.step = step;
field.input.min = "0";
field.input.value = String(value);
return field;
}
export function createBermPanel(deps: BermPanelDeps): BermPanel {
const root = buildGroup("소단");
const anchor = stationFields("기준 측점", () => deps.getInterval());
const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5");
const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5");
const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1");
const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5");
const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5");
const spanRow = document.createElement("div");
spanRow.className = "b05-structure__grid";
spanRow.append(beforeField.root, afterField.root);
const specRow = document.createElement("div");
specRow.className = "b05-structure__grid";
specRow.append(widthField.root, intervalField.root);
const slopeRow = document.createElement("div");
slopeRow.className = "b05-structure__grid";
slopeRow.append(slopeField.root);
const list = document.createElement("ul");
list.className = "b05-route__irregular-list";
let selected = -1;
const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() });
const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() });
removeButton.classList.add("is-danger");
removeButton.disabled = true;
const actions = document.createElement("div");
actions.className = "b06-profile__field-row";
actions.append(addButton, removeButton);
root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list);
function spans(): BermSpan[] {
const target = deps.target();
return target ? readBermSpans(target.projectId, target.routeId) : [];
}
function save(next: BermSpan[]): void {
const target = deps.target();
if (!target) return;
writeBermSpans(target.projectId, target.routeId, next);
render();
deps.onChange();
}
function readNumber(field: { input: HTMLInputElement }, fallback: number): number {
const parsed = Number(field.input.value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function add(): void {
const interval = deps.getInterval();
const chainage = anchor.read(interval, true);
if (chainage === null) {
showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error");
return;
}
const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M);
const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M);
if (width <= 0 || gap <= 0) {
showToast("소단 폭과 간격은 0보다 커야 합니다.", "error");
return;
}
const before = readNumber(beforeField, DEFAULT_BEFORE_M);
const after = readNumber(afterField, DEFAULT_AFTER_M);
save([
...spans(),
{
start_m: Math.max(chainage - before, 0),
end_m: chainage + after,
width_m: width,
interval_m: gap,
slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG),
},
]);
selected = -1;
}
function remove(): void {
if (selected < 0) return;
const next = spans().filter((_span, index) => index !== selected);
selected = -1;
save(next);
}
function render(): void {
const interval = deps.getInterval();
const current = spans();
list.replaceChildren();
removeButton.disabled = selected < 0 || selected >= current.length;
if (!current.length) {
const empty = document.createElement("li");
empty.className = "b05-route__irregular-empty";
empty.textContent = "놓은 소단이 없습니다.";
list.append(empty);
return;
}
current.forEach((span, index) => {
const item = document.createElement("li");
item.className = "b05-route__irregular-item";
item.classList.toggle("is-selected", index === selected);
const where = document.createElement("strong");
where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`;
const what = document.createElement("span");
what.textContent = `${span.width_m}m · ${span.interval_m}m마다`;
item.append(where, what);
item.addEventListener("click", () => {
selected = index === selected ? -1 : index;
render();
});
list.append(item);
});
}
render();
return {
root,
reload(): void {
selected = -1;
render();
},
};
}
+22 -11
View File
@@ -24,11 +24,13 @@ import {
} from "./B06_Section_Api_Fetch";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import { createBermPanel } from "./B06_Section_UI_Berm_Panel";
import {
bermSpansFromStructures,
confirmCurrentSections,
createRockBoundaryStore,
readBermSpans,
saveCurrentSections,
writeBermSpans,
type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
@@ -150,7 +152,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
reveal: () => layout.setOptionsOpen(true),
// 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4).
// 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다.
onMarks: (structures, types) => structureMarksSink?.(structures, types),
onMarks: (structures, types) => {
structureMarksSink?.(structures, types);
syncBermSpans(structures);
},
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시
// 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정).
onStructuresChanged: () => void refreshDetailForStructures(),
@@ -207,14 +212,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
// 소단은 사용자가 구간에 놓는다(2026-09-07 확정) — 폼은 전용 모듈에 있다(700줄 제한).
const bermPanel = createBermPanel({
getInterval: () => stationInterval ?? 20,
target: () =>
projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null,
onChange: () => void reconcileStaleDesigns({ force: true }),
});
leftForm.append(structuresPanel.root, viewGroup, bermPanel.root, standardGroup, actionDock);
leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock);
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
attachCollapsible(leftForm);
@@ -337,6 +335,20 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
}
/**
* 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다.
*
* 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만
* 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다.
*/
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
if (!projectId || currentRouteId === null) return;
const next = bermSpansFromStructures(structures);
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
writeBermSpans(projectId, currentRouteId, next);
void reconcileStaleDesigns({ force: true });
}
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
async function refreshDetailForStructures(): Promise<void> {
if (!projectId || currentRouteId === null) return;
@@ -726,7 +738,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
stationInterval = storedOptions.station_interval_m;
renderSectionDetail();
bermPanel.reload(); // 노선이 정해진 뒤라야 세션에서 소단 목록을 읽을 수 있다.
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
updateActionState();
} catch (error) {
@@ -22,6 +22,11 @@ import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structure
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state";
import {
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
} from "@util/common_util_cross_berm";
import {
applyStructureAreaRows,
STRUCTURE_AREA_KEYS,
@@ -86,6 +91,52 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp
writeState("berm", spans, projectId, routeId);
}
/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */
export const BERM_TYPE_ID = "berm";
/**
* 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다
* (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리).
*
* 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을
* 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은
* 그 사본만 본다.
*/
export function bermSpansFromStructures(
structures: ReadonlyArray<{
type_id: string;
start_m?: number | null;
end_m?: number | null;
chainage_m?: number | null;
options?: Record<string, string | number>;
}>,
): BermSpan[] {
const spans: BermSpan[] = [];
for (const item of structures) {
if (item.type_id !== BERM_TYPE_ID) continue;
const anchor = item.chainage_m ?? item.start_m ?? null;
const start = item.start_m ?? anchor;
const end = item.end_m ?? anchor;
if (start === null || end === null) continue;
const options = item.options ?? {};
const number = (key: string, fallback: number): number => {
const parsed = Number(options[key]);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
const width = number("width_m", BERM_DEFAULT_WIDTH_M);
const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M);
if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다
spans.push({
start_m: Math.min(start, end),
end_m: Math.max(start, end),
width_m: width,
interval_m: interval,
slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG),
});
}
return spans;
}
/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */
export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null {
const found = spans.find(