diff --git a/bench-graph.tmp.mjs b/bench-graph.tmp.mjs new file mode 100644 index 0000000..f056528 --- /dev/null +++ b/bench-graph.tmp.mjs @@ -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` + ); +} diff --git a/generate-schema-graph.js b/generate-schema-graph.js index fe29108..c2ea98c 100644 --- a/generate-schema-graph.js +++ b/generate-schema-graph.js @@ -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 diff --git a/schema-graph.dot b/schema-graph.dot index 1293518..618d329 100644 --- a/schema-graph.dot +++ b/schema-graph.dot @@ -1,7 +1,7 @@ digraph schema { graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430", 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]; @@ -9,11 +9,11 @@ digraph schema { label="Identity & Company"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - customergroup [label=<
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| meta | |||
| |||
| |||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| companyhistory | |||
| |||
| |||
| |||
| |||
| |||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||||||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| companyhistory | |||
| |||
| |||
| |||
| |||
| |||
| |||||||||||
| |||||||||||
| |||||||||||
| |||||||||||
| |||||||||||
| |||||||||||
| |||||||||||
| |||||||||||
| numbers | |||
| |||
| |||
| |||
| |||
| |||
| |||
| entitycontact | |||
| |||
| |||
| |||
| |||
| |||
| |||||||||
| |||||||||
| |||||||||
| unittext | |||
| |||
| |||
| |||
| |||
| |||
| articletext | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| categorytext | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| articletext | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| articletombstone | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||||
| |||||
| |||||
| |||||
| |||||
| |||||
| |||||
| articlelistarticle | |||
| |||
| |||
| |||
| |||
| |||
| |||||
| |||||
| optionslistarticle | |||
| |||
| |||
| |||
| |||
| |||
| |||
| articlehistory | |||
| |||
| |||
| |||
| |||
| |||
| articleean | |||
| |||
| |||
| |||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| articlesupplier | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| categoryarticle | |||
| |||
| |||
| |||
| |||||||||||||||||||
| |||||||||||||||||||
| |||||||||||||||||||
| |||||||||||||||||||
| |||||||||||||||||||
| categoryarticle | |||
| |||
| |||
| |||
| articlehistory | |||
| |||
| |||
| |||
| |||
| |||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| |||||||||||||||||||||||||
| articleean | |||
| |||
| |||
| |||
| articlesupplier | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||||
| |||||
| articlelistarticle | |||
| |||
| |||
| |||
| |||
| |||
| |||||
| |||||
| optionslistarticle | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||||
| |||||
| |||||
| |||||
| |||||
| articletombstone | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| brandpic | |||
| |||
| |||
| |||
| |||
| articlepic | |||
| |||
| |||
| |||
| |||
| articlepic | |||
| |||
| |||
| |||
| |||
| categorypic | |||
| |||
| |||
| |||
| |||
| brandpic | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| ledger | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| ledger | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| manifestattachment | |||
| |||
| |||
| |||
| |||
| package | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||||||||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||||||||||
| |||||||||||
| |||||||||||
| price | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| price | |||
| |||
| |||
| |||
| |||
| |||
| |||
| payment | |||
| |||
| |||
| |||
| |||
| |||
| posterminalcategory | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| printer | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| posterminalcategory | |||
| |||
| |||
| |||
| labeltemplate | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| modelvfs | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| modelmessage | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| modelvfs | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| articleproperty | |||
| |||
| |||
| |||
| |||
| |||
| articleattributevalue | |||
| |||
| |||
| |||
| |||
| categoryproperty | |||
| |||
| |||
| |||
| |||
| |||
| categoryattributevalue | |||
| |||
| |||
| |||
| |||
| |||||
| |||||
| |||||
| |||||
| |||||
| |||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||
| |||||
| |||||
| |||||
| |||||
| categoryproperty | |||
| |||
| |||
| |||
| |||
| |||
| categoryattributevalue | |||
| |||
| |||
| |||
| |||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| |||||||
| articleproperty | |||
| |||
| |||
| |||
| |||
| |||
| articleattributevalue | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| package | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| payment | |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| |||
| Legend | |
| PK | primary key |
| FK | foreign key |
| ? | nullable column |
| — | FOREIGN KEY constraint |
| - - | naming convention only, no constraint |