feat(diagram): show table and column comments on cards

This commit is contained in:
zipg
2026-09-20 19:12:51 +08:00
committed by GitHub
parent a35ceb754e
commit 6aab94b3a8
12 changed files with 273 additions and 55 deletions
@@ -35,7 +35,7 @@ import { saveDiagramBinaryExport, saveDiagramTextExport } from "@/lib/export/sav
import { inferRelationships, filterByStorage } from "@/lib/diagram/match-engine";
import { loadMatchConfirms, saveMatchConfirms, loadMatchIgnores, saveMatchIgnores, isAutoMatchEnabled } from "@/lib/diagram/match-storage";
import { toVueFlowNodes, toVueFlowEdges, toDiagramEdges, toVueFlowLayerNodes, toAbsolutePosition, isTableCanvasVisible } from "@/lib/diagram/vue-flow-adapter";
import { CARD_WIDTH, CARD_HEADER_HEIGHT, COLUMN_ROW_HEIGHT, CARD_BOTTOM_PADDING, GAP_X, GAP_Y, MARGIN, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, DIAGRAM_HOVERED_EDGE_KEY, DIAGRAM_EDGE_OBSTACLES_KEY } from "@/lib/diagram/diagram-constants";
import { CARD_WIDTH, CARD_HEADER_HEIGHT, COLUMN_ROW_HEIGHT, CARD_BOTTOM_PADDING, GAP_X, GAP_Y, MARGIN, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, DIAGRAM_HOVERED_EDGE_KEY, DIAGRAM_EDGE_OBSTACLES_KEY, diagramTableCardHeight } from "@/lib/diagram/diagram-constants";
import { sizeLayerToFit, findLayerAtPoint, placeNewLayer } from "@/lib/diagram/size-layer";
import { computeLtrAutoLayout } from "@/lib/diagram/ltr-auto-layout";
import { computeLayoutWithLayers } from "@/lib/diagram/elk-layout";
@@ -530,7 +530,9 @@ const customRelationshipCount = computed(() => customRelationships.value.length)
const matchRelationshipCount = computed(() => matchResult.value.relationships.length);
function tableHeight(table: DiagramTable): number {
return CARD_HEADER_HEIGHT + table.columns.length * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING;
// Includes the rendered table/column comment lines so canvas bounds, layers,
// edge routing and auto layout match the card height.
return diagramTableCardHeight(table);
}
const canvasSize = computed(() => {
@@ -1345,7 +1347,7 @@ async function setSelectedSchemas(values: string[]) {
if (diagramReady.value) await loadDiagram();
}
async function loadTableDiagramData(tableName: string, querySchema: string): Promise<DiagramTable> {
async function loadTableDiagramData(tableName: string, querySchema: string, tableComment?: string | null): Promise<DiagramTable> {
try {
const [columns, foreignKeys, indexes] = await Promise.all([
api.getColumns(connectionId.value, database.value, querySchema, tableName),
@@ -1355,6 +1357,7 @@ async function loadTableDiagramData(tableName: string, querySchema: string): Pro
return {
name: tableName,
schema: querySchema,
comment: tableComment ?? null,
columns,
foreignKeys,
indexes: indexes.map((index) => ({
@@ -1374,7 +1377,7 @@ async function loadTableDiagramData(tableName: string, querySchema: string): Pro
} catch (e) {
failedTableCount.value += 1;
console.warn(`[diagram] failed to load table metadata: ${querySchema}.${tableName}`, e);
return { name: tableName, schema: querySchema, columns: [], foreignKeys: [], indexes: [] };
return { name: tableName, schema: querySchema, comment: tableComment ?? null, columns: [], foreignKeys: [], indexes: [] };
}
}
@@ -1395,7 +1398,8 @@ async function loadDiagram() {
targetSchemas.map(async (sc) => {
try {
const list = await api.listTables(connectionId.value, database.value, sc);
return list.filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").map((table) => ({ name: table.name, schema: sc }));
// Keep the table comment so the card can render the table description.
return list.filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").map((table) => ({ name: table.name, schema: sc, comment: table.comment ?? null }));
} catch (e) {
console.warn(`[diagram] failed to list tables for schema ${sc}`, e);
return [];
@@ -1411,7 +1415,7 @@ async function loadDiagram() {
const loadedTables: DiagramTable[] = [];
for (let index = 0; index < baseTables.length; index += METADATA_BATCH_SIZE) {
const batch = baseTables.slice(index, index + METADATA_BATCH_SIZE);
const batchTables = await Promise.all(batch.map((item) => loadTableDiagramData(item.name, item.schema)));
const batchTables = await Promise.all(batch.map((item) => loadTableDiagramData(item.name, item.schema, item.comment)));
loadedTables.push(...batchTables);
loadedTableCount.value = loadedTables.length;
}
@@ -4,10 +4,10 @@ import { Handle, Position } from "@vue-flow/core";
import { Table2, KeyRound, Link2 } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import type { DiagramTable, DiagramRelationship } from "@/lib/diagram/erDiagram";
import { isDraftTable, isDroppedColumn } from "@/lib/diagram/erDiagram";
import { isDraftTable } from "@/lib/diagram/erDiagram";
import type { InferredRelationship } from "@/types/diagram";
import { useLayerStore } from "@/lib/diagram/layer-store";
import { CARD_WIDTH, COLUMN_TYPE_WIDTH, COLUMN_NAME_MAX_CHARS, COLUMN_TYPE_MAX_CHARS, TABLE_NAME_MAX_CHARS, EDGE_HANDLE_OUTSET } from "@/lib/diagram/diagram-constants";
import { CARD_WIDTH, COLUMN_TYPE_WIDTH, COLUMN_NAME_MAX_CHARS, COLUMN_TYPE_MAX_CHARS, TABLE_NAME_MAX_CHARS, EDGE_HANDLE_OUTSET, diagramVisibleColumns } from "@/lib/diagram/diagram-constants";
const layerStore = useLayerStore();
@@ -26,7 +26,12 @@ const emit = defineEmits<{
const isDraft = computed(() => isDraftTable(props.data.table));
function visibleColumns(table: DiagramTable) {
return table.columns.filter((column) => !isDroppedColumn(table, column.name));
return diagramVisibleColumns(table);
}
/** Comments are optional metadata; blank/whitespace-only values render nothing. */
function commentText(value: string | null | undefined): string {
return typeof value === "string" ? value.trim() : "";
}
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
@@ -86,18 +91,26 @@ const handleOffsetStyle = computed(() =>
<Badge v-if="isDraft" variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] border-amber-500/50 text-amber-700 dark:text-amber-400">Draft</Badge>
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">{{ visibleColumns(data.table).length }}</Badge>
</div>
<div v-if="commentText(data.table.comment)" class="flex h-4 items-center gap-1.5 border-b border-border/40 bg-muted/20 px-3">
<span class="min-w-0 flex-1 truncate text-[10px] leading-none text-muted-foreground" :title="commentText(data.table.comment)">{{ commentText(data.table.comment) }}</span>
</div>
<div>
<div v-for="column in visibleColumns(data.table)" :key="column.name" class="flex h-6 min-w-0 items-center gap-1.5 border-b border-border/40 px-3 text-xs last:border-b-0">
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
<Link2 v-else-if="isForeignKeyColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
<Link2 v-else-if="isRelationshipColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-muted-foreground" />
<span v-else class="h-3 w-3 shrink-0" />
<span class="min-w-0 flex-1 truncate font-mono" :title="column.name">
{{ truncateLabel(column.name, COLUMN_NAME_MAX_CHARS) }}
</span>
<span class="shrink-0 truncate text-right text-[10px] text-muted-foreground" :style="{ width: `${COLUMN_TYPE_WIDTH}px` }" :title="column.data_type">
{{ truncateLabel(column.data_type, COLUMN_TYPE_MAX_CHARS) }}
</span>
<div v-for="column in visibleColumns(data.table)" :key="column.name" class="border-b border-border/40 last:border-b-0">
<div class="flex h-6 min-w-0 items-center gap-1.5 px-3 text-xs">
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 shrink-0 text-amber-500" />
<Link2 v-else-if="isForeignKeyColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-primary" />
<Link2 v-else-if="isRelationshipColumn(data.table, column.name)" class="h-3 w-3 shrink-0 text-muted-foreground" />
<span v-else class="h-3 w-3 shrink-0" />
<span class="min-w-0 flex-1 truncate font-mono" :title="column.name">
{{ truncateLabel(column.name, COLUMN_NAME_MAX_CHARS) }}
</span>
<span class="shrink-0 truncate text-right text-[10px] text-muted-foreground" :style="{ width: `${COLUMN_TYPE_WIDTH}px` }" :title="column.data_type">
{{ truncateLabel(column.data_type, COLUMN_TYPE_MAX_CHARS) }}
</span>
</div>
<div v-if="commentText(column.comment)" class="flex h-4 items-center pb-0.5 pl-[18px] pr-3">
<span class="min-w-0 flex-1 truncate text-[10px] leading-none text-muted-foreground" :title="commentText(column.comment)">{{ commentText(column.comment) }}</span>
</div>
</div>
</div>
</div>
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { CARD_BOTTOM_PADDING, CARD_HEADER_HEIGHT, COLUMN_ROW_HEIGHT, COMMENT_LINE_HEIGHT, diagramTableCardHeight, diagramVisibleColumns, tableCardHeight } from "@/lib/diagram/diagram-constants";
import type { DiagramTable } from "@/lib/diagram/erDiagram";
function table(overrides: Partial<DiagramTable> = {}): DiagramTable {
return {
name: "copy_src",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "name", data_type: "varchar(64)", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [],
...overrides,
};
}
describe("diagramTableCardHeight", () => {
it("matches the plain card height when nothing has a comment", () => {
expect(diagramTableCardHeight(table())).toBe(tableCardHeight(2));
expect(diagramTableCardHeight(table())).toBe(CARD_HEADER_HEIGHT + 2 * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING);
});
it("adds one line for the table comment", () => {
expect(diagramTableCardHeight(table({ comment: "订单主表" }))).toBe(tableCardHeight(2) + COMMENT_LINE_HEIGHT);
});
it("adds one line per commented column", () => {
const commented = table({
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: "主键ID" },
{ name: "name", data_type: "varchar(64)", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: "下单人" },
],
});
expect(diagramTableCardHeight(commented)).toBe(tableCardHeight(2) + 2 * COMMENT_LINE_HEIGHT);
const withTableComment = table({ ...commented, comment: "测试用表" });
expect(diagramTableCardHeight(withTableComment)).toBe(tableCardHeight(2) + 3 * COMMENT_LINE_HEIGHT);
});
it("ignores blank and missing comments", () => {
const blank = table({
comment: " ",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: "" },
{ name: "name", data_type: "varchar(64)", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: "\n" },
],
});
expect(diagramTableCardHeight(blank)).toBe(tableCardHeight(2));
expect(diagramTableCardHeight(table({ comment: null }))).toBe(tableCardHeight(2));
});
it("does not count columns marked for DROP COLUMN", () => {
const dropped = table({
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: "主键ID" },
{ name: "note", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: "备注" },
],
droppedColumnNames: ["note"],
});
expect(diagramVisibleColumns(dropped).map((column) => column.name)).toEqual(["id"]);
expect(diagramTableCardHeight(dropped)).toBe(tableCardHeight(2) + COMMENT_LINE_HEIGHT);
});
it("honours caller-supplied card metrics", () => {
const commented = table({ comment: "备注" });
expect(diagramTableCardHeight(commented, { cardHeaderHeight: 44, columnRowHeight: 24, cardBottomPadding: 12, commentLineHeight: 10 })).toBe(tableCardHeight(2) + 10);
expect(tableCardHeight(3, { columnRowHeight: 10 })).toBe(CARD_HEADER_HEIGHT + 30 + CARD_BOTTOM_PADDING);
});
});
@@ -39,8 +39,47 @@ export const LAYER_CONTENT_PADDING = 20;
export const EMPTY_LAYER_WIDTH = 240;
export const EMPTY_LAYER_HEIGHT = LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING;
export function tableCardHeight(columnCount: number): number {
return CARD_HEADER_HEIGHT + columnCount * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING;
/** Extra card height for one rendered comment line (table comment / column comment). */
export const COMMENT_LINE_HEIGHT = 16;
/** Card metrics that callers (e.g. the SVG exporter) may override. */
export interface DiagramCardMetrics {
cardHeaderHeight?: number;
columnRowHeight?: number;
cardBottomPadding?: number;
commentLineHeight?: number;
}
export function tableCardHeight(columnCount: number, metrics: DiagramCardMetrics = {}): number {
return (metrics.cardHeaderHeight ?? CARD_HEADER_HEIGHT) + columnCount * (metrics.columnRowHeight ?? COLUMN_ROW_HEIGHT) + (metrics.cardBottomPadding ?? CARD_BOTTOM_PADDING);
}
/** Minimal shape needed to size a card that renders table/column comments. */
export interface DiagramCommentedTable {
comment?: string | null;
columns: readonly { name: string; comment?: string | null }[];
/** Columns marked for DROP COLUMN stay in `columns` but are not rendered. */
droppedColumnNames?: readonly string[];
}
function hasText(value: string | null | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
/** Columns actually rendered on the canvas, in card order. */
export function diagramVisibleColumns<T extends { name: string }>(table: { columns: readonly T[]; droppedColumnNames?: readonly string[] }): T[] {
const dropped = table.droppedColumnNames ?? [];
return table.columns.filter((column) => !dropped.some((name) => name === column.name));
}
/**
* Card height including the table comment line and the per-column comment lines,
* so the canvas bounds, layer boxes, edge routing and auto layout all agree with
* what `TableNode` renders.
*/
export function diagramTableCardHeight(table: DiagramCommentedTable, metrics: DiagramCardMetrics = {}): number {
const commentLines = (hasText(table.comment) ? 1 : 0) + diagramVisibleColumns(table).filter((column) => hasText(column.comment)).length;
return tableCardHeight(table.columns.length, metrics) + commentLines * (metrics.commentLineHeight ?? COMMENT_LINE_HEIGHT);
}
/** LTR wrap column count from available canvas width (at least 1). */
+8 -7
View File
@@ -1,6 +1,6 @@
import ELK from "elkjs/lib/elk.bundled.js";
import type { LayoutOptions, DiagramNode, DiagramEdge, DiagramLayer } from "@/types/diagram";
import { CARD_WIDTH, COLUMN_ROW_HEIGHT, CARD_HEADER_HEIGHT, CARD_BOTTOM_PADDING, LAYER_PADDING, LAYER_HEADER_HEIGHT } from "./diagram-constants";
import { CARD_WIDTH, LAYER_PADDING, LAYER_HEADER_HEIGHT, diagramTableCardHeight, tableCardHeight, type DiagramCommentedTable } from "./diagram-constants";
import { handlesFromWaypoints, type Point } from "./edge-obstacle-router";
const elk = new ELK();
@@ -45,8 +45,9 @@ export interface LayerLayoutInfo {
height: number;
}
function calculateTableHeight(tableColumns: unknown[]): number {
return CARD_HEADER_HEIGHT + tableColumns.length * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING;
function calculateTableHeight(table?: DiagramCommentedTable): number {
if (!table) return tableCardHeight(0);
return diagramTableCardHeight(table);
}
export async function computeLayout(nodes: DiagramNode[], edges: DiagramEdge[], options: LayoutOptions = {}): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[] }> {
@@ -121,7 +122,7 @@ export async function computeLayoutWithLayers(nodes: DiagramNode[], edges: Diagr
const pos = positionMap.get(tableName) || node?.position;
if (!pos) continue;
hasAny = true;
const height = calculateTableHeight(node?.data?.table?.columns || []);
const height = calculateTableHeight(node?.data?.table);
minX = Math.min(minX, pos.x);
minY = Math.min(minY, pos.y);
maxX = Math.max(maxX, pos.x + CARD_WIDTH);
@@ -173,7 +174,7 @@ function buildElkGraph(nodes: DiagramNode[], edges: DiagramEdge[]): ElkGraph {
const elkNodes: ElkNode[] = nodes.map((node) => ({
id: node.id,
width: CARD_WIDTH,
height: calculateTableHeight(node.data?.table?.columns || []),
height: calculateTableHeight(node.data?.table),
}));
const elkEdges: ElkEdge[] = edges.map((edge) => ({
@@ -200,7 +201,7 @@ function buildHierarchicalElkGraph(nodes: DiagramNode[], edges: DiagramEdge[], a
const elkTableNodes = layerTables.map((tableNode) => ({
id: tableNode.id,
width: CARD_WIDTH,
height: calculateTableHeight(tableNode.data?.table?.columns || []),
height: calculateTableHeight(tableNode.data?.table),
}));
layerTables.forEach((n) => tableIdSet.add(n.id));
@@ -221,7 +222,7 @@ function buildHierarchicalElkGraph(nodes: DiagramNode[], edges: DiagramEdge[], a
const unassignedElkNodes = unassignedNodes.map((node) => ({
id: node.id,
width: CARD_WIDTH,
height: calculateTableHeight(node.data?.table?.columns || []),
height: calculateTableHeight(node.data?.table),
}));
return {
@@ -17,6 +17,8 @@ export function editableStructureIndexes(table: DiagramTable): EditableStructure
export interface DiagramTable {
name: string;
schema?: string;
/** Table description (`TABLE_COMMENT` and friends); rendered under the card title. */
comment?: string | null;
columns: ColumnInfo[];
foreignKeys: ForeignKeyInfo[];
/** Unique/PK indexes used for FK cardinality; drafts also sync via buildCreateTableSql */
@@ -2,7 +2,7 @@ import type { DiagramLayer } from "@/types/diagram";
import type { DiagramPosition, DiagramTable } from "@/lib/diagram/erDiagram";
import { layoutDiagramTables } from "@/lib/diagram/erDiagram";
import { sizeLayerToFit } from "@/lib/diagram/size-layer";
import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, GAP_X, GAP_Y, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN, columnsPerRowForWidth, tableCardHeight } from "@/lib/diagram/diagram-constants";
import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, GAP_X, GAP_Y, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN, columnsPerRowForWidth, diagramTableCardHeight } from "@/lib/diagram/diagram-constants";
export interface LtrAutoLayoutInput {
tables: DiagramTable[];
@@ -30,7 +30,8 @@ type LayerBox = {
function tableHeightsMap(tables: DiagramTable[]): Record<string, number> {
const heights: Record<string, number> = {};
for (const table of tables) {
heights[table.name] = tableCardHeight(table.columns?.length ?? 0);
// Comment lines are part of the rendered card, so layers and row wrapping include them.
heights[table.name] = diagramTableCardHeight(table);
}
return heights;
}
+37 -23
View File
@@ -2,7 +2,7 @@ import type { EngineeringDiagram, EngineeringEntityNode } from "@/lib/diagram/en
import { isDroppedColumn, type DiagramPosition, type DiagramRelationship, type DiagramTable } from "@/lib/diagram/erDiagram";
import { pickHandles } from "@/lib/diagram/vue-flow-adapter";
import { pointAlongPolyline, pointsToSvgPath, type Point } from "@/lib/diagram/edge-obstacle-router";
import { CARD_BOTTOM_PADDING, CARD_HEADER_HEIGHT, CARD_WIDTH, COLUMN_ROW_HEIGHT, MARGIN } from "@/lib/diagram/diagram-constants";
import { CARD_WIDTH, COMMENT_LINE_HEIGHT, MARGIN, diagramTableCardHeight, tableCardHeight, type DiagramCardMetrics } from "@/lib/diagram/diagram-constants";
const SOURCE_CARDINALITY_T = 0.18;
const TARGET_CARDINALITY_T = 0.82;
@@ -39,6 +39,11 @@ function tableExportKey(table: DiagramSvgTable): string {
return table.diagramKey || table.name;
}
/** Comments are optional metadata; blank/whitespace-only values render nothing. */
function commentText(value: string | null | undefined): string {
return typeof value === "string" ? value.trim() : "";
}
/** Columns the card actually renders: pending-drop columns are hidden on the canvas too. */
function tableExportColumns(table: DiagramTable): DiagramTable["columns"] {
return table.columns.filter((column) => !isDroppedColumn(table, column.name));
@@ -56,15 +61,11 @@ export interface TableDiagramSvgOptions {
cardHeaderHeight: number;
columnRowHeight: number;
cardBottomPadding?: number;
/** Extra height for a rendered table/column comment line. */
commentLineHeight?: number;
layers?: DiagramSvgLayer[];
}
type CardHeightMetrics = {
cardHeaderHeight: number;
columnRowHeight: number;
cardBottomPadding?: number;
};
function escapeXml(value: string | number): string {
return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
@@ -101,11 +102,6 @@ function svgText(
return `<text ${attrs.join(" ")}>${escapeXml(label)}</text>`;
}
/** Shared table card height for SVG canvas / paths / cards. */
function svgCardHeight(columnCount: number, metrics: CardHeightMetrics): number {
return metrics.cardHeaderHeight + columnCount * metrics.columnRowHeight + (metrics.cardBottomPadding ?? CARD_BOTTOM_PADDING);
}
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
return table.foreignKeys.some((fk) => fk.column === columnName);
}
@@ -137,6 +133,7 @@ type RelationshipGeometryInput = {
cardHeaderHeight?: number;
columnRowHeight?: number;
cardBottomPadding?: number;
commentLineHeight?: number;
};
/**
@@ -144,16 +141,17 @@ type RelationshipGeometryInput = {
*/
export function buildTableRelationshipPolylines(input: RelationshipGeometryInput): Record<string, Point[]> {
const cardWidth = input.cardWidth ?? CARD_WIDTH;
const metrics: CardHeightMetrics = {
cardHeaderHeight: input.cardHeaderHeight ?? CARD_HEADER_HEIGHT,
columnRowHeight: input.columnRowHeight ?? COLUMN_ROW_HEIGHT,
cardBottomPadding: input.cardBottomPadding ?? CARD_BOTTOM_PADDING,
const metrics: DiagramCardMetrics = {
cardHeaderHeight: input.cardHeaderHeight,
columnRowHeight: input.columnRowHeight,
cardBottomPadding: input.cardBottomPadding,
commentLineHeight: input.commentLineHeight,
};
// Relationships address tables by their diagram id, cards by their table; index both so the
// routed edges match the drawn card heights in multi-schema mode too.
const heightByName = new Map<string, number>();
for (const table of input.tables) {
const height = svgCardHeight(tableExportColumns(table).length, metrics);
const height = diagramTableCardHeight(table, metrics);
heightByName.set(tableExportKey(table), height);
heightByName.set(table.name, height);
}
@@ -168,8 +166,8 @@ export function buildTableRelationshipPolylines(input: RelationshipGeometryInput
const sourcePos = input.positions[rel.sourceTable];
const targetPos = input.positions[rel.targetTable];
if (!sourcePos || !targetPos) continue;
const sh = heightByName.get(rel.sourceTable) ?? svgCardHeight(0, metrics);
const th = heightByName.get(rel.targetTable) ?? svgCardHeight(0, metrics);
const sh = heightByName.get(rel.sourceTable) ?? tableCardHeight(0, metrics);
const th = heightByName.get(rel.targetTable) ?? tableCardHeight(0, metrics);
polylines[rel.id] = orthogonalPointsBetweenTables(sourcePos, targetPos, sh, th, cardWidth);
}
return polylines;
@@ -196,6 +194,7 @@ export function computeTableDiagramCanvas(
cardHeaderHeight: number;
columnRowHeight: number;
cardBottomPadding?: number;
commentLineHeight?: number;
layers?: DiagramSvgLayer[];
relationshipPolylines?: Record<string, Point[]>;
padding?: number;
@@ -221,7 +220,7 @@ export function computeTableDiagramCanvas(
for (const table of tables) {
const pos = positions[tableExportKey(table)] ?? positions[table.name] ?? { x: 0, y: 0 };
const height = svgCardHeight(tableExportColumns(table).length, options);
const height = diagramTableCardHeight(table, options);
expand(pos.x, pos.y, pos.x + options.cardWidth, pos.y + height);
}
@@ -304,7 +303,7 @@ export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
for (const table of options.tables) {
const position = options.positions[tableExportKey(table)] ?? options.positions[table.name] ?? { x: 0, y: 0 };
const columns = tableExportColumns(table);
const height = svgCardHeight(columns.length, options);
const height = diagramTableCardHeight(table, options);
const showSchema = Boolean(table.schema) && tableExportKey(table) !== table.name;
const nameY = options.cardHeaderHeight / 2 + (showSchema ? 6 : 0);
parts.push(`<g transform="translate(${svgNumber(position.x)} ${svgNumber(position.y)})">`);
@@ -323,8 +322,17 @@ export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
}),
);
columns.forEach((column, index) => {
const rowTop = options.cardHeaderHeight + index * options.columnRowHeight;
const commentLineHeight = options.commentLineHeight ?? COMMENT_LINE_HEIGHT;
let rowTop = options.cardHeaderHeight;
const tableComment = commentText(table.comment);
if (tableComment) {
parts.push(`<rect x="0" y="${svgNumber(rowTop)}" width="${options.cardWidth}" height="${svgNumber(commentLineHeight)}" fill="#fafafa"/>`);
parts.push(`<path d="M 0 ${svgNumber(rowTop)} H ${options.cardWidth}" stroke="#e4e4e7"/>`);
parts.push(svgText(tableComment, 36, rowTop + commentLineHeight / 2, { size: 9, fill: "#71717a" }));
rowTop += commentLineHeight;
}
columns.forEach((column) => {
const rowCenter = rowTop + options.columnRowHeight / 2;
parts.push(`<path d="M 0 ${svgNumber(rowTop)} H ${options.cardWidth}" stroke="#f0f0f1"/>`);
if (column.is_primary_key) {
@@ -340,6 +348,12 @@ export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
anchor: "end",
}),
);
rowTop += options.columnRowHeight;
const columnComment = commentText(column.comment);
if (columnComment) {
parts.push(svgText(columnComment, 38, rowTop + commentLineHeight / 2, { size: 9, fill: "#71717a" }));
rowTop += commentLineHeight;
}
});
parts.push("</g>");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+76 -1
View File
@@ -4,7 +4,7 @@ import { buildEngineeringDiagram } from "../../apps/desktop/src/lib/diagram/engi
import { buildEngineeringDiagramSvg, buildTableDiagramSvg, buildTableRelationshipPaths, computeTableDiagramCanvas, diagramSvgFileName } from "../../apps/desktop/src/lib/export/diagramSvgExport.ts";
import { buildDiagramRelationships, normalizeCustomDiagramRelationship, type DiagramTable } from "../../apps/desktop/src/lib/diagram/erDiagram.ts";
import { pointsToSvgPath } from "../../apps/desktop/src/lib/diagram/edge-obstacle-router.ts";
import { CARD_BOTTOM_PADDING, CARD_HEADER_HEIGHT, CARD_WIDTH, COLUMN_ROW_HEIGHT, MARGIN } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts";
import { CARD_BOTTOM_PADDING, CARD_HEADER_HEIGHT, CARD_WIDTH, COLUMN_ROW_HEIGHT, COMMENT_LINE_HEIGHT, MARGIN, diagramTableCardHeight } from "../../apps/desktop/src/lib/diagram/diagram-constants.ts";
const tables: DiagramTable[] = [
{
@@ -466,3 +466,78 @@ test("multi-schema relationship routing measures card heights by diagram key", (
const sourceCenter = 40 + (CARD_HEADER_HEIGHT + 4 * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING) / 2;
assert.ok(path.includes(`${sourceCenter}`), `path ${path} should use the source card centre ${sourceCenter}`);
});
const commentedTables: DiagramTable[] = [
{
name: "copy_src",
comment: "复制源表",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: "主键ID" },
{ name: "name", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: "名称" },
{ name: "note", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [],
},
{
name: "copy_dst",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: "目标主键" }],
foreignKeys: [{ name: "copy_dst_id_fk", column: "id", ref_table: "copy_src", ref_column: "id" }],
},
];
test("exports table and column comments onto the cards", () => {
const relationships = buildDiagramRelationships(commentedTables);
const positions = { copy_src: { x: 40, y: 40 }, copy_dst: { x: 460, y: 40 } };
const svg = buildTableDiagramSvg({
tables: commentedTables,
relationships,
positions,
relationshipPaths: { [relationships[0].id]: "M 400 96 L 460 96" },
canvas: { width: 900, height: 400 },
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
});
assert.match(svg, />复制源表</);
assert.match(svg, />主键ID</);
assert.match(svg, />名称</);
assert.match(svg, />目标主键</);
// Cards keep growing by one comment line per rendered table/column comment.
assert.ok(svg.includes(`height="${diagramTableCardHeight(commentedTables[0])}"`));
assert.ok(svg.includes(`height="${diagramTableCardHeight(commentedTables[1])}"`));
});
test("computeTableDiagramCanvas reserves room for the rendered comment lines", () => {
const positions = { copy_src: { x: 0, y: 0 } };
const canvas = computeTableDiagramCanvas([commentedTables[0]], positions, {
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
});
// 3 columns + 1 table comment + 2 column comments = 3 comment lines
const expectedHeight = CARD_HEADER_HEIGHT + 3 * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING + 3 * COMMENT_LINE_HEIGHT;
assert.equal(canvas.height, Math.ceil(expectedHeight + 2 * MARGIN));
});
test("relationship routing measures commented card heights", () => {
const relationships = buildDiagramRelationships(commentedTables);
const positions = { copy_src: { x: 0, y: 0 }, copy_dst: { x: 400, y: 0 } };
const withComments = buildTableRelationshipPaths({
relationships,
positions,
tables: commentedTables,
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
});
const withoutComments = buildTableRelationshipPaths({
relationships,
positions,
tables: commentedTables.map((table) => ({ ...table, comment: null, columns: table.columns.map((column) => ({ ...column, comment: null })) })),
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
});
assert.notEqual(withComments[relationships[0].id], withoutComments[relationships[0].id]);
});