diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index a74ccf3b..d2154fea 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -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(); }, }; diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index bc473aed..e405ef0a 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -287,6 +287,17 @@ gap: var(--spacing-8); } +/* 구간 구분선 — 「표준횡단면 상세값」 한 컨테이너 안에서 공통 / 암 / 포장을 가른다 + (2026-09-04 사용자 지시: 공통 항목은 지우고 구분선을 쓸 것). */ +.b06-std__divider { + margin: var(--spacing-4) 0 0; + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + .b06-std__note { margin: 0; font-size: var(--text-caption); diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 5877b4ab..3e21a402 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -506,6 +506,10 @@ export const ui_locales_b2 = { B06_Std_Group_Soil: ["토사 구간", "Soil section"], B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"], B06_Std_Group_Paved: ["포장 구간", "Paved section"], + B06_Std_Detail_Title: ["표준횡단면 상세값", "Standard cross-section details"], + B06_Std_Section_Common: ["공통", "Common"], + B06_Std_Section_RockOnly: ["암 구간 — 다른 값만", "Rock section - differing values"], + B06_Std_Section_PavedOnly: ["포장 구간 — 다른 값만", "Paved section - differing values"], B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"], B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"], B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"],