/** * 표 기하 — 셀 사각형과 그려야 할 경계선을 계산한다. 상태가 없는 순수 함수라 * 화면·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; }