import { html, css, svg } from 'lit'; export const connectionStyles = css` .connections { pointer-events: none; } .connections path { transition: stroke 0.3s ease, stroke-width 0.3s ease; pointer-events: all; cursor: pointer; stroke: var(--connection-stroke-color, #999); stroke-width: var(--connection-stroke-width, 3); } .connections path:hover { stroke: var(--connection-stroke-color-hover, #999); stroke-width: var(--connection-stroke-width-hover, 3); } .connections path.selected { stroke: var(--connection-stroke-color-selected, #3498db); stroke-width: var(--connection-stroke-width-selected, 3); } .delete-button-group { pointer-events: all; cursor: pointer; } .delete-button-group:hover .delete-button-circle { fill: #c0392b; } .delete-button-circle { fill: #e74c3c; transition: fill 0.2s ease; } .delete-button-x { stroke: white; stroke-width: 2; } .delete-button-group { pointer-events: all; cursor: pointer; /* Ensure buttons are always on top of nodes */ z-index: 1000; } .data-flow-particle { fill: var(--data-flow-particle-color, #00c3ff); filter: url(#particleGlow); } @keyframes particlePulse { 0% { opacity: 0.7; } 50% { opacity: 1; } 100% { opacity: 0.7; } } .data-flow-particle { animation: particlePulse 0.5s ease-in-out infinite; } `; export const connections = function(superClass) { return class extends superClass { constructor() { super(); this.connections = []; this.draggingConnection = null; this.mousePosition = { x: 0, y: 0 }; this._updatePreviewConnection = this._updatePreviewConnection.bind(this) this._conDocClick = this._conDocClick.bind(this); // Particle animation this.minParticleDelay = 100; this.maxQueueLength = 5; // Maximum particles per connection queue this.particleAnimationDuration = 500; this._particleQueue = new Map(); // Queue for each connection this._particleTimers = new Map(); // Last particle time for each connection this._activeParticles = new Set(); this._filterCreated = false; } firstUpdated() { super.firstUpdated(); this.addEventListener('port-click', this._handlePortClick); document.addEventListener('mousemove', this._updatePreviewConnection); document.addEventListener('click', this._conDocClick); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('port-click', this._handlePortClick); document.removeEventListener('mousemove', this._updatePreviewConnection); document.removeEventListener('click', this._conDocClick); this._activeParticles.forEach(particle => particle.remove()); this._activeParticles.clear(); } _conDocClick(e) { // Check if clicking on an output port const path = e.composedPath(); const isOutputPort = path.some(el => el instanceof HTMLElement && el.classList && el.classList.contains('output-ports') ); // Only cancel connection if we're dragging AND it's not an output port if (this.draggingConnection && !isOutputPort) { // Check if we clicked on an input port const isInputPort = path.some(el => el instanceof HTMLElement && el.classList && el.classList.contains('input-ports') ); // If not an input port, cancel the connection if (!isInputPort) { this.draggingConnection = null; this.requestUpdate(); } } // Handle existing connection deselection if (e.target === this.canvas) { this.selectedConnection = null; this.requestUpdate(); } } _updatePreviewConnection(e) { if (this.draggingConnection) { this.mousePosition = { x: e.clientX, y: e.clientY }; this.requestUpdate(); } } _renderConnections() { const bounds = this._calculateSVGBounds(); // Handle initial render when canvas is not ready const style = this.canvas ? ` position: absolute; top: ${bounds.minY}px; left: ${bounds.minX}px; width: ${bounds.width}px; height: ${bounds.height}px; pointer-events: none; z-index: -1; ` : ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: -1; `; return html` ${this.connections.map(conn => this._renderConnection(conn))} ${this.draggingConnection ? this._renderPreviewConnection() : null} `; } _renderPreviewConnection() { const sourceNode = this.nodes.find(node => node.id === this.draggingConnection.sourceNodeId); if (!sourceNode) return ''; const sourcePort = sourceNode.shadowRoot.querySelector( `.output-ports [portid="${this.draggingConnection.sourcePortId}"]` ); if (!sourcePort) return ''; const sourceRect = sourcePort.getBoundingClientRect(); const canvasRect = this.canvas.getBoundingClientRect(); const start = { x: (sourceRect.left + sourceRect.width/2 - canvasRect.left) / this.scale, y: (sourceRect.top + sourceRect.height/2 - canvasRect.top) / this.scale }; const end = { x: (this.mousePosition.x - canvasRect.left) / this.scale, y: (this.mousePosition.y - canvasRect.top) / this.scale }; const controlPoint1 = { x: start.x + Math.min(100, Math.abs(end.x - start.x) / 2), y: start.y }; const controlPoint2 = { x: end.x - Math.min(100, Math.abs(end.x - start.x) / 2), y: end.y }; const path = `M${start.x},${start.y} C${controlPoint1.x},${controlPoint1.y} ${controlPoint2.x},${controlPoint2.y} ${end.x},${end.y}`; return svg` `; } _renderConnection(conn) { const sourceNode = this.nodes.find(node => node.id === conn.sourceNodeId); const targetNode = this.nodes.find(node => node.id === conn.targetNodeId); if (!sourceNode?.shadowRoot || !targetNode?.shadowRoot) return ''; const sourcePort = sourceNode.shadowRoot.querySelector(`.output-ports [portid="${conn.sourcePortId}"]`); const targetPort = targetNode.shadowRoot.querySelector(`.input-ports [portid="${conn.targetPortId}"]`); if (!sourcePort || !targetPort) return ''; const sourceRect = sourcePort.getBoundingClientRect(); const targetRect = targetPort.getBoundingClientRect(); const canvasRect = this.canvas.getBoundingClientRect(); const start = { x: (sourceRect.left + sourceRect.width/2 - canvasRect.left) / this.scale, y: (sourceRect.top + sourceRect.height/2 - canvasRect.top) / this.scale }; const end = { x: (targetRect.left + targetRect.width/2 - canvasRect.left) / this.scale, y: (targetRect.top + targetRect.height/2 - canvasRect.top) / this.scale }; const controlPoint1 = { x: start.x + Math.min(100, Math.abs(end.x - start.x) / 2), y: start.y }; const controlPoint2 = { x: end.x - Math.min(100, Math.abs(end.x - start.x) / 2), y: end.y }; // Helper function to get point on cubic bezier curve at t (0-1) const getPointOnCurve = (t) => { const t1 = 1 - t; return { x: Math.pow(t1, 3) * start.x + 3 * Math.pow(t1, 2) * t * controlPoint1.x + 3 * t1 * Math.pow(t, 2) * controlPoint2.x + Math.pow(t, 3) * end.x, y: Math.pow(t1, 3) * start.y + 3 * Math.pow(t1, 2) * t * controlPoint1.y + 3 * t1 * Math.pow(t, 2) * controlPoint2.y + Math.pow(t, 3) * end.y }; }; // Get points at 15% and 85% along the curve const startButton = getPointOnCurve(0.15); const endButton = getPointOnCurve(0.85); const path = `M${start.x},${start.y} C${controlPoint1.x},${controlPoint1.y} ${controlPoint2.x},${controlPoint2.y} ${end.x},${end.y}`; const isSelected = this.selectedConnection === conn.id; return svg` ${isSelected ? svg` ` : ''} `; } _handleConnectionClick(connectionId) { // Deselect if clicking the same connection if (this.selectedConnection === connectionId) { this.selectedConnection = null; } else { this.selectedConnection = connectionId; } this.requestUpdate(); } _deleteConnection(connectionId) { const connection = this.connections.find(conn => conn.id === connectionId); this.connections = this.connections.filter(conn => conn.id !== connectionId); this.selectedConnection = null; if (connection) { this._dispatchChangeEvent({ type: 'connection-removed', data: { connectionId } }); } this.requestUpdate(); } _handlePortClick(e) { const { node, port } = e.detail; const nodeId = node.id; const { portType, portId, portName } = port; if (!this.draggingConnection) { if (portType === 'output') { this.draggingConnection = { sourceNodeId: nodeId, sourcePortId: portId, sourcePortName: portName, sourcePortType: portType, sourceNode: node }; } } else { if (portType === 'input' && nodeId !== this.draggingConnection.sourceNodeId) { const targetNode = node; const sourceNode = this.draggingConnection.sourceNode; // Validate the connection const isValid = targetNode.validateConnection(sourceNode, this.draggingConnection.sourcePortId, portId); if (!isValid) { return; } const connectionExists = this.connections.some(conn => conn.sourceNodeId === this.draggingConnection.sourceNodeId && conn.sourcePortId === this.draggingConnection.sourcePortId && conn.targetNodeId === nodeId && conn.targetPortId === portId ); if (!connectionExists) { const connection = { id: `conn_${Date.now()}`, sourceNodeId: this.draggingConnection.sourceNodeId, sourcePortId: this.draggingConnection.sourcePortId, sourcePortName: this.draggingConnection.sourcePortName, targetNodeId: nodeId, targetPortId: portId, targetPortName: portName }; this.connections = [...this.connections, connection]; this._dispatchChangeEvent({ type: 'connection-added', data: connection }); } } this.draggingConnection = null; this.requestUpdate(); } } _calculateSVGBounds() { // If canvas is not ready yet, return default bounds if (!this.canvas) { return { minX: 0, minY: 0, width: '100%', height: '100%' }; } let minX = 0, minY = 0, maxX = 0, maxY = 0; const canvasRect = this.canvas.getBoundingClientRect(); // Include all node positions this.nodes.forEach(node => { const rect = node.getBoundingClientRect(); minX = Math.min(minX, (rect.left - canvasRect.left) / this.scale); minY = Math.min(minY, (rect.top - canvasRect.top) / this.scale); maxX = Math.max(maxX, (rect.right - canvasRect.left) / this.scale); maxY = Math.max(maxY, (rect.bottom - canvasRect.top) / this.scale); }); const padding = 1000; return { minX: minX - padding, minY: minY - padding, width: maxX - minX + (padding * 2), height: maxY - minY + (padding * 2) }; } async animateDataFlow(sourceNodeId, targetNodeId, sourcePortName, targetPortName) { const connection = this.connections.find(conn => conn.sourceNodeId === sourceNodeId && conn.targetNodeId === targetNodeId && conn.sourcePortName === sourcePortName && conn.targetPortName === targetPortName ); if (!connection) return; const pathElement = this.shadowRoot.querySelector(`path#${connection.id}`); if (!pathElement) return; // Create shared filter if it doesn't exist if (!this._filterCreated) { const svgElement = this.shadowRoot.querySelector('svg.connections'); const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); defs.innerHTML = ` `; svgElement.insertBefore(defs, svgElement.firstChild); this._filterCreated = true; } // Create particle group const animationGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); const particle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); particle.setAttribute("r", "4"); particle.classList.add("data-flow-particle"); animationGroup.appendChild(particle); pathElement.parentNode.appendChild(animationGroup); // Track this particle this._activeParticles.add(animationGroup); // Get the total length of the path const pathLength = pathElement.getTotalLength(); return new Promise((resolve) => { let startTime = null; const duration = this.particleAnimationDuration; const animate = (timestamp) => { if (!startTime) startTime = timestamp; const progress = (timestamp - startTime) / duration; if (progress <= 1) { // Get point at current position along the path const point = pathElement.getPointAtLength(progress * pathLength); // Update particle position particle.setAttribute("cx", point.x); particle.setAttribute("cy", point.y); requestAnimationFrame(animate); } else { // Animation complete this._activeParticles.delete(animationGroup); animationGroup.remove(); resolve(); } }; requestAnimationFrame(animate); }); } // Add method to animate multiple particles async animateDataFlowBurst(sourceNodeId, targetNodeId, sourcePortName, targetPortName, count = 3, delay = 200) { const animations = []; for (let i = 0; i < count; i++) { animations.push( new Promise(resolve => { setTimeout(() => { this.animateDataFlow(sourceNodeId, targetNodeId, sourcePortName, targetPortName) .then(resolve); }, i * delay); }) ); } return Promise.all(animations); } // Method to get a unique connection identifier _getConnectionKey(sourceNodeId, targetNodeId, sourcePortName, targetPortName) { return `${sourceNodeId}-${sourcePortName}-${targetNodeId}-${targetPortName}`; } async queueParticle(sourceNodeId, targetNodeId, sourcePortName, targetPortName) { const connectionKey = this._getConnectionKey(sourceNodeId, targetNodeId, sourcePortName, targetPortName); // Create queue for this connection if it doesn't exist if (!this._particleQueue.has(connectionKey)) { this._particleQueue.set(connectionKey, []); } const queue = this._particleQueue.get(connectionKey); if (queue.length >= this.maxQueueLength) { // Ignore new particles when queue is full return Promise.resolve(); } // Add to queue and process return new Promise(resolve => { queue.push(resolve); this._processParticleQueue(connectionKey, sourceNodeId, targetNodeId, sourcePortName, targetPortName); }); } async _processParticleQueue(connectionKey, sourceNodeId, targetNodeId, sourcePortName, targetPortName) { const queue = this._particleQueue.get(connectionKey); if (!queue || queue.length === 0) return; const now = Date.now(); const lastTime = this._particleTimers.get(connectionKey) || 0; const timeSinceLastParticle = now - lastTime; if (timeSinceLastParticle >= this.minParticleDelay) { // Process next particle const resolve = queue.shift(); this._particleTimers.set(connectionKey, now); const result = await this.animateDataFlow(sourceNodeId, targetNodeId, sourcePortName, targetPortName); resolve(result); // Process next in queue if any if (queue.length > 0) { setTimeout(() => { this._processParticleQueue(connectionKey, sourceNodeId, targetNodeId, sourcePortName, targetPortName); }, this.minParticleDelay); } } else { // Wait for minimum delay setTimeout(() => { this._processParticleQueue(connectionKey, sourceNodeId, targetNodeId, sourcePortName, targetPortName); }, this.minParticleDelay - timeSinceLastParticle); } } }; }