Files
dashback/generate-api-graph.js
2026-10-13 13:21:14 +02:00

782 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Graphviz } from '@hpcc-js/wasm-graphviz';
import { getOpenApiSpec } from './src/api/openapiSpec.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outputPath = join(__dirname, 'api-graph.svg');
const dotOutputPath = join(__dirname, 'api-graph.dot');
const COLORS = {
background: '#1e2430',
tableBorder: '#3f4c5f',
headerBg: '#33415c',
headerText: '#e2e8f0',
rowBg: '#2a3242',
rowBgAlt: '#272e3d',
keyBg: '#2b3a52',
text: '#d8dee9',
muted: '#8b95a7',
clusterFill: '#202a3a',
};
const METHOD_COLORS = {
GET: { fg: '#38bdf8', bg: '#182b40' },
POST: { fg: '#4ade80', bg: '#143826' },
PUT: { fg: '#fbbf24', bg: '#3b2c12' },
PATCH: { fg: '#c084fc', bg: '#301c44' },
DELETE: { fg: '#f87171', bg: '#3a181e' },
};
const EDGE_COLORS = [
'#ff6b6b',
'#6bdf9a',
'#5aa7ff',
'#f5c15c',
'#c79bff',
'#ff9f5a',
'#5cd6d6',
'#ff7fc2',
'#8f95ff',
'#58d18a',
'#ff8f66',
'#4fc4e8',
'#a98cff',
'#ff6f6f',
'#4ecb8f',
'#5aa7e6',
'#ff7aa8',
'#e0b25a',
'#56c8c8',
'#ff5f9e',
'#ffb25a',
'#56d68a',
'#66b3ff',
'#c79bff',
];
const CLUSTERS = [
{
id: 'identity',
label: 'Identity & Access',
resources: ['Auth', 'Company', 'CompanySetting', 'Customergroup', 'Number'],
},
{
id: 'crm',
label: 'Entities & CRM',
resources: ['Entity'],
},
{
id: 'catalog',
label: 'Catalog & Products',
resources: ['Article', 'Category', 'Brand', 'Unit', 'PublicCatalog'],
},
{
id: 'properties',
label: 'Properties & Attributes',
resources: ['Property', 'Attribute'],
},
{
id: 'purchasing',
label: 'Suppliers & Purchasing',
resources: ['Supplier', 'ArticleSupplier', 'Delivery'],
},
{
id: 'sales',
label: 'Sales & Fulfillment',
resources: ['Quote', 'Order', 'Shipment', 'Shipping', 'Package'],
},
{
id: 'inventory',
label: 'Inventory & Warehousing',
resources: ['Stock', 'Ledger', 'Warehouse'],
},
{
id: 'finance',
label: 'Finance & Payments',
resources: ['Account', 'Payment'],
},
{
id: 'media',
label: 'Media & Storage',
resources: ['Image', 'Serper', 'Vfs'],
},
{
id: 'pos',
label: 'POS & Hardware',
resources: ['POSTerminal', 'Printer', 'LabelTemplate', 'POSSync'],
},
{
id: 'ai_mcp',
label: 'AI & Integrations',
resources: ['ModelConversation', 'MCP'],
},
{
id: 'system',
label: 'System & Realtime',
resources: ['Events', 'Health', 'Telemetry', 'Legend'],
},
];
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Maps an OpenAPI endpoint to its logical API resource card.
*/
function assignResource(apiPath, method, tag) {
if (apiPath.startsWith('/auth') || apiPath.startsWith('/.well-known')) return 'Auth';
if (apiPath.startsWith('/company/setting') || apiPath.startsWith('/companysetting')) return 'CompanySetting';
if (apiPath.startsWith('/company')) return 'Company';
if (apiPath.startsWith('/customergroup')) return 'Customergroup';
if (apiPath.startsWith('/number')) return 'Number';
if (apiPath.startsWith('/entity')) return 'Entity';
if (apiPath.startsWith('/propertyname')) return 'Property';
if (apiPath.startsWith('/attributename') || apiPath.startsWith('/attributevalue')) return 'Attribute';
if (apiPath.startsWith('/article/') && apiPath.includes('/supplier')) return 'ArticleSupplier';
if (apiPath.startsWith('/article/') && apiPath.includes('/property')) return 'Property';
if (apiPath.startsWith('/article/') && apiPath.includes('/attributevalue')) return 'Attribute';
if (apiPath.startsWith('/article')) return 'Article';
if (apiPath.startsWith('/category/') && apiPath.includes('/property')) return 'Property';
if (apiPath.startsWith('/category/') && apiPath.includes('/attributevalue')) return 'Attribute';
if (apiPath.startsWith('/category')) return 'Category';
if (apiPath.startsWith('/brand')) return 'Brand';
if (apiPath.startsWith('/unit')) return 'Unit';
if (apiPath.startsWith('/public')) return 'PublicCatalog';
if (apiPath.startsWith('/supplier')) return 'Supplier';
if (apiPath.startsWith('/delivery')) return 'Delivery';
if (apiPath.startsWith('/quote')) return 'Quote';
if (apiPath.startsWith('/order')) return 'Order';
if (apiPath.startsWith('/shipment')) return 'Shipment';
if (apiPath.startsWith('/shipping')) return 'Shipping';
if (apiPath.startsWith('/package')) return 'Package';
if (apiPath.startsWith('/stock')) return 'Stock';
if (apiPath.startsWith('/ledger')) return 'Ledger';
if (apiPath.startsWith('/warehouse')) return 'Warehouse';
if (apiPath.startsWith('/account')) return 'Account';
if (apiPath.startsWith('/payment')) return 'Payment';
if (apiPath.startsWith('/image') || apiPath.startsWith('/thumb') || apiPath.startsWith('/pic')) return 'Image';
if (apiPath.startsWith('/serper')) return 'Serper';
if (apiPath.startsWith('/vfs')) return 'Vfs';
if (apiPath.startsWith('/posterminal')) return 'POSTerminal';
if (apiPath.startsWith('/printer')) return 'Printer';
if (apiPath.startsWith('/labeltemplate')) return 'LabelTemplate';
if (apiPath.startsWith('/v1')) return 'POSSync';
if (apiPath.startsWith('/modelconversation')) return 'ModelConversation';
if (apiPath.startsWith('/mcp')) return 'MCP';
if (apiPath.startsWith('/events')) return 'Events';
if (apiPath.startsWith('/health')) return 'Health';
if (apiPath.startsWith('/telemetry')) return 'Telemetry';
return tag || 'Other';
}
function getResourceBasePath(resourceId) {
const map = {
Auth: '/auth',
Company: '/company',
CompanySetting: '/companysetting',
Customergroup: '/customergroup',
Number: '/number',
Entity: '/entity',
Article: '/article',
Category: '/category',
Brand: '/brand',
Unit: '/unit',
PublicCatalog: '/public',
Property: '/propertyname',
Attribute: '/attributename',
Supplier: '/supplier',
ArticleSupplier: '/article/{sku}/supplier',
Delivery: '/delivery',
Quote: '/quote',
Order: '/order',
Shipment: '/shipment',
Shipping: '/shipping',
Package: '/package',
Stock: '/stock',
Ledger: '/ledger',
Warehouse: '/warehouse',
Account: '/account',
Payment: '/payment',
Image: '/image',
Serper: '/serper',
Vfs: '/vfs',
POSTerminal: '/posterminal',
Printer: '/printer',
LabelTemplate: '/labeltemplate',
POSSync: '/v1',
ModelConversation: '/modelconversation',
MCP: '/mcp',
Events: '/events',
Health: '/health',
Telemetry: '/telemetry',
Legend: '',
};
return map[resourceId] ?? `/${resourceId.toLowerCase()}`;
}
function cleanSubpath(apiPath, basePath) {
if (apiPath === basePath) return '/';
if (apiPath.startsWith(basePath)) {
const sub = apiPath.slice(basePath.length);
return sub.startsWith('/') ? sub : `/${sub}`;
}
return apiPath;
}
function truncate(str, max = 28) {
if (!str) return '';
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
}
function sanitizePort(name) {
return name.replace(/[^a-zA-Z0-9_]/g, '_');
}
function parseApi(spec) {
const resources = new Map();
for (const [apiPath, pathItem] of Object.entries(spec.paths || {})) {
for (const [method, op] of Object.entries(pathItem || {})) {
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method.toLowerCase())) continue;
if (!op || typeof op !== 'object') continue;
const methodUpper = method.toUpperCase();
const tag = Array.isArray(op.tags) && op.tags[0] ? op.tags[0] : '';
const resourceId = assignResource(apiPath, methodUpper, tag);
const basePath = getResourceBasePath(resourceId);
if (!resources.has(resourceId)) {
resources.set(resourceId, {
id: resourceId,
basePath,
endpoints: [],
});
}
const res = resources.get(resourceId);
const subpath = cleanSubpath(apiPath, basePath);
const summary = op.summary || op.description || '';
const portId = sanitizePort(`ep_${methodUpper.toLowerCase()}_${apiPath}`);
res.endpoints.push({
method: methodUpper,
path: apiPath,
subpath,
portId,
summary: truncate(summary, 28),
operation: op,
});
}
}
// Sort endpoints within each resource: root subpath first, then by method rank, then path
const methodRank = { GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5 };
for (const res of resources.values()) {
res.endpoints.sort((a, b) => {
if (a.subpath === '/' && b.subpath !== '/') return -1;
if (b.subpath === '/' && a.subpath !== '/') return 1;
const rankDiff = (methodRank[a.method] ?? 99) - (methodRank[b.method] ?? 99);
if (rankDiff !== 0) return rankDiff;
return a.subpath.localeCompare(b.subpath);
});
}
return resources;
}
function findEndpointPort(res, method, pathSnippet) {
if (!res || !res.endpoints) return null;
const match = res.endpoints.find((ep) => {
if (method && ep.method !== method.toUpperCase()) return false;
if (pathSnippet) {
return ep.path.includes(pathSnippet) || ep.subpath.includes(pathSnippet);
}
return true;
});
return match?.portId ?? null;
}
/**
* Builds high-fidelity relationships between API resources.
*/
function buildApiEdges(resources) {
const edgeDefs = [
// Sales & Fulfillment
{ from: 'Order', to: 'Entity', label: 'entityId', method: 'GET', path: '/order/{id}' },
{ from: 'Order', to: 'Article', label: 'lines.sku', method: 'POST', path: '/order' },
{ from: 'Order', to: 'Shipment', label: 'shipments', method: 'GET', path: '/order/{id}' },
{ from: 'Order', to: 'Payment', label: 'payments', method: 'GET', path: '/order/{id}' },
{ from: 'Order', to: 'Shipping', label: 'shippings', method: 'GET', path: '/order/{id}' },
{ from: 'Quote', to: 'Entity', label: 'entityId', method: 'GET', path: '/quote/{id}' },
{ from: 'Quote', to: 'Article', label: 'lines.sku', method: 'POST', path: '/quote' },
{ from: 'Quote', to: 'Order', label: 'convert order', method: 'POST', path: '/quote' },
{ from: 'Shipment', to: 'Order', label: 'manifestId', method: 'GET', path: '/shipment/{id}' },
{ from: 'Shipment', to: 'Article', label: 'lines.sku', method: 'GET', path: '/shipment/{id}' },
{ from: 'Shipment', to: 'Shipping', label: 'shippings', method: 'GET', path: '/shipment/{id}/shipping' },
{ from: 'Shipping', to: 'Package', label: 'packages', method: 'GET', path: '/shipping/{id}' },
{ from: 'Package', to: 'Shipping', label: 'shippingId', method: 'POST', path: '/package' },
{ from: 'Package', to: 'Entity', label: 'addressId', method: 'POST', path: '/package' },
// Purchasing & Goods receipt
{ from: 'Delivery', to: 'Supplier', label: 'supplierId', method: 'POST', path: '/delivery' },
{ from: 'Delivery', to: 'Article', label: 'lines.sku', method: 'POST', path: '/delivery' },
{ from: 'Delivery', to: 'Ledger', label: 'ledger', method: 'GET', path: '/delivery/{id}' },
{ from: 'ArticleSupplier', to: 'Supplier', label: 'entityId', method: 'POST', path: '/supplier' },
{ from: 'ArticleSupplier', to: 'Article', label: 'sku', method: 'GET', path: '/supplier' },
{ from: 'Supplier', to: 'Entity', label: 'entityId', method: 'GET', path: '/supplier/{id}' },
// Inventory & Warehousing
{ from: 'Stock', to: 'Article', label: 'sku', method: 'POST', path: '/stock' },
{ from: 'Stock', to: 'Warehouse', label: 'warehouseId', method: 'POST', path: '/stock' },
{ from: 'Stock', to: 'Ledger', label: 'ledger', method: 'POST', path: '/stock' },
{ from: 'Ledger', to: 'Article', label: 'sku', method: 'GET', path: '/ledger' },
{ from: 'Ledger', to: 'Warehouse', label: 'warehouseId', method: 'GET', path: '/ledger' },
// Catalog & Products
{ from: 'Article', to: 'Category', label: 'categoryId', method: 'POST', path: '/article' },
{ from: 'Article', to: 'Brand', label: 'brand', method: 'PUT', path: '/brand' },
{ from: 'Article', to: 'Unit', label: 'unitSi', method: 'PATCH', path: '/article/{sku}' },
{ from: 'Article', to: 'ArticleSupplier', label: 'suppliers', method: 'GET', path: '/article/{sku}' },
{ from: 'Article', to: 'Image', label: 'photos', method: 'POST', path: '/image' },
{ from: 'Article', to: 'Customergroup', label: 'prices', method: 'PUT', path: '/prices' },
{ from: 'Article', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
{ from: 'Article', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
{ from: 'Article', to: 'Article', label: 'bundles & variants', method: 'GET', path: '/children' },
{ from: 'Category', to: 'Category', label: 'parentId', method: 'GET', path: '/category' },
{ from: 'Category', to: 'Article', label: 'articles', method: 'GET', path: '/category/{id}/article' },
{ from: 'Category', to: 'Image', label: 'image', method: 'POST', path: '/image' },
{ from: 'Category', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
{ from: 'Category', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
{ from: 'Brand', to: 'Image', label: 'image', method: 'POST', path: '/image' },
// Properties & Attributes
{ from: 'Property', to: 'Company', label: 'companyId', method: 'GET', path: '/propertyname' },
{ from: 'Attribute', to: 'Image', label: 'image', method: 'POST', path: '/image' },
// Finance & Payments
{ from: 'Payment', to: 'Account', label: 'accountId', method: 'POST', path: '/payment' },
{ from: 'Payment', to: 'Order', label: 'manifestId', method: 'POST', path: '/payment' },
{ from: 'Account', to: 'Company', label: 'companyId', method: 'GET', path: '/account' },
// CRM & Company
{ from: 'Entity', to: 'Customergroup', label: 'customergroupId', method: 'POST', path: '/entity' },
{ from: 'Company', to: 'Customergroup', label: 'poscustomergroup', method: 'GET', path: '/company/{id}' },
{ from: 'Company', to: 'Category', label: 'root_category', method: 'GET', path: '/company/{id}' },
{ from: 'Company', to: 'CompanySetting', label: 'settings', method: 'GET', path: '/company/{id}' },
{ from: 'Number', to: 'Company', label: 'companyId', method: 'GET', path: '/number' },
// POS & Hardware
{ from: 'POSTerminal', to: 'Company', label: 'companyId', method: 'GET', path: '/posterminal' },
{ from: 'POSTerminal', to: 'Category', label: 'root_category', method: 'GET', path: '/posterminal' },
{ from: 'Printer', to: 'Company', label: 'companyId', method: 'GET', path: '/printer' },
{ from: 'Printer', to: 'LabelTemplate', label: 'templates', method: 'GET', path: '/printer' },
{ from: 'LabelTemplate', to: 'Company', label: 'companyId', method: 'GET', path: '/labeltemplate' },
{ from: 'POSSync', to: 'POSTerminal', label: 'client bind', method: 'POST', path: '/client' },
{ from: 'POSSync', to: 'Article', label: 'product sync', method: 'GET', path: '/product' },
{ from: 'POSSync', to: 'Category', label: 'category sync', method: 'GET', path: '/category' },
// AI, Tools & Public
{ from: 'PublicCatalog', to: 'Article', label: 'articles', method: 'GET', path: '/product' },
{ from: 'PublicCatalog', to: 'Category', label: 'category', method: 'GET', path: '/category' },
{ from: 'ModelConversation', to: 'Vfs', label: 'vfs workspace', method: 'GET', path: '/modelconversation' },
{ from: 'MCP', to: 'Article', label: 'article tools', method: 'POST', path: '/mcp' },
{ from: 'MCP', to: 'Order', label: 'order tools', method: 'POST', path: '/mcp' },
{ from: 'Events', to: 'Stock', label: 'stock sse', method: 'GET', path: '/events' },
{ from: 'Events', to: 'Ledger', label: 'ledger sse', method: 'GET', path: '/events' },
{ from: 'Events', to: 'Article', label: 'article sse', method: 'GET', path: '/events' },
];
const edges = [];
for (const def of edgeDefs) {
if (!resources.has(def.from) || !resources.has(def.to)) continue;
const resFrom = resources.get(def.from);
const resolvedPort = findEndpointPort(resFrom, def.method, def.path);
edges.push({
from: def.from,
to: def.to,
label: def.label,
port: resolvedPort,
tooltip: `${def.from} → ${def.to} (${def.label})`,
inferred: false,
});
}
return edges;
}
function edgeKey(edge) {
return `${edge.from}|${edge.to}|${edge.label}`;
}
function assignEdgeColors(edges) {
const colors = new Map();
let next = 0;
for (const edge of edges) {
const key = edgeKey(edge);
if (!colors.has(key)) {
colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]);
next++;
}
}
return colors;
}
/**
* Spanning ranking forest: only one primary outgoing edge per resource
* is used for rank constraints. Other edges get constraint=false to avoid
* vertical over-stretching.
*/
function selectRankingEdgeKeys(edges) {
const ranking = new Set();
const bySource = new Map();
for (const edge of edges) {
if (edge.from === edge.to) continue;
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
bySource.get(edge.from).push(edge);
}
for (const group of bySource.values()) {
group.sort((a, b) => a.label.localeCompare(b.label));
ranking.add(edgeKey(group[0]));
}
return ranking;
}
function buildLegendLabel() {
const methodRow = (method, desc) => {
const c = METHOD_COLORS[method];
return (
`<TR><TD BGCOLOR="${c.bg}" ALIGN="CENTER" CELLPADDING="2" WIDTH="38">` +
`<FONT COLOR="${c.fg}" POINT-SIZE="9"><B>${method}</B></FONT></TD>` +
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${desc}</FONT></TD></TR>`
);
};
const row = (swatch, text) =>
`<TR><TD ALIGN="CENTER" CELLPADDING="2">${swatch}</TD>` +
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER">` +
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>API Graph Legend</B></FONT></TD></TR>` +
methodRow('GET', 'Safe retrieval / query') +
methodRow('POST', 'Create resource / action') +
methodRow('PUT', 'Full replace / collection update') +
methodRow('PATCH', 'Partial entity update') +
methodRow('DELETE', 'Remove resource') +
row(
`<FONT COLOR="${COLORS.headerText}"><B>in#</B></FONT>`,
'Inbound arrival port on resource'
) +
row(
`<FONT COLOR="#5aa7ff">&#9642;&#8212;&#9654;</FONT>`,
'Relational reference / dataflow'
) +
`</TABLE>>`
);
}
function renderResourceNode(res, inboundCount = 0) {
if (res.id === 'Legend') {
return buildLegendLabel();
}
const titleFont =
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(res.id)}</B></FONT>` +
`<BR/><FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(res.basePath)}</FONT>`;
let headerInner;
if (inboundCount > 0) {
const portCells = [];
for (let i = 0; i < inboundCount; i++) {
portCells.push(
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
);
}
headerInner =
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>${portCells.join('')}</TR>` +
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">${titleFont}</TD></TR>` +
`</TABLE>`;
} else {
headerInner = titleFont;
}
const rows = [
`<TR><TD COLSPAN="3" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">${headerInner}</TD></TR>`,
];
for (const [i, ep] of res.endpoints.entries()) {
const mColor = METHOD_COLORS[ep.method] ?? { fg: COLORS.text, bg: COLORS.rowBg };
const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
rows.push(
`<TR><TD PORT="${escapeHtml(ep.portId)}" COLSPAN="3" BGCOLOR="${rowBg}" BORDER="1" CELLPADDING="0">` +
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>` +
`<TD BGCOLOR="${mColor.bg}" ALIGN="CENTER" CELLPADDING="3" WIDTH="38">` +
`<FONT COLOR="${mColor.fg}" POINT-SIZE="9"><B>${escapeHtml(ep.method)}</B></FONT></TD>` +
`<TD ALIGN="LEFT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.text}" POINT-SIZE="10"><B>${escapeHtml(ep.subpath)}</B></FONT></TD>` +
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="8">${escapeHtml(ep.summary)}</FONT></TD>` +
`</TR></TABLE></TD></TR>`
);
}
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="${COLORS.tableBorder}">` +
rows.join('') +
`</TABLE>>`
);
}
function assignEdgeAnchors(edges, positions) {
const headPort = new Map();
const tailCompass = new Map();
const inboundCounts = new Map();
const byTarget = new Map();
const bySource = new Map();
for (const edge of edges) {
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
byTarget.get(edge.to).push(edge);
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
bySource.get(edge.from).push(edge);
}
for (const [target, group] of byTarget) {
group.sort((a, b) => {
const ax = positions?.get(a.from)?.x ?? 0;
const bx = positions?.get(b.from)?.x ?? 0;
if (ax !== bx) return ax - bx;
return a.label.localeCompare(b.label);
});
inboundCounts.set(target, group.length);
group.forEach((edge, i) => {
headPort.set(edgeKey(edge), `in${i}`);
});
}
for (const group of bySource.values()) {
group.forEach((edge, i) => {
if (edge.from === edge.to) {
tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw');
return;
}
const from = positions?.get(edge.from);
const to = positions?.get(edge.to);
const fromX = from?.x ?? 0;
const toX = to?.x ?? fromX;
const goEast = toX >= fromX;
tailCompass.set(edgeKey(edge), goEast ? 'e' : 'w');
});
}
return { headPort, tailCompass, inboundCounts };
}
function buildDot(resources, edges, positions, anchors) {
const edgeColors = assignEdgeColors(edges);
const rankingKeys = selectRankingEdgeKeys(edges);
const { headPort, tailCompass, inboundCounts } =
anchors ?? assignEdgeAnchors(edges, positions);
const lines = [
'digraph api {',
' graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430",',
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
' compound=true, mclimit=1.0];',
' node [shape=plaintext, fontname="Helvetica"];',
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
'',
];
const clusteredResources = new Set();
for (const cluster of CLUSTERS) {
const presentInCluster = cluster.resources.filter((id) => resources.has(id));
if (presentInCluster.length === 0) continue;
lines.push(` subgraph cluster_${cluster.id} {`);
lines.push(` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`);
lines.push(` style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f";`);
lines.push(` fontcolor="#e2e8f0"; margin=14;`);
for (const resId of presentInCluster) {
clusteredResources.add(resId);
const res = resources.get(resId);
const inCount = inboundCounts.get(resId) ?? 0;
const label = renderResourceNode(res, inCount);
lines.push(` ${resId} [label=${label}];`);
}
lines.push(' }');
}
// Any unclustered resources
for (const [resId, res] of resources) {
if (clusteredResources.has(resId)) continue;
const inCount = inboundCounts.get(resId) ?? 0;
const label = renderResourceNode(res, inCount);
lines.push(` ${resId} [label=${label}];`);
}
lines.push('');
for (const edge of edges) {
const key = edgeKey(edge);
const color = edgeColors.get(key) ?? '#5aa7ff';
const isRanking = rankingKeys.has(key);
const hp = headPort.get(key) ?? 'in0';
const tc = tailCompass.get(key) ?? 'e';
const attrs = [
`color="${color}"`,
`fontcolor="${color}"`,
`tooltip="${escapeHtml(edge.tooltip)}"`,
`label=" ${escapeHtml(edge.label)} "`,
];
if (!isRanking && edge.from !== edge.to) {
attrs.push('constraint=false', 'weight=1');
} else {
attrs.push('weight=3');
}
// Source port from endpoint if available, else exit compass
const fromPort = edge.port ? `:${edge.port}:${tc}` : `:${tc}`;
const toPort = `:${hp}:n`;
lines.push(` ${edge.from}${fromPort} -> ${edge.to}${toPort} [${attrs.join(', ')}];`);
}
lines.push('}');
return lines.join('\n');
}
function parsePlainPositions(plain) {
const positions = new Map();
for (const line of plain.split('\n')) {
if (!line.startsWith('node ')) continue;
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
if (!match) continue;
positions.set(match[1], {
x: Number(match[2]),
y: Number(match[3]),
width: Number(match[4]),
height: Number(match[5]),
});
}
return positions;
}
function fixSelfLoopSvg(svg) {
return svg.replace(
/<g id="(edge\d+)" class="edge">\s*<title>(\w+)(?::\w+)?&#45;&gt;\2(?::\w+)?<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g,
(match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => {
const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
if (pts.length < 2) return match;
const [x0, y0] = pts[0];
const [x1, y1] = pts[pts.length - 1];
const east = Math.max(x0, x1) + 36;
const north = Math.min(y0, y1) - 28;
const tx = x1 - east;
const ty = y1 - north;
const len = Math.hypot(tx, ty) || 1;
const dx = tx / len;
const dy = ty / len;
const arrowLen = 9;
const arrowHalf = 3.5;
const baseX = x1 - dx * arrowLen;
const baseY = y1 - dy * arrowLen;
const px = -dy * arrowHalf;
const py = dx * arrowHalf;
const fmt = (n) => (Math.round(n * 100) / 100).toString();
const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`;
const newPointsFmt =
`${fmt(x1)},${fmt(y1)} ` +
`${fmt(baseX + px)},${fmt(baseY + py)} ` +
`${fmt(baseX - px)},${fmt(baseY - py)} ` +
`${fmt(x1)},${fmt(y1)}`;
return (
`<g id="${edgeId}" class="edge">\n` +
`<title>${_table}&#45;&gt;${_table}</title>\n` +
`<g id="${aId}"><a ${aAttrs}>\n` +
`<path${pathAttrs} d="${newDFmt}"/>\n` +
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
`</a>\n` +
`</g>\n` +
`</g>`
);
}
);
}
console.log('Generating Dash API Graph…');
const spec = getOpenApiSpec(true);
const resources = parseApi(spec);
// Add Legend pseudo-resource
resources.set('Legend', {
id: 'Legend',
basePath: '',
endpoints: [],
});
const edges = buildApiEdges(resources);
console.log(`Parsed ${resources.size - 1} API resources and ${edges.length} relationships`);
const graphviz = await Graphviz.load();
// Pass 1: Initial layout to learn coordinates
console.log('Layout pass 1/2: computing node positions…');
const initialDot = buildDot(resources, edges, null, null);
const plain = graphviz.layout(initialDot, 'plain', 'dot');
const positions = parsePlainPositions(plain);
// Pass 2: Anchor assignment using learned positions
console.log('Layout pass 2/2: assigning header arrival ports & exit directions…');
const anchors = assignEdgeAnchors(edges, positions);
const finalDot = buildDot(resources, edges, positions, anchors);
const rawSvg = graphviz.layout(finalDot, 'svg', 'dot');
const fixedSvg = fixSelfLoopSvg(rawSvg);
// Ensure background color is set on svg tag
const finalSvg = fixedSvg.replace(
/<svg([^>]*?)>/,
`<svg$1 style="background-color:${COLORS.background};">`
);
writeFileSync(dotOutputPath, finalDot, 'utf8');
writeFileSync(outputPath, finalSvg, 'utf8');
const dim = finalSvg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
console.log(`Written ${dotOutputPath}`);
console.log(`Written ${outputPath}`);
if (dim) {
console.log(`Layout ${dim[1]}pt × ${dim[2]}pt`);
}