측점 표기 규칙(2026-08-18 사용자 확정): 잔여거리는 [0, 간격 20m) 범위만 유효, 넘치면 측점번호로 올려 표시한다(1+30 → 2+10). 저장 수치는 원래 누가거리라 계산은 이미 동일했고, 입력 칸 표시를 확정(change) 시점에 정규화한다. 시작·기준·종료 측점 세 칸 공통(stationFields 확장). 검증(헤드 브라우저): 1+30→2+10.0, 0+45→2+5.0, 3+19.9 유지, 3+20→4+0.0 PASS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
119 lines
4.9 KiB
TypeScript
119 lines
4.9 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): HTMLLabelElement {
|
|
const wrapper = document.createElement("label");
|
|
wrapper.className = "b05-route__field";
|
|
const caption = document.createElement("span");
|
|
caption.textContent = labelText;
|
|
wrapper.append(caption, input);
|
|
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;
|
|
}
|
|
|
|
/** 측점번호 + 잔여거리 두 칸 묶음. */
|
|
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),
|
|
};
|
|
}
|