Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts
T
eomsangdonandClaude Opus 5.5 659baa7751 feat(484 B07 설정): 좌측 ⚙ 설정 모달 · [현재 도면 확정] · [수정] 지움 · 저장 후 이동(481-3) · 탭 줄 겹판 자리
- 공용 createPanelCard 곁값 onSettings — 캐럿 왼쪽 ⚙ · 누름이 접힘 안 일으킴(B08 · B09 그대로)
- 평면도 ⚙ = 평면도 관리 모달([평면도 관리] 단추 지움) · 횡단도 ⚙ = 배치 칸 · 표 배율 · 안내선(카드 밑 칸 지움) · 구조물 ⚙ = 「M02 그림 — 축척 없음」 · 종단 1장 · 토적 = 단추 안 ⚙ · 표지 없음
- ⚙ 머리 [원본에서 다시 그리기] — 그 묶음 도면을 연 때만 · 저장본이면 invalidate
- 잠금 없음(요구 281) — 읽기 전용 안 묶음 · 자동 B08 이동 · 「모든 횡단 확정」 잠금 지움
- onStepClick 한 길 = 고친 탭(●) + 지금 탭 저장 → B08 · B09 면 단계 4 완료(새 POST design-drawings-complete) → 이동 · 실패 = 머묾
- 저장 · 원본 묶음 = 새 B07_DesignDetail_UI_Save.ts · 모달 = 새 B07_DesignDetail_UI_Settings.ts(UI_Page 633 → 543줄)
- 탭 줄 = .cad-host 의 --cad-tab-slot-left/right(px · CSS left · right 꼴) 있으면 리본 빈 자리에 겹침 · 없으면 지금 자리
- 시험: 새 test_280_5_b07_settings · 130 · 153-1 · 180-3 · 212-3 · 269 · 311 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:13:04 +09:00

417 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B07_DesignDetail_UI_Panels.ts
* 상세 설계 화면의 좌측 패널 — 도면 목록 12분류, 측점 설계 정보 카드.
*
* 화면 조립(`B07_DesignDetail_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04).
* DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다.
* ========================================================================== */
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
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 = document.createElement("div");
heading.className = "b07-drawing-list__heading";
const title = document.createElement("strong");
title.textContent = "설계 도면";
const count = document.createElement("span");
count.textContent = `${drawings.length}건`;
heading.append(title, count);
panel.append(heading);
// 확정을 건너뛴 상태라는 것을 **조용히 두지 않는다** — 그냥 열면 다음 사람이
// 「왜 값이 없나」로 헤맨다(2026-09-09). 되돌리기는 B08 좌측 [확정 없이 다음으로] 옆.
if (devBypass) {
const notice = document.createElement("p");
notice.className = "b07-drawing-list__bypass";
notice.textContent = "확정을 건너뛴 상태입니다 — 개발 전용. 값이 비어 보일 수 있습니다.";
panel.append(notice);
}
if (errorMessage || drawings.length === 0) {
const empty = document.createElement("p");
empty.className = "b07-drawing-list__empty";
empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다.";
panel.append(empty);
return panel;
}
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "b07-drawing-button ui-panel-btn";
button.dataset.drawingId = drawing.id;
button.dataset.confirmed = String(drawing.confirmed);
const name = document.createElement("span");
name.className = "b07-drawing-button__name";
name.textContent = label;
button.append(name);
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 = document.createElement("button");
placeholder.type = "button";
placeholder.className = "b07-drawing-button ui-panel-btn";
placeholder.disabled = true;
placeholder.title = "준비 중";
placeholder.dataset.pending = "true";
const name = document.createElement("span");
name.className = "b07-drawing-button__name";
name.textContent = group.label;
placeholder.append(name);
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장 (구간)" 라벨을 그대로 쓴다.
section.append(drawingButton(drawing, drawing.label));
}
if (structure && items.length === 0) {
const empty = document.createElement("p");
empty.className = "b07-drawing-list__empty";
empty.textContent = "B05 · B06 구조물 배치에 연결된 구조물 도면이 없습니다.";
section.append(empty);
}
panel.append(section);
}
attachCollapsible(panel);
return panel;
}
/** 한 장 단추 안 오른쪽 ⚙(280-5) — 단추 안 단추 금지라 span · 누름 · Enter 가 도면 열기로 안 올라감. */
function buttonGear(onSettings: () => void): HTMLElement {
const gear = document.createElement("span");
gear.className = "b07-drawing-button__settings";
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");
}
/** 표 · 글 배율 범위(180-2) — 서버 `CROSS_SCALE_RANGE` 와 같음. */
const CROSS_SCALE_MIN = 0.5;
const CROSS_SCALE_MAX = 2;
/** 배치 칸 줄 뒤 덧칸 — 표 배율(180-2). */
export interface CrossLayoutExtras {
scale?: { value: number; onApply: (scale: number) => void };
}
/**
* 횡단 장 배치 칸(140-3 · 153-1) — 줄마다 한 일(212-3):
* 「배치 열 [4] 행 [4]」 · 「[이 장] [모든 장]」 · 「표 배율 [1] [배율 적용]」 · (부르는 쪽이 붙이는 [안내선 숨기기]).
* 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*`) · `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔.
*/
export function buildCrossLayoutControls(
grid: [number, number],
onApply: ((grid: [number, number]) => void) | null,
onAllDefault: (grid: [number, number]) => void,
extras: CrossLayoutExtras = {},
): HTMLElement {
const box = document.createElement("div");
box.className = "b07-cross-layout";
const row = (...items: HTMLElement[]): HTMLElement => {
const line = document.createElement("div");
line.className = "b07-cross-layout__row";
line.append(...items);
return line;
};
const number = (value: number, label: string) =>
createInputField({
label,
type: "number",
value: String(value),
min: 1,
max: 10,
compact: true,
});
const cols = number(grid[0], "배치 열");
const rows = number(grid[1], "행");
const read = (): [number, number] | null => {
const value: [number, number] = [Number(cols.input.value), Number(rows.input.value)];
return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null;
};
const action = (label: string, title: string, run: ((grid: [number, number]) => void) | null) => {
const button = createButton({
label,
variant: "ghost",
size: "sm",
disabled: !run,
onClick: () => {
const value = read();
if (value) run?.(value);
else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error");
},
});
button.title = title;
return button;
};
box.append(
row(cols.root, rows.root),
row(
action("이 장", "이 장만 이 배치로", onApply),
action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
),
);
if (extras.scale) {
const { onApply: applyScale } = extras.scale;
const scale = createInputField({
label: "표 배율",
type: "number",
value: String(extras.scale.value),
min: CROSS_SCALE_MIN,
max: CROSS_SCALE_MAX,
compact: true,
});
scale.input.setAttribute("step", "0.1");
const button = createButton({
label: "배율 적용",
variant: "ghost",
size: "sm",
onClick: () => {
const value = Number(scale.input.value);
if (value >= CROSS_SCALE_MIN && value <= CROSS_SCALE_MAX) applyScale(value);
else
showToast(`표 배율은 ${CROSS_SCALE_MIN}~${CROSS_SCALE_MAX} 사이로 적어 주세요.`, "error");
},
});
button.title = "모든 장의 표 · 표 위 글 크기(기본 1)";
box.append(row(scale.root, button));
}
return box;
}
/** 측구 규격 표시 문자열 (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`;
}
function infoRow(label: string, value: string): HTMLElement {
const row = document.createElement("div");
row.className = "b07-info__row";
const key = document.createElement("span");
key.className = "b07-info__key";
key.textContent = label;
const val = document.createElement("span");
val.className = "b07-info__val";
val.textContent = value;
row.append(key, val);
return row;
}
/**
* 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (B06 정본 값 · B07 확정은 상태만 올림).
*
* **장(여러 측점을 담은 도면)에는 측점 단위 값이 없다** — 서버가 `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 heading = document.createElement("div");
heading.className = "b07-info__heading";
const stationName = document.createElement("strong");
const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station");
stationName.textContent = `${scopeLabel} ${title}`;
const confirmed = design?.status === "confirmed";
const badge = document.createElement("span");
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional");
heading.append(stationName, badge);
panel.append(heading);
if (!design) {
const empty = document.createElement("p");
empty.className = "b07-info__empty";
empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign");
panel.append(empty);
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,
infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])),
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
infoRow(
L("B07_Info_DitchSide"),
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,
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
infoRow(L("B07_Info_Ditch"), ditchLabel(design)),
infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`),
infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`),
);
panel.append(ground, plan);
return panel;
}