/* ============================================================================= * E06_StabRock_UI_Joints.ts * E06 좌측 — 절리군 표(구간 · 경사방향 · 경사 · φj · cj · 지속성 · [추가] [지움]) + 해석 설정 * (측방 한계 · 암 단위중량 · 우기 수위 · 인장균열 · 볼트). 칸을 바꾸면 doc 을 고치고 onChange — * 캐시 · 저장은 페이지 몫. 범위 = 서버 LIMITS(벗어나면 칸 빨강 · 마우스 글). * ========================================================================== */ import { createPanelCard } from "@ui/ui_template_collapsible"; import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { settingRow as settingRowPart } from "@ui/ui_template_setting_row"; import { e06Table, note } from "./E06_StabRock_UI_Parts"; export interface JointSet { id: string; name: string; from_m: number; to_m: number; dip_direction: number; dip: number; phi: number; c: number; persistence_m: number | null; } export type Projection = "equal_angle" | "equal_area"; export interface RockSettings { projection: Projection; lateral_planar_deg: number; lateral_toppling_deg: number; gamma_r: number | null; water_fill_wet: number; min_block_m3: number; crack: { mode: "none" | "critical" | "depth"; z: number | null }; bolt: { force: number; plunge: number }; } export interface JointsDoc { version: number; sets: JointSet[]; settings: RockSettings; } export interface RockDefaults { settings: RockSettings; new_set: { phi: number; c: number }; limits: Record; } export interface JointsView { doc: JointsDoc; defaults: RockDefaults; editable: boolean; endM: number; // 마지막 암 측점(새 줄 구간 끝) change: () => void; // 값만 바뀜 rebuild: () => void; // 줄 수가 바뀜 } /** 틀린 칸 표시 — 부품 오류 상태(`is-error` · aria-invalid) */ function markInvalid(input: HTMLElement, bad: boolean): void { input.classList.toggle("is-error", bad); if (bad) input.setAttribute("aria-invalid", "true"); else input.removeAttribute("aria-invalid"); } /** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 오류 표시 · 바꾸면 set 부르고 change */ function numberInput( view: JointsView, value: number | null, limit: string, set: (v: number | null) => void, opts: { optional?: boolean; step?: string; path: string }, ): HTMLInputElement { const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity]; const input = createInputField({ type: "number", size: 24 }).input; input.classList.add("e06-input"); input.step = opts.step ?? "any"; input.dataset.path = opts.path; input.value = value === null ? "" : String(value); input.disabled = !view.editable; const check = (): boolean => { const raw = input.value.trim(); const num = Number(raw); const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi; markInvalid(input, !ok); input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`; return ok; }; input.addEventListener("input", () => { const raw = input.value.trim(); set(raw === "" ? null : Number(raw)); check(); view.change(); }); check(); return input; } function nextId(sets: JointSet[]): string { let k = sets.length + 1; while (sets.some((s) => s.id === `J${k}`)) k += 1; return `J${k}`; } const COLUMNS: LocaleKey[] = [ "E06_Col_Name", "E06_Col_From", "E06_Col_To", "E06_Col_DipDir", "E06_Col_Dip", "E06_Col_Phi", "E06_Col_C", "E06_Col_Persist", ]; function jointTable(view: JointsView): HTMLElement { const wrap = el("div", { className: "e06-table-wrap" }); const sets = view.doc.sets; const fields: [keyof JointSet, string, boolean][] = [ ["from_m", "chainage_m", false], ["to_m", "chainage_m", false], ["dip_direction", "dip_direction", false], ["dip", "dip", false], ["phi", "phi", false], ["c", "c", false], ["persistence_m", "persistence_m", true], ]; const rows = sets.map((row, k) => { const name = createInputField({ size: 24 }).input; name.classList.add("e06-input", "e06-input--name"); name.dataset.path = `sets.${k}.name`; name.value = row.name; name.disabled = !view.editable; name.addEventListener("input", () => { row.name = name.value; markInvalid(name, !name.value.trim()); view.change(); }); const cells: HTMLElement[] = [name]; for (const [key, limit, optional] of fields) { cells.push( numberInput( view, row[key] as number | null, limit, (v) => ((row as unknown as Record)[key] = v), { optional, path: `sets.${k}.${key}` }, ), ); } const remove = createButton({ label: "×", variant: "danger-ghost", size: "sm", onClick: () => { sets.splice(k, 1); view.rebuild(); }, }); remove.title = t("E06_Remove"); remove.disabled = !view.editable; cells.push(remove); return cells; }); const list = e06Table([...COLUMNS.map((key) => t(key)), ""], rows, "e06-table"); list.rows.forEach((tr, k) => (tr.dataset.id = sets[k].id)); wrap.append(list.root); if (!sets.length) wrap.append(note(t("E06_NoSets"))); return wrap; } /** 좁은 패널 설정 줄 — 설정 줄 부품 좁은 꼴 */ function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement { return settingRowPart(t(label), "", control, { compact: true }); } function select( view: JointsView, value: T, options: [T, LocaleKey][], set: (v: T) => void, path: string, ): HTMLSelectElement { const box = createSelectField({ options: options.map(([v, key]) => ({ value: v, text: t(key) })), size: 24, }).select; box.classList.add("e06-input"); box.dataset.path = path; box.value = value; box.disabled = !view.editable; box.addEventListener("change", () => { set(box.value as T); view.rebuild(); }); return box; } /** 최소 블록 크기 칸 + [기본값] — 기본값 = 근거 후보(사용자 확정 전) */ function minBlockControl(view: JointsView, s: RockSettings): HTMLElement { const reset = createButton({ label: t("E06_Defaults"), variant: "ghost", size: "sm", onClick: () => { s.min_block_m3 = view.defaults.settings.min_block_m3; view.change(); view.rebuild(); }, }); reset.disabled = !view.editable; const box = el("span", { className: "e06-ctl" }); box.append( numberInput(view, s.min_block_m3, "min_block_m3", (v) => (s.min_block_m3 = v ?? NaN), { step: "0.01", path: "settings.min_block_m3", }), reset, ); return box; } export function buildJointCards(view: JointsView): HTMLElement[] { const s = view.doc.settings; const setsCard = createPanelCard(t("E06_Card_Joints"), false); const add = createButton({ label: t("E06_Add"), variant: "ghost", size: "sm", onClick: () => { const id = nextId(view.doc.sets); view.doc.sets.push({ id, name: id, from_m: 0, to_m: Math.ceil(view.endM), dip_direction: 0, dip: 45, phi: view.defaults.new_set.phi, c: view.defaults.new_set.c, persistence_m: null, }); view.rebuild(); }, }); add.disabled = !view.editable; setsCard.append( note(t("E06_JointsHint")), jointTable(view), el("div", { className: "e06-row-actions", children: [add] }), ); const setCard = createPanelCard(t("E06_Card_Settings"), true); const crack = s.crack; setCard.append( settingRow( "E06_Set_LateralPlanar", numberInput( view, s.lateral_planar_deg, "lateral_deg", (v) => (s.lateral_planar_deg = v ?? NaN), { path: "settings.lateral_planar_deg", }, ), ), settingRow( "E06_Set_LateralTopple", numberInput( view, s.lateral_toppling_deg, "lateral_deg", (v) => (s.lateral_toppling_deg = v ?? NaN), { path: "settings.lateral_toppling_deg" }, ), ), settingRow( "E06_Set_Gamma", numberInput(view, s.gamma_r, "gamma_r", (v) => (s.gamma_r = v), { optional: true, path: "settings.gamma_r", }), ), settingRow( "E06_Set_WaterWet", numberInput(view, s.water_fill_wet, "water_fill_wet", (v) => (s.water_fill_wet = v ?? NaN), { step: "0.05", path: "settings.water_fill_wet", }), ), settingRow("E06_Set_MinBlock", minBlockControl(view, s)), settingRow( "E06_Set_Crack", select( view, crack.mode, [ ["none", "E06_Crack_None"], ["critical", "E06_Crack_Critical"], ["depth", "E06_Crack_Depth"], ], (v) => { crack.mode = v; crack.z = v === "depth" ? (crack.z ?? 1) : null; }, "settings.crack.mode", ), ), ); if (crack.mode === "depth") setCard.append( settingRow( "E06_Set_CrackZ", numberInput(view, crack.z, "crack_z", (v) => (crack.z = v), { path: "settings.crack.z" }), ), ); setCard.append( settingRow( "E06_Set_BoltForce", numberInput(view, s.bolt.force, "bolt_force", (v) => (s.bolt.force = v ?? NaN), { path: "settings.bolt.force", }), ), settingRow( "E06_Set_BoltPlunge", numberInput(view, s.bolt.plunge, "bolt_plunge", (v) => (s.bolt.plunge = v ?? NaN), { path: "settings.bolt.plunge", }), ), note(t("E06_SettingsHint")), note(t("E06_MinBlockHint")), ); return [setsCard, setCard]; }