diff --git a/docs/public/js/blueprint-graph.js b/docs/public/js/blueprint-graph.js new file mode 100644 index 00000000..a349d3ae --- /dev/null +++ b/docs/public/js/blueprint-graph.js @@ -0,0 +1,462 @@ +/** + * Blueprint Graph Renderer + * Custom layout algorithm designed specifically for blueprint-style graphs + */ +(function() { + const PIN_COLORS = { + exec: '#ffffff', + entity: '#00a0e0', + component: '#7030c0', + float: '#7ecd32', + int: '#1cc4c4', + bool: '#8c0000', + string: '#e060e0', + any: '#707070' + }; + + const HEADER_CLASSES = { + event: 'event', + function: 'function', + pure: 'pure', + flow: 'flow', + math: 'math', + time: 'time', + debug: 'debug', + variable: 'variable' + }; + + const H_GAP = 50; // Horizontal gap between columns + const V_GAP = 25; // Vertical gap between nodes + const START_X = 20; + const START_Y = 20; + + function estimateNodeSize(node) { + const headerHeight = 28; // Match CSS: min-height 28px + const pinRowHeight = 22; // Match CSS: pin row ~22px + const bodyPadding = 12; // Top + bottom padding + + // Count all pins in body (each pin is its own row now) + const inputExecCount = node.inputs ? node.inputs.filter(p => p.type === 'exec').length : 0; + const inputDataCount = node.inputs ? node.inputs.filter(p => p.type !== 'exec').length : 0; + const outputExecCount = node.outputs ? node.outputs.filter(p => p.type === 'exec' && !p.inHeader).length : 0; + const outputDataCount = node.outputs ? node.outputs.filter(p => p.type !== 'exec' && !p.inHeader).length : 0; + const totalPins = inputExecCount + inputDataCount + outputExecCount + outputDataCount; + + // Calculate height: header + body padding + all pin rows + const bodyHeight = totalPins > 0 ? bodyPadding + (totalPins * pinRowHeight) : 0; + const height = headerHeight + bodyHeight; + + // Calculate width based on content + let maxLabelLen = node.title.length; + if (node.inputs) { + node.inputs.forEach(p => { + const len = (p.label || '').length + (p.value ? String(p.value).length + 3 : 0); + maxLabelLen = Math.max(maxLabelLen, len); + }); + } + if (node.outputs) { + node.outputs.forEach(p => { + maxLabelLen = Math.max(maxLabelLen, (p.label || '').length); + }); + } + + const width = Math.max(110, Math.min(170, maxLabelLen * 8 + 40)); + return { width, height }; + } + + /** + * Smart blueprint layout algorithm + * + * Uses weighted graph analysis: + * - All connections matter (exec has higher weight) + * - Topological sort for X ordering + * - Force-directed optimization for Y positions + */ + function autoLayout(graphData, maxWidth) { + const nodes = graphData.nodes; + const connections = graphData.connections; + + if (nodes.length === 0) return { positions: {}, sizes: {} }; + + // Calculate node sizes + const nodeSizes = {}; + nodes.forEach(n => { nodeSizes[n.id] = estimateNodeSize(n); }); + + // Build maps + const pinToNode = {}; + const nodeById = {}; + nodes.forEach(n => { + nodeById[n.id] = n; + (n.inputs || []).forEach(p => { pinToNode[p.id] = n.id; }); + (n.outputs || []).forEach(p => { pinToNode[p.id] = n.id; }); + }); + + // Build weighted adjacency: outgoing[nodeId] = [{to, weight}] + const outgoing = {}; + const incoming = {}; + nodes.forEach(n => { outgoing[n.id] = []; incoming[n.id] = []; }); + + connections.forEach(c => { + const from = pinToNode[c.from]; + const to = pinToNode[c.to]; + if (!from || !to || from === to) return; + + const weight = c.type === 'exec' ? 3 : 1; + outgoing[from].push({ to, weight }); + incoming[to].push({ from, weight }); + }); + + // Calculate node "depth" using weighted longest path + const nodeDepth = {}; + const visited = new Set(); + const inProcess = new Set(); + + function calcDepth(nodeId) { + if (visited.has(nodeId)) return nodeDepth[nodeId]; + if (inProcess.has(nodeId)) return 0; // Cycle detected + + inProcess.add(nodeId); + + let maxPrevDepth = -1; + incoming[nodeId].forEach(({ from, weight }) => { + const prevDepth = calcDepth(from); + maxPrevDepth = Math.max(maxPrevDepth, prevDepth); + }); + + inProcess.delete(nodeId); + visited.add(nodeId); + nodeDepth[nodeId] = maxPrevDepth + 1; + return nodeDepth[nodeId]; + } + + // Calculate depth for all nodes + nodes.forEach(n => calcDepth(n.id)); + + // Group nodes by depth (column) + const columnNodes = {}; + nodes.forEach(n => { + const depth = nodeDepth[n.id]; + if (!columnNodes[depth]) columnNodes[depth] = []; + columnNodes[depth].push(n.id); + }); + + // Sort columns + const sortedColumns = Object.keys(columnNodes).map(Number).sort((a, b) => a - b); + + // Calculate X positions + const columnX = {}; + let currentX = START_X; + sortedColumns.forEach(col => { + columnX[col] = currentX; + let maxW = 0; + columnNodes[col].forEach(id => { + maxW = Math.max(maxW, nodeSizes[id].width); + }); + currentX += maxW + H_GAP; + }); + + // Initialize Y positions - simple stacking first + const positions = {}; + sortedColumns.forEach(col => { + let y = START_Y; + columnNodes[col].forEach(id => { + positions[id] = { x: columnX[col], y }; + y += nodeSizes[id].height + V_GAP; + }); + }); + + // Force-directed optimization for Y positions (few iterations) + for (let iter = 0; iter < 5; iter++) { + const forces = {}; + nodes.forEach(n => { forces[n.id] = 0; }); + + // Calculate forces from connections + connections.forEach(c => { + const from = pinToNode[c.from]; + const to = pinToNode[c.to]; + if (!from || !to || from === to) return; + + const weight = c.type === 'exec' ? 2 : 1; + const fromY = positions[from].y + nodeSizes[from].height / 2; + const toY = positions[to].y + nodeSizes[to].height / 2; + const diff = toY - fromY; + + // Pull nodes toward each other + forces[from] += diff * 0.1 * weight; + forces[to] -= diff * 0.1 * weight; + }); + + // Apply forces + nodes.forEach(n => { + positions[n.id].y += forces[n.id]; + positions[n.id].y = Math.max(START_Y, positions[n.id].y); + }); + + // Resolve overlaps within columns + sortedColumns.forEach(col => { + const nodesInCol = columnNodes[col]; + nodesInCol.sort((a, b) => positions[a].y - positions[b].y); + + for (let i = 1; i < nodesInCol.length; i++) { + const prevId = nodesInCol[i - 1]; + const currId = nodesInCol[i]; + const minY = positions[prevId].y + nodeSizes[prevId].height + V_GAP; + if (positions[currId].y < minY) { + positions[currId].y = minY; + } + } + }); + } + + return { positions, sizes: nodeSizes }; + } + + function renderPinSvg(type, filled = true) { + const color = PIN_COLORS[type] || PIN_COLORS.any; + if (type === 'exec') { + return ``; + } + return ``; + } + + function renderNode(node, position, size) { + const isEvent = node.category === 'event'; + const headerClass = HEADER_CLASSES[node.category] || 'function'; + + let html = `