885 lines
37 KiB
JavaScript
885 lines
37 KiB
JavaScript
import { writeFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { Graphviz } from '@hpcc-js/wasm-graphviz';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const outputPath = join(__dirname, 'automat-graph.svg');
|
||
const dotOutputPath = join(__dirname, 'automat-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 BADGE_COLORS = {
|
||
STATE: { fg: '#c084fc', bg: '#2b1a42' }, // Purple
|
||
ACTION: { fg: '#4ade80', bg: '#143826' }, // Green
|
||
SELECT: { fg: '#38bdf8', bg: '#182b40' }, // Sky Blue
|
||
API: { fg: '#fbbf24', bg: '#3b2c12' }, // Amber
|
||
SSE: { fg: '#f87171', bg: '#3a181e' }, // Rose/Red
|
||
HOOK: { fg: '#fb923c', bg: '#382414' }, // Orange/Gold
|
||
};
|
||
|
||
const EDGE_COLORS = [
|
||
'#5aa7ff', // Blue
|
||
'#6bdf9a', // Mint Green
|
||
'#f5c15c', // Golden Amber
|
||
'#ff7fc2', // Pink
|
||
'#c79bff', // Lavender
|
||
'#5cd6d6', // Cyan
|
||
'#ff9f5a', // Coral
|
||
'#8f95ff', // Indigo
|
||
'#ff6b6b', // Soft Red
|
||
'#58d18a', // Emerald
|
||
'#4fc4e8', // Sky
|
||
'#a98cff', // Violet
|
||
'#ff8f66', // Tangerine
|
||
'#4ecb8f', // Seafoam
|
||
'#ff7aa8', // Rose
|
||
'#e0b25a', // Sand
|
||
'#56c8c8', // Aqua
|
||
'#ff5f9e', // Fuchsia
|
||
'#ffb25a', // Ochre
|
||
'#66b3ff', // Periwinkle
|
||
];
|
||
|
||
const CLUSTERS = [
|
||
{
|
||
id: 'session_realtime',
|
||
label: 'Session & Real-Time Hub',
|
||
automats: ['authAutomat', 'sseAutomat'],
|
||
},
|
||
{
|
||
id: 'company_basedata',
|
||
label: 'Company & Consolidated Base Data',
|
||
automats: ['companyAutomat'],
|
||
},
|
||
{
|
||
id: 'catalog_products',
|
||
label: 'Catalog & Products',
|
||
automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat'],
|
||
},
|
||
{
|
||
id: 'search_indexing',
|
||
label: 'Search & Indexing',
|
||
automats: ['articleIndexAutomat', 'articleSearchAutomat'],
|
||
},
|
||
{
|
||
id: 'purchasing_logistics',
|
||
label: 'Deliveries & Inbound',
|
||
automats: ['deliveryAutomat', 'deliveryEditorAutomat'],
|
||
},
|
||
{
|
||
id: 'sales_crm',
|
||
label: 'Sales & CRM',
|
||
automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat'],
|
||
},
|
||
{
|
||
id: 'ai_workspace',
|
||
label: 'AI Assistant & Workspace',
|
||
automats: ['chatAutomat', 'vfsAutomat'],
|
||
},
|
||
{
|
||
id: 'audit_grids',
|
||
label: 'Audit & Virtual Grids',
|
||
automats: ['articleHistoryGridAutomat', 'globalHistoryGridAutomat', 'ledgerHistoryGridAutomat'],
|
||
},
|
||
{
|
||
id: 'system_ui',
|
||
label: 'UI, Navigation & System',
|
||
automats: ['navigationAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'],
|
||
},
|
||
];
|
||
|
||
function escapeHtml(text) {
|
||
return String(text)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function truncate(str, max = 30) {
|
||
if (!str) return '';
|
||
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
||
}
|
||
|
||
/**
|
||
* Complete metadata for every Automat in Dashfront.
|
||
*/
|
||
const AUTOMAT_SPECS = [
|
||
// 1. Session & Real-Time Hub
|
||
{
|
||
id: 'authAutomat',
|
||
title: 'authAutomat',
|
||
subtitle: 'Always-On Session & Multi-Tenant Gateway',
|
||
entries: [
|
||
{ type: 'STATE', name: 'hasAuth: boolean', desc: 'Authenticated session bit', port: 'p_auth_hasAuth' },
|
||
{ type: 'STATE', name: 'hasCompany: boolean', desc: 'hasAuth && selectedCompanyId', port: 'p_auth_hasCompany' },
|
||
{ type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company', port: 'p_auth_user' },
|
||
{ type: 'STATE', name: 'schemaVersion: number|null', desc: 'Backend DB schema version', port: 'p_auth_schemaVersion' },
|
||
{ type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'', port: 'p_auth_status' },
|
||
{ type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' },
|
||
{ type: 'ACTION', name: 'switchCompany(id)', desc: 'Multi-tenant switch', port: 'p_auth_switchCompany' },
|
||
{ type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' },
|
||
{ type: 'SELECT', name: 'selectHasAuth() / selectHasCompany()', desc: 'Auth & tenant gates' },
|
||
{ type: 'SELECT', name: 'selectSchemaVersion()', desc: 'Active DB schema version' },
|
||
{ type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' },
|
||
],
|
||
},
|
||
{
|
||
id: 'sseAutomat',
|
||
title: 'sseAutomat',
|
||
subtitle: 'Single Persistent SSE Connection',
|
||
entries: [
|
||
{ type: 'STATE', name: 'status', desc: '\'connected\' | \'error\'' },
|
||
{ type: 'STATE', name: 'clientId (cid)', desc: 'Tab-scoped UUID in session' },
|
||
{ type: 'ACTION', name: 'reconnect()', desc: 'Cycle on company switch' },
|
||
{ type: 'ACTION', name: 'on(event, handler)', desc: 'Register signal listener' },
|
||
{ type: 'ACTION', name: 'watchArticle(sku)', desc: 'Attach article-<sku>' },
|
||
{ type: 'SELECT', name: 'invalidateArticle(sku)', desc: 'Silent cache refresh' },
|
||
{ type: 'SSE', name: 'category / articles', desc: 'Catalog mutation events', port: 'p_sse_events' },
|
||
{ type: 'SSE', name: 'order / shipment', desc: 'Fulfillment events' },
|
||
{ type: 'API', name: 'GET /api/events', desc: 'Persistent stream' },
|
||
],
|
||
},
|
||
|
||
// 2. Company & Consolidated Base Data
|
||
{
|
||
id: 'companyAutomat',
|
||
title: 'companyAutomat',
|
||
subtitle: 'Consolidated Company & Base Data Hub',
|
||
entries: [
|
||
{ type: 'STATE', name: 'current: Company', desc: 'Tax rates, legal & root cat', port: 'p_comp_curr' },
|
||
{ type: 'STATE', name: 'numbers: NumberSeq[]', desc: 'Document number sequences', port: 'p_comp_num' },
|
||
{ type: 'STATE', name: 'terminals: Posterminal[]', desc: 'POS hardware & categories', port: 'p_comp_pos' },
|
||
{ type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Vendors & supplier articles', port: 'p_comp_sup' },
|
||
{ type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units', port: 'p_comp_unit' },
|
||
{ type: 'STATE', name: 'viewerPosition / chatDrawerWidth', desc: 'Persisted user UI settings', port: 'p_comp_usr' },
|
||
{ type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage locations & stock', port: 'p_comp_wh' },
|
||
{ type: 'STATE', name: 'properties: PropertyName[]', desc: 'Custom text properties', port: 'p_comp_prop' },
|
||
{ type: 'STATE', name: 'customergroups: Group[]', desc: 'Pricing discount tiers', port: 'p_comp_cgrp' },
|
||
{ type: 'STATE', name: 'carriers: Carrier[]', desc: 'Shipping methods & carriers', port: 'p_comp_car' },
|
||
{ type: 'STATE', name: 'brands: Brand[]', desc: 'Catalog brands & logos', port: 'p_comp_brand' },
|
||
{ type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes & discrete values', port: 'p_comp_attr' },
|
||
{ type: 'STATE', name: 'accounts: Account[]', desc: 'Bank & payment ledgers', port: 'p_comp_acc' },
|
||
{ type: 'ACTION', name: 'loadAll() / reset()', desc: 'Fetch or reset all base data' },
|
||
{ type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' },
|
||
{ type: 'API', name: '/api/company, /api/brand, …', desc: '13 consolidated endpoints' },
|
||
],
|
||
},
|
||
|
||
// 3. Catalog & Products
|
||
{
|
||
id: 'categoryAutomat',
|
||
title: 'categoryAutomat',
|
||
subtitle: 'Hierarchical Category Tree',
|
||
entries: [
|
||
{ type: 'STATE', name: 'categories: Map', desc: 'ID -> Category Node Map', port: 'p_cat_categories' },
|
||
{ type: 'STATE', name: 'rootCategoryId', desc: 'Company root node ID' },
|
||
{ type: 'STATE', name: 'articleCount', desc: 'Total parent items' },
|
||
{ type: 'ACTION', name: 'loadTree()', desc: 'Fetch tree & warm views', port: 'p_cat_loadTree' },
|
||
{ type: 'ACTION', name: 'createCategory()', desc: 'Insert child node' },
|
||
{ type: 'ACTION', name: 'moveCategory()', desc: 'Parent reparenting' },
|
||
{ type: 'ACTION', name: 'deleteCategory()', desc: 'Prune subtree' },
|
||
{ type: 'SELECT', name: 'getCategoryBreadcrumbs()', desc: 'Hierarchical path trace' },
|
||
{ type: 'API', name: 'GET /api/category', desc: 'Recursive tree load' },
|
||
],
|
||
},
|
||
{
|
||
id: 'categoryViewAutomat',
|
||
title: 'categoryViewAutomat',
|
||
subtitle: 'KeyedAutomat (Slug Path)',
|
||
entries: [
|
||
{ type: 'STATE', name: 'views: Map<slug, Data>', desc: 'Articles & direct children' },
|
||
{ type: 'ACTION', name: 'loadAllCategoryViews()', desc: 'Prewarm all tree slugs' },
|
||
{ type: 'ACTION', name: 'reloadLoadedCategoryViews()', desc: 'Refresh cached views' },
|
||
{ type: 'ACTION', name: 'invalidateAllCategoryViews()', desc: 'Clear on SSE signal' },
|
||
{ type: 'SELECT', name: 'collectLoadedCategoryViewData()', desc: 'Extract union data', port: 'p_catview_collect' },
|
||
{ type: 'SELECT', name: 'getOrCreateCategoryAutomat()', desc: 'Per-slug reactive store' },
|
||
{ type: 'API', name: 'GET /api/category/:id/articles', desc: 'Category article batch' },
|
||
],
|
||
},
|
||
{
|
||
id: 'artikelAutomat',
|
||
title: 'artikelAutomat',
|
||
subtitle: 'In-Memory Article Record Cache',
|
||
entries: [
|
||
{ type: 'STATE', name: 'articles: Map<sku, Object>', desc: 'Deduplicated SKU cache', port: 'p_art_set' },
|
||
{ type: 'ACTION', name: 'loadArticle(sku)', desc: 'Fetch or cached promise' },
|
||
{ type: 'ACTION', name: 'reloadArticle(sku)', desc: 'Silent background sync' },
|
||
{ type: 'ACTION', name: 'setArticleName(sku, name)', desc: 'Optimistic rename', port: 'p_art_setName' },
|
||
{ type: 'ACTION', name: 'clearArticle(sku)', desc: 'Evict SKU from cache' },
|
||
{ type: 'SELECT', name: 'getArticle(sku)', desc: 'Sync constructor read' },
|
||
{ type: 'SELECT', name: 'createArticleSelector(sku)', desc: 'Fine-grained selector' },
|
||
{ type: 'API', name: 'GET /api/article/:sku', desc: 'Full article document' },
|
||
],
|
||
},
|
||
{
|
||
id: 'articleDetailAutomat',
|
||
title: 'articleDetailAutomat',
|
||
subtitle: 'KeyedAutomat (SKU)',
|
||
entries: [
|
||
{ type: 'STATE', name: 'articleHistory: Map<sku>', desc: 'Change history log', port: 'p_artdet_hist' },
|
||
{ type: 'STATE', name: 'articleSupplier: Map<sku>', desc: 'Assigned supplier list' },
|
||
{ type: 'ACTION', name: 'articleHistoryAutomat.load()', desc: 'Fetch SKU audit trail' },
|
||
{ type: 'ACTION', name: 'articleSupplierAutomat.load()', desc: 'Fetch supplier catalog' },
|
||
{ type: 'API', name: 'GET /api/article/:sku/history', desc: 'Audit change entries' },
|
||
{ type: 'API', name: 'GET /api/article/:sku/supplier', desc: 'Vendor links & prices' },
|
||
],
|
||
},
|
||
|
||
// 4. Search & Discovery
|
||
{
|
||
id: 'articleIndexAutomat',
|
||
title: 'articleIndexAutomat',
|
||
subtitle: 'Derived Client Search Index',
|
||
entries: [
|
||
{ type: 'STATE', name: 'items: ArticleIndexRecord[]', desc: 'De-duplicated article union', port: 'p_idx_items' },
|
||
{ type: 'STATE', name: 'status: \'ready\'', desc: 'Index lifecycle flag' },
|
||
{ type: 'ACTION', name: 'buildArticleIndex()', desc: 'Derive from category views' },
|
||
{ type: 'ACTION', name: 'setArticleIndexName()', desc: 'Patch on rename' },
|
||
{ type: 'ACTION', name: 'reload()', desc: 'Re-derive from views' },
|
||
{ type: 'SELECT', name: 'selectArticleIndexItems()', desc: 'Fast record projection' },
|
||
{ type: 'SELECT', name: 'ensureLoaded()', desc: 'Await warm views' },
|
||
],
|
||
},
|
||
{
|
||
id: 'articleSearchAutomat',
|
||
title: 'articleSearchAutomat',
|
||
subtitle: 'In-Memory FlexSearch Engine',
|
||
entries: [
|
||
{ type: 'STATE', name: 'target: string', desc: 'Active search query' },
|
||
{ type: 'STATE', name: 'filter: string', desc: 'Active category filter' },
|
||
{ type: 'STATE', name: 'result: ArticleSearchResult', desc: 'Facets, recordset, count' },
|
||
{ type: 'ACTION', name: 'search(target, filter)', desc: 'Synchronous indexed query' },
|
||
{ type: 'SELECT', name: 'selectSearchResult()', desc: 'Reactive search outcome' },
|
||
],
|
||
},
|
||
|
||
// 5. Deliveries & Inbound
|
||
{
|
||
id: 'deliveryAutomat',
|
||
title: 'deliveryAutomat',
|
||
subtitle: 'Chunk-Paginated Deliveries',
|
||
entries: [
|
||
{ type: 'STATE', name: 'items: Delivery[]', desc: 'Company delivery receipts' },
|
||
{ type: 'STATE', name: 'status: \'ready\'', desc: 'List lifecycle state' },
|
||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch & sync' },
|
||
{ type: 'ACTION', name: 'reset()', desc: 'Clear on session switch' },
|
||
{ type: 'API', name: 'GET /api/delivery', desc: 'Inbound delivery log' },
|
||
],
|
||
},
|
||
{
|
||
id: 'deliveryEditorAutomat',
|
||
title: 'deliveryEditorAutomat',
|
||
subtitle: 'Inbound Receipt & OCR Machine',
|
||
entries: [
|
||
{ type: 'STATE', name: 'lines: DeliveryLine[]', desc: 'Uncommitted draft lines', port: 'p_deled_lines' },
|
||
{ type: 'STATE', name: 'deliveryEntity', desc: 'Selected supplier ID', port: 'p_deled_supplier' },
|
||
{ type: 'STATE', name: 'attachments: File[]', desc: 'Invoice PDFs & OCR results' },
|
||
{ type: 'STATE', name: 'draftCount: number', desc: 'Uncommitted lines for badge' },
|
||
{ type: 'ACTION', name: 'addLine() / updateLine()', desc: 'Interactive item editing' },
|
||
{ type: 'ACTION', name: 'commitDelivery()', desc: 'Persist goods receipt', port: 'p_deled_commit' },
|
||
{ type: 'SELECT', name: 'buildSupplierArticlesMap()', desc: 'Auto-match SKU/EAN' },
|
||
{ type: 'API', name: 'POST /api/delivery', desc: 'Create goods receipt' },
|
||
],
|
||
},
|
||
|
||
// 6. Sales & CRM
|
||
{
|
||
id: 'quoteAutomat',
|
||
title: 'quoteAutomat',
|
||
subtitle: 'Chunk-Paginated Quotes',
|
||
entries: [
|
||
{ type: 'STATE', name: 'items: Quote[]', desc: 'Sales quotes / Angebote', port: 'p_quote_items' },
|
||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch' },
|
||
{ type: 'ACTION', name: 'reset()', desc: 'Reset on logout' },
|
||
{ type: 'API', name: 'GET /api/quote', desc: 'Company quote list' },
|
||
],
|
||
},
|
||
{
|
||
id: 'orderAutomat',
|
||
title: 'orderAutomat',
|
||
subtitle: 'Chunk-Paginated Order Engine',
|
||
entries: [
|
||
{ type: 'STATE', name: 'items: Order[]', desc: 'Sales orders & lines', port: 'p_order_items' },
|
||
{ type: 'STATE', name: 'total: number', desc: 'Unfiltered order count' },
|
||
{ type: 'STATE', name: 'onlyUnpaid / onlyUndelivered', desc: 'Fulfillment filters' },
|
||
{ type: 'ACTION', name: 'loadChunk(index)', desc: 'Chunk size 50 prefetch' },
|
||
{ type: 'ACTION', name: 'reload()', desc: 'Silent orders re-fetch' },
|
||
{ type: 'SELECT', name: 'selectOrders() / selectTotal()', desc: 'Filter-applied slice' },
|
||
{ type: 'API', name: 'GET /api/order', desc: 'Paged order stream' },
|
||
{ type: 'SSE', name: 'order / shipment', desc: 'Fulfillment mutations' },
|
||
],
|
||
},
|
||
{
|
||
id: 'entityAutomat',
|
||
title: 'entityAutomat',
|
||
subtitle: 'Customer & Partner Directory',
|
||
entries: [
|
||
{ type: 'STATE', name: 'items: Entity[]', desc: 'Customers & partners' },
|
||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Customer directory load' },
|
||
{ type: 'ACTION', name: 'reset()', desc: 'Reset on session switch' },
|
||
{ type: 'API', name: 'GET /api/entity', desc: 'Directory records' },
|
||
],
|
||
},
|
||
|
||
// 7. AI Assistant & Workspace
|
||
{
|
||
id: 'chatAutomat',
|
||
title: 'chatAutomat',
|
||
subtitle: 'AI Assistant Drawer & SSE',
|
||
entries: [
|
||
{ type: 'STATE', name: 'isOpen: boolean', desc: 'Docked chat drawer toggle' },
|
||
{ type: 'STATE', name: 'conversations: []', desc: 'Conversation sessions' },
|
||
{ type: 'STATE', name: 'messages: ModelMessage[]', desc: 'Live message transcript', port: 'p_chat_msg' },
|
||
{ type: 'STATE', name: 'sseStatus: \'connected\'', desc: 'Token streaming stream', port: 'p_chat_sse' },
|
||
{ type: 'ACTION', name: 'sendMessage(text)', desc: 'Prompt model with tools' },
|
||
{ type: 'ACTION', name: 'answerAskUser()', desc: 'Interactive tool modal' },
|
||
{ type: 'SELECT', name: 'userEnv / routeTools', desc: 'Client navigation tool', port: 'p_chat_tools' },
|
||
{ type: 'API', name: 'POST /api/modelconversation', desc: 'AI conversation session' },
|
||
{ type: 'SSE', name: 'modelconversation/events', desc: 'Live token stream' },
|
||
],
|
||
},
|
||
{
|
||
id: 'vfsAutomat',
|
||
title: 'vfsAutomat',
|
||
subtitle: 'Virtual Filesystem Explorer',
|
||
entries: [
|
||
{ type: 'STATE', name: 'files: VfsFile[]', desc: 'Company VFS workspace' },
|
||
{ type: 'STATE', name: 'selectedFileName', desc: 'Current active file' },
|
||
{ type: 'STATE', name: 'fileContent / isDirty', desc: 'Editor buffer & diff' },
|
||
{ type: 'ACTION', name: 'selectFile() / saveFile()', desc: 'Applet editor buffer' },
|
||
{ type: 'ACTION', name: 'uploadFiles() / deleteFile()', desc: 'File manipulations' },
|
||
{ type: 'API', name: 'GET /api/vfs', desc: 'VFS directory index' },
|
||
{ type: 'SSE', name: 'vfs', desc: 'VFS mutation signal' },
|
||
],
|
||
},
|
||
|
||
// 8. Audit & Virtual Grids
|
||
{
|
||
id: 'articleHistoryGridAutomat',
|
||
title: 'articleHistoryGridAutomat',
|
||
subtitle: 'createGridAutomat (Article)',
|
||
entries: [
|
||
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
|
||
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
|
||
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
|
||
{ type: 'API', name: 'GET /api/article/history', desc: 'Infinite audit window' },
|
||
],
|
||
},
|
||
{
|
||
id: 'globalHistoryGridAutomat',
|
||
title: 'globalHistoryGridAutomat',
|
||
subtitle: 'createGridAutomat (Global)',
|
||
entries: [
|
||
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
|
||
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
|
||
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
|
||
{ type: 'API', name: 'GET /api/globalhistory', desc: 'Company global audit' },
|
||
],
|
||
},
|
||
{
|
||
id: 'ledgerHistoryGridAutomat',
|
||
title: 'ledgerHistoryGridAutomat',
|
||
subtitle: 'createGridAutomat (Ledger)',
|
||
entries: [
|
||
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
|
||
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
|
||
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
|
||
{ type: 'API', name: 'GET /api/ledger/history', desc: 'Append-only ledger audit' },
|
||
],
|
||
},
|
||
|
||
// 9. UI, Navigation & System
|
||
{
|
||
id: 'navigationAutomat',
|
||
title: 'navigationAutomat',
|
||
subtitle: 'Cross-Section Navigation Memory',
|
||
entries: [
|
||
{ type: 'STATE', name: 'lastDeliveryPath', desc: 'Active receipt subpath', port: 'p_nav_delpath' },
|
||
{ type: 'STATE', name: 'lastArticleSku', desc: 'Active SKU selection', port: 'p_nav_sku' },
|
||
{ type: 'STATE', name: 'lastCategorySlug / Path', desc: 'Active category tree node', port: 'p_nav_catslug' },
|
||
{ type: 'STATE', name: 'lastSearchTerm / Path', desc: 'Preserved search query' },
|
||
{ type: 'ACTION', name: 'setLastDeliveryPath()', desc: 'Restore on sidebar click' },
|
||
{ type: 'ACTION', name: 'setLastArticleSku()', desc: 'Restore SKU on section click' },
|
||
{ type: 'ACTION', name: 'clearNavigation()', desc: 'Reset on company switch' },
|
||
],
|
||
},
|
||
{
|
||
id: 'helpAutomat',
|
||
title: 'helpAutomat',
|
||
subtitle: 'IndexedDB Contextual Help',
|
||
entries: [
|
||
{ type: 'STATE', name: 'enabled: boolean', desc: 'Interactive help badges' },
|
||
{ type: 'STATE', name: 'activeHelpId: string|null', desc: 'Active popper ID', port: 'p_help_active' },
|
||
{ type: 'ACTION', name: 'toggle() / setEnabled()', desc: 'Toggle badge mode' },
|
||
{ type: 'ACTION', name: 'openPopper() / closePopper()', desc: 'Single active popper' },
|
||
{ type: 'HOOK', name: 'persist: true', desc: 'Survives page reloads' },
|
||
],
|
||
},
|
||
{
|
||
id: 'pwaAutomat',
|
||
title: 'pwaAutomat',
|
||
subtitle: 'Service Worker & Offline Lifecycle',
|
||
entries: [
|
||
{ type: 'STATE', name: 'needRefresh: boolean', desc: 'New SW release pending' },
|
||
{ type: 'STATE', name: 'offlineReady: boolean', desc: 'Offline assets cached' },
|
||
{ type: 'STATE', name: 'dialogOpen / updating', desc: 'Prompt modal state' },
|
||
{ type: 'ACTION', name: 'applyUpdate()', desc: 'Skip waiting & reload' },
|
||
{ type: 'ACTION', name: 'openDialog() / closeDialog()', desc: 'Modal controls' },
|
||
],
|
||
},
|
||
];
|
||
|
||
/**
|
||
* High-fidelity relationships between Automats:
|
||
* - Lifecycle synchronization (Auth / Session)
|
||
* - Real-time SSE dispatch & invalidation
|
||
* - Hierarchical catalog derivation & indexing
|
||
* - Purchasing & inventory workflows
|
||
* - AI tools & virtual workspace
|
||
*/
|
||
const EDGE_DEFS = [
|
||
// Session & Auth lifecycle
|
||
{ from: 'authAutomat', to: 'sseAutomat', label: 'hasAuth -> connect SSE', port: 'p_auth_hasAuth' },
|
||
{ from: 'authAutomat', to: 'companyAutomat', label: 'hasCompany -> loadAll()', port: 'p_auth_hasCompany' },
|
||
{ from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on hasCompany', port: 'p_auth_hasCompany' },
|
||
{ from: 'authAutomat', to: 'deliveryAutomat', label: 'sync deliveries on authed', port: 'p_auth_status' },
|
||
{ from: 'authAutomat', to: 'quoteAutomat', label: 'sync quotes on authed', port: 'p_auth_status' },
|
||
{ from: 'authAutomat', to: 'orderAutomat', label: 'sync orders on authed', port: 'p_auth_status' },
|
||
{ from: 'authAutomat', to: 'entityAutomat', label: 'sync entities on authed', port: 'p_auth_status' },
|
||
{ from: 'authAutomat', to: 'chatAutomat', label: 'auth chat & reload', port: 'p_auth_status' },
|
||
{ from: 'authAutomat', to: 'vfsAutomat', label: 'load company vfs', port: 'p_auth_status' },
|
||
{ from: 'authAutomat', to: 'articleHistoryGridAutomat', label: 'reload history on authed', port: 'p_auth_status' },
|
||
{ from: 'authAutomat', to: 'globalHistoryGridAutomat', label: 'reload audit on authed', port: 'p_auth_status' },
|
||
{ from: 'authAutomat', to: 'ledgerHistoryGridAutomat', label: 'reload ledger on authed', port: 'p_auth_status' },
|
||
|
||
// Real-time SSE dispatch
|
||
{ from: 'sseAutomat', to: 'categoryAutomat', label: '\'category\' sse trigger', port: 'p_sse_events' },
|
||
{ from: 'sseAutomat', to: 'categoryViewAutomat', label: 'invalidate views on sse', port: 'p_sse_events' },
|
||
{ from: 'sseAutomat', to: 'articleIndexAutomat', label: '\'articles\' sse trigger', port: 'p_sse_events' },
|
||
{ from: 'sseAutomat', to: 'artikelAutomat', label: '\'article-<sku>\' sse reload', port: 'p_sse_events' },
|
||
{ from: 'sseAutomat', to: 'articleDetailAutomat', label: 'invalidateArticle(sku)', port: 'p_sse_events' },
|
||
{ from: 'sseAutomat', to: 'companyAutomat', label: 'warehouse/supplier/brand/attr/prop/carrier sse', port: 'p_sse_events' },
|
||
{ from: 'sseAutomat', to: 'orderAutomat', label: '\'order\'/\'shipment\' sse', port: 'p_sse_events' },
|
||
{ from: 'sseAutomat', to: 'vfsAutomat', label: '\'vfs\' sse mutation', port: 'p_sse_events' },
|
||
|
||
// Catalog, Indexing & Search pipeline
|
||
{ from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId & terminals', port: 'p_comp_root' },
|
||
{ from: 'categoryAutomat', to: 'categoryViewAutomat', label: 'attachTree & warm slugs', port: 'p_cat_loadTree' },
|
||
{ from: 'categoryViewAutomat', to: 'articleIndexAutomat', label: 'derived union index', port: 'p_catview_collect' },
|
||
{ from: 'articleIndexAutomat', to: 'articleSearchAutomat', label: 'query index items', port: 'p_idx_items' },
|
||
{ from: 'categoryAutomat', to: 'articleSearchAutomat', label: 'categoryFacets', port: 'p_cat_categories' },
|
||
{ from: 'artikelAutomat', to: 'articleIndexAutomat', label: 'setArticleIndexName (optimistic)', port: 'p_art_setName' },
|
||
{ from: 'artikelAutomat', to: 'categoryAutomat', label: 'articleCount updates', port: 'p_art_set' },
|
||
{ from: 'articleDetailAutomat', to: 'artikelAutomat', label: 'watch SKU details', port: 'p_artdet_hist' },
|
||
|
||
// Purchasing & Deliveries
|
||
{ from: 'deliveryEditorAutomat', to: 'companyAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' },
|
||
{ from: 'deliveryEditorAutomat', to: 'artikelAutomat', label: 'lookup SKU & pack units', port: 'p_deled_lines' },
|
||
{ from: 'deliveryEditorAutomat', to: 'deliveryAutomat', label: 'commit delivery receipt', port: 'p_deled_commit' },
|
||
|
||
// Sales & CRM
|
||
{ from: 'quoteAutomat', to: 'orderAutomat', label: 'convert quote to order', port: 'p_quote_items' },
|
||
{ from: 'orderAutomat', to: 'entityAutomat', label: 'customer lookup', port: 'p_order_items' },
|
||
|
||
// AI Assistant & Workspace
|
||
{ from: 'chatAutomat', to: 'vfsAutomat', label: 'inspect / edit applets', port: 'p_chat_msg' },
|
||
{ from: 'chatAutomat', to: 'navigationAutomat', label: 'set_user_route tool', port: 'p_chat_tools' },
|
||
{ from: 'chatAutomat', to: 'sseAutomat', label: 'getSseClientId()', port: 'p_chat_sse' },
|
||
|
||
// UI Navigation & Preferences
|
||
{ from: 'navigationAutomat', to: 'artikelAutomat', label: 'restore last SKU', port: 'p_nav_sku' },
|
||
{ from: 'navigationAutomat', to: 'categoryAutomat', label: 'restore last category', port: 'p_nav_catslug' },
|
||
{ from: 'navigationAutomat', to: 'deliveryEditorAutomat', label: 'restore draft path', port: 'p_nav_delpath' },
|
||
{ from: 'companyAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_comp_usr' },
|
||
{ from: 'helpAutomat', to: 'navigationAutomat', label: 'route-contextual help', port: 'p_help_active' },
|
||
|
||
// Self-loops
|
||
{ from: 'categoryAutomat', to: 'categoryAutomat', label: 'nested tree hierarchy', port: 'p_cat_categories' },
|
||
{ from: 'artikelAutomat', to: 'artikelAutomat', label: 'variants & parent SKU', port: 'p_art_set' },
|
||
{ from: 'chatAutomat', to: 'chatAutomat', label: 'stream delta appends', port: 'p_chat_msg' },
|
||
];
|
||
|
||
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 automat
|
||
* 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 badgeRow = (type, desc) => {
|
||
const c = BADGE_COLORS[type];
|
||
return (
|
||
`<TR><TD BGCOLOR="${c.bg}" ALIGN="CENTER" CELLPADDING="2" WIDTH="48">` +
|
||
`<FONT COLOR="${c.fg}" POINT-SIZE="9"><B>${type}</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>Automat Graph Legend</B></FONT></TD></TR>` +
|
||
badgeRow('STATE', 'Reactive store state property') +
|
||
badgeRow('ACTION', 'State mutation action') +
|
||
badgeRow('SELECT', 'Reactive PureComponent selector') +
|
||
badgeRow('API', 'Backend REST/Fetch endpoint') +
|
||
badgeRow('SSE', 'Inbound Server-Sent Event trigger') +
|
||
badgeRow('HOOK', 'Auth / persistence lifecycle hook') +
|
||
row(
|
||
`<FONT COLOR="${COLORS.headerText}"><B>in#</B></FONT>`,
|
||
'Inbound arrival port on Automat header'
|
||
) +
|
||
row(
|
||
`<FONT COLOR="#5aa7ff">▪—▶</FONT>`,
|
||
'Reactive dataflow / upstream trigger'
|
||
) +
|
||
`</TABLE>>`
|
||
);
|
||
}
|
||
|
||
function renderAutomatNode(spec, inboundCount = 0) {
|
||
if (spec.id === 'Legend') {
|
||
return buildLegendLabel();
|
||
}
|
||
|
||
const titleFont =
|
||
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(spec.title)}</B></FONT>` +
|
||
`<BR/><FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(spec.subtitle)}</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, entry] of spec.entries.entries()) {
|
||
const bColor = BADGE_COLORS[entry.type] ?? { fg: COLORS.text, bg: COLORS.rowBg };
|
||
const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
|
||
const portAttr = entry.port ? ` PORT="${escapeHtml(entry.port)}"` : '';
|
||
|
||
rows.push(
|
||
`<TR><TD${portAttr} COLSPAN="3" BGCOLOR="${rowBg}" BORDER="1" CELLPADDING="0">` +
|
||
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||
`<TR>` +
|
||
`<TD BGCOLOR="${bColor.bg}" ALIGN="CENTER" CELLPADDING="3" WIDTH="48">` +
|
||
`<FONT COLOR="${bColor.fg}" POINT-SIZE="8"><B>${escapeHtml(entry.type)}</B></FONT></TD>` +
|
||
`<TD ALIGN="LEFT" CELLPADDING="4">` +
|
||
`<FONT COLOR="${COLORS.text}" POINT-SIZE="10"><B>${escapeHtml(entry.name)}</B></FONT></TD>` +
|
||
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
|
||
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="8">${escapeHtml(truncate(entry.desc, 30))}</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(specsMap, edges, positions, anchors) {
|
||
const edgeColors = assignEdgeColors(edges);
|
||
const rankingKeys = selectRankingEdgeKeys(edges);
|
||
const { headPort, tailCompass, inboundCounts } =
|
||
anchors ?? assignEdgeAnchors(edges, positions);
|
||
|
||
const lines = [
|
||
'digraph automats {',
|
||
' 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 clusteredAutomats = new Set();
|
||
|
||
for (const cluster of CLUSTERS) {
|
||
const presentInCluster = cluster.automats.filter((id) => specsMap.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 autoId of presentInCluster) {
|
||
clusteredAutomats.add(autoId);
|
||
const spec = specsMap.get(autoId);
|
||
const inCount = inboundCounts.get(autoId) ?? 0;
|
||
const label = renderAutomatNode(spec, inCount);
|
||
lines.push(` ${autoId} [label=${label}];`);
|
||
}
|
||
lines.push(' }');
|
||
}
|
||
|
||
// Any unclustered automats
|
||
for (const [autoId, spec] of specsMap) {
|
||
if (clusteredAutomats.has(autoId)) continue;
|
||
const inCount = inboundCounts.get(autoId) ?? 0;
|
||
const label = renderAutomatNode(spec, inCount);
|
||
lines.push(` ${autoId} [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.from} → ${edge.to} (${edge.label})`)}"`,
|
||
`label=" ${escapeHtml(edge.label)} "`,
|
||
];
|
||
|
||
if (!isRanking && edge.from !== edge.to) {
|
||
attrs.push('constraint=false', 'weight=1');
|
||
} else {
|
||
attrs.push('weight=3');
|
||
}
|
||
|
||
// Source port from row entry 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+)?->\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}->${_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 Dashfront Automat Graph…');
|
||
|
||
const specsMap = new Map();
|
||
for (const spec of AUTOMAT_SPECS) {
|
||
specsMap.set(spec.id, spec);
|
||
}
|
||
|
||
// Add Legend pseudo-automat
|
||
specsMap.set('Legend', {
|
||
id: 'Legend',
|
||
title: 'Legend',
|
||
subtitle: '',
|
||
entries: [],
|
||
});
|
||
|
||
const edges = EDGE_DEFS.filter((def) => specsMap.has(def.from) && specsMap.has(def.to));
|
||
|
||
console.log(`Structured ${specsMap.size - 1} Automats across ${CLUSTERS.length} domain clusters with ${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(specsMap, 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(specsMap, 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`);
|
||
}
|