332 lines
9.7 KiB
TypeScript
332 lines
9.7 KiB
TypeScript
/* =============================================================================
|
||
* 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<string, [number, number]>;
|
||
}
|
||
|
||
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<string, number | null>)[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<T extends string>(
|
||
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];
|
||
}
|