fix(diagram): align multi-schema export positions with canvas

This commit is contained in:
zipg
2026-09-20 00:50:14 +08:00
committed by GitHub
parent ead307dba0
commit f9b984da68
4 changed files with 135 additions and 13 deletions
@@ -1843,7 +1843,9 @@ function currentDiagramSvg(): string {
prepareTableDiagramExportGeometry();
const exportTables = canvasVisibleTables.value;
// Positions are keyed by the diagram table id (`getTableKey`), which is schema-qualified in
// multi-schema mode; hand that id to the exporter so cards are not all drawn at the origin.
const exportTables = canvasVisibleTables.value.map((table) => ({ ...table, diagramKey: getTableKey(table) }));
const exportRelationships = canvasVisibleRelationships.value;
const layers = exportSvgLayers();
const geometryInput = {
+44 -12
View File
@@ -1,5 +1,5 @@
import type { EngineeringDiagram, EngineeringEntityNode } from "@/lib/diagram/engineeringDiagram";
import type { DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram";
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";
@@ -25,8 +25,27 @@ export interface DiagramSvgLayer {
height: number;
}
/**
* Table plus the id the canvas positions it by.
*
* Diagram positions are keyed by the diagram table id (`getTableKey`), which is
* schema-qualified once more than one schema is selected. Export code used to look positions
* up by the bare table name, so in multi-schema mode every card silently fell back to
* `{ x: 0, y: 0 }` and the whole diagram was exported stacked on the origin (issue #9445).
*/
export type DiagramSvgTable = DiagramTable & { diagramKey?: string };
function tableExportKey(table: DiagramSvgTable): string {
return table.diagramKey || table.name;
}
/** 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));
}
export interface TableDiagramSvgOptions {
tables: DiagramTable[];
tables: DiagramSvgTable[];
relationships: DiagramRelationship[];
positions: Record<string, DiagramPosition>;
relationshipPaths: Record<string, string>;
@@ -112,7 +131,7 @@ function orthogonalPointsBetweenTables(sourcePos: DiagramPosition, targetPos: Di
type RelationshipGeometryInput = {
relationships: DiagramRelationship[];
positions: Record<string, DiagramPosition>;
tables: DiagramTable[];
tables: DiagramSvgTable[];
waypoints?: Record<string, Point[]>;
cardWidth?: number;
cardHeaderHeight?: number;
@@ -130,7 +149,14 @@ export function buildTableRelationshipPolylines(input: RelationshipGeometryInput
columnRowHeight: input.columnRowHeight ?? COLUMN_ROW_HEIGHT,
cardBottomPadding: input.cardBottomPadding ?? CARD_BOTTOM_PADDING,
};
const heightByName = new Map(input.tables.map((t) => [t.name, svgCardHeight(t.columns.length, metrics)]));
// 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);
heightByName.set(tableExportKey(table), height);
heightByName.set(table.name, height);
}
const polylines: Record<string, Point[]> = {};
for (const rel of input.relationships) {
@@ -163,7 +189,7 @@ export function buildTableRelationshipPaths(input: RelationshipGeometryInput): R
/** Compute canvas size that fits tables + layers + relationship polylines with padding. */
export function computeTableDiagramCanvas(
tables: DiagramTable[],
tables: DiagramSvgTable[],
positions: Record<string, DiagramPosition>,
options: {
cardWidth: number;
@@ -194,8 +220,8 @@ export function computeTableDiagramCanvas(
}
for (const table of tables) {
const pos = positions[table.name] ?? { x: 0, y: 0 };
const height = svgCardHeight(table.columns.length, options);
const pos = positions[tableExportKey(table)] ?? positions[table.name] ?? { x: 0, y: 0 };
const height = svgCardHeight(tableExportColumns(table).length, options);
expand(pos.x, pos.y, pos.x + options.cardWidth, pos.y + height);
}
@@ -276,22 +302,28 @@ export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
parts.push("</g>");
for (const table of options.tables) {
const position = options.positions[table.name] ?? { x: 0, y: 0 };
const height = svgCardHeight(table.columns.length, options);
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 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)})">`);
parts.push(`<rect width="${options.cardWidth}" height="${svgNumber(height)}" rx="6" fill="#ffffff" stroke="#d4d4d8"/>`);
parts.push(`<rect width="${options.cardWidth}" height="${options.cardHeaderHeight}" rx="6" fill="#f4f4f5"/>`);
parts.push(`<path d="M 0 ${options.cardHeaderHeight} H ${options.cardWidth}" stroke="#e4e4e7"/>`);
parts.push(svgText(table.name, 36, options.cardHeaderHeight / 2, { size: 13, weight: "600" }));
if (showSchema) {
parts.push(svgText(table.schema ?? "", 36, options.cardHeaderHeight / 2 - 6, { size: 10, fill: "#71717a", family: "Menlo, Consolas, monospace" }));
}
parts.push(svgText(table.name, 36, nameY, { size: 13, weight: "600" }));
parts.push(
svgText(String(table.columns.length), options.cardWidth - 18, options.cardHeaderHeight / 2, {
svgText(String(columns.length), options.cardWidth - 18, options.cardHeaderHeight / 2, {
size: 10,
anchor: "end",
fill: "#52525b",
}),
);
table.columns.forEach((column, index) => {
columns.forEach((column, index) => {
const rowTop = options.cardHeaderHeight + index * options.columnRowHeight;
const rowCenter = rowTop + options.columnRowHeight / 2;
parts.push(`<path d="M 0 ${svgNumber(rowTop)} H ${options.cardWidth}" stroke="#f0f0f1"/>`);
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+88
View File
@@ -378,3 +378,91 @@ test("buildTableDiagramSvg normalizes negative-origin content to viewBox 0 0", (
assert.match(svg, new RegExp(`transform="translate\\(${-(canvas.originX ?? 0)} ${-(canvas.originY ?? 0)}\\)"`));
assert.match(svg, /users/);
});
// Regression for #9445: in multi-schema mode positions are keyed by the schema-qualified
// diagram id, while the exporter used to look them up by the bare table name. Every card then
// fell back to { x: 0, y: 0 } and the exported PNG showed all tables stacked on the origin.
const multiSchemaTables: DiagramTable[] = [
{
name: "aaa",
schema: "sales",
columns: [
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "name", data_type: "varchar(255)", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
{ name: "sex", data_type: "varchar(255)", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
{ name: "desc1", data_type: "varchar(255)", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
foreignKeys: [],
},
{
name: "bbb",
schema: "ops",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }],
foreignKeys: [],
},
];
const multiSchemaPositions = { "sales.aaa": { x: 40, y: 40 }, "ops.bbb": { x: 480, y: 40 } };
test("multi-schema export positions cards by the schema-qualified diagram key", () => {
const exportTables = multiSchemaTables.map((table) => ({ ...table, diagramKey: `${table.schema}.${table.name}` }));
const canvas = computeTableDiagramCanvas(exportTables, multiSchemaPositions, {
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
cardBottomPadding: CARD_BOTTOM_PADDING,
});
// Both cards sit on one row: 40 .. 840 plus MARGIN padding on each side.
assert.equal(canvas.width, 480 + CARD_WIDTH + 2 * MARGIN - 40);
assert.equal(canvas.originX, 0);
assert.equal(canvas.height, 40 + (CARD_HEADER_HEIGHT + 4 * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING) + MARGIN);
const svg = buildTableDiagramSvg({
tables: exportTables,
relationships: [],
positions: multiSchemaPositions,
relationshipPaths: {},
canvas,
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
cardBottomPadding: CARD_BOTTOM_PADDING,
});
assert.match(svg, /<g transform="translate\(40 40\)">/);
assert.match(svg, /<g transform="translate\(480 40\)">/);
// Cards must not collapse onto the origin, and the schema badge must survive the export.
assert.doesNotMatch(svg, /<g transform="translate\(0 0\)"><rect width="360"/);
assert.match(svg, />sales</);
assert.match(svg, />ops</);
});
test("multi-schema relationship routing measures card heights by diagram key", () => {
const exportTables = multiSchemaTables.map((table) => ({ ...table, diagramKey: `${table.schema}.${table.name}` }));
const relationships = [
{
id: "fk:aaa:bbb",
name: "aaa_bbb_fk",
sourceTable: "sales.aaa",
sourceColumn: "id",
targetTable: "ops.bbb",
targetColumn: "id",
},
] as unknown as Parameters<typeof buildTableRelationshipPaths>[0]["relationships"];
const paths = buildTableRelationshipPaths({
tables: exportTables,
relationships,
positions: multiSchemaPositions,
cardWidth: CARD_WIDTH,
cardHeaderHeight: CARD_HEADER_HEIGHT,
columnRowHeight: COLUMN_ROW_HEIGHT,
cardBottomPadding: CARD_BOTTOM_PADDING,
});
const path = paths[relationships[0].id];
assert.ok(path, "relationship path should be routed");
// The route must follow the source card's real vertical centre, not the 0-column height.
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}`);
});