Files
Aislo/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts
T
eomsangdonandClaude Opus 5 ea5ce92725 feat(B07): 표를 객체로 만들어 도면의 표가 진짜 표가 되게 한다
표가 DXF의 표로 나가야 한다(사용자 확정). 지금까지 도면의 표는 선과 문자 뭉치라
내보낼 때 고를 수 있는 길이 하나뿐이었다.

- TableEntity: 열별 폭·행별 높이·칸 문자·병합을 한 객체가 들고 있다. 격자선은 담지
  않고 병합 자리에서 선을 끊는 규칙을 표가 스스로 안다(helpers/table-geometry.ts).
  회전·대칭은 지원하지 않는다 — 표는 축에 붙어 있다.
- 명령: TABLE을 표 객체 생성으로 다시 쓰고 TABLEEDIT(칸 문자)·TABLEROW·TABLECOL·
  TABLEMERGE·TABLEUNMERGE를 더했다. EXPLODE는 표를 선과 문자로 흩는다.
- 그립: 좌측 상단으로 표를 옮기고, 열·행 경계로 폭·높이를 바꾼다.
- 백엔드: 유역 정보표와 횡단 수량 산출표를 표 객체로 낸다. 횡단표는 머리행이 폭
  8등분, 본문이 11열 가중치로 격자가 서로 달라 두 경계를 합친 18열로 만들고 병합으로
  원래 칸을 되살렸다 — 손으로 하던 가로선 끊기가 사라졌다.
- 수량 역추출: 값 Text의 결정적 id로 읽던 것을 칸에 실은 key로 읽도록 옮겼다. 이미
  저장된 도면을 위해 옛 방식을 폴백으로 남겼다.

토적도·종단표는 값이 칸이 아니라 측점 위치에 놓이는 성격이라 이관하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:12:19 +09:00

226 lines
7.6 KiB
TypeScript

/**
* 표 기하 — 셀 사각형과 그려야 할 경계선을 계산한다. 상태가 없는 순수 함수라
* 화면·SVG·내보내기가 같은 규칙을 쓴다.
*
* 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은
* 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다.
*/
export interface TableCell {
text: string;
/** 오른쪽으로 합칠 칸 수 (기본 1) */
colSpan?: number;
/** 아래로 합칠 칸 수 (기본 1) */
rowSpan?: number;
align?: 'left' | 'center' | 'right';
/** 문자 색. 없으면 표 기본색 (라벨·값 색을 칸마다 달리 쓰려고 둔다) */
color?: string;
fontSize?: number;
bold?: boolean;
italic?: boolean;
/** 백엔드가 값을 되읽을 때 쓰는 이름 (수량 산출표의 항목 키). 화면 동작에는 쓰지 않는다 */
key?: string;
}
/** null은 앞 칸의 병합에 먹힌 자리다 */
export type TableCells = (TableCell | null)[][];
export interface CellRect {
left: number;
right: number;
top: number;
bottom: number;
}
/** 열 경계 x 좌표 (길이 = 열 수 + 1) */
export function columnEdges(originX: number, columnWidths: number[]): number[] {
const edges = [originX];
for (const width of columnWidths) edges.push(edges[edges.length - 1] + width);
return edges;
}
/** 행 경계 y 좌표. 표는 좌측 상단이 원점이라 아래로 내려간다 (길이 = 행 수 + 1) */
export function rowEdges(originY: number, rowHeights: number[]): number[] {
const edges = [originY];
for (const height of rowHeights) edges.push(edges[edges.length - 1] - height);
return edges;
}
const span = (value: number | undefined) => Math.max(1, Math.round(value ?? 1));
/**
* 칸마다 그 칸을 소유한 앵커 셀의 위치를 적어 둔 표.
* 병합된 칸은 앵커와 같은 값을 갖는다 — 경계선을 그릴지 판단하는 데 쓴다.
*/
export function cellOwners(cells: TableCells, rows: number, columns: number): (string | null)[][] {
const owners: (string | null)[][] = Array.from({ length: rows }, () =>
Array.from({ length: columns }, () => null as string | null)
);
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const cell = cells[row]?.[column];
if (!cell) continue;
const key = `${row}:${column}`;
const rowEnd = Math.min(rows, row + span(cell.rowSpan));
const columnEnd = Math.min(columns, column + span(cell.colSpan));
for (let r = row; r < rowEnd; r += 1) {
for (let c = column; c < columnEnd; c += 1) {
owners[r][c] = key;
}
}
}
}
return owners;
}
/** 앵커 셀이 차지하는 사각형 (병합 반영) */
export function cellRect(
origin: { x: number; y: number },
columnWidths: number[],
rowHeights: number[],
row: number,
column: number,
cell: TableCell | null
): CellRect {
const xs = columnEdges(origin.x, columnWidths);
const ys = rowEdges(origin.y, rowHeights);
const columnEnd = Math.min(columnWidths.length, column + span(cell?.colSpan));
const rowEnd = Math.min(rowHeights.length, row + span(cell?.rowSpan));
return {
left: xs[column],
right: xs[columnEnd],
top: ys[row],
bottom: ys[rowEnd],
};
}
export interface BorderSegment {
x1: number;
y1: number;
x2: number;
y2: number;
}
/**
* 그려야 할 경계선. 바깥 테두리는 항상 긋고, 안쪽 선은 양옆(위아래) 칸이 같은 병합 셀에
* 속하면 긋지 않는다 — 병합 자리에서 선을 끊는 규칙을 표가 스스로 안다.
*/
export function tableBorders(
origin: { x: number; y: number },
columnWidths: number[],
rowHeights: number[],
cells: TableCells
): BorderSegment[] {
const rows = rowHeights.length;
const columns = columnWidths.length;
if (!rows || !columns) return [];
const xs = columnEdges(origin.x, columnWidths);
const ys = rowEdges(origin.y, rowHeights);
const owners = cellOwners(cells, rows, columns);
const borders: BorderSegment[] = [];
// 바깥 테두리
borders.push({ x1: xs[0], y1: ys[0], x2: xs[columns], y2: ys[0] });
borders.push({ x1: xs[0], y1: ys[rows], x2: xs[columns], y2: ys[rows] });
borders.push({ x1: xs[0], y1: ys[0], x2: xs[0], y2: ys[rows] });
borders.push({ x1: xs[columns], y1: ys[0], x2: xs[columns], y2: ys[rows] });
// 안쪽 세로선
for (let column = 1; column < columns; column += 1) {
for (let row = 0; row < rows; row += 1) {
const left = owners[row][column - 1];
const right = owners[row][column];
if (left !== null && left === right) continue; // 병합 구간 — 선을 끊는다
borders.push({ x1: xs[column], y1: ys[row], x2: xs[column], y2: ys[row + 1] });
}
}
// 안쪽 가로선
for (let row = 1; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const above = owners[row - 1][column];
const below = owners[row][column];
if (above !== null && above === below) continue;
borders.push({ x1: xs[column], y1: ys[row], x2: xs[column + 1], y2: ys[row] });
}
}
return borders;
}
/** 격자 교차점 — 스냅점으로 쓴다 */
export function gridPoints(
origin: { x: number; y: number },
columnWidths: number[],
rowHeights: number[]
): { x: number; y: number }[] {
const points: { x: number; y: number }[] = [];
for (const x of columnEdges(origin.x, columnWidths)) {
for (const y of rowEdges(origin.y, rowHeights)) points.push({ x, y });
}
return points;
}
/** 셀 문자를 놓을 자리. align에 따라 좌우 여백을 준다 */
export function cellTextAnchor(
rect: CellRect,
align: TableCell['align'] = 'center',
padding = 1
): { x: number; y: number } {
const y = (rect.top + rect.bottom) / 2;
if (align === 'left') return { x: rect.left + padding, y };
if (align === 'right') return { x: rect.right - padding, y };
return { x: (rect.left + rect.right) / 2, y };
}
/** 클릭 지점이 들어 있는 칸 (앵커 위치로 돌려준다). 표 밖이면 null */
export function cellAt(
origin: { x: number; y: number },
columnWidths: number[],
rowHeights: number[],
cells: TableCells,
point: { x: number; y: number }
): { row: number; column: number } | null {
const xs = columnEdges(origin.x, columnWidths);
const ys = rowEdges(origin.y, rowHeights);
if (point.x < xs[0] || point.x > xs[xs.length - 1]) return null;
if (point.y > ys[0] || point.y < ys[ys.length - 1]) return null;
let column = columnWidths.length - 1;
for (let index = 0; index < columnWidths.length; index += 1) {
if (point.x <= xs[index + 1]) {
column = index;
break;
}
}
let row = rowHeights.length - 1;
for (let index = 0; index < rowHeights.length; index += 1) {
if (point.y >= ys[index + 1]) {
row = index;
break;
}
}
const owner = cellOwners(cells, rowHeights.length, columnWidths.length)[row][column];
if (!owner) return { row, column };
const [ownerRow, ownerColumn] = owner.split(':').map(Number);
return { row: ownerRow, column: ownerColumn };
}
/** 모든 칸이 자기 자리를 갖도록 채운 셀 표 (행·열을 늘렸을 때 쓴다) */
export function normalizeCells(cells: TableCells, rows: number, columns: number): TableCells {
// 병합 관계는 원본에서 먼저 읽는다 — 빈 칸을 채운 뒤에 읽으면 병합이 풀린다
const owners = cellOwners(cells, rows, columns);
const normalized: TableCells = [];
for (let row = 0; row < rows; row += 1) {
const line: (TableCell | null)[] = [];
for (let column = 0; column < columns; column += 1) {
const owner = owners[row][column];
const coveredByMerge = owner !== null && owner !== `${row}:${column}`;
line.push(coveredByMerge ? null : (cells[row]?.[column] ?? { text: '' }));
}
normalized.push(line);
}
return normalized;
}