Files
dashlibstorage/generate-schema-graph.js
2026-07-31 05:21:28 +02:00

813 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Graphviz } from '@hpcc-js/wasm-graphviz';
const __dirname = dirname(fileURLToPath(import.meta.url));
const schemaPath = join(__dirname, 'src', 'schema.js');
const outputPath = join(__dirname, 'schema-graph.svg');
const dotOutputPath = join(__dirname, 'schema-graph.dot');
function parseSchema(source) {
const tables = new Map();
const edges = [];
const initRegex = /export const (INIT_\w+)\s*=\s*`([\s\S]*?)`;/g;
let initMatch;
while ((initMatch = initRegex.exec(source)) !== null) {
const initName = initMatch[1];
const sqlBlock = initMatch[2];
const createRegex = /CREATE TABLE\s+(\w+)\s*\(([\s\S]*?)\);/gi;
let createMatch;
while ((createMatch = createRegex.exec(sqlBlock)) !== null) {
const tableName = createMatch[1];
const body = createMatch[2];
const columns = [];
const constraints = [];
for (const line of body.split('\n')) {
const trimmed = line.trim().replace(/,$/, '');
if (!trimmed || trimmed.startsWith('CONSTRAINT')) {
const fkMatch = trimmed.match(
/CONSTRAINT\s+(\w+)\s+FOREIGN KEY\s*\(([^)]+)\)\s+REFERENCES\s+(\w+)\(([^)]+)\)(?:\s+ON DELETE\s+(.+))?/i
);
if (fkMatch) {
const constraintName = fkMatch[1];
const localCols = fkMatch[2]
.split(',')
.map((c) => c.trim().replace(/^\[|\]$/g, ''));
const refTable = fkMatch[3];
const refCols = fkMatch[4]
.split(',')
.map((c) => c.trim().replace(/^\[|\]$/g, ''));
const onDelete = fkMatch[5]?.trim() ?? null;
constraints.push({
name: constraintName,
localCols,
refTable,
refCols,
onDelete,
});
edges.push({
from: tableName,
to: refTable,
label: localCols.join(', '),
localCols,
refCols: refCols.join(', '),
constraint: constraintName,
onDelete,
});
}
continue;
}
const colMatch = trimmed.match(
/^(?:\[(\w+)\]|(\w+))\s+([\w]+(?:\([^)]*\))?(?:\s+IDENTITY\([^)]*\))?)/i
);
if (!colMatch) continue;
const name = (colMatch[1] ?? colMatch[2]).replace(/^\[|\]$/g, '');
const type = colMatch[3].replace(/\s+IDENTITY\([^)]*\)/i, '');
const nullable =
/\bNULL\b/i.test(trimmed) && !/\bNOT NULL\b/i.test(trimmed);
const isPk = /\bPRIMARY KEY\b/i.test(trimmed);
columns.push({ name, type, nullable, isPk });
}
tables.set(tableName, {
name: tableName,
initName,
columns,
constraints,
});
}
const alterFkRegex =
/ALTER TABLE\s+(\w+)\s+ADD CONSTRAINT\s+(\w+)\s+FOREIGN KEY\s*\(([^)]+)\)\s+REFERENCES\s+(\w+)\(([^)]+)\)(?:\s+ON DELETE\s+([\w\s]+?))?\s*;/gi;
let alterMatch;
while ((alterMatch = alterFkRegex.exec(sqlBlock)) !== null) {
const tableName = alterMatch[1];
const constraintName = alterMatch[2];
const localCols = alterMatch[3]
.split(',')
.map((c) => c.trim().replace(/^\[|\]$/g, ''));
const refTable = alterMatch[4];
const refCols = alterMatch[5]
.split(',')
.map((c) => c.trim().replace(/^\[|\]$/g, ''));
const onDelete = alterMatch[6]?.trim() ?? null;
const table = tables.get(tableName);
if (table) {
table.constraints.push({
name: constraintName,
localCols,
refTable,
refCols,
onDelete,
});
}
edges.push({
from: tableName,
to: refTable,
label: localCols.join(', '),
localCols,
refCols: refCols.join(', '),
constraint: constraintName,
onDelete,
});
}
}
addInferredEdges(tables, edges);
markForeignKeyColumns(tables, edges);
return { tables, edges };
}
/**
* Some columns follow a `<table>_id` naming convention that implies a
* relationship (e.g. `articlelist_id` -> `articlelist`) but never got an
* explicit FOREIGN KEY constraint in the SQL (often because the referenced
* table is declared later in the file). These are surfaced as dashed
* "inferred" edges so the gap is visible in the diagram instead of the
* column silently looking unrelated.
*/
function addInferredEdges(tables, edges) {
const explicitlyConstrained = new Set();
for (const edge of edges) {
explicitlyConstrained.add(`${edge.from}.${edge.label}`);
}
for (const [tableName, table] of tables) {
for (const column of table.columns) {
if (column.isPk) continue;
const match = column.name.match(/^(.+)_id$/i);
if (!match) continue;
const candidate = match[1];
if (!tables.has(candidate) || candidate === tableName) continue;
if (explicitlyConstrained.has(`${tableName}.${column.name}`)) continue;
edges.push({
from: tableName,
to: candidate,
label: column.name,
localCols: [column.name],
refCols: 'id',
constraint: null,
onDelete: null,
inferred: true,
});
}
}
}
function markForeignKeyColumns(tables, edges) {
for (const edge of edges) {
const table = tables.get(edge.from);
if (!table) continue;
for (const localCol of edge.localCols ?? []) {
const column = table.columns.find((c) => c.name === localCol);
if (column) column.isFk = true;
}
}
}
const EDGE_COLORS = [
'#c53030',
'#2f855a',
'#2b6cb0',
'#b7791f',
'#6b46c1',
'#c05621',
'#319795',
'#97266d',
'#4c51bf',
'#276749',
'#9c4221',
'#0987a0',
'#553c9a',
'#742a2a',
'#22543d',
'#1a365d',
'#702459',
'#744210',
'#285e61',
'#d53f8c',
'#dd6b20',
'#38a169',
'#3182ce',
'#805ad5',
];
function edgeKey(edge) {
return `${edge.from}|${edge.to}|${edge.label}|${edge.inferred ? 'inferred' : 'fk'}`;
}
function assignEdgeColors(edges) {
const colors = new Map();
let next = 0;
for (const edge of edges) {
const key = edgeKey(edge);
if (!colors.has(key)) {
colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]);
next++;
}
}
return colors;
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Domain grouping. Graphviz keeps the members of a cluster physically
* together, which is what turns the diagram from one big hairball into
* readable regions. New tables that are not listed here still render, they
* just float outside any cluster - the script logs them so they can be
* assigned.
*/
const CLUSTERS = [
{
id: 'identity',
label: 'Identity & Company',
tables: [
'meta',
'google_auth',
'companyuser',
'company',
'customergroup',
'entity',
'numbers',
],
},
{
id: 'i18n',
label: 'Localization',
tables: ['language', 'categorytext', 'articletext', 'unittext'],
},
{
id: 'catalog',
label: 'Catalog',
tables: [
'category',
'categoryarticle',
'brand',
'article',
'articleean',
'articlehistory',
'articlelist',
'articlelistarticle',
'optionslist',
'optionslistarticle',
],
},
{
id: 'media',
label: 'Media',
tables: ['pic', 'articlepic', 'categorypic', 'brandpic'],
},
{
id: 'commerce',
label: 'Pricing & Documents',
tables: ['tax', 'price', 'manifest', 'manifestarticle'],
},
{ id: 'units', label: 'Units', tables: ['unit'] },
];
const COLORS = {
background: '#ebf4ff',
tableBorder: '#a0aec0',
headerBg: '#bee3f8',
headerText: '#1a365d',
rowBg: '#ffffff',
rowBgAlt: '#f7fafc',
keyBg: '#ebf8ff',
text: '#2d3748',
muted: '#718096',
};
/**
* Reorder columns so FK ports line up with the horizontal position of their
* targets (barycenter heuristic on column/port order). PK stays on top,
* plain columns stay at the bottom. This is the classic "swap columns to
* reduce edge crossings" trick for port-based ERDs.
*/
function reorderColumnsForCrossingReduction(tables, edges, positions) {
const targetX = (tableName) => positions.get(tableName)?.x ?? 0;
for (const [tableName, table] of tables) {
const outEdges = edges.filter((e) => e.from === tableName);
const score = new Map();
for (const edge of outEdges) {
const x = targetX(edge.to);
for (const col of edge.localCols ?? []) {
if (!score.has(col)) score.set(col, []);
score.get(col).push(x);
}
}
const median = (values) => {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
};
const pk = [];
const fk = [];
const rest = [];
for (const column of table.columns) {
if (column.isPk) pk.push(column);
else if (column.isFk || score.has(column.name)) fk.push(column);
else rest.push(column);
}
fk.sort((a, b) => {
const aVals = score.get(a.name) ?? [Number.POSITIVE_INFINITY];
const bVals = score.get(b.name) ?? [Number.POSITIVE_INFINITY];
return median(aVals) - median(bVals);
});
table.columns = [...pk, ...fk, ...rest];
}
}
function parsePlainPositions(plain) {
const positions = new Map();
for (const line of plain.split('\n')) {
if (!line.startsWith('node ')) continue;
// node name x y width height ...
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+/);
if (!match) continue;
positions.set(match[1], { x: Number(match[2]), y: Number(match[3]) });
}
return positions;
}
/**
* Sort cluster members by their first-pass x so Graphviz's crossing
* minimizer starts from a better initial order within each cluster.
*/
function orderClusterMembers(cluster, positions) {
return [...cluster.tables].sort((a, b) => {
const ax = positions.get(a)?.x ?? Number.POSITIVE_INFINITY;
const bx = positions.get(b)?.x ?? Number.POSITIVE_INFINITY;
return ax - bx;
});
}
function buildTableLabel(table, inboundCount = 0) {
const titleFont =
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(table.name)}</B></FONT>`;
let headerInner;
if (inboundCount > 0) {
// Ports sit on the top edge of the title bar so arrows end on the
// header itself instead of crossing through it toward the id row.
const portCells = [];
for (let i = 0; i < inboundCount; i++) {
portCells.push(
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" ` +
`WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
);
}
headerInner =
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>${portCells.join('')}</TR>` +
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">` +
`${titleFont}</TD></TR>` +
`</TABLE>`;
} else {
headerInner = titleFont;
}
const rows = [
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">` +
`${headerInner}</TD></TR>`,
];
for (const [i, column] of table.columns.entries()) {
const marker = column.isPk ? 'PK' : column.isFk ? 'FK' : '';
const isKey = Boolean(marker);
const bg = isKey ? COLORS.keyBg : i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
const nameMarkup = isKey
? `<B>${escapeHtml(column.name)}</B>`
: escapeHtml(column.name);
const nullMarker = column.nullable ? '?' : '';
rows.push(
`<TR><TD PORT="${escapeHtml(column.name)}" COLSPAN="2" BGCOLOR="${bg}" ` +
`BORDER="1" CELLPADDING="0">` +
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>` +
`<TD ALIGN="LEFT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.text}">${nameMarkup}${nullMarker}</FONT></TD>` +
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(column.type)}` +
(marker ? ` <B>${marker}</B>` : '') +
`</FONT></TD>` +
`</TR></TABLE></TD></TR>`
);
}
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
`COLOR="${COLORS.tableBorder}">` +
rows.join('') +
`</TABLE>>`
);
}
/**
* Head: unique in* ports on the target title bar, ordered by source x.
* Tail: FK column port, always leaving sideways or diagonally
* (e/w/ne/nw/se/sw) — never straight up/down, which stacks stubs on the
* row edge.
*/
function assignEdgeAnchors(edges, positions) {
const headPort = new Map();
const tailCompass = new Map();
const inboundCounts = new Map();
const byTarget = new Map();
const bySourceCol = new Map();
for (const edge of edges) {
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
byTarget.get(edge.to).push(edge);
const colKey = `${edge.from}|${edge.localCols?.[0] ?? 'id'}`;
if (!bySourceCol.has(colKey)) bySourceCol.set(colKey, []);
bySourceCol.get(colKey).push(edge);
}
for (const [target, group] of byTarget) {
group.sort((a, b) => {
const ax = positions?.get(a.from)?.x ?? 0;
const bx = positions?.get(b.from)?.x ?? 0;
if (ax !== bx) return ax - bx;
return a.label.localeCompare(b.label);
});
inboundCounts.set(target, group.length);
group.forEach((edge, i) => {
headPort.set(edgeKey(edge), `in${i}`);
});
}
for (const group of bySourceCol.values()) {
group.sort((a, b) => {
const ax = positions?.get(a.to)?.x ?? 0;
const bx = positions?.get(b.to)?.x ?? 0;
if (ax !== bx) return ax - bx;
return (positions?.get(a.to)?.y ?? 0) - (positions?.get(b.to)?.y ?? 0);
});
group.forEach((edge, i) => {
// Self-loops: leave via top diagonals so the arc sits above the table
// instead of curling SE into neighboring edges.
if (edge.from === edge.to) {
tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw');
return;
}
const fromX = positions?.get(edge.from)?.x ?? 0;
const toX = positions?.get(edge.to)?.x ?? fromX;
const fromY = positions?.get(edge.from)?.y ?? 0;
const toY = positions?.get(edge.to)?.y ?? fromY;
const dx = toX - fromX;
const dy = toY - fromY;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
// Graphviz y grows upward; smaller y = visually lower under rankdir=TB.
const goEast = dx > 0 || (dx === 0 && edge.from <= edge.to);
const goDown = dy < 0 || (dy === 0 && goEast);
const fan =
group.length === 1 ? 0 : (i / (group.length - 1)) * 2 - 1;
let compass;
if (absDx >= absDy) {
// Mostly horizontal — cardinal side, fan to diagonals if shared.
if (group.length === 1) {
compass = goEast ? 'e' : 'w';
} else {
compass = goEast
? fan < -0.33
? 'ne'
: fan > 0.33
? 'se'
: 'e'
: fan < -0.33
? 'nw'
: fan > 0.33
? 'sw'
: 'w';
}
} else {
// Mostly vertical — still leave via a diagonal/side, never n/s.
compass = goEast
? goDown
? 'se'
: 'ne'
: goDown
? 'sw'
: 'nw';
}
tailCompass.set(edgeKey(edge), compass);
});
}
return { headPort, tailCompass, inboundCounts };
}
function buildLegendLabel() {
const row = (swatch, text) =>
`<TR><TD ALIGN="LEFT">${swatch}</TD><TD ALIGN="LEFT">` +
`<FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}">` +
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>Legend</B></FONT></TD></TR>` +
row(`<FONT COLOR="${COLORS.text}"><B>PK</B></FONT>`, 'primary key') +
row(`<FONT COLOR="${COLORS.text}"><B>FK</B></FONT>`, 'foreign key') +
row(`<FONT COLOR="${COLORS.text}"><B>?</B></FONT>`, 'nullable column') +
row(
`<FONT COLOR="${COLORS.muted}">&#8212;</FONT>`,
'FOREIGN KEY constraint'
) +
row(
`<FONT COLOR="${COLORS.muted}">- -</FONT>`,
'naming convention only, no constraint'
) +
`</TABLE>>`
);
}
function buildDot(tables, edges, positions = null) {
const edgeColors = assignEdgeColors(edges);
const { headPort, tailCompass, inboundCounts } = assignEdgeAnchors(
edges,
positions
);
const lines = [
'digraph schema {',
// TB = landscape for this schema (many tables per rank spread horizontally).
// mclimit bumps Graphviz's own crossing-minimization iterations.
` graph [rankdir=TB, splines=spline, overlap=false, bgcolor="${COLORS.background}",`,
' nodesep=0.55, ranksep=1.0, pad=0.5, fontname="Helvetica",',
' compound=true, newrank=true, mclimit=4.0];',
' node [shape=plaintext, fontname="Helvetica"];',
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
'',
];
const unassigned = new Set(tables.keys());
const emitNode = (name, indent) => {
const table = tables.get(name);
const label = buildTableLabel(table, inboundCounts.get(name) ?? 0);
lines.push(`${indent}${name} [label=${label}];`);
};
for (const cluster of CLUSTERS) {
const members = (
positions ? orderClusterMembers(cluster, positions) : cluster.tables
).filter((name) => tables.has(name));
if (members.length === 0) continue;
lines.push(` subgraph cluster_${cluster.id} {`);
lines.push(
` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`,
` style="rounded,filled"; fillcolor="#ffffff60"; color="${COLORS.tableBorder}";`,
` fontcolor="${COLORS.headerText}"; margin=14;`
);
for (const name of members) {
emitNode(name, ' ');
unassigned.delete(name);
}
lines.push(' }');
}
const leftover = positions
? [...unassigned].sort(
(a, b) => (positions.get(a)?.x ?? 0) - (positions.get(b)?.x ?? 0)
)
: [...unassigned];
for (const name of leftover) {
emitNode(name, ' ');
}
lines.push('');
lines.push(` __legend [label=${buildLegendLabel()}];`);
lines.push('');
for (const edge of edges) {
const key = edgeKey(edge);
const color = edgeColors.get(key);
const sourcePort = edge.localCols?.[0] ?? 'id';
const targetPort = headPort.get(key) ?? 'in0';
const tail = tailCompass.get(key) ?? 'e';
const attrs = [
`color="${color}"`,
`fontcolor="${color}"`,
`tooltip="${escapeHtml(edge.constraint ?? `${edge.from}.${edge.label} -> ${edge.to}`)}"`,
];
if (edge.inferred) {
attrs.push('style=dashed', 'label="inferred"');
}
// Self-FK: leave the FK column, enter the PK — loop on the east side
// of those rows (e→e). Node-level ne→nw looked above the table but
// detached from the actual columns; column→header with :ne still dipped SE.
if (edge.from === edge.to) {
lines.push(
` ${edge.from}:${sourcePort}:e -> ${edge.to}:id:e [${attrs.join(', ')}];`
);
continue;
}
lines.push(
` ${edge.from}:${sourcePort}:${tail} -> ${edge.to}:${targetPort}:n [${attrs.join(', ')}];`
);
}
lines.push('}');
return lines.join('\n');
}
function countSvgCrossings(svg) {
const paths = [
...svg.matchAll(/<g id="edge\d+"[\s\S]*?<path[^>]*d="([^"]+)"/g),
].map((m) => m[1]);
const segs = [];
for (const d of paths) {
const nums = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [
Number(m[1]),
Number(m[2]),
]);
for (let i = 0; i < nums.length - 1; i++) segs.push([nums[i], nums[i + 1]]);
}
const orient = (a, b, c) =>
(b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
const crosses = (a, b, c, d) => {
const o1 = orient(a, b, c);
const o2 = orient(a, b, d);
const o3 = orient(c, d, a);
const o4 = orient(c, d, b);
return o1 * o2 < 0 && o3 * o4 < 0;
};
let n = 0;
for (let i = 0; i < segs.length; i++) {
for (let j = i + 1; j < segs.length; j++) {
const [a, b] = segs[i];
const [c, d] = segs[j];
if (
(a[0] === c[0] && a[1] === c[1]) ||
(a[0] === d[0] && a[1] === d[1]) ||
(b[0] === c[0] && b[1] === c[1]) ||
(b[0] === d[0] && b[1] === d[1])
) {
continue;
}
if (crosses(a, b, c, d)) n++;
}
}
return n;
}
/**
* Graphviz self-edges from a lower FK row to id:e dip southeast. Rebuild the
* spline so it bulges east + north (up) while keeping start/end ports.
* In this SVG, more-negative y is visually higher.
*/
function fixSelfLoopSvg(svg) {
return svg.replace(
/<g id="(edge\d+)" class="edge">\s*<title>(\w+):e&#45;&gt;\2:e<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g,
(match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => {
const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [
Number(m[1]),
Number(m[2]),
]);
if (pts.length < 2) return match;
const [x0, y0] = pts[0];
const [x1, y1] = pts[pts.length - 1];
const east = Math.max(x0, x1) + 36;
const north = Math.min(y0, y1) - 28;
// Cubic end tangent is P3-P2; orient arrow along that arrival direction.
const tx = x1 - east;
const ty = y1 - north;
const len = Math.hypot(tx, ty) || 1;
const dx = tx / len;
const dy = ty / len;
const arrowLen = 9;
const arrowHalf = 3.5;
const baseX = x1 - dx * arrowLen;
const baseY = y1 - dy * arrowLen;
const px = -dy * arrowHalf;
const py = dx * arrowHalf;
const fmt = (n) => (Math.round(n * 100) / 100).toString();
const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`;
const newPointsFmt =
`${fmt(x1)},${fmt(y1)} ` +
`${fmt(baseX + px)},${fmt(baseY + py)} ` +
`${fmt(baseX - px)},${fmt(baseY - py)} ` +
`${fmt(x1)},${fmt(y1)}`;
return (
`<g id="${edgeId}" class="edge">\n` +
`<title>${_table}:e&#45;&gt;${_table}:e</title>\n` +
`<g id="${aId}"><a ${aAttrs}>\n` +
`<path${pathAttrs} d="${newDFmt}"/>\n` +
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
`</a>\n` +
`</g>\n` +
`</g>`
);
}
);
}
const schemaSource = readFileSync(schemaPath, 'utf8');
const { tables, edges } = parseSchema(schemaSource);
if (tables.size === 0) {
console.error('No tables found in schema.js');
process.exit(1);
}
const clustered = new Set(CLUSTERS.flatMap((c) => c.tables));
const unclustered = [...tables.keys()].filter((name) => !clustered.has(name));
if (unclustered.length > 0) {
console.warn(
`Not assigned to a cluster (add them to CLUSTERS): ${unclustered.join(', ')}`
);
}
const graphviz = await Graphviz.load();
// Two layout passes: learn positions, then reorder columns/cluster members
// (barycenter) so FK ports and neighbor tables line up and crossings drop.
let positions = null;
for (let pass = 0; pass < 2; pass++) {
const probeDot = buildDot(tables, edges, positions);
const plain = graphviz.layout(probeDot, 'plain', 'dot');
positions = parsePlainPositions(plain);
reorderColumnsForCrossingReduction(tables, edges, positions);
}
const dot = buildDot(tables, edges, positions);
writeFileSync(dotOutputPath, dot, 'utf8');
const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot'));
writeFileSync(outputPath, svg, 'utf8');
const dim = svg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
const crossings = countSvgCrossings(svg);
console.log(`Parsed ${tables.size} tables and ${edges.length} foreign keys`);
console.log(`Written ${dotOutputPath}`);
console.log(`Written ${outputPath}`);
if (dim) {
console.log(
`Layout ${dim[1]}pt × ${dim[2]}pt · ~${crossings} segment crossings`
);
}