mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-01 00:25:38 +00:00
add database relationship diagrams
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import type { EngineeringDiagram, EngineeringEntityNode } from "./engineeringDiagram";
|
||||
import type { DiagramPosition, DiagramRelationship, DiagramTable } from "./erDiagram";
|
||||
|
||||
type DiagramSvgMode = "table" | "engineering";
|
||||
|
||||
interface DiagramCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TableDiagramSvgOptions {
|
||||
tables: DiagramTable[];
|
||||
relationships: DiagramRelationship[];
|
||||
positions: Record<string, DiagramPosition>;
|
||||
relationshipPaths: Record<string, string>;
|
||||
canvas: DiagramCanvas;
|
||||
cardWidth: number;
|
||||
cardHeaderHeight: number;
|
||||
columnRowHeight: number;
|
||||
maxVisibleColumns: number;
|
||||
cardBottomPadding?: number;
|
||||
moreColumnsLabel?: (count: number) => string;
|
||||
}
|
||||
|
||||
function escapeXml(value: string | number): string {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function svgNumber(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function svgHeader(canvas: DiagramCanvas): string {
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgNumber(canvas.width)}" height="${svgNumber(canvas.height)}" viewBox="0 0 ${svgNumber(canvas.width)} ${svgNumber(canvas.height)}">`,
|
||||
"<rect width=\"100%\" height=\"100%\" fill=\"#fafafa\"/>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function svgText(
|
||||
label: string,
|
||||
x: number,
|
||||
y: number,
|
||||
options: {
|
||||
size?: number;
|
||||
fill?: string;
|
||||
weight?: string;
|
||||
anchor?: "start" | "middle" | "end";
|
||||
family?: string;
|
||||
decoration?: string;
|
||||
} = {},
|
||||
): string {
|
||||
const attrs = [
|
||||
`x="${svgNumber(x)}"`,
|
||||
`y="${svgNumber(y)}"`,
|
||||
`fill="${options.fill ?? "#18181b"}"`,
|
||||
`font-size="${options.size ?? 12}"`,
|
||||
`font-family="${options.family ?? "Arial, Helvetica, sans-serif"}"`,
|
||||
"dominant-baseline=\"middle\"",
|
||||
];
|
||||
if (options.weight) attrs.push(`font-weight="${options.weight}"`);
|
||||
if (options.anchor) attrs.push(`text-anchor="${options.anchor}"`);
|
||||
if (options.decoration) attrs.push(`text-decoration="${options.decoration}"`);
|
||||
return `<text ${attrs.join(" ")}>${escapeXml(label)}</text>`;
|
||||
}
|
||||
|
||||
function tableHeight(table: DiagramTable, options: TableDiagramSvgOptions): number {
|
||||
const visibleCount = Math.min(table.columns.length, options.maxVisibleColumns);
|
||||
const overflowHeight = table.columns.length > options.maxVisibleColumns ? options.columnRowHeight : 0;
|
||||
return options.cardHeaderHeight +
|
||||
visibleCount * options.columnRowHeight +
|
||||
overflowHeight +
|
||||
(options.cardBottomPadding ?? 12);
|
||||
}
|
||||
|
||||
function tableDiagramDefs(): string {
|
||||
return [
|
||||
"<defs>",
|
||||
"<marker id=\"dbx-diagram-arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"4\" orient=\"auto\" markerUnits=\"strokeWidth\">",
|
||||
"<path d=\"M 0 0 L 8 4 L 0 8 z\" fill=\"#2563eb\"/>",
|
||||
"</marker>",
|
||||
"</defs>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean {
|
||||
return table.foreignKeys.some((fk) => fk.column === columnName);
|
||||
}
|
||||
|
||||
export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string {
|
||||
const parts = [
|
||||
svgHeader(options.canvas),
|
||||
tableDiagramDefs(),
|
||||
"<g fill=\"none\" stroke=\"#2563eb\" stroke-opacity=\"0.58\" stroke-width=\"1.6\">",
|
||||
];
|
||||
|
||||
for (const relationship of options.relationships) {
|
||||
const path = options.relationshipPaths[relationship.id];
|
||||
if (!path) continue;
|
||||
parts.push(
|
||||
`<path d="${escapeXml(path)}" marker-end="url(#dbx-diagram-arrow)">` +
|
||||
`<title>${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`)}</title>` +
|
||||
"</path>",
|
||||
);
|
||||
}
|
||||
parts.push("</g>");
|
||||
|
||||
for (const table of options.tables) {
|
||||
const position = options.positions[table.name] ?? { x: 0, y: 0 };
|
||||
const height = tableHeight(table, options);
|
||||
const visibleColumns = table.columns.slice(0, options.maxVisibleColumns);
|
||||
const hiddenCount = Math.max(0, table.columns.length - options.maxVisibleColumns);
|
||||
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" }));
|
||||
parts.push(svgText(String(table.columns.length), options.cardWidth - 18, options.cardHeaderHeight / 2, {
|
||||
size: 10,
|
||||
anchor: "end",
|
||||
fill: "#52525b",
|
||||
}));
|
||||
|
||||
visibleColumns.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"/>`);
|
||||
if (column.is_primary_key) {
|
||||
parts.push(svgText("PK", 14, rowCenter, { size: 9, fill: "#d97706", weight: "700" }));
|
||||
} else if (isForeignKeyColumn(table, column.name)) {
|
||||
parts.push(svgText("FK", 14, rowCenter, { size: 9, fill: "#2563eb", weight: "700" }));
|
||||
}
|
||||
parts.push(svgText(column.name, 38, rowCenter, { size: 11, family: "Menlo, Consolas, monospace" }));
|
||||
parts.push(svgText(column.data_type, options.cardWidth - 12, rowCenter, {
|
||||
size: 10,
|
||||
fill: "#71717a",
|
||||
anchor: "end",
|
||||
}));
|
||||
});
|
||||
|
||||
if (hiddenCount > 0) {
|
||||
const y = options.cardHeaderHeight + visibleColumns.length * options.columnRowHeight + options.columnRowHeight / 2;
|
||||
parts.push(svgText(options.moreColumnsLabel?.(hiddenCount) ?? `+ ${hiddenCount} columns`, 12, y, {
|
||||
size: 11,
|
||||
fill: "#71717a",
|
||||
}));
|
||||
}
|
||||
parts.push("</g>");
|
||||
}
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function nodeCenter(node: { x: number; y: number; width: number; height: number }): DiagramPosition {
|
||||
return {
|
||||
x: node.x + node.width / 2,
|
||||
y: node.y + node.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function cardinalityPoint(from: DiagramPosition, to: DiagramPosition): DiagramPosition {
|
||||
return {
|
||||
x: from.x + (to.x - from.x) * 0.72,
|
||||
y: from.y + (to.y - from.y) * 0.72,
|
||||
};
|
||||
}
|
||||
|
||||
function entityCenterMap(entities: EngineeringEntityNode[]): Map<string, DiagramPosition> {
|
||||
return new Map(entities.map((entity) => [entity.name, nodeCenter(entity)]));
|
||||
}
|
||||
|
||||
export function buildEngineeringDiagramSvg(diagram: EngineeringDiagram): string {
|
||||
const parts = [svgHeader(diagram.canvas)];
|
||||
const centers = entityCenterMap(diagram.entities);
|
||||
|
||||
parts.push("<g stroke=\"#52525b\" stroke-width=\"1.2\">");
|
||||
for (const attribute of diagram.attributes) {
|
||||
const from = centers.get(attribute.tableName);
|
||||
if (!from) continue;
|
||||
const to = nodeCenter(attribute);
|
||||
parts.push(`<line x1="${svgNumber(from.x)}" y1="${svgNumber(from.y)}" x2="${svgNumber(to.x)}" y2="${svgNumber(to.y)}"/>`);
|
||||
}
|
||||
for (const relationship of diagram.relationships) {
|
||||
const source = centers.get(relationship.sourceTable);
|
||||
const target = centers.get(relationship.targetTable);
|
||||
if (!source || !target) continue;
|
||||
const middle = nodeCenter(relationship);
|
||||
parts.push(`<line x1="${svgNumber(source.x)}" y1="${svgNumber(source.y)}" x2="${svgNumber(middle.x)}" y2="${svgNumber(middle.y)}"/>`);
|
||||
parts.push(`<line x1="${svgNumber(middle.x)}" y1="${svgNumber(middle.y)}" x2="${svgNumber(target.x)}" y2="${svgNumber(target.y)}"/>`);
|
||||
const sourceLabel = cardinalityPoint(middle, source);
|
||||
const targetLabel = cardinalityPoint(middle, target);
|
||||
parts.push(svgText(relationship.sourceCardinality, sourceLabel.x, sourceLabel.y - 8, { size: 13, weight: "700", anchor: "middle" }));
|
||||
parts.push(svgText(relationship.targetCardinality, targetLabel.x, targetLabel.y - 8, { size: 13, weight: "700", anchor: "middle" }));
|
||||
}
|
||||
parts.push("</g>");
|
||||
|
||||
for (const attribute of diagram.attributes) {
|
||||
parts.push(
|
||||
`<ellipse cx="${svgNumber(attribute.x + attribute.width / 2)}" cy="${svgNumber(attribute.y + attribute.height / 2)}" ` +
|
||||
`rx="${svgNumber(attribute.width / 2)}" ry="${svgNumber(attribute.height / 2)}" fill="#dcfce7" stroke="#16a34a" stroke-opacity="0.65"/>`,
|
||||
);
|
||||
parts.push(svgText(attribute.label, attribute.x + attribute.width / 2, attribute.y + attribute.height / 2, {
|
||||
size: 11,
|
||||
fill: "#052e16",
|
||||
weight: attribute.primaryKey ? "700" : undefined,
|
||||
anchor: "middle",
|
||||
decoration: attribute.primaryKey ? "underline" : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
for (const relationship of diagram.relationships) {
|
||||
const cx = relationship.x + relationship.width / 2;
|
||||
const cy = relationship.y + relationship.height / 2;
|
||||
const points = [
|
||||
[cx, relationship.y],
|
||||
[relationship.x + relationship.width, cy],
|
||||
[cx, relationship.y + relationship.height],
|
||||
[relationship.x, cy],
|
||||
].map(([x, y]) => `${svgNumber(x)},${svgNumber(y)}`).join(" ");
|
||||
parts.push(`<polygon points="${points}" fill="#fee2e2" stroke="#ef4444" stroke-opacity="0.7"/>`);
|
||||
parts.push(svgText(relationship.label, cx, cy, {
|
||||
size: 11,
|
||||
fill: "#450a0a",
|
||||
weight: "600",
|
||||
anchor: "middle",
|
||||
}));
|
||||
}
|
||||
|
||||
for (const entity of diagram.entities) {
|
||||
parts.push(`<rect x="${svgNumber(entity.x)}" y="${svgNumber(entity.y)}" width="${entity.width}" height="${entity.height}" fill="#dbeafe" stroke="#3b82f6" stroke-opacity="0.7"/>`);
|
||||
parts.push(svgText(entity.name, entity.x + entity.width / 2, entity.y + entity.height / 2, {
|
||||
size: 13,
|
||||
fill: "#172554",
|
||||
weight: "700",
|
||||
anchor: "middle",
|
||||
}));
|
||||
}
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function fileToken(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function diagramSvgFileName(connectionName: string, databaseName: string, mode: DiagramSvgMode): string {
|
||||
const context = [connectionName, databaseName].map(fileToken).filter(Boolean);
|
||||
const suffix = mode === "engineering" ? "engineering-er" : "table-structure";
|
||||
return ["dbx", ...(context.length > 0 ? context : ["diagram"]), suffix].join("-") + ".svg";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const DIAGRAM_MIN_ZOOM = 0.6;
|
||||
export const DIAGRAM_MAX_ZOOM = 1.5;
|
||||
const WHEEL_ZOOM_SENSITIVITY = 0.003;
|
||||
|
||||
export function clampDiagramZoom(value: number): number {
|
||||
const clamped = Math.min(DIAGRAM_MAX_ZOOM, Math.max(DIAGRAM_MIN_ZOOM, value));
|
||||
return Number(clamped.toFixed(2));
|
||||
}
|
||||
|
||||
export function zoomFromWheelDelta(currentZoom: number, deltaY: number): number {
|
||||
return clampDiagramZoom(currentZoom * Math.exp(-deltaY * WHEEL_ZOOM_SENSITIVITY));
|
||||
}
|
||||
|
||||
export function zoomFromGestureScale(startZoom: number, scale: number): number {
|
||||
return clampDiagramZoom(startZoom * scale);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import type { DiagramPosition, DiagramRelationship, DiagramTable } from "./erDiagram";
|
||||
|
||||
export const ENGINEERING_ENTITY_WIDTH = 184;
|
||||
export const ENGINEERING_ENTITY_HEIGHT = 58;
|
||||
export const ENGINEERING_ATTRIBUTE_HEIGHT = 34;
|
||||
export const ENGINEERING_RELATIONSHIP_WIDTH = 104;
|
||||
export const ENGINEERING_RELATIONSHIP_HEIGHT = 58;
|
||||
|
||||
const ATTRIBUTE_MIN_WIDTH = 96;
|
||||
const ATTRIBUTE_MAX_WIDTH = 156;
|
||||
const ATTRIBUTE_GAP_X = 18;
|
||||
const ATTRIBUTE_GAP_Y = 12;
|
||||
const ATTRIBUTE_ENTITY_GAP = 38;
|
||||
const HORIZONTAL_ATTRIBUTE_COLUMNS = 4;
|
||||
const ENGINEERING_CLUSTER_GAP_X = 120;
|
||||
const ENGINEERING_CLUSTER_GAP_Y = 100;
|
||||
const CANVAS_PADDING = 80;
|
||||
|
||||
type AttributeSide = "top" | "right" | "bottom" | "left";
|
||||
type EngineeringColumn = DiagramTable["columns"][number];
|
||||
|
||||
interface AttributeDraft {
|
||||
column: EngineeringColumn;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface BlockSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface EngineeringCluster {
|
||||
tableName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
entityX: number;
|
||||
entityY: number;
|
||||
attributes: EngineeringAttributeNode[];
|
||||
}
|
||||
|
||||
export interface EngineeringEntityNode {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface EngineeringAttributeNode {
|
||||
id: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
label: string;
|
||||
dataType: string;
|
||||
primaryKey: boolean;
|
||||
foreignKey: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface EngineeringRelationshipNode {
|
||||
id: string;
|
||||
label: string;
|
||||
sourceTable: string;
|
||||
targetTable: string;
|
||||
sourceCardinality: "1" | "N";
|
||||
targetCardinality: "1" | "N";
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface EngineeringDiagram {
|
||||
entities: EngineeringEntityNode[];
|
||||
attributes: EngineeringAttributeNode[];
|
||||
relationships: EngineeringRelationshipNode[];
|
||||
canvas: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
function attributeWidth(label: string): number {
|
||||
return Math.min(ATTRIBUTE_MAX_WIDTH, Math.max(ATTRIBUTE_MIN_WIDTH, label.length * 10 + 30));
|
||||
}
|
||||
|
||||
function relationshipLabel(relationship: DiagramRelationship): string {
|
||||
if (relationship.name && relationship.name.length <= 16) return relationship.name;
|
||||
return relationship.sourceColumn || "rel";
|
||||
}
|
||||
|
||||
function entityCenter(entity: EngineeringEntityNode): DiagramPosition {
|
||||
return {
|
||||
x: entity.x + entity.width / 2,
|
||||
y: entity.y + entity.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function chunkAttributes(items: AttributeDraft[], size: number): AttributeDraft[][] {
|
||||
const rows: AttributeDraft[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
rows.push(items.slice(index, index + size));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function rowWidth(items: AttributeDraft[]): number {
|
||||
if (items.length === 0) return 0;
|
||||
return items.reduce((width, item) => width + item.width, 0) +
|
||||
(items.length - 1) * ATTRIBUTE_GAP_X;
|
||||
}
|
||||
|
||||
function horizontalBlockSize(items: AttributeDraft[]): BlockSize {
|
||||
if (items.length === 0) return { width: 0, height: 0 };
|
||||
const rows = chunkAttributes(items, HORIZONTAL_ATTRIBUTE_COLUMNS);
|
||||
return {
|
||||
width: Math.max(...rows.map(rowWidth)),
|
||||
height: rows.length * ENGINEERING_ATTRIBUTE_HEIGHT + (rows.length - 1) * ATTRIBUTE_GAP_Y,
|
||||
};
|
||||
}
|
||||
|
||||
function verticalBlockSize(items: AttributeDraft[]): BlockSize {
|
||||
if (items.length === 0) return { width: 0, height: 0 };
|
||||
return {
|
||||
width: Math.max(...items.map((item) => item.width)),
|
||||
height: items.length * ENGINEERING_ATTRIBUTE_HEIGHT + (items.length - 1) * ATTRIBUTE_GAP_Y,
|
||||
};
|
||||
}
|
||||
|
||||
function sideGap(block: BlockSize): number {
|
||||
return block.width > 0 && block.height > 0 ? ATTRIBUTE_ENTITY_GAP : 0;
|
||||
}
|
||||
|
||||
function distributeAttributes(table: DiagramTable): Record<AttributeSide, AttributeDraft[]> {
|
||||
const sides: AttributeSide[] = ["top", "right", "bottom", "left"];
|
||||
const groups: Record<AttributeSide, AttributeDraft[]> = {
|
||||
top: [],
|
||||
right: [],
|
||||
bottom: [],
|
||||
left: [],
|
||||
};
|
||||
|
||||
table.columns.forEach((column, index) => {
|
||||
groups[sides[index % sides.length]].push({
|
||||
column,
|
||||
width: attributeWidth(column.name),
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function localAttributeNode(
|
||||
table: DiagramTable,
|
||||
item: AttributeDraft,
|
||||
x: number,
|
||||
y: number,
|
||||
): EngineeringAttributeNode {
|
||||
return {
|
||||
id: `${table.name}:${item.column.name}`,
|
||||
tableName: table.name,
|
||||
columnName: item.column.name,
|
||||
label: item.column.name,
|
||||
dataType: item.column.data_type,
|
||||
primaryKey: item.column.is_primary_key,
|
||||
foreignKey: table.foreignKeys.some((fk) => fk.column === item.column.name),
|
||||
x,
|
||||
y,
|
||||
width: item.width,
|
||||
height: ENGINEERING_ATTRIBUTE_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEngineeringCluster(table: DiagramTable): EngineeringCluster {
|
||||
const groups = distributeAttributes(table);
|
||||
const topSize = horizontalBlockSize(groups.top);
|
||||
const rightSize = verticalBlockSize(groups.right);
|
||||
const bottomSize = horizontalBlockSize(groups.bottom);
|
||||
const leftSize = verticalBlockSize(groups.left);
|
||||
|
||||
const leftGap = sideGap(leftSize);
|
||||
const rightGap = sideGap(rightSize);
|
||||
const topGap = sideGap(topSize);
|
||||
const bottomGap = sideGap(bottomSize);
|
||||
const centerWidth = Math.max(ENGINEERING_ENTITY_WIDTH, topSize.width, bottomSize.width);
|
||||
const centerHeight = Math.max(ENGINEERING_ENTITY_HEIGHT, leftSize.height, rightSize.height);
|
||||
const centerX = leftSize.width + leftGap;
|
||||
const centerY = topSize.height + topGap;
|
||||
const entityX = centerX + centerWidth / 2 - ENGINEERING_ENTITY_WIDTH / 2;
|
||||
const entityY = centerY + centerHeight / 2 - ENGINEERING_ENTITY_HEIGHT / 2;
|
||||
const attributes: EngineeringAttributeNode[] = [];
|
||||
|
||||
chunkAttributes(groups.top, HORIZONTAL_ATTRIBUTE_COLUMNS).forEach((row, rowIndex) => {
|
||||
let x = centerX + (centerWidth - rowWidth(row)) / 2;
|
||||
const y = rowIndex * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y);
|
||||
row.forEach((item) => {
|
||||
attributes.push(localAttributeNode(table, item, x, y));
|
||||
x += item.width + ATTRIBUTE_GAP_X;
|
||||
});
|
||||
});
|
||||
|
||||
groups.right.forEach((item, index) => {
|
||||
attributes.push(localAttributeNode(
|
||||
table,
|
||||
item,
|
||||
centerX + centerWidth + rightGap + (rightSize.width - item.width) / 2,
|
||||
centerY + (centerHeight - rightSize.height) / 2 + index * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y),
|
||||
));
|
||||
});
|
||||
|
||||
chunkAttributes(groups.bottom, HORIZONTAL_ATTRIBUTE_COLUMNS).forEach((row, rowIndex) => {
|
||||
let x = centerX + (centerWidth - rowWidth(row)) / 2;
|
||||
const y = centerY + centerHeight + bottomGap + rowIndex * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y);
|
||||
row.forEach((item) => {
|
||||
attributes.push(localAttributeNode(table, item, x, y));
|
||||
x += item.width + ATTRIBUTE_GAP_X;
|
||||
});
|
||||
});
|
||||
|
||||
groups.left.forEach((item, index) => {
|
||||
attributes.push(localAttributeNode(
|
||||
table,
|
||||
item,
|
||||
(leftSize.width - item.width) / 2,
|
||||
centerY + (centerHeight - leftSize.height) / 2 + index * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y),
|
||||
));
|
||||
});
|
||||
|
||||
return {
|
||||
tableName: table.name,
|
||||
width: leftSize.width + leftGap + centerWidth + rightGap + rightSize.width,
|
||||
height: topSize.height + topGap + centerHeight + bottomGap + bottomSize.height,
|
||||
entityX,
|
||||
entityY,
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
function positionKey(value: number): string {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
|
||||
function orderedTableRows(tables: DiagramTable[], positions: Record<string, DiagramPosition>): DiagramTable[][] {
|
||||
const columnsPerRow = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(Math.max(tables.length, 1)))));
|
||||
const ordered = tables.map((table, fallbackIndex) => ({
|
||||
table,
|
||||
position: positions[table.name] ?? {
|
||||
x: fallbackIndex % columnsPerRow,
|
||||
y: Math.floor(fallbackIndex / columnsPerRow),
|
||||
},
|
||||
}));
|
||||
const ys = [...new Set(ordered.map((item) => positionKey(item.position.y)))]
|
||||
.sort((left, right) => Number(left) - Number(right));
|
||||
|
||||
return ys.map((y) =>
|
||||
ordered
|
||||
.filter((item) => positionKey(item.position.y) === y)
|
||||
.sort((left, right) => left.position.x - right.position.x)
|
||||
.map((item) => item.table)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDiagram(diagram: Omit<EngineeringDiagram, "canvas">): EngineeringDiagram {
|
||||
const rects = [
|
||||
...diagram.entities,
|
||||
...diagram.attributes,
|
||||
...diagram.relationships,
|
||||
];
|
||||
if (rects.length === 0) {
|
||||
return {
|
||||
...diagram,
|
||||
canvas: {
|
||||
width: CANVAS_PADDING * 2,
|
||||
height: CANVAS_PADDING * 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const minX = Math.min(...rects.map((rect) => rect.x));
|
||||
const minY = Math.min(...rects.map((rect) => rect.y));
|
||||
const maxX = Math.max(...rects.map((rect) => rect.x + rect.width));
|
||||
const maxY = Math.max(...rects.map((rect) => rect.y + rect.height));
|
||||
const dx = CANVAS_PADDING - minX;
|
||||
const dy = CANVAS_PADDING - minY;
|
||||
|
||||
const shift = <T extends { x: number; y: number }>(node: T): T => ({
|
||||
...node,
|
||||
x: node.x + dx,
|
||||
y: node.y + dy,
|
||||
});
|
||||
|
||||
return {
|
||||
entities: diagram.entities.map(shift),
|
||||
attributes: diagram.attributes.map(shift),
|
||||
relationships: diagram.relationships.map(shift),
|
||||
canvas: {
|
||||
width: maxX + dx + CANVAS_PADDING,
|
||||
height: maxY + dy + CANVAS_PADDING,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEngineeringDiagram(
|
||||
tables: DiagramTable[],
|
||||
relationships: DiagramRelationship[],
|
||||
positions: Record<string, DiagramPosition>,
|
||||
): EngineeringDiagram {
|
||||
const clusters = new Map(tables.map((table) => [table.name, buildEngineeringCluster(table)]));
|
||||
const rows = orderedTableRows(tables, positions);
|
||||
const entities: EngineeringEntityNode[] = [];
|
||||
const attributes: EngineeringAttributeNode[] = [];
|
||||
let nextRowY = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
const rowClusters = row
|
||||
.map((table) => clusters.get(table.name))
|
||||
.filter((cluster): cluster is EngineeringCluster => cluster !== undefined);
|
||||
const rowHeight = Math.max(...rowClusters.map((cluster) => cluster.height), ENGINEERING_ENTITY_HEIGHT);
|
||||
let nextX = 0;
|
||||
|
||||
rowClusters.forEach((cluster) => {
|
||||
const originX = nextX;
|
||||
const originY = nextRowY + (rowHeight - cluster.height) / 2;
|
||||
|
||||
entities.push({
|
||||
id: cluster.tableName,
|
||||
name: cluster.tableName,
|
||||
x: originX + cluster.entityX,
|
||||
y: originY + cluster.entityY,
|
||||
width: ENGINEERING_ENTITY_WIDTH,
|
||||
height: ENGINEERING_ENTITY_HEIGHT,
|
||||
});
|
||||
attributes.push(...cluster.attributes.map((attribute) => ({
|
||||
...attribute,
|
||||
x: originX + attribute.x,
|
||||
y: originY + attribute.y,
|
||||
})));
|
||||
|
||||
nextX += cluster.width + ENGINEERING_CLUSTER_GAP_X;
|
||||
});
|
||||
|
||||
nextRowY += rowHeight + ENGINEERING_CLUSTER_GAP_Y;
|
||||
});
|
||||
const entityMap = new Map(entities.map((entity) => [entity.name, entity]));
|
||||
const orderedEntities = tables
|
||||
.map((table) => entityMap.get(table.name))
|
||||
.filter((entity): entity is EngineeringEntityNode => entity !== undefined);
|
||||
|
||||
const relationshipNodes: EngineeringRelationshipNode[] = relationships.flatMap((relationship) => {
|
||||
const source = entityMap.get(relationship.sourceTable);
|
||||
const target = entityMap.get(relationship.targetTable);
|
||||
if (!source || !target) return [];
|
||||
|
||||
const sourceCenter = entityCenter(source);
|
||||
const targetCenter = entityCenter(target);
|
||||
return [{
|
||||
id: relationship.id,
|
||||
label: relationshipLabel(relationship),
|
||||
sourceTable: relationship.sourceTable,
|
||||
targetTable: relationship.targetTable,
|
||||
sourceCardinality: "N",
|
||||
targetCardinality: "1",
|
||||
x: (sourceCenter.x + targetCenter.x) / 2 - ENGINEERING_RELATIONSHIP_WIDTH / 2,
|
||||
y: (sourceCenter.y + targetCenter.y) / 2 - ENGINEERING_RELATIONSHIP_HEIGHT / 2,
|
||||
width: ENGINEERING_RELATIONSHIP_WIDTH,
|
||||
height: ENGINEERING_RELATIONSHIP_HEIGHT,
|
||||
}];
|
||||
});
|
||||
|
||||
return normalizeDiagram({
|
||||
entities: orderedEntities,
|
||||
attributes,
|
||||
relationships: relationshipNodes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ColumnInfo, ForeignKeyInfo } from "../types/database";
|
||||
|
||||
export interface DiagramTable {
|
||||
name: string;
|
||||
columns: ColumnInfo[];
|
||||
foreignKeys: ForeignKeyInfo[];
|
||||
}
|
||||
|
||||
export interface DiagramRelationship {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceTable: string;
|
||||
sourceColumn: string;
|
||||
targetTable: string;
|
||||
targetColumn: string;
|
||||
}
|
||||
|
||||
export interface DiagramPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DiagramLayoutOptions {
|
||||
columnsPerRow?: number;
|
||||
cardWidth?: number;
|
||||
rowHeight?: number;
|
||||
gapX?: number;
|
||||
gapY?: number;
|
||||
margin?: number;
|
||||
}
|
||||
|
||||
function relationshipId(sourceTable: string, fk: ForeignKeyInfo): string {
|
||||
return [
|
||||
sourceTable,
|
||||
fk.name || "foreign_key",
|
||||
fk.column,
|
||||
fk.ref_table,
|
||||
fk.ref_column,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function buildDiagramRelationships(tables: DiagramTable[]): DiagramRelationship[] {
|
||||
const visibleTableNames = new Set(tables.map((table) => table.name));
|
||||
|
||||
return tables.flatMap((table) =>
|
||||
table.foreignKeys
|
||||
.filter((fk) => visibleTableNames.has(fk.ref_table))
|
||||
.map((fk) => ({
|
||||
id: relationshipId(table.name, fk),
|
||||
name: fk.name,
|
||||
sourceTable: table.name,
|
||||
sourceColumn: fk.column,
|
||||
targetTable: fk.ref_table,
|
||||
targetColumn: fk.ref_column,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterDiagramTables(tables: DiagramTable[], query: string): DiagramTable[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return tables;
|
||||
|
||||
return tables.filter((table) => {
|
||||
if (table.name.toLowerCase().includes(q)) return true;
|
||||
if (table.columns.some((column) =>
|
||||
column.name.toLowerCase().includes(q) ||
|
||||
column.data_type.toLowerCase().includes(q)
|
||||
)) return true;
|
||||
return table.foreignKeys.some((fk) =>
|
||||
fk.name.toLowerCase().includes(q) ||
|
||||
fk.column.toLowerCase().includes(q) ||
|
||||
fk.ref_table.toLowerCase().includes(q) ||
|
||||
fk.ref_column.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function layoutDiagramTables(
|
||||
tables: Pick<DiagramTable, "name" | "columns">[],
|
||||
options: DiagramLayoutOptions = {},
|
||||
): Record<string, DiagramPosition> {
|
||||
const columnsPerRow = Math.max(1, options.columnsPerRow ?? Math.ceil(Math.sqrt(Math.max(tables.length, 1))));
|
||||
const cardWidth = options.cardWidth ?? 260;
|
||||
const rowHeight = options.rowHeight ?? 220;
|
||||
const gapX = options.gapX ?? 56;
|
||||
const gapY = options.gapY ?? 40;
|
||||
const margin = options.margin ?? 40;
|
||||
|
||||
return Object.fromEntries(tables.map((table, index) => {
|
||||
const col = index % columnsPerRow;
|
||||
const row = Math.floor(index / columnsPerRow);
|
||||
return [
|
||||
table.name,
|
||||
{
|
||||
x: margin + col * (cardWidth + gapX),
|
||||
y: margin + row * (rowHeight + gapY),
|
||||
},
|
||||
];
|
||||
}));
|
||||
}
|
||||
@@ -146,7 +146,7 @@ function buildTableItems(prefix: string, tables: SqlCompletionTable[]): SqlCompl
|
||||
label: table.name,
|
||||
type: "table" as const,
|
||||
detail: table.schema ? `${table.schema}.${table.name}` : table.type,
|
||||
boost: computeBoost(table.name, prefix),
|
||||
boost: computeBoost(table.name, prefix) + 1000,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user