This commit is contained in:
2026-07-20 17:27:45 +09:00
parent 2649b7e325
commit ce82feda30
10 changed files with 572 additions and 349 deletions
@@ -34,8 +34,26 @@ import {
invalidateDesignDrawing,
type CadDrawing,
type DesignDrawingItem,
type DesignDrawingResponse,
type QuantityTable,
} from "./B07_wf4_DesignDetail_Api_Fetch";
import { buildQuantityTable } from "./B07_wf4_DesignDetail_UI_QuantityTable";
/** 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";
@@ -52,6 +70,7 @@ 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 {
@@ -86,7 +105,8 @@ function stationLabel(chainage: number, interval: number): string {
/** B06 확정 산출물 기반 도면 목록 패널. */
function buildDrawingSidePanel(
drawings: DesignDrawingItem[],
onSelect: (drawing: DesignDrawingItem, button: HTMLButtonElement) => Promise<void>,
stationInterval: number,
onSelect: (drawing: DesignDrawingItem) => void,
errorMessage?: string,
): HTMLDivElement {
const panel = document.createElement("div");
@@ -108,11 +128,6 @@ function buildDrawingSidePanel(
return panel;
}
const crossChainages = drawings
.filter((item) => item.kind === "cross" && typeof item.chainage_m === "number")
.map((item) => item.chainage_m as number);
const stationInterval = inferStationInterval(crossChainages);
const groups: [string, DesignDrawingItem["kind"], DesignDrawingItem[]][] = [
["종단도", "longitudinal", drawings.filter((item) => item.kind === "longitudinal")],
["횡단도", "cross", drawings.filter((item) => item.kind === "cross")],
@@ -138,7 +153,7 @@ function buildDrawingSidePanel(
? stationLabel(drawing.chainage_m, stationInterval)
: drawing.label;
button.append(name);
button.addEventListener("click", () => void onSelect(drawing, button));
button.addEventListener("click", () => onSelect(drawing));
section.append(button);
}
panel.append(section);
@@ -182,66 +197,99 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
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 pendingDrawing: CadDrawing | undefined;
let pendingLoad: { drawing: CadDrawing; meta: DesignMeta } | undefined;
let currentDrawing: DesignDrawingItem | undefined;
let currentButton: HTMLButtonElement | undefined;
let currentIndex = -1;
let currentConfirmed = false;
let allDrawingsConfirmed = drawings.length > 0 && drawings.every((item) => item.confirmed);
let resolveSave: ((drawing: CadDrawing) => void) | undefined;
let resolveSave: ((payload: SaveResult) => void) | undefined;
let drawingListEl: HTMLElement | undefined;
// 편집 가능한 수량 산출표 (횡단도 선택 시 CAD 영역 위에 오버레이).
// 값 편집은 CAD 도면 변경과 동일하게 확정 상태를 롤백시킨다.
const quantityTable = buildQuantityTable(() => void invalidateCurrentDrawing());
cadHost.append(quantityTable.element);
const confirmButton = createButton({
label: "현재 도면 확정",
variant: "filled",
onClick: () => void confirmCurrentDrawing(),
});
confirmButton.disabled = true;
const sendDrawing = (drawing: CadDrawing) => {
pendingDrawing = drawing;
if (!cadReady) return;
frame.contentWindow?.postMessage({ type: CAD_LOAD_MESSAGE, drawing }, window.location.origin);
pendingDrawing = undefined;
};
const selectDrawing = async (drawing: DesignDrawingItem, button: HTMLButtonElement) => {
if (!projectId) return;
const buttons = button
.closest(".b07-drawing-list")
?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button");
buttons?.forEach((item) => {
item.disabled = true;
item.dataset.active = String(item === button);
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);
});
button.dataset.loading = "true";
};
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;
currentButton = button;
currentIndex = index;
currentConfirmed = response.confirmed;
confirmButton.disabled = response.confirmed;
sendDrawing(response.drawing);
if (drawing.kind === "cross") {
quantityTable.update(button.textContent ?? drawing.label, response.quantity_table);
} else {
quantityTable.element.hidden = true;
}
sendLoad(response.drawing, buildMeta(drawing, response, index));
} catch (error) {
cadHost.dataset.loading = "false";
cadHost.dataset.error =
error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
} finally {
button.dataset.loading = "false";
buttons?.forEach((item) => {
item.disabled = false;
});
if (button) button.dataset.loading = "false";
}
};
const requestCadDrawing = (): Promise<CadDrawing> =>
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);
@@ -256,17 +304,18 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
if (!projectId || !currentDrawing) return;
showLoadingOverlay();
try {
const drawing = await requestCadDrawing();
const saved = await requestCadDrawing();
const result = await confirmDesignDrawing(
projectId,
currentDrawing.id,
drawing,
currentDrawing.kind === "cross" ? quantityTable.getValues() : null,
saved.drawing,
currentDrawing.kind === "cross" ? (saved.quantityTable ?? null) : null,
);
currentConfirmed = true;
currentDrawing.confirmed = true;
confirmButton.disabled = true;
if (currentButton) currentButton.dataset.confirmed = "true";
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "true";
showToast("현재 도면을 확정하고 저장했습니다.", "success");
if (result.all_confirmed) {
allDrawingsConfirmed = true;
@@ -289,7 +338,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
allDrawingsConfirmed = false;
currentDrawing.confirmed = false;
confirmButton.disabled = false;
if (currentButton) currentButton.dataset.confirmed = "false";
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "false";
if (wasConfirmed) {
try {
await invalidateDesignDrawing(projectId, currentDrawing.id);
@@ -308,24 +358,34 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
type?: string;
detail?: string;
drawing?: CadDrawing;
quantityTable?: QuantityTable | null;
direction?: "prev" | "next";
};
if (message.type === CAD_READY_MESSAGE) {
cadReady = true;
if (pendingDrawing) sendDrawing(pendingDrawing);
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(message.drawing);
resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null });
}
});
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError);
const drawingPanel = buildDrawingSidePanel(
drawings,
stationInterval,
selectDrawing,
drawingError,
);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
confirmActions.className = "b07-drawing-actions";
confirmActions.append(confirmButton);
@@ -1,201 +0,0 @@
/* =============================================================================
* B07_wf4_DesignDetail_UI_QuantityTable.ts
* 횡단도 수량 산출표 (편집 가능). 첨부 양식의 병합셀 구조를 12열 표로 재현한다.
*
* 값은 백엔드 `_quantity_table`가 내려준 초기값으로 채우되, 사용자가 각 칸을
* 직접 수정할 수 있다. 절토고/성토고는 지반고·계획고에서 파생되어 읽기 전용이며
* 입력 변경 시 즉시 재계산된다.
* ========================================================================== */
import type { QuantityTable } from "./B07_wf4_DesignDetail_Api_Fetch";
/** 편집 입력이 있는 항목 키 (cut/fill 제외 — 파생 읽기전용). */
const EDITABLE_KEYS = [
"ground",
"planned",
"cut_soil",
"cut_soft_rock",
"cut_rock",
"tree_removal",
"fill_slope_protection",
"cut_slope_protection",
"ditch_soil",
"ditch_soft_rock",
"ditch_rock",
"embankment",
"grubbing",
"surface_grading",
] as const;
export interface QuantityTableController {
element: HTMLElement;
/** 새 도면 선택 시 값·측점명을 갱신한다. */
update(stationTitle: string, values: QuantityTable | null | undefined): void;
/** 현재 입력값(파생 cut/fill 포함)을 수집한다. */
getValues(): QuantityTable;
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function formatValue(value: number | null | undefined): string {
return typeof value === "number" && Number.isFinite(value) ? String(round2(value)) : "";
}
function parseValue(raw: string): number | null {
const text = raw.trim();
if (!text) return null;
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}
/** 편집 가능한 값 입력 칸을 만든다. */
function valueCell(
key: string,
colSpan: number,
inputs: Map<string, HTMLInputElement>,
options: { readonly?: boolean } = {},
): HTMLTableCellElement {
const cell = document.createElement("td");
cell.colSpan = colSpan;
cell.className = "b07-qtable__value";
const input = document.createElement("input");
input.type = "text";
input.inputMode = "decimal";
input.autocomplete = "off";
input.dataset.key = key;
if (options.readonly) {
input.readOnly = true;
cell.classList.add("b07-qtable__value--derived");
}
inputs.set(key, input);
cell.append(input);
return cell;
}
/** 라벨(헤더) 셀을 만든다. */
function labelCell(
text: string,
colSpan: number,
options: { rowSpan?: number; vertical?: boolean } = {},
): HTMLTableCellElement {
const cell = document.createElement("th");
cell.scope = "row";
cell.colSpan = colSpan;
if (options.rowSpan) cell.rowSpan = options.rowSpan;
cell.className = "b07-qtable__label";
if (options.vertical) cell.classList.add("b07-qtable__label--vertical");
cell.textContent = text;
return cell;
}
/**
* 첨부 양식과 동일한 편집 가능 수량 산출표를 생성한다.
* @param onEdit 사용자가 값을 바꿀 때마다 호출 (확정 상태 롤백 연동용)
*/
export function buildQuantityTable(onEdit: () => void): QuantityTableController {
const inputs = new Map<string, HTMLInputElement>();
const container = document.createElement("section");
container.className = "b07-qtable";
container.hidden = true;
const table = document.createElement("table");
const body = document.createElement("tbody");
// 1행: 측 점 | (측점명)
const titleRow = document.createElement("tr");
titleRow.append(labelCell("측 점", 2));
const titleCell = document.createElement("td");
titleCell.colSpan = 10;
titleCell.className = "b07-qtable__station";
titleRow.append(titleCell);
body.append(titleRow);
// 2행: 지반고 | 계획고 | 절토고 | 성토고
const baseRow = document.createElement("tr");
baseRow.append(labelCell("지반고", 2), valueCell("ground", 1, inputs));
baseRow.append(labelCell("계획고", 2), valueCell("planned", 1, inputs));
baseRow.append(labelCell("절토고", 2), valueCell("cut", 1, inputs, { readonly: true }));
baseRow.append(labelCell("성토고", 2), valueCell("fill", 1, inputs, { readonly: true }));
body.append(baseRow);
// 3행: 흙깎기 토사 | 지장목제거 | 옆도랑파기 토사
const row3 = document.createElement("tr");
row3.append(labelCell("흙깎기", 1, { rowSpan: 3, vertical: true }));
row3.append(labelCell("토사", 1), valueCell("cut_soil", 2, inputs));
row3.append(labelCell("지장목제거", 2), valueCell("tree_removal", 2, inputs));
row3.append(labelCell("옆도랑파기", 1, { rowSpan: 3, vertical: true }));
row3.append(labelCell("토사", 1), valueCell("ditch_soil", 2, inputs));
body.append(row3);
// 4행: 연암 | 비탈보호공 성토면 | 연암
const row4 = document.createElement("tr");
row4.append(labelCell("연암", 1), valueCell("cut_soft_rock", 2, inputs));
row4.append(labelCell("비탈보호공", 1, { rowSpan: 2, vertical: true }));
row4.append(labelCell("성토면", 1), valueCell("fill_slope_protection", 2, inputs));
row4.append(labelCell("연암", 1), valueCell("ditch_soft_rock", 2, inputs));
body.append(row4);
// 5행: 보통암 | 절토면 | 보통암
const row5 = document.createElement("tr");
row5.append(labelCell("보통암", 1), valueCell("cut_rock", 2, inputs));
row5.append(labelCell("절토면", 1), valueCell("cut_slope_protection", 2, inputs));
row5.append(labelCell("보통암", 1), valueCell("ditch_rock", 2, inputs));
body.append(row5);
// 6행: 흙쌓기 | 제근 | 노면고르기
const row6 = document.createElement("tr");
row6.append(labelCell("흙쌓기", 2), valueCell("embankment", 2, inputs));
row6.append(labelCell("제근", 2), valueCell("grubbing", 2, inputs));
row6.append(labelCell("노면고르기", 2), valueCell("surface_grading", 2, inputs));
body.append(row6);
table.append(body);
container.append(table);
const groundInput = inputs.get("ground");
const plannedInput = inputs.get("planned");
const cutInput = inputs.get("cut");
const fillInput = inputs.get("fill");
const recomputeCutFill = () => {
const ground = parseValue(groundInput?.value ?? "");
const planned = parseValue(plannedInput?.value ?? "");
if (ground !== null && planned !== null) {
if (cutInput) cutInput.value = formatValue(Math.max(ground - planned, 0));
if (fillInput) fillInput.value = formatValue(Math.max(planned - ground, 0));
} else {
if (cutInput) cutInput.value = "";
if (fillInput) fillInput.value = "";
}
};
for (const key of EDITABLE_KEYS) {
const input = inputs.get(key);
input?.addEventListener("input", () => {
if (key === "ground" || key === "planned") recomputeCutFill();
onEdit();
});
}
const update: QuantityTableController["update"] = (stationTitle, values) => {
titleCell.textContent = stationTitle;
for (const [key, input] of inputs) {
input.value = formatValue(values?.[key]);
}
recomputeCutFill();
container.hidden = false;
};
const getValues: QuantityTableController["getValues"] = () => {
const result: QuantityTable = {};
for (const [key, input] of inputs) {
result[key] = parseValue(input.value);
}
return result;
};
return { element: container, update, getValues };
}
@@ -157,89 +157,3 @@
color: var(--color-text-muted);
text-decoration: underline;
}
/* -----------------------------------------------------------------------------
* 수량 산출표 (횡단도 편집 오버레이) — 첨부 양식 재현
* -------------------------------------------------------------------------- */
.b07-qtable {
position: absolute;
z-index: 2;
bottom: 10px;
left: 10px;
max-width: min(600px, 62%);
max-height: 48%;
overflow: auto;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
box-shadow: 0 6px 18px #0006;
}
.b07-qtable[hidden] {
display: none;
}
.b07-qtable table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.b07-qtable th,
.b07-qtable td {
padding: 2px 3px;
border: 1px solid var(--color-border);
font-size: 11px;
text-align: center;
vertical-align: middle;
}
.b07-qtable__label {
color: var(--color-text-muted);
font-weight: 600;
white-space: nowrap;
background: color-mix(in srgb, var(--color-surface) 78%, #000);
}
/* 세로 라벨(흙깎기 / 옆도랑파기 / 비탈보호공) */
.b07-qtable__label--vertical {
width: 20px;
writing-mode: vertical-rl;
text-orientation: upright;
letter-spacing: 1px;
}
.b07-qtable__station {
color: var(--color-text);
font-size: 15px;
font-weight: 700;
}
.b07-qtable__value {
padding: 0;
}
.b07-qtable__value input {
width: 100%;
min-width: 0;
padding: 3px 4px;
border: 0;
background: transparent;
color: var(--color-text);
font: inherit;
text-align: center;
}
.b07-qtable__value input:focus {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
/* 파생 읽기전용 (절토고 / 성토고) */
.b07-qtable__value--derived input {
color: var(--color-text-muted);
background: color-mix(in srgb, var(--color-surface) 68%, #000);
cursor: not-allowed;
}
+128 -10
View File
@@ -3,7 +3,8 @@
:root {
--cad-title-height: 42px;
--cad-ribbon-height: 82px;
--cad-command-height: 58px;
/* 하단 명령어 입력창 제거 → 높이 0으로 캔버스가 공간을 회수 (추후 사용성 개선 예정) */
--cad-command-height: 0px;
--cad-status-height: 28px;
--cad-panel-width: 248px;
font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif;
@@ -343,16 +344,9 @@ body > canvas[data-id="canvas"] {
text-align: center;
}
/* 하단 명령어 입력창은 숨김 처리 (마우스 커서 단축키 입력으로 대체) */
.cad-command-area {
position: fixed;
z-index: 3;
right: 0;
bottom: var(--cad-status-height);
left: var(--cad-panel-width);
height: var(--cad-command-height);
padding: 5px 10px;
background: #151c24f2;
border-top: 1px solid #3b4b5a;
display: none;
}
.cad-command-prompt {
display: flex;
@@ -435,6 +429,130 @@ body > canvas[data-id="canvas"] {
font-size: 10px;
}
/* -----------------------------------------------------------------------------
* 수량 산출표 패널 (CAD 화면 하단 중심, 접이식) — 첨부 양식
* -------------------------------------------------------------------------- */
.cad-qtable {
position: fixed;
z-index: 3;
bottom: calc(var(--cad-status-height) + 8px);
left: calc(var(--cad-panel-width) + (100vw - var(--cad-panel-width)) / 2);
transform: translateX(-50%);
max-width: min(720px, calc(100vw - var(--cad-panel-width) - 24px));
border: 1px solid #465565;
border-radius: 6px;
background: #202b37f2;
box-shadow: 0 4px 16px #0009;
color: #dce6f2;
}
.cad-qtable__header {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
}
.cad-qtable__title {
display: flex;
flex: 1;
align-items: baseline;
justify-content: center;
gap: 8px;
min-width: 0;
}
.cad-qtable__title strong {
color: #f7fbff;
font-size: 14px;
}
.cad-qtable__title span {
overflow: hidden;
color: #91a2b5;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cad-qtable__status {
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-style: normal;
}
.cad-qtable__status[data-confirmed="true"] {
background: #1f4d33;
color: #7fdca4;
}
.cad-qtable__status[data-confirmed="false"] {
background: #4a3a1c;
color: #e6c07a;
}
.cad-qtable__nav,
.cad-qtable__collapse {
width: 26px;
height: 24px;
border: 1px solid #40505f;
border-radius: 4px;
background: #2a3745;
color: #cdd8e4;
font-size: 13px;
}
.cad-qtable__nav:hover:not(:disabled),
.cad-qtable__collapse:hover {
background: #31516b;
}
.cad-qtable__nav:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.cad-qtable__body {
padding: 0 8px 8px;
overflow-x: auto;
}
.cad-qtable table {
margin: 0 auto;
border-collapse: collapse;
}
.cad-qtable th,
.cad-qtable td {
padding: 0;
border: 1px solid #40505f;
font-size: 11px;
text-align: center;
vertical-align: middle;
}
.cad-qtable__label {
padding: 2px 5px;
color: #a9bccd;
font-weight: 600;
white-space: nowrap;
background: #26333f;
}
.cad-qtable__label--vertical {
width: 18px;
writing-mode: vertical-rl;
text-orientation: upright;
letter-spacing: 1px;
}
.cad-qtable__value input {
width: 48px;
padding: 3px 2px;
border: 0;
background: transparent;
color: #eaf1f8;
font: inherit;
text-align: center;
}
.cad-qtable__value input:focus {
outline: 2px solid #4ca6e8;
outline-offset: -2px;
background: #12324a;
}
.cad-qtable__value--derived input {
color: #8fa3b5;
background: #1a242e;
cursor: not-allowed;
}
@media (max-width: 800px) {
:root {
--cad-panel-width: 200px;
@@ -1,11 +1,13 @@
import './App.css';
import { ToastContainer } from 'react-toastify';
import { QuantityPanel } from './components/QuantityPanel.tsx';
import { Toolbar } from './components/Toolbar.tsx';
function App() {
return (
<div className="cad-app">
<Toolbar />
<QuantityPanel />
<ToastContainer position="bottom-right" theme="light" />
</div>
);
@@ -38,6 +38,21 @@ export enum HtmlEvent {
DRAWING_CHANGED = 'DRAWING_CHANGED',
}
/** 부모(B07 페이지)가 도면과 함께 넘기는 설계 컨텍스트 (수량 패널 표시용). */
export interface DesignMeta {
kind: 'cross' | 'longitudinal';
/** 패널 제목 (측점 라벨, 예: "2+0.0" 또는 "종단도 전체") */
title: string;
/** 측점 부가 정보 (예: "STA.0+050.000") */
info: string;
confirmed: boolean;
/** 수량 산출표 값 (횡단도만). 미산정 항목은 null. */
quantityTable: Record<string, number | null> | null;
/** 이전/다음 도면 존재 여부 (경계에서 버튼 비활성화) */
hasPrev: boolean;
hasNext: boolean;
}
export interface StateMetaData {
instructions: string;
}
@@ -0,0 +1,277 @@
import { type FC, useCallback, useEffect, useRef, useState } from 'react';
import { HtmlEvent } from '../App.types';
import {
notifyDrawingChangedByTable,
requestDrawingNavigation,
} from '../integration/aislo-drawing-bridge';
import { getDesignMeta, setDesignQuantityTable } from '../state';
/** 편집 가능한 항목 키 (cut/fill 은 지반고·계획고에서 파생되는 읽기전용). */
const EDITABLE_KEYS = [
'ground',
'planned',
'cut_soil',
'cut_soft_rock',
'cut_rock',
'tree_removal',
'fill_slope_protection',
'cut_slope_protection',
'ditch_soil',
'ditch_soft_rock',
'ditch_rock',
'embankment',
'grubbing',
'surface_grading',
] as const;
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function formatValue(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value) ? String(round2(value)) : '';
}
function parseValue(raw: string): number | null {
const text = raw.trim();
if (!text) return null;
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}
/** 편집 값 문자열 → 저장용 수치 테이블 (파생 cut/fill 포함) */
function toNumericTable(values: Record<string, string>): Record<string, number | null> {
const table: Record<string, number | null> = {};
for (const key of EDITABLE_KEYS) table[key] = parseValue(values[key] ?? '');
const ground = table.ground;
const planned = table.planned;
if (typeof ground === 'number' && typeof planned === 'number') {
table.cut = Math.max(ground - planned, 0);
table.fill = Math.max(planned - ground, 0);
} else {
table.cut = null;
table.fill = null;
}
return table;
}
interface ValueInputProps {
fieldKey: (typeof EDITABLE_KEYS)[number];
colSpan: number;
values: Record<string, string>;
onEdit: (key: string, value: string) => void;
}
const ValueInput: FC<ValueInputProps> = ({ fieldKey, colSpan, values, onEdit }) => (
<td className="cad-qtable__value" colSpan={colSpan}>
<input
type="text"
inputMode="decimal"
autoComplete="off"
value={values[fieldKey] ?? ''}
onChange={(event) => onEdit(fieldKey, event.target.value)}
/>
</td>
);
const DerivedCell: FC<{ value: string }> = ({ value }) => (
<td className="cad-qtable__value cad-qtable__value--derived" colSpan={1}>
<input type="text" readOnly value={value} tabIndex={-1} />
</td>
);
/**
* 횡단도 수량 산출표 (CAD 화면 내부, 하단 중심 접이식 패널).
* 헤더(제목·측점정보·확정상태·이전/다음)는 접어도 항상 보인다.
*/
export const QuantityPanel: FC = () => {
const [meta, setMeta] = useState(getDesignMeta());
const [collapsed, setCollapsed] = useState(false);
const [values, setValues] = useState<Record<string, string>>({});
const identityRef = useRef<string>('');
const refresh = useCallback(() => setMeta(getDesignMeta()), []);
useEffect(() => {
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
}, [refresh]);
// 새 도면(측점)으로 바뀌면 입력값을 원본값으로 초기화한다.
const identity = meta ? `${meta.kind}:${meta.title}` : '';
useEffect(() => {
if (!meta) return;
if (identityRef.current === identity) return;
identityRef.current = identity;
const next: Record<string, string> = {};
for (const key of EDITABLE_KEYS) next[key] = formatValue(meta.quantityTable?.[key]);
setValues(next);
}, [identity, meta]);
if (!meta) return null;
const handleEdit = (key: string, value: string) => {
const nextValues = { ...values, [key]: value };
setValues(nextValues);
setDesignQuantityTable(toNumericTable(nextValues));
notifyDrawingChangedByTable();
};
const ground = parseValue(values.ground ?? '');
const planned = parseValue(values.planned ?? '');
const cutText =
ground !== null && planned !== null ? formatValue(Math.max(ground - planned, 0)) : '';
const fillText =
ground !== null && planned !== null ? formatValue(Math.max(planned - ground, 0)) : '';
return (
<section className="cad-qtable controls" data-collapsed={collapsed}>
<header className="cad-qtable__header">
<button
type="button"
className="cad-qtable__nav"
disabled={!meta.hasPrev}
title="이전 도면"
onClick={() => requestDrawingNavigation('prev')}
>
</button>
<div className="cad-qtable__title">
<strong>{meta.title}</strong>
{meta.info && <span>{meta.info}</span>}
<em
className="cad-qtable__status"
data-confirmed={meta.confirmed}
title={meta.confirmed ? '설계 확정됨' : '미확정'}
>
{meta.confirmed ? '확정' : '미확정'}
</em>
</div>
<button
type="button"
className="cad-qtable__nav"
disabled={!meta.hasNext}
title="다음 도면"
onClick={() => requestDrawingNavigation('next')}
>
</button>
{meta.kind === 'cross' && (
<button
type="button"
className="cad-qtable__collapse"
title={collapsed ? '표 펼치기' : '표 접기'}
onClick={() => setCollapsed((value) => !value)}
>
{collapsed ? '▲' : '▼'}
</button>
)}
</header>
{meta.kind === 'cross' && !collapsed && (
<div className="cad-qtable__body">
<table>
<tbody>
<tr>
<th className="cad-qtable__label" colSpan={2}>
</th>
<ValueInput fieldKey="ground" colSpan={1} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
</th>
<ValueInput fieldKey="planned" colSpan={1} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
</th>
<DerivedCell value={cutText} />
<th className="cad-qtable__label" colSpan={2}>
</th>
<DerivedCell value={fillText} />
</tr>
<tr>
<th className="cad-qtable__label cad-qtable__label--vertical" rowSpan={3}>
</th>
<th className="cad-qtable__label"></th>
<ValueInput fieldKey="cut_soil" colSpan={2} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
</th>
<ValueInput
fieldKey="tree_removal"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
<th className="cad-qtable__label cad-qtable__label--vertical" rowSpan={3}>
</th>
<th className="cad-qtable__label"></th>
<ValueInput fieldKey="ditch_soil" colSpan={2} values={values} onEdit={handleEdit} />
</tr>
<tr>
<th className="cad-qtable__label"></th>
<ValueInput
fieldKey="cut_soft_rock"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
<th className="cad-qtable__label cad-qtable__label--vertical" rowSpan={2}>
</th>
<th className="cad-qtable__label"></th>
<ValueInput
fieldKey="fill_slope_protection"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
<th className="cad-qtable__label"></th>
<ValueInput
fieldKey="ditch_soft_rock"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
</tr>
<tr>
<th className="cad-qtable__label"></th>
<ValueInput fieldKey="cut_rock" colSpan={2} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label"></th>
<ValueInput
fieldKey="cut_slope_protection"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
<th className="cad-qtable__label"></th>
<ValueInput fieldKey="ditch_rock" colSpan={2} values={values} onEdit={handleEdit} />
</tr>
<tr>
<th className="cad-qtable__label" colSpan={2}>
</th>
<ValueInput fieldKey="embankment" colSpan={2} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
</th>
<ValueInput fieldKey="grubbing" colSpan={2} values={values} onEdit={handleEdit} />
<th className="cad-qtable__label" colSpan={2}>
</th>
<ValueInput
fieldKey="surface_grading"
colSpan={2}
values={values}
onEdit={handleEdit}
/>
</tr>
</tbody>
</table>
</div>
)}
</section>
);
};
@@ -28,6 +28,7 @@ export enum StateVariable {
activeLineWidth = 'activeLineWidth',
activeLineDash = 'activeLineDash',
activeTextStyle = 'activeTextStyle',
designMeta = 'designMeta',
layers = 'layers',
}
@@ -1,10 +1,12 @@
import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts';
import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts';
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts';
import { HtmlEvent } from '../App.types.ts';
import { type DesignMeta, HtmlEvent } from '../App.types.ts';
import {
getDesignMeta,
getScreenCanvasDrawController,
setActiveLayerId,
setDesignMeta,
setEntities,
setLayers,
} from '../state.ts';
@@ -16,10 +18,12 @@ export const AISLO_DRAWING_ERROR_MESSAGE = 'aislo:b07:drawing-error';
export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b07:drawing-changed';
export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b07:save-request';
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b07:save-response';
export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b07:navigate';
interface DrawingLoadMessage {
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
drawing: JsonDrawingFileSerialized;
meta?: DesignMeta | null;
}
interface DrawingSaveRequestMessage {
@@ -37,6 +41,16 @@ function notifyParent(type: string, payload: Record<string, unknown> = {}) {
window.parent.postMessage({ type, ...payload }, window.location.origin);
}
/** 수량 패널에서 이전/다음 도면으로 이동 요청 (부모가 처리). */
export function requestDrawingNavigation(direction: 'prev' | 'next') {
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
}
/** 수량표 값 편집을 부모에 알린다 (확정 상태 롤백 연동). */
export function notifyDrawingChangedByTable() {
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE);
}
/**
* B07 parent page와 CAD 앱 사이의 same-origin JSON 경계다.
* DXF/DWG 파일이나 파서 객체는 이 경계를 통과하지 않는다.
@@ -50,7 +64,9 @@ export function registerAisloDrawingBridge() {
const drawing = JSON.parse(
await exportEntitiesAndLayersToJsonString()
) as JsonDrawingFileSerialized;
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing });
// 편집된 수량표 값을 도면과 함께 부모로 돌려준다.
const quantityTable = getDesignMeta()?.quantityTable ?? null;
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing, quantityTable });
} catch (error) {
const detail = error instanceof Error ? error.message : 'Unable to serialize drawing';
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
@@ -64,6 +80,8 @@ export function registerAisloDrawingBridge() {
setEntities(drawing.entities, false);
setLayers(drawing.layers);
setActiveLayerId(drawing.layers[0].id);
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
setDesignMeta(event.data.meta ?? null);
getScreenCanvasDrawController().zoomToFitScreen();
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
} catch (error) {
@@ -3,6 +3,7 @@ import { isEqual } from 'es-toolkit';
import { toast } from 'react-toastify';
import type { Actor, MachineSnapshot } from 'xstate';
import {
type DesignMeta,
type HoverPoint,
HtmlEvent,
type Layer,
@@ -164,6 +165,12 @@ let activeLayerId: string = layers[0].id;
let snapEnabled = true;
let gridEnabled = false;
/**
* 부모(B07 페이지)에서 넘어온 설계 컨텍스트. 수량 산출 패널이 이 값을 읽어
* 제목·측점정보·확정상태·수량표를 렌더한다. null이면 패널을 숨긴다.
*/
let designMeta: DesignMeta | null = null;
// getters
export const getCanvas = () => canvas;
export const getActiveToolActor = () => activeToolActor;
@@ -215,6 +222,7 @@ export const getActiveLayerId = (): string => {
};
export const getSnapEnabled = () => snapEnabled;
export const getGridEnabled = () => gridEnabled;
export const getDesignMeta = (): DesignMeta | null => designMeta;
// setters
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
@@ -392,6 +400,16 @@ export const setGridEnabled = (enabled: boolean) => {
gridEnabled = enabled;
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
export const setDesignMeta = (newMeta: DesignMeta | null) => {
designMeta = newMeta;
triggerReactUpdate(StateVariable.designMeta);
};
/** 사용자가 편집한 수량표 전체를 반영하고 확정 상태를 롤백한다 (저장 대상). */
export const setDesignQuantityTable = (table: Record<string, number | null>) => {
if (!designMeta) return;
designMeta = { ...designMeta, quantityTable: table, confirmed: false };
triggerReactUpdate(StateVariable.designMeta);
};
// Computed setters
export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => {
@@ -414,6 +432,7 @@ const reactStateVariables: StateVariable[] = [
StateVariable.activeLineWidth,
StateVariable.activeLineDash,
StateVariable.activeTextStyle,
StateVariable.designMeta,
StateVariable.screenZoom,
StateVariable.layers,
];