- ground.json groundwater 칸 덧붙임(wet_mode · depth_ratio · ru · lines · 기존 칸 그대로) · 기본값 = KDS · 범위 칸 넷
- check_ground ↔ checkGround 짝 고침(갈래 · 비율 · r_u · 수위선 구간 · 점) · 거울 시험 판 넷 더함
- groundwater_for · station_groundwater — 측점 모형 → {mode: none | ru | line}(좌 · 우 비탈 높이 따로 · 덮지 않은 측점 = KDS)
- GET · [초기화] = 옛 정본 빠진 칸 기본값으로 채움 · 화면 옛 캐시도 서버 값으로 채움
- 화면 = 지하수 카드 따로(「KDS 기본 · 우기 기준안전율 1.2 와 짝」 안내) · 하중 카드 = 노면 하중 · 지진
- 시험 test_532_4_stab_walls_water(벽 · 지하수) · test_532_3 기본값 지하수 칸 대조 고침(칸 늘어남)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* 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 = <T>(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 };
|
|
}
|