/* ============================================================================= * B05_Profile_UI_Structures_Fields.ts * 「구조물 배치」 폼 입력 조각 — 라벨 필드·숫자칸·셀렉트·측점번호+잔여거리 두 칸. * * 패널 본체(B05_Profile_UI_Structures_Panel)가 700줄 한계에 닿아 분리했다. * 측점 두 칸은 구 비정규 측점 UI와 같은 입력 방식이다(2026-08-17 사용자 확정) — * 저장은 누가거리(m), 화면 입출력만 측점번호+잔여거리. * ========================================================================== */ import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station"; export function field( labelText: string, input: HTMLElement, basis?: string | null, ): HTMLLabelElement { const wrapper = document.createElement("label"); wrapper.className = "b05-route__field"; const caption = document.createElement("span"); caption.textContent = labelText; wrapper.append(caption, input); // 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정). if (basis) { const note = document.createElement("small"); note.className = "b05-structure__basis"; note.textContent = basis; wrapper.append(note); } return wrapper; } export function numberInput(step = "0.1", min = "0"): HTMLInputElement { const input = document.createElement("input"); input.type = "number"; input.step = step; input.min = min; return input; } export function select(options: ReadonlyArray<[string, string]>): HTMLSelectElement { const element = document.createElement("select"); element.replaceChildren(...options.map(([value, label]) => new Option(label, value))); return element; } /** 등록부 옵션 한 칸의 모양 — `StructureOptionField` 에서 이 칸이 쓰는 몫만. */ interface OptionShape { input: "select" | "number" | "text"; choices: string[]; default: string | number | null; required?: boolean; /** 비워 두면 어떻게 되나 — 칸에 마우스를 올리면 보임. */ empty_means?: string | null; /** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음). */ warn_above?: number | null; warn_message?: string | null; /** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */ not_in_table?: string | null; /** 기본값의 뜻 — 칸 밑 근거 한 줄. */ default_basis?: string | null; } /** * 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임 * (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래 * 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨. * 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음. */ export function optionControl( option: OptionShape, stored: string | number | undefined, ): HTMLInputElement | HTMLSelectElement { const value = stored === undefined || stored === null ? "" : String(stored); const suggested = option.default === null || option.default === "" ? "" : String(option.default); // 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻. const tip = option.not_in_table ?? option.empty_means ?? ""; if (option.input === "select") { const blank = suggested ? `— 안 정함 (제안 ${suggested}) —` : option.required ? "— 선택 —" : "— 안 정함 —"; const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]); element.value = value; if (tip) element.title = tip; return element; } const element = option.input === "number" ? numberInput("0.1", "0") : document.createElement("input"); if (option.input !== "number") element.type = "text"; element.value = value; element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : ""; if (tip) element.title = tip; const limit = option.warn_above; if (typeof limit === "number") { // 기준을 넘으면 알리기만 — 막지 않음(2026-09-14 브레인 판정 ①·㉰). const warn = (): void => { const over = element.value.trim() !== "" && Number(element.value) > limit; element.classList.toggle("is-outside-standard", over); element.title = over ? (option.warn_message ?? "") : tip; }; element.addEventListener("input", warn); warn(); } return element; } /** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */ export function suggestButton( entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>, ): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = "b05-structure__suggest"; button.textContent = "제안값 넣기"; button.addEventListener("click", () => { const filled = entries.filter((entry) => entry.suggested && !entry.input.value); filled.forEach((entry) => { entry.input.value = entry.suggested; entry.input.dispatchEvent(new Event("input", { bubbles: true })); }); // 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림. filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true })); }); return button; } /** 측점번호 + 잔여거리 두 칸 묶음. */ export interface StationFields { wrap: HTMLElement; station: HTMLInputElement; remainder: HTMLInputElement; /** 두 칸을 누가거리로 읽는다. 비었으면 null, 형식 오류면 붉히고 null. */ read: (intervalM: number, required: boolean) => number | null; /** 누가거리를 두 칸에 나눠 싣는다. null이면 비운다. */ write: (chainageM: number | null, intervalM: number) => void; clearInvalid: () => void; } export function stationFields( labelText: string, /** 측점간격(m) 공급자 — 주면 입력 확정(change) 때 표시를 정규화한다: * 잔여거리는 [0, 간격) 범위만 유효하고, 넘치면 측점번호로 올린다(1+30 → 2+10). * 프로그램 전역 공통 규칙(2026-08-18 사용자 확정). 저장 수치는 원래부터 * 누가거리라 표시만 맞추면 된다. */ getInterval?: () => number, ): StationFields { const station = numberInput("1", "0"); station.placeholder = "측점"; const remainder = numberInput("0.1", "0"); remainder.placeholder = "+m"; const pair = document.createElement("div"); pair.className = "b05-structure__station-pair"; pair.append(station, remainder); const wrap = field(labelText, pair); const markInvalid = (bad: boolean): void => { station.classList.toggle("is-invalid", bad); remainder.classList.toggle("is-invalid", bad); }; if (getInterval) { const normalize = (): void => { const intervalM = getInterval(); if (!(intervalM > 0)) return; const stationText = station.value.trim(); const remainderText = remainder.value.trim(); if (!stationText && !remainderText) return; const stationNo = stationText ? Number(stationText) : 0; const rest = remainderText ? Number(remainderText) : 0; if (!Number.isFinite(stationNo) || stationNo < 0 || !Number.isFinite(rest) || rest < 0) return; // 잔여 넘침(≥간격)·소수 측점번호만 손본다 — 그 외 표기는 사용자가 친 그대로 둔다. if (rest < intervalM && Number.isInteger(stationNo)) return; const parts = chainageToStation(stationToChainage(stationNo, rest, intervalM), intervalM); station.value = String(parts.station); remainder.value = parts.remainder.toFixed(1); }; // 다른 change 리스너(옵션 잠금·임시 배치)보다 먼저 등록돼 정규화된 값이 읽힌다. [station, remainder].forEach((input) => input.addEventListener("change", normalize)); } return { wrap, station, remainder, read(intervalM, required) { const stationText = station.value.trim(); const remainderText = remainder.value.trim(); if (!stationText && !remainderText) { markInvalid(required); return null; } const stationNo = stationText ? Number(stationText) : 0; const rest = remainderText ? Number(remainderText) : 0; const valid = Number.isFinite(stationNo) && stationNo >= 0 && Number.isFinite(rest) && rest >= 0; markInvalid(!valid); return valid ? stationNo * intervalM + rest : null; }, write(chainageM, intervalM) { if (chainageM === null) { station.value = ""; remainder.value = ""; return; } const parts = chainageToStation(chainageM, intervalM); station.value = String(parts.station); remainder.value = parts.remainder.toFixed(1); }, clearInvalid: () => markInvalid(false), }; }