feat(B06): 표준 횡단면 설정을 「표준횡단면 상세값」 한 컨테이너로 통합

- 토사/암/포장 3그룹이 같은 필드를 각각 갖던 화면을 공통 한 벌로 합침 (칸 31 → 15)
- 공통 = 노폭·노견·측구·절토/성토 경사·횡단 경사, 구분선 뒤 암 = 절토 경사·L형 측구,
  구분선 뒤 포장 = 횡단 경사
- 저장 구조는 3그룹 그대로 — 공통값은 고칠 때 세 그룹에 펼쳐 넣음
- 옛 프로젝트가 그룹마다 다른 공통값을 갖고 있으면 토사 값 기준으로 한 벌 통일
  (사용자 확정 2026-09-04)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:36:46 +09:00
co-authored by Claude Opus 5
parent b51c887549
commit 4be8c19e97
3 changed files with 161 additions and 55 deletions
+146 -55
View File
@@ -1,6 +1,12 @@
/* =============================================================================
* B06_Section_UI_Standard_Panel.ts
* 좌측 사이드 "표준 횡단면 설정" 패널 (토사 / 암 / 포장 3그룹).
* 좌측 사이드 "표준 횡단면 설정" 패널 — **「표준횡단면 상세값」 한 컨테이너**(2026-09-04
* 사용자 지시). 토사·암·포장 3그룹이 같은 필드를 각각 갖던 화면을 공통 한 벌 +
* 구간별로 다른 값만 남겼다. 공통 = 노폭·노견·측구·절토/성토 경사·횡단 경사,
* 암 = 절토 경사·L형 측구, 포장 = 횡단 경사.
*
* **저장 구조는 그대로 3그룹**(`standard_cross_section`) — 화면만 합치고 저장할 때
* 공통값을 세 그룹에 펼쳐 넣는다. 백엔드·기존 프로젝트가 그대로 동작한다.
*
* 각 그룹의 노폭·노견·측구 규격·경사값을 편집한다. 기본값은 백엔드 config
* (STANDARD_CROSS_SECTION, context.standard_cross_section)에서 내려오고, 사용자가
@@ -26,12 +32,6 @@ function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [
["soil", "B06_Std_Group_Soil"],
["rock", "B06_Std_Group_Rock"],
["paved", "B06_Std_Group_Paved"],
];
const SESSION_PREFIX = "b06:std-cross:";
/** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */
const DEFAULTS_PREFIX = "b06:std-cross-default:";
@@ -151,49 +151,98 @@ export interface StandardPanelController {
applyStored: (stored: StandardCrossSection) => void;
}
/** 값을 어느 그룹에 쓸 것인가. `common` 은 세 그룹에 함께 펼쳐 넣는다. */
type FieldScope = "common" | "rock" | "paved";
interface NumberFieldSpec {
label: string;
label: keyof typeof ui_locales;
scope: FieldScope;
/** 화면에 보일 값을 읽는다 — 공통은 **토사 값이 기준**(2026-09-04 사용자 확정). */
get: (group: StandardCrossGroup) => number;
set: (group: StandardCrossGroup, value: number) => void;
/** 암 그룹의 L형 측구처럼 특정 그룹에만 존재하는 필드는 조건으로 거른다. */
only?: StandardCrossKey;
/**
* 공통값이지만 이 그룹은 따로 값을 갖는다 — 공통을 펼칠 때 건너뛴다.
* (절토 경사는 암이, 횡단 경사는 포장이 자기 값을 쓴다)
*/
exclude?: StandardCrossKey;
}
/** 그룹 하나에 노출할 편집 필드 정의. 순서 = 화면 표기 순서. */
/** 한 컨테이너에 늘어놓을 편집 필드. 순서 = 화면 표기 순서. */
const FIELD_SPECS: NumberFieldSpec[] = [
{
label: "B06_Std_Field_RoadWidth",
scope: "common",
get: (g) => g.road_width_m,
set: (g, v) => (g.road_width_m = v),
},
{
label: "B06_Std_Field_ShoulderLeft",
scope: "common",
get: (g) => g.shoulder_left_m,
set: (g, v) => (g.shoulder_left_m = v),
},
{
label: "B06_Std_Field_ShoulderRight",
scope: "common",
get: (g) => g.shoulder_right_m,
set: (g, v) => (g.shoulder_right_m = v),
},
{
label: "B06_Std_Field_DitchTop",
scope: "common",
get: (g) => g.ditch.top_width_m,
set: (g, v) => (g.ditch.top_width_m = v),
},
{
label: "B06_Std_Field_DitchBottom",
scope: "common",
get: (g) => g.ditch.bottom_width_m,
set: (g, v) => (g.ditch.bottom_width_m = v),
},
{
label: "B06_Std_Field_DitchDepth",
scope: "common",
get: (g) => g.ditch.depth_m,
set: (g, v) => (g.ditch.depth_m = v),
},
{
// 암은 아래에서 자기 절토 경사를 따로 가진다 — 공통은 토사·포장 몫이다.
label: "B06_Std_Field_CutSlope",
scope: "common",
exclude: "rock",
get: (g) => g.cut_slope_ratio,
set: (g, v) => (g.cut_slope_ratio = v),
},
{
label: "B06_Std_Field_FillSlope",
scope: "common",
get: (g) => g.fill_slope_ratio,
set: (g, v) => (g.fill_slope_ratio = v),
},
{
// 포장은 아래에서 자기 횡단 경사를 따로 가진다.
label: "B06_Std_Field_CrossSlopeMin",
scope: "common",
exclude: "paved",
get: (g) => g.cross_slope_pct.min,
set: (g, v) => (g.cross_slope_pct.min = v),
},
{
label: "B06_Std_Field_CrossSlopeMax",
scope: "common",
exclude: "paved",
get: (g) => g.cross_slope_pct.max,
set: (g, v) => (g.cross_slope_pct.max = v),
},
{
label: "B06_Std_Field_CutSlope",
scope: "rock",
get: (g) => g.cut_slope_ratio,
set: (g, v) => (g.cut_slope_ratio = v),
},
{
label: "B06_Std_Field_LDitchWidth",
only: "rock",
scope: "rock",
get: (g) => g.ditch_l_type?.width_m ?? 0,
set: (g, v) => {
g.ditch_l_type = { width_m: v, depth_m: g.ditch_l_type?.depth_m ?? 0 };
@@ -201,34 +250,49 @@ const FIELD_SPECS: NumberFieldSpec[] = [
},
{
label: "B06_Std_Field_LDitchDepth",
only: "rock",
scope: "rock",
get: (g) => g.ditch_l_type?.depth_m ?? 0,
set: (g, v) => {
g.ditch_l_type = { width_m: g.ditch_l_type?.width_m ?? 0, depth_m: v };
},
},
{
label: "B06_Std_Field_CutSlope",
get: (g) => g.cut_slope_ratio,
set: (g, v) => (g.cut_slope_ratio = v),
},
{
label: "B06_Std_Field_FillSlope",
get: (g) => g.fill_slope_ratio,
set: (g, v) => (g.fill_slope_ratio = v),
},
{
label: "B06_Std_Field_CrossSlopeMin",
scope: "paved",
get: (g) => g.cross_slope_pct.min,
set: (g, v) => (g.cross_slope_pct.min = v),
},
{
label: "B06_Std_Field_CrossSlopeMax",
scope: "paved",
get: (g) => g.cross_slope_pct.max,
set: (g, v) => (g.cross_slope_pct.max = v),
},
];
/** 공통 필드 하나를 그룹들에 펼쳐 넣는다(자기 값을 갖는 그룹은 건너뛴다). */
function spread(state: StandardCrossSection, spec: NumberFieldSpec, value: number): void {
for (const key of ["soil", "rock", "paved"] as StandardCrossKey[]) {
if (spec.exclude === key) continue;
const group = state[key];
if (group) spec.set(group, value);
}
}
/**
* 그룹마다 공통값이 다르게 저장돼 있을 수 있다(옛 프로젝트) — **토사 값을 기준**으로
* 한 벌로 맞춘다(2026-09-04 사용자 확정). 화면은 값 하나를 보이는데 저장분이 셋으로
* 갈려 있으면 어느 값이 나갔는지 알 수 없기 때문이다.
*/
function unifyCommon(state: StandardCrossSection): void {
const soil = state.soil;
if (!soil) return;
for (const spec of FIELD_SPECS) {
if (spec.scope !== "common") continue;
spread(state, spec, spec.get(soil));
}
}
/**
* 표준 횡단면 설정 패널을 만든다.
* @param projectId 세션 캐시 스코프.
@@ -258,49 +322,73 @@ export function createStandardPanel(
const persist = (): void => writeSession(projectId, state);
const buildGroup = (key: StandardCrossKey, legendKey: keyof typeof ui_locales): HTMLElement => {
const group = state[key];
// B05 "기준값 직접 지정"과 동일한 details/summary 패턴, 기본 접힘(N-4-1).
const fieldset = document.createElement("details");
fieldset.className = "b06-std__group";
const legend = document.createElement("summary");
legend.className = "b06-std__legend";
legend.textContent = L(legendKey);
fieldset.append(legend);
/** 필드 한 칸. 공통은 토사 값을 보이고, 고치면 세 그룹에 함께 펼친다. */
const buildField = (spec: NumberFieldSpec, grid: HTMLElement): void => {
const source = spec.scope === "common" ? state.soil : state[spec.scope];
if (!source) return;
const field = createInputField({
label: L(spec.label),
type: "number",
value: String(spec.get(source)),
onInput: (raw) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
if (spec.scope === "common") spread(state, spec, parsed);
else spec.set(source, parsed);
persist();
},
});
field.input.step = "0.1";
field.input.min = "0";
grid.append(field.root);
};
/** 구분선 + 구간 이름 — 아래 값들이 그 구간에서만 쓰인다는 표시. */
const buildDivider = (labelKey: keyof typeof ui_locales): HTMLElement => {
const divider = document.createElement("p");
divider.className = "b06-std__divider";
divider.textContent = L(labelKey);
return divider;
};
const buildScope = (scope: FieldScope): HTMLElement => {
const grid = document.createElement("div");
grid.className = "b06-std__grid";
for (const spec of FIELD_SPECS) {
if (spec.only && spec.only !== key) continue;
const field = createInputField({
label: L(spec.label as keyof typeof ui_locales),
type: "number",
value: String(spec.get(group)),
onInput: (raw) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
spec.set(group, parsed);
persist();
},
});
field.input.step = "0.1";
field.input.min = "0";
grid.append(field.root);
if (spec.scope === scope) buildField(spec, grid);
}
fieldset.append(grid);
return grid;
};
if (key === "rock") {
const note = document.createElement("p");
note.className = "b06-std__note";
note.textContent = L("B06_Std_LType_Note");
fieldset.append(note);
}
/** 「표준횡단면 상세값」 한 컨테이너 — 공통 → 암 → 포장 순, 구분선으로 나눈다. */
const buildDetails = (): HTMLElement => {
const fieldset = document.createElement("details");
fieldset.className = "b06-std__group";
fieldset.open = true;
const legend = document.createElement("summary");
legend.className = "b06-std__legend";
legend.textContent = L("B06_Std_Detail_Title");
const note = document.createElement("p");
note.className = "b06-std__note";
note.textContent = L("B06_Std_LType_Note");
fieldset.append(
legend,
buildScope("common"),
buildDivider("B06_Std_Section_RockOnly"),
buildScope("rock"),
buildDivider("B06_Std_Section_PavedOnly"),
buildScope("paved"),
note,
);
return fieldset;
};
const renderBody = (): void => {
body.replaceChildren(...GROUP_ORDER.map(([key, legendKey]) => buildGroup(key, legendKey)));
body.replaceChildren(buildDetails());
};
// 옛 저장분이 그룹마다 다른 공통값을 갖고 있으면 토사 기준으로 한 벌로 맞춘다.
unifyCommon(state);
persist();
renderBody();
/** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */
@@ -308,6 +396,7 @@ export function createStandardPanel(
(Object.keys(source) as StandardCrossKey[]).forEach((key) => {
if (source[key]) state[key] = JSON.parse(JSON.stringify(source[key])) as StandardCrossGroup;
});
unifyCommon(state);
persist();
renderBody();
};
@@ -324,6 +413,7 @@ export function createStandardPanel(
(Object.keys(fresh) as StandardCrossKey[]).forEach((key) => {
state[key] = fresh[key];
});
unifyCommon(state);
persist();
renderBody();
},
@@ -354,6 +444,7 @@ export function createStandardPanel(
(Object.keys(stored) as StandardCrossKey[]).forEach((key) => {
if (stored[key]) state[key] = JSON.parse(JSON.stringify(stored[key])) as StandardCrossGroup;
});
unifyCommon(state);
renderBody();
},
};