표가 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>
226 lines
7.6 KiB
TypeScript
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;
|
|
}
|