Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

# Conflicts:
#	B07_DesignDetail/B07_DesignDetail_Engine_Cad.py
#	B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py
#	B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py
This commit is contained in:
2026-10-10 12:14:12 +09:00
23 changed files with 1584 additions and 91 deletions
+2
View File
@@ -110,6 +110,8 @@ export const STATE_REGISTRY = {
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
"structure-notes": { bucket: "draft", scope: "route" }, "structure-notes": { bucket: "draft", scope: "route" },
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
"detail-table": { bucket: "draft", scope: "route" },
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` }, structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */ /** 3D 램프로 바꾼 측점 상단측(측구 방향). */
+7 -1
View File
@@ -297,9 +297,15 @@ export interface CrossQuantitySheet {
layout: { layout: {
header: Array<[string, string]>; header: Array<[string, string]>;
left: Array<[string | null, string | null, string | null]>; 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]>; 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 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */ /** 캐시 설계로 B07 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */
+17
View File
@@ -471,6 +471,8 @@ export interface CrossDesign {
pavement_suggested?: boolean; pavement_suggested?: boolean;
/** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */ /** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */
structure_note?: string; structure_note?: string;
/** [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 서버가 다시 셈). */
detail_table?: CrossDetailTable;
/** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */
road_edges: { road_edges: {
left: { offset_m: number; elevation_m: number }; left: { offset_m: number; elevation_m: number };
@@ -617,4 +619,19 @@ export interface CrossSectionPatch {
cut_rock_area_m2?: number; cut_rock_area_m2?: number;
/** 측점 구조물 글(138-4) — 빈 글 = 지움. */ /** 측점 구조물 글(138-4) — 빈 글 = 지움. */
structure_note?: string; 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<string, CrossCellSource>;
rows: Array<{ id: string; after: string | null; grouped: boolean; labels: string[] }>;
columns: Array<{ id: string; label: string }>;
} }
+2
View File
@@ -90,6 +90,8 @@ export const USER_TOUCHED_KEYS = [
"cut_slope_ratio_user", "cut_slope_ratio_user",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note", "structure_note",
// [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
"detail_table",
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
"shoulder_extend_left_m", "shoulder_extend_left_m",
"shoulder_extend_right_m", "shoulder_extend_right_m",
+10 -7
View File
@@ -3,6 +3,7 @@
값은 B07 횡단도가 쓰는 `_quantity_table` 한 벌을 **브라우저 캐시 설계**로 돌린 것이다 — 값은 B07 횡단도가 쓰는 `_quantity_table` 한 벌을 **브라우저 캐시 설계**로 돌린 것이다 —
화면이 TS 로 다시 짜지 않는다(CLAUDE.md 5장 ③ 서버 단독). 칸 배치도 B07 표 정의를 그대로 화면이 TS 로 다시 짜지 않는다(CLAUDE.md 5장 ③ 서버 단독). 칸 배치도 B07 표 정의를 그대로
실어 보내 화면은 그리기만 한다. **표시 전용** — 정본을 쓰지 않는다. 모달을 열 때 부른다. 실어 보내 화면은 그리기만 한다. **표시 전용** — 정본을 쓰지 않는다. 모달을 열 때 부른다.
표 손질(179-4 · 고른 칸 · 더한 행 · 열)은 설계 `detail_table` 을 `_Engine_Cross_Table_Edit` 가 읽음.
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,6 +28,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
_CROSS_MIDDLE_ROWS, _CROSS_MIDDLE_ROWS,
_CROSS_RIGHT_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 B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table
from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool 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]: 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) table = _quantity_table(source, design)
text = { text = {
key: _format(value) if isinstance(value, (int, float)) else "-" key: _format(value) if isinstance(value, (int, float)) else "-"
for key, value in table.items() for key, value in table.items()
} }
_values, texts, repick = edited_cells(design)
text.update(texts)
return { return {
"quantity_table": table, "quantity_table": table,
"text": text, "text": text,
"layout": { "layout": table_layout(
"header": [list(row) for row in _CROSS_HEADER_KEYS], design, _CROSS_HEADER_KEYS, _CROSS_LEFT_ROWS, _CROSS_MIDDLE_ROWS, _CROSS_RIGHT_ROWS
"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], "repick": repick,
},
} }
+2
View File
@@ -155,6 +155,8 @@ USER_TOUCHED_KEYS = (
"cut_slope_ratio_user", "cut_slope_ratio_user",
# 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. # 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note", "structure_note",
# [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
"detail_table",
# 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`) # 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`)
"shoulder_extend_left_m", "shoulder_extend_left_m",
"shoulder_extend_right_m", "shoulder_extend_right_m",
+36
View File
@@ -135,6 +135,40 @@ class BoxAdjustPatch(BaseModel):
right: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch) 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): class CrossSectionPatch(BaseModel):
"""확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" """확정 시 측점별 data.design에 병합할 프론트 세션 보관값."""
@@ -199,6 +233,8 @@ class CrossSectionPatch(BaseModel):
ford_pipe_length_m: float | None = Field(default=None, ge=0) ford_pipe_length_m: float | None = Field(default=None, ge=0)
# 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움. # 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움.
structure_note: str | None = Field(default=None, max_length=2000) structure_note: str | None = Field(default=None, max_length=2000)
# [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈
detail_table: CrossDetailTable | None = None
class SectionConfirmRequest(BaseModel): class SectionConfirmRequest(BaseModel):
+128 -34
View File
@@ -6,10 +6,11 @@
* 칸 크기가 바뀌면 다시 그림) · * 칸 크기가 바뀌면 다시 그림) ·
* 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 · * 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 ·
* B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) ·
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
* 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`).
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) — * 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
* 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임. * 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3).
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤. * 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
* ========================================================================== */ * ========================================================================== */
@@ -30,14 +31,20 @@ import {
type ZoomPanState, type ZoomPanState,
} from "../A00_Common/b_svg_zoom_pan"; } from "../A00_Common/b_svg_zoom_pan";
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; 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`) — 카드 줄은 프로젝트 · 노선을 모름. */ /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
export interface CrossDetailHost { export interface CrossDetailHost {
projectId: string; projectId: string;
routeId: () => number | null; routeId: () => number | null;
detail: () => SectionDetailResponse | null; detail: () => SectionDetailResponse | null;
/** 칸 크기(W × H px)로 새로 그린 카드 svg(173-4 · 뷰 `detailChart`). */ /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */
chart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null; chart: (
stationId: string,
widthPx: number,
heightPx: number,
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
} }
let host: CrossDetailHost | null = null; let host: CrossDetailHost | null = null;
@@ -92,46 +99,110 @@ function cell(
return element; 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<string, string | undefined>,
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 { export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement {
const { layout, text } = sheet; 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"); const table = document.createElement("table");
table.className = "b06-detail__qtable"; table.className = "b06-detail__qtable";
const titleRow = table.insertRow(); const titleRow = table.insertRow();
titleRow.append(cell("th", title, { col: 8 })); titleRow.append(cell("th", title, { col: 8 + columns.length }));
const headRow = table.insertRow(); const headRow = table.insertRow();
for (const [label, key] of layout.header) 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) => { layout.left.forEach(([groupL, subL, keyL], index) => {
const id = rowIds[index];
const row = table.insertRow(); const row = table.insertRow();
// 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4)
if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }, keyL)); if (groupL !== null && subL !== null)
else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }, keyL)); row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0));
if (subL !== null) row.append(cell("th", subL, undefined, keyL)); else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0));
row.append(cell("td", value(keyL), undefined, keyL)); if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0));
row.append(valueCell(keyL, id));
const [groupM, subM, keyM] = layout.middle[index]; const [groupM, subM, keyM] = layout.middle[index];
if (groupM !== null) row.append(cell("th", groupM, { row: 2 }, keyM)); if (groupM !== null && subM !== null)
row.append(cell("th", subM, undefined, keyM), cell("td", value(keyM), undefined, keyM)); 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]; 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; return table;
} }
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ /* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
/** 복제 자식을 g 하나로 감싸 공용 줌 · 팬을 검(축 · 눈금째 한 장) · 휠 = 커서 고정 줌. */ /** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */
export function attachDetailZoom( export function attachDetailZoom(
clone: SVGSVGElement, clone: SVGSVGElement,
axes: CrossAxesOptions | null,
initial: ZoomPanState | undefined, initial: ZoomPanState | undefined,
onChange: (state: ZoomPanState) => void, onChange: (state: ZoomPanState) => void,
): ZoomPanHandle { ): ZoomPanHandle | null {
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); const layer = clone.querySelector<SVGGElement>("g.b06-chart__plot-layer");
const layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); if (!layer) return null;
layer.append(...clone.childNodes);
// 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확 // 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확
clone.append(layer); const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
const axisLayer = clone.querySelector<SVGGElement>("g.b06-chart__axes");
const renderAxes = axes && axisLayer ? createCrossAxes(axisLayer, axes) : null;
const marker = layer.querySelector<SVGGElement>("g.b06-chart__center");
const markerLine = marker?.querySelector("line");
const cx = Number(markerLine?.getAttribute("x1"));
const cy = Number(markerLine?.getAttribute("y1")) + 18;
const follow = (state: ZoomPanState): void => {
renderAxes?.(state);
marker?.setAttribute(
"transform",
`translate(${cx} ${cy}) scale(${1 / Math.max(state.scale, 1e-6)}) translate(${-cx} ${-cy})`,
);
};
follow(initial ?? IDENTITY_VIEW);
return attachSvgZoomPan({ return attachSvgZoomPan({
svg: clone, svg: clone,
layer, layer,
@@ -143,7 +214,10 @@ export function attachDetailZoom(
content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, content: { x: -width, y: -height, width: 3 * width, height: 3 * height },
wheelZoom: true, wheelZoom: true,
initial, initial,
onChange, onChange: (state) => {
follow(state);
onChange(state);
},
}); });
} }
@@ -164,12 +238,16 @@ let tableTimer = 0;
let drawing = false; let drawing = false;
/** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */ /** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */
function drawChart(section: CrossSection, widthPx: number, heightPx: number): SVGSVGElement | null { function drawChart(
section: CrossSection,
widthPx: number,
heightPx: number,
): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null {
drawing = true; drawing = true;
try { try {
const svg = host?.chart(section.station_id, widthPx, heightPx); const drawn = host?.chart(section.station_id, widthPx, heightPx);
if (!svg) return null; if (!drawn) return null;
const clone = svg.cloneNode(true) as SVGSVGElement; const clone = drawn.svg.cloneNode(true) as SVGSVGElement;
clone.querySelectorAll("clipPath[id]").forEach((clip) => { clone.querySelectorAll("clipPath[id]").forEach((clip) => {
const id = clip.id; const id = clip.id;
clip.id = `b06-detail-${id}`; clip.id = `b06-detail-${id}`;
@@ -177,7 +255,7 @@ function drawChart(section: CrossSection, widthPx: number, heightPx: number): SV
.querySelectorAll(`[clip-path="url(#${id})"]`) .querySelectorAll(`[clip-path="url(#${id})"]`)
.forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`)); .forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`));
}); });
return clone; return { clone, axes: drawn.axes };
} finally { } finally {
drawing = false; drawing = false;
} }
@@ -233,6 +311,7 @@ export function openCrossDetail(section: CrossSection): void {
// 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4) // 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4)
let zoom: ZoomPanState | undefined; let zoom: ZoomPanState | undefined;
let axes: CrossAxesOptions | null = null;
let drawnSize = ""; let drawnSize = "";
const resize = new ResizeObserver(() => state.paint()); const resize = new ResizeObserver(() => state.paint());
resize.observe(cad); resize.observe(cad);
@@ -246,11 +325,13 @@ export function openCrossDetail(section: CrossSection): void {
const size = `${width}x${height}`; const size = `${width}x${height}`;
if (size !== drawnSize) zoom = undefined; if (size !== drawnSize) zoom = undefined;
drawnSize = size; drawnSize = size;
const clone = drawChart(state.section, width, height); const drawn = drawChart(state.section, width, height);
if (!clone) cad.replaceChildren(document.createTextNode("그림 없음")); if (!drawn) cad.replaceChildren(document.createTextNode("그림 없음"));
else { else {
const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next)); const { clone } = drawn;
cad.replaceChildren(clone, buildZoomControls(handle)); axes = drawn.axes;
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
} }
showPicked(); showPicked();
}, },
@@ -267,7 +348,7 @@ export function openCrossDetail(section: CrossSection): void {
projectId, projectId,
routeId, routeId,
state.section.chainage_m, state.section.chainage_m,
state.section.design, designForTable(state.section, projectId, routeId),
) )
.then((sheet) => { .then((sheet) => {
if (seq !== tableSeq || current !== state) return; if (seq !== tableSeq || current !== state) return;
@@ -284,8 +365,21 @@ export function openCrossDetail(section: CrossSection): void {
forget(); forget();
close(); 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; current = state;
tableBox.textContent = "수량표 불러오는 중…"; tableBox.textContent = "수량표 불러오는 중…";
state.paint(); state.paint();
@@ -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<string, CrossDetailTable>;
const tableKey = (chainageM: number): string => chainageM.toFixed(2);
/** [저장]이 patch 로 싣는 표 손질 — 측점(m) → 표 손질. */
export function readDetailTables(
projectId: string,
routeId: number,
): Map<number, CrossDetailTable> {
const raw = readState<Drafts>("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<Drafts>("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<Drafts>("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<ReadonlyArray<string | null>>, 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<Record<string, string>> = {
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<string, unknown>;
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<typeof createMapContextMenu> | 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<HTMLElement>(`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<HTMLElement>(
"[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);
};
}
@@ -31,6 +31,7 @@ import {
} from "./B06_Section_Structure_Layouts"; } from "./B06_Section_Structure_Layouts";
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; 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 { StandardCrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
@@ -408,6 +409,11 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => { readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => {
patchFor(chainage).structure_note = note; 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) — 서버가 [저장] 때 이 값으로 설계선을 다시 냄 // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 서버가 [저장] 때 이 값으로 설계선을 다시 냄
readShoulderExtends(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => { readShoulderExtends(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => {
const patch = patchFor(chainage); const patch = patchFor(chainage);
+17 -2
View File
@@ -37,6 +37,7 @@ import {
import { import {
type CrossPlotBase, type CrossPlotBase,
crossPlotBase, crossPlotBase,
crossPlotFromBase,
effectiveCardHalfWidth, effectiveCardHalfWidth,
fitPlotBase, fitPlotBase,
naturalHeightAtWidth, naturalHeightAtWidth,
@@ -561,9 +562,23 @@ export function createSectionView(
// 카드 줌 · 팬은 카드 몫 — 모달 그림은 1배에서 시작(카드 상태는 되돌려 둠) // 카드 줌 · 팬은 카드 몫 — 모달 그림은 1배에서 시작(카드 상태는 되돌려 둠)
const cardZoom = cardZoomStates.get(stationId); const cardZoom = cardZoomStates.get(stationId);
cardZoomStates.delete(stationId); cardZoomStates.delete(stationId);
const card = buildCrossCard(section, heightPx, base && fitPlotBase(base, heightPx), widthPx); const fitted = base && fitPlotBase(base, heightPx);
const card = buildCrossCard(section, heightPx, fitted, widthPx);
if (cardZoom) cardZoomStates.set(stationId, cardZoom); if (cardZoom) cardZoomStates.set(stationId, cardZoom);
return card.querySelector<SVGSVGElement>("svg.b06-section__chart"); const svg = card.querySelector<SVGSVGElement>("svg.b06-section__chart");
if (!svg) return null;
// 축 다시 그리기 옵션(179-3) — 카드 `createCrossAxes` 와 같은 기하
const plot = fitted && crossPlotFromBase(fitted, heightPx);
const axes = plot && {
widthPx,
heightPx,
pixelsPerMeter: plot.pixelsPerMeter,
maxOffset: plot.maxOffset,
displayMax: plot.displayMax,
elevationMid: plot.elevationMid,
exaggeration: Math.max(currentExaggeration, 0.1),
};
return { svg, axes };
}, },
focusStation(stationId) { focusStation(stationId) {
if (stationId === null) clearSelection(); if (stationId === null) clearSelection();
@@ -11,6 +11,7 @@ import type {
} from "./B06_Section_Api_Fetch"; } from "./B06_Section_Api_Fetch";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
/** /**
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**. * 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
@@ -37,8 +38,13 @@ export interface SectionViewController {
refreshCard: (chainageM: number) => void; refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void; refreshCards: (chainages: ReadonlyArray<number>) => void;
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null). */ /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null)
detailChart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null; * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */
detailChart: (
stationId: string,
widthPx: number,
heightPx: number,
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
focusStation: (stationId: string | null) => void; focusStation: (stationId: string | null) => void;
@@ -54,9 +54,11 @@
} }
.b06-detail__qtable { .b06-detail__qtable {
/* 이름 칸 바탕 = 데이터 표 머리색(179-3 · `ui_template_data_table` --dt-head) */
--b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: var(--text-caption); font-size: var(--text-body-sm);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -70,7 +72,8 @@
.b06-detail__qtable th { .b06-detail__qtable th {
font-weight: 500; font-weight: 500;
color: var(--color-text-secondary); background: var(--b06-detail-head);
color: var(--color-text);
} }
.b06-detail__qtable tr:first-child > th { .b06-detail__qtable tr:first-child > th {
@@ -100,10 +103,18 @@
/* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */ /* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */
.b06-detail__qtable [data-key][data-empty] { .b06-detail__qtable [data-key][data-empty] {
cursor: default; cursor: default;
background: none;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* 빈 칸 = 값 칸만 바탕 지움 · 이름 칸은 머리색 그대로(글만 회색) */
.b06-detail__qtable td[data-key][data-empty] {
background: none;
}
.b06-detail__qtable th[data-key][data-empty] {
background: var(--b06-detail-head);
}
/* 그림 강조 한 벌 = 붉은 계통(164-2 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */ /* 그림 강조 한 벌 = 붉은 계통(164-2 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */
.b06-detail__cad .b06-chart__area.is-active > polygon { .b06-detail__cad .b06-chart__area.is-active > polygon {
fill: color-mix(in srgb, var(--color-danger) 35%, transparent); fill: color-mix(in srgb, var(--color-danger) 35%, transparent);
@@ -131,3 +142,41 @@
.b06-detail__cad .b06-chart__area > polygon { .b06-detail__cad .b06-chart__area > polygon {
cursor: default; 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);
}
@@ -549,7 +549,7 @@ def build_cross_drawing(
stack = 0.0 stack = 0.0
if quantity_table is not None: if quantity_table is not None:
stack = ( stack = (
cross_table_height(table_scale) cross_table_height(design, table_scale)
+ cross_note_height(cross_note_lines(design), table_scale) + cross_note_height(cross_note_lines(design), table_scale)
+ 8.0 + 8.0
) )
@@ -588,10 +588,10 @@ def build_cross_drawing(
table_top -= cross_note_height(note_lines, table_scale) table_top -= cross_note_height(note_lines, table_scale)
entities.extend( entities.extend(
_cross_table_entities( _cross_table_entities(
drawing_id, quantity_table, table_top, title_label, center_x, table_scale drawing_id, quantity_table, table_top, title_label, center_x, design, table_scale
) )
) )
table_bottom = table_top - cross_table_height(table_scale) table_bottom = table_top - cross_table_height(design, table_scale)
# 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다. # 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다.
pad = CROSS_BORDER_PAD_MM pad = CROSS_BORDER_PAD_MM
@@ -101,7 +101,7 @@ def section_block_size(
below = half_height + _BORDER_PAD_MM below = half_height + _BORDER_PAD_MM
if with_table: if with_table:
note_h = cross_note_height(cross_note_lines(design_spec), table_scale) note_h = cross_note_height(cross_note_lines(design_spec), table_scale)
below += 8.0 + note_h + cross_table_height(table_scale) below += 8.0 + note_h + cross_table_height(design_spec, table_scale)
return (width, top + below) return (width, top + below)
@@ -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) _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]: def _cross_column_edges(
total = sum(_CROSS_COLUMN_WEIGHTS) width: float, center_x: float = 0.0, weights: tuple[float, ...] = _CROSS_COLUMN_WEIGHTS
) -> list[float]:
total = sum(weights)
left = center_x - width / 2.0 left = center_x - width / 2.0
edges = [left] edges = [left]
accumulated = 0.0 accumulated = 0.0
for weight in _CROSS_COLUMN_WEIGHTS: for weight in weights:
accumulated += weight accumulated += weight
edges.append(left + width * accumulated / total) edges.append(left + width * accumulated / total)
return edges return edges
@@ -128,6 +132,7 @@ def _cross_table_entities(
table_top: float, table_top: float,
title_label: str, title_label: str,
center_x: float = 0.0, center_x: float = 0.0,
design: dict[str, Any] | None = None,
scale: float = 1.0, scale: float = 1.0,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다 — scale = 표 · 글 배율(180-2). """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다 — scale = 표 · 글 배율(180-2).
@@ -135,19 +140,34 @@ def _cross_table_entities(
머리행은 폭을 8등분하고 본문은 11열 가중치를 쓴다 — 격자가 서로 다르므로 두 경계를 머리행은 폭을 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 * scale width = _CROSS_TABLE_WIDTH * scale
row_h = _CROSS_TABLE_ROW_HEIGHT * scale row_h = _CROSS_TABLE_ROW_HEIGHT * scale
font = _CROSS_TABLE_FONT * scale font = _CROSS_TABLE_FONT * scale
left = center_x - width / 2.0 left = center_x - width / 2.0
right = center_x + width / 2.0 right = center_x + width / 2.0
body_edges = _cross_column_edges(width, center_x) weights = _CROSS_COLUMN_WEIGHTS + (_CROSS_EXTRA_COLUMN_WEIGHT,) * len(extra)
header_cell = width / 8.0 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)] header_edges = [left + header_cell * index for index in range(9)]
edges = _union_edges(body_edges, header_edges) edges = _union_edges(body_edges, header_edges)
column_widths = [edges[i + 1] - edges[i] for i in range(len(edges) - 1)] 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 # 제목행 + 머리행 + 본문 rows = body_rows + 2 # 제목행 + 머리행 + 본문
cells: list[list[dict[str, Any] | None]] = [[None] * len(column_widths) for _ in range(rows)] cells: list[list[dict[str, Any] | None]] = [[None] * len(column_widths) for _ in range(rows)]
@@ -177,8 +197,13 @@ def _cross_table_entities(
cells[row][column] = cell cells[row][column] = cell
def value_of(key: str) -> str: def value_of(key: str) -> str:
if key in texts:
return texts[key]
value = quantity_table.get(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.측점) — 전체 병합 # ── 제목행 (No.측점) — 전체 병합
put(0, left, right, title_label, TABLE_LABEL_COLOR, align="left", font_size=font * 1.15) put(0, left, right, title_label, TABLE_LABEL_COLOR, align="left", font_size=font * 1.15)
@@ -188,14 +213,18 @@ def _cross_table_entities(
x0 = left + header_cell * pair_index * 2 x0 = left + header_cell * pair_index * 2
put(1, x0, x0 + header_cell, label, TABLE_LABEL_COLOR) 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) 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): for row_index in range(body_rows):
row = row_index + 2 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: 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: elif group_l is not None:
# 하위 라벨이 없으면 그룹 열과 하위 열을 합친다 # 하위 라벨이 없으면 그룹 열과 하위 열을 합친다
put(row, body_edges[0], body_edges[2], group_l, TABLE_LABEL_COLOR) put(row, body_edges[0], body_edges[2], group_l, TABLE_LABEL_COLOR)
@@ -204,17 +233,31 @@ def _cross_table_entities(
if key_l is not None: if key_l is not None:
put(row, body_edges[2], body_edges[3], value_of(key_l), TABLE_VALUE_COLOR, key=key_l) 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] group_m, sub_m, key_m = middle_rows[row_index]
if group_m is not None: if group_m is not None and sub_m is not None:
put(row, body_edges[4], body_edges[5], group_m, TABLE_LABEL_COLOR, row_span=2) span = group_span(middle_rows, row_index)
put(row, body_edges[5], body_edges[6], sub_m, TABLE_LABEL_COLOR) 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) 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: if label_r is not None:
put(row, body_edges[8], body_edges[9], label_r, TABLE_LABEL_COLOR) put(row, body_edges[8], body_edges[9], label_r, TABLE_LABEL_COLOR)
if key_r is not None: if key_r is not None:
put(row, body_edges[9], body_edges[10], value_of(key_r), TABLE_VALUE_COLOR, key=key_r) 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 [ return [
table_entity( table_entity(
@@ -363,6 +406,8 @@ def cross_table_width(scale: float = 1.0) -> float:
return _CROSS_TABLE_WIDTH * scale return _CROSS_TABLE_WIDTH * scale
def cross_table_height(scale: float = 1.0) -> float: def cross_table_height(design: dict[str, Any] | None = None, scale: float = 1.0) -> float:
"""수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행.""" """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행(+ [상세]에서 더한 행 · 179-4) × 배율(180-2)."""
return _CROSS_TABLE_ROW_HEIGHT * scale * (len(_CROSS_LEFT_ROWS) + 2) from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import added_row_count
return _CROSS_TABLE_ROW_HEIGHT * scale * (len(_CROSS_LEFT_ROWS) + 2 + added_row_count(design))
@@ -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
@@ -43,6 +43,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
QUANTITY_VALUE_KEYS, QUANTITY_VALUE_KEYS,
) )
from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import derived_cells 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 from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields
# 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04). # 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04).
@@ -311,6 +312,8 @@ def _quantity_table(
table.update(derived_cells(chainage, design)) table.update(derived_cells(chainage, design))
for key in QUANTITY_VALUE_KEYS: for key in QUANTITY_VALUE_KEYS:
table.setdefault(key, num(quantities.get(key))) table.setdefault(key, num(quantities.get(key)))
# [상세] 표 손질(179-4) — 고른 칸 · 더한 칸을 서버가 다시 셈(모달 · B07 도면 한 벌)
table.update(edited_cells(design)[0])
return table return table
+89 -11
View File
@@ -11,11 +11,29 @@ const B06 = path.join(ROOT, "B06_Section");
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); 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]}`)(); const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
// 축 눈금 간격도 원본 함수 그대로(179-3 · 축 다시 그리기)
const niceSrc = /export function niceTickStep[\s\S]*?\n\}/.exec(common.replace(/\r/g, ""))[0];
const niceTickStep = ts
.transpileModule(niceSrc.replace("export ", ""), { compilerOptions: {} })
.outputText.concat("\nreturn niceTickStep;");
const svgElement = (tag, attrs = {}) => {
const node = el(tag);
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
return node;
};
const svgText = (text, attrs) => Object.assign(svgElement("text", attrs), { textContent: text });
const STUBS = { const STUBS = {
"@ui/ui_template_modal": { openModal: () => {} }, "@ui/ui_template_modal": { openModal: () => {} },
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
"../A00_Common/b_page_state": {}, "../A00_Common/b_page_state": {},
"./B06_Section_Api_Fetch": {}, "./B06_Section_Api_Fetch": {},
"./B06_Section_UI_Section_Common": { CROSS_PAD, L: (key) => key }, "./B06_Section_UI_Section_Common": {
CROSS_PAD,
L: (key) => key,
niceTickStep: Function(niceTickStep)(),
svgElement,
svgText,
},
"./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} }, "./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} },
}; };
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
@@ -52,6 +70,24 @@ function el(tag) {
node.parent = self; node.parent = self;
self.childNodes.push(node); self.childNodes.push(node);
}), }),
replaceChildren: (...nodes) => {
self.childNodes.splice(0).forEach((node) => (node.parent = null));
self.append(...nodes);
},
// 「tag.class」 · 「tag」 만 — 깊이 우선 첫 것
querySelector: (selector) => {
const [tagName, cls] = selector.split(".");
const walk = (node) => {
for (const child of node.childNodes) {
const classes = (child.attrs.class ?? "").split(" ");
if (child.tag === tagName && (!cls || classes.includes(cls))) return child;
const found = walk(child);
if (found) return found;
}
return null;
};
return walk(self);
},
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
fire: (type, event) => { fire: (type, event) => {
const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }; const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event };
@@ -69,16 +105,42 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e
const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts")); const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts"));
const svg = el("svg"); // 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150)
svg.setAttribute("viewBox", "0 0 600 300"); const AXES = {
svg.append(el("rect"), el("g"), el("g")); widthPx: 600,
heightPx: 300,
pixelsPerMeter: 10,
maxOffset: 24,
displayMax: 120,
elevationMid: 110,
exaggeration: 1,
};
function chart() {
const svg = el("svg");
svg.setAttribute("viewBox", "0 0 600 300");
const axes = svgElement("g", { class: "b06-chart__axes" });
const clipGroup = svgElement("g", { "clip-path": "url(#c)" });
const plot = svgElement("g", { class: "b06-chart__plot-layer" });
const center = svgElement("g", { class: "b06-chart__center" });
center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 }));
plot.append(center);
clipGroup.append(plot);
svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" }));
return { svg, axes, plot, center };
}
const ticks = (axes) =>
axes.childNodes.filter((node) => node.tag === "text").map((node) => node.textContent);
const { svg, axes, plot: layer, center } = chart();
let saved; let saved;
const handle = attachDetailZoom(svg, undefined, (next) => (saved = next)); const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next));
const layer = svg.childNodes[0]; const ticksBefore = ticks(axes);
const out = { const out = {
wrapped: svg.childNodes.length === 1 && layer.tag === "g" && layer.childNodes.length === 3, // 179-3 — 감싸지 않음 · 자식 넷 그대로
wrapped: svg.childNodes.length === 1 && svg.childNodes[0].tag === "g",
children: svg.childNodes.length,
ratio: svg.attrs["style:--ratio"], ratio: svg.attrs["style:--ratio"],
before: layer.getAttribute("transform"), before: layer.getAttribute("transform"),
ticksBefore: ticksBefore.length,
}; };
// 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정 // 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
@@ -97,19 +159,35 @@ svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 });
svg.fire("pointermove", { clientX: 300, clientY: 140 }); svg.fire("pointermove", { clientX: 300, clientY: 140 });
svg.fire("pointerup", {}); svg.fire("pointerup", {});
out.middlePan = { ...saved }; out.middlePan = { ...saved };
// 팬 뒤 — 도형 층만 transform · 축 층 · 바깥 그룹 transform 없음 · 눈금 글 바뀜
out.plotTransform = layer.getAttribute("transform");
out.axesTransform = axes.getAttribute("transform");
out.clipTransform = svg.childNodes[2].getAttribute("transform");
out.ticksChanged = JSON.stringify(ticks(axes)) !== JSON.stringify(ticksBefore);
out.ticksAfter = ticks(axes).length;
out.panClickStopped = svg.fire("click", { out.panClickStopped = svg.fire("click", {
stopPropagation() { stopPropagation() {
this.stopped = true; this.stopped = true;
}, },
}).stopped; }).stopped;
// 다시 떠도 배율 이어 감 // 다시 떠도 배율 이어 감
const again = el("svg"); const again = chart();
again.setAttribute("viewBox", "0 0 600 300"); attachDetailZoom(again.svg, AXES, saved, () => {});
attachDetailZoom(again, saved, () => {}); out.restored = again.plot.getAttribute("transform");
out.restored = again.childNodes[0].getAttribute("transform"); // 다시 떠도 축은 이어 간 범위로(1배 원래 눈금과 다름)
out.restoredTicksShifted = JSON.stringify(ticks(again.axes)) !== JSON.stringify(ticksBefore);
// 축 옵션 없음 · 도형 층 없음 — 줌은 걸고 축은 그대로 / 줌 없음
const bare = chart();
out.noAxesHandle = attachDetailZoom(bare.svg, null, undefined, () => {}) !== null;
const flat = el("svg");
flat.setAttribute("viewBox", "0 0 600 300");
out.noLayer = attachDetailZoom(flat, AXES, undefined, () => {});
// 더블클릭 원복 // 더블클릭 원복
svg.fire("dblclick", {}); svg.fire("dblclick", {});
out.reset = layer.getAttribute("transform"); out.reset = layer.getAttribute("transform");
// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로)
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
out.centerTransform = center.getAttribute("transform");
out.max = (() => { out.max = (() => {
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
return saved.scale; return saved.scale;
@@ -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));
+51 -6
View File
@@ -40,10 +40,24 @@ def out() -> dict:
return json.loads(proc.stdout) return json.loads(proc.stdout)
def test_복제_자식을_g_하나로_감쌈_비율(out): def test_감싸지_않음_비율(out):
assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임 # 179-3 — 복제를 g 하나로 감싸지 않음(축 · 눈금 · 도형 한 장 → 도형 층만)
assert out["wrapped"] is False and out["children"] == 4
assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음 assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음
assert out["before"] is None assert out["before"] is None
assert out["ticksBefore"] > 0
def test_축_고정_도형_층만_움직임_눈금_다시(out):
# 179-3 — 팬 뒤 도형 층만 transform · 축 층 · clip 그룹 제자리 · 눈금 글은 보이는 범위로
assert out["plotTransform"] == "translate(100 20) scale(1)"
assert out["axesTransform"] is None and out["clipTransform"] is None
assert out["ticksChanged"] is True and out["ticksAfter"] > 0
assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로
# 확대 — 십자는 중심(300,150)에서 배율 되돌림
assert out["centerTransform"].startswith("translate(300 150) scale(0.869")
assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만
assert out["noLayer"] is None # 도형 층 없음 = 줌 없음
def test_휠_당기면_확대_커서_고정(out): def test_휠_당기면_확대_커서_고정(out):
@@ -69,8 +83,14 @@ def test_모달_배선_단추_칸_비율():
src = _read("B06_Section_UI_Cross_Detail.ts") src = _read("B06_Section_UI_Cross_Detail.ts")
css = _read("B06_Section_UI_Style_Cross_Detail.css") css = _read("B06_Section_UI_Style_Cross_Detail.css")
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src
assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src
assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3)
assert 'clone.querySelector<SVGGElement>("g.b06-chart__plot-layer")' in src
assert "createCrossAxes(axisLayer, axes)" in src
view = _read("B06_Section_UI_Section_View.ts")
assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view
assert "return { svg, axes };" in view
assert "position: relative;" in css assert "position: relative;" in css
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득 # 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css
@@ -100,5 +120,30 @@ def test_빈칸_누름_없음_이름_칸_첫_키():
src = _read("B06_Section_UI_Cross_Detail.ts") src = _read("B06_Section_UI_Cross_Detail.ts")
assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src
assert 'if (!key || el.hasAttribute("data-empty")) return;' in src assert 'if (!key || el.hasAttribute("data-empty")) return;' in src
assert 'cell("th", groupL, { row: 2 }, keyL)' in src # 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키
assert 'cell("th", groupM, { row: 2 }, keyM)' in src 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_이름_칸_머리색_빈칸_값칸만():
# 179-3 — 표 글자 14px · 이름 칸(th) 바탕 = 데이터 표 머리색 · 빈 칸 바탕 지움은 값 칸(td)만
css = _read("B06_Section_UI_Style_Cross_Detail.css")
table = css.split(".b06-detail__qtable {", 1)[1].split("}", 1)[0]
assert "font-size: var(--text-body-sm);" in table
assert (
"--b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));"
in table
)
head = css.split(".b06-detail__qtable th {", 1)[1].split("}", 1)[0]
assert "background: var(--b06-detail-head);" in head and "color: var(--color-text);" in head
empty = css.split(".b06-detail__qtable [data-key][data-empty] {", 1)[1].split("}", 1)[0]
assert "background" not in empty
assert ".b06-detail__qtable td[data-key][data-empty] {\n background: none;" in css
assert (
".b06-detail__qtable th[data-key][data-empty] {\n background: var(--b06-detail-head);"
in css
)
theme = open(
os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8"
).read()
assert "--text-body-sm: 14px;" in theme
+7 -2
View File
@@ -87,7 +87,7 @@ def test_상세_테마_토큰_어두운_고정색_없음():
def test_상세_칸_크기로_새로_그림_배선(): def test_상세_칸_크기로_새로_그림_배선():
src = _read("B06_Section_UI_Cross_Detail.ts") src = _read("B06_Section_UI_Cross_Detail.ts")
assert "const { clientWidth: width, clientHeight: height } = cad;" in src assert "const { clientWidth: width, clientHeight: height } = cad;" in src
assert "const clone = drawChart(state.section, width, height);" in src assert "const drawn = drawChart(state.section, width, height);" in src
assert "host?.chart(section.station_id, widthPx, heightPx)" in src assert "host?.chart(section.station_id, widthPx, heightPx)" in src
assert "new ResizeObserver(() => state.paint())" in src and "resize.disconnect()" in src assert "new ResizeObserver(() => state.paint())" in src and "resize.disconnect()" in src
# 카드 clip id 와 가름 · 그리는 동안 refresh 되먹임 막음 # 카드 clip id 와 가름 · 그리는 동안 refresh 되먹임 막음
@@ -99,7 +99,12 @@ def test_상세_칸_크기로_새로_그림_배선():
assert "base && fitPlotBase(base, heightPx)" in view assert "base && fitPlotBase(base, heightPx)" in view
assert "cardZoomStates.delete(stationId);" in view assert "cardZoomStates.delete(stationId);" in view
types = _read("B06_Section_UI_Section_View_Types.ts") types = _read("B06_Section_UI_Section_View_Types.ts")
assert "detailChart: (stationId: string, widthPx: number, heightPx: number) =>" in types # 179-3 — svg + 축 옵션
assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types
assert (
" detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number,"
in types
)
sync = _read("B06_Section_UI_Page_Design_Sync.ts") sync = _read("B06_Section_UI_Page_Design_Sync.ts")
assert "ctx.view().detailChart(stationId, widthPx, heightPx)" in sync assert "ctx.view().detailChart(stationId, widthPx, heightPx)" in sync
+264
View File
@@ -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