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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user