From ce82feda30ce624a3df4abf70362834c2dd3d8a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 20 Jul 2026 17:27:45 +0900 Subject: [PATCH] 260720_1 --- .../B07_wf4_DesignDetail_UI_Page.ts | 160 ++++++---- .../B07_wf4_DesignDetail_UI_QuantityTable.ts | 201 ------------- .../B07_wf4_DesignDetail_UI_Style.css | 86 ------ B07_wf4_DesignDetail/openwebcad/src/App.css | 138 ++++++++- B07_wf4_DesignDetail/openwebcad/src/App.tsx | 2 + .../openwebcad/src/App.types.ts | 15 + .../src/components/QuantityPanel.tsx | 277 ++++++++++++++++++ .../openwebcad/src/helpers/undo-stack.ts | 1 + .../src/integration/aislo-drawing-bridge.ts | 22 +- B07_wf4_DesignDetail/openwebcad/src/state.ts | 19 ++ 10 files changed, 572 insertions(+), 349 deletions(-) delete mode 100644 B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_QuantityTable.ts create mode 100644 B07_wf4_DesignDetail/openwebcad/src/components/QuantityPanel.tsx diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts index 9341c1f7..1361e15a 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts @@ -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, + 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 { 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(".b07-drawing-button"); - buttons?.forEach((item) => { - item.disabled = true; - item.dataset.active = String(item === button); + + const findButton = (drawingId: string) => + drawingListEl?.querySelector( + `.b07-drawing-button[data-drawing-id="${drawingId}"]`, + ) ?? undefined; + + const highlightActive = (drawingId: string) => { + drawingListEl?.querySelectorAll(".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 => + 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 => 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 { 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 { 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 { 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); diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_QuantityTable.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_QuantityTable.ts deleted file mode 100644 index 3fdcb169..00000000 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_QuantityTable.ts +++ /dev/null @@ -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, - 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(); - - 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 }; -} diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css index 28c9795d..0cd60e52 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Style.css @@ -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; -} diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.css b/B07_wf4_DesignDetail/openwebcad/src/App.css index 783731d9..f5a49e7d 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.css +++ b/B07_wf4_DesignDetail/openwebcad/src/App.css @@ -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; diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.tsx b/B07_wf4_DesignDetail/openwebcad/src/App.tsx index 0d3f6865..d5f6c178 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/App.tsx @@ -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 (
+
); diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.types.ts b/B07_wf4_DesignDetail/openwebcad/src/App.types.ts index a10a9c81..07a5c547 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.types.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/App.types.ts @@ -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 | null; + /** 이전/다음 도면 존재 여부 (경계에서 버튼 비활성화) */ + hasPrev: boolean; + hasNext: boolean; +} + export interface StateMetaData { instructions: string; } diff --git a/B07_wf4_DesignDetail/openwebcad/src/components/QuantityPanel.tsx b/B07_wf4_DesignDetail/openwebcad/src/components/QuantityPanel.tsx new file mode 100644 index 00000000..dcee7a4e --- /dev/null +++ b/B07_wf4_DesignDetail/openwebcad/src/components/QuantityPanel.tsx @@ -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): Record { + const table: Record = {}; + 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; + onEdit: (key: string, value: string) => void; +} + +const ValueInput: FC = ({ fieldKey, colSpan, values, onEdit }) => ( + + onEdit(fieldKey, event.target.value)} + /> + +); + +const DerivedCell: FC<{ value: string }> = ({ value }) => ( + + + +); + +/** + * 횡단도 수량 산출표 (CAD 화면 내부, 하단 중심 접이식 패널). + * 헤더(제목·측점정보·확정상태·이전/다음)는 접어도 항상 보인다. + */ +export const QuantityPanel: FC = () => { + const [meta, setMeta] = useState(getDesignMeta()); + const [collapsed, setCollapsed] = useState(false); + const [values, setValues] = useState>({}); + const identityRef = useRef(''); + + 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 = {}; + 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 ( +
+
+ +
+ {meta.title} + {meta.info && {meta.info}} + + {meta.confirmed ? '확정' : '미확정'} + +
+ + {meta.kind === 'cross' && ( + + )} +
+ + {meta.kind === 'cross' && !collapsed && ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 지반고 + + 계획고 + + 절토고 + + 성토고 +
+ 흙깎기 + 토사 + 지장목제거 + + 옆도랑파기 + 토사
연암 + 비탈보호공 + 성토면연암
보통암절토면보통암
+ 흙쌓기 + + 제근 + + 노면고르기 +
+
+ )} +
+ ); +}; diff --git a/B07_wf4_DesignDetail/openwebcad/src/helpers/undo-stack.ts b/B07_wf4_DesignDetail/openwebcad/src/helpers/undo-stack.ts index 00d10939..dbbe75cd 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/helpers/undo-stack.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/helpers/undo-stack.ts @@ -28,6 +28,7 @@ export enum StateVariable { activeLineWidth = 'activeLineWidth', activeLineDash = 'activeLineDash', activeTextStyle = 'activeTextStyle', + designMeta = 'designMeta', layers = 'layers', } diff --git a/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 465736fd..2788a612 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -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 = {}) { 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) { diff --git a/B07_wf4_DesignDetail/openwebcad/src/state.ts b/B07_wf4_DesignDetail/openwebcad/src/state.ts index 75adfc6e..28563c07 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/state.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/state.ts @@ -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) => { + 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, ];