Compare commits

...

2 Commits

Author SHA1 Message Date
seb
906ebafbbe u 2026-10-09 21:43:20 +02:00
seb
ba376686f3 u 2026-10-09 21:37:42 +02:00
10 changed files with 3006 additions and 2715 deletions

1
API.md
View File

@@ -242,6 +242,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `setSelectedCompany(googleAuthId: number, companyId: number): Promise<void>`
- [pic](API.pic.md)
- `getImageByHash(googleAuthId: number, fullHash: string, tx?: object): Promise<{buffer: Buffer, w: number, h: number, size: number, contentType: string}>`
- `getSmallThumbByHash(googleAuthId: number, fullHash: string, tx?: object): Promise<{buffer: Buffer, w: number, h: number, contentType: string}>`
- `getThumbByHash(googleAuthId: number, fullHash: string, tx?: object): Promise<{buffer: Buffer, w: number, h: number, contentType: string}>`
- [numbers](API.numbers.md)
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, type: string, prefix: string, padding: number, next: number}>>`

View File

@@ -31,6 +31,35 @@ const image = await store.pic.getImageByHash(42, 'abc123');
// => { buffer, w, h, size, contentType: 'image/avif' }
```
## `pic.getSmallThumbByHash`
*Source: `src/store/pic/getSmallThumbByHash.js`*
Returns the small thumbnail image (AVIF, 40px max edge) for a given hash.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `fullHash` | `string` | The full image hash. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{buffer: Buffer, w: number, h: number, contentType: string}>` — The small thumbnail image.
**Throws**
`Error` — If the image is not found.
**Example**
```js
const thumb = await store.pic.getSmallThumbByHash(42, 'abc123');
// => { buffer, w, h, contentType: 'image/avif' }
```
## `pic.getThumbByHash`
*Source: `src/store/pic/getThumbByHash.js`*

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

View File

@@ -138,6 +138,7 @@ import googleAuthSetSelectedCompany from './store/google_auth/setSelectedCompany
import googleAuthSetProfile from './store/google_auth/setProfile.js';
import picGetImageByHash from './store/pic/getImageByHash.js';
import picGetThumbByHash from './store/pic/getThumbByHash.js';
import picGetSmallThumbByHash from './store/pic/getSmallThumbByHash.js';
import numbersList from './store/numbers/list.js';
import numbersUpdate from './store/numbers/update.js';
import storePurge from './store/purge.js';
@@ -471,6 +472,7 @@ export default class LibDashStore {
this.pic = {
getImageByHash: picGetImageByHash(provider),
getThumbByHash: picGetThumbByHash(provider),
getSmallThumbByHash: picGetSmallThumbByHash(provider),
};
this.numbers = {

View File

@@ -61,6 +61,12 @@ FROM pic
WHERE hash = @hash
`;
export const GET_SMALLTHUMB_BY_HASH = `
SELECT smallthumbw AS w, smallthumbh AS h, smallthumbbinary AS buffer
FROM pic
WHERE hash = @hash
`;
export const DELETE_BY_ARTICLE_AND_HASH = `
DELETE p
FROM pic p

View File

@@ -30,6 +30,12 @@ FROM pic
WHERE hash = @hash
`;
export const GET_SMALLTHUMB_BY_HASH = `
SELECT smallthumbw AS w, smallthumbh AS h, smallthumbbinary AS buffer
FROM pic
WHERE hash = @hash
`;
export const DELETE_BY_ARTICLE_AND_HASH = `
DELETE FROM pic
WHERE hash = @fullHash

View File

@@ -0,0 +1,46 @@
import { GET_SMALLTHUMB_BY_HASH as MSSQL_GET } from '../../providers/mssql/queries/pic.js';
import { GET_SMALLTHUMB_BY_HASH as SQLITE_GET } from '../../providers/sqlite3/queries/pic.js';
import resolvePicAccess from './resolvePicAccess.js';
import makeRunner from '../runner.js';
export default function getSmallThumbByHash(provider) {
const authorize = resolvePicAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
/**
* Returns the small thumbnail image (AVIF, 40px max edge) for a given hash.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} fullHash - The full image hash.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{buffer: Buffer, w: number, h: number, contentType: string}>} The small thumbnail image.
* @throws {Error} If the image is not found.
*
* @example
* const thumb = await store.pic.getSmallThumbByHash(42, 'abc123');
* // => { buffer, w, h, contentType: 'image/avif' }
*/
return async (googleAuthId, fullHash, tx) => {
await authorize(googleAuthId, fullHash, tx);
const runner = makeRunner(provider, tx);
const result = await runner.query(sql, { hash: fullHash });
const row = result.rows[0];
if (!row) {
throw new Error('Image not found');
}
const buffer = row.buffer ?? row.smallthumbbinary;
if (!buffer) {
throw new Error('Image not found');
}
return {
buffer,
w: row.w,
h: row.h,
contentType: 'image/avif',
};
};
}