class ObjectFlowDesigner { constructor() { this.canvas = document.getElementById("canvas"); this.ctx = this.canvas.getContext("2d"); this.objects = []; this.connections = []; this.selectedObject = null; this.selectedConnection = null; this.mode = "select"; // 'select' or 'connect' this.connectionStart = null; this.dragOffset = { x: 0, y: 0 }; this.isDragging = false; this.nextId = 1; this.setupEventListeners(); this.resizeCanvas(); this.render(); } setupEventListeners() { // Canvas events this.canvas.addEventListener("mousedown", this.handleMouseDown.bind(this)); this.canvas.addEventListener("mousemove", this.handleMouseMove.bind(this)); this.canvas.addEventListener("mouseup", this.handleMouseUp.bind(this)); this.canvas.addEventListener("click", this.handleClick.bind(this)); // Palette events document.querySelectorAll(".palette-item").forEach((item) => { item.addEventListener("click", this.handlePaletteClick.bind(this)); }); // Control buttons document .getElementById("connectionBtn") .addEventListener("click", () => this.setMode("connect")); document .getElementById("selectBtn") .addEventListener("click", () => this.setMode("select")); document .getElementById("clearBtn") .addEventListener("click", this.clearAll.bind(this)); document .getElementById("exportBtn") .addEventListener("click", this.exportData.bind(this)); document.getElementById("importBtn").addEventListener("click", () => { document.getElementById("fileInput").click(); }); document .getElementById("fileInput") .addEventListener("change", this.importData.bind(this)); // Window resize window.addEventListener("resize", this.resizeCanvas.bind(this)); } resizeCanvas() { const rect = this.canvas.getBoundingClientRect(); this.canvas.width = rect.width; this.canvas.height = rect.height; this.render(); } setMode(mode) { this.mode = mode; this.connectionStart = null; this.selectedObject = null; this.selectedConnection = null; this.updatePropertiesPanel(); document.body.className = `mode-${mode}`; // Update button states document .getElementById("connectionBtn") .classList.toggle("btn-primary", mode === "connect"); document .getElementById("connectionBtn") .classList.toggle("btn-outline", mode !== "connect"); document .getElementById("selectBtn") .classList.toggle("btn-primary", mode === "select"); document .getElementById("selectBtn") .classList.toggle("btn-outline", mode !== "select"); this.render(); } handlePaletteClick(event) { const type = event.currentTarget.dataset.type; // Check if triangle already exists if ( type === "triangle" && this.objects.some((obj) => obj.type === "triangle") ) { this.showError("Only one triangle is allowed!"); return; } this.createObject(type, 100, 100); } createObject(type, x, y) { const id = this.nextId++; const obj = { id, type, x, y, width: type === "triangle" ? 40 : 50, height: type === "triangle" ? 40 : 50, data: { name: `${type.charAt(0).toUpperCase() + type.slice(1)} ${id}`, description: "", value: "", metadata: {}, }, connections: { inputs: [], outputs: [], }, }; this.objects.push(obj); this.render(); // Auto-select the new object this.selectedObject = obj; this.updatePropertiesPanel(); } handleMouseDown(event) { const rect = this.canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const clickedObject = this.getObjectAt(x, y); const clickedConnection = this.getConnectionAt(x, y); if (this.mode === "select") { if (clickedObject) { this.selectedObject = clickedObject; this.selectedConnection = null; this.isDragging = true; this.dragOffset = { x: x - clickedObject.x, y: y - clickedObject.y, }; this.updatePropertiesPanel(); } else if (clickedConnection) { this.selectedConnection = clickedConnection; this.selectedObject = null; this.updatePropertiesPanel(); } else { this.selectedObject = null; this.selectedConnection = null; this.updatePropertiesPanel(); } } else if (this.mode === "connect" && clickedObject) { if (!this.connectionStart) { // Start connection this.connectionStart = clickedObject; } else { // End connection this.createConnection(this.connectionStart, clickedObject); this.connectionStart = null; } } else { this.selectedObject = null; this.selectedConnection = null; this.connectionStart = null; this.updatePropertiesPanel(); } this.render(); } handleMouseMove(event) { const rect = this.canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; if (this.isDragging && this.selectedObject) { this.selectedObject.x = x - this.dragOffset.x; this.selectedObject.y = y - this.dragOffset.y; this.render(); } // Show tooltip const hoveredObject = this.getObjectAt(x, y); this.showTooltip(hoveredObject, x, y); } handleMouseUp() { this.isDragging = false; } handleClick(event) { // Handle click events that don't require dragging } getObjectAt(x, y) { for (let i = this.objects.length - 1; i >= 0; i--) { const obj = this.objects[i]; if (obj.type === "triangle") { // Triangle collision detection const centerX = obj.x + obj.width / 2; const centerY = obj.y + obj.height / 2; const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); if (distance <= obj.width / 2) { return obj; } } else { // Rectangle collision detection if ( x >= obj.x && x <= obj.x + obj.width && y >= obj.y && y <= obj.y + obj.height ) { return obj; } } } return null; } getConnectionAt(x, y) { const tolerance = 10; // Distance tolerance for clicking on connection lines for (let connection of this.connections) { const fromPoint = this.getConnectionPoint(connection.from, "output"); const toPoint = this.getConnectionPoint(connection.to, "input"); // Calculate bezier curve points const controlX1 = fromPoint.x + 50; const controlY1 = fromPoint.y; const controlX2 = toPoint.x - 50; const controlY2 = toPoint.y; // Sample points along the bezier curve and check distance for (let t = 0; t <= 1; t += 0.05) { const curvePoint = this.getBezierPoint( fromPoint.x, fromPoint.y, controlX1, controlY1, controlX2, controlY2, toPoint.x, toPoint.y, t ); const distance = Math.sqrt( (x - curvePoint.x) ** 2 + (y - curvePoint.y) ** 2 ); if (distance <= tolerance) { return connection; } } } return null; } getBezierPoint(x1, y1, cx1, cy1, cx2, cy2, x2, y2, t) { const u = 1 - t; const tt = t * t; const uu = u * u; const uuu = uu * u; const ttt = tt * t; return { x: uuu * x1 + 3 * uu * t * cx1 + 3 * u * tt * cx2 + ttt * x2, y: uuu * y1 + 3 * uu * t * cy1 + 3 * u * tt * cy2 + ttt * y2, }; } createConnection(fromObj, toObj) { // Validate connection rules if (fromObj === toObj) { this.showError("Cannot connect object to itself!"); return; } // Check if connection already exists if ( this.connections.some( (conn) => conn.from === fromObj && conn.to === toObj ) ) { this.showError("Connection already exists!"); return; } // Triangle can only have one output if ( fromObj.type === "triangle" && fromObj.connections.outputs.length >= 1 ) { this.showError("Triangle can only have one outgoing connection!"); return; } // Square can only have one input from the left if (toObj.type === "square" && toObj.connections.inputs.length >= 1) { this.showError("Square can only have one incoming connection!"); return; } // Only allow triangle->square or square->square connections if (fromObj.type === "square" && toObj.type === "triangle") { this.showError("Cannot connect from square to triangle!"); return; } const connection = { id: this.nextId++, from: fromObj, to: toObj, data: { label: `Connection ${this.nextId - 1}`, description: "", weight: 1, dataType: "default", metadata: {}, }, }; this.connections.push(connection); fromObj.connections.outputs.push(connection); toObj.connections.inputs.push(connection); this.render(); } render() { // Clear canvas this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // Draw grid this.drawGrid(); // Draw connections this.connections.forEach((connection) => this.drawConnection(connection)); // Draw objects this.objects.forEach((obj) => this.drawObject(obj)); // Draw connection preview if (this.mode === "connect" && this.connectionStart) { this.drawConnectionPreview(); } } drawGrid() { const gridSize = 20; this.ctx.strokeStyle = "#f0f0f0"; this.ctx.lineWidth = 1; for (let x = 0; x < this.canvas.width; x += gridSize) { this.ctx.beginPath(); this.ctx.moveTo(x, 0); this.ctx.lineTo(x, this.canvas.height); this.ctx.stroke(); } for (let y = 0; y < this.canvas.height; y += gridSize) { this.ctx.beginPath(); this.ctx.moveTo(0, y); this.ctx.lineTo(this.canvas.width, y); this.ctx.stroke(); } } drawObject(obj) { const isSelected = obj === this.selectedObject; const isConnectionStart = obj === this.connectionStart; this.ctx.save(); if (obj.type === "triangle") { this.drawTriangle(obj, isSelected, isConnectionStart); } else { this.drawSquare(obj, isSelected, isConnectionStart); } this.ctx.restore(); } drawTriangle(obj, isSelected, isConnectionStart) { const centerX = obj.x + obj.width / 2; const centerY = obj.y + obj.height / 2; this.ctx.beginPath(); this.ctx.moveTo(centerX, obj.y); this.ctx.lineTo(obj.x, obj.y + obj.height); this.ctx.lineTo(obj.x + obj.width, obj.y + obj.height); this.ctx.closePath(); // Fill this.ctx.fillStyle = isConnectionStart ? "#38a169" : "#48bb78"; this.ctx.fill(); // Border if (isSelected) { this.ctx.strokeStyle = "#2d3748"; this.ctx.lineWidth = 3; } else { this.ctx.strokeStyle = "#2f855a"; this.ctx.lineWidth = 2; } this.ctx.stroke(); // Label this.ctx.fillStyle = "white"; this.ctx.font = "12px Arial"; this.ctx.textAlign = "center"; this.ctx.fillText("▲", centerX, centerY + 4); } drawSquare(obj, isSelected, isConnectionStart) { // Fill this.ctx.fillStyle = isConnectionStart ? "#2b6cb0" : "#4299e1"; this.ctx.fillRect(obj.x, obj.y, obj.width, obj.height); // Border if (isSelected) { this.ctx.strokeStyle = "#2d3748"; this.ctx.lineWidth = 3; } else { this.ctx.strokeStyle = "#2c5282"; this.ctx.lineWidth = 2; } this.ctx.strokeRect(obj.x, obj.y, obj.width, obj.height); // Label this.ctx.fillStyle = "white"; this.ctx.font = "12px Arial"; this.ctx.textAlign = "center"; this.ctx.fillText("■", obj.x + obj.width / 2, obj.y + obj.height / 2 + 4); } drawConnection(connection) { const fromObj = connection.from; const toObj = connection.to; const isSelected = connection === this.selectedConnection; // Calculate connection points const fromPoint = this.getConnectionPoint(fromObj, "output"); const toPoint = this.getConnectionPoint(toObj, "input"); this.ctx.save(); this.ctx.strokeStyle = isSelected ? "#2d3748" : "#4a5568"; this.ctx.lineWidth = isSelected ? 4 : 3; this.ctx.lineCap = "round"; // Draw curved line this.ctx.beginPath(); this.ctx.moveTo(fromPoint.x, fromPoint.y); const controlX1 = fromPoint.x + 50; const controlY1 = fromPoint.y; const controlX2 = toPoint.x - 50; const controlY2 = toPoint.y; this.ctx.bezierCurveTo( controlX1, controlY1, controlX2, controlY2, toPoint.x, toPoint.y ); this.ctx.stroke(); // Draw arrowhead this.drawArrowhead( toPoint.x, toPoint.y, Math.atan2(controlY2 - toPoint.y, controlX2 - toPoint.x), isSelected ); // Draw connection label if it exists if (connection.data.label) { const midPoint = this.getBezierPoint( fromPoint.x, fromPoint.y, controlX1, controlY1, controlX2, controlY2, toPoint.x, toPoint.y, 0.5 ); this.ctx.fillStyle = "white"; this.ctx.strokeStyle = "#4a5568"; this.ctx.lineWidth = 2; this.ctx.font = "12px Arial"; this.ctx.textAlign = "center"; const textWidth = this.ctx.measureText(connection.data.label).width; const padding = 4; // Draw background this.ctx.fillRect( midPoint.x - textWidth / 2 - padding, midPoint.y - 8 - padding, textWidth + padding * 2, 16 + padding * 2 ); this.ctx.strokeRect( midPoint.x - textWidth / 2 - padding, midPoint.y - 8 - padding, textWidth + padding * 2, 16 + padding * 2 ); // Draw text this.ctx.fillStyle = "#4a5568"; this.ctx.fillText(connection.data.label, midPoint.x, midPoint.y + 4); } this.ctx.restore(); } drawConnectionPreview() { if (!this.connectionStart) return; const rect = this.canvas.getBoundingClientRect(); const mouseX = event?.clientX - rect.left || 0; const mouseY = event?.clientY - rect.top || 0; const fromPoint = this.getConnectionPoint(this.connectionStart, "output"); this.ctx.save(); this.ctx.strokeStyle = "#a0aec0"; this.ctx.lineWidth = 2; this.ctx.setLineDash([5, 5]); this.ctx.beginPath(); this.ctx.moveTo(fromPoint.x, fromPoint.y); this.ctx.lineTo(mouseX, mouseY); this.ctx.stroke(); this.ctx.restore(); } getConnectionPoint(obj, type) { if (type === "input") { return { x: obj.x, y: obj.y + obj.height / 2, }; } else { return { x: obj.x + obj.width, y: obj.y + obj.height / 2, }; } } drawArrowhead(x, y, angle, isSelected = false) { const headLength = isSelected ? 12 : 10; this.ctx.save(); this.ctx.translate(x, y); this.ctx.rotate(angle); this.ctx.beginPath(); this.ctx.moveTo(0, 0); this.ctx.lineTo(-headLength, -headLength / 2); this.ctx.lineTo(-headLength, headLength / 2); this.ctx.closePath(); this.ctx.fillStyle = isSelected ? "#2d3748" : "#4a5568"; this.ctx.fill(); this.ctx.restore(); } updatePropertiesPanel() { const panel = document.getElementById("objectProperties"); if (this.selectedObject) { this.updateObjectProperties(panel); } else if (this.selectedConnection) { this.updateConnectionProperties(panel); } else { panel.innerHTML = '

No object selected

'; } } updateObjectProperties(panel) { const obj = this.selectedObject; panel.innerHTML = `

Object Properties

Object Info

Type: ${obj.type}

ID: ${obj.id}

Position: (${Math.round( obj.x )}, ${Math.round(obj.y)})

Inputs: ${obj.connections.inputs.length}

Outputs: ${ obj.connections.outputs.length }

Actions

`; // Add event listeners for property changes document.getElementById("objName").addEventListener("input", (e) => { obj.data.name = e.target.value; }); document.getElementById("objDescription").addEventListener("input", (e) => { obj.data.description = e.target.value; }); document.getElementById("objValue").addEventListener("input", (e) => { obj.data.value = e.target.value; }); } updateConnectionProperties(panel) { const conn = this.selectedConnection; panel.innerHTML = `

Connection Properties

Connection Info

ID: ${conn.id}

From: ${conn.from.data.name} (${ conn.from.type })

To: ${conn.to.data.name} (${ conn.to.type })

Flow Direction: ${conn.from.data.name} → ${ conn.to.data.name }

Actions

`; // Add event listeners for property changes document.getElementById("connLabel").addEventListener("input", (e) => { conn.data.label = e.target.value; this.render(); // Re-render to show label changes }); document .getElementById("connDescription") .addEventListener("input", (e) => { conn.data.description = e.target.value; }); document.getElementById("connWeight").addEventListener("input", (e) => { conn.data.weight = parseFloat(e.target.value) || 0; }); document.getElementById("connDataType").addEventListener("change", (e) => { conn.data.dataType = e.target.value; }); } deleteObject(id) { const objIndex = this.objects.findIndex((obj) => obj.id === id); if (objIndex === -1) return; const obj = this.objects[objIndex]; // Remove all connections involving this object this.connections = this.connections.filter((conn) => { if (conn.from === obj || conn.to === obj) { // Remove connection references from other objects if (conn.from !== obj) { conn.from.connections.outputs = conn.from.connections.outputs.filter( (c) => c !== conn ); } if (conn.to !== obj) { conn.to.connections.inputs = conn.to.connections.inputs.filter( (c) => c !== conn ); } return false; } return true; }); // Remove the object this.objects.splice(objIndex, 1); if (this.selectedObject === obj) { this.selectedObject = null; this.updatePropertiesPanel(); } this.render(); } deleteConnection(id) { const connIndex = this.connections.findIndex((conn) => conn.id === id); if (connIndex === -1) return; const connection = this.connections[connIndex]; // Remove connection references from objects connection.from.connections.outputs = connection.from.connections.outputs.filter((c) => c !== connection); connection.to.connections.inputs = connection.to.connections.inputs.filter( (c) => c !== connection ); // Remove the connection this.connections.splice(connIndex, 1); if (this.selectedConnection === connection) { this.selectedConnection = null; this.updatePropertiesPanel(); } this.render(); } showTooltip(obj, x, y) { const tooltip = document.getElementById("tooltip"); if (obj) { tooltip.style.display = "block"; tooltip.style.left = x + 10 + "px"; tooltip.style.top = y - 30 + "px"; tooltip.textContent = `${obj.data.name} (${obj.type})`; } else { // Check if hovering over a connection const connection = this.getConnectionAt(x, y); if (connection) { tooltip.style.display = "block"; tooltip.style.left = x + 10 + "px"; tooltip.style.top = y - 30 + "px"; tooltip.textContent = `${connection.data.label || "Connection"} (${ connection.from.data.name } → ${connection.to.data.name})`; } else { tooltip.style.display = "none"; } } } showError(message) { // Create temporary error message const error = document.createElement("div"); error.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #e53e3e; color: white; padding: 1rem; border-radius: 8px; z-index: 10000; animation: slideIn 0.3s ease; `; error.textContent = message; document.body.appendChild(error); setTimeout(() => { error.remove(); }, 3000); } clearAll() { if ( confirm("Are you sure you want to clear all objects and connections?") ) { this.objects = []; this.connections = []; this.selectedObject = null; this.selectedConnection = null; this.connectionStart = null; this.nextId = 1; this.updatePropertiesPanel(); this.render(); } } exportData() { const data = { objects: this.objects.map((obj) => ({ ...obj, connections: { inputs: obj.connections.inputs.map((conn) => conn.id), outputs: obj.connections.outputs.map((conn) => conn.id), }, })), connections: this.connections.map((conn) => ({ ...conn, from: conn.from.id, to: conn.to.id, })), nextId: this.nextId, }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "flow-design.json"; a.click(); URL.revokeObjectURL(url); } importData(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const data = JSON.parse(e.target.result); // Clear current data this.objects = []; this.connections = []; this.selectedObject = null; this.selectedConnection = null; this.connectionStart = null; // Restore objects this.objects = data.objects.map((obj) => ({ ...obj, connections: { inputs: [], outputs: [] }, })); // Restore connections data.connections.forEach((connData) => { const fromObj = this.objects.find((obj) => obj.id === connData.from); const toObj = this.objects.find((obj) => obj.id === connData.to); if (fromObj && toObj) { const connection = { ...connData, from: fromObj, to: toObj, }; this.connections.push(connection); fromObj.connections.outputs.push(connection); toObj.connections.inputs.push(connection); } }); this.nextId = data.nextId || this.nextId; this.updatePropertiesPanel(); this.render(); } catch (error) { this.showError("Failed to import file: Invalid format"); } }; reader.readAsText(file); // Reset file input event.target.value = ""; } } // Initialize the application let designer; document.addEventListener("DOMContentLoaded", () => { designer = new ObjectFlowDesigner(); }); // Add CSS animation keyframes const style = document.createElement("style"); style.textContent = ` @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } `; document.head.appendChild(style);