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:
seb
2026-08-06 07:39:48 +02:00
parent 3b77a1c946
commit ae549fd022
17 changed files with 2019 additions and 1656 deletions

View File

@@ -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`
);
}