Files
dashfront/generate-automat-graph.js
2026-10-29 17:32:39 +01:00

885 lines
37 KiB
JavaScript
Raw Permalink 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';
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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">&#9642;&#8212;&#9654;</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+)?&#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 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`);
}