Files
Aislo/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts
T
eomsangdonandClaude Fable 5 673487ceaa feat(ui): 3D 측점 라벨 라운드 + 사이드 컨테이너 외곽선 강화·B07 적용
2026-08-06 사용자 지시 2건:
- 3D 측점 라벨 배경을 fillRect → roundRect(반경 10)로 — 둥근 판
- 사이드 컨테이너 외곽선: 어제 45% 혼합이 아직 흐리다는 재보고로 보조색
  비중 75%로 상향. 공통 클래스(ui-sidebar-section)가 테두리 두께·형태까지
  소유하도록 강화 — 자체 테두리 없는 컨테이너도 클래스만 붙이면 동일 외곽선.
  테마 변수 혼합이라 라이트/다크 자동 대응
- B04~B06만 적용돼 있던 것을 B07 설계 도면 그룹에도 적용(+라운드·패딩)

실측: 3D 라벨 라운드, B05·B07 외곽선 라이트/다크 모두 선명, 콘솔 에러 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 18:22:56 +09:00

557 lines
21 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_wf4_DesignDetail_UI_Page.ts
* 로그인 후 07: 4차 워크플로우 (상세 설계) — 독립형 2D CAD 임베드
*
* B07_wf4_DesignDetail/openwebcad를 프로젝트 소유 B07 CAD 앱으로 빌드하여
* /b07-cad 경로로 서빙한다. 업무 도면은 추후 same-origin postMessage로
* JSON만 전달하며 DXF/DWG 파싱은 이 브라우저 앱에서 수행하지 않는다.
*
* 레이아웃 (사용자 지시): 사이드 패널 빈 상태 유지 + 상세 영역 CAD 화면.
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용.
* ========================================================================== */
import "./B07_wf4_DesignDetail_UI_Style.css";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
confirmDesignDrawing,
fetchDesignDrawing,
fetchDesignDrawingList,
invalidateDesignDrawing,
type CadDrawing,
type CrossDesignInfo,
type DesignDrawingItem,
type DesignDrawingResponse,
type QuantityTable,
} from "./B07_wf4_DesignDetail_Api_Fetch";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta {
kind: "cross" | "longitudinal";
title: string;
info: string;
confirmed: boolean;
quantityTable: QuantityTable | null;
hasPrev: boolean;
hasNext: boolean;
}
/** CAD 저장 응답 (도면 + 편집된 수량표). */
interface SaveResult {
drawing: CadDrawing;
quantityTable: QuantityTable | null;
}
/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
const B07_CAD_APP_URL = "/b07-cad/index.html";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const CAD_LOAD_MESSAGE = "aislo:b07:load-drawing";
const CAD_READY_MESSAGE = "aislo:b07:drawing-ready";
const CAD_LOADED_MESSAGE = "aislo:b07:drawing-loaded";
const CAD_ERROR_MESSAGE = "aislo:b07:drawing-error";
const CAD_CHANGED_MESSAGE = "aislo:b07:drawing-changed";
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b07:save-request";
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b07:save-response";
const CAD_NAVIGATE_MESSAGE = "aislo:b07:navigate";
/** 측점 간격을 연속 chainage 차이의 최빈값으로 추정한다 (B06 그래프와 동일 방식). */
function inferStationInterval(chainages: number[]): number {
const counts = new Map<number, number>();
const sorted = [...chainages].sort((a, b) => a - b);
for (let index = 1; index < sorted.length; index += 1) {
const difference = sorted[index] - sorted[index - 1];
if (difference <= 0) continue;
const rounded = Math.round(difference * 10) / 10;
counts.set(rounded, (counts.get(rounded) ?? 0) + 1);
}
return (
[...counts.entries()].sort(
([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA,
)[0]?.[0] ?? 1
);
}
/** 측점 번호+나머지 표기 (B06 그래프 영역 횡단도 라벨과 동일 형식, 예: "2+0.0"). */
function stationLabel(chainage: number, interval: number): string {
const safeInterval = interval > 0 ? interval : 1;
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
let remainder = chainage - stationNumber * safeInterval;
if (Math.abs(remainder) < 0.05) remainder = 0;
if (remainder >= safeInterval - 0.05) {
stationNumber += 1;
remainder = 0;
}
return `${stationNumber}+${remainder.toFixed(1)}`;
}
/** B06 확정 산출물 기반 도면 목록 패널. */
function buildDrawingSidePanel(
drawings: DesignDrawingItem[],
stationInterval: number,
onSelect: (drawing: DesignDrawingItem) => void,
errorMessage?: string,
): HTMLDivElement {
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);
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 groups: [string, DesignDrawingItem["kind"], DesignDrawingItem[]][] = [
["종단도", "longitudinal", drawings.filter((item) => item.kind === "longitudinal")],
["횡단도", "cross", drawings.filter((item) => item.kind === "cross")],
];
for (const [label, kind, items] of groups) {
if (!items.length) continue;
const section = document.createElement("section");
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시).
section.className = "b07-drawing-group ui-collapsible ui-sidebar-section";
section.dataset.kind = kind;
const sectionTitle = document.createElement("h3");
sectionTitle.className = "ui-collapsible__title";
sectionTitle.textContent = `${label} ${items.length}`;
section.append(sectionTitle);
for (const drawing of items) {
const button = document.createElement("button");
button.type = "button";
button.className = "b07-drawing-button";
button.dataset.drawingId = drawing.id;
button.dataset.confirmed = String(drawing.confirmed);
const name = document.createElement("span");
name.className = "b07-drawing-button__name";
name.textContent =
drawing.kind === "cross" && typeof drawing.chainage_m === "number"
? stationLabel(drawing.chainage_m, stationInterval)
: drawing.label;
button.append(name);
button.addEventListener("click", () => onSelect(drawing));
section.append(button);
}
panel.append(section);
}
attachCollapsible(panel);
return panel;
}
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");
}
/** 측구 규격 표시 문자열 (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분할로 렌더한다 (잠정치, B07 확정 시 재계산). */
function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): 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");
stationName.textContent = `${L("B07_Info_Station")} ${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 = 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;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
let workflowState: WorkflowState | undefined;
let drawings: DesignDrawingItem[] = [];
let drawingError: string | undefined;
if (projectId) {
const [workflowResult, drawingResult] = await Promise.allSettled([
fetchWorkflowState(projectId),
fetchDesignDrawingList(projectId),
]);
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings;
else
drawingError =
drawingResult.reason instanceof Error
? drawingResult.reason.message
: "도면 목록을 불러오지 못했습니다.";
}
const cadHost = document.createElement("div");
cadHost.className = "b07-cad-host";
const frame = document.createElement("iframe");
frame.className = "b07-cad-frame";
frame.src = B07_CAD_APP_URL;
frame.title = L("B07_Design_Title");
const license = document.createElement("a");
license.className = "b07-cad-license";
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
license.target = "_blank";
license.rel = "noreferrer";
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
cadHost.append(frame, license);
const crossChainages = drawings
.filter((item) => item.kind === "cross" && typeof item.chainage_m === "number")
.map((item) => item.chainage_m as number);
const stationInterval = inferStationInterval(crossChainages);
let cadReady = false;
let pendingLoad: { drawing: CadDrawing; meta: DesignMeta } | undefined;
let currentDrawing: DesignDrawingItem | undefined;
let currentIndex = -1;
let currentConfirmed = false;
// 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관).
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
let allDrawingsConfirmed =
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
let resolveSave: ((payload: SaveResult) => void) | undefined;
let drawingListEl: HTMLElement | undefined;
const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host";
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
if (drawing.kind !== "cross") {
infoPanelHost.replaceChildren();
return;
}
const title =
typeof drawing.chainage_m === "number"
? stationLabel(drawing.chainage_m, stationInterval)
: drawing.label;
infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null));
};
const confirmButton = createButton({
label: "현재 도면 확정",
variant: "filled",
onClick: () => void confirmCurrentDrawing(),
});
confirmButton.disabled = true;
const findButton = (drawingId: string) =>
drawingListEl?.querySelector<HTMLButtonElement>(
`.b07-drawing-button[data-drawing-id="${drawingId}"]`,
) ?? undefined;
const highlightActive = (drawingId: string) => {
drawingListEl?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button").forEach((item) => {
item.dataset.active = String(item.dataset.drawingId === drawingId);
});
};
const buildMeta = (
drawing: DesignDrawingItem,
response: DesignDrawingResponse,
index: number,
): DesignMeta => ({
kind: drawing.kind,
title:
drawing.kind === "cross" && typeof drawing.chainage_m === "number"
? stationLabel(drawing.chainage_m, stationInterval)
: drawing.label,
info: drawing.kind === "cross" ? drawing.label : "",
confirmed: response.confirmed,
quantityTable: response.quantity_table ?? null,
hasPrev: index > 0,
hasNext: index < drawings.length - 1,
});
const sendLoad = (drawing: CadDrawing, meta: DesignMeta) => {
pendingLoad = { drawing, meta };
if (!cadReady) return;
frame.contentWindow?.postMessage(
{ type: CAD_LOAD_MESSAGE, drawing, meta },
window.location.origin,
);
pendingLoad = undefined;
};
const loadDrawing = async (drawing: DesignDrawingItem, index: number) => {
if (!projectId) return;
highlightActive(drawing.id);
const button = findButton(drawing.id);
if (button) button.dataset.loading = "true";
cadHost.dataset.loading = "true";
try {
const response = await fetchDesignDrawing(projectId, drawing.id);
currentDrawing = drawing;
currentIndex = index;
currentConfirmed = response.confirmed;
confirmButton.disabled = response.confirmed;
updateInfoPanel(drawing, response);
sendLoad(response.drawing, buildMeta(drawing, response, index));
} catch (error) {
cadHost.dataset.loading = "false";
cadHost.dataset.error =
error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
} finally {
if (button) button.dataset.loading = "false";
}
};
const selectDrawing = (drawing: DesignDrawingItem) => {
void loadDrawing(drawing, drawings.indexOf(drawing));
};
const navigateDrawing = (direction: "prev" | "next") => {
if (currentIndex < 0) return;
const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
if (target < 0 || target >= drawings.length) return;
void loadDrawing(drawings[target], target);
};
const requestCadDrawing = (): Promise<SaveResult> =>
new Promise((resolve, reject) => {
resolveSave = resolve;
frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin);
window.setTimeout(() => {
if (!resolveSave) return;
resolveSave = undefined;
reject(new Error("CAD 저장 응답 시간이 초과되었습니다."));
}, 5000);
});
async function confirmCurrentDrawing(): Promise<void> {
if (!projectId || !currentDrawing) return;
showLoadingOverlay();
try {
const saved = await requestCadDrawing();
const result = await confirmDesignDrawing(
projectId,
currentDrawing.id,
saved.drawing,
currentDrawing.kind === "cross" ? (saved.quantityTable ?? null) : null,
);
currentConfirmed = true;
currentDrawing.confirmed = true;
confirmButton.disabled = true;
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "true";
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
if (currentDrawing.kind === "cross") {
const infoTitle =
typeof currentDrawing.chainage_m === "number"
? stationLabel(currentDrawing.chainage_m, stationInterval)
: currentDrawing.label;
infoPanelHost.replaceChildren(buildDesignInfoPanel(infoTitle, result.design ?? null));
}
showToast("현재 도면을 확정하고 저장했습니다.", "success");
if (result.all_confirmed) {
allDrawingsConfirmed = true;
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]);
}
} catch (error) {
showToast(
error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.",
"error",
);
} finally {
hideLoadingOverlay();
}
}
const invalidateCurrentDrawing = async () => {
if (!projectId || !currentDrawing) return;
const wasConfirmed = currentConfirmed;
currentConfirmed = false;
allDrawingsConfirmed = false;
currentDrawing.confirmed = false;
confirmButton.disabled = false;
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "false";
if (wasConfirmed) {
try {
await invalidateDesignDrawing(projectId, currentDrawing.id);
} catch (error) {
showToast(
error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.",
"error",
);
}
}
};
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
const message = event.data as {
type?: string;
detail?: string;
drawing?: CadDrawing;
quantityTable?: QuantityTable | null;
direction?: "prev" | "next";
};
if (message.type === CAD_READY_MESSAGE) {
cadReady = true;
if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta);
} else if (message.type === CAD_LOADED_MESSAGE) {
cadHost.dataset.loading = "false";
} else if (message.type === CAD_ERROR_MESSAGE) {
cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다.";
} else if (message.type === CAD_CHANGED_MESSAGE) {
void invalidateCurrentDrawing();
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
navigateDrawing(message.direction);
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
const resolve = resolveSave;
resolveSave = undefined;
resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null });
}
});
const drawingPanel = buildDrawingSidePanel(
drawings,
stationInterval,
selectDrawing,
drawingError,
);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
// b07-drawing-actions는 자체 sticky 하단 고정(B07 Style.css) — 공용 ui-sidebar-actions와
// 같은 동작이라 중복 적용하지 않는다(2026-08-05 확인).
confirmActions.className = "b07-drawing-actions";
confirmActions.append(confirmButton);
// TODO: [임시 테스트용] B08 강제 이동 버튼 (테스트 완료 후 제거 예정)
const tempB08Btn = createButton({
label: "[임시] B08 이동",
variant: "ghost",
onClick: () => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]);
},
});
confirmActions.append(tempB08Btn);
drawingPanel.append(infoPanelHost, confirmActions);
const layout = createWorkflowLayout({
title: L("B07_Design_Title"),
steps: workflowSteps(),
activeStep: 4,
leftPanel: drawingPanel,
mainContent: cadHost,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (!projectId) return;
if (stepIndex > 4 && !allDrawingsConfirmed) {
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
return;
}
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
layout.root.classList.add("b07-design-layout");
root.replaceChildren(layout.root);
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
// CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다.
if (drawings.length > 0) {
void loadDrawing(drawings[0], 0);
}
}