diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts index 6eeff376..26c7cfea 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Api_Fetch.ts @@ -27,6 +27,9 @@ export interface DesignDrawingListResponse { drawings: DesignDrawingItem[]; } +/** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */ +export type QuantityTable = Record; + export interface DesignDrawingResponse { status: string; project_id: string; @@ -36,6 +39,7 @@ export interface DesignDrawingResponse { label: string; drawing: CadDrawing; confirmed: boolean; + quantity_table?: QuantityTable | null; } export interface DesignDrawingConfirmResponse { @@ -79,10 +83,11 @@ export function confirmDesignDrawing( projectId: string, drawingId: string, drawing: CadDrawing, + quantityTable?: QuantityTable | null, ): Promise { return requestJson( `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/confirm`, - { method: "PUT", body: JSON.stringify({ drawing }) }, + { method: "PUT", body: JSON.stringify({ drawing, quantity_table: quantityTable ?? null }) }, ); } diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py index dbeca753..fc12327a 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py @@ -160,129 +160,49 @@ def _line_entity( } -def _text_entity( - drawing_id: str, - index: int, - label: str, - point: tuple[float, float], -) -> dict[str, Any]: - return { - "id": str( - uuid5( - UUID("8ce96e1d-17e8-457b-b46e-329456701225"), - f"{drawing_id}:{index}", - ) - ), - "type": "Text", - "lineColor": "#cbd5e1", - "lineWidth": 1, - "layerId": "b07-quantity-table", - "shapeData": { - "label": label, - "basePoint": {"x": point[0], "y": point[1]}, - "options": { - "textDirection": {"x": 1, "y": 0}, - "textAlign": "left", - "textColor": "#cbd5e1", - "fontSize": 0.32, - "fontFamily": "Noto Sans KR", - }, - }, - } +# 수량 산출표 항목 키 (프론트 편집 테이블과 1:1 대응). center_z→지반고, +# planned_elevation_m→계획고, cut/fill은 파생값, 나머지는 source["quantities"]에서 읽는다. +_QUANTITY_ITEM_KEYS = ( + "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", +) -def _quantity_rows(source: dict[str, Any]) -> list[tuple[str, str, str]]: - ground = source.get("center_z") - planned = source.get("planned_elevation_m", source.get("design_elevation_m")) - cut = ( - max(float(ground) - float(planned), 0.0) - if isinstance(ground, (int, float)) and isinstance(planned, (int, float)) - else None - ) - fill = ( - max(float(planned) - float(ground), 0.0) - if isinstance(ground, (int, float)) and isinstance(planned, (int, float)) - else None - ) +def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: + """횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다. + + 아직 산정되지 않은 값은 None으로 두어 프론트 입력칸에서 사용자가 채운다. + 절토고/성토고(cut/fill)는 지반고·계획고에서 파생한 초기값이다. + """ + + def num(value: Any) -> float | None: + return float(value) if isinstance(value, (int, float)) else None + + ground = num(source.get("center_z")) + planned = num(source.get("planned_elevation_m", source.get("design_elevation_m"))) + cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None + fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {} - def value(number: Any) -> str: - return f"{float(number):.3f}" if isinstance(number, (int, float)) else "-" - - return [ - ("기본", "측점", str(source.get("label", "-"))), - ("기본", "지반고", value(ground)), - ("기본", "계획고", value(planned)), - ("기본", "절토고", value(cut)), - ("기본", "성토고", value(fill)), - ("흙깎기", "토사", value(quantities.get("cut_soil"))), - ("흙깎기", "연암", value(quantities.get("cut_soft_rock"))), - ("흙깎기", "보통암", value(quantities.get("cut_rock"))), - ("옆도랑파기", "토사", value(quantities.get("ditch_soil"))), - ("옆도랑파기", "연암", value(quantities.get("ditch_soft_rock"))), - ("옆도랑파기", "보통암", value(quantities.get("ditch_rock"))), - ("비탈보호공", "성토면", value(quantities.get("fill_slope_protection"))), - ("비탈보호공", "절토면", value(quantities.get("cut_slope_protection"))), - ("기타", "지장목제거", value(quantities.get("tree_removal"))), - ("기타", "흙쌓기", value(quantities.get("embankment"))), - ("기타", "제근", value(quantities.get("grubbing"))), - ("기타", "노면고르기", value(quantities.get("surface_grading"))), - ] - - -def _quantity_table_entities( - source: dict[str, Any], drawing_id: str, points: list[tuple[float, float]] -) -> list[dict[str, Any]]: - if not points: - return [] - rows = [("구분", "항목", "값"), *_quantity_rows(source)] - row_height = 0.75 - column_widths = (3.2, 3.2, 3.0) - left = max(point[0] for point in points) + 2.0 - top = max(point[1] for point in points) - right = left + sum(column_widths) - bottom = top - row_height * len(rows) - entities: list[dict[str, Any]] = [] - for row_index in range(len(rows) + 1): - y = top - row_index * row_height - entities.append( - _line_entity( - f"{drawing_id}:table-h", - row_index, - (left, y), - (right, y), - "b07-quantity-table", - "#64748b", - ) - ) - x_positions = [left] - for width in column_widths: - x_positions.append(x_positions[-1] + width) - for column_index, x in enumerate(x_positions): - entities.append( - _line_entity( - f"{drawing_id}:table-v", - column_index, - (x, top), - (x, bottom), - "b07-quantity-table", - "#64748b", - ) - ) - text_index = 0 - for row_index, row in enumerate(rows): - y = top - row_index * row_height - 0.5 - for column_index, label in enumerate(row): - entities.append( - _text_entity( - drawing_id, - text_index, - label, - (x_positions[column_index] + 0.12, y), - ) - ) - text_index += 1 - return entities + table: dict[str, float | None] = { + "ground": ground, + "planned": planned, + "cut": cut, + "fill": fill, + } + for key in _QUANTITY_ITEM_KEYS: + table[key] = num(quantities.get(key)) + return table def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str, Any]: @@ -318,8 +238,7 @@ def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str "children": children, } ) - if kind == "cross": - entities.extend(_quantity_table_entities(source, drawing_id, points)) + # 수량 산출표는 정적 도면 엔티티가 아니라 편집 가능한 HTML 테이블로 분리되었다. return { "entities": entities, "layers": [ @@ -329,25 +248,26 @@ def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str "isVisible": True, "isLocked": False, }, - { - "id": "b07-quantity-table", - "name": "Quantity Table", - "isVisible": True, - "isLocked": False, - }, ], } def _read_drawing( project_root: Path, longitudinal_path: Path, drawing_id: str -) -> tuple[str, str, dict[str, Any], bool]: +) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]: + """(kind, label, drawing, confirmed, quantity_table)를 반환한다. + + quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자 + 편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다. + """ manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {}) saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json" if manifest_entry.get("confirmed") and saved_path.is_file(): kind = "longitudinal" if drawing_id == "longitudinal" else "cross" label = str(manifest_entry.get("label") or drawing_id) - return kind, label, _read_json(saved_path), True + stored_table = manifest_entry.get("quantity_table") + table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None + return kind, label, _read_json(saved_path), True, table if drawing_id == "longitudinal": source = _read_json(longitudinal_path) return ( @@ -355,6 +275,7 @@ def _read_drawing( "종단도 전체", _cad_drawing(source, drawing_id, "longitudinal"), False, + None, ) if not _CROSS_ID.fullmatch(drawing_id): @@ -364,7 +285,7 @@ def _read_drawing( raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.") source = _read_json(path) label = str(source.get("label") or drawing_id) - return "cross", label, _cad_drawing(source, drawing_id, "cross"), False + return "cross", label, _cad_drawing(source, drawing_id, "cross"), False, _quantity_table(source) def _store_confirmed_drawing( @@ -372,6 +293,7 @@ def _store_confirmed_drawing( item: DesignDrawingItem, drawing: dict[str, Any], expected_ids: set[str], + quantity_table: dict[str, Any] | None = None, ) -> bool: if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list): raise ValueError("CAD 도면 스키마가 올바르지 않습니다.") @@ -383,12 +305,15 @@ def _store_confirmed_drawing( temporary.replace(path) manifest = _read_manifest(project_root) - manifest["drawings"][item.id] = { + entry: dict[str, Any] = { "kind": item.kind, "label": item.label, "confirmed": True, "file": f"drawings/{item.id}.json", } + if item.kind == "cross" and isinstance(quantity_table, dict): + entry["quantity_table"] = quantity_table + manifest["drawings"][item.id] = entry _write_manifest(project_root, manifest) confirmed_ids = { item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed") @@ -434,7 +359,7 @@ async def get_design_drawing( """선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다.""" try: route_id, project_root, longitudinal_path = await _confirmed_source(project_id) - kind, label, drawing, confirmed = await asyncio.to_thread( + kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread( _read_drawing, project_root, longitudinal_path, drawing_id ) return DesignDrawingResponse( @@ -445,6 +370,7 @@ async def get_design_drawing( label=label, drawing=drawing, confirmed=confirmed, + quantity_table=quantity_table, ) except ValueError as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) @@ -482,6 +408,7 @@ async def confirm_design_drawing( item, request.drawing, {candidate.id for candidate in items}, + request.quantity_table, ) pool = get_db_pool() diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py index 67fcb6d3..e12adfe4 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Schema.py @@ -35,12 +35,16 @@ class DesignDrawingResponse(BaseModel): label: str drawing: dict[str, Any] confirmed: bool = False + # 횡단도 편집용 수량 산출표 값 (미산정 항목은 null). 종단도는 None. + quantity_table: dict[str, float | None] | None = None class DesignDrawingConfirmRequest(BaseModel): """CAD 앱에서 직렬화한 현재 편집 도면.""" drawing: dict[str, Any] + # 사용자가 편집한 수량 산출표 값 (횡단도 확정 시 영구 저장). + quantity_table: dict[str, Any] | None = None class DesignDrawingConfirmResponse(BaseModel): diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts index 3083a19c..6b970ea1 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts @@ -35,6 +35,7 @@ import { type CadDrawing, type DesignDrawingItem, } from "./B07_wf4_DesignDetail_Api_Fetch"; +import { buildQuantityTable } from "./B07_wf4_DesignDetail_UI_QuantityTable"; /** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */ const B07_CAD_APP_URL = "/b07-cad/index.html"; @@ -181,6 +182,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { license.textContent = "Drawing engine based on OpenWebCAD · MIT License"; cadHost.append(frame, license); + // CAD 영역 + 하단 수량 산출표를 세로로 묶는 메인 콘텐츠 + const mainContent = document.createElement("div"); + mainContent.className = "b07-main-stack"; + mainContent.append(cadHost); + let cadReady = false; let pendingDrawing: CadDrawing | undefined; let currentDrawing: DesignDrawingItem | undefined; @@ -218,6 +224,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { 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; + } } catch (error) { cadHost.dataset.loading = "false"; cadHost.dataset.error = @@ -246,7 +257,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { showLoadingOverlay(); try { const drawing = await requestCadDrawing(); - const result = await confirmDesignDrawing(projectId, currentDrawing.id, drawing); + const result = await confirmDesignDrawing( + projectId, + currentDrawing.id, + drawing, + currentDrawing.kind === "cross" ? quantityTable.getValues() : null, + ); currentConfirmed = true; currentDrawing.confirmed = true; confirmButton.disabled = true; diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_QuantityTable.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_QuantityTable.ts new file mode 100644 index 00000000..3fdcb169 --- /dev/null +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_QuantityTable.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * 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 }; +}