표가 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>
362 lines
11 KiB
TypeScript
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>;
|
|
}
|