- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음」(등록부 default_basis 를 모든 칸 밑 회색 한 줄로) · ② 일반관리비 「임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음」 · ③ 모르타르 배합 「산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)」 - B08: 막자갈 비고 「칸을 새로 두는 것은 B05 등록부 몫」 · 일위대가 머리 5단 한도 · 야면석 계수 칸 밑 「돌 종류는 그대로」 · 식 저장 전 상태 줄 「제원을 바꿔도 따라감」 · 양식 장 머리 「반올림은 m당 값에 걸고 수량 = m당 × 연장」 - 산출 조건 제근 굴착기 크기 칸(임목축적 등급 밑 · 「안 정함」 · 회색 제안 0.7㎥ + 근거 + [제안값 넣기] 누른 때만) — 936be972 소림 + 0.7 → 뿌리뽑기 55원/㎡ × 17,873.80㎡ = 983,057원(되돌림) - 라이브러리 이름표: [내 라이브러리에 저장]·[발행] 창이 장 이름(종류 + 제원 요약)을 채워 묻고 고친 이름으로 저장 · 취소면 안 씀 · 비우면 양식 이름 - B05 계곡 통과 시설 폼 머리 「[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸은 그대로 둠」 · B09 폐기물처리비 자리 칸 밑 「법정경비 밑수에는 안 넣음」 · 내역서 「금액을 못 세운 줄」 첫 줄 「사유는 아래 단계가 낸 것을 그대로 옮김」 - 남은 하나(표 형태 66표 까닭 form_basis 화면)는 자원 축 파일을 거쳐야 해 브레인 물음 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
212 lines
8.9 KiB
TypeScript
212 lines
8.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,
|
|
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),
|
|
};
|
|
}
|