/* ============================================================================= * E06_StabRock_UI_Joints.ts * E06 좌측 — 절리군 표(구간 · 경사방향 · 경사 · φj · cj · 지속성 · [추가] [지움]) + 해석 설정 * (측방 한계 · 암 단위중량 · 우기 수위 · 인장균열 · 볼트). 칸을 바꾸면 doc 을 고치고 onChange — * 캐시 · 저장은 페이지 몫. 범위 = 서버 LIMITS(벗어나면 칸 빨강 · 마우스 글). * ========================================================================== */ import { createPanelCard } from "@ui/ui_template_collapsible"; import { createButton, el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; 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-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 = el("input", { className: "e06-input", attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path }, }) as HTMLInputElement; 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; input.classList.toggle("is-invalid", !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 table = el("table", { className: "e06-table" }); const head = el("tr"); for (const key of COLUMNS) head.append(el("th", { text: t(key) })); head.append(el("th")); table.append(el("thead"), el("tbody")); table.tHead!.append(head); view.doc.sets.forEach((row, k) => { const tr = el("tr", { attrs: { "data-id": row.id } }); const name = el("input", { className: "e06-input e06-input--name", attrs: { type: "text", "data-path": `sets.${k}.name` }, }) as HTMLInputElement; name.value = row.name; name.disabled = !view.editable; name.addEventListener("input", () => { row.name = name.value; name.classList.toggle("is-invalid", !name.value.trim()); view.change(); }); tr.append(el("td", { children: [name] })); 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], ]; for (const [key, limit, optional] of fields) { const input = numberInput( view, row[key] as number | null, limit, (v) => ((row as unknown as Record)[key] = v), { optional, path: `sets.${k}.${key}` }, ); tr.append(el("td", { children: [input] })); } const remove = createButton({ label: "×", variant: "danger-ghost", size: "sm", onClick: () => { view.doc.sets.splice(k, 1); view.rebuild(); }, }); remove.title = t("E06_Remove"); remove.disabled = !view.editable; tr.append(el("td", { children: [remove] })); table.tBodies[0].append(tr); }); wrap.append(table); if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") })); return wrap; } function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement { const row = el("label", { className: "e06-setting" }); row.append(el("span", { text: t(label) }), control); return row; } function select( view: JointsView, value: T, options: [T, LocaleKey][], set: (v: T) => void, path: string, ): HTMLSelectElement { const box = el("select", { className: "e06-input", attrs: { "data-path": path }, }) as HTMLSelectElement; for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } })); 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-setting__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( el("p", { className: "e06-hint", text: 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", }), ), el("p", { className: "e06-hint", text: t("E06_SettingsHint") }), el("p", { className: "e06-hint", text: t("E06_MinBlockHint") }), ); return [setsCard, setCard]; }