Files
elcalc/script.js
2025-06-26 20:00:04 +02:00

949 lines
24 KiB
JavaScript

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 = '<p class="no-selection">No object selected</p>';
}
}
updateObjectProperties(panel) {
const obj = this.selectedObject;
panel.innerHTML = `
<div class="property-group">
<h4>Object Properties</h4>
<label>Name:</label>
<input type="text" id="objName" value="${obj.data.name}">
<label>Description:</label>
<textarea id="objDescription">${obj.data.description}</textarea>
<label>Value:</label>
<input type="text" id="objValue" value="${obj.data.value}">
</div>
<div class="property-group">
<h4>Object Info</h4>
<p><strong>Type:</strong> ${obj.type}</p>
<p><strong>ID:</strong> ${obj.id}</p>
<p><strong>Position:</strong> (${Math.round(
obj.x
)}, ${Math.round(obj.y)})</p>
<p><strong>Inputs:</strong> ${obj.connections.inputs.length}</p>
<p><strong>Outputs:</strong> ${
obj.connections.outputs.length
}</p>
</div>
<div class="property-group">
<h4>Actions</h4>
<button class="btn btn-danger" onclick="designer.deleteObject(${
obj.id
})">Delete Object</button>
</div>
`;
// 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 = `
<div class="property-group">
<h4>Connection Properties</h4>
<label>Label:</label>
<input type="text" id="connLabel" value="${conn.data.label}">
<label>Description:</label>
<textarea id="connDescription">${
conn.data.description
}</textarea>
<label>Weight:</label>
<input type="number" id="connWeight" value="${
conn.data.weight
}" step="0.1">
<label>Data Type:</label>
<select id="connDataType">
<option value="default" ${
conn.data.dataType === "default" ? "selected" : ""
}>Default</option>
<option value="numeric" ${
conn.data.dataType === "numeric" ? "selected" : ""
}>Numeric</option>
<option value="text" ${
conn.data.dataType === "text" ? "selected" : ""
}>Text</option>
<option value="boolean" ${
conn.data.dataType === "boolean" ? "selected" : ""
}>Boolean</option>
<option value="object" ${
conn.data.dataType === "object" ? "selected" : ""
}>Object</option>
</select>
</div>
<div class="property-group">
<h4>Connection Info</h4>
<p><strong>ID:</strong> ${conn.id}</p>
<p><strong>From:</strong> ${conn.from.data.name} (${
conn.from.type
})</p>
<p><strong>To:</strong> ${conn.to.data.name} (${
conn.to.type
})</p>
<p><strong>Flow Direction:</strong> ${conn.from.data.name}${
conn.to.data.name
}</p>
</div>
<div class="property-group">
<h4>Actions</h4>
<button class="btn btn-danger" onclick="designer.deleteConnection(${
conn.id
})">Delete Connection</button>
</div>
`;
// 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);