594 lines
19 KiB
JavaScript
594 lines
19 KiB
JavaScript
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`
|
|
<svg
|
|
class="connections"
|
|
style="${style}"
|
|
viewBox="${this.canvas ? `${bounds.minX} ${bounds.minY} ${bounds.width} ${bounds.height}` : '0 0 100 100'}"
|
|
>
|
|
${this.connections.map(conn => this._renderConnection(conn))}
|
|
${this.draggingConnection ? this._renderPreviewConnection() : null}
|
|
</svg>
|
|
`;
|
|
}
|
|
|
|
_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`
|
|
<path
|
|
d="${path}"
|
|
fill="none"
|
|
stroke="#666"
|
|
stroke-width="2"
|
|
stroke-dasharray="5,5"
|
|
id="preview-connection"
|
|
/>
|
|
`;
|
|
}
|
|
|
|
_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`
|
|
<g>
|
|
<path
|
|
d="${path}"
|
|
fill="none"
|
|
stroke="#666"
|
|
stroke-width="2"
|
|
id="${conn.id}"
|
|
class="${isSelected ? 'selected' : ''}"
|
|
@click="${() => this._handleConnectionClick(conn.id)}"
|
|
/>
|
|
${isSelected ? svg`
|
|
<g>
|
|
<!-- Source delete button -->
|
|
<g class="delete-button-group" @click="${() => this._deleteConnection(conn.id)}">
|
|
<circle
|
|
class="delete-button-circle"
|
|
cx="${startButton.x}"
|
|
cy="${startButton.y}"
|
|
r="8"
|
|
/>
|
|
<path
|
|
class="delete-button-x"
|
|
d="M ${startButton.x-4},${startButton.y-4} L ${startButton.x+4},${startButton.y+4} M ${startButton.x-4},${startButton.y+4} L ${startButton.x+4},${startButton.y-4}"
|
|
/>
|
|
</g>
|
|
<!-- Target delete button -->
|
|
<g class="delete-button-group" @click="${() => this._deleteConnection(conn.id)}">
|
|
<circle
|
|
class="delete-button-circle"
|
|
cx="${endButton.x}"
|
|
cy="${endButton.y}"
|
|
r="8"
|
|
/>
|
|
<path
|
|
class="delete-button-x"
|
|
d="M ${endButton.x-4},${endButton.y-4} L ${endButton.x+4},${endButton.y+4} M ${endButton.x-4},${endButton.y+4} L ${endButton.x+4},${endButton.y-4}"
|
|
/>
|
|
</g>
|
|
</g>
|
|
` : ''}
|
|
</g>
|
|
`;
|
|
}
|
|
|
|
_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 = `
|
|
<filter id="particleGlow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
|
<feMerge>
|
|
<feMergeNode in="coloredBlur"/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
`;
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
} |