- 잇기 줄 글 「집계 항목」 → 「구조물 조합」 (가드레일 · 맹암거 · 대피로 · 사토장 · 토취장) - 그룹 숫자 칸 = −/+ 단추 달린 숫자 칸(0.1 · 소수 1자리 · 라벨 대상 = 숫자 칸) - 상자 = 명세와 같은 조각(group · grid) · 그룹 전용 상자 꼴 CSS 걷음 - 저장 값 · 키 · 잇기 동작 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
265 lines
13 KiB
TypeScript
265 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* 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 가드레일). */
|
||
const SOLVE_SPLIT = new Set(["guardrail"]);
|
||
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,
|
||
option.default_basis,
|
||
);
|
||
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);
|
||
ctx.liveCommit();
|
||
});
|
||
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));
|
||
}
|
||
});
|
||
/** 길이 · 전 · 후 맞물림 — 고친 차례(최근이 앞)를 쥐고 나머지 하나를 셈. */
|
||
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();
|
||
}
|
||
|
||
/** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */
|
||
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();
|
||
box.hidden = !stale.length;
|
||
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);
|
||
}
|