refactor(E06): 절리 표 · 측점 목록 · 모드 표 · 판정 램프 · 보기 고르기 · 범례 · 갱신 띠를 부품으로 (PLAN 553-4)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:34:36 +09:00
co-authored by Claude Sonnet 5.5
parent cb6f9e4d4c
commit bb6c67deab
7 changed files with 277 additions and 342 deletions
+56 -50
View File
@@ -6,8 +6,9 @@
* ========================================================================== */
import { createPanelCard } from "@ui/ui_template_collapsible";
import { createButton, el } from "@ui/ui_template_elements";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { e06Table, note } from "./E06_StabRock_UI_Parts";
export interface JointSet {
id: string;
@@ -55,7 +56,14 @@ export interface JointsView {
rebuild: () => void; // 줄 수가 바뀜
}
/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 is-invalid · 바꾸면 set 부르고 change */
/** 틀린 칸 표시 — 부품 오류 상태(`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,
@@ -64,17 +72,17 @@ function numberInput(
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;
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;
input.classList.toggle("is-invalid", !ok);
markInvalid(input, !ok);
input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`;
return ok;
};
@@ -107,64 +115,61 @@ const COLUMNS: LocaleKey[] = [
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;
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;
name.classList.toggle("is-invalid", !name.value.trim());
markInvalid(name, !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],
];
const cells: HTMLElement[] = [name];
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}` },
cells.push(
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);
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);
cells.push(remove);
return cells;
});
wrap.append(table);
if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") }));
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;
}
/** 좁은 패널 설정 줄 — 설정 줄 부품(줄 52 · 큰 글)은 이 폭에 안 맞아 옛 꼴 그대로 */
function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement {
const row = el("label", { className: "e06-setting" });
row.append(el("span", { text: t(label) }), control);
@@ -178,11 +183,12 @@ function select<T extends string>(
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 } }));
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", () => {
@@ -241,7 +247,7 @@ export function buildJointCards(view: JointsView): HTMLElement[] {
});
add.disabled = !view.editable;
setsCard.append(
el("p", { className: "e06-hint", text: t("E06_JointsHint") }),
note(t("E06_JointsHint")),
jointTable(view),
el("div", { className: "e06-row-actions", children: [add] }),
);
@@ -324,8 +330,8 @@ export function buildJointCards(view: JointsView): HTMLElement[] {
path: "settings.bolt.plunge",
}),
),
el("p", { className: "e06-hint", text: t("E06_SettingsHint") }),
el("p", { className: "e06-hint", text: t("E06_MinBlockHint") }),
note(t("E06_SettingsHint")),
note(t("E06_MinBlockHint")),
);
return [setsCard, setCard];
}
+20 -37
View File
@@ -10,6 +10,7 @@
import "./E06_StabRock_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
createButton,
@@ -19,7 +20,7 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { t } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { mountGroupStep } from "../A00_Common/group_workflow";
import {
@@ -38,6 +39,7 @@ import {
type StationEntry,
} from "./E06_StabRock_UI_Result";
import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet";
import { emptyBox, note, toggle } from "./E06_StabRock_UI_Parts";
interface RockResponse {
joints: JointsDoc;
@@ -76,33 +78,16 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
return body;
}
function toggle<T extends string>(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) {
const group = el("div", { className: "e06-toggle" });
const buttons = options.map(([key, v]) => {
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
button.dataset.value = v;
button.classList.toggle("is-on", v === value);
button.addEventListener("click", () => {
buttons.forEach((b) => b.classList.toggle("is-on", b === button));
onPick(v);
});
return button;
});
group.append(...buttons);
return group;
}
export function renderE06StabRock(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const panel = el("div", { className: "ui-sidebar-panel e06-panel" });
const main = el("div", { className: "e06-page" });
mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") }));
if (!projectId) return void main.append(emptyBox(t("E06_NoProject")));
// ---- 좌 ----
const cards = el("div", { className: "e06-cards" });
const status = el("p", { className: "e06-status" });
const status = el("div", { className: "e06-status" });
const saveButton = createButton({
label: t("Common_Btn_Save"),
variant: "ghost",
@@ -121,11 +106,12 @@ export function renderE06StabRock(root: HTMLElement): void {
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, saveButton, analyzeButton);
panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions);
panel.append(note(t("E06_Note")), cards, status, actions);
attachCollapsible(cards);
// ---- 가운데 · 우 ----
const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") });
const staleBar = createBanner({ tone: "warning", text: t("E06_Stale") });
staleBar.classList.add("e06-stale");
staleBar.hidden = true;
const bandSlot = el("div");
const title = el("strong", { className: "e06-title" });
@@ -140,10 +126,10 @@ export function renderE06StabRock(root: HTMLElement): void {
projectionToggle,
toggle<NetMode>(
[
["E06_View_All", "all"],
["E06_Mode_planar", "planar"],
["E06_Mode_wedge", "wedge"],
["E06_Mode_toppling", "toppling"],
[t("E06_View_All"), "all"],
[t("E06_Mode_planar"), "planar"],
[t("E06_Mode_wedge"), "wedge"],
[t("E06_Mode_toppling"), "toppling"],
],
netMode,
(v) => {
@@ -178,14 +164,14 @@ export function renderE06StabRock(root: HTMLElement): void {
let active: string | null = null;
let shown: StationResponse | null = null;
const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length;
const invalidCount = (): number => cards.querySelectorAll('[aria-invalid="true"]').length;
const paintStatus = (): void => {
const lines: string[] = [];
if (view && !view.editable) lines.push(t("E06_ReadOnly"));
if (dirty) lines.push(t("E06_Unsaved"));
const bad = invalidCount();
if (bad) lines.push(`${t("E06_Invalid")} ${bad}`);
status.textContent = lines.join("\n");
status.replaceChildren(...(lines.length ? [note(lines.join("\n"), "e06-status__text")] : []));
status.classList.toggle("is-error", bad > 0);
};
@@ -211,8 +197,8 @@ export function renderE06StabRock(root: HTMLElement): void {
projectionToggle.replaceChildren(
toggle<Projection>(
[
["E06_Proj_Angle", "equal_angle"],
["E06_Proj_Area", "equal_area"],
[t("E06_Proj_Angle"), "equal_angle"],
[t("E06_Proj_Area"), "equal_area"],
],
projection,
(v) => {
@@ -227,7 +213,7 @@ export function renderE06StabRock(root: HTMLElement): void {
legendSlot.replaceChildren(netLegend(netMode));
if (!shown) {
title.textContent = "";
netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") }));
netSlot.replaceChildren(emptyBox(t("E06_PickStation")));
return;
}
const e = shown.entry;
@@ -247,7 +233,7 @@ export function renderE06StabRock(root: HTMLElement): void {
listSlot.replaceChildren(
stations.length
? buildList(stations, active, (id) => void select(id))
: el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }),
: note(t(result ? "E06_NoRock" : "E06_NoResult")),
);
detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span"));
paintNet();
@@ -346,7 +332,7 @@ export function renderE06StabRock(root: HTMLElement): void {
async function analyze(): Promise<void> {
if (!view?.editable) return;
analyzeButton.disabled = true;
status.textContent = t("E06_Running");
status.replaceChildren(note(t("E06_Running"), "e06-status__text"));
try {
if (dirty && !(await putJoints())) return;
const body = await api<{ status: string; summary?: RockResult["summary"] }>(
@@ -387,10 +373,7 @@ export function renderE06StabRock(root: HTMLElement): void {
await loadResult();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e06-empty",
text: error instanceof Error ? error.message : t("Common_Status_Error"),
}),
emptyBox(error instanceof Error ? error.message : t("Common_Status_Error")),
);
}
})();
+80
View File
@@ -0,0 +1,80 @@
/* =============================================================================
* E06_StabRock_UI_Parts.ts
* E06 안 부품 걸이(553-4) — 부품(보조 글 · 빈 자리 · 표 · 배지 · 분할 고르기)을 부르고
* 촬영 장면 · 시험이 잡는 이름(`e06-hint` · `e06-empty` · `e06-table` · `e06-list` · `e06-cases` · `e06-verdict` · `e06-toggle`)만 얹음.
* 모양은 부품 · 이 파일에 CSS 없음.
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createEmpty } from "@ui/ui_template_empty";
import { createSegment } from "@ui/ui_template_segment";
import { createTable, type Cell } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
/** 보조 글 — 걸이 이름(`e06-hint` · `e06-kin` · `e06-status` …) */
export function note(text: string, hook = "e06-hint"): HTMLElement {
const node = createText(text, "muted");
node.classList.add(hook);
return node;
}
export function emptyBox(text: string): HTMLElement {
const node = createEmpty({ text, small: true });
node.classList.add("e06-empty");
return node;
}
export interface E06Table {
root: HTMLElement;
table: HTMLTableElement;
rows: HTMLTableRowElement[];
}
/** 표 부품(데이터표) — `className` = 표에 얹는 걸이 이름 · `role` = data-role */
export function e06Table(
headers: string[],
rows: Cell[][],
className: string,
role?: string,
): E06Table {
const root = createTable(
headers.map((label) => ({ label })),
rows,
{ density: "data" },
);
const table = root.querySelector("table") as HTMLTableElement;
table.classList.add(className);
if (role) table.dataset.role = role;
return { root, table, rows: [...table.tBodies[0].rows] };
}
const TONE: Record<string, BadgeTone> = {
ng: "danger",
check: "warning",
ok: "success",
none: "neutral",
};
/** 판정 램프 — verdict = NG · check · OK · none */
export function verdictLamp(verdict: string, text: string): HTMLElement {
const key = verdict.toLowerCase();
const lamp = createBadge(text, TONE[key] ?? "neutral", { kind: "lamp" });
lamp.classList.add("e06-verdict", `e06-verdict--${key}`);
return lamp;
}
/** 보기 고르기 — 단추 모양은 분할 고르기 부품 · `data-value` 는 그대로 */
export function toggle<T extends string>(
options: [string, T][],
value: T,
onPick: (v: T) => void,
): HTMLElement {
const group = createSegment({
options: options.map(([label, v]) => ({ value: v, label })),
value,
size: 24,
onChange: (v) => onPick(v as T),
});
group.root.classList.add("e06-toggle");
return group.root;
}
+59 -78
View File
@@ -6,6 +6,7 @@
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { e06Table, note, verdictLamp } from "./E06_StabRock_UI_Parts";
export type Verdict = "NG" | "check" | "OK" | "none";
@@ -66,10 +67,7 @@ const fmt = (v: number | null | undefined, d = 2): string =>
v === null || v === undefined ? "–" : v.toFixed(d);
function badge(verdict: Verdict): HTMLElement {
return el("span", {
className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`,
text: t(`E06_V_${verdict}` as LocaleKey),
});
return verdictLamp(verdict, t(`E06_V_${verdict}` as LocaleKey));
}
/** 위험 띠 — 측점마다 가장 나쁜 판정 · 이웃 측점 가운데서 끊음 */
@@ -119,7 +117,7 @@ export function buildBand(
el("span", { text: `${hi.toFixed(0)} m` }),
);
box.append(legend, strip, ticks);
} else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") }));
} else box.append(legend, note(t("E06_NoRock")));
return box;
}
@@ -128,34 +126,28 @@ export function buildList(
active: string | null,
onSelect: (id: string) => void,
): HTMLElement {
const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } });
const head = el("tr");
for (const key of [
"E06_L_Station",
"E06_L_Side",
"E06_L_Modes",
"E06_L_MinF",
"E06_L_Verdict",
] as LocaleKey[])
head.append(el("th", { text: t(key) }));
table.append(el("thead", { children: [head] }), el("tbody"));
for (const e of stations) {
const heads = ["E06_L_Station", "E06_L_Side", "E06_L_Modes", "E06_L_MinF", "E06_L_Verdict"];
const list = e06Table(
heads.map((key) => t(key as LocaleKey)),
stations.map((e) => [
e.label ?? e.key,
t(e.slope.side === "left" ? "E06_Left" : "E06_Right"),
`${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}`,
fmt(e.min_F),
badge(e.verdict),
]),
"e06-list",
"station-list",
);
list.rows.forEach((tr, i) => {
const e = stations[i];
const id = entryId(e);
const tr = el("tr", {
className: id === active ? "is-active" : "",
attrs: { "data-id": id, "data-verdict": e.verdict },
});
tr.append(
el("td", { text: e.label ?? e.key }),
el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }),
el("td", { text: `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}` }),
el("td", { text: fmt(e.min_F) }),
el("td", { children: [badge(e.verdict)] }),
);
tr.dataset.id = id;
tr.dataset.verdict = e.verdict;
tr.classList.toggle("is-active", id === active);
tr.addEventListener("click", () => onSelect(id));
table.tBodies[0].append(tr);
}
return table;
});
return list.root;
}
export function buildDetail(e: StationEntry): HTMLElement {
@@ -163,15 +155,14 @@ export function buildDetail(e: StationEntry): HTMLElement {
const s = e.slope;
box.append(
el("h4", { text: `${e.label ?? e.key} · ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }),
el("p", {
className: "e06-hint",
text: t("E06_SlopeLine")
note(
t("E06_SlopeLine")
.replace("{dd}", s.dip_direction.toFixed(0))
.replace("{dip}", s.dip.toFixed(1))
.replace("{h}", s.height_m.toFixed(2))
.replace("{up}", s.upper_dip.toFixed(1))
.replace("{g}", e.gamma_r.toFixed(1)),
}),
),
);
const kin = e.kinematic;
const kinText = [
@@ -179,57 +170,47 @@ export function buildDetail(e: StationEntry): HTMLElement {
`${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("·")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "–"}`,
`${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "–"}`,
];
box.append(el("p", { className: "e06-kin", text: kinText.join("\n") }));
box.append(note(kinText.join("\n"), "e06-kin"));
if (!e.cases.length) {
box.append(
el("p", {
className: "e06-hint",
text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"),
}),
);
box.append(note(t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere")));
return box;
}
const table = el("table", { className: "e06-cases" });
const head = el("tr");
for (const key of [
const heads = [
"E06_C_Mode",
"E06_C_Joints",
"E06_C_Cond",
"E06_C_F",
"E06_C_Req",
"E06_L_Verdict",
] as LocaleKey[])
head.append(el("th", { text: t(key) }));
table.append(el("thead", { children: [head] }), el("tbody"));
for (const c of e.cases) {
const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK";
const note = !c.formed
? t("E06_NotFormed")
: c.small
? t("E06_Small")
: c.persistence_short
? t("E06_Persist")
: "";
const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } });
tr.append(
el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }),
el("td", { text: c.ids.join("·") }),
el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }),
el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }),
el("td", { text: fmt(c.required) }),
el("td", { children: [c.small ? el("span", { text: "–" }) : badge(verdict)] }),
);
table.tBodies[0].append(tr);
}
box.append(table);
if (e.small_blocks)
box.append(
el("p", {
className: "e06-hint",
text: t("E06_SmallCount").replace("{n}", String(e.small_blocks)),
}),
);
if (kin.toppling.length)
box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") }));
];
const cases = e06Table(
heads.map((key) => t(key as LocaleKey)),
e.cases.map((c) => {
const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK";
const text = !c.formed
? t("E06_NotFormed")
: c.small
? t("E06_Small")
: c.persistence_short
? t("E06_Persist")
: "";
return [
t(`E06_Mode_${c.mode}` as LocaleKey),
c.ids.join("·"),
t(`E06_Cond_${c.condition}` as LocaleKey),
`${fmt(c.F, 3)}${text ? ` ${text}` : ""}`,
fmt(c.required),
c.small ? "–" : badge(verdict),
];
}),
"e06-cases",
);
cases.rows.forEach((tr, i) => {
tr.dataset.mode = e.cases[i].mode;
tr.dataset.cond = e.cases[i].condition;
});
box.append(cases.root);
if (e.small_blocks) box.append(note(t("E06_SmallCount").replace("{n}", String(e.small_blocks))));
if (kin.toppling.length) box.append(note(t("E06_ToppleNote")));
return box;
}
+21 -10
View File
@@ -6,7 +6,7 @@
* 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createLegend } from "@ui/ui_template_flow_legend";
import { t, type LocaleKey } from "@ui/ui_template_locale";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -169,8 +169,19 @@ export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVG
return root;
}
/** 범례 — 범례 부품(선 · 점선 · 칸 · 점) · 삼각(교선)만 걸이 모양 */
const LEGEND_MARK: Record<string, { color: string; kind: "chip" | "line" | "dash" | "dot" }> = {
slope: { color: "var(--color-text)", kind: "line" },
friction: { color: "var(--color-text-secondary)", kind: "dash" },
planar: { color: "color-mix(in srgb, var(--e06-planar) 35%, transparent)", kind: "chip" },
daylight: { color: "var(--e06-planar)", kind: "dash" },
wedge: { color: "color-mix(in srgb, var(--e06-wedge) 35%, transparent)", kind: "chip" },
line: { color: "var(--e06-wedge)", kind: "chip" },
toppling: { color: "color-mix(in srgb, var(--e06-toppling) 35%, transparent)", kind: "chip" },
pole: { color: "var(--e06-pole)", kind: "dot" },
};
export function netLegend(mode: NetMode): HTMLElement {
const box = el("div", { className: "e06-legend" });
const items: [string, LocaleKey][] = [
["slope", "E06_Lg_Slope"],
["friction", "E06_Lg_Friction"],
@@ -181,13 +192,13 @@ export function netLegend(mode: NetMode): HTMLElement {
items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]);
if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]);
if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]);
for (const [cls, key] of items) {
const row = el("span", { className: "e06-legend__row" });
row.append(
el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }),
el("span", { text: t(key) }),
);
box.append(row);
}
const box = createLegend(
items.map(([cls, key]) => ({ label: t(key), ...LEGEND_MARK[cls] })),
{ vertical: true },
);
box.classList.add("e06-legend");
box.querySelectorAll(".ui-legend__mark").forEach((mark, i) => {
if (items[i][0] === "line") mark.classList.add("e06-legend__mark--line");
});
return box;
}
+12 -167
View File
@@ -12,6 +12,7 @@
--e06-planar: #d03b3b;
--e06-wedge: #f1963b;
--e06-toppling: #9b6dde;
--e06-pole: #2f6fd6;
height: 100dvh;
box-sizing: border-box;
}
@@ -31,39 +32,21 @@
overflow: auto;
}
.e06-empty,
/* 보조 글 · 빈 자리 · 띠 알림 · 표 · 설정 줄 · 판정 램프는 부품 — 여기는 자리만 */
.e06-status__text,
.e06-hint,
.e06-status,
.e06-kin {
margin: 0;
white-space: pre-line;
color: var(--color-text-muted);
font-size: 12px;
}
.e06-empty {
padding: var(--spacing-16);
}
.e06-status.is-error {
color: var(--color-danger);
}
.e06-kin {
color: var(--color-text);
margin: var(--spacing-16);
}
.e06-panel .ui-sidebar-actions {
flex-wrap: wrap;
}
.e06-stale {
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
color: var(--color-text);
}
.e06-stale[hidden] {
display: none;
}
@@ -73,32 +56,8 @@
overflow-x: auto;
}
.e06-table {
border-collapse: collapse;
font-size: 12px;
}
.e06-table th {
padding: 2px 3px;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
white-space: nowrap;
}
.e06-table td {
padding: 1px 2px;
}
.e06-input {
width: 4.2em;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 12px;
box-sizing: border-box;
}
.e06-input--name {
@@ -109,11 +68,6 @@ select.e06-input {
width: auto;
}
.e06-input.is-invalid {
border-color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface));
}
.e06-row-actions {
display: flex;
justify-content: flex-end;
@@ -152,17 +106,6 @@ select.e06-input {
color: var(--color-text);
}
.e06-toggle {
display: inline-flex;
gap: var(--spacing-4);
}
.e06-toggle .ui-btn.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-primary-text);
}
.e06-stage {
display: flex;
gap: var(--spacing-12);
@@ -289,58 +232,9 @@ select.e06-input {
color: #0e7490;
}
.e06-legend {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--color-text);
}
.e06-legend__row {
display: inline-flex;
align-items: center;
gap: 6px;
}
.e06-legend__mark {
display: inline-block;
width: 16px;
height: 10px;
border-radius: 2px;
}
.e06-legend__mark--slope {
height: 3px;
background: var(--color-text);
}
.e06-legend__mark--friction {
height: 0;
border-top: 1px dashed var(--color-text-secondary);
}
.e06-legend__mark--daylight {
height: 0;
border-top: 1px dashed var(--e06-planar);
}
.e06-legend__mark--planar {
background: color-mix(in srgb, var(--e06-planar) 35%, transparent);
}
.e06-legend__mark--wedge {
background: color-mix(in srgb, var(--e06-wedge) 35%, transparent);
}
.e06-legend__mark--toppling {
background: color-mix(in srgb, var(--e06-toppling) 35%, transparent);
}
.e06-legend__mark--line {
width: 10px;
background: var(--e06-wedge);
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.e06-legend__mark--pole {
width: 10px;
border-radius: 50%;
background: #2f6fd6;
}
/* ---- 위험 띠 ---- */
.e06-band {
@@ -418,74 +312,25 @@ select.e06-input {
overflow: auto;
}
.e06-list,
.e06-cases {
width: 100%;
border-collapse: collapse;
font-size: 12px;
color: var(--color-text);
.e06-list-slot .ui-table-wrap {
overflow: visible;
border: 0;
border-radius: 0;
background: none;
}
.e06-list th,
.e06-cases th {
.e06-list-slot th {
position: sticky;
top: 0;
padding: 3px 4px;
background: var(--color-surface);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
text-align: left;
}
.e06-list td,
.e06-cases td {
padding: 3px 4px;
border-top: 1px solid var(--color-border);
z-index: 1;
background: var(--color-surface-raised);
}
.e06-list tbody tr {
cursor: pointer;
}
.e06-list tbody tr:hover {
background: var(--color-surface-raised);
}
.e06-list tr.is-active {
background: color-mix(in srgb, var(--color-primary) 16%, transparent);
}
.e06-detail h4 {
margin: 0 0 4px;
color: var(--color-text);
}
.e06-verdict {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.e06-verdict::before {
content: "";
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--e06-none);
}
.e06-verdict--ng::before {
background: var(--e06-ng);
border-radius: 2px;
transform: rotate(45deg);
}
.e06-verdict--ok::before {
background: var(--e06-ok);
}
.e06-verdict--check::before {
background: var(--e06-check);
border-radius: 0;
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
+29
View File
@@ -145,3 +145,32 @@ def test_e05_parts():
assert ".e05-stale[hidden]" in css # 띠 알림은 flex 라 hidden 을 덮음
for dead in (".e05-preview {", ".e05-preview__head", ".e05-status.is-error"):
assert dead not in css, dead
def test_e06_parts():
names = ["Joints", "Page", "Result", "Stereonet"]
ts = {n: _src(f"E06_StabRock/E06_StabRock_UI_{n}.ts") for n in names}
parts = _src("E06_StabRock/E06_StabRock_UI_Parts.ts")
css = _src("E06_StabRock/E06_StabRock_UI_Style.css")
for n, src in ts.items():
assert 'el("table"' not in src and 'el("input"' not in src, n
assert 'el("select"' not in src and 'el("p"' not in src, n
for part in ("createTable(", "createBadge(", "createSegment(", "createText(", "createEmpty("):
assert part in parts, part
assert "createLegend(" in ts["Stereonet"] and "createBanner(" in ts["Page"]
assert "createInputField(" in ts["Joints"] and "createSelectField(" in ts["Joints"]
assert (
'classList.add("e06-toggle")' in parts
) # 촬영 장면이 `.e06-toggle button[data-value]` 로 잡음
assert '"station-list"' in ts["Result"] # 촬영 장면 · 시험이 잡는 data-role
for dead in (
".e06-verdict",
".e06-list th",
".e06-cases",
".e06-table th",
".e06-legend__row",
".e06-toggle .ui-btn",
"is-invalid",
):
assert dead not in css, dead
assert "is-invalid" not in ts["Joints"] + ts["Page"]