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', 'providers', 'mssql', '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 alterAddColumnRegex = /ALTER TABLE\s+\[?(\w+)\]?\s+ADD\s+\[?(\w+)\]?\s+([\w]+(?:\([^)]*\))?)(?:\s+(NOT NULL|NULL))?\s*;/gi; let alterColumnMatch; while ((alterColumnMatch = alterAddColumnRegex.exec(sqlBlock)) !== null) { const tableName = alterColumnMatch[1]; const name = alterColumnMatch[2]; const type = alterColumnMatch[3]; const nullable = alterColumnMatch[4]?.toUpperCase() !== 'NOT NULL'; const table = tables.get(tableName); if (!table || table.columns.some((c) => c.name === name)) continue; table.columns.push({ name, type, nullable, isPk: false }); } 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 `_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 = [ '#ff6b6b', '#6bdf9a', '#5aa7ff', '#f5c15c', '#c79bff', '#ff9f5a', '#5cd6d6', '#ff7fc2', '#8f95ff', '#58d18a', '#ff8f66', '#4fc4e8', '#a98cff', '#ff6f6f', '#4ecb8f', '#5aa7e6', '#ff7aa8', '#e0b25a', '#56c8c8', '#ff5f9e', '#ffb25a', '#56d68a', '#66b3ff', '#c79bff', ]; function edgeKey(edge) { return `${edge.from}|${edge.to}|${edge.label}|${edge.inferred ? 'inferred' : 'fk'}`; } /** * Only a spanning forest of FK edges constrains Graphviz ranks. Extra FKs * from the same table get constraint=false so they may connect peers on the * same level instead of forcing another rank (saves vertical space). * * Per source table, pick one "primary" parent for ranking: * 1. real FK over inferred * 2. ownership-style parent (parent name is a prefix of the child, e.g. * manifest → manifestarticle) * 3. highest in-degree target (hubs like company stay above) */ function selectRankingEdgeKeys(edges) { const ranking = new Set(); const inDegree = new Map(); for (const edge of edges) { if (edge.from === edge.to) continue; inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1); } const ownershipScore = (child, parent) => { const c = child.toLowerCase(); const p = parent.toLowerCase(); if (c.startsWith(p) && c.length > p.length) return p.length; return 0; }; const bySource = new Map(); for (const edge of edges) { if (edge.from === edge.to) continue; if (!bySource.has(edge.from)) bySource.set(edge.from, []); bySource.get(edge.from).push(edge); } for (const group of bySource.values()) { group.sort((a, b) => { const aInferred = a.inferred ? 1 : 0; const bInferred = b.inferred ? 1 : 0; if (aInferred !== bInferred) return aInferred - bInferred; const aOwn = ownershipScore(a.from, a.to); const bOwn = ownershipScore(b.from, b.to); if (aOwn !== bOwn) return bOwn - aOwn; const aDeg = inDegree.get(a.to) ?? 0; const bDeg = inDegree.get(b.to) ?? 0; if (aDeg !== bDeg) return bDeg - aDeg; return a.label.localeCompare(b.label); }); ranking.add(edgeKey(group[0])); } return ranking; } 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, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** * 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', 'syncpos', 'company', 'companysetting', 'customergroup', 'entity', 'entityhistory', 'entityaddress', 'entitycontact', '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', 'articlesupplier', ], }, { id: 'media', label: 'Media', tables: ['pic', 'articlepic', 'categorypic', 'brandpic', 'file'], }, { id: 'manifests', label: 'Documents', tables: ['manifest', 'manifestarticle', 'manifestattachment', 'ledger', 'warehouse'], }, { id: 'commerce', label: 'Pricing', tables: ['tax', 'price'], }, { id: 'units', label: 'Units', tables: ['unit'] }, { id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'printer'] }, ]; const COLORS = { background: '#1e2430', tableBorder: '#3f4c5f', headerBg: '#33415c', headerText: '#e2e8f0', rowBg: '#2a3242', rowBgAlt: '#272e3d', keyBg: '#2b3a52', text: '#d8dee9', muted: '#8b95a7', clusterFill: '#202a3a', }; /** * 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+([-\d.]+)\s+([-\d.]+)/ ); if (!match) continue; positions.set(match[1], { x: Number(match[2]), y: Number(match[3]), width: Number(match[4]), height: Number(match[5]), }); } return positions; } function flipCompassLR(compass) { return ( { e: 'w', w: 'e', ne: 'nw', nw: 'ne', se: 'sw', sw: 'se' }[compass] ?? compass ); } function flipCompassNS(compass) { return ( { ne: 'se', se: 'ne', nw: 'sw', sw: 'nw', e: 'e', w: 'w' }[compass] ?? compass ); } /** * Greedy exit/port tweaks against real Graphviz splines. Tries L/R flips, * N/S diagonal flips, and adjacent head-port swaps; keeps moves that cut * SVG segment crossings. */ function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors) { const tailCompass = new Map(anchors.tailCompass); const headPort = new Map(anchors.headPort); const inboundCounts = anchors.inboundCounts; let current = { headPort, tailCompass, inboundCounts }; const measure = () => { const dot = buildDot(tables, edges, positions, current); const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot')); return { svg, crossings: countSvgCrossings(svg), dot }; }; let { crossings: best, svg: bestSvg, dot: bestDot } = measure(); const acceptIfBetter = (trial) => { if (trial.crossings < best) { best = trial.crossings; bestSvg = trial.svg; bestDot = trial.dot; return true; } return false; }; const trySeed = (seedCompass) => { const saved = new Map(tailCompass); for (const [k, v] of seedCompass) tailCompass.set(k, v); current = { headPort, tailCompass, inboundCounts }; const trial = measure(); if (!acceptIfBetter(trial)) { for (const [k, v] of saved) tailCompass.set(k, v); current = { headPort, tailCompass, inboundCounts }; } }; // Also try the globally mirrored L/R assignment as an alternate seed. trySeed( new Map([...tailCompass.entries()].map(([k, v]) => [k, flipCompassLR(v)])) ); const byTarget = new Map(); for (const edge of edges) { if (edge.from === edge.to) continue; if (!byTarget.has(edge.to)) byTarget.set(edge.to, []); byTarget.get(edge.to).push(edge); } let improved = true; for (let sweep = 0; sweep < 4 && improved; sweep++) { improved = false; for (const edge of edges) { if (edge.from === edge.to) continue; const key = edgeKey(edge); const prev = tailCompass.get(key); if (!prev) continue; for (const flip of [flipCompassLR, flipCompassNS]) { const next = flip(prev); if (next === prev) continue; tailCompass.set(key, next); current = { headPort, tailCompass, inboundCounts }; const trial = measure(); if (acceptIfBetter(trial)) { improved = true; break; } tailCompass.set(key, prev); current = { headPort, tailCompass, inboundCounts }; } } for (const group of byTarget.values()) { const keys = group .map((e) => edgeKey(e)) .sort( (a, b) => Number(String(headPort.get(a)).replace(/^in/, '')) - Number(String(headPort.get(b)).replace(/^in/, '')) ); for (let i = 0; i < keys.length - 1; i++) { const a = headPort.get(keys[i]); const b = headPort.get(keys[i + 1]); if (!a || !b || a === b) continue; headPort.set(keys[i], b); headPort.set(keys[i + 1], a); current = { headPort, tailCompass, inboundCounts }; const trial = measure(); if (acceptIfBetter(trial)) { improved = true; } else { headPort.set(keys[i], a); headPort.set(keys[i + 1], b); current = { headPort, tailCompass, inboundCounts }; } } } } return { anchors: current, crossings: best, svg: bestSvg, dot: bestDot }; } /** * 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 = `${escapeHtml(table.name)}`; 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( `` ); } headerInner = `
` + `${portCells.join('')}` + `` + `
` + `${titleFont}
`; } else { headerInner = titleFont; } const rows = [ `` + `${headerInner}`, ]; 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 ? `${escapeHtml(column.name)}` : escapeHtml(column.name); const nullMarker = column.nullable ? '?' : ''; rows.push( `` + `` + `` + `` + `` + `
` + `${nameMarkup}${nullMarker}` + `${escapeHtml(column.type)}` + (marker ? ` ${marker}` : '') + `
` ); } return ( `<` + rows.join('') + `
>` ); } /** * 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. Exit side aims at the head-port x (not just the target center) * so left-header arrivals leave west and right-header arrivals leave east. */ 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 from = positions?.get(edge.from); const to = positions?.get(edge.to); const fromX = from?.x ?? 0; const fromY = from?.y ?? 0; const toY = to?.y ?? fromY; // Aim at the specific header port, not the node center — otherwise // edges to the left/right of a wide header pick the wrong exit side. const key = edgeKey(edge); const hp = headPort.get(key) ?? 'in0'; const hi = Number(String(hp).replace(/^in/, '')) || 0; const nIn = Math.max(1, inboundCounts.get(edge.to) ?? 1); const toW = to?.width ?? 1; const toX = to != null ? to.x - toW / 2 + ((hi + 0.5) / nIn) * toW : fromX; 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(key, compass); }); } return { headPort, tailCompass, inboundCounts }; } function buildLegendLabel() { const row = (swatch, text) => `${swatch}` + `${text}`; return ( `<` + `` + row(`PK`, 'primary key') + row(`FK`, 'foreign key') + row(`?`, 'nullable column') + row( `—`, 'FOREIGN KEY constraint' ) + row( `- -`, 'naming convention only, no constraint' ) + `
` + `Legend
>` ); } function buildDot(tables, edges, positions = null, anchors = null) { const edgeColors = assignEdgeColors(edges); const rankingEdgeKeys = selectRankingEdgeKeys(edges); const { headPort, tailCompass, inboundCounts } = anchors ?? 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.95, ranksep=1.0, pad=0.5, fontname="Helvetica",', ' compound=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="${COLORS.clusterFill}"; 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) { attrs.push('constraint=false'); lines.push( ` ${edge.from}:${sourcePort}:e -> ${edge.to}:id:e [${attrs.join(', ')}];` ); continue; } // Non-primary FKs may share a rank with their target (lateral links). if (!rankingEdgeKeys.has(key)) { attrs.push('constraint=false', 'weight=1'); } else { // Prefer ranking edges in crossing minimization / length tradeoffs. attrs.push('weight=3'); } 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(/]*d="([^"]+)"/g), ].map((m) => m[1]); const edgeSegs = []; for (const d of paths) { const nums = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [ Number(m[1]), Number(m[2]), ]); const segs = []; for (let i = 0; i < nums.length - 1; i++) segs.push([nums[i], nums[i + 1]]); edgeSegs.push(segs); } 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 e1 = 0; e1 < edgeSegs.length; e1++) { for (let e2 = e1 + 1; e2 < edgeSegs.length; e2++) { let pairCrosses = false; outer: for (const [a, b] of edgeSegs[e1]) { for (const [c, d] of edgeSegs[e2]) { 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)) { pairCrosses = true; break outer; } } } if (pairCrosses) 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( /\s*(\w+):e->\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->${_table}:e\n` + `\n` + `\n` + `\n` + `\n` + `\n` + `` ); } ); } 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(); // Layout passes: learn positions, reorder columns/cluster members (barycenter). let positions = null; for (let pass = 0; pass < 3; pass++) { const probeDot = buildDot(tables, edges, positions); const plain = graphviz.layout(probeDot, 'plain', 'dot'); positions = parsePlainPositions(plain); reorderColumnsForCrossingReduction(tables, edges, positions); } // Hill-climb L/R exits against real spline crossings. const baseAnchors = assignEdgeAnchors(edges, positions); const optimized = optimizeAnchorsWithLayout( graphviz, tables, edges, positions, baseAnchors ); // Fill the whole canvas (including Graphviz's pad margin) so there is no // transparent/white gutter around the dark graph when opened in a browser. const finalsvg = optimized.svg.replace( /]*?)>/, `` ); writeFileSync(dotOutputPath, optimized.dot, 'utf8'); writeFileSync(outputPath, finalsvg, 'utf8'); const dim = optimized.svg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/); 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 · ~${optimized.crossings} edge-pair crossings` ); }