/* ============================================================================= * B06_Section_UI_Standard_Panel.ts * 좌측 사이드 "표준 횡단면 설정" 패널 (토사 / 암 / 포장 3그룹). * * 각 그룹의 노폭·노견·측구 규격·경사값을 편집한다. 기본값은 백엔드 config * (STANDARD_CROSS_SECTION, context.standard_cross_section)에서 내려오고, 사용자가 * 편집한 값은 프론트 세션(sessionStorage)에 프로젝트 단위로 보관한다. 종·횡단 * 확정 시 이 값을 백엔드/DB에 저장하기 위해 getValues()를 노출한다. * * 암 그룹은 일반 측구 + L형 측구 두 세트를 보관하며, 둘 중 선택은 각 횡단면도 * 카드에서 이뤄진다(여기서는 값만 보관). * ========================================================================== */ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; import { getCompanyStandard, listCompanyStandards, type StandardCrossGroup, type StandardCrossKey, type StandardCrossSection, } from "./B06_Section_Api_Fetch"; 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:"; function sessionKey(projectId: string): string { return `${SESSION_PREFIX}${projectId}`; } /** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */ function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection { return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection; } /** 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. */ function readSession(projectId: string): StandardCrossSection | null { try { const raw = window.sessionStorage.getItem(sessionKey(projectId)); return raw ? (JSON.parse(raw) as StandardCrossSection) : null; } catch { return null; } } function writeSession(projectId: string, value: StandardCrossSection): void { try { window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(value)); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } } export interface StandardPanelController { root: HTMLElement; /** 확정 시 백엔드/DB 저장에 쓰는 현재 편집값. */ getValues: () => StandardCrossSection; /** * DB에 확정 저장된 값으로 복원한다. 단, 세션에 미확정 편집값이 있으면 * 그쪽을 우선하고 무시한다(진행 중 편집 보호). */ applyStored: (stored: StandardCrossSection) => void; } interface NumberFieldSpec { label: string; get: (group: StandardCrossGroup) => number; set: (group: StandardCrossGroup, value: number) => void; /** 암 그룹의 L형 측구처럼 특정 그룹에만 존재하는 필드는 조건으로 거른다. */ only?: StandardCrossKey; } /** 그룹 하나에 노출할 편집 필드 정의. 순서 = 화면 표기 순서. */ const FIELD_SPECS: NumberFieldSpec[] = [ { label: "B06_Std_Field_RoadWidth", get: (g) => g.road_width_m, set: (g, v) => (g.road_width_m = v), }, { label: "B06_Std_Field_ShoulderLeft", get: (g) => g.shoulder_left_m, set: (g, v) => (g.shoulder_left_m = v), }, { label: "B06_Std_Field_ShoulderRight", get: (g) => g.shoulder_right_m, set: (g, v) => (g.shoulder_right_m = v), }, { label: "B06_Std_Field_DitchTop", get: (g) => g.ditch.top_width_m, set: (g, v) => (g.ditch.top_width_m = v), }, { label: "B06_Std_Field_DitchBottom", get: (g) => g.ditch.bottom_width_m, set: (g, v) => (g.ditch.bottom_width_m = v), }, { label: "B06_Std_Field_DitchDepth", get: (g) => g.ditch.depth_m, set: (g, v) => (g.ditch.depth_m = v), }, { label: "B06_Std_Field_LDitchWidth", only: "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 }; }, }, { label: "B06_Std_Field_LDitchDepth", only: "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", get: (g) => g.cross_slope_pct.min, set: (g, v) => (g.cross_slope_pct.min = v), }, { label: "B06_Std_Field_CrossSlopeMax", get: (g) => g.cross_slope_pct.max, set: (g, v) => (g.cross_slope_pct.max = v), }, ]; /** * 표준 횡단면 설정 패널을 만든다. * @param projectId 세션 캐시 스코프. * @param defaults config에서 내려온 기본값(복원 기준). */ export function createStandardPanel( projectId: string, defaults: StandardCrossSection, onApplyAll?: () => void | Promise, /** [전체 측점 반영] 버튼 **위**에 끼울 추가 컨트롤(횡단 반폭 입력, 2026-08-06 사용자 지시). */ extraControl?: HTMLElement, ): StandardPanelController { // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. const sessionValue = readSession(projectId); const hadSession = sessionValue !== null; const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults); const root = document.createElement("div"); root.className = "b06-std"; // 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5). root.append(buildStandardDiagram()); // 그룹 재구성(리셋 시) 편의를 위해 본문 컨테이너를 분리한다. const body = document.createElement("div"); body.className = "b06-std__body"; 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 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); } fieldset.append(grid); if (key === "rock") { const note = document.createElement("p"); note.className = "b06-std__note"; note.textContent = L("B06_Std_LType_Note"); fieldset.append(note); } return fieldset; }; const renderBody = (): void => { body.replaceChildren(...GROUP_ORDER.map(([key, legendKey]) => buildGroup(key, legendKey))); }; renderBody(); /** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */ const applyStandard = (source: StandardCrossSection): void => { (Object.keys(source) as StandardCrossKey[]).forEach((key) => { if (source[key]) state[key] = JSON.parse(JSON.stringify(source[key])) as StandardCrossGroup; }); persist(); renderBody(); }; // 다른 프로젝트에서 설계값 불러오기: 선택·미리보기만으로는 현재 값이 바뀌지 않고, // "적용" 버튼을 눌러야만 반영된다(작업 C-6). const loader = buildProjectLoader(projectId, applyStandard); const resetButton = createButton({ label: L("B06_Std_Reset"), variant: "ghost", onClick: () => { const fresh = cloneDefaults(defaults); (Object.keys(fresh) as StandardCrossKey[]).forEach((key) => { state[key] = fresh[key]; }); persist(); renderBody(); }, }); const actions = document.createElement("div"); actions.className = "b06-std__actions"; // [전체 반영](N-2-1): 패널 수치를 design 보유 전 측점에 서버 재계산으로 일괄 반영한다. // 측점별 버튼 선택값(지반/단면 등)은 보존하고 표준단면 수치만 갱신한다. if (onApplyAll) { actions.append( createButton({ label: L("B06_Std_ApplyAll"), variant: "filled", onClick: () => void onApplyAll(), }), ); } actions.append(resetButton); // 추가 컨트롤(횡단 반폭)은 액션 행 바로 위 — [전체 측점 반영]이 반폭 적용까지 담당한다. if (extraControl) root.append(body, loader, extraControl, actions); else root.append(body, loader, actions); return { root, getValues: () => state, applyStored: (stored) => { if (hadSession) return; // 진행 중 세션 편집값이 우선. (Object.keys(stored) as StandardCrossKey[]).forEach((key) => { if (stored[key]) state[key] = JSON.parse(JSON.stringify(stored[key])) as StandardCrossGroup; }); renderBody(); }, }; } /** * "다른 프로젝트에서 불러오기" 컨트롤. 같은 회사의 최근 프로젝트 5개를 보여주고, * 사용자가 선택하면 그 프로젝트의 저장 설계값을 즉시 현재 설정(그룹 값)에 적용한다. * 각 횡단면도 카드에서 사용자가 고른 버튼 옵션(지반유형·단면유형 등)은 건드리지 않는다 * — 이 값들은 측점별 design으로 별도 보관되며 패널 값과 독립이다. */ function buildProjectLoader( projectId: string, onApply: (source: StandardCrossSection) => void, ): HTMLElement { const wrap = document.createElement("details"); wrap.className = "b06-std__loader"; const summary = document.createElement("summary"); summary.className = "b06-std__loader-summary"; summary.textContent = L("B06_Std_Load_Title"); wrap.append(summary); const status = document.createElement("p"); status.className = "b06-std__loader-status"; status.textContent = L("B06_Std_Load_Loading"); const field = createSelectField({ label: L("B06_Std_Load_Select"), options: [{ value: "", text: L("B06_Std_Load_Placeholder") }], onChange: (value) => void onSelect(value), }); field.root.hidden = true; async function onSelect(sourceId: string): Promise { if (!sourceId) { status.textContent = ""; return; } status.textContent = L("B06_Std_Load_Loading"); try { const response = await getCompanyStandard(projectId, sourceId); onApply(response.standard_cross_section); status.textContent = L("B06_Std_Load_Applied"); } catch (error) { // 404 = 해당 프로젝트에 저장된 설계값 없음(목록은 보유 여부 무관 최근 5개). const message = error instanceof Error ? error.message : ""; status.textContent = message.includes("찾을 수 없") ? L("B06_Std_Load_None") : L("B06_Std_Load_Failed"); } } // 같은 회사 최근 프로젝트 5개 비동기 로드 → 셀렉트 채우기. void (async () => { try { const response = await listCompanyStandards(projectId); if (!response.projects.length) { status.textContent = L("B06_Std_Load_Empty"); return; } for (const project of response.projects) { const option = document.createElement("option"); option.value = project.project_id; option.textContent = project.name; field.select.append(option); } field.root.hidden = false; status.textContent = ""; } catch { status.textContent = L("B06_Std_Load_Failed"); } })(); wrap.append(status, field.root); return wrap; }