- 한눈 표(M02_SO_Def_Table): 군 · 종류 ▲▼ · 이름 · 목록 · 자리꼴 · 정본(고정) · 횡단도 · 카드 이름 · 형상(+ 벽 기본 형태 · 물빼기) · 알약 색 · 3D · 검사 · 처음 값 = 흐림 「기본」 · 바꿈 = 진함 · [처음 값으로] - 종류별: 반영 칸 + 명세 이름 나무 줄마다 보임(컨테이너 = 그 안 칸) · 못 숨김 · 미리보기 숨김 따름 · 「안 씀」 = 목록 B06 만 - 부품 탭(M02_SO_Def_Parts): 부품 · 읽는 칸 · 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 부품 = 끈 것만) - 편집 손질(M02_SO_Def_Reflect): 처음 값이면 반영에서 지움 · [저장] 때 enabled = 목록 both 일 때만 참 - 검사 짝 M02_SO_Def_Check.ts ↔ M02_SO_Check.py(글까지 같음): 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 기본값 없는 읽는 칸 새로 숨김 금지(앞 판 숨김 그대로) · 종류 지우기 · 자리꼴 · 정본 · 군 글자 · 선택지 값 고정 · 서버 check(doc, 지금 판) · 앞 판 없으면 꼴만 - 명세 폼: 쓰는 옵션 키가 모두 숨긴 칸 · 상자는 안 그림(값 그대로) · B05 · B06 시설 · 구조물 명세 폼이 정의 「표시」를 따름 - 판 알림: GET 구조물 목록에 definition_version · structures.json 판 ≠ 지금 판이면 「구조물 정의가 판 N 으로 바뀜 — 저장하면 반영」 한 번 - 시험: test_322_2_check_mirror(거울 · 규칙 · 반영 손질 · 서버 저장 · 422) · 판 알림 · 옛 정의 화면 시험 = 선택지 값 대신 이름 표 · 못 숨김 아닌 칸 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
222 lines
10 KiB
TypeScript
222 lines
10 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";
|
|
import { DEFAULT_BASIN_SHAPE } from "../B06_Section/B06_Section_UI_Cross_Culvert_Basin";
|
|
import { fillPipeHeights } from "../common_util/common_util_culvert_sets";
|
|
import { snapStructureStation } from "../common_util/common_util_structure_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;
|
|
// 칸 밑 근거 글(`default_basis`)은 [?] 설명서 `구조물_<종류>.md` 로 옮김(54-2)
|
|
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;
|
|
}
|
|
|
|
/** 등록부 옵션 한 칸의 모양 — `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 · 제안값 단추 없음) · 옛 기록의 빈 칸은 빈 칸 그대로(미확정).
|
|
* 필수 · 기본값 있는 고르기 칸 = 빈 보기 없음(54-43 · 빈 옛 값은 기본값 · 없으면 첫 보기) ·
|
|
* 그 밖 고르기 칸 = 빈 글 빈 보기.
|
|
*/
|
|
export function optionControl(
|
|
option: OptionShape,
|
|
stored: string | number | undefined,
|
|
/** 선택지 보일 글(정의 이름 표 · 52-12) — 값은 그대로 */
|
|
label: (choice: string) => string = (c) => c,
|
|
): HTMLInputElement | HTMLSelectElement {
|
|
const value = stored === undefined || stored === null ? "" : String(stored);
|
|
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
|
|
const tip = option.not_in_table ?? option.empty_means ?? "";
|
|
if (option.input === "select") {
|
|
const fallback = option.default === null || option.default === "" ? "" : String(option.default);
|
|
const must = !!option.required || fallback !== "";
|
|
// 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음)
|
|
const kept = value && !option.choices.includes(value) ? [value] : [];
|
|
const element = select([
|
|
...(must ? [] : [["", ""] as [string, string]]),
|
|
...[...option.choices, ...kept].map((c) => [c, label(c)] as [string, string]),
|
|
]);
|
|
element.value = value || fallback;
|
|
if (element.selectedIndex < 0) element.selectedIndex = 0;
|
|
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;
|
|
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;
|
|
}
|
|
|
|
/** 정의 「표시」로 숨긴 옵션 키(서버 `visible` 거짓 · 322-2) — 명세 폼이 그 칸 · 상자를 안 그림. */
|
|
export const hiddenOptionKeys = (
|
|
options: ReadonlyArray<{ key: string; visible?: boolean }>,
|
|
): Set<string> => new Set(options.filter((o) => o.visible === false).map((o) => o.key));
|
|
|
|
/** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */
|
|
export function optionDefaults(
|
|
options: ReadonlyArray<{ key: string; default: string | number | null }>,
|
|
): Record<string, string | number> {
|
|
const values: Record<string, string | number> = Object.fromEntries(
|
|
options
|
|
.filter((option) => option.default !== null && option.default !== "")
|
|
.map((option) => [option.key, option.default as string | number]),
|
|
);
|
|
// 배관 집수정 모양도 실제 값으로(54-39 · 54-36 기본 · 자동 배치 짝 `_apply_new_defaults`)
|
|
if (values.inlet_type === "집수정") values.inlet_structure ??= DEFAULT_BASIN_SHAPE;
|
|
// 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1)
|
|
fillPipeHeights(
|
|
values,
|
|
options.map((option) => option.key),
|
|
);
|
|
return values;
|
|
}
|
|
|
|
/** 측점번호 + 잔여거리 두 칸 묶음. */
|
|
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("1", "0"); // 기준 측점 = 정수 m(234-1)
|
|
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(0); // 측점 + 거리 = 정수 m(167)
|
|
};
|
|
// 다른 change 리스너(옵션 잠금·임시 배치)보다 먼저 등록돼 정규화된 값이 읽힌다.
|
|
[station, remainder].forEach((input) => input.addEventListener("change", normalize));
|
|
}
|
|
// 칸은 잔여를 정수로 보임(167) — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌
|
|
// (표시값을 다시 읽으면 옛 소수 값이 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨)
|
|
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);
|
|
// 친 값은 정수 m(반올림 · 167) — 안 고친 칸은 위에서 옛 값 그대로
|
|
return valid ? snapStructureStation(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(0); // 측점 + 거리 = 정수 m(167)
|
|
written = { chainageM, station: station.value, remainder: remainder.value };
|
|
},
|
|
clearInvalid: () => markInvalid(false),
|
|
};
|
|
}
|