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

132
bench-graph.tmp.mjs Normal file
View File

@@ -0,0 +1,132 @@
import { readFileSync } from 'node:fs';
import { Graphviz } from '@hpcc-js/wasm-graphviz';
const src = readFileSync(
'/home/seb/src/dashdbschema/generate-schema-graph.js',
'utf8'
);
const parser = src.slice(
src.indexOf('function parseSchema'),
src.indexOf('const EDGE_COLORS')
);
const edgeColorsConst = src.slice(
src.indexOf('const EDGE_COLORS'),
src.indexOf('function edgeKey')
);
const edgeKeyFn = src.slice(
src.indexOf('function edgeKey'),
src.indexOf('/**\n * Only a spanning forest')
);
const ranking = src.slice(
src.indexOf('function selectRankingEdgeKeys'),
src.indexOf('function assignEdgeColors')
);
const edgeColors = src.slice(
src.indexOf('function assignEdgeColors'),
src.indexOf('function escapeHtml')
);
const escape = src.slice(
src.indexOf('function escapeHtml'),
src.indexOf('/**\n * Domain grouping')
);
const clusters = src.slice(
src.indexOf('const CLUSTERS'),
src.indexOf('const COLORS =')
);
const colors = src.slice(
src.indexOf('const COLORS ='),
src.indexOf('const COLORS =') +
src.slice(src.indexOf('const COLORS =')).indexOf('};') +
2
);
const orderMembers = src.slice(
src.indexOf('function orderClusterMembers'),
src.indexOf('function buildTableLabel')
);
const plainPositions = src.slice(
src.indexOf('function parsePlainPositions'),
src.indexOf('function flipCompassLR')
);
const reorder = src.slice(
src.indexOf('function reorderColumnsForCrossingReduction'),
src.indexOf('function parsePlainPositions')
);
const builder = src.slice(
src.indexOf('function buildTableLabel'),
src.indexOf('function countSvgCrossings')
);
const crossingsFn = src.slice(
src.indexOf('function countSvgCrossings'),
src.indexOf('/**\n * Graphviz self-edges')
);
const mod = await import(
'data:text/javascript,' +
encodeURIComponent(
parser +
'\n' +
edgeColorsConst +
'\n' +
clusters +
'\n' +
colors +
'\n' +
orderMembers +
'\n' +
plainPositions +
'\n' +
reorder +
'\n' +
edgeKeyFn +
'\n' +
ranking +
'\n' +
edgeColors +
'\n' +
escape +
'\n' +
builder +
'\n' +
crossingsFn +
'\nexport { parseSchema, buildDot, assignEdgeAnchors, parsePlainPositions, reorderColumnsForCrossingReduction, orderClusterMembers, CLUSTERS, COLORS, countSvgCrossings };'
)
);
const schemaSource = readFileSync(
'/home/seb/src/dashdbschema/src/providers/mssql/schema.js',
'utf8'
);
const { tables, edges } = mod.parseSchema(schemaSource);
const graphviz = await Graphviz.load();
let positions = null;
for (let pass = 0; pass < 3; pass++) {
const probeDot = mod.buildDot(tables, edges, positions);
const plain = graphviz.layout(probeDot, 'plain', 'dot');
positions = mod.parsePlainPositions(plain);
mod.reorderColumnsForCrossingReduction(tables, edges, positions);
}
const anchors = mod.assignEdgeAnchors(edges, positions);
const baseDot = mod.buildDot(tables, edges, positions, anchors);
const variants = [
['mclimit=4.0 splines=spline (current)', 'mclimit=4.0', 'splines=spline'],
['mclimit=1.0 splines=spline', 'mclimit=1.0', 'splines=spline'],
['mclimit=0.5 splines=spline', 'mclimit=0.5', 'splines=spline'],
['mclimit=1.0 splines=true', 'mclimit=1.0', 'splines=true'],
['mclimit=1.0 splines=polyline', 'mclimit=1.0', 'splines=polyline'],
];
for (const [label, mclimit, splines] of variants) {
const dot = baseDot
.replace('mclimit=4.0', mclimit)
.replace('splines=spline', splines);
const t = process.hrtime.bigint();
const svg = graphviz.layout(dot, 'svg', 'dot');
const ms = Number(process.hrtime.bigint() - t) / 1e6;
const t2 = process.hrtime.bigint();
const crossings = mod.countSvgCrossings(svg);
const cms = Number(process.hrtime.bigint() - t2) / 1e6;
console.log(
`${label}: layout=${ms.toFixed(0)}ms crossings=${crossings} (count=${cms.toFixed(0)}ms) total=${(ms + cms).toFixed(0)}ms`
);
}

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 316 KiB