From ae1e7e039a48bdafe7a3c1aead50df9a6658cc30 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 12:11:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20[=EC=83=81=EC=84=B8]=20=ED=91=9C?= =?UTF-8?q?=20=EC=86=90=EC=A7=88=20=3D=20=EC=B9=B8=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EA=B7=B8=EB=9E=98=ED=94=84=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0(=EB=A9=B4=EC=A0=81=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4)=20=C2=B7=20=EC=9E=90=EB=8F=99=EA=B0=92?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=C2=B7=20=ED=96=89=20=C2=B7=20=EC=97=B4=20?= =?UTF-8?q?=EB=8D=94=ED=95=A8=20/=20=EC=A7=80=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EC=84=A4=EA=B3=84=20patch=20detail=5Ftabl?= =?UTF-8?q?e=20=C2=B7=20=EC=84=9C=EB=B2=84=20=EA=B2=80=EC=82=B0=20=C2=B7?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20B07=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=ED=91=9C=20=ED=95=9C=20=EB=B2=8C=20(179-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- A00_Common/b_page_state.ts | 2 + B06_Section/B06_Section_Api_Fetch.ts | 8 +- B06_Section/B06_Section_Api_Types.ts | 17 + B06_Section/B06_Section_Cross_Refresh.ts | 2 + B06_Section/B06_Section_Router_CrossTable.py | 17 +- B06_Section/B06_Section_Router_Design.py | 2 + B06_Section/B06_Section_Schema.py | 36 ++ B06_Section/B06_Section_UI_Cross_Detail.ts | 98 ++++- .../B06_Section_UI_Cross_Detail_Table.ts | 412 ++++++++++++++++++ B06_Section/B06_Section_UI_Page_Persist.ts | 6 + .../B06_Section_UI_Style_Cross_Detail.css | 38 ++ .../B07_DesignDetail_Engine_Cad.py | 8 +- .../B07_DesignDetail_Engine_Cad_Sheet.py | 4 +- .../B07_DesignDetail_Engine_Cad_Table.py | 83 +++- ...07_DesignDetail_Engine_Cross_Table_Edit.py | 205 +++++++++ .../B07_DesignDetail_Router_Support.py | 3 + resources/tester/helper_164_detail_zoom.cjs | 1 + .../tester/helper_179_4_detail_table.cjs | 198 +++++++++ resources/tester/test_164_detail_zoom.py | 5 +- resources/tester/test_179_4_detail_table.py | 264 +++++++++++ 20 files changed, 1360 insertions(+), 49 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Detail_Table.ts create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py create mode 100644 resources/tester/helper_179_4_detail_table.cjs create mode 100644 resources/tester/test_179_4_detail_table.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 1861a4ed7..0319b43d2 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -110,6 +110,8 @@ export const STATE_REGISTRY = { /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ "structure-notes": { bucket: "draft", scope: "route" }, + /** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */ + "detail-table": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` }, /** 3D 램프로 바꾼 측점 상단측(측구 방향). */ diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 67266c563..011d68b79 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -294,9 +294,15 @@ export interface CrossQuantitySheet { layout: { header: Array<[string, string]>; left: Array<[string | null, string | null, string | null]>; - middle: Array<[string | null, string, string]>; + /** 더한 홀로 선 줄(179-4)은 하위 이름 없음 */ + middle: Array<[string | null, string | null, string]>; right: Array<[string | null, string | null]>; + /** 본문 줄 id(양식 b0~ · 더한 행 r…) · 더한 열(키 = 본문 줄마다) — 179-4 */ + rows: string[]; + columns: Array<{ id: string; label: string; keys: string[] }>; }; + /** 길이 출처가 설계선을 벗어난 칸 — 값 비움 · 「다시 고름」(179-4). */ + repick: string[]; } /** 캐시 설계로 B07 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */ diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index e59268017..f7922346e 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -471,6 +471,8 @@ export interface CrossDesign { pavement_suggested?: boolean; /** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */ structure_note?: string; + /** [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 서버가 다시 셈). */ + detail_table?: CrossDetailTable; /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ road_edges: { left: { offset_m: number; elevation_m: number }; @@ -617,4 +619,19 @@ export interface CrossSectionPatch { cut_rock_area_m2?: number; /** 측점 구조물 글(138-4) — 빈 글 = 지움. */ structure_note?: string; + /** [상세] 표 손질(179-4) — 세션 초안 그대로. */ + detail_table?: CrossDetailTable; +} + +/** [상세] 표 칸 출처(179-4) — area = 설계 면적 칸(표 키) · length = 두 끝 [offset, 표고](m) · text = 글. */ +export type CrossCellSource = + | { src: "area"; key: string } + | { src: "length"; start: [number, number]; end: [number, number] } + | { src: "text"; text: string }; + +/** [상세] 표 손질 — 칸 출처(표 키 →) · 더한 행(`after` 아래 · 없으면 끝) · 더한 열(오른쪽 끝). */ +export interface CrossDetailTable { + cells: Record; + rows: Array<{ id: string; after: string | null; grouped: boolean; labels: string[] }>; + columns: Array<{ id: string; label: string }>; } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index fe8f9b199..e8165778c 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -89,6 +89,8 @@ export const USER_TOUCHED_KEYS = [ "cut_slope_ratio_user", // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + // [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) + "detail_table", // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 "shoulder_extend_left_m", "shoulder_extend_right_m", diff --git a/B06_Section/B06_Section_Router_CrossTable.py b/B06_Section/B06_Section_Router_CrossTable.py index a8b47e5e8..e13d35579 100644 --- a/B06_Section/B06_Section_Router_CrossTable.py +++ b/B06_Section/B06_Section_Router_CrossTable.py @@ -3,6 +3,7 @@ 값은 B07 횡단도가 쓰는 `_quantity_table` 한 벌을 **브라우저 캐시 설계**로 돌린 것이다 — 화면이 TS 로 다시 짜지 않는다(CLAUDE.md 5장 ③ 서버 단독). 칸 배치도 B07 표 정의를 그대로 실어 보내 화면은 그리기만 한다. **표시 전용** — 정본을 쓰지 않는다. 모달을 열 때 부른다. +표 손질(179-4 · 고른 칸 · 더한 행 · 열)은 설계 `detail_table` 을 `_Engine_Cross_Table_Edit` 가 읽음. """ from __future__ import annotations @@ -27,6 +28,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( _CROSS_MIDDLE_ROWS, _CROSS_RIGHT_ROWS, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import edited_cells, table_layout from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -42,21 +44,22 @@ class CrossQuantityTableRequest(BaseModel): def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None) -> dict[str, Any]: - """B07 횡단 수량표 한 장 — 값(숫자) · 글(B07 표와 같은 꼴) · 칸 배치.""" + """B07 횡단 수량표 한 장 — 값(숫자) · 글(B07 표와 같은 꼴) · 칸 배치(+ 더한 행 · 열 179-4).""" table = _quantity_table(source, design) text = { key: _format(value) if isinstance(value, (int, float)) else "-" for key, value in table.items() } + _values, texts, repick = edited_cells(design) + text.update(texts) return { "quantity_table": table, "text": text, - "layout": { - "header": [list(row) for row in _CROSS_HEADER_KEYS], - "left": [list(row) for row in _CROSS_LEFT_ROWS], - "middle": [list(row) for row in _CROSS_MIDDLE_ROWS], - "right": [list(row) for row in _CROSS_RIGHT_ROWS], - }, + "layout": table_layout( + design, _CROSS_HEADER_KEYS, _CROSS_LEFT_ROWS, _CROSS_MIDDLE_ROWS, _CROSS_RIGHT_ROWS + ), + # 길이 출처가 설계선을 벗어난 칸 — 값 비움 · 화면 「다시 고름」 + "repick": repick, } diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index f3c644403..f2865ec17 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -154,6 +154,8 @@ USER_TOUCHED_KEYS = ( "cut_slope_ratio_user", # 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) + "detail_table", # 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`) "shoulder_extend_left_m", "shoulder_extend_right_m", diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 2a864e6b2..794f29ede 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -135,6 +135,40 @@ class BoxAdjustPatch(BaseModel): right: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch) +class CrossCellSource(BaseModel): + """[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text.""" + + src: Literal["area", "length", "text"] + key: str | None = Field(default=None, max_length=40) + start: list[float] | None = Field(default=None, min_length=2, max_length=2) + end: list[float] | None = Field(default=None, min_length=2, max_length=2) + text: str | None = Field(default=None, max_length=200) + + +class CrossDetailRow(BaseModel): + """[상세] 표 더한 행 — `after` 행 아래(없으면 끝) · grouped = 묶음 안 · 이름 좌 · 중 · 우.""" + + id: str = Field(..., pattern=r"^r\d{1,4}$") + after: str | None = Field(default=None, max_length=10) + grouped: bool = False + labels: list[str] = Field(default_factory=list, max_length=3) + + +class CrossDetailColumn(BaseModel): + """[상세] 표 더한 열 — 오른쪽 끝 · 모든 행.""" + + id: str = Field(..., pattern=r"^c\d{1,4}$") + label: str = Field(default="", max_length=40) + + +class CrossDetailTable(BaseModel): + """[상세] 표 손질(179-4) — 서버 `_Engine_Cross_Table_Edit` 가 읽어 모달 · B07 도면 표 한 벌.""" + + cells: dict[str, CrossCellSource] = Field(default_factory=dict, max_length=300) + rows: list[CrossDetailRow] = Field(default_factory=list, max_length=12) + columns: list[CrossDetailColumn] = Field(default_factory=list, max_length=4) + + class CrossSectionPatch(BaseModel): """확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" @@ -199,6 +233,8 @@ class CrossSectionPatch(BaseModel): ford_pipe_length_m: float | None = Field(default=None, ge=0) # 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움. structure_note: str | None = Field(default=None, max_length=2000) + # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈 + detail_table: CrossDetailTable | None = None class SectionConfirmRequest(BaseModel): diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 3d7e62700..4f52eab17 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -6,7 +6,8 @@ * 칸 크기가 바뀌면 다시 그림) · * 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 · * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · - * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). + * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) · + * 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). * 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) — * 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3). @@ -30,6 +31,7 @@ import { type ZoomPanState, } from "../A00_Common/b_svg_zoom_pan"; import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; +import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table"; import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ @@ -97,29 +99,78 @@ function cell( return element; } -/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우). */ +/** 더한 칸(179-4 · `r:` 행 · `c:` 열) — 값 없음 = 빈칸 · 글 손질. */ +const isAdded = (key: string): boolean => key.startsWith("r:") || key.startsWith("c:"); + +/** 손질 표시(179-4) — 값 칸 키 · 본문 줄 · 더한 열 · 글 손질 칸(처음 글 = `data-shown`). */ +function mark( + el: HTMLElement, + data: Record, + editable: boolean, +): HTMLElement { + for (const [name, value] of Object.entries(data)) + if (value !== undefined) el.dataset[name] = value; + if (editable) { + el.contentEditable = "plaintext-only"; + el.dataset.shown = el.textContent ?? ""; + } + return el; +} + +/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우) + * + 더한 행 · 오른쪽 끝 더한 열(179-4 · 서버 배치 그대로). */ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement { const { layout, text } = sheet; - const value = (key: string | null): string => (key ? (text[key] ?? "-") : ""); + const repick = new Set(sheet.repick ?? []); + const columns = layout.columns ?? []; + const rowIds = layout.rows ?? layout.left.map((_, index) => `b${index}`); + const value = (key: string | null): string => + !key ? "" : repick.has(key) ? "다시 고름" : (text[key] ?? (isAdded(key) ? "" : "-")); + const valueCell = (key: string | null, row?: string, col?: string): HTMLElement => { + const td = cell("td", value(key), undefined, key); + if (!key) return td; + if (repick.has(key)) td.dataset.repick = ""; + return mark(td, { cell: key, row, col }, isAdded(key)); + }; + // 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) + const nameCell = ( + label: string | null, + span: { col?: number; row?: number } | undefined, + key: string | null, + row: string, + slot: number, + ): HTMLElement => { + const added = row.startsWith("r"); + const data = { row, label: added ? `${row}:${slot}` : undefined }; + return mark(cell("th", label ?? "", span, key), data, added); + }; const table = document.createElement("table"); table.className = "b06-detail__qtable"; const titleRow = table.insertRow(); - titleRow.append(cell("th", title, { col: 8 })); + titleRow.append(cell("th", title, { col: 8 + columns.length })); const headRow = table.insertRow(); for (const [label, key] of layout.header) - headRow.append(cell("th", label, undefined, key), cell("td", value(key), undefined, key)); + headRow.append(cell("th", label, undefined, key), valueCell(key)); + for (const column of columns) + headRow.append(mark(cell("th", column.label), { col: column.id, colLabel: column.id }, true)); layout.left.forEach(([groupL, subL, keyL], index) => { + const id = rowIds[index]; const row = table.insertRow(); - // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) - if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }, keyL)); - else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }, keyL)); - if (subL !== null) row.append(cell("th", subL, undefined, keyL)); - row.append(cell("td", value(keyL), undefined, keyL)); + // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4) + if (groupL !== null && subL !== null) + row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)); + else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0)); + if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0)); + row.append(valueCell(keyL, id)); const [groupM, subM, keyM] = layout.middle[index]; - if (groupM !== null) row.append(cell("th", groupM, { row: 2 }, keyM)); - row.append(cell("th", subM, undefined, keyM), cell("td", value(keyM), undefined, keyM)); + if (groupM !== null && subM !== null) + row.append(nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)); + else if (groupM !== null) row.append(nameCell(groupM, { col: 2 }, keyM, id, 1)); + if (subM !== null) row.append(nameCell(subM, undefined, keyM, id, 1)); + row.append(valueCell(keyM, id)); const [labelR, keyR] = layout.right[index]; - row.append(cell("th", labelR ?? "", undefined, keyR), cell("td", value(keyR), undefined, keyR)); + row.append(nameCell(labelR, undefined, keyR, id, 2), valueCell(keyR, id)); + for (const column of columns) row.append(valueCell(column.keys[index], id, column.id)); }); return table; } @@ -260,6 +311,7 @@ export function openCrossDetail(section: CrossSection): void { // 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4) let zoom: ZoomPanState | undefined; + let axes: CrossAxesOptions | null = null; let drawnSize = ""; const resize = new ResizeObserver(() => state.paint()); resize.observe(cad); @@ -276,7 +328,8 @@ export function openCrossDetail(section: CrossSection): void { const drawn = drawChart(state.section, width, height); if (!drawn) cad.replaceChildren(document.createTextNode("그림 없음")); else { - const { clone, axes } = drawn; + const { clone } = drawn; + axes = drawn.axes; const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); } @@ -295,7 +348,7 @@ export function openCrossDetail(section: CrossSection): void { projectId, routeId, state.section.chainage_m, - state.section.design, + designForTable(state.section, projectId, routeId), ) .then((sheet) => { if (seq !== tableSeq || current !== state) return; @@ -312,8 +365,21 @@ export function openCrossDetail(section: CrossSection): void { forget(); close(); }, - stop: () => resize.disconnect(), + stop: () => { + resize.disconnect(); + detachTable(); + }, }; + // 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음 + const detachTable = attachDetailTable({ + tableBox, + cad, + section: () => state.section, + projectId: host?.projectId, + routeId: () => host?.routeId(), + axes: () => axes, + changed: () => state.loadTable(), + }); current = state; tableBox.textContent = "수량표 불러오는 중…"; state.paint(); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts new file mode 100644 index 000000000..4413240e1 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -0,0 +1,412 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Table.ts + * [상세] 수량표 손질(179-4) — 칸 우클릭 메뉴 · 그래프에서 고르기 · 행 · 열 더함 / 지움 · 세션 초안. + * + * 칸 출처 = 횡단 설계 patch `detail_table`(세션 초안 `detail-table` → [저장] patch → 서버). + * 값은 서버(`B07_DesignDetail_Engine_Cross_Table_Edit`)가 그릴 때마다 다시 셈 — 모달 · B07 도면 표 한 벌. + * 조작 직후 그 칸 글만 `localCellValue` 로 즉시 — 짝(① 파이썬 · TS) · 거울 시험 + * `resources/tester/test_179_4_detail_table.py`. + * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) · 길이 = 보라 판정 선 두 끝(화면 px → m · 축 옵션). + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import type { + CrossCellSource, + CrossDesign, + CrossDetailTable, + CrossSection, +} from "./B06_Section_Api_Fetch"; +import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; +import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; + +/** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ +export const MAX_ROWS = 12; +export const MAX_COLUMNS = 4; +export const LINE_TOLERANCE_M = 0.05; + +/* ── 세션 초안(측점키 0.01 m → 표 손질) ─────────────────────────────────── */ + +type Drafts = Record; +const tableKey = (chainageM: number): string => chainageM.toFixed(2); + +/** [저장]이 patch 로 싣는 표 손질 — 측점(m) → 표 손질. */ +export function readDetailTables( + projectId: string, + routeId: number, +): Map { + const raw = readState("detail-table", projectId, routeId) ?? {}; + return new Map(Object.entries(raw).map(([key, table]) => [Number(key), table])); +} + +/** 그 측점 표 손질 — 세션 초안 → 캐시 설계 → 빈 것(복사본). */ +export function detailTableOf( + section: CrossSection, + projectId?: string, + routeId?: number | null, +): CrossDetailTable { + const draft = + projectId && routeId !== null && routeId !== undefined + ? readState("detail-table", projectId, routeId)?.[tableKey(section.chainage_m)] + : undefined; + const table = draft ?? section.design?.detail_table; + return table ? structuredClone(table) : { cells: {}, rows: [], columns: [] }; +} + +/** 캐시 설계에도 얹음(모달 표 요청 · B05 [저장]이 같은 값을 실음) + 세션 초안. */ +export function writeDetailTable( + section: CrossSection, + table: CrossDetailTable, + projectId?: string, + routeId?: number | null, +): void { + if (section.design) section.design.detail_table = table; + if (!projectId || routeId === null || routeId === undefined) return; + const all = readState("detail-table", projectId, routeId) ?? {}; + all[tableKey(section.chainage_m)] = table; + writeState("detail-table", all, projectId, routeId); +} + +/** 표 요청에 실을 설계 — 표 손질을 얹음(카드가 다시 그려져 설계 객체가 바뀌어도 초안 그대로). */ +export function designForTable( + section: CrossSection, + projectId?: string, + routeId?: number | null, +): CrossDesign | undefined { + if (!section.design) return undefined; + return { ...section.design, detail_table: detailTableOf(section, projectId, routeId) }; +} + +/* ── 표 손질(순수) ──────────────────────────────────────────────────────── */ + +const nextId = (prefix: string, used: string[]): string => { + let n = 1; + while (used.includes(`${prefix}${n}`)) n += 1; + return `${prefix}${n}`; +}; + +export function setCell(table: CrossDetailTable, key: string, source: CrossCellSource | null) { + if (source) table.cells[key] = source; + else delete table.cells[key]; +} + +/** 행 더함 — `after` 줄 아래(null = 표 끝) · grouped = 그 줄 묶음 안. 새 id. */ +export function addRow(table: CrossDetailTable, after: string | null, grouped: boolean): string { + const id = nextId( + "r", + table.rows.map((row) => row.id), + ); + table.rows.push({ id, after, grouped, labels: ["", "", ""] }); + return id; +} + +export function addColumn(table: CrossDetailTable): string { + const id = nextId( + "c", + table.columns.map((column) => column.id), + ); + table.columns.push({ id, label: "" }); + return id; +} + +/** 더한 행 지움 — 그 행 칸 · 열 칸도 지우고 그 아래 붙던 행은 지운 행 자리로. */ +export function removeRow(table: CrossDetailTable, rowId: string): void { + const removed = table.rows.find((row) => row.id === rowId); + if (!removed) return; + table.rows = table.rows + .filter((row) => row.id !== rowId) + .map((row) => (row.after === rowId ? { ...row, after: removed.after } : row)); + for (const key of Object.keys(table.cells)) + if (key.startsWith(`r:${rowId}:`) || (key.startsWith("c:") && key.endsWith(`:${rowId}`))) + delete table.cells[key]; +} + +export function removeColumn(table: CrossDetailTable, columnId: string): void { + table.columns = table.columns.filter((column) => column.id !== columnId); + for (const key of Object.keys(table.cells)) + if (key.startsWith(`c:${columnId}:`)) delete table.cells[key]; +} + +export function setRowLabel(table: CrossDetailTable, rowId: string, slot: number, text: string) { + const row = table.rows.find((entry) => entry.id === rowId); + if (!row) return; + const labels = [0, 1, 2].map((index) => row.labels[index] ?? ""); + labels[slot] = text; + row.labels = labels; +} + +export function setColumnLabel(table: CrossDetailTable, columnId: string, text: string) { + const column = table.columns.find((entry) => entry.id === columnId); + if (column) column.label = text; +} + +/** 묶음 이름 칸이 덮는 줄 수 — 서버 `group_span` 과 같은 규칙. */ +export function groupSpan(slots: ReadonlyArray>, index: number) { + let span = 1; + while ( + index + span < slots.length && + slots[index + span][0] === null && + slots[index + span][1] !== null + ) + span += 1; + return span; +} + +/* ── 그래프 고르기 → 출처 ───────────────────────────────────────────────── */ + +/** 띠 · 사토장 클래스 → 표 키(서버 `AREA_KEYS` 의 표 키). */ +const AREA_BANDS: ReadonlyArray<[string, string]> = [ + ["b06-chart__area--cut_soil", "cut_soil"], + ["b06-chart__area--cut_rock", "cut_rock"], + ["b06-chart__area--fill", "embankment"], + ["b06-chart__spoil-fill", "spoil_fill"], +]; +export const AREA_PICK_SELECTOR = ".b06-chart__area, .b06-chart__spoil-fill"; +export const LENGTH_PICK_SELECTOR = ".b06-chart__design-hit"; + +export function areaSourceOf(target: Element): CrossCellSource | null { + const hit = target.closest(AREA_PICK_SELECTOR); + const band = hit && AREA_BANDS.find(([name]) => hit.classList.contains(name)); + return band ? { src: "area", key: band[1] } : null; +} + +const round4 = (value: number): number => Math.round(value * 1e4) / 1e4; + +/** 판정 선 두 끝 → [offset, 표고](m) — 도형 층 좌표(카드 `x` · `toDisplayY` 의 역). */ +export function lengthSourceOf(target: Element, axes: CrossAxesOptions): CrossCellSource | null { + const hit = target.closest(LENGTH_PICK_SELECTOR); + const points = (hit?.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)); + if (points.length < 2 || points.some((p) => p.length !== 2 || !p.every(Number.isFinite))) + return null; + const toM = ([px, py]: number[]): [number, number] => { + const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter; + return [ + round4(axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter), + round4(axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration), + ]; + }; + return { src: "length", start: toM(points[0]), end: toM(points[points.length - 1]) }; +} + +/* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */ + +const AREA_DESIGN: Readonly> = { + cut_soil: "cut_soil_area_m2", + cut_rock: "cut_rock_area_m2", + embankment: "fill_area_m2", + ditch_soil: "ditch_soil_area_m2", + ditch_rock: "ditch_rock_area_m2", + spoil_fill: "spoil_fill_area_m2", +}; + +const finite = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +function onLine([px, py]: [number, number], line: ReadonlyArray<[number, number]>): boolean { + for (let i = 0; i + 1 < line.length; i++) { + const [ax, ay] = line[i]; + const [bx, by] = line[i + 1]; + const dx = bx - ax; + const dy = by - ay; + const length2 = dx * dx + dy * dy; + const t = + length2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2)); + if (Math.hypot(px - (ax + t * dx), py - (ay + t * dy)) <= LINE_TOLERANCE_M) return true; + } + return false; +} + +/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null. */ +export function localCellValue(source: CrossCellSource, design: CrossDesign | undefined) { + const record = (design ?? {}) as unknown as Record; + if (source.src === "area") return finite(record[AREA_DESIGN[source.key] ?? ""]); + if (source.src !== "length") return null; + const line = (design?.design_line ?? []) + .map((p): [number, number] => [p.offset_m, p.elevation_m]) + .filter((p) => p.every(Number.isFinite)); + const { start, end } = source; + if (!onLine(start, line) || !onLine(end, line)) return null; + return Math.hypot(end[0] - start[0], end[1] - start[1]); +} + +/* ── 우클릭 메뉴 ───────────────────────────────────────────────────────── */ + +/** 우클릭한 칸 — 값 칸 키 · 본문 줄 id · 더한 열 id. */ +export interface CellTarget { + key: string | null; + rowId: string | null; + columnId: string | null; +} + +export interface CellActions { + pick: (kind: "area" | "length") => void; + auto: () => void; + addRow: (after: string | null, grouped: boolean) => void; + addColumn: () => void; + removeRow: (rowId: string) => void; + removeColumn: (columnId: string) => void; +} + +export function cellMenuItems( + table: CrossDetailTable, + target: CellTarget, + act: CellActions, +): MapContextMenuItem[] { + const items: MapContextMenuItem[] = []; + const { key, rowId, columnId } = target; + if (key) { + items.push( + ["그래프에서 고르기 — 면적", () => act.pick("area")], + ["그래프에서 고르기 — 길이", () => act.pick("length")], + ); + if (table.cells[key]) items.push(["자동값으로", act.auto]); + } + items.push("-"); + if (table.rows.length < MAX_ROWS) { + if (rowId) items.push(["행 더함 — 이 행 아래(묶음 안)", () => act.addRow(rowId, true)]); + items.push(["행 더함 — 표 끝", () => act.addRow(null, false)]); + } + if (table.columns.length < MAX_COLUMNS) items.push(["열 더함 — 오른쪽 끝", act.addColumn]); + items.push("-"); + if (rowId?.startsWith("r")) items.push(["이 행 지움", () => act.removeRow(rowId)]); + if (columnId) items.push(["이 열 지움", () => act.removeColumn(columnId)]); + return items; +} + +/* ── 모달 배선 ─────────────────────────────────────────────────────────── */ + +export interface DetailTableHost { + tableBox: HTMLElement; + cad: HTMLElement; + section: () => CrossSection; + projectId: string | undefined; + routeId: () => number | null | undefined; + axes: () => CrossAxesOptions | null; + /** 표 손질이 바뀜 — 서버 표를 다시 받음. */ + changed: () => void; +} + +let menu: ReturnType | null = null; + +const PICK_HINT = { + area: "면적 띠를 누름 · Esc 취소", + length: "보라 선을 누름 · Esc 취소", +} as const; + +/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. 돌려준 함수 = 떼기(모달 닫힐 때). */ +export function attachDetailTable(host: DetailTableHost): () => void { + menu ??= createMapContextMenu("b06-detail"); + const ctxMenu = menu; + if (!ctxMenu.element.isConnected) { + ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) · 모달 위 + document.body.append(ctxMenu.element); + } + const { tableBox, cad } = host; + const edit = (change: (table: CrossDetailTable) => void): void => { + const section = host.section(); + const table = detailTableOf(section, host.projectId, host.routeId()); + change(table); + writeDetailTable(section, table, host.projectId, host.routeId()); + host.changed(); + }; + + let picking: { kind: "area" | "length"; key: string } | null = null; + const stopPick = (): void => { + picking = null; + delete cad.dataset.pick; + delete cad.dataset.pickHint; + document.removeEventListener("keydown", onKey, true); + }; + const onKey = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.stopPropagation(); // 모달은 그대로 — 고르기만 취소 + stopPick(); + }; + const startPick = (kind: "area" | "length", key: string): void => { + picking = { kind, key }; + cad.dataset.pick = kind; + cad.dataset.pickHint = PICK_HINT[kind]; + document.addEventListener("keydown", onKey, true); + }; + const onPick = (event: MouseEvent): void => { + if (!picking) return; + const selector = picking.kind === "area" ? AREA_PICK_SELECTOR : LENGTH_PICK_SELECTOR; + // 겹친 요소(띠 위 판정 선 등) 아래까지 — 그 자리 맨 위 대상 + const hit = document + .elementsFromPoint(event.clientX, event.clientY) + .find((el) => cad.contains(el) && el.closest(selector)); + const axes = host.axes(); + const source = !hit + ? null + : picking.kind === "area" + ? areaSourceOf(hit) + : axes && lengthSourceOf(hit, axes); + if (!source) return; + event.stopPropagation(); + const { key } = picking; + stopPick(); + const shown = localCellValue(source, host.section().design); + const td = tableBox.querySelector(`td[data-cell="${CSS.escape(key)}"]`); + if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2); + edit((table) => setCell(table, key, source)); + }; + cad.addEventListener("click", onPick); + + const onMenu = (event: MouseEvent): void => { + const el = (event.target as Element).closest( + "[data-cell], [data-row], [data-col]", + ); + if (!el || !tableBox.contains(el)) return; + event.preventDefault(); + const table = detailTableOf(host.section(), host.projectId, host.routeId()); + const key = el.dataset.cell ?? null; + const items = cellMenuItems( + table, + { key, rowId: el.dataset.row ?? null, columnId: el.dataset.col ?? null }, + { + pick: (kind) => key && startPick(kind, key), + auto: () => key && edit((next) => setCell(next, key, null)), + addRow: (after, grouped) => edit((next) => void addRow(next, after, grouped)), + addColumn: () => edit((next) => void addColumn(next)), + removeRow: (rowId) => edit((next) => removeRow(next, rowId)), + removeColumn: (columnId) => edit((next) => removeColumn(next, columnId)), + }, + ); + ctxMenu.open(event.clientX, event.clientY, items); + }; + tableBox.addEventListener("contextmenu", onMenu); + + // 더한 칸 글 — 손 뗄 때(포커스 잃음) 한 번 · Enter = 끝 + const onBlur = (event: FocusEvent): void => { + const el = event.target as HTMLElement; + if (!el.isContentEditable) return; + const text = (el.textContent ?? "").trim(); + if (text === (el.dataset.shown ?? "")) return; // 안 고침 + const { label, colLabel, cell } = el.dataset; + if (label) { + const [rowId, slot] = label.split(":"); + edit((table) => setRowLabel(table, rowId, Number(slot), text)); + } else if (colLabel) edit((table) => setColumnLabel(table, colLabel, text)); + else if (cell) edit((table) => setCell(table, cell, text ? { src: "text", text } : null)); + }; + const onEnter = (event: KeyboardEvent): void => { + if (event.key === "Enter" && (event.target as HTMLElement).isContentEditable) { + event.preventDefault(); + (event.target as HTMLElement).blur(); + } + }; + tableBox.addEventListener("focusout", onBlur); + tableBox.addEventListener("keydown", onEnter); + + return () => { + stopPick(); + ctxMenu.close(); + cad.removeEventListener("click", onPick); + tableBox.removeEventListener("contextmenu", onMenu); + tableBox.removeEventListener("focusout", onBlur); + tableBox.removeEventListener("keydown", onEnter); + }; +} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 88398bdcf..12964ebaf 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -30,6 +30,7 @@ import { } from "./B06_Section_Structure_Layouts"; import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; +import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; @@ -393,6 +394,11 @@ export function collectSectionEdits(ctx: SectionPersistContext): { readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => { patchFor(chainage).structure_note = note; }); + // [상세] 표 손질(179-4) — 세션 초안 그대로 · 값은 서버가 그릴 때 다시 셈 + if (ctx.projectId && routeId !== null) + readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => { + patchFor(chainage).detail_table = table; + }); // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 서버가 [저장] 때 이 값으로 설계선을 다시 냄 readShoulderExtends(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => { const patch = patchFor(chainage); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index d5218d7e2..ab0354901 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -142,3 +142,41 @@ .b06-detail__cad .b06-chart__area > polygon { cursor: default; } + +/* 표 손질(179-4) — 더한 칸 글 손질 · 다시 고를 칸 · 우클릭 메뉴는 모달 위 */ +.b06-detail__qtable [contenteditable] { + cursor: text; + min-width: 2em; +} + +.b06-detail__qtable [contenteditable]:focus { + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} + +.b06-detail__qtable [data-repick] { + color: var(--color-danger); +} + +.b06-detail__context-menu.ui-ctx-menu { + z-index: 1001; +} + +/* 그래프에서 고르기 — 고를 대상만 손가락 · 왼쪽 아래(눈금 자리) 안내 글 */ +.b06-detail__cad[data-pick="area"] .b06-chart__area > polygon, +.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill, +.b06-detail__cad[data-pick="length"] .b06-chart__design-hit { + cursor: pointer; +} + +.b06-detail__cad[data-pick]::after { + content: attr(data-pick-hint); + position: absolute; + bottom: 4px; + left: 4px; + padding: 2px 8px; + border-radius: var(--radius-cards); + background: var(--color-accent); + color: var(--color-surface); + font-size: var(--text-caption); +} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index f6813dc6f..90e2c5594 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -546,7 +546,7 @@ def build_cross_drawing( # 아래에서 위로 — 표 → 글(150-3) → 간격 8 → 그림(174-1) stack = 0.0 if quantity_table is not None: - stack = cross_table_height() + cross_note_height(cross_note_lines(design)) + 8.0 + stack = cross_table_height(design) + cross_note_height(cross_note_lines(design)) + 8.0 oy = table_bottom + stack + own_half_height * CROSS_MM def paper(point: tuple[float, float]) -> tuple[float, float]: @@ -579,9 +579,11 @@ def build_cross_drawing( entities.extend(_cross_note_entities(drawing_id, note_lines, table_top, center_x)) table_top -= cross_note_height(note_lines) entities.extend( - _cross_table_entities(drawing_id, quantity_table, table_top, title_label, center_x) + _cross_table_entities( + drawing_id, quantity_table, table_top, title_label, center_x, design + ) ) - table_bottom = table_top - cross_table_height() + table_bottom = table_top - cross_table_height(design) # 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다. pad = CROSS_BORDER_PAD_MM diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 46818bbd6..7925859fe 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -97,7 +97,9 @@ def section_block_size( top = half_height + _BORDER_PAD_MM below = half_height + _BORDER_PAD_MM if with_table: - below += 8.0 + cross_note_height(cross_note_lines(design_spec)) + cross_table_height() + below += ( + 8.0 + cross_note_height(cross_note_lines(design_spec)) + cross_table_height(design_spec) + ) return (width, top + below) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index b92b8f5c3..3ce2de58b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -91,14 +91,18 @@ _CROSS_TABLE_FONT = 2.0 # 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백) _CROSS_COLUMN_WEIGHTS: tuple[float, ...] = (1.0, 1.7, 2.1, 1.0, 1.9, 1.4, 2.1, 1.0, 2.6, 2.1, 1.0) +# [상세] 표에서 더한 열 하나의 비율(179-4 · 표 폭은 그대로 · 본문 열이 그만큼 좁아짐) +_CROSS_EXTRA_COLUMN_WEIGHT = 2.1 -def _cross_column_edges(width: float, center_x: float = 0.0) -> list[float]: - total = sum(_CROSS_COLUMN_WEIGHTS) +def _cross_column_edges( + width: float, center_x: float = 0.0, weights: tuple[float, ...] = _CROSS_COLUMN_WEIGHTS +) -> list[float]: + total = sum(weights) left = center_x - width / 2.0 edges = [left] accumulated = 0.0 - for weight in _CROSS_COLUMN_WEIGHTS: + for weight in weights: accumulated += weight edges.append(left + width * accumulated / total) return edges @@ -128,25 +132,41 @@ def _cross_table_entities( table_top: float, title_label: str, center_x: float = 0.0, + design: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다. 머리행은 폭을 8등분하고 본문은 11열 가중치를 쓴다 — 격자가 서로 다르므로 두 경계를 합친 열로 표를 만들고 병합으로 원래 칸을 되살린다. 병합 자리에서 선을 끊는 일은 표가 스스로 하므로, 예전처럼 가로선을 손으로 끊지 않는다. + [상세] 표 손질(179-4 · `design.detail_table`) — 더한 행은 본문에 끼고 더한 열은 오른쪽 끝. """ + from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import ( + edited_cells, + group_span, + table_layout, + ) + + layout = table_layout( + design, _CROSS_HEADER_KEYS, _CROSS_LEFT_ROWS, _CROSS_MIDDLE_ROWS, _CROSS_RIGHT_ROWS + ) + texts = edited_cells(design)[1] + left_rows, middle_rows, right_rows = layout["left"], layout["middle"], layout["right"] + extra = layout["columns"] width = _CROSS_TABLE_WIDTH row_h = _CROSS_TABLE_ROW_HEIGHT font = _CROSS_TABLE_FONT left = center_x - width / 2.0 right = center_x + width / 2.0 - body_edges = _cross_column_edges(width, center_x) - header_cell = width / 8.0 + weights = _CROSS_COLUMN_WEIGHTS + (_CROSS_EXTRA_COLUMN_WEIGHT,) * len(extra) + body_edges = _cross_column_edges(width, center_x, weights) + # 머리행 8등분은 납품 양식 몫(더한 열 앞)만 — 더한 열이 없으면 표 폭 그대로 + header_cell = width * sum(_CROSS_COLUMN_WEIGHTS) / sum(weights) / 8.0 header_edges = [left + header_cell * index for index in range(9)] edges = _union_edges(body_edges, header_edges) column_widths = [edges[i + 1] - edges[i] for i in range(len(edges) - 1)] - body_rows = len(_CROSS_LEFT_ROWS) + body_rows = len(left_rows) rows = body_rows + 2 # 제목행 + 머리행 + 본문 cells: list[list[dict[str, Any] | None]] = [[None] * len(column_widths) for _ in range(rows)] @@ -176,8 +196,13 @@ def _cross_table_entities( cells[row][column] = cell def value_of(key: str) -> str: + if key in texts: + return texts[key] value = quantity_table.get(key) - return _format(value) if isinstance(value, (int, float)) else "-" + if isinstance(value, (int, float)): + return _format(value) + # 더한 칸(179-4 · `r:` 행 · `c:` 열)은 빈칸 · 양식 칸은 「-」 + return "" if key[:2] in ("r:", "c:") else "-" # ── 제목행 (No.측점) — 전체 병합 put(0, left, right, title_label, TABLE_LABEL_COLOR, align="left", font_size=font * 1.15) @@ -187,14 +212,18 @@ def _cross_table_entities( x0 = left + header_cell * pair_index * 2 put(1, x0, x0 + header_cell, label, TABLE_LABEL_COLOR) put(1, x0 + header_cell, x0 + header_cell * 2, value_of(key), TABLE_VALUE_COLOR, key=key) + # 더한 열 머리 = 열 이름 + for index, column in enumerate(extra): + put(1, body_edges[11 + index], body_edges[12 + index], column["label"], TABLE_LABEL_COLOR) - # ── 본문 6행 (좌/중/우 그룹) + # ── 본문 6행 (좌/중/우 그룹) + 더한 행 for row_index in range(body_rows): row = row_index + 2 - group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index] + group_l, sub_l, key_l = left_rows[row_index] if group_l is not None and sub_l is not None: - # 그룹 라벨은 아래 행까지 두 행 병합 - put(row, body_edges[0], body_edges[1], group_l, TABLE_LABEL_COLOR, row_span=2) + # 그룹 라벨은 하위 줄까지 병합(양식 = 두 행 · 더한 하위 줄만큼 늘어남) + span = group_span(left_rows, row_index) + put(row, body_edges[0], body_edges[1], group_l, TABLE_LABEL_COLOR, row_span=span) elif group_l is not None: # 하위 라벨이 없으면 그룹 열과 하위 열을 합친다 put(row, body_edges[0], body_edges[2], group_l, TABLE_LABEL_COLOR) @@ -203,17 +232,31 @@ def _cross_table_entities( if key_l is not None: put(row, body_edges[2], body_edges[3], value_of(key_l), TABLE_VALUE_COLOR, key=key_l) - group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index] - if group_m is not None: - put(row, body_edges[4], body_edges[5], group_m, TABLE_LABEL_COLOR, row_span=2) - put(row, body_edges[5], body_edges[6], sub_m, TABLE_LABEL_COLOR) + group_m, sub_m, key_m = middle_rows[row_index] + if group_m is not None and sub_m is not None: + span = group_span(middle_rows, row_index) + put(row, body_edges[4], body_edges[5], group_m, TABLE_LABEL_COLOR, row_span=span) + elif group_m is not None: + put(row, body_edges[4], body_edges[6], group_m, TABLE_LABEL_COLOR) + if sub_m is not None: + put(row, body_edges[5], body_edges[6], sub_m, TABLE_LABEL_COLOR) put(row, body_edges[6], body_edges[7], value_of(key_m), TABLE_VALUE_COLOR, key=key_m) - label_r, key_r = _CROSS_RIGHT_ROWS[row_index] + label_r, key_r = right_rows[row_index] if label_r is not None: put(row, body_edges[8], body_edges[9], label_r, TABLE_LABEL_COLOR) if key_r is not None: put(row, body_edges[9], body_edges[10], value_of(key_r), TABLE_VALUE_COLOR, key=key_r) + for index, column in enumerate(extra): + column_key = column["keys"][row_index] + put( + row, + body_edges[11 + index], + body_edges[12 + index], + value_of(column_key), + TABLE_VALUE_COLOR, + key=column_key, + ) return [ table_entity( @@ -362,6 +405,8 @@ def cross_table_width() -> float: return _CROSS_TABLE_WIDTH -def cross_table_height() -> float: - """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행.""" - return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2) +def cross_table_height(design: dict[str, Any] | None = None) -> float: + """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행(+ [상세]에서 더한 행 · 179-4).""" + from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import added_row_count + + return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2 + added_row_count(design)) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py new file mode 100644 index 000000000..bcdb964c2 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -0,0 +1,205 @@ +"""횡단 수량표 손질(179-4) — 횡단 설계 patch `detail_table` → 칸 값 · 표 배치 한 벌. + +모달(`B06_Section_Router_CrossTable`)과 B07 도면 표(`_Engine_Cad_Table`)가 같은 함수를 쓴다. + +칸 출처(`detail_table.cells` · 표 키 → 출처) — 그릴 때마다 서버가 다시 셈(브라우저 값 안 받음): + area = 설계 면적 칸 그대로(`AREA_KEYS` · 띠 키 = 표 키) + length = 두 끝(m · offset · 표고)이 설계선 위면 hypot · 아니면 비움 → 「다시 고름」 + text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만 +행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값). +열(`columns`) = 오른쪽 끝 · 모든 행(비고 등). + +짝(① 파이썬 · TS) = `B06_Section/B06_Section_UI_Cross_Detail_Table.ts` `localCellValue` +(조작 중 즉시 표시) · 거울 시험 `resources/tester/test_179_4_detail_table.py`. +""" + +from __future__ import annotations + +from math import hypot +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import AREA_KEYS + +_AREA_DESIGN = dict(AREA_KEYS) +#: 두 끝이 설계선에서 이만큼(m) 안이면 「선 위」 +LINE_TOLERANCE_M = 0.05 +#: 더한 행 · 열 상한 — 도면 표가 장 칸을 넘지 않게 +MAX_ROWS = 12 +MAX_COLUMNS = 4 + +Slot = tuple[str | None, str | None, str | None] + + +def _edit(design: dict[str, Any] | None) -> dict[str, Any]: + edit = design.get("detail_table") if isinstance(design, dict) else None + return edit if isinstance(edit, dict) else {} + + +def _num(value: Any) -> float | None: + return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None + + +def _point(value: Any) -> tuple[float, float] | None: + if not isinstance(value, list) or len(value) != 2: + return None + x, y = _num(value[0]), _num(value[1]) + return (x, y) if x is not None and y is not None else None + + +def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: + points = [] + for point in design.get("design_line") or []: + if isinstance(point, dict): + x, y = _num(point.get("offset_m")), _num(point.get("elevation_m")) + if x is not None and y is not None: + points.append((x, y)) + return points + + +def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> bool: + """점이 설계선(꺾은선) 조각 하나에서 `LINE_TOLERANCE_M` 안인가.""" + px, py = point + for (ax, ay), (bx, by) in zip(line, line[1:]): + dx, dy = bx - ax, by - ay + length2 = dx * dx + dy * dy + t = 0.0 if length2 == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length2)) + if hypot(px - (ax + t * dx), py - (ay + t * dy)) <= LINE_TOLERANCE_M: + return True + return False + + +def cell_value( + source: dict[str, Any], design: dict[str, Any] | None +) -> tuple[float | None, str | None, bool]: + """출처 하나 → (숫자, 글, 다시 고름).""" + design = design if isinstance(design, dict) else {} + kind = source.get("src") + if kind == "area": + return _num(design.get(_AREA_DESIGN.get(str(source.get("key")), ""))), None, False + if kind == "length": + start, end = _point(source.get("start")), _point(source.get("end")) + line = _design_line(design) + if start and end and _on_line(start, line) and _on_line(end, line): + return hypot(end[0] - start[0], end[1] - start[1]), None, False + return None, None, True + if kind == "text": + text = source.get("text") + return None, text if isinstance(text, str) else "", False + return None, None, False + + +def edited_cells( + design: dict[str, Any] | None, +) -> tuple[dict[str, float | None], dict[str, str], list[str]]: + """patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만.""" + values: dict[str, float | None] = {} + texts: dict[str, str] = {} + repick: list[str] = [] + cells = _edit(design).get("cells") + for key, source in (cells if isinstance(cells, dict) else {}).items(): + if not isinstance(source, dict): + continue + number, text, again = cell_value(source, design) + if text is not None: + if key[:2] in ("r:", "c:"): + texts[key] = text + continue + values[key] = number + if again: + repick.append(key) + return values, texts, repick + + +def _labels(row: dict[str, Any]) -> list[str]: + raw = row.get("labels") + raw = raw if isinstance(raw, list) else [] + return [str(raw[i]) if i < len(raw) and isinstance(raw[i], str) else "" for i in range(3)] + + +def _added_rows(edit: dict[str, Any]) -> list[dict[str, Any]]: + """쓸 수 있는 더한 행 — id = 「r…」 · 겹친 id 는 앞 것만 · 상한 `MAX_ROWS`.""" + raw = edit.get("rows") + rows: list[dict[str, Any]] = [] + for row in raw if isinstance(raw, list) else []: + row_id = row.get("id") if isinstance(row, dict) else None + if ( + isinstance(row_id, str) + and row_id.startswith("r") + and all(r["id"] != row_id for r in rows) + ): + rows.append(row) + return rows[:MAX_ROWS] + + +def added_row_count(design: dict[str, Any] | None) -> int: + """더한 행 수 — 도면 표 높이(`cross_table_height`)가 씀.""" + return len(_added_rows(_edit(design))) + + +def _slot(anchor: Slot | None, label: str, key: str) -> Slot: + """묶음(하위 이름이 있는 줄) 아래면 하위 줄 · 아니면 홀로 선 이름.""" + if anchor is not None and anchor[1] is not None: + return (None, label, key) + return (label, None, key) + + +def table_layout( + design: dict[str, Any] | None, + header: tuple[tuple[str, str], ...], + left: tuple[Slot, ...], + middle: tuple[Slot, ...], + right: tuple[tuple[str | None, str | None], ...], +) -> dict[str, Any]: + """납품 양식(머리 넷 쌍 + 본문 좌 · 중 · 우) + 더한 행 · 열 = 실제 표 배치.""" + edit = _edit(design) + rows: list[tuple[str, Slot, Slot, tuple[str | None, str | None]]] = [ + (f"b{index}", left[index], middle[index], right[index]) for index in range(len(left)) + ] + for row in _added_rows(edit): + row_id = row["id"] + labels = _labels(row) + index = next((i for i, r in enumerate(rows) if r[0] == row.get("after")), None) + anchor = rows[index] if index is not None and row.get("grouped") else None + rows.insert( + len(rows) if index is None else index + 1, + ( + row_id, + _slot(anchor[1] if anchor else None, labels[0], f"r:{row_id}:0"), + _slot(anchor[2] if anchor else None, labels[1], f"r:{row_id}:1"), + (labels[2], f"r:{row_id}:2"), + ), + ) + columns = [] + raw_columns = edit.get("columns") + for column in (raw_columns if isinstance(raw_columns, list) else [])[:MAX_COLUMNS]: + column_id = column.get("id") if isinstance(column, dict) else None + if not isinstance(column_id, str) or not column_id: + continue + label = column.get("label") + columns.append( + { + "id": column_id, + "label": label if isinstance(label, str) else "", + "keys": [f"c:{column_id}:{r[0]}" for r in rows], + } + ) + return { + "header": [list(pair) for pair in header], + "left": [list(r[1]) for r in rows], + "middle": [list(r[2]) for r in rows], + "right": [list(r[3]) for r in rows], + "rows": [r[0] for r in rows], + "columns": columns, + } + + +def group_span(slots: list[list[Any]] | list[Slot], index: int) -> int: + """묶음 이름 칸이 덮는 줄 수 — 이 줄 + 뒤따르는 하위 줄(이름 없음 · 하위 이름 있음).""" + span = 1 + while ( + index + span < len(slots) + and slots[index + span][0] is None + and slots[index + span][1] is not None + ): + span += 1 + return span diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b42a95f53..18774439f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -43,6 +43,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( QUANTITY_VALUE_KEYS, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import derived_cells +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import edited_cells from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields # 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04). @@ -299,6 +300,8 @@ def _quantity_table( table.update(derived_cells(chainage, design)) for key in QUANTITY_VALUE_KEYS: table.setdefault(key, num(quantities.get(key))) + # [상세] 표 손질(179-4) — 고른 칸 · 더한 칸을 서버가 다시 셈(모달 · B07 도면 한 벌) + table.update(edited_cells(design)[0]) return table diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index 75bdba7bb..9cb7ffa3f 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -24,6 +24,7 @@ const svgElement = (tag, attrs = {}) => { const svgText = (text, attrs) => Object.assign(svgElement("text", attrs), { textContent: text }); const STUBS = { "@ui/ui_template_modal": { openModal: () => {} }, + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, "../A00_Common/b_page_state": {}, "./B06_Section_Api_Fetch": {}, "./B06_Section_UI_Section_Common": { diff --git a/resources/tester/helper_179_4_detail_table.cjs b/resources/tester/helper_179_4_detail_table.cjs new file mode 100644 index 000000000..80bf13535 --- /dev/null +++ b/resources/tester/helper_179_4_detail_table.cjs @@ -0,0 +1,198 @@ +/* PLAN 179-4 단위검증 헬퍼 — [상세] 표 손질(`B06_Section_UI_Cross_Detail_Table.ts`). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, sources} · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_179_4_detail_table.py`(파이썬 `cell_value` 거울). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); + +// 세션 저장소 — 진짜 b_page_state 를 그대로 돌림 +const store = () => { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +}; +globalThis.window = { sessionStorage: store(), localStorage: store() }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts")); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; + +// ① 칸 값 거울 — 같은 출처 · 설계를 파이썬 `cell_value` 와 견줌 +out.local = input.sources.map((source) => T.localCellValue(source, input.design)); + +// ② 그래프 고르기 — 띠 → 표 키 · 판정 선 화면 px → m(카드 x · toDisplayY 의 역) +const fake = (classes, attrs = {}) => { + const self = { + classList: { contains: (name) => classes.includes(name) }, + getAttribute: (name) => attrs[name] ?? null, + closest: (selector) => + selector.split(",").some((part) => classes.includes(part.trim().slice(1))) ? self : null, + }; + return self; +}; +out.area = { + cut_soil: T.areaSourceOf(fake(["b06-chart__area", "b06-chart__area--cut_soil"])), + cut_rock: T.areaSourceOf( + fake(["b06-chart__area", "b06-chart__area--cut_rock", "b06-chart__area--cut_rr"]), + ), + fill: T.areaSourceOf(fake(["b06-chart__area", "b06-chart__area--fill"])), + spoil: T.areaSourceOf(fake(["b06-chart__spoil-fill"])), + other: T.areaSourceOf(fake(["b06-chart__ground"])), +}; +const AXES = { + widthPx: 600, + heightPx: 300, + pixelsPerMeter: 10, + maxOffset: 24, + displayMax: 120, + elevationMid: 110, + exaggeration: 2, +}; +const px = ([offset, elevation]) => [ + CROSS_PAD.left + (AXES.maxOffset - offset) * AXES.pixelsPerMeter, + CROSS_PAD.top + + (AXES.displayMax - (AXES.elevationMid + (elevation - AXES.elevationMid) * AXES.exaggeration)) * + AXES.pixelsPerMeter, +]; +const points = input.pick + .map(px) + .map((p) => p.join(",")) + .join(" "); +out.length = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points }), AXES); +out.lengthBad = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points: "1,2" }), AXES); +out.lengthValue = T.localCellValue(out.length, input.design); + +// ③ 우클릭 메뉴 항목 +const noop = () => {}; +const act = { + pick: noop, + auto: noop, + addRow: noop, + addColumn: noop, + removeRow: noop, + removeColumn: noop, +}; +const labels = (items) => items.map((item) => (typeof item === "string" ? item : item[0])); +const empty = { cells: {}, rows: [], columns: [] }; +out.menuHeader = labels( + T.cellMenuItems(empty, { key: "ground", rowId: null, columnId: null }, act), +); +out.menuBase = labels( + T.cellMenuItems( + { ...empty, cells: { cut_soil: { src: "area", key: "embankment" } } }, + { key: "cut_soil", rowId: "b0", columnId: null }, + act, + ), +); +out.menuAdded = labels( + T.cellMenuItems( + { + cells: {}, + rows: [{ id: "r1", after: "b0", grouped: true, labels: [] }], + columns: [{ id: "c1", label: "" }], + }, + { key: "c:c1:r1", rowId: "r1", columnId: "c1" }, + act, + ), +); +const full = { + cells: {}, + rows: Array.from({ length: T.MAX_ROWS }, (_, i) => ({ + id: `r${i + 1}`, + after: null, + grouped: false, + labels: [], + })), + columns: Array.from({ length: T.MAX_COLUMNS }, (_, i) => ({ id: `c${i + 1}`, label: "" })), +}; +out.menuFull = labels(T.cellMenuItems(full, { key: "fence", rowId: "b0", columnId: null }, act)); +let picked = null; +T.cellMenuItems( + empty, + { key: "fence", rowId: "b0", columnId: null }, + { + ...act, + pick: (kind) => (picked = kind), + }, +).find((item) => item[0] === "그래프에서 고르기 — 길이")[1](); +out.menuPicks = picked; + +// ④ 표 손질 → 세션 초안 → patch(Persist 가 싣는 꼴) +const section = { station_id: "s40", chainage_m: 40, design: { ...input.design } }; +const table = T.detailTableOf(section, "p1", 7); +out.fresh = JSON.parse(JSON.stringify(table)); +T.setCell(table, "cut_soil", { src: "area", key: "embankment" }); +T.setCell(table, "fence", out.length); +const r1 = T.addRow(table, "b0", true); +const r2 = T.addRow(table, null, false); +const r3 = T.addRow(table, r1, true); +const c1 = T.addColumn(table); +T.setRowLabel(table, r1, 0, "풍화암"); +T.setColumnLabel(table, c1, "비고"); +T.setCell(table, `c:${c1}:${r1}`, { src: "text", text: "L형" }); +T.setCell(table, `r:${r2}:2`, { src: "area", key: "cut_rock" }); +T.writeDetailTable(section, table, "p1", 7); +out.ids = [r1, r2, r3, c1]; +out.cacheHas = section.design.detail_table === table; +out.drafts = Object.fromEntries(T.readDetailTables("p1", 7)); +out.forTable = T.designForTable({ ...section, design: { paved: true } }, "p1", 7).detail_table; +// 지움 — r1 을 지우면 r3 은 r1 자리(b0 아래)로 · 그 행 열 칸도 지움 +const removed = T.detailTableOf(section, "p1", 7); +T.removeRow(removed, r1); +T.removeColumn(removed, c1); +out.removed = removed; +out.autoBack = (() => { + const back = T.detailTableOf(section, "p1", 7); + T.setCell(back, "cut_soil", null); + return Object.keys(back.cells); +})(); +out.span = [ + T.groupSpan( + [ + ["깍기", "토사", "a"], + [null, "x", "r"], + [null, "암석", "b"], + ["쌓기", null, "c"], + ], + 0, + ), + T.groupSpan( + [ + ["쌓기", null, "c"], + ["층따기", null, "d"], + ], + 0, + ), +]; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index c8b59ecfc..e85518359 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -120,8 +120,9 @@ def test_빈칸_누름_없음_이름_칸_첫_키(): src = _read("B06_Section_UI_Cross_Detail.ts") assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src assert 'if (!key || el.hasAttribute("data-empty")) return;' in src - assert 'cell("th", groupL, { row: 2 }, keyL)' in src - assert 'cell("th", groupM, { row: 2 }, keyM)' in src + # 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 + assert "nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)" in src + assert "nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)" in src def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만(): diff --git a/resources/tester/test_179_4_detail_table.py b/resources/tester/test_179_4_detail_table.py new file mode 100644 index 000000000..a513fc37e --- /dev/null +++ b/resources/tester/test_179_4_detail_table.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +"""PLAN 179-4 — B06 [상세] 표 손질(그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움). + +못박는 것 + - 칸 값 짝(① 파이썬 `cell_value` ↔ TS `localCellValue`) 거울 · 면적 → 설계 칸 값 · 길이 = hypot. + - 길이 검산 — 두 끝이 설계선 밖이면 값 비움 + 「다시 고름」(브라우저 값 안 받음). + - 그래프 고르기(띠 → 표 키 · 판정 선 px → m) · 우클릭 메뉴 항목 · 세션 초안 → patch → 스키마. + - 살림 목록 짝(RD ↔ CR)에 `detail_table` · 표 배치 한 벌(모달 sheet · B07 도면 표 · 높이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_179_4_detail_table.py -q +""" + +import json +import os +import re +import subprocess +from math import hypot +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from B06_Section import B06_Section_Router_CrossTable as table_mod +from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( + _cross_table_entities, + cross_table_height, + extract_quantity_table, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +LINE = [(-6.0, 104.0), (-1.0, 110.0), (1.0, 110.0), (5.0, 108.0), (9.0, 112.0)] +DESIGN = { + "design_elevation_m": 110.0, + "cut_soil_area_m2": 1.234, + "cut_rock_area_m2": 0.5, + "fill_area_m2": 2.25, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], +} +SOURCES = [ + {"src": "area", "key": "cut_soil"}, + {"src": "area", "key": "embankment"}, + {"src": "area", "key": "spoil_fill"}, # 설계에 없음 → 빈 값 + {"src": "length", "start": [5.0, 108.0], "end": [1.0, 110.0]}, + {"src": "length", "start": [3.0, 109.0], "end": [9.0, 112.0]}, # 조각 가운데 점도 선 위 + {"src": "length", "start": [5.0, 108.5], "end": [1.0, 110.0]}, # 0.5 m 벗어남 + {"src": "length", "start": [5.0, 108.0], "end": [1.0, 110.04]}, # 허용 0.05 m 안 + {"src": "text", "text": "L형"}, +] +PICK = [[5.0, 108.0], [1.0, 110.0]] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_179_4_detail_table.cjs")], + input=json.dumps({"design": DESIGN, "sources": SOURCES, "pick": PICK}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 칸 값 짝 · 검산 ────────────────────────────────────────────────────── + + +def test_칸_값_파이썬_TS_거울(out): + for source, local in zip(SOURCES, out["local"], strict=True): + number, _text, _again = cell_value(source, DESIGN) + if number is None: + assert local is None, source + else: + assert local == pytest.approx(number), source + + +def test_면적_출처는_설계_칸_값_길이는_hypot(): + assert cell_value(SOURCES[0], DESIGN) == (1.234, None, False) + assert cell_value(SOURCES[1], DESIGN) == (2.25, None, False) # 쌓기 = fill_area_m2 + assert cell_value(SOURCES[2], DESIGN) == (None, None, False) + assert cell_value(SOURCES[3], DESIGN)[0] == pytest.approx(hypot(4.0, 2.0)) + assert cell_value(SOURCES[4], DESIGN)[0] == pytest.approx(hypot(6.0, 3.0)) + assert cell_value(SOURCES[7], DESIGN) == (None, "L형", False) + + +def test_길이_검산_설계선_밖이면_비우고_다시_고름(): + assert cell_value(SOURCES[5], DESIGN) == (None, None, True) + assert cell_value(SOURCES[6], DESIGN)[0] == pytest.approx(hypot(4.0, 2.04)) + # 설계선이 바뀌면(옛 두 끝이 선 밖) 다시 고름 + moved = {**DESIGN, "design_line": [{"offset_m": x, "elevation_m": y + 1} for x, y in LINE]} + assert cell_value(SOURCES[3], moved) == (None, None, True) + assert cell_value(SOURCES[3], {**DESIGN, "design_line": []})[2] is True + + +# ── 그래프 고르기 · 메뉴 ───────────────────────────────────────────────── + + +def test_그래프_고르기_띠_키_판정_선_m(out): + area = out["area"] + assert area["cut_soil"] == {"src": "area", "key": "cut_soil"} + assert area["cut_rock"] == {"src": "area", "key": "cut_rock"} + assert area["fill"] == {"src": "area", "key": "embankment"} + assert area["spoil"] == {"src": "area", "key": "spoil_fill"} + assert area["other"] is None + length = out["length"] + assert length["src"] == "length" + assert length["start"] == pytest.approx(PICK[0]) and length["end"] == pytest.approx(PICK[1]) + assert out["lengthBad"] is None + assert out["lengthValue"] == pytest.approx(hypot(4.0, 2.0)) + + +def test_우클릭_메뉴_항목(out): + pick = ["그래프에서 고르기 — 면적", "그래프에서 고르기 — 길이"] + add = ["행 더함 — 표 끝", "열 더함 — 오른쪽 끝"] + assert out["menuHeader"] == [*pick, "-", *add, "-"] # 머리 칸 = 줄 없음 · 자동값 없음 + assert out["menuBase"] == [ + *pick, + "자동값으로", + "-", + "행 더함 — 이 행 아래(묶음 안)", + *add, + "-", + ] + assert out["menuAdded"][-2:] == ["이 행 지움", "이 열 지움"] + assert "자동값으로" not in out["menuAdded"] + # 상한(행 12 · 열 4)이면 더함 없음 + assert not any(label.startswith(("행 더함", "열 더함")) for label in out["menuFull"]) + assert out["menuPicks"] == "length" + + +# ── 세션 초안 → patch → 스키마 → 서버 표 한 벌 ──────────────────────────── + + +def test_세션_초안_patch_스키마_통과(out): + assert out["fresh"] == {"cells": {}, "rows": [], "columns": []} + assert out["ids"] == ["r1", "r2", "r3", "c1"] + assert out["cacheHas"] is True + draft = out["drafts"]["40"] + assert draft == out["forTable"] # 표 요청 설계에도 초안 그대로 + patch = CrossSectionPatch(chainage_m=40.0, detail_table=draft).model_dump() + table = patch["detail_table"] + assert [row["id"] for row in table["rows"]] == ["r1", "r2", "r3"] + assert table["rows"][0]["labels"] == ["풍화암", "", ""] + assert table["columns"] == [{"id": "c1", "label": "비고"}] + assert table["cells"]["c:c1:r1"]["text"] == "L형" + + +def test_지움_아래_행은_지운_행_자리로_열_칸도_지움(out): + removed = out["removed"] + assert [(row["id"], row["after"]) for row in removed["rows"]] == [("r2", None), ("r3", "b0")] + assert removed["columns"] == [] + assert not any(key.startswith(("r:r1:", "c:")) for key in removed["cells"]) + assert "cut_soil" not in out["autoBack"] and "fence" in out["autoBack"] + assert out["span"] == [3, 1] + + +@pytest.mark.parametrize( + "bad", + [ + {"rows": [{"id": "x1"}]}, + {"columns": [{"id": "r1"}]}, + {"cells": {"cut_soil": {"src": "volume"}}}, + {"cells": {"fence": {"src": "length", "start": [1.0], "end": [2.0, 3.0]}}}, + {"rows": [{"id": f"r{i}"} for i in range(1, 14)]}, + {"columns": [{"id": f"c{i}"} for i in range(1, 6)]}, + ], +) +def test_스키마가_틀린_표를_거름(bad): + with pytest.raises(ValidationError): + CrossSectionPatch(chainage_m=40.0, detail_table=bad) + + +def _stored(out) -> dict: + dumped = CrossSectionPatch(chainage_m=40.0, detail_table=out["drafts"]["40"]).model_dump() + return {**DESIGN, "detail_table": dumped["detail_table"]} + + +def test_모달_표_배치_값_검산(out): + design = _stored(out) + sheet = table_mod.cross_quantity_sheet({"chainage_m": 40.0, "center_z": 111.0}, design) + layout = sheet["layout"] + assert layout["rows"] == ["b0", "r1", "r3", "b1", "b2", "b3", "b4", "b5", "r2"] + assert layout["left"][1] == [None, "풍화암", "r:r1:0"] # 깍기 묶음 안 하위 줄 + assert layout["left"][2] == [None, "", "r:r3:0"] + assert layout["middle"][1] == [None, "", "r:r1:1"] # 면고르기 묶음 안 + assert layout["left"][8] == ["", None, "r:r2:0"] # 끝 = 홀로 선 줄 + assert layout["right"][8] == ["", "r:r2:2"] + assert layout["columns"] == [ + {"id": "c1", "label": "비고", "keys": [f"c:c1:{row}" for row in layout["rows"]]} + ] + table = sheet["quantity_table"] + assert table["cut_soil"] == 2.25 # 덮어쓰기 = 쌓기 면적 + assert table["fence"] == pytest.approx(hypot(4.0, 2.0)) # 판정 선 길이 + assert table["r:r2:2"] == 0.5 + assert sheet["text"]["c:c1:r1"] == "L형" and sheet["repick"] == [] + # 설계선이 바뀌면 길이 칸 비움 · 다시 고름 + moved = {**design, "design_line": [{"offset_m": x, "elevation_m": y + 1} for x, y in LINE]} + again = table_mod.cross_quantity_sheet({"chainage_m": 40.0}, moved) + assert again["quantity_table"]["fence"] is None and again["repick"] == ["fence"] + + +def test_B07_도면_표_같은_자료_행_열_높이(out): + design = _stored(out) + from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table + + values = _quantity_table({"chainage_m": 40.0, "center_z": 111.0}, design) + [entity] = _cross_table_entities("d", values, 0.0, "No.2", 0.0, design) + shape = entity["shapeData"] + assert len(shape["rowHeights"]) == 2 + 6 + 3 + assert sum(shape["columnWidths"]) == pytest.approx(108.0) + cells = [cell for row in shape["cells"] for cell in row if cell] + texts = {cell.get("key"): cell["text"] for cell in cells if cell.get("key")} + assert texts["cut_soil"] == "2.25" and texts["fence"] == "4.47" + assert texts["c:c1:r1"] == "L형" and texts["r:r1:0"] == "" + assert any(cell["text"] == "비고" for cell in shape["cells"][1] if cell) + labels = [cell["text"] for cell in cells if not cell.get("key")] + assert "풍화암" in labels + # 깍기 묶음 = 4줄(토사 · 풍화암 · 더한 줄 · 암석) + cut = next(cell for cell in cells if cell["text"] == "깍기") + assert cut["rowSpan"] == 4 + assert cross_table_height(design) == pytest.approx(5.0 * 11) + assert cross_table_height() == pytest.approx(40.0) # 손질 없으면 그대로 + # 확정 되읽기 = 양식 칸만(더한 칸은 patch 몫) + extracted = extract_quantity_table("d", {"entities": [entity]}) + assert extracted["cut_soil"] == 2.25 and "c:c1:r1" not in extracted + + +def test_손질_없으면_도면_표_그대로(): + plain = _cross_table_entities("d", {"ground": 1.0}, 0.0, "No.1", 0.0)[0]["shapeData"] + empty = {"detail_table": {"cells": {}, "rows": [], "columns": []}} + same = _cross_table_entities("d", {"ground": 1.0}, 0.0, "No.1", 0.0, empty)[0]["shapeData"] + assert plain == same and len(plain["rowHeights"]) == 8 + + +# ── 살림 목록 짝 · 배선 ──────────────────────────────────────────────── + + +def test_살림_목록_짝에_detail_table(): + assert "detail_table" in USER_TOUCHED_KEYS + text = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + block = re.search(r"export const USER_TOUCHED_KEYS = \[(.*?)\] as const;", text, re.S) + assert block and '"detail_table"' in block.group(1) + + +def test_저장_patch_배선_초안_등록(): + persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + assert "readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => {" in persist + assert "patchFor(chainage).detail_table = table;" in persist + state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + assert '"detail-table": { bucket: "draft", scope: "route" }' in state + detail = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail.ts").read_text(encoding="utf-8") + assert "designForTable(state.section, projectId, routeId)" in detail + assert "const detachTable = attachDetailTable({" in detail