415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* 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 { 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 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");
|
|
section.className = "b07-drawing-group";
|
|
section.dataset.kind = kind;
|
|
const sectionTitle = document.createElement("h3");
|
|
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);
|
|
}
|
|
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;
|
|
let allDrawingsConfirmed = drawings.length > 0 && drawings.every((item) => item.confirmed);
|
|
let resolveSave: ((payload: SaveResult) => void) | undefined;
|
|
let drawingListEl: HTMLElement | undefined;
|
|
|
|
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)
|
|
: "종단도 전체",
|
|
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;
|
|
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";
|
|
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");
|
|
confirmActions.className = "b07-drawing-actions";
|
|
confirmActions.append(confirmButton);
|
|
drawingPanel.append(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);
|
|
}
|