/* ============================================================================= * B06_Section_UI_Standard_Panel.ts * 좌측 사이드 "표준 횡단면 설정" 패널 — **「표준횡단면 상세값」 한 컨테이너**(2026-09-04 * 사용자 지시). 토사·암·포장 3그룹이 같은 필드를 각각 갖던 화면을 공통 한 벌 + * 구간별로 다른 값만 남겼다. 공통 = 노폭·노견·측구·절토/성토 경사·횡단 경사, * 암 = 절토 경사·L형 측구, 포장 = 횡단 경사. * * **저장 구조는 그대로 3그룹**(`standard_cross_section`) — 화면만 합치고 저장할 때 * 공통값을 세 그룹에 펼쳐 넣는다. 백엔드·기존 프로젝트가 그대로 동작한다. * * 각 그룹의 노폭·노견·측구 규격·경사값을 편집한다. 기본값은 백엔드 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 SESSION_PREFIX = "b06:std-cross:"; /** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */ const DEFAULTS_PREFIX = "b06:std-cross-default:"; function sessionKey(projectId: string): string { return `${SESSION_PREFIX}${projectId}`; } function defaultsKey(projectId: string): string { return `${DEFAULTS_PREFIX}${projectId}`; } /** * 서버가 내려 준 표준횡단 **config 기본값**을 세션에 둔다(`sections/context` 응답). * * 횡단 계산이 브라우저에서 돌므로 config 수치가 프론트에도 있어야 하는데, 상수를 * 복제하면 정본이 둘이 된다. 그래서 복제 대신 **서버가 준 값을 그대로 기억**한다. * 패널을 열지 않는 B05도 이 값으로 계산해야 두 화면 결과가 같다(2026-09-03 로컬 전환). */ export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void { try { window.sessionStorage.setItem(defaultsKey(projectId), JSON.stringify(defaults)); } catch { /* 세션 저장 실패는 무시 — 계산은 편집값·저장분으로 이어 간다. */ } } /** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */ export function readStandardDefaults(projectId: string): StandardCrossSection | null { try { const raw = window.sessionStorage.getItem(defaultsKey(projectId)); return raw ? (JSON.parse(raw) as StandardCrossSection) : null; } catch { return null; } } const ROCK_DEFAULT_PREFIX = "b06:rock-boundary-default:"; /** * 암반 경계선 기본 오프셋(config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`)을 기억한다. * 저장분에 경계 오프셋이 없는 옛 암 측점을 서버와 **같은 기본값**으로 다시 계산하려면 * 브라우저에도 이 값이 있어야 한다 — 상수 복제 대신 컨텍스트 응답을 기억하는 방식이다. */ export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void { try { window.sessionStorage.setItem(`${ROCK_DEFAULT_PREFIX}${projectId}`, String(offsetM)); } catch { /* 세션 저장 실패는 무시. */ } } /** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */ export function readRockBoundaryDefault(projectId: string): number | null { try { const raw = window.sessionStorage.getItem(`${ROCK_DEFAULT_PREFIX}${projectId}`); const parsed = raw === null ? Number.NaN : Number(raw); return Number.isFinite(parsed) ? parsed : null; } catch { return null; } } /** * 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값이 있으면 그쪽, 없으면 config 기본값**. * 서버 `_resolve_group(preset, standard)`의 "요청값 → config" 우선순위와 같은 뜻이다. */ export function effectiveStandardCross(projectId: string): StandardCrossSection | null { return readStandardCrossSession(projectId) ?? readStandardDefaults(projectId); } /** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */ export function clearStandardCrossSession(projectId: string): void { try { window.sessionStorage.removeItem(sessionKey(projectId)); } catch { /* 세션 접근이 막혀도 초기화는 계속한다. */ } } /** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */ function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection { return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection; } /** * 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. * * 패널 밖에서도 필요하다 — 횡단 재계산 단일 창구(`B06_Section_Cross_Refresh`)가 B05처럼 * 패널이 없는 화면에서도 **같은 표준 단면값**을 서버로 보내야 두 화면 결과가 같다. */ export function readStandardCrossSession(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; } /** 값을 어느 그룹에 쓸 것인가. `common` 은 세 그룹에 함께 펼쳐 넣는다. */ type FieldScope = "common" | "rock" | "paved"; interface NumberFieldSpec { label: keyof typeof ui_locales; scope: FieldScope; /** 화면에 보일 값을 읽는다 — 공통은 **토사 값이 기준**(2026-09-04 사용자 확정). */ get: (group: StandardCrossGroup) => number; set: (group: StandardCrossGroup, value: number) => void; /** * 공통값이지만 이 그룹은 따로 값을 갖는다 — 공통을 펼칠 때 건너뛴다. * (절토 경사는 암이, 횡단 경사는 포장이 자기 값을 쓴다) */ 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", 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 }; }, }, { label: "B06_Std_Field_LDitchDepth", 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_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 세션 캐시 스코프. * @param defaults config에서 내려온 기본값(복원 기준). */ export function createStandardPanel( projectId: string, defaults: StandardCrossSection, onApplyAll?: () => void | Promise, ): StandardPanelController { // 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다. rememberStandardDefaults(projectId, defaults); // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. const sessionValue = readStandardCrossSession(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 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.scope === scope) buildField(spec, grid); } return grid; }; /** 「표준횡단면 상세값」 한 컨테이너 — 공통 → 암 → 포장 순, 구분선으로 나눈다. */ 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(buildDetails()); }; // 옛 저장분이 그룹마다 다른 공통값을 갖고 있으면 토사 기준으로 한 벌로 맞춘다. unifyCommon(state); persist(); 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; }); unifyCommon(state); 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]; }); unifyCommon(state); 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); // 횡단 반폭은 사이드의 **별도 컨테이너**로 뺐다(2026-08-23) — 여기는 표준단면 설정만. 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; }); unifyCommon(state); 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; }