This commit is contained in:
seb
2026-10-09 21:37:42 +02:00
parent 00cf5b418e
commit ba376686f3
4 changed files with 2916 additions and 2715 deletions

View File

@@ -369,13 +369,21 @@ const CLUSTERS = [
},
{
id: 'manifests',
label: 'Documents',
tables: ['manifest', 'manifestarticle', 'manifestattachment', 'ledger', 'warehouse'],
label: 'Documents & Shipping',
tables: [
'manifest',
'manifestarticle',
'manifestattachment',
'ledger',
'warehouse',
'shipping',
'package',
],
},
{
id: 'commerce',
label: 'Pricing',
tables: ['tax', 'price'],
label: 'Pricing & Payments',
tables: ['tax', 'price', 'account', 'payment'],
},
{ id: 'units', label: 'Units', tables: ['unit'] },
{ id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'printer', 'labeltemplate'] },
@@ -500,14 +508,33 @@ function flipCompassNS(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.
*
* Each trial is a full Graphviz layout, so the search is bounded by
* `maxLayouts` and reports progress — otherwise a large schema silently
* runs for many minutes with no output.
*/
function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors) {
function optimizeAnchorsWithLayout(
graphviz,
tables,
edges,
positions,
anchors,
maxLayouts = 400
) {
const tailCompass = new Map(anchors.tailCompass);
const headPort = new Map(anchors.headPort);
const inboundCounts = anchors.inboundCounts;
let current = { headPort, tailCompass, inboundCounts };
let layouts = 0;
const started = Date.now();
const measure = () => {
layouts++;
if (layouts % 25 === 0) {
console.log(
` optimizing anchors: ${layouts} layouts, ${best} crossings, ${((Date.now() - started) / 1000).toFixed(0)}s`
);
}
const dot = buildDot(tables, edges, positions, current);
const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot'));
return { svg, crossings: countSvgCrossings(svg), dot };
@@ -525,6 +552,8 @@ function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors)
return false;
};
const budgetLeft = () => layouts < maxLayouts;
const trySeed = (seedCompass) => {
const saved = new Map(tailCompass);
for (const [k, v] of seedCompass) tailCompass.set(k, v);
@@ -551,9 +580,11 @@ function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors)
let improved = true;
for (let sweep = 0; sweep < 4 && improved; sweep++) {
improved = false;
if (!budgetLeft()) break;
for (const edge of edges) {
if (edge.from === edge.to) continue;
if (!budgetLeft()) break;
const key = edgeKey(edge);
const prev = tailCompass.get(key);
if (!prev) continue;
@@ -574,6 +605,7 @@ function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors)
}
for (const group of byTarget.values()) {
if (!budgetLeft()) break;
const keys = group
.map((e) => edgeKey(e))
.sort(
@@ -829,10 +861,12 @@ function buildDot(tables, edges, positions = null, anchors = null) {
const lines = [
'digraph schema {',
// TB = landscape for this schema (many tables per rank spread horizontally).
// mclimit bumps Graphviz's own crossing-minimization iterations.
// mclimit is Graphviz's crossing-minimization iteration budget. 4.0 (the
// old value) costs ~12x the layout time for only ~15% fewer crossings,
// which multiplied by the ~157 optimizer trials below. 1.0 is the default.
` 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];',
' compound=true, mclimit=1.0];',
' node [shape=plaintext, fontname="Helvetica"];',
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
'',
@@ -941,6 +975,27 @@ function countSvgCrossings(svg) {
edgeSegs.push(segs);
}
// Two splines can only intersect if their bounding boxes overlap, so the
// sweep below can skip almost every pair. Without this the check is
// O(edges^2 * segments^2) and dominates each optimizer trial.
const boxes = edgeSegs.map((segs) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const [a] of segs) {
if (a[0] < minX) minX = a[0];
if (a[0] > maxX) maxX = a[0];
if (a[1] < minY) minY = a[1];
if (a[1] > maxY) maxY = a[1];
}
return { minX, minY, maxX, maxY };
});
const order = boxes
.map((_, i) => i)
.sort((a, b) => boxes[a].minX - boxes[b].minX);
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) => {
@@ -952,8 +1007,17 @@ function countSvgCrossings(svg) {
};
let n = 0;
for (let e1 = 0; e1 < edgeSegs.length; e1++) {
for (let e2 = e1 + 1; e2 < edgeSegs.length; e2++) {
for (let ii = 0; ii < order.length; ii++) {
const e1 = order[ii];
const b1 = boxes[e1];
for (let jj = ii + 1; jj < order.length; jj++) {
const e2 = order[jj];
const b2 = boxes[e2];
// Sorted by minX: once a box starts past b1's right edge, no later
// box can overlap it either.
if (b2.minX > b1.maxX) break;
if (b1.minX > b2.maxX || b1.minY > b2.maxY || b2.minY > b1.maxY) continue;
let pairCrosses = false;
outer: for (const [a, b] of edgeSegs[e1]) {
for (const [c, d] of edgeSegs[e2]) {
@@ -1054,20 +1118,25 @@ const graphviz = await Graphviz.load();
// Layout passes: learn positions, reorder columns/cluster members (barycenter).
let positions = null;
for (let pass = 0; pass < 3; pass++) {
console.log(`Layout pass ${pass + 1}/3…`);
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.
// Hill-climb L/R exits against real spline crossings. Each trial is a full
// layout, so the budget keeps the worst case bounded; raise it via
// SCHEMA_GRAPH_MAX_LAYOUTS if you want a longer search.
const maxLayouts = Number(process.env.SCHEMA_GRAPH_MAX_LAYOUTS ?? 400);
const baseAnchors = assignEdgeAnchors(edges, positions);
const optimized = optimizeAnchorsWithLayout(
graphviz,
tables,
edges,
positions,
baseAnchors
baseAnchors,
maxLayouts
);
// Fill the whole canvas (including Graphviz's pad margin) so there is no