무슨 일이 있었나 랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운 커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`· `CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴. 잃었던 것 - 공용 — `common_util_provenance.py` · `ui_template_provenance.ts` - B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸 - B09 — 근거 사전 셋 - B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈 - 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등) 어떻게 되살렸나 `611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림. 이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난 옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛 상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 — `9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑). 위키 여덟은 코덱스 몫이라 손대지 않음. 자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 · `B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의 `attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped** (되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
204 lines
7.9 KiB
TypeScript
204 lines
7.9 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, string> = {
|
|
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<string, FactorDraft>,
|
|
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<string, ConversionFactorChoice> | undefined,
|
|
pumsem: PumsemRange[] | undefined,
|
|
draft: Record<string, FactorDraft>,
|
|
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<string, FactorDraft>,
|
|
): Record<string, { compacted: number; reason?: string }> {
|
|
const payload: Record<string, { compacted: number; reason?: string }> = {};
|
|
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;
|
|
}
|