- 도면 단추(날 ui-panel-btn) · 장 번호 · 도구 단추 = 단추 부품 · ⚙ = 아이콘 단추 꼴 · 평면도 줄 = 목록 줄 꼴 - 머리 = 패널 머리 + 배지 · 확정 건너뜀 = 띠 알림 · 빈 글 = 빈 상태 · 측점 정보 = 값 목록 · ⚙ 모달 그룹 상자 = 카드 · 종류 이름 칸 = 입력 - window.confirm 둘 → 확인창 부품 · 죽은 CSS 걷음 · 시험 헬퍼 부품 가짜 한 벌(helper_b07_part_stubs) · 153-1 옛 오류(removeAttribute) 고침 · 가이드 선택자 · 지킴 시험 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
320 lines
14 KiB
TypeScript
320 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* B07_DesignDetail_UI_Panels.ts
|
||
* 상세 설계 화면의 좌측 패널 — 도면 목록 12분류, 측점 설계 정보 카드.
|
||
*
|
||
* 화면 조립(`B07_DesignDetail_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04).
|
||
* DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다.
|
||
* ========================================================================== */
|
||
|
||
import { createBadge } from "@ui/ui_template_badge";
|
||
import { createBanner } from "@ui/ui_template_banner";
|
||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||
import { createButton } from "@ui/ui_template_elements";
|
||
import { createEmpty } from "@ui/ui_template_empty";
|
||
import { createKv } from "@ui/ui_template_kv";
|
||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||
import { createPanelHead } from "@ui/ui_template_panel_head";
|
||
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
|
||
import { buildDevelopmentSection } from "./B07_DesignDetail_UI_Development";
|
||
import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds";
|
||
|
||
function L(key: keyof typeof ui_locales): string {
|
||
return ui_locales[key][currentLanguageIndex];
|
||
}
|
||
|
||
/**
|
||
* 도면 구성 12분류 (2026-08-29 사용자 확정 순서).
|
||
*
|
||
* 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다
|
||
* (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다.
|
||
*/
|
||
export const DRAWING_GROUPS: readonly {
|
||
label: string;
|
||
kind?: DesignDrawingItem["kind"];
|
||
blankId?: string;
|
||
/** 축척 고정으로 장이 나뉘는 도면 — `plan_route`, `plan_route_2` … 를 한 묶음으로 본다. */
|
||
idPrefix?: string;
|
||
}[] = [
|
||
{ label: "표지", kind: "cover" },
|
||
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도 · 사용자 추가)를 한 묶음으로.
|
||
{ label: "평면도", kind: "plan" },
|
||
{ label: "종단도", kind: "longitudinal" },
|
||
// 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5).
|
||
{ label: "횡단도", kind: "cross" },
|
||
{ label: "토적도(유토곡선)", kind: "mass_haul" },
|
||
// 구조물 도면(376) — B05 · B06 배치에 연결된 구조물 조합마다 한 장(더한장은 장마다) · 늘 묶음.
|
||
{ label: "구조물 도면", kind: "structure" },
|
||
// 전개도(요구 241 · 377) — 늘 카드 · 구조물 종류마다 하위 묶음
|
||
{ label: "전개도", kind: "development" },
|
||
];
|
||
|
||
/** 311 지금 목록의 묶음 접힘(kind → 접힘) — 화면을 다시 세울 때 이어 받음 · B07 목록이 없으면 빈 것. */
|
||
export function foldedGroups(root: ParentNode): Map<string, boolean> {
|
||
return new Map(
|
||
[...root.querySelectorAll<HTMLElement>(".b07-drawing-group")].map((section) => [
|
||
section.dataset.kind ?? "",
|
||
section.classList.contains("is-collapsed"),
|
||
]),
|
||
);
|
||
}
|
||
|
||
/** B06 확정 산출물 기반 도면 목록 패널. */
|
||
export function buildDrawingSidePanel(
|
||
drawings: DesignDrawingItem[],
|
||
onSelect: (drawing: DesignDrawingItem) => void,
|
||
errorMessage?: string,
|
||
devBypass = false,
|
||
/** 묶음 ⚙(280-5) — 카드 제목 · 한 장 단추 안 오른쪽 · 표지 · 빈 도각은 없음. */
|
||
onSettings?: (kind: DesignDrawingItem["kind"]) => void,
|
||
/** 311 다시 세울 때 옛 접힘(`foldedGroups`) — 없는 묶음은 기본값. */
|
||
folded = new Map<string, boolean>(),
|
||
): HTMLDivElement {
|
||
const isFolded = (kind: string): boolean => folded.get(kind) ?? true; // 389-3 모든 묶음 기본 닫힘
|
||
const panel = document.createElement("div");
|
||
panel.className = "b07-drawing-list";
|
||
const heading = createPanelHead({
|
||
title: "설계 도면",
|
||
actions: [createBadge(`${drawings.length}건`, "neutral")],
|
||
divider: true,
|
||
}).root;
|
||
heading.classList.add("b07-drawing-list__heading");
|
||
panel.append(heading);
|
||
// 확정을 건너뛴 상태라는 것을 **조용히 두지 않는다** — 그냥 열면 다음 사람이
|
||
// 「왜 값이 없나」로 헤맨다(2026-09-09). 되돌리기는 B08 좌측 [확정 없이 다음으로] 옆.
|
||
if (devBypass) {
|
||
panel.append(
|
||
createBanner({
|
||
tone: "warning",
|
||
text: "확정을 건너뛴 상태입니다 — 개발 전용. 값이 비어 보일 수 있습니다.",
|
||
}),
|
||
);
|
||
}
|
||
|
||
if (errorMessage || drawings.length === 0) {
|
||
const empty = createEmpty({
|
||
text: errorMessage ?? "확정된 종·횡단 도면이 없습니다.",
|
||
small: true,
|
||
});
|
||
empty.classList.add("b07-drawing-list__empty");
|
||
panel.append(empty);
|
||
return panel;
|
||
}
|
||
|
||
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
|
||
const button = listButton(label);
|
||
button.dataset.drawingId = drawing.id;
|
||
button.dataset.confirmed = String(drawing.confirmed);
|
||
button.addEventListener("click", () => onSelect(drawing));
|
||
return button;
|
||
};
|
||
const settingsOf = (kind: DesignDrawingItem["kind"]) =>
|
||
onSettings && kind !== "cover" && kind !== "blank" ? () => onSettings(kind) : undefined;
|
||
|
||
for (const group of DRAWING_GROUPS) {
|
||
if (group.kind === "plan") {
|
||
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
|
||
const plans = drawings.filter((item) => item.kind === "plan");
|
||
panel.append(buildPlanSection(plans, drawingButton, settingsOf("plan"), isFolded("plan")));
|
||
continue;
|
||
}
|
||
if (group.kind === "development") {
|
||
const sheets = drawings.filter((item) => item.kind === "development");
|
||
panel.append(
|
||
buildDevelopmentSection(
|
||
sheets,
|
||
drawingButton,
|
||
isFolded("development"),
|
||
settingsOf("development"),
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
const items = group.kind
|
||
? drawings.filter((item) => item.kind === group.kind)
|
||
: group.idPrefix
|
||
? drawings.filter(
|
||
(item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`),
|
||
)
|
||
: drawings.filter((item) => item.id === group.blankId);
|
||
const cross = group.kind === "cross";
|
||
const structure = group.kind === "structure";
|
||
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다 — 횡단도 · 구조물 도면은 늘 묶음.
|
||
if (items.length <= 1 && !cross && !structure) {
|
||
const [drawing] = items;
|
||
// 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다.
|
||
if (!drawing) {
|
||
const placeholder = listButton(group.label);
|
||
placeholder.disabled = true;
|
||
placeholder.title = "준비 중";
|
||
placeholder.dataset.pending = "true";
|
||
panel.append(placeholder);
|
||
continue;
|
||
}
|
||
const button = drawingButton(drawing, group.label);
|
||
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
|
||
button.dataset.pending = String(drawing.kind === "blank");
|
||
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
|
||
const settings = settingsOf(drawing.kind);
|
||
if (settings) button.append(buttonGear(settings));
|
||
panel.append(button);
|
||
continue;
|
||
}
|
||
// 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식).
|
||
const section = createPanelCard(
|
||
`${group.label} ${items.length}`,
|
||
isFolded(group.kind ?? ""),
|
||
group.kind && settingsOf(group.kind),
|
||
);
|
||
section.classList.add("b07-drawing-group");
|
||
if (group.kind) section.dataset.kind = group.kind;
|
||
if (cross) {
|
||
// 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만.
|
||
for (const standard of drawings.filter((item) => item.kind === "cross_standard"))
|
||
section.append(drawingButton(standard, standard.label));
|
||
}
|
||
for (const drawing of items) {
|
||
// 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다.
|
||
const button = drawingButton(drawing, drawing.label);
|
||
// 이름 뒤 「 (MC)」(507-3 덧) — 한 칸에 안 들어가 두 열을 가로지름
|
||
if (drawing.label.includes("(MC)")) button.dataset.mc = "true";
|
||
section.append(button);
|
||
}
|
||
if (structure && items.length === 0) {
|
||
const empty = createEmpty({
|
||
text: "B05 · B06 구조물 배치에 연결된 구조물 도면이 없습니다.",
|
||
small: true,
|
||
});
|
||
empty.classList.add("b07-drawing-list__empty");
|
||
section.append(empty);
|
||
}
|
||
panel.append(section);
|
||
}
|
||
attachCollapsible(panel);
|
||
return panel;
|
||
}
|
||
|
||
/** 도면 단추 한 개 — 단추 부품(고스트 32) · 이름 칸 이름만 B07 자리 이름을 더함. */
|
||
function listButton(label: string): HTMLButtonElement {
|
||
const button = createButton({ label, variant: "ghost", size: 32 });
|
||
button.classList.add("b07-drawing-button");
|
||
button.children[button.children.length - 1]?.classList.add("b07-drawing-button__name");
|
||
return button;
|
||
}
|
||
|
||
/** 한 장 단추 안 오른쪽 ⚙(280-5) — 단추 안 단추 금지라 span · 누름 · Enter 가 도면 열기로 안 올라감. */
|
||
function buttonGear(onSettings: () => void): HTMLElement {
|
||
const gear = document.createElement("span");
|
||
// 아이콘 단추 부품 꼴을 입힘 — 단추 안 단추는 못 둬서 span 으로 둠
|
||
gear.className = "b07-drawing-button__settings ui-icon-btn ui-icon-btn--ghost";
|
||
gear.dataset.size = "20";
|
||
gear.setAttribute("role", "button");
|
||
gear.tabIndex = 0;
|
||
gear.textContent = "⚙";
|
||
gear.title = "설정";
|
||
gear.setAttribute("aria-label", "설정");
|
||
gear.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
onSettings();
|
||
});
|
||
gear.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
onSettings();
|
||
});
|
||
return gear;
|
||
}
|
||
|
||
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
|
||
soil: "B06_Design_Ground_Soil",
|
||
ripping_rock: "B06_Design_Ground_Ripping",
|
||
blasting_rock: "B06_Design_Ground_Blasting",
|
||
};
|
||
|
||
/** 단면유형에서 절토측 표기를 유도한다. */
|
||
function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string {
|
||
if (mode === "left_cut") return L("B06_Design_Ditch_Left");
|
||
if (mode === "right_cut") return L("B06_Design_Ditch_Right");
|
||
if (mode === "both_cut") return L("B06_Design_Mode_BothCut");
|
||
return L("B06_Design_Mode_BothFill");
|
||
}
|
||
|
||
/** 장(여러 측점을 담은 횡단 도면)인가 — id 가 `cross_s…` 면 장이다. */
|
||
export function isCrossSheet(drawing: DesignDrawingItem): boolean {
|
||
return drawing.kind === "cross" && drawing.id.startsWith("cross_s");
|
||
}
|
||
|
||
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
|
||
function ditchLabel(design: CrossDesignInfo): string {
|
||
const ditch = design.ditch;
|
||
if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음";
|
||
if (ditch.type === "l_type")
|
||
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||
}
|
||
|
||
/**
|
||
* 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (B06 정본 값 · 확정 배지 없음 · 요구 343).
|
||
*
|
||
* **장(여러 측점을 담은 도면)에는 측점 단위 값이 없다** — 서버가 `design` 을 넘기지
|
||
* 않는데도 제목만 「측점 …」으로 달려 어느 측점 값인지 오해됐다(2026-09-03 정리).
|
||
* 장이면 제목을 「장」으로 바꾸고 어디서 봐야 하는지 한 줄로 알린다.
|
||
*/
|
||
export function buildDesignInfoPanel(
|
||
title: string,
|
||
design: CrossDesignInfo | null,
|
||
scope: "station" | "sheet" = "station",
|
||
): HTMLElement {
|
||
const panel = document.createElement("div");
|
||
panel.className = "b07-info";
|
||
const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station");
|
||
panel.append(createPanelHead({ title: `${scopeLabel} ${title}`, level: 4 }).root);
|
||
|
||
if (!design) {
|
||
panel.append(
|
||
createEmpty({
|
||
text: scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"),
|
||
small: true,
|
||
}),
|
||
);
|
||
return panel;
|
||
}
|
||
|
||
const ground = document.createElement("section");
|
||
ground.className = "b07-info__block";
|
||
const groundTitle = document.createElement("h4");
|
||
groundTitle.textContent = L("B07_Info_Ground_Title");
|
||
ground.append(
|
||
groundTitle,
|
||
createKv([
|
||
{ label: L("B07_Info_GroundType"), value: L(GROUND_TYPE_LABEL[design.ground_type]) },
|
||
{ label: L("B07_Info_CutSide"), value: cutSideLabel(design.section_mode) },
|
||
{
|
||
label: L("B07_Info_DitchSide"),
|
||
value:
|
||
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
|
||
},
|
||
]),
|
||
);
|
||
|
||
const plan = document.createElement("section");
|
||
plan.className = "b07-info__block";
|
||
const planTitle = document.createElement("h4");
|
||
planTitle.textContent = L("B07_Info_Plan_Title");
|
||
plan.append(
|
||
planTitle,
|
||
createKv([
|
||
{ label: L("B07_Info_DesignElevation"), value: `${design.design_elevation_m.toFixed(2)}m` },
|
||
{ label: L("B07_Info_CutSlope"), value: `1:${design.cut_slope_ratio}` },
|
||
{ label: L("B07_Info_FillSlope"), value: `1:${design.fill_slope_ratio}` },
|
||
{ label: L("B07_Info_RoadWidth"), value: `${design.roadbed_width_m.toFixed(2)}m` },
|
||
{ label: L("B07_Info_Ditch"), value: ditchLabel(design) },
|
||
{ label: L("B07_Info_CutArea"), value: `${design.cut_area_m2.toFixed(2)}㎡` },
|
||
{ label: L("B07_Info_FillArea"), value: `${design.fill_area_m2.toFixed(2)}㎡` },
|
||
]),
|
||
);
|
||
|
||
panel.append(ground, plan);
|
||
return panel;
|
||
}
|