Add ledger, address/contact sort, and tighter schema-graph layout.
Replace manifestarticle.transactedamount with an append-only ledger, add sort on entity address/contact, and allow same-rank FKs with crossing-aware exits in the graph.
This commit is contained in:
@@ -231,6 +231,62 @@ 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;
|
||||
@@ -309,7 +365,7 @@ const CLUSTERS = [
|
||||
{
|
||||
id: 'commerce',
|
||||
label: 'Pricing & Documents',
|
||||
tables: ['tax', 'price', 'manifest', 'manifestarticle'],
|
||||
tables: ['tax', 'price', 'manifest', 'manifestarticle', 'ledger'],
|
||||
},
|
||||
{ id: 'units', label: 'Units', tables: ['unit'] },
|
||||
];
|
||||
@@ -381,14 +437,142 @@ function parsePlainPositions(plain) {
|
||||
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+/);
|
||||
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]) });
|
||||
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.
|
||||
@@ -468,7 +652,8 @@ function buildTableLabel(table, inboundCount = 0) {
|
||||
* 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.
|
||||
* 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();
|
||||
@@ -516,10 +701,24 @@ function assignEdgeAnchors(edges, positions) {
|
||||
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 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);
|
||||
@@ -560,7 +759,7 @@ function assignEdgeAnchors(edges, positions) {
|
||||
: 'nw';
|
||||
}
|
||||
|
||||
tailCompass.set(edgeKey(edge), compass);
|
||||
tailCompass.set(key, compass);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -592,18 +791,17 @@ function buildLegendLabel() {
|
||||
);
|
||||
}
|
||||
|
||||
function buildDot(tables, edges, positions = null) {
|
||||
function buildDot(tables, edges, positions = null, anchors = null) {
|
||||
const edgeColors = assignEdgeColors(edges);
|
||||
const { headPort, tailCompass, inboundCounts } = assignEdgeAnchors(
|
||||
edges,
|
||||
positions
|
||||
);
|
||||
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.55, ranksep=1.0, pad=0.5, fontname="Helvetica",',
|
||||
' nodesep=0.95, 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];',
|
||||
@@ -673,12 +871,21 @@ function buildDot(tables, edges, positions = null) {
|
||||
// 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(', ')}];`
|
||||
);
|
||||
@@ -692,14 +899,16 @@ function countSvgCrossings(svg) {
|
||||
const paths = [
|
||||
...svg.matchAll(/<g id="edge\d+"[\s\S]*?<path[^>]*d="([^"]+)"/g),
|
||||
].map((m) => m[1]);
|
||||
const segs = [];
|
||||
|
||||
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) =>
|
||||
@@ -713,19 +922,26 @@ function countSvgCrossings(svg) {
|
||||
};
|
||||
|
||||
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;
|
||||
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 (crosses(a, b, c, d)) n++;
|
||||
if (pairCrosses) n++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,30 +1021,35 @@ if (unclustered.length > 0) {
|
||||
|
||||
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.
|
||||
// Layout passes: learn positions, reorder columns/cluster members (barycenter).
|
||||
let positions = null;
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
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);
|
||||
}
|
||||
|
||||
const dot = buildDot(tables, edges, positions);
|
||||
writeFileSync(dotOutputPath, dot, 'utf8');
|
||||
// Hill-climb L/R exits against real spline crossings.
|
||||
const baseAnchors = assignEdgeAnchors(edges, positions);
|
||||
const optimized = optimizeAnchorsWithLayout(
|
||||
graphviz,
|
||||
tables,
|
||||
edges,
|
||||
positions,
|
||||
baseAnchors
|
||||
);
|
||||
|
||||
const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot'));
|
||||
writeFileSync(outputPath, svg, 'utf8');
|
||||
writeFileSync(dotOutputPath, optimized.dot, 'utf8');
|
||||
writeFileSync(outputPath, optimized.svg, 'utf8');
|
||||
|
||||
const dim = svg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
|
||||
const crossings = countSvgCrossings(svg);
|
||||
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 · ~${crossings} segment crossings`
|
||||
`Layout ${dim[1]}pt × ${dim[2]}pt · ~${optimized.crossings} edge-pair crossings`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user