Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Fields.ts
T

206 lines
8.7 KiB
TypeScript

/* =============================================================================
* 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;
}
/**
* 등록부 옵션 입력 한 칸 — **받은 값만 칸에 넣음**. 새 구조물은 부르는 쪽이 등록부 기본값을 값으로
* 실어 줌(PLAN 46-7 · 제안값 단추 없음) · 옛 기록의 빈 칸은 빈 칸 그대로(미확정).
* 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음.
*/
export function optionControl(
option: OptionShape,
stored: string | number | undefined,
): HTMLInputElement | HTMLSelectElement {
const value = stored === undefined || stored === null ? "" : String(stored);
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
const blank = 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 = 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;
}
/** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */
export function optionDefaults(
options: ReadonlyArray<{ key: string; default: string | number | null }>,
): Record<string, string | number> {
return Object.fromEntries(
options
.filter((option) => option.default !== null && option.default !== "")
.map((option) => [option.key, option.default as string | number]),
);
}
/** 측점번호 + 잔여거리 두 칸 묶음. */
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));
}
// 칸은 잔여를 소수 1자리로 보임 — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌
// (표시값을 다시 읽으면 0.1m 로 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨)
let written: { chainageM: number; station: string; remainder: string } | null = null;
return {
wrap,
station,
remainder,
read(intervalM, required) {
if (written && station.value === written.station && remainder.value === written.remainder) {
markInvalid(false);
return written.chainageM;
}
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) {
written = null;
if (chainageM === null) {
station.value = "";
remainder.value = "";
return;
}
const parts = chainageToStation(chainageM, intervalM);
station.value = String(parts.station);
remainder.value = parts.remainder.toFixed(1);
written = { chainageM, station: station.value, remainder: remainder.value };
},
clearInvalid: () => markInvalid(false),
};
}