Compare commits
2 Commits
00cf5b418e
...
906ebafbbe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906ebafbbe | ||
|
|
ba376686f3 |
1
API.md
1
API.md
@@ -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}>>`
|
||||
|
||||
29
API.pic.md
29
API.pic.md
@@ -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
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 |
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
46
src/store/pic/getSmallThumbByHash.js
Normal file
46
src/store/pic/getSmallThumbByHash.js
Normal 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',
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user