Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts
T
eomsangdonandClaude Opus 5.5 4a04442611 wip: 사면안정 표 09 흙막이 · 옹벽 · 소단 컨테이너(54-26) 중지(사용자) · 남은 일 = 검증 데이터 되돌림 · 사진
- 정의 판 15(이력 판_14) — 흙막이 「흙막이」(형태 6 · 길이 · 높이 · 전 · 후) · 옹벽 「옹벽」(새 칸 wall_form 콘크리트 · 길이 · 높이 · 전 · 후) · 소단 「상세 내용」(소단 폭 · 소단 높이 새 칸)
- 표 밖 옛 칸 = 표시 숨김(값 · 기본값 그대로) · 옹벽 옛 형식(form) 값 그대로 통과 · 흙막이 형태 B05 로 옮기며 필수 풂(옛 흙막이 저장 보호)
- 흙막이 · 옹벽 길이 = 전 + 후 맞물림(solveSplit) · 숨긴 길이 · 전 · 후(소단)는 값만 쥐어 범위 · 저장 이어 씀
- 높이 = 좌측 칸 하나 · 횡단 그림이 따라감(독립 벽 조정창은 위치 전용 · 높이 줄 안 둠)
- 시험 test_54_26_slope_table09 · 54-6 · 등록부 정책 시험 표 09 몫 맞춤 · ui_checks/54-26_사면안정_표09
- 남은 일: 검증 프로젝트(랩탑_보조) 심은 흙막이 · 소단 되돌림(restore 단계) · 사진 · 소단 높이는 계산에 아직 안 쓰임

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-06 13:00:25 +09:00

303 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B05_Profile_UI_Structures_Panel_Options.ts
* 구조물 배치 패널의 **옵션 칸 그리기** — 타입의 옵션 스키마대로 입력 칸을 다시 그린다.
*
* `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 떼어낸 조각이다(2026-09-29 · PLAN 21-4).
* 그리는 규칙은 옮기기 전 그대로이고, 패널 클로저가 쥐던 값만 `ctx` 로 받는다.
* 새로 얹은 것 하나 — 서버가 `visible: false` 로 채운 옵션은 그리지 않는다(정의 · 옛 값은 그대로,
* 저장 때 안 그린 칸은 보존한다 → `B05_Profile_UI_Structures_Summary.mergeStructureOptions`).
* ========================================================================== */
import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { field, optionControl } from "./B05_Profile_UI_Structures_Fields";
import {
columnsOf,
gridPlace,
orderedOptions,
ruleDefaults,
ruleStates,
staleValues,
} from "./B05_Profile_UI_Structures_Define";
import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { stepper } from "./B05_Profile_UI_Drainage_Facility_Fields";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { paintStructureSpec, specOwnedKeys } from "./B05_Profile_UI_Structures_Spec";
import { solveSplit, specKeys, type SplitRole } from "../A00_Common/design/design_structure_spec";
/** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로
* 그리는 종류(PLAN 52-1 · 52-6b · 52-7) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */
export const hidesTypeSummary = (type: StructureType): boolean =>
!!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length || type.group === "B";
/** 길이 = 전 + 후 를 배관 맞물림(`solveSplit` · 마지막에 고친 둘을 지킴)으로 푸는 종류(표 07 가드레일 · 표 09 흙막이 · 옹벽). */
const SOLVE_SPLIT = new Set(["guardrail", "soil_guard", "retaining_wall"]);
const SPLIT_ROLE: Record<string, SplitRole> = {
length_m: "length",
before_m: "before",
after_m: "after",
};
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
export interface OptionRenderContext {
optionInputs: Array<{
key: string;
required: boolean;
input: HTMLInputElement | HTMLSelectElement;
read: () => string | number;
isEmpty: () => boolean;
}>;
optionRow: HTMLElement;
/** B06/B07 은 상세(detail) 옵션까지 그린다. */
includeDetail: boolean;
currentType: () => StructureType | null;
facilityFormKind: (type: StructureType | null) => PipeFacility | null;
liveCommit: () => void;
syncOptionLock: () => void;
syncRangeDisplay: () => void;
syncSplitInputs: (source: "length" | "before_m" | "after_m", normalizeSource: boolean) => void;
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면
* (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */
export function renderOptionFields(
ctx: OptionRenderContext,
values: Record<string, string | number> = {},
): void {
const { optionInputs, optionRow } = ctx;
const type = ctx.currentType();
// 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로
// 바꾸면 옛 칸 목록을 읽어 저장값이 어긋난다(2026-09-04).
optionInputs.length = 0;
optionRow.replaceChildren();
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
// 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`).
// 명세 종류(52-10)는 명세 키 · 명세가 뺀 옛 키를 칸으로 안 그림 — 명세 폼이 옵션 칸 아래에 그림
const owned = specOwnedKeys(type);
const shown =
type && !ctx.facilityFormKind(type)
? orderedOptions(type).filter(
(o) => (ctx.includeDetail || isB05Option(o)) && !owned.has(o.key),
)
: [];
paintStructureSpec(optionRow, type, values);
optionRow.style.gridTemplateColumns = "";
if (!type || !shown.length) {
optionRow.hidden = true;
ctx.syncOptionLock();
return;
}
optionRow.hidden = false;
const columns = columnsOf(type);
if (columns !== 2) optionRow.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`;
// 규칙(48-4)이 가린 칸은 `optionInputs` 에서 뺌 — 필수 검사 · 저장이 안 봄(옛 값은 병합이 지킴)
const all: Array<{ entry: (typeof optionInputs)[number]; wrap: HTMLElement }> = [];
shown.forEach((option) => {
// 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7).
// 보기는 정의 전부로 세워 옛 값을 잃지 않고, 숨김 · 규칙 선택지는 `applyRules` 가 좁힘.
const name = (c: string): string => type.spec_names?.[`${option.key}=${c}`] ?? c;
const input = optionControl(option, values[option.key], name);
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
// 정의 그룹 상자로 옮겨 갈 숫자 칸 = 명세 컨테이너와 같은 [-][숫자][+](52-18)
const grouped =
option.input === "number" && !!type.groups?.some((g) => g.options.includes(option.key));
const wrap = field(label, grouped ? stepper(input as HTMLInputElement, 0.1) : input);
if (grouped) wrap.htmlFor = input.id; // 라벨 대상 = 숫자 칸(첫 버튼으로 새지 않게)
wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3)
// 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로
const place = gridPlace(type.spots?.[option.key], columns);
wrap.style.gridRow = place?.row ?? "";
wrap.style.gridColumn = place?.column ?? "";
optionRow.append(wrap);
// 상위 값이 바뀌면 하위 기본값 · 선택지 · 보이기를 먼저 맞추고 반영(48-4)
input.addEventListener("change", () => {
applyRules(option.key);
markRequired(optionInputs, optionRow);
ctx.liveCommit();
});
input.addEventListener("input", () => markRequired(optionInputs, optionRow));
all.push({
wrap,
entry: {
key: option.key,
required: !!option.required,
input,
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
isEmpty: () => input.value.trim() === "",
},
});
// 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을
// 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다.
if (option.key === "face_slope_ratio") {
const warn = (): void => {
const value = Number(input.value);
const outside =
input.value.trim() !== "" && Number.isFinite(value) && (value < 0.2 || value > 0.5);
input.title = outside
? "품셈 표준경사 표 범위(0.20~0.50) 밖 값입니다 — 근거를 적어 두시기 바랍니다."
: "비워 두면 품셈 표대로 자동 판정합니다.";
input.classList.toggle("is-outside-standard", outside);
};
warn();
input.addEventListener("input", warn);
input.addEventListener("change", warn);
}
// 길이·기준측점 전/후가 바뀌면 서로 연동하고 측점 범위 표시가 즉시 따라온다
// (범위 계산 타입, 2026-08-19). 전·후는 **나중에 고친 쪽**이 살아남고 반대쪽이
// 길이 − 그 값으로 맞춰진다. 길이를 고치면 나중에 고쳤던 쪽을 지키며 재배분.
if (option.key === "length_m" || option.key === "before_m" || option.key === "after_m") {
const source = (option.key === "length_m" ? "length" : option.key) as
"length" | "before_m" | "after_m";
const refresh = (normalize: boolean): void => {
input.classList.remove("is-invalid");
if (SOLVE_SPLIT.has(type!.type_id)) {
solveInto(option.key); // 타이핑 중에도 — change 의 실시간 반영이 푼 값을 읽게
ctx.syncRangeDisplay();
} else ctx.syncSplitInputs(source, normalize);
};
input.addEventListener("input", () => refresh(false));
input.addEventListener("change", () => refresh(true));
}
});
// 숨긴 길이 · 전 · 후(표 09 소단) — 칸은 안 그리고 값만 쥠: 범위 계산 · 저장이 옛 값 · 기본값을 이어 씀(54-26)
for (const option of type.options) {
if (!SPLIT_ROLE[option.key] || option.visible !== false) continue;
const input = document.createElement("input");
input.value = values[option.key] === undefined ? "" : String(values[option.key]);
all.push({
wrap: document.createElement("div"),
entry: {
key: option.key,
required: false,
input,
read: () => Number(input.value) || 0,
isEmpty: () => input.value.trim() === "",
},
});
}
/** 길이 · 전 · 후 맞물림 — 고친 차례(최근이 앞)를 쥐고 나머지 하나를 셈. */
const recent: SplitRole[] = [];
function solveInto(key: string): void {
const role = SPLIT_ROLE[key];
recent.splice(0, recent.length, role, ...recent.filter((r) => r !== role));
const inputOf = (r: SplitRole) =>
all.find(({ entry }) => SPLIT_ROLE[entry.key] === r)?.entry.input;
const values = Object.fromEntries(
(["length", "before", "after"] as SplitRole[]).map((r) => [r, inputOf(r)?.value ?? ""]),
);
const solved = solveSplit(values, recent);
for (const r of ["length", "before", "after"] as SplitRole[]) {
const input = inputOf(r);
if (input) input.value = solved[r] ?? "";
}
}
/** 규칙 걸기 — `parent` 가 바뀐 칸이면 그 칸에 매인 기본값부터 넣음. */
function applyRules(parent: string | null): void {
const now = (): Record<string, string> =>
Object.fromEntries(all.map(({ entry }) => [entry.key, entry.input.value]));
if (parent) {
for (const [key, value] of Object.entries(ruleDefaults(type!, parent, now()))) {
const hit = all.find(({ entry }) => entry.key === key);
if (hit) hit.entry.input.value = String(value);
}
}
const next = ruleStates(type!, now());
optionInputs.length = 0;
for (const { entry, wrap } of all) {
const state = next.get(entry.key);
const off = state?.visible === false;
wrap.classList.toggle("is-rule-hidden", off); // 그룹 상자로 옮겨 가도 같은 표식
if (!off) optionInputs.push(entry);
if (state && entry.input.tagName.toLowerCase() === "select")
syncChoices(
entry.input as HTMLSelectElement,
state.choices,
(c) => type!.spec_names?.[`${entry.key}=${c}`] ?? c,
);
}
}
applyRules(null);
ctx.syncOptionLock();
ctx.syncRangeDisplay();
}
/** 빈 필수 칸 표시(54-12) — 칸 테두리 + 그 칸이 든 상자(격자) 안 안내 한 줄. 칸이 그룹 상자로 옮겨 간
* 뒤에도 맞게 패널이 칸 그리기 · 그룹 다시 세우기 · 값 바꿈마다 부름. 막지 않음(막기는 [추가] · [저장]). */
export function markRequired(
optionInputs: OptionRenderContext["optionInputs"],
optionRow: HTMLElement,
): void {
const scope = optionRow.parentElement ?? optionRow;
scope.querySelectorAll(".b05-structure__required-note").forEach((note) => note.remove());
scope
.querySelectorAll(".is-required-empty")
.forEach((input) => input.classList.remove("is-required-empty"));
for (const entry of optionInputs) {
if (!entry.required || !entry.isEmpty()) continue;
entry.input.classList.add("is-required-empty");
const box = entry.input.closest<HTMLElement>("[data-option-keys]")?.parentElement;
if (!box || box.querySelector(":scope > .b05-structure__required-note")) continue;
const note = document.createElement("p");
note.className = "b05-structure__required-note";
note.textContent = "필수 칸 비어 있음 — [저장] 전 채움";
box.append(note);
}
}
/** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */
function syncChoices(
element: HTMLSelectElement,
choices: string[],
label: (choice: string) => string,
): void {
const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value);
const keep = element.value && !choices.includes(element.value) ? [element.value] : [];
const want = [...choices, ...keep];
if (now.length === want.length && now.every((v, i) => v === want[i])) return;
const value = element.value;
const blank = element.children[0];
element.replaceChildren(blank, ...want.map((c) => new Option(label(c), c)));
element.value = value;
}
/** 「지난 정의 값」 상자(48-3) — 지금 정의에 없는 키의 값을 읽기만 칸으로 보임(오류 없음 · 저장은
* 병합이 옛 값 그대로 지킴). 아는 키 = 정의 옵션 전부(안 씀 포함) + 시설 폼이 적는 칸. */
export function paintStale(
box: HTMLElement,
type: StructureType | null,
values: Record<string, string | number> | undefined,
facility: PipeFacility | null,
/** 그룹 형상 키(`{그룹 id}__{칸}`) — 정의된 그룹 몫이라 지난 값이 아님. */
shapeKeys: string[] = [],
): void {
// 명세 종류(52) — 명세가 뺀 옛 키(retired)는 지난 정의 값 · 선택 컨테이너 키(머리)는 앎
const spec = type ? STRUCTURE_SPECS[type.type_id] : undefined;
const own = spec ? specKeys(spec) : { keys: [], prefixes: [] };
const known = [
...shapeKeys,
...(type?.options ?? []).map((o) => o.key),
...(facility ? (FORM_OPTION_KEYS[facility] ?? []) : []),
...own.keys,
].filter((key) => !spec?.retired.includes(key));
const stale = type
? staleValues(values, known).filter(([key]) => !own.prefixes.some((p) => key.startsWith(p)))
: [];
box.replaceChildren();
// 상자는 모든 종류에서 숨김(54-1) — 값은 파일에 그대로
box.hidden = true;
if (!stale.length) return;
const legend = document.createElement("legend");
legend.textContent = "지난 정의 값";
const body = document.createElement("div");
body.className = "b05-structure__grid";
for (const [key, value] of stale) {
const input = document.createElement("input");
input.type = "text";
input.readOnly = true;
input.value = String(value);
input.title = "지금 정의에 없는 칸 — 읽기만 · 계산에 안 쓰임";
body.append(field(key, input));
}
box.append(legend, body);
}