refactor(D03·D04): 진행 막대 · 고르는 카드 · 입력 · 고르기 · 체크 · 띠 알림을 부품으로 (PLAN 553-4)

This commit is contained in:
2026-10-26 21:57:01 +09:00
parent 065ca8b983
commit f54eed25bc
7 changed files with 93 additions and 99 deletions
+13 -9
View File
@@ -7,6 +7,8 @@
* ========================================================================== */
import "../B05_Profile/B05_Profile_UI_Style.css";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createSelectField } from "@ui/ui_template_input";
import { openModal } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
@@ -51,17 +53,19 @@ function gridBounds(grid: DtmGrid): {
export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: number): void {
if (!years.length) return void showToast("3D 로 볼 연도 노선이 없습니다.", "warning");
let viewer: RouteViewer | null = createRouteViewer();
const pick = document.createElement("select");
pick.className = "d03-plan__3d-year";
for (const item of years)
pick.append(new Option(`${item.year}년`, String(item.year), false, item.year === firstYear));
const corridorBox = document.createElement("label");
corridorBox.className = "d03-plan__3d-corridor";
const corridorOn = Object.assign(document.createElement("input"), {
type: "checkbox",
const pick = createSelectField({
options: years.map((item) => ({ value: String(item.year), text: `${item.year}년` })),
value: String(firstYear),
size: 28,
ariaLabel: "연도",
}).select;
pick.classList.add("d03-plan__3d-year");
const corridor = createCheckbox(" 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)", {
checked: true,
});
corridorBox.append(corridorOn, " 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)");
const corridorBox = corridor.root;
corridorBox.classList.add("d03-plan__3d-corridor");
const corridorOn = corridor.input;
corridorOn.addEventListener("change", () => viewer?.setCorridorVisible(corridorOn.checked));
const top = document.createElement("div");
top.className = "d03-plan__3d-top";
@@ -8,6 +8,7 @@
import "../B05_Profile/B05_Profile_UI_Style_RouteEdit.css";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createText } from "@ui/ui_template_text";
import { stationLabel } from "../common_util/common_util_station";
import type { DtmGrid } from "../common_util/common_util_surface_sampler";
import { buildCrossSvg } from "../B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw";
@@ -63,7 +64,7 @@ export function createPlanCross(): PlanCross {
const clear = (text: string): void => {
head.textContent = "";
body.replaceChildren(Object.assign(document.createElement("p"), { textContent: text }));
body.replaceChildren(createText(text, "muted", 14));
delete root.dataset.chainage;
};
clear("평면에서 측점을 누르면 그 자리 지반선 · 예상 횡단이 보입니다.");
+35 -30
View File
@@ -15,7 +15,10 @@
import "./D03_PlanAnalysis_UI_Style.css";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { createCard } from "@ui/ui_template_card";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createProgressBar } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import {
createButton,
hideLoadingOverlay,
@@ -118,12 +121,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
main.className = "d03-plan";
main.append(map.root, cross.root);
const terrainText = document.createElement("p");
terrainText.className = "d03-plan__hint";
const bar = document.createElement("progress");
bar.className = "d03-plan__bar";
bar.max = 100;
bar.hidden = true;
const terrainText = createText("", "muted");
terrainText.classList.add("d03-plan__hint");
const bar = createProgressBar();
bar.root.hidden = true;
const more = createButton({
label: "범위 밖 — 더 받기",
variant: "filled",
@@ -145,7 +146,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
actions.className = "ui-sidebar-actions";
actions.append(analyzeButton, saveButton, viewButton, undoButton);
const terrainCard = createPanelCard("지형", false);
terrainCard.append(terrainText, bar, more);
terrainCard.append(terrainText, bar.root, more);
const routeCard = createPanelCard("연도별 노선", false);
routeCard.append(yearList);
const panel = document.createElement("div");
@@ -334,29 +335,33 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
for (const mode of PLAN_MODES) {
const route = result.routes[mode];
if (!route) continue;
const card = document.createElement("button");
card.type = "button";
card.className = "d03-plan__mode" + (row.mode === mode ? " is-picked" : "");
card.dataset.mode = mode;
const metrics = route.metrics;
const values = createText(
!metrics
? "노선 없음"
: metrics.max_grade_pct === undefined
? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈`
: `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`,
"muted",
);
const card = createCard({
title: MODE_LABEL[mode],
body: [values],
pad: 12,
selected: row.mode === mode,
onSelect: () => {
if (row.mode === mode) return;
remember();
choices = { ...choices, [result.year]: mode };
keepDraft();
refresh();
},
});
card.classList.add("d03-plan__mode");
card.classList.toggle("is-picked", row.mode === mode);
card.dataset.mode = mode;
card.disabled = !!route.error || !metrics;
card.title = route.error ?? "";
const name = document.createElement("strong");
name.textContent = MODE_LABEL[mode];
const values = document.createElement("span");
values.textContent = !metrics
? "노선 없음"
: metrics.max_grade_pct === undefined
? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈`
: `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`;
card.append(name, values);
card.addEventListener("click", () => {
if (row.mode === mode) return;
remember();
choices = { ...choices, [result.year]: mode };
keepDraft();
refresh();
});
box.append(card);
}
return box;
@@ -526,13 +531,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
/** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */
async function watch(kind: "terrain" | "analyze"): Promise<PlanJob | null> {
busy = kind;
bar.hidden = false;
bar.root.hidden = false;
refresh();
try {
for (;;) {
if (!root.isConnected) return null;
const job = (await fetchPlanProgress(projectId))[kind];
bar.value = job.percent;
bar.set(job.percent / 100);
terrainText.textContent = `${kind === "terrain" ? "지형 받기" : "분석"} ${job.percent}% · ${job.message ?? ""}`;
if (!job.running) {
if (job.error) showToast(job.error, "error");
@@ -546,7 +551,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
return null;
} finally {
busy = null;
bar.hidden = true;
bar.root.hidden = true;
refresh();
}
}
+2 -36
View File
@@ -63,17 +63,10 @@
.d03-plan__cross-body p {
margin: 12px 0;
color: var(--color-text-secondary);
}
.d03-plan__hint {
margin: 0 0 6px;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.d03-plan__bar {
width: 100%;
}
.d03-plan__years {
@@ -93,37 +86,10 @@
font-size: 0.85rem;
}
.d03-plan__mode {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.d03-plan__mode.is-picked {
border-color: var(--color-primary);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
.d03-plan__mode:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.d03-plan__mode span {
color: var(--color-text-secondary);
}
.d03-plan__3d-top {
display: flex;
gap: var(--spacing-8);
align-items: center;
justify-content: flex-end;
margin-bottom: 6px;
}
+16 -14
View File
@@ -14,6 +14,7 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_fro
import { kstText } from "../common_util/common_util_time_kst";
import {
createButton,
createInputField,
createTag,
el,
hideLoadingOverlay,
@@ -21,6 +22,8 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createBanner } from "@ui/ui_template_banner";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createPanelCard } from "@ui/ui_template_collapsible";
import { t } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
@@ -146,14 +149,11 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
const listId = `d04-companies-${year}`;
const options = el("datalist", { attrs: { id: listId } });
const found = new Map<string, number>();
const firm = el("input", {
className: "d04-confirm__input",
attrs: { list: listId, placeholder: t("D04_Company") },
});
const mail = el("input", {
className: "d04-confirm__input",
attrs: { type: "email", placeholder: t("D04_Email") },
});
const firm = createInputField({ placeholder: t("D04_Company"), size: 28 }).input;
firm.setAttribute("list", listId);
const mail = createInputField({ type: "email", placeholder: t("D04_Email"), size: 28 }).input;
firm.classList.add("d04-confirm__input");
mail.classList.add("d04-confirm__input");
firm.addEventListener("input", () => {
const query = firm.value.trim();
if (!query) return;
@@ -245,18 +245,20 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
className: "d04-confirm__table",
children: [el("thead", { children: [head] }), el("tbody", { children: rows })],
});
const notes = draft()
? [el("p", { className: "d04-confirm__dirty", text: t("D04_Dirty") })]
: [];
const notes = draft() ? [createBanner({ tone: "warning", text: t("D04_Dirty") })] : [];
notes[0]?.classList.add("d04-confirm__dirty");
main.replaceChildren(el("h2", { text: summary.name }), ...notes, table);
pickList.replaceChildren(
...summary.years
.filter((row) => row.saved)
.map((row) => {
const box = el("input", { attrs: { type: "checkbox", value: String(row.year) } });
box.checked = !row.confirmed_at;
return el("label", { className: "d04-confirm__pick", children: [box, ` ${row.year}`] });
const pick = createCheckbox(String(row.year), {
checked: !row.confirmed_at,
value: String(row.year),
});
pick.root.classList.add("d04-confirm__pick");
return pick.root;
}),
);
}
@@ -44,15 +44,6 @@
.d04-confirm__input {
min-width: 10rem;
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
}
.d04-confirm__dirty {
color: var(--color-warning, var(--color-text-secondary));
}
.d04-confirm__picks {
+25
View File
@@ -32,3 +32,28 @@ def test_d02_parts():
for dead in (".d02-info", "is-invalid", "font-size: var(--text-caption);\n}\n\n.d02-info"):
assert dead not in css
assert "data-axis" in ts or "dataset.axis" in ts
def test_d03_parts():
ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts")
three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts")
css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css")
assert 'createElement("progress")' not in ts and "createProgressBar()" in ts
assert "createCard({" in ts and "onSelect:" in ts
assert 'classList.toggle("is-picked"' in ts # 촬영 장면이 `.d03-plan__mode.is-picked` 로 잡음
assert "card.dataset.mode = mode" in ts
assert 'createElement("select")' not in three and "createSelectField" in three
assert 'createElement("input")' not in three and "createCheckbox" in three
for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
assert dead not in css, dead
def test_d04_parts():
ts = _src("D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts")
css = _src("D04_PlanConfirm/D04_PlanConfirm_UI_Style.css")
assert 'el("input"' not in ts
assert ts.count("createInputField(") == 2 and "createCheckbox(" in ts
assert 'createBanner({ tone: "warning"' in ts
assert "input:checked" in ts # [확정] 이 고른 연도를 읽는 길
assert "border:" not in css.split(".d04-confirm__input")[1].split("}")[0]
assert ".d04-confirm__dirty" not in css