/** * 표 객체 — 열별 폭·행별 높이·칸 문자·병합을 한 객체가 들고 있다. * 선과 문자를 따로 두던 방식과 달리, 내보낼 때 진짜 표(DXF TABLE)로 낼지 선으로 낼지를 * 그 시점에 고를 수 있다. * * ponytail: 회전·대칭은 지원하지 않는다 — 표는 축에 붙어 있고, 기울어진 표는 도면에서 * 쓰지 않는다. 필요해지면 origin과 축 벡터를 들고 다니는 쪽으로 올린다. */ import type * as Flatten from '@flatten-js/core'; import { Box, Point, Polygon, Segment } from '@flatten-js/core'; import type { Shape, SnapPoint } from '../App.types'; import { SnapPointType } from '../App.types'; import type { DrawController } from '../drawControllers/DrawController'; import { type TableCell, type TableCells, cellRect, cellTextAnchor, columnEdges, gridPoints, normalizeCells, rowEdges, tableBorders, } from '../helpers/table-geometry'; import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts'; import { type Entity, EntityName, type JsonEntity } from './Entity'; import type { LineEntity } from './LineEntity.ts'; export interface TableStyleOptions { /** 칸 문자 기본 크기 */ fontSize: number; fontFamily: string; /** 칸 문자 기본색. 셀이 color를 따로 가지면 그쪽이 이긴다 */ textColor: string; /** 문자를 칸 좌우 끝에서 띄우는 거리 */ padding: number; } export const DEFAULT_TABLE_STYLE: TableStyleOptions = { fontSize: 2.2, fontFamily: 'Noto Sans KR', textColor: '#ffffff', padding: 1, }; export class TableEntity implements Entity { public id: string = crypto.randomUUID(); public lineColor = '#fff'; public lineWidth = 1; public lineDash: number[] | undefined = undefined; public layerId: string; /** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */ public opacity?: number; /** GROUP으로 묶인 객체가 공유하는 식별자 */ public groupId?: string; private origin: Point; private columnWidths: number[]; private rowHeights: number[]; private cells: TableCells; private style: TableStyleOptions; constructor( layerId: string, origin: Point, columnWidths: number[], rowHeights: number[], cells?: TableCells, style?: Partial ) { this.layerId = layerId; this.origin = origin; this.columnWidths = [...columnWidths]; this.rowHeights = [...rowHeights]; this.cells = normalizeCells(cells ?? [], rowHeights.length, columnWidths.length); this.style = { ...DEFAULT_TABLE_STYLE, ...style }; } // ── 읽기 (명령·그립이 쓴다) public getOrigin(): Point { return this.origin; } public getColumnWidths(): number[] { return [...this.columnWidths]; } public getRowHeights(): number[] { return [...this.rowHeights]; } public getCells(): TableCells { return this.cells; } public getStyle(): TableStyleOptions { return { ...this.style }; } public getCell(row: number, column: number): TableCell | null { return this.cells[row]?.[column] ?? null; } // ── 쓰기 (표 편집 명령이 쓴다) public setCell(row: number, column: number, patch: Partial): void { const current = this.cells[row]?.[column]; if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다 this.cells[row][column] = { ...current, ...patch }; } public setColumnWidth(column: number, width: number): void { if (column < 0 || column >= this.columnWidths.length) return; this.columnWidths[column] = Math.max(1, width); } public setRowHeight(row: number, height: number): void { if (row < 0 || row >= this.rowHeights.length) return; this.rowHeights[row] = Math.max(1, height); } public insertRow(at: number, height?: number): void { const index = Math.min(Math.max(0, at), this.rowHeights.length); this.rowHeights.splice(index, 0, height ?? this.rowHeights[Math.max(0, index - 1)] ?? 5); this.cells.splice( index, 0, this.columnWidths.map(() => ({ text: '' }) as TableCell | null) ); } public deleteRow(at: number): void { if (this.rowHeights.length <= 1) return; this.rowHeights.splice(at, 1); this.cells.splice(at, 1); this.cells = normalizeCells(this.cells, this.rowHeights.length, this.columnWidths.length); } public insertColumn(at: number, width?: number): void { const index = Math.min(Math.max(0, at), this.columnWidths.length); this.columnWidths.splice(index, 0, width ?? this.columnWidths[Math.max(0, index - 1)] ?? 20); for (const row of this.cells) row.splice(index, 0, { text: '' }); } public deleteColumn(at: number): void { if (this.columnWidths.length <= 1) return; this.columnWidths.splice(at, 1); for (const row of this.cells) row.splice(at, 1); this.cells = normalizeCells(this.cells, this.rowHeights.length, this.columnWidths.length); } /** 앵커 칸에서 오른쪽·아래로 병합한다 */ public mergeCells(row: number, column: number, colSpan: number, rowSpan: number): void { const anchor = this.cells[row]?.[column]; if (!anchor) return; this.cells[row][column] = { ...anchor, colSpan: Math.max(1, colSpan), rowSpan: Math.max(1, rowSpan), }; this.cells = normalizeCells(this.cells, this.rowHeights.length, this.columnWidths.length); } public unmergeCells(row: number, column: number): void { const anchor = this.cells[row]?.[column]; if (!anchor) return; this.cells[row][column] = { ...anchor, colSpan: 1, rowSpan: 1 }; this.cells = normalizeCells(this.cells, this.rowHeights.length, this.columnWidths.length); } // ── Entity public draw( drawController: DrawController, parentHighlighted?: boolean, parentSelected?: boolean ): void { drawController.setLineStyles( parentHighlighted ?? isEntityHighlighted(this), parentSelected ?? isEntitySelected(this), this.lineColor, this.lineWidth, this.lineDash ); for (const border of tableBorders( this.origin, this.columnWidths, this.rowHeights, this.cells )) { drawController.drawLine(new Point(border.x1, border.y1), new Point(border.x2, border.y2)); } for (let row = 0; row < this.rowHeights.length; row += 1) { for (let column = 0; column < this.columnWidths.length; column += 1) { const cell = this.cells[row]?.[column]; if (!cell?.text) continue; const rect = cellRect(this.origin, this.columnWidths, this.rowHeights, row, column, cell); const anchor = cellTextAnchor(rect, cell.align, this.style.padding); drawController.drawText(cell.text, new Point(anchor.x, anchor.y), { textAlign: cell.align ?? 'center', textColor: cell.color ?? this.style.textColor, fontSize: cell.fontSize ?? this.style.fontSize, fontFamily: this.style.fontFamily, bold: cell.bold, italic: cell.italic, }); } } } public move(x: number, y: number): void { this.origin = new Point(this.origin.x + x, this.origin.y + y); } public scale(scaleOrigin: Point, scaleFactor: number): void { this.origin = new Point( scaleOrigin.x + (this.origin.x - scaleOrigin.x) * scaleFactor, scaleOrigin.y + (this.origin.y - scaleOrigin.y) * scaleFactor ); this.columnWidths = this.columnWidths.map((width) => width * scaleFactor); this.rowHeights = this.rowHeights.map((height) => height * scaleFactor); this.style = { ...this.style, fontSize: this.style.fontSize * scaleFactor }; } public rotate(_rotateOrigin: Point, _angle: number): void { // 표는 축에 붙어 있다 — 회전하지 않는다 (파일 머리 주석 참고) } public mirror(_mirrorAxis: LineEntity): void { // 표는 대칭하지 않는다 — 문자가 뒤집히면 읽을 수 없다 } public clone(): TableEntity { return new TableEntity( getActiveLayerId(), this.origin.clone(), this.columnWidths, this.rowHeights, this.cells.map((row) => row.map((cell) => (cell ? { ...cell } : null))), this.style ); } private borderSegments(): Segment[] { return tableBorders(this.origin, this.columnWidths, this.rowHeights, this.cells).map( (border) => new Segment(new Point(border.x1, border.y1), new Point(border.x2, border.y2)) ); } private outerPolygon(): Polygon { const xs = columnEdges(this.origin.x, this.columnWidths); const ys = rowEdges(this.origin.y, this.rowHeights); const left = xs[0]; const right = xs[xs.length - 1]; const top = ys[0]; const bottom = ys[ys.length - 1]; return new Polygon([ new Point(left, top), new Point(right, top), new Point(right, bottom), new Point(left, bottom), ]); } public intersectsWithBox(selectionBox: Box): boolean { return this.borderSegments().some((segment) => segment.intersect(selectionBox).length > 0); } public isContainedInBox(selectionBox: Box): boolean { return selectionBox.contains(this.getBoundingBox()); } public distanceTo(shape: Shape): [number, Segment] | null { let shortest: [number, Segment] | null = null; for (const segment of this.borderSegments()) { const info = segment.distanceTo(shape); if (!shortest || info[0] < shortest[0]) shortest = info as [number, Segment]; } return shortest; } public getBoundingBox(): Box { const xs = columnEdges(this.origin.x, this.columnWidths); const ys = rowEdges(this.origin.y, this.rowHeights); return new Box(xs[0], ys[ys.length - 1], xs[xs.length - 1], ys[0]); } public getShape(): Shape | null { return this.outerPolygon(); } public getSnapPoints(): SnapPoint[] { return gridPoints(this.origin, this.columnWidths, this.rowHeights).map((point) => ({ point: new Point(point.x, point.y), type: SnapPointType.LineEndPoint, })); } public getIntersections(entity: Entity): Point[] { const otherShape = entity.getShape(); if (!otherShape) return []; return this.borderSegments().flatMap((segment) => segment.intersect(otherShape)); } public getFirstPoint(): Point | null { return this.origin; } public getSvgString(): string | null { // SVG 내보내기는 draw()를 SvgDrawController로 다시 태우므로 여기서 만들 필요가 없다 return null; } public getType(): EntityName { return EntityName.Table; } public containsPointOnShape(point: Flatten.Point): boolean { return this.borderSegments().some((segment) => segment.contains(point)); } public async toJson(): Promise | null> { return { id: this.id, type: EntityName.Table, lineColor: this.lineColor, lineWidth: this.lineWidth, lineDash: this.lineDash, layerId: this.layerId, shapeData: { origin: { x: this.origin.x, y: this.origin.y }, columnWidths: [...this.columnWidths], rowHeights: [...this.rowHeights], cells: this.cells.map((row) => row.map((cell) => (cell ? { ...cell } : null))), style: { ...this.style }, }, }; } public static async fromJson(jsonEntity: JsonEntity): Promise { if (!jsonEntity.shapeData) { throw new Error('Invalid JSON entity of type Table: missing shapeData'); } const data = jsonEntity.shapeData; const table = new TableEntity( jsonEntity.layerId || getActiveLayerId(), new Point(data.origin.x, data.origin.y), data.columnWidths, data.rowHeights, data.cells, data.style ); table.id = jsonEntity.id; table.lineColor = jsonEntity.lineColor; table.lineWidth = jsonEntity.lineWidth; table.lineDash = jsonEntity.lineDash; return table; } } export interface TableJsonData { origin: { x: number; y: number }; columnWidths: number[]; rowHeights: number[]; cells: TableCells; style?: Partial; }