Files
Aislo/B07_DesignDetail/openwebcad/src/entities/TableEntity.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

362 lines
11 KiB
TypeScript

/**
* 표 객체 — 열별 폭·행별 높이·칸 문자·병합을 한 객체가 들고 있다.
* 선과 문자를 따로 두던 방식과 달리, 내보낼 때 진짜 표(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<TableStyleOptions>
) {
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<TableCell>): 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<JsonEntity<TableJsonData> | 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<TableJsonData>): Promise<TableEntity> {
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<TableStyleOptions>;
}