u
This commit is contained in:
132
bench-graph.tmp.mjs
Normal file
132
bench-graph.tmp.mjs
Normal 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`
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
222
schema-graph.dot
222
schema-graph.dot
File diff suppressed because one or more lines are too long
5186
schema-graph.svg
5186
schema-graph.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 316 KiB |
Reference in New Issue
Block a user