/* ============================================================================= * E02_StabGround_UI_Tables.ts * E02 지반 조건 본문 카드 넷(요구 347-2 · 3 · 4) — 물성 표(지층 추가 · 지움 · B06 지반 연결) · * 층 두께 · 구간 덮어쓰기 + 측점별 적용 · 기준안전율 · 하중 · 지진 · 지하수(`_UI_Water`). 카드마다 [기본값]. * 칸 = data-path(검사 열쇠 · 파이썬과 같음) · data-src(근거 열쇠) · data-def(기본값 글). * 값 바꿈 = view.change()(캐시 · 검사) · 줄 더함 · 지움 · [기본값] = view.rebuild(). * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { createCheckbox } from "@ui/ui_template_checkbox"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { CASES, LABELS, MATERIAL_FIELDS, SLOPES, WATER_GROUPS, resolveStations, type Ground, type GroundDefaults, type Material, } from "./logic/E02_StabGround_Check"; import { waterCard } from "./E02_StabGround_UI_Water"; export interface E02View { ground: Ground; defaults: GroundDefaults; editable: boolean; change: () => void; rebuild: () => void; } const L = (key: string): string => t(key as LocaleKey); export const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = { gamma_t: "E02_Col_GammaT", gamma_sat: "E02_Col_GammaSat", c: "E02_Col_C", phi: "E02_Col_Phi", }; /** 숫자 칸 — 빈 칸 = null(nullable) · 아니면 숫자 아님으로 검사에 걸림 */ export function numInput( view: E02View, path: string, value: number | null, set: (v: number | null) => void, meta: { src?: string; def?: number | null; nullable?: boolean } = {}, ): HTMLInputElement { const input = el("input", { className: "ui-input ui-input--sm e02-input e02-input--num", }) as HTMLInputElement; input.type = "number"; input.step = "any"; input.value = value === null || value === undefined ? "" : String(value); input.dataset.path = path; if (meta.src) input.dataset.src = meta.src; if (meta.def !== undefined && meta.def !== null) input.dataset.def = String(meta.def); if (meta.nullable) input.placeholder = t("E02_Zone_Keep"); input.disabled = !view.editable; input.addEventListener("input", () => { const raw = input.value.trim(); set(raw === "" ? (meta.nullable ? null : Number.NaN) : Number(raw)); view.change(); }); return input; } export function selectInput( view: E02View, path: string, options: { value: string; text: string }[], value: string | null, set: (v: string) => void, meta: { src?: string; def?: string } = {}, ): HTMLSelectElement { const select = el("select", { className: "ui-select ui-select--sm e02-input", }) as HTMLSelectElement; for (const option of options) select.append(new Option(option.text, option.value)); select.value = value ?? ""; select.dataset.path = path; if (meta.src) select.dataset.src = meta.src; if (meta.def) select.dataset.def = meta.def; select.disabled = !view.editable; select.addEventListener("change", () => { set(select.value); view.change(); }); return select; } export function card( title: LocaleKey, ...body: HTMLElement[] ): { section: HTMLElement; head: HTMLElement } { const head = el("div", { className: "e02-card__head" }); head.append(el("h3", { className: "e02-card__title", text: t(title) })); const section = el("section", { className: "e02-card ui-sidebar-section" }); section.append(head, ...body); return { section, head }; } export function headButtons( view: E02View, head: HTMLElement, ...buttons: HTMLButtonElement[] ): void { if (!view.editable) return; const row = el("div", { className: "e02-card__actions" }); row.append(...buttons); head.append(row); } export function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement { const node = el("table", { className: "e02-table" }) as HTMLTableElement; const tr = node.createTHead().insertRow(); for (const text of headers) tr.append(el("th", { text })); const body = node.createTBody(); for (const cells of rows) { const row = body.insertRow(); for (const cell of cells) row.insertCell().append(cell); } return node; } function defaultMaterial(view: E02View, id: string): Material | undefined { return view.defaults.materials.find((m) => m.id === id); } function layerOptions(ground: Ground, blank?: string): { value: string; text: string }[] { const list = ground.materials.map((m) => ({ value: m.id, text: m.name || m.id })); return blank === undefined ? list : [{ value: "", text: blank }, ...list]; } /* --- 1. 지반물성치 --- */ function materialsCard(view: E02View): HTMLElement { const g = view.ground; const used = (id: string): boolean => Object.values(g.label_map).includes(id) || g.zones.some((z) => z.soil === id || z.rock === id); const rows = g.materials.map((m, i) => { const base = `materials.${i}`; const name = el("input", { className: "ui-input ui-input--sm e02-input e02-input--name", }) as HTMLInputElement; name.value = m.name; name.dataset.path = `${base}.name`; name.disabled = !view.editable; name.addEventListener("input", () => { m.name = name.value; view.change(); }); const cells: HTMLElement[] = [name]; for (const field of MATERIAL_FIELDS) cells.push( numInput(view, `${base}.${field}`, m[field], (v) => (m[field] = v as number), { src: `materials.${m.id}.${field}`, def: defaultMaterial(view, m.id)?.[field] ?? null, }), ); cells.push( selectInput( view, `${base}.water`, WATER_GROUPS.map((w) => ({ value: w, text: L(w === "soil" ? "E02_Water_Soil" : "E02_Water_Rock"), })), m.water, (v) => (m.water = v as Material["water"]), ), ); const remove = createButton({ label: t("E02_Btn_Remove"), variant: "danger-ghost", size: "sm", onClick: () => { if (used(m.id)) return void showToast(t("E02_InUse"), "warning"); g.materials.splice(i, 1); view.rebuild(); }, }); remove.hidden = !view.editable; cells.push(remove); return cells; }); const headers = [ t("E02_Col_Name"), ...MATERIAL_FIELDS.map((f) => t(FIELD_LABEL[f])), t("E02_Col_Water"), "", ]; const mapRow = el("div", { className: "e02-map" }); mapRow.append(el("span", { className: "e02-map__title", text: t("E02_LabelMap") })); for (const label of LABELS) { const field = el("label", { className: "e02-field" }); field.append( el("span", { text: L(`E02_Label_${label}`) }), selectInput(view, `label_map.${label}`, layerOptions(g), g.label_map[label], (v) => { g.label_map[label] = v; view.rebuild(); }), ); mapRow.append(field); } const { section, head } = card("E02_Card_Materials", table(headers, rows), mapRow); section.classList.add("e02-card--wide"); headButtons( view, head, createButton({ label: t("E02_Btn_AddLayer"), variant: "ghost", size: "sm", onClick: () => { let n = 1; while (g.materials.some((m) => m.name === `${t("E02_NewLayer")} ${n}`)) n += 1; let id = `user_${n}`; for (let k = n; g.materials.some((m) => m.id === id); k += 1) id = `user_${k}`; const seed = view.defaults.materials[0]; g.materials.push({ ...seed, id, name: `${t("E02_NewLayer")} ${n}` }); view.rebuild(); }, }), createButton({ label: t("E02_Btn_Defaults"), variant: "ghost", size: "sm", onClick: () => { g.materials = clone(view.defaults.materials); g.label_map = clone(view.defaults.label_map); const ids = new Set(g.materials.map((m) => m.id)); for (const zone of g.zones) { if (zone.soil !== null && !ids.has(zone.soil)) zone.soil = null; if (zone.rock !== null && !ids.has(zone.rock)) zone.rock = null; } view.rebuild(); }, }), ); return section; } /* --- 2. 층 두께 · 구간 덮어쓰기 + 측점별 적용 --- */ function zonesCard(view: E02View): { section: HTMLElement; refresh: () => void } { const g = view.ground; const rows = g.zones.map((z, i) => { const base = `zones.${i}`; const remove = createButton({ label: t("E02_Btn_Remove"), variant: "danger-ghost", size: "sm", onClick: () => { g.zones.splice(i, 1); view.rebuild(); }, }); remove.hidden = !view.editable; const layer = (key: "soil" | "rock"): HTMLSelectElement => selectInput(view, `${base}.${key}`, layerOptions(g, t("E02_Zone_Keep")), z[key], (v) => { z[key] = v || null; }); return [ numInput(view, `${base}.from_m`, z.from_m, (v) => (z.from_m = v as number)), numInput(view, `${base}.to_m`, z.to_m, (v) => (z.to_m = v as number)), layer("soil"), layer("rock"), numInput( view, `${base}.soil_thickness_m`, z.soil_thickness_m, (v) => (z.soil_thickness_m = v), { nullable: true, src: "stations", }, ), remove, ]; }); const zoneBody = rows.length ? table( [ t("E02_Col_From"), t("E02_Col_To"), t("E02_Col_Upper"), t("E02_Col_Lower"), t("E02_Col_Thickness"), "", ], rows, ) : el("p", { className: "e02-empty", text: t("E02_Zone_Empty") }); const stationsHost = el("div", { className: "e02-stations" }); const refresh = (): void => { stationsHost.replaceChildren(); if (!g.stations.length) return void stationsHost.append( el("p", { className: "e02-empty", text: t("E02_Stations_Empty") }), ); const names = new Map(g.materials.map((m) => [m.id, m.name])); const interval = inferStationInterval(g.stations); const fmt = (v: number | null): string => (v === null ? "-" : v.toFixed(2)); const resolved = resolveStations(g); const node = table( [ t("E02_Col_Station"), t("E02_Col_GroundType"), t("E02_Col_B06Thickness"), t("E02_Col_Upper"), t("E02_Col_Lower"), t("E02_Col_Thickness"), ], resolved.map((r, i) => [ el("span", { text: stationLabel(r.chainage_m, interval) }), el("span", { text: L(`E02_Ground_${r.ground_type}`) }), el("span", { text: fmt(g.stations[i].soil_thickness_m) }), el("span", { text: (r.upper && names.get(r.upper)) || "-" }), el("span", { text: r.lower ? (names.get(r.lower) ?? "-") : t("E02_AllUpper") }), el("span", { text: fmt(r.soil_thickness_m) }), ]), ); resolved.forEach((r, i) => node.tBodies[0].rows[i].classList.toggle("is-zoned", r.zoned)); node.classList.add("e02-table--read"); stationsHost.append(node); }; refresh(); const { section, head } = card( "E02_Card_Zones", el("p", { className: "e02-hint", text: t("E02_Zone_Hint") }), zoneBody, el("h4", { className: "e02-card__sub", text: t("E02_Card_Stations") }), stationsHost, ); const first = g.stations[0]?.chainage_m ?? 0; const last = g.stations[g.stations.length - 1]?.chainage_m ?? 100; headButtons( view, head, createButton({ label: t("E02_Btn_AddZone"), variant: "ghost", size: "sm", onClick: () => { g.zones.push({ from_m: first, to_m: last > first ? last : first + 20, soil: null, rock: null, soil_thickness_m: null, }); view.rebuild(); }, }), createButton({ label: t("E02_Btn_Defaults"), variant: "ghost", size: "sm", onClick: () => { g.zones = clone(view.defaults.zones); view.rebuild(); }, }), ); return { section, refresh }; } /* --- 3. 기준안전율 --- */ function safetyCard(view: E02View): HTMLElement { const g = view.ground; const rows = CASES.map((c) => [ el("span", { className: "e02-rowhead", text: L(`E02_Case_${c}`) }), ...SLOPES.map((s) => numInput( view, `safety_factors.${c}.${s}`, g.safety_factors[c][s], (v) => (g.safety_factors[c][s] = v as number), { src: `safety_factors.${c}`, def: view.defaults.safety_factors[c][s], }, ), ), ]); const { section, head } = card( "E02_Card_Safety", table(["", ...SLOPES.map((s) => L(`E02_Slope_${s}`))], rows), ); headButtons( view, head, createButton({ label: t("E02_Btn_Defaults"), variant: "ghost", size: "sm", onClick: () => { g.safety_factors = clone(view.defaults.safety_factors); view.rebuild(); }, }), ); return section; } /* --- 4. 노면 하중 · 지진 (지하수 = `E02_StabGround_UI_Water`) --- */ function loadsCard(view: E02View): HTMLElement { const g = view.ground; const d = view.defaults; const field = (label: string, input: HTMLElement): HTMLElement => { const node = el("label", { className: "e02-field" }); node.append(el("span", { text: label }), input); return node; }; const kh = numInput(view, "seismic.kh", g.seismic.kh, (v) => (g.seismic.kh = v as number), { src: "seismic.kh", def: d.seismic.kh, }); kh.disabled = !view.editable || !g.seismic.enabled; const seismic = createCheckbox(t("E02_Seismic"), { checked: g.seismic.enabled }); seismic.input.dataset.path = "seismic.enabled"; seismic.input.dataset.src = "seismic.enabled"; seismic.input.dataset.def = d.seismic.enabled ? "on" : "off"; seismic.input.disabled = !view.editable; seismic.input.addEventListener("change", () => { g.seismic.enabled = seismic.input.checked; kh.disabled = !view.editable || !g.seismic.enabled; view.change(); }); const grid = el("div", { className: "e02-grid" }); grid.append( field( t("E02_Surcharge"), numInput( view, "road_surcharge_kpa", g.road_surcharge_kpa, (v) => (g.road_surcharge_kpa = v as number), { src: "road_surcharge_kpa", def: d.road_surcharge_kpa, }, ), ), seismic.root, field(t("E02_Kh"), kh), ); const { section, head } = card("E02_Card_Loads", grid); headButtons( view, head, createButton({ label: t("E02_Btn_Defaults"), variant: "ghost", size: "sm", onClick: () => { g.road_surcharge_kpa = d.road_surcharge_kpa; g.seismic = clone(d.seismic); view.rebuild(); }, }), ); return section; } /** 본문 전체 — 돌려준 refreshStations = 값만 바뀐 뒤 측점별 적용 표를 다시 그림 */ export function buildCards(view: E02View): { root: HTMLElement; refreshStations: () => void } { const root = el("div", { className: "e02-cards" }); const zones = zonesCard(view); zones.section.classList.add("e02-card--wide"); root.append( materialsCard(view), safetyCard(view), loadsCard(view), waterCard(view), zones.section, ); return { root, refreshStations: zones.refresh }; }