사용자가 소단을 **직접 놓는** 화면을 만들었음. 프로그램이 「붕괴 우려 지역」을 판정하지 않고, 법정 기준 셋 중 무엇도 자동 적용하지 않음(2026-09-07 사용자 확정). 폼은 C군 구간형 구조물과 **같은 꼴** — 기준 측점 + 전·후 거리로 종단 범위를 잡고 폭·간격·기울기를 함께 받음. 기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 2°. 새 모듈 `B06_Section_UI_Berm_Panel.ts` 로 뺐음 — 좌측 페이지가 이미 700줄을 넘어 있어 거기 더 넣지 않고 두 줄만 꽂음(그 파일 분리는 별건). 세션에는 **구간 목록**으로 둠. 측점별로 펴서 저장하면 「어디부터 어디까지 놓았나」를 되짚을 수 없음. 읽는 자리에서 펴고, 서버에는 측점키 dict 로 실어 보냄. **확정 뒤에도 계단이 남게** — 소단 제원을 설계 결과에 되싣고(`berm`), `USER_TOUCHED_KEYS` 에 넣어 재계산이 지우지 않게 함. 서버 재계산은 세션값이 없으면 저장분을 씀(`stored_berm`). ⚠ 소단은 사용자 조작값이면서 **기하 입력**이라 다른 사용자 키와 다름 — 계산 뒤에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아 서로 어긋남. 그래서 단측점 설계 경로는 저장분을 **계산 전에** 읽도록 순서를 바꿨고, 포장 강제·세월교 노면 하강 재계산 경로에도 저장분 소단을 실었음. `extra_spans` 가 그렇게 빠져 있던 자리와 같음. 자체검증 — 새 시험 2건(저장분만으로 같은 설계가 나오는지 · 값이 없거나 손상되면 None). 전체 541 passed · 18 skipped. TS 타입 검사·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
186 lines
6.9 KiB
TypeScript
186 lines
6.9 KiB
TypeScript
/* =============================================================================
|
|
* 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();
|
|
},
|
|
};
|
|
}
|