/* ============================================================================= * B08_Quantity_UI_ConversionFactors.ts * 산출 조건 패널의 「토량환산계수(다짐)」 칸 — 고를 수 있게 열어 둔 자리. * * 왜 고르게 하나 (오솔길 대조 06절 3번) * 품셈 체적변화율표가 암종마다 **범위**를 주고 「토질 시험하여 적용함을 원칙」이라 한다. * 즉 정답 숫자가 하나가 아니다. 경쟁사(오솔길)가 전 구간 1.0 을 쓰는 것도 풍화암·연암 * 범위의 하한이라 틀린 값이 아니다. 그래서 **값을 못 박지 않고 범위를 보이며 고르게** 한다. * * ⚠ 기본값은 건드리지 않는다 * 정의처는 서버 `config_system_design.EARTHWORK_CONVERSION_FACTORS` 한 곳이다. 화면은 * 고른 값만 `conversion_factors_override` 로 보내고, 안 고른 갈래는 **키 자체를 안 보낸다** — * 그래야 나중에 정본이 바뀌어도 옛 프로젝트가 따라온다. * * ⚠ 범위 밖을 막지 않는다 * 토질시험 값일 수 있다. 막는 대신 **사유를 적게** 하고, 그 사유가 정본에 함께 남는다. * * ⚠ 이 계수는 토적표만 쓰는 것이 아니다 * 유토곡선(B06) · 운반표 · 기초단가가 같은 값을 읽는다. 패널에 그 사실을 한 줄 보인다 — * 안 보이면 「토적표만 바뀌겠지」로 읽힌다. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import type { ConversionFactorChoice, PumsemRange } from "./B08_Quantity_UI_EarthworkGrid"; /** locale 헬퍼 — 페이지 쪽과 같은 모양으로 둔다(문구는 `ui_template_locale_b2`). */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 화면이 들고 있는 고른 값 — `compacted` 가 `null` 이면 「안 고름」이라 저장에서 빠진다. */ export interface FactorDraft { compacted: number | null; reason: string; } /** 서버 키 → 사람이 읽는 이름. 모르는 키는 **지어내지 않고** 그대로 보인다. */ const GROUND_LABELS: Record = { soil: "토사", ripping_rock: "리핑암", blasting_rock: "발파암", }; function groundLabel(kind: string): string { return GROUND_LABELS[kind] ?? kind; } function hint(text: string): HTMLElement { const row = document.createElement("p"); row.className = "b08-quantity__hint"; row.textContent = text; return row; } /** 범위 밖인가 — 서버가 준 범위로 판정한다. 범위를 모르면 **밖이라고 하지 않는다.** */ function outOfRange(value: number, range: [number, number] | null): boolean { if (!range) return false; return value < range[0] || value > range[1]; } /** * 갈래 한 줄 — 값 칸 + 기본값·품셈 범위 안내 + (범위 밖일 때만) 사유 칸. * * 값을 비우면 「안 고름」으로 돌아가 기본값이 선다. 그 되돌리는 길이 있어야 * 한 번 넣은 값이 영영 남지 않는다. */ function factorRow( kind: string, choice: ConversionFactorChoice, draft: Record, onChange: () => void, ): HTMLElement { const box = document.createElement("div"); box.className = "b08-quantity__factor"; const row = document.createElement("label"); row.className = "b08-quantity__field"; const name = document.createElement("span"); name.textContent = groundLabel(kind); const input = document.createElement("input"); input.type = "number"; input.className = "b08-quantity__input"; input.min = "0"; input.step = "0.01"; input.placeholder = String(choice.default); const current = draft[kind]?.compacted; input.value = current === null || current === undefined ? "" : String(current); row.append(name, input); box.append(row); const range = (choice.range ?? null) as [number, number] | null; box.append( hint( `${L("B08_Quantity_Factor_Default")} ${choice.default}` + (range ? ` · ${L("B08_Quantity_Factor_Range")} ${range[0].toFixed(2)}~${range[1].toFixed(2)}` : ""), ), ); // 사유 칸은 **범위 밖일 때만** 선다 — 늘 띄우면 채우지 않아도 되는 칸으로 읽힌다. const reasonRow = document.createElement("label"); reasonRow.className = "b08-quantity__field"; const reasonName = document.createElement("span"); reasonName.textContent = L("B08_Quantity_Factor_Reason"); const reasonInput = document.createElement("input"); reasonInput.type = "text"; reasonInput.className = "b08-quantity__input"; reasonInput.value = draft[kind]?.reason ?? ""; reasonRow.append(reasonName, reasonInput); const warning = hint(L("B08_Quantity_Factor_OutOfRange")); warning.classList.add("b08-quantity__hint--warn"); const sync = (): void => { const value = input.value.trim() === "" ? null : Number(input.value); const outside = value !== null && Number.isFinite(value) && outOfRange(value, range); warning.hidden = !outside; reasonRow.hidden = !outside; }; input.addEventListener("input", () => { const raw = input.value.trim(); const value = raw === "" ? null : Number(raw); draft[kind] = { compacted: value !== null && Number.isFinite(value) ? value : null, reason: draft[kind]?.reason ?? "", }; sync(); onChange(); }); reasonInput.addEventListener("input", () => { draft[kind] = { compacted: draft[kind]?.compacted ?? null, reason: reasonInput.value, }; onChange(); }); box.append(warning, reasonRow); sync(); return box; } /** * 「토량환산계수(다짐)」 구획 전체. 서버가 준 갈래만 그린다 — 갈래 수를 화면에 안 박는다. * * `choices` 가 없으면(옛 응답) **아무것도 그리지 않는다** — 빈 칸을 지어내지 않는다. */ export function renderConversionFactorFields( choices: Record | undefined, pumsem: PumsemRange[] | undefined, draft: Record, onChange: () => void, ): HTMLElement | null { const entries = Object.entries(choices ?? {}); if (!entries.length) return null; const box = document.createElement("div"); // 제 제목을 제 안에 들고 있어 이 구획 자신이 공용 접기 컨테이너가 된다(B03~B07 과 같은 틀). box.className = "b08-quantity__factors ui-collapsible"; const title = document.createElement("div"); title.className = "b08-quantity__field ui-collapsible__title"; const titleName = document.createElement("span"); titleName.textContent = L("B08_Quantity_Side_Factors"); title.append(titleName); box.append(title); // 어디까지 닿는 값인지 먼저 보인다 — 토적표만 바뀌는 줄 알면 함부로 고친다. box.append(hint(L("B08_Quantity_Factor_Reach"))); for (const [kind, choice] of entries) { box.append(factorRow(kind, choice, draft, onChange)); } // 품셈 암종별 범위 — 서버가 내려 준 값을 그대로 보인다(화면에 다시 적지 않는다). if (pumsem?.length) { box.append( hint( `${L("B08_Quantity_Factor_Pumsem")} — ` + pumsem .map((item) => `${item.name} ${item.min.toFixed(2)}~${item.max.toFixed(2)}`) .join(" · "), ), ); } return box; } /** 저장 몸통에 실을 모양 — **고른 갈래만** 담는다. 빈 dict 는 「전부 기본값」이다. */ export function conversionOverridePayload( draft: Record, ): Record { const payload: Record = {}; for (const [kind, entry] of Object.entries(draft)) { if (entry.compacted === null || !Number.isFinite(entry.compacted) || entry.compacted <= 0) { continue; } payload[kind] = entry.reason.trim() ? { compacted: entry.compacted, reason: entry.reason.trim() } : { compacted: entry.compacted }; } return payload; }