/* ============================================================================= * E03_StabSection_UI_Support.ts * E03 「보강 검토」 탭 네일 · 앵커 가정(PLAN 536-3 뒷부분) — 종류 · 배치(행 수 · 첫 행 높이 · 수직 · 수평 간격) · * 제원 → supports 목록(`layoutSupports` · 입력 꼴 그대로 POST …/stab/whatif/{측점}) · 최소 길이 찾기 · * 전후 안전율 표 · 네일마다 동원력 · mode 표 · 후 단면 그림(머리 · 정착 구간 · 활동면과 만나는 점 · mode 색). * 기본값 = 국내 흔한 값 후보(사용자 확정 전) · 계산은 서버. * ========================================================================== */ import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { slopeTable } from "./E03_StabSection_UI_Reinforce"; import { createSectionCanvas } from "./E03_StabSection_UI_Section"; import type { Condition, SectionView, SideCase, StationRow } from "./logic/E03_StabSection_Logic"; import { layoutSupports, supportDraws, SUPPORT_DEFAULTS, type SupportForce, type SupportIn, type SupportLayout, type SupportType, } from "./logic/E03_StabSection_Support"; export interface SupportRecord { type: "support"; station_key: string; assume: { side: string; role: "cut" | "fill"; supports: SupportIn[] }; search: { found: number | null; tried: [number, boolean][]; longest: number; condition: Condition | null; } | null; before: Partial>; after: Partial>; support_forces: Partial> | null; supports_pending: boolean; elapsed_s: number; view: SectionView; } export interface SupportAsk { side: string; ratio: null; bench_w: 0; bench_h: 0; search: boolean; condition: Condition | null; supports: SupportIn[]; } export interface SupportOptions { row: StationRow; /** 지금 측점 단면(원본) — 비탈 면 · 활동 방향 */ view: SectionView; condition: Condition; side: string | null; editable: boolean; busy: boolean; record: SupportRecord | undefined; run: (ask: SupportAsk) => void; } /** 마지막 배치 — 다시 그려도(계산 중 · 측점 바꿈) 입력 그대로 */ let layout: SupportLayout = { ...SUPPORT_DEFAULTS.nail }; type NumKey = Exclude; const FIELDS: NumKey[] = [ "rows", "first_m", "v_spacing_m", "h_spacing_m", "length_m", "angle_deg", "tensile_kN", "bond_kPa", "bond_length_m", "hole_dia_mm", "prestress_kN", ]; const ANCHOR_ONLY: NumKey[] = ["bond_length_m", "prestress_kN"]; const MODES = ["tensile", "pullout", "stripping", "prestress", "not_crossing"] as const; const fmt = (v: number | null | undefined, d = 2): string => v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); function assumeText(rec: SupportRecord): string { const s = rec.assume.supports; const first = s[0]; if (!first) return ""; let text = `${t(`E03_Sp_${first.type}` as LocaleKey)} ${s.length}${t("E03_Sp_Count")} · ` + `L ${fmt(first.length_m, 1)} m · ${first.angle_deg}° · @${first.spacing_m} m`; if (rec.search) text += rec.search.found ? ` · ${t("E03_Sp_Found")} (${rec.search.tried.length}${t("E03_Iter")})` : ` · ${t("E03_Sp_NotFound").replace("{n}", String(rec.search.longest))}`; return `${text} · ${rec.elapsed_s} s`; } function forceTable(rec: SupportRecord, condition: Condition): HTMLElement { const forces = rec.support_forces?.[condition] ?? null; const table = el("table", { className: "e03-table", attrs: { "data-role": "support-table" } }); const head = ["E03_Sp_No", "E03_Sp_Type", "E03_Sp_Head", "E03_Sp_Length", "E03_Sp_Force"]; table.append( el("thead", { children: [ el("tr", { children: [ ...head.map((k, i) => el("th", { className: i > 1 ? "is-num" : "", text: t(k as LocaleKey) }), ), el("th", { text: t("E03_Sp_Mode") }), ], }), ], }), ); const body = el("tbody"); rec.assume.supports.forEach((s, i) => { const f = forces?.find((v) => v.index === i); const mode = f?.mode ?? "pending"; body.append( el("tr", { attrs: { "data-i": String(i), "data-mode": mode }, children: [ el("td", { text: String(i + 1) }), el("td", { text: t(`E03_Sp_${s.type}` as LocaleKey) }), el("td", { className: "is-num", text: `${fmt(s.x)} / ${fmt(s.z)}` }), el("td", { className: "is-num", text: fmt(s.length_m, 1) }), el("td", { className: "is-num", text: fmt(f?.mobilized_kN_per_m, 1) }), el("td", { children: [ el("span", { className: `e03-support e03-support--${mode}`, children: [el("span", { className: "e03-support-swatch" })], }), el("span", { text: t(`E03_Sp_Mode_${mode}` as LocaleKey) }), ], }), ], }), ); }); table.append(body); return table; } function legend(): HTMLElement { const box = el("p", { className: "e03-muted", attrs: { "data-role": "support-legend" } }); for (const mode of MODES) box.append( el("span", { className: `e03-support e03-support--${mode}`, children: [el("span", { className: "e03-support-swatch" })], }), el("span", { text: `${t(`E03_Sp_Mode_${mode}` as LocaleKey)} ` }), ); return box; } export function buildSupport(o: SupportOptions): HTMLElement { const host = el("div", { className: "e03-card", attrs: { "data-role": "support" } }); const sides = o.row.sides; const sideField = createSelectField({ label: t("E03_Side"), options: sides.map((s) => ({ value: s.side, text: `${t(s.side === "left" ? "E03_Left" : "E03_Right")} · ${t(s.role === "cut" ? "E03_Cut" : "E03_Fill")}`, })), value: o.record?.assume.side ?? o.side ?? undefined, compact: true, }); const typeField = createSelectField({ label: t("E03_Sp_Type"), options: (["nail", "anchor"] as SupportType[]).map((v) => ({ value: v, text: t(`E03_Sp_${v}` as LocaleKey), })), value: layout.type, compact: true, }); typeField.select.dataset.role = "support-type"; const inputs = new Map(); const fields = el("div", { className: "e03-support-fields" }); fields.append(sideField.root, typeField.root); for (const key of FIELDS) { const f = createInputField({ label: t(`E03_Sp_F_${key}` as LocaleKey), type: "number", min: 0, compact: true, value: String(layout[key]), }); f.input.step = key === "rows" ? "1" : "0.1"; f.input.dataset.field = key; f.input.disabled = layout.type === "nail" && ANCHOR_ONLY.includes(key); f.input.addEventListener("input", () => { const v = Number(f.input.value); if (f.input.value.trim() !== "" && Number.isFinite(v)) layout = { ...layout, [key]: v }; }); inputs.set(key, f.input); fields.append(f.root); } typeField.select.addEventListener("change", () => { layout = { ...SUPPORT_DEFAULTS[typeField.select.value as SupportType] }; for (const [key, input] of inputs) { input.value = String(layout[key]); input.disabled = layout.type === "nail" && ANCHOR_ONLY.includes(key); } }); const ask = (search: boolean): SupportAsk | null => { const side = sideField.select.value; const info = sides.find((s) => s.side === side); if (!info) return null; const supports = layoutSupports(o.view.surface, side, info.role, info.height_m, layout); return { side, ratio: null, bench_w: 0, bench_h: 0, search, condition: search ? o.condition : null, supports, }; }; const send = (search: boolean): void => { const a = ask(search); if (a && a.supports.length) o.run(a); }; const run = createButton({ label: o.busy ? t("E03_Rf_Busy") : t("E03_Sp_Run"), variant: "filled", onClick: () => send(false), }); const find = createButton({ label: t("E03_Sp_Search"), variant: "ghost", onClick: () => send(true), }); run.disabled = find.disabled = o.busy || !o.editable; run.dataset.role = "support-run"; find.dataset.role = "support-search"; const buttons = el("div", { className: "e03-panel__row" }); buttons.append(find, run); host.append( el("h3", { className: "e03-card__title", text: t("E03_Sp_Title") }), el("p", { className: "e03-muted", text: t("E03_Sp_Hint") }), el("p", { className: "e03-muted", text: t("E03_Sp_Source"), attrs: { "data-role": "support-source" }, }), fields, buttons, ); const rec = o.record; if (!rec) return host; host.append( el("p", { className: "e03-muted", text: assumeText(rec), attrs: { "data-role": "support-assume" }, }), ); if (rec.supports_pending) host.append( el("p", { className: "e03-muted", text: t("E03_Sp_Pending"), attrs: { "data-role": "support-pending" }, }), ); host.append(slopeTable(rec), forceTable(rec, o.condition), legend()); const canvas = createSectionCanvas(() => undefined); canvas.root.style.height = "260px"; canvas.root.style.flex = "none"; canvas.root.dataset.role = "support-section"; host.append(canvas.root); const analyses = rec.view.analyses.filter((a) => a.condition === o.condition); const analysis = analyses.find((a) => a.side === rec.assume.side) ?? analyses[0] ?? null; const direction = rec.view.analyses.find((a) => a.side === rec.assume.side)?.direction ?? (rec.assume.side === "left" ? -1 : 1); const draw = { view: rec.view, analysis, toggles: { map: false, centers: false, candidates: false }, picked: null, supports: supportDraws( rec.assume.supports, direction, analysis?.critical?.points ?? null, rec.support_forces?.[o.condition], ), }; canvas.render(draw); // 붙기 전 = 기본 폭 · 붙으면 ResizeObserver 가 다시 그림 return host; }