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) → 글. [저장]이 내보냄. */
"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 램프로 바꾼 측점 상단측(측구 방향). */
+7 -1
View File
@@ -297,9 +297,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 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */
+17
View File
@@ -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<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",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
// [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
"detail_table",
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
"shoulder_extend_left_m",
"shoulder_extend_right_m",
+10 -7
View File
@@ -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,
}
+2
View File
@@ -155,6 +155,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",
+36
View File
@@ -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):
+128 -34
View File
@@ -6,10 +6,11 @@
* 칸 크기가 바뀌면 다시 그림) ·
* 가운데 = 도면 표 위 글(`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 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
* 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임.
* 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3).
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
* ========================================================================== */
@@ -30,14 +31,20 @@ 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`) — 카드 줄은 프로젝트 · 노선을 모름. */
export interface CrossDetailHost {
projectId: string;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
/** 칸 크기(W × H px)로 새로 그린 카드 svg(173-4 · 뷰 `detailChart`). */
chart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null;
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */
chart: (
stationId: string,
widthPx: number,
heightPx: number,
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
}
let host: CrossDetailHost | null = null;
@@ -92,46 +99,110 @@ 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<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 {
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;
}
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
/** 복제 자식을 g 하나로 감싸 공용 줌 · 팬을 검(축 · 눈금째 한 장) · 휠 = 커서 고정 줌. */
/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */
export function attachDetailZoom(
clone: SVGSVGElement,
axes: CrossAxesOptions | null,
initial: ZoomPanState | undefined,
onChange: (state: ZoomPanState) => void,
): ZoomPanHandle {
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
const layer = document.createElementNS("http://www.w3.org/2000/svg", "g");
layer.append(...clone.childNodes);
): ZoomPanHandle | null {
const layer = clone.querySelector<SVGGElement>("g.b06-chart__plot-layer");
if (!layer) return null;
// 칸 = 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({
svg: clone,
layer,
@@ -143,7 +214,10 @@ export function attachDetailZoom(
content: { x: -width, y: -height, width: 3 * width, height: 3 * height },
wheelZoom: true,
initial,
onChange,
onChange: (state) => {
follow(state);
onChange(state);
},
});
}
@@ -164,12 +238,16 @@ let tableTimer = 0;
let drawing = false;
/** 칸 크기로 새로 그림 → 복제(듣개 없음) · 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;
try {
const svg = host?.chart(section.station_id, widthPx, heightPx);
if (!svg) return null;
const clone = svg.cloneNode(true) as SVGSVGElement;
const drawn = host?.chart(section.station_id, widthPx, heightPx);
if (!drawn) return null;
const clone = drawn.svg.cloneNode(true) as SVGSVGElement;
clone.querySelectorAll("clipPath[id]").forEach((clip) => {
const id = clip.id;
clip.id = `b06-detail-${id}`;
@@ -177,7 +255,7 @@ function drawChart(section: CrossSection, widthPx: number, heightPx: number): SV
.querySelectorAll(`[clip-path="url(#${id})"]`)
.forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`));
});
return clone;
return { clone, axes: drawn.axes };
} finally {
drawing = false;
}
@@ -233,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);
@@ -246,11 +325,13 @@ export function openCrossDetail(section: CrossSection): void {
const size = `${width}x${height}`;
if (size !== drawnSize) zoom = undefined;
drawnSize = size;
const clone = drawChart(state.section, width, height);
if (!clone) cad.replaceChildren(document.createTextNode("그림 없음"));
const drawn = drawChart(state.section, width, height);
if (!drawn) cad.replaceChildren(document.createTextNode("그림 없음"));
else {
const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next));
cad.replaceChildren(clone, buildZoomControls(handle));
const { clone } = drawn;
axes = drawn.axes;
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
}
showPicked();
},
@@ -267,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;
@@ -284,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();
@@ -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";
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";
@@ -408,6 +409,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);
+17 -2
View File
@@ -37,6 +37,7 @@ import {
import {
type CrossPlotBase,
crossPlotBase,
crossPlotFromBase,
effectiveCardHalfWidth,
fitPlotBase,
naturalHeightAtWidth,
@@ -561,9 +562,23 @@ export function createSectionView(
// 카드 줌 · 팬은 카드 몫 — 모달 그림은 1배에서 시작(카드 상태는 되돌려 둠)
const cardZoom = cardZoomStates.get(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);
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) {
if (stationId === null) clearSelection();
@@ -11,6 +11,7 @@ import type {
} from "./B06_Section_Api_Fetch";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
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;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null). */
detailChart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null;
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 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). */
focusStation: (stationId: string | null) => void;
@@ -54,9 +54,11 @@
}
.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%;
border-collapse: collapse;
font-size: var(--text-caption);
font-size: var(--text-body-sm);
font-variant-numeric: tabular-nums;
}
@@ -70,7 +72,8 @@
.b06-detail__qtable th {
font-weight: 500;
color: var(--color-text-secondary);
background: var(--b06-detail-head);
color: var(--color-text);
}
.b06-detail__qtable tr:first-child > th {
@@ -100,10 +103,18 @@
/* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */
.b06-detail__qtable [data-key][data-empty] {
cursor: default;
background: none;
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 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */
.b06-detail__cad .b06-chart__area.is-active > polygon {
fill: color-mix(in srgb, var(--color-danger) 35%, transparent);
@@ -131,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);
}
@@ -549,7 +549,7 @@ def build_cross_drawing(
stack = 0.0
if quantity_table is not None:
stack = (
cross_table_height(table_scale)
cross_table_height(design, table_scale)
+ cross_note_height(cross_note_lines(design), table_scale)
+ 8.0
)
@@ -588,10 +588,10 @@ def build_cross_drawing(
table_top -= cross_note_height(note_lines, table_scale)
entities.extend(
_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 띄운다.
pad = CROSS_BORDER_PAD_MM
@@ -101,7 +101,7 @@ def section_block_size(
below = half_height + _BORDER_PAD_MM
if with_table:
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)
@@ -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,6 +132,7 @@ def _cross_table_entities(
table_top: float,
title_label: str,
center_x: float = 0.0,
design: dict[str, Any] | None = None,
scale: float = 1.0,
) -> list[dict[str, Any]]:
"""횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다 — scale = 표 · 글 배율(180-2).
@@ -135,19 +140,34 @@ def _cross_table_entities(
머리행은 폭을 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
row_h = _CROSS_TABLE_ROW_HEIGHT * scale
font = _CROSS_TABLE_FONT * scale
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)]
@@ -177,8 +197,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)
@@ -188,14 +213,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)
@@ -204,17 +233,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(
@@ -363,6 +406,8 @@ def cross_table_width(scale: float = 1.0) -> float:
return _CROSS_TABLE_WIDTH * scale
def cross_table_height(scale: float = 1.0) -> float:
"""수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행."""
return _CROSS_TABLE_ROW_HEIGHT * scale * (len(_CROSS_LEFT_ROWS) + 2)
def cross_table_height(design: dict[str, Any] | None = None, scale: float = 1.0) -> float:
"""수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행(+ [상세]에서 더한 행 · 179-4) × 배율(180-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,
)
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).
@@ -311,6 +312,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
+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 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 = {
"@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": { 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: {} },
};
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
@@ -52,6 +70,24 @@ function el(tag) {
node.parent = self;
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),
fire: (type, 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 svg = el("svg");
svg.setAttribute("viewBox", "0 0 600 300");
svg.append(el("rect"), el("g"), el("g"));
// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150)
const AXES = {
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;
const handle = attachDetailZoom(svg, undefined, (next) => (saved = next));
const layer = svg.childNodes[0];
const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next));
const ticksBefore = ticks(axes);
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"],
before: layer.getAttribute("transform"),
ticksBefore: ticksBefore.length,
};
// 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정
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("pointerup", {});
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", {
stopPropagation() {
this.stopped = true;
},
}).stopped;
// 다시 떠도 배율 이어 감
const again = el("svg");
again.setAttribute("viewBox", "0 0 600 300");
attachDetailZoom(again, saved, () => {});
out.restored = again.childNodes[0].getAttribute("transform");
const again = chart();
attachDetailZoom(again.svg, AXES, saved, () => {});
out.restored = again.plot.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", {});
out.reset = layer.getAttribute("transform");
// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로)
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
out.centerTransform = center.getAttribute("transform");
out.max = (() => {
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
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)
def test_복제_자식을_g_하나로_감쌈_비율(out):
assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임
def test_감싸지_않음_비율(out):
# 179-3 — 복제를 g 하나로 감싸지 않음(축 · 눈금 · 도형 한 장 → 도형 층만)
assert out["wrapped"] is False and out["children"] == 4
assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음
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):
@@ -69,8 +83,14 @@ def test_모달_배선_단추_칸_비율():
src = _read("B06_Section_UI_Cross_Detail.ts")
css = _read("B06_Section_UI_Style_Cross_Detail.css")
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src
assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src
assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" 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
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
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")
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_이름_칸_머리색_빈칸_값칸만():
# 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_상세_칸_크기로_새로_그림_배선():
src = _read("B06_Section_UI_Cross_Detail.ts")
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 "new ResizeObserver(() => state.paint())" in src and "resize.disconnect()" in src
# 카드 clip id 와 가름 · 그리는 동안 refresh 되먹임 막음
@@ -99,7 +99,12 @@ def test_상세_칸_크기로_새로_그림_배선():
assert "base && fitPlotBase(base, heightPx)" in view
assert "cardZoomStates.delete(stationId);" in view
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")
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