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; relationshipPaths: Record; 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, "'"); } function svgNumber(value: number): string { return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, ""); } function svgHeader(canvas: DiagramCanvas): string { return [ ``, '', ].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 `${escapeXml(label)}`; } 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 [ "", '', '', "", "", ].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(), '', ]; for (const relationship of options.relationships) { const path = options.relationshipPaths[relationship.id]; if (!path) continue; parts.push( `` + `${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`)}` + "", ); } parts.push(""); 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(``); parts.push( ``, ); parts.push(``); parts.push(``); 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(``); 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(""); } parts.push(""); 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 { 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(''); for (const attribute of diagram.attributes) { const from = centers.get(attribute.tableName); if (!from) continue; const to = nodeCenter(attribute); parts.push( ``, ); } 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( ``, ); parts.push( ``, ); 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(""); for (const attribute of diagram.attributes) { parts.push( ``, ); 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(``); parts.push( svgText(relationship.label, cx, cy, { size: 11, fill: "#450a0a", weight: "600", anchor: "middle", }), ); } for (const entity of diagram.entities) { parts.push( ``, ); 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(""); 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"; }