Files
elcalc/script.js
Chop 643a4210ea feat: Implement grid snapping and panning functionality in ObjectFlowDesigner
- Added grid snapping to object creation and dragging for better alignment.
- Introduced panning support with right-click and mouse wheel events for canvas navigation.
- Enhanced object properties for transformers and nodes, including detailed information display.
- Updated connection properties to include cable type, cross-section, and length.
- Implemented validation for overhead cable connections to ensure correct node types.
- Improved rendering logic to account for scrolling and grid display.
- Added CSS styles for warnings and specific node types for better UI feedback.
2025-06-26 21:51:42 +02:00

1527 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.gridSize = 20; // Grid size for snapping
// Scrolling/panning support
this.scrollOffset = { x: 0, y: 0 };
this.isPanning = false;
this.panStart = { x: 0, y: 0 };
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));
// Scrolling and panning
this.canvas.addEventListener("wheel", this.handleWheel.bind(this), {
passive: false,
});
this.canvas.addEventListener(
"contextmenu",
this.handleContextMenu.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++;
// Snap coordinates to grid
const snappedPos = this.snapToGrid(x, y);
let obj;
if (type === "triangle") {
// Transformer
obj = {
id,
type,
x: snappedPos.x,
y: snappedPos.y,
width: 50,
height: 50,
data: {
number: `T${id}`,
name: `Transformer ${id}`,
upperVoltage: 20000, // V
bottomVoltage: 400, // V
powerKVA: 630, // kVA
description: "",
metadata: {},
},
connections: {
inputs: [],
outputs: [],
},
};
} else {
// Square - Cable box or Pole
obj = {
id,
type,
x: snappedPos.x,
y: snappedPos.y,
width: 50,
height: 50,
data: {
number: `N${id}`,
name: `Node ${id}`,
nodeType: "cable_box", // cable_box, pole, or end_connection
boxPoleType: "", // Hand input field
powerKW: 0, // Power rating in kW
description: "",
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 screenX = event.clientX - rect.left;
const screenY = event.clientY - rect.top;
const worldPos = this.screenToWorld(screenX, screenY);
const x = worldPos.x;
const y = worldPos.y;
const clickedObject = this.getObjectAt(x, y);
const clickedConnection = this.getConnectionAt(x, y);
// Check for right-click panning
if (event.button === 2) {
// Right mouse button
this.isPanning = true;
this.panStart = { x: screenX, y: screenY };
return;
}
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 screenX = event.clientX - rect.left;
const screenY = event.clientY - rect.top;
// Handle panning
if (this.isPanning) {
const deltaX = screenX - this.panStart.x;
const deltaY = screenY - this.panStart.y;
this.scrollOffset.x += deltaX;
this.scrollOffset.y += deltaY;
this.panStart = { x: screenX, y: screenY };
this.render();
return;
}
const worldPos = this.screenToWorld(screenX, screenY);
const x = worldPos.x;
const y = worldPos.y;
if (this.isDragging && this.selectedObject) {
const newX = x - this.dragOffset.x;
const newY = y - this.dragOffset.y;
// Snap to grid during dragging
const snappedPos = this.snapToGrid(newX, newY);
this.selectedObject.x = snappedPos.x;
this.selectedObject.y = snappedPos.y;
this.render();
}
// Show tooltip
const hoveredObject = this.getObjectAt(x, y);
this.showTooltip(hoveredObject, screenX, screenY);
}
handleMouseUp() {
this.isDragging = false;
this.isPanning = false;
}
handleClick(event) {
// Handle click events that don't require dragging
}
handleWheel(event) {
event.preventDefault();
// Scroll sensitivity
const scrollSpeed = 1;
// Update scroll offset
this.scrollOffset.x -= event.deltaX * scrollSpeed;
this.scrollOffset.y -= event.deltaY * scrollSpeed;
// Optional: Limit scrolling bounds
// You can uncomment these lines to limit how far users can scroll
// this.scrollOffset.x = Math.max(-2000, Math.min(2000, this.scrollOffset.x));
// this.scrollOffset.y = Math.max(-2000, Math.min(2000, this.scrollOffset.y));
this.render();
}
handleContextMenu(event) {
event.preventDefault(); // Prevent context menu from appearing
}
// Transform screen coordinates to world coordinates
screenToWorld(screenX, screenY) {
return {
x: screenX - this.scrollOffset.x,
y: screenY - this.scrollOffset.y,
};
}
// Transform world coordinates to screen coordinates
worldToScreen(worldX, worldY) {
return {
x: worldX + this.scrollOffset.x,
y: worldY + this.scrollOffset.y,
};
}
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("Transformer can only have one outgoing connection!");
return;
}
// Square can only have one input from the left (except end connections)
if (
toObj.type === "square" &&
toObj.data.nodeType !== "end_connection" &&
toObj.connections.inputs.length >= 1
) {
this.showError("Node can only have one incoming connection!");
return;
}
// End connections can have multiple inputs but no outputs
if (
toObj.type === "square" &&
toObj.data.nodeType === "end_connection" &&
toObj.connections.outputs.length > 0
) {
this.showError("End connections cannot have outgoing connections!");
return;
}
// Cannot connect from end connections
if (
fromObj.type === "square" &&
fromObj.data.nodeType === "end_connection"
) {
this.showError("Cannot connect from end connections!");
return;
}
// Only allow triangle->square or square->square connections
if (fromObj.type === "square" && toObj.type === "triangle") {
this.showError("Cannot connect from node to transformer!");
return;
}
// Validate overhead cable connections (only to poles or end connections)
// This will be checked later when cable type is selected
const connection = {
id: this.nextId++,
from: fromObj,
to: toObj,
data: {
label: `Cable ${this.nextId - 1}`,
cableType: "YAKY", // YAKY, NA2XY-J (ground), AL, AsXSn (overhead)
crossSection: 50, // mm²
length: 100, // meters
description: "",
metadata: {},
},
};
this.connections.push(connection);
fromObj.connections.outputs.push(connection);
toObj.connections.inputs.push(connection);
// Validate cable type after creation
this.validateCableConnection(connection);
this.render();
}
validateCableConnection(connection) {
const isOverheadCable =
connection.data.cableType === "AL" ||
connection.data.cableType === "AsXSn";
if (isOverheadCable) {
// Check if both ends are connected to poles or end connections
const fromIsPole =
connection.from.type === "square" &&
(connection.from.data.nodeType === "pole" ||
connection.from.data.nodeType === "end_connection");
const toIsPole =
connection.to.type === "square" &&
(connection.to.data.nodeType === "pole" ||
connection.to.data.nodeType === "end_connection");
const fromIsTransformer = connection.from.type === "triangle";
if (!((fromIsPole || fromIsTransformer) && toIsPole)) {
this.showError(
"Overhead cables can only connect to poles or end connections!"
);
// Optionally remove the invalid connection
this.deleteConnection(connection.id);
return false;
}
}
return true;
}
render() {
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Save context and apply scroll transform
this.ctx.save();
this.ctx.translate(this.scrollOffset.x, this.scrollOffset.y);
// Draw grid
this.drawGrid();
// Draw connections
this.connections.forEach((connection) => this.drawConnection(connection));
// Draw objects
this.objects.forEach((obj) => this.drawObject(obj));
// Restore context for UI elements that shouldn't scroll
this.ctx.restore();
// Draw connection preview (in screen coordinates)
if (this.mode === "connect" && this.connectionStart) {
this.drawConnectionPreview();
}
}
drawGrid() {
// Calculate visible area based on scroll offset
const startX =
Math.floor(-this.scrollOffset.x / this.gridSize) * this.gridSize;
const startY =
Math.floor(-this.scrollOffset.y / this.gridSize) * this.gridSize;
const endX = startX + this.canvas.width + this.gridSize * 2;
const endY = startY + this.canvas.height + this.gridSize * 2;
this.ctx.strokeStyle = "#e8e8e8";
this.ctx.lineWidth = 0.5;
// Draw major grid lines every 4 squares (80px)
for (let x = startX; x < endX; x += this.gridSize * 4) {
this.ctx.strokeStyle = "#d0d0d0";
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.moveTo(x, startY);
this.ctx.lineTo(x, endY);
this.ctx.stroke();
}
for (let y = startY; y < endY; y += this.gridSize * 4) {
this.ctx.strokeStyle = "#d0d0d0";
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.moveTo(startX, y);
this.ctx.lineTo(endX, y);
this.ctx.stroke();
}
// Draw minor grid lines
this.ctx.strokeStyle = "#f0f0f0";
this.ctx.lineWidth = 0.5;
for (let x = startX; x < endX; x += this.gridSize) {
this.ctx.beginPath();
this.ctx.moveTo(x, startY);
this.ctx.lineTo(x, endY);
this.ctx.stroke();
}
for (let y = startY; y < endY; y += this.gridSize) {
this.ctx.beginPath();
this.ctx.moveTo(startX, y);
this.ctx.lineTo(endX, 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 = "16px Arial";
this.ctx.textAlign = "center";
this.ctx.fillText("▲", centerX, centerY + 6);
// Power display below transformer
this.ctx.font = "14px Arial";
this.ctx.fillStyle = isSelected ? "#2d3748" : "#4a5568";
this.ctx.fillText(
`${obj.data.powerKVA}kVA`,
centerX,
obj.y + obj.height + 18
);
}
drawSquare(obj, isSelected, isConnectionStart) {
const isPole = obj.data.nodeType === "pole";
const isCableBox = obj.data.nodeType === "cable_box";
const isEndConnection = obj.data.nodeType === "end_connection";
// Different colors for different node types
let fillColor, borderColor;
if (isPole) {
fillColor = isConnectionStart ? "#2d3748" : "#4a5568"; // Darker gray for poles
borderColor = "#1a202c";
} else if (isEndConnection) {
fillColor = isConnectionStart ? "#c53030" : "#e53e3e"; // Red for end connections
borderColor = "#9b2c2c";
} else {
fillColor = isConnectionStart ? "#2b6cb0" : "#4299e1"; // Blue for cable boxes
borderColor = "#2c5282";
}
// Fill
this.ctx.fillStyle = fillColor;
this.ctx.fillRect(obj.x, obj.y, obj.width, obj.height);
// Border
if (isSelected) {
this.ctx.strokeStyle = "#fbb040"; // Orange border for selected
this.ctx.lineWidth = 3;
} else {
this.ctx.strokeStyle = borderColor;
this.ctx.lineWidth = 2;
}
this.ctx.strokeRect(obj.x, obj.y, obj.width, obj.height);
// Different symbols for different types
this.ctx.fillStyle = "white";
this.ctx.font = "16px Arial";
this.ctx.textAlign = "center";
if (isPole) {
// Draw pole symbol
this.ctx.fillText("⬆", obj.x + obj.width / 2, obj.y + obj.height / 2 + 6);
} else if (isEndConnection) {
// Draw end connection symbol
this.ctx.fillText("⬤", obj.x + obj.width / 2, obj.y + obj.height / 2 + 6);
} else {
// Draw cable box symbol
this.ctx.fillText(
"⬛",
obj.x + obj.width / 2,
obj.y + obj.height / 2 + 6
);
}
// Add detailed information below nodes
this.ctx.font = "11px Arial";
this.ctx.fillStyle = isSelected ? "#fbb040" : "#2d3748";
this.ctx.textAlign = "center";
let yOffset = obj.y + obj.height + 16;
// 1. Number
this.ctx.fillText(`#${obj.data.number}`, obj.x + obj.width / 2, yOffset);
yOffset += 13;
// 2. Node power (for boxes and end connections)
if (!isPole && obj.data.powerKW > 0) {
this.ctx.fillText(
`${obj.data.powerKW}kW`,
obj.x + obj.width / 2,
yOffset
);
yOffset += 13;
}
// 3. Total downstream power
const downstreamPower = this.calculateDownstreamPower(obj);
if (downstreamPower > 0) {
this.ctx.fillText(
`Σ${downstreamPower}kW`,
obj.x + obj.width / 2,
yOffset
);
yOffset += 13;
}
// 4. Distance from transformer
const distance = this.calculateNodeDistance(obj);
if (distance > 0) {
this.ctx.fillText(`D${distance}`, obj.x + obj.width / 2, yOffset);
yOffset += 13;
}
// Type description (moved down to accommodate other info)
if (obj.data.boxPoleType) {
this.ctx.fillText(
obj.data.boxPoleType.substr(0, 8),
obj.x + obj.width / 2,
yOffset
);
}
}
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";
// Set line style based on cable type
const isOverheadCable =
connection.data.cableType === "AL" ||
connection.data.cableType === "AsXSn";
if (isOverheadCable) {
// Overhead cables: continuous line
this.ctx.setLineDash([]);
} else {
// Underground cables: dashed line
this.ctx.setLineDash([10, 5]);
}
// 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();
// Reset line dash for arrowhead (always solid)
this.ctx.setLineDash([]);
// Draw arrowhead
this.drawArrowhead(
toPoint.x,
toPoint.y,
Math.atan2(controlY2 - toPoint.y, controlX2 - toPoint.x),
isSelected
);
// Draw connection information
const midPoint = this.getBezierPoint(
fromPoint.x,
fromPoint.y,
controlX1,
controlY1,
controlX2,
controlY2,
toPoint.x,
toPoint.y,
0.5
);
// Prepare the text lines to display
const textLines = [];
if (connection.data.label) {
textLines.push(connection.data.label);
}
// Format cable type display
const isOverhead =
connection.data.cableType === "AL" ||
connection.data.cableType === "AsXSn";
const cableTypeDisplay = `${connection.data.cableType} ${
isOverhead ? "(OH)" : "(UG)"
}`;
textLines.push(`${cableTypeDisplay} ${connection.data.crossSection}mm²`);
textLines.push(`${connection.data.length}m`);
if (textLines.length > 0) {
this.ctx.font = "12px Arial";
this.ctx.textAlign = "center";
// Calculate dimensions for background
const lineHeight = 16;
const padding = 5;
let maxWidth = 0;
// Measure all text lines to find the maximum width
textLines.forEach((line) => {
const width = this.ctx.measureText(line).width;
if (width > maxWidth) maxWidth = width;
});
const totalHeight = textLines.length * lineHeight;
// Draw background
this.ctx.fillStyle = "rgba(255, 255, 255, 0.95)";
this.ctx.strokeStyle = "#4a5568";
this.ctx.lineWidth = 1;
const bgX = midPoint.x - maxWidth / 2 - padding;
const bgY = midPoint.y - totalHeight / 2 - padding;
const bgWidth = maxWidth + padding * 2;
const bgHeight = totalHeight + padding * 2;
this.ctx.fillRect(bgX, bgY, bgWidth, bgHeight);
this.ctx.strokeRect(bgX, bgY, bgWidth, bgHeight);
// Draw text lines
this.ctx.fillStyle = "#2d3748";
textLines.forEach((line, index) => {
const yOffset = (index - (textLines.length - 1) / 2) * lineHeight;
this.ctx.fillText(line, midPoint.x, midPoint.y + yOffset + 4);
});
}
this.ctx.restore();
}
drawConnectionPreview() {
if (!this.connectionStart) return;
const rect = this.canvas.getBoundingClientRect();
const screenX = event?.clientX - rect.left || 0;
const screenY = event?.clientY - rect.top || 0;
const worldMouse = this.screenToWorld(screenX, screenY);
// Apply scroll transform for this preview
this.ctx.save();
this.ctx.translate(this.scrollOffset.x, this.scrollOffset.y);
const fromPoint = this.getConnectionPoint(this.connectionStart, "output");
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(worldMouse.x, worldMouse.y);
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;
if (obj.type === "triangle") {
// Transformer properties
panel.innerHTML = `
<div class="property-group">
<h4>Transformer Properties</h4>
<label>Number:</label>
<input type="text" id="objNumber" value="${obj.data.number}">
<label>Name:</label>
<input type="text" id="objName" value="${obj.data.name}">
<label>Upper Voltage (V):</label>
<input type="number" id="objUpperVoltage" value="${obj.data.upperVoltage}">
<label>Bottom Voltage (V):</label>
<input type="number" id="objBottomVoltage" value="${obj.data.bottomVoltage}">
<label>Power (kVA):</label>
<input type="number" id="objPowerKVA" value="${obj.data.powerKVA}">
<label>Description:</label>
<textarea id="objDescription">${obj.data.description}</textarea>
</div>
<div class="property-group">
<h4>Transformer Info</h4>
<p><strong>Type:</strong> Transformer</p>
<p><strong>ID:</strong> ${obj.id}</p>
<p><strong>Position:</strong> (${Math.round(obj.x)}, ${Math.round(obj.y)})</p>
<p><strong>Ratio:</strong> ${obj.data.upperVoltage}V / ${
obj.data.bottomVoltage
}V</p>
<p><strong>Outputs:</strong> ${obj.connections.outputs.length}/1</p>
</div>
<div class="property-group">
<h4>Actions</h4>
<button class="btn btn-danger" onclick="designer.deleteObject(${
obj.id
})">Delete Transformer</button>
</div>
`;
// Add event listeners
document.getElementById("objNumber").addEventListener("input", (e) => {
obj.data.number = e.target.value;
});
document.getElementById("objName").addEventListener("input", (e) => {
obj.data.name = e.target.value;
});
document
.getElementById("objUpperVoltage")
.addEventListener("input", (e) => {
obj.data.upperVoltage = parseInt(e.target.value) || 0;
});
document
.getElementById("objBottomVoltage")
.addEventListener("input", (e) => {
obj.data.bottomVoltage = parseInt(e.target.value) || 0;
});
document.getElementById("objPowerKVA").addEventListener("input", (e) => {
obj.data.powerKVA = parseInt(e.target.value) || 0;
});
document
.getElementById("objDescription")
.addEventListener("input", (e) => {
obj.data.description = e.target.value;
});
} else {
// Square (Node) properties
panel.innerHTML = `
<div class="property-group">
<h4>Node Properties</h4>
<label>Number:</label>
<input type="text" id="objNumber" value="${obj.data.number}">
<label>Name:</label>
<input type="text" id="objName" value="${obj.data.name}">
<label>Node Type:</label>
<select id="objNodeType">
<option value="cable_box" ${
obj.data.nodeType === "cable_box" ? "selected" : ""
}>Cable Box</option>
<option value="pole" ${
obj.data.nodeType === "pole" ? "selected" : ""
}>Pole</option>
<option value="end_connection" ${
obj.data.nodeType === "end_connection" ? "selected" : ""
}>End Connection</option>
</select>
<label>Type Description:</label>
<input type="text" id="objBoxPoleType" value="${
obj.data.boxPoleType
}" placeholder="Enter type description">
${
obj.data.nodeType !== "pole"
? `
<label>Power Rating (kW):</label>
<input type="number" id="objPowerKW" value="${obj.data.powerKW}" step="0.1" min="0">
`
: ""
}
<label>Description:</label>
<textarea id="objDescription">${obj.data.description}</textarea>
</div>
<div class="property-group">
<h4>Node Info</h4>
<p><strong>Type:</strong> ${
obj.data.nodeType === "cable_box"
? "Cable Box"
: obj.data.nodeType === "pole"
? "Pole"
: "End Connection"
}</p>
<p><strong>ID:</strong> ${obj.id}</p>
<p><strong>Position:</strong> (${Math.round(obj.x)}, ${Math.round(obj.y)})</p>
${
obj.data.nodeType !== "pole" && obj.data.powerKW > 0
? `<p><strong>Power:</strong> ${obj.data.powerKW} kW</p>`
: ""
}
<p><strong>Inputs:</strong> ${obj.connections.inputs.length}${
obj.data.nodeType === "end_connection" ? "" : "/1"
}</p>
<p><strong>Outputs:</strong> ${obj.connections.outputs.length}${
obj.data.nodeType === "end_connection" ? "/0" : ""
}</p>
</div>
<div class="property-group">
<h4>Actions</h4>
<button class="btn btn-danger" onclick="designer.deleteObject(${
obj.id
})">Delete Node</button>
</div>
`;
// Add event listeners
document.getElementById("objNumber").addEventListener("input", (e) => {
obj.data.number = e.target.value;
});
document.getElementById("objName").addEventListener("input", (e) => {
obj.data.name = e.target.value;
});
document.getElementById("objNodeType").addEventListener("change", (e) => {
obj.data.nodeType = e.target.value;
// Reset power for poles
if (e.target.value === "pole") {
obj.data.powerKW = 0;
}
this.updatePropertiesPanel(); // Refresh to update options
this.render(); // Re-render to update visual representation
});
document
.getElementById("objBoxPoleType")
.addEventListener("input", (e) => {
obj.data.boxPoleType = e.target.value;
this.render(); // Re-render to update visual representation
});
// Only add power listener if the field exists (not for poles)
const powerField = document.getElementById("objPowerKW");
if (powerField) {
powerField.addEventListener("input", (e) => {
obj.data.powerKW = parseFloat(e.target.value) || 0;
});
}
document
.getElementById("objDescription")
.addEventListener("input", (e) => {
obj.data.description = e.target.value;
});
}
}
updateConnectionProperties(panel) {
const conn = this.selectedConnection;
const isOverheadCable =
conn.data.cableType === "AL" || conn.data.cableType === "AsXSn";
panel.innerHTML = `
<div class="property-group">
<h4>Power Cable Properties</h4>
<label>Label:</label>
<input type="text" id="connLabel" value="${conn.data.label}">
<label>Cable Type:</label>
<select id="connCableType">
<option value="YAKY" ${
conn.data.cableType === "YAKY" ? "selected" : ""
}>YAKY (Ground Cable)</option>
<option value="NA2XY-J" ${
conn.data.cableType === "NA2XY-J" ? "selected" : ""
}>NA2XY-J (Ground Cable)</option>
<option value="AL" ${
conn.data.cableType === "AL" ? "selected" : ""
}>AL (Overhead Cable)</option>
<option value="AsXSn" ${
conn.data.cableType === "AsXSn" ? "selected" : ""
}>AsXSn (Overhead Cable)</option>
</select>
<label>Cross Section (mm²):</label>
<input type="number" id="connCrossSection" value="${
conn.data.crossSection
}" min="1">
<label>Length (m):</label>
<input type="number" id="connLength" value="${
conn.data.length
}" min="0.1" step="0.1">
<label>Description:</label>
<textarea id="connDescription">${conn.data.description}</textarea>
</div>
<div class="property-group">
<h4>Cable Info</h4>
<p><strong>ID:</strong> ${conn.id}</p>
<p><strong>Type:</strong> ${conn.data.cableType} ${
isOverheadCable ? "(Overhead)" : "(Ground)"
}</p>
<p><strong>From:</strong> ${conn.from.data.name} (${
conn.from.type === "triangle"
? "Transformer"
: this.getNodeDisplayType(conn.from.data.nodeType)
})</p>
<p><strong>To:</strong> ${conn.to.data.name} (${
conn.to.type === "triangle"
? "Transformer"
: this.getNodeDisplayType(conn.to.data.nodeType)
})</p>
<p><strong>Capacity:</strong> ${conn.data.crossSection} mm² × ${
conn.data.length
} m</p>
${
isOverheadCable
? '<p class="warning"><strong>⚠️ Overhead cables can only connect to poles or end connections!</strong></p>'
: ""
}
</div>
<div class="property-group">
<h4>Actions</h4>
<button class="btn btn-danger" onclick="designer.deleteConnection(${
conn.id
})">Delete Cable</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("connCableType").addEventListener("change", (e) => {
conn.data.cableType = e.target.value;
this.validateCableConnection(conn); // Re-validate connection
this.updatePropertiesPanel(); // Refresh to show warnings
this.render();
});
document
.getElementById("connCrossSection")
.addEventListener("input", (e) => {
conn.data.crossSection = parseInt(e.target.value) || 1;
});
document.getElementById("connLength").addEventListener("input", (e) => {
conn.data.length = parseFloat(e.target.value) || 0.1;
});
document
.getElementById("connDescription")
.addEventListener("input", (e) => {
conn.data.description = 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, screenX, screenY) {
const tooltip = document.getElementById("tooltip");
if (obj) {
tooltip.style.display = "block";
tooltip.style.left = screenX + 10 + "px";
tooltip.style.top = screenY - 30 + "px";
tooltip.textContent = `${obj.data.name} (${obj.type})`;
} else {
// Check if hovering over a connection
const worldPos = this.screenToWorld(screenX, screenY);
const connection = this.getConnectionAt(worldPos.x, worldPos.y);
if (connection) {
tooltip.style.display = "block";
tooltip.style.left = screenX + 10 + "px";
tooltip.style.top = screenY - 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 and snap them to grid
this.objects = data.objects.map((obj) => {
const snappedPos = this.snapToGrid(obj.x, obj.y);
return {
...obj,
x: snappedPos.x,
y: snappedPos.y,
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 = "";
}
getNodeDisplayType(nodeType) {
switch (nodeType) {
case "cable_box":
return "Cable Box";
case "pole":
return "Pole";
case "end_connection":
return "End Connection";
default:
return "Node";
}
}
snapToGrid(x, y) {
return {
x: Math.round(x / this.gridSize) * this.gridSize,
y: Math.round(y / this.gridSize) * this.gridSize,
};
}
calculateNodeDistance(node) {
// Calculate distance from transformer (number of hops)
if (node.type === "triangle") return 0;
const visited = new Set();
const queue = [{ node, distance: 0 }];
visited.add(node.id);
while (queue.length > 0) {
const { node: currentNode, distance } = queue.shift();
// Check all input connections
for (const connection of currentNode.connections.inputs) {
const fromNode = connection.from;
if (fromNode.type === "triangle") {
return distance + 1;
}
if (!visited.has(fromNode.id)) {
visited.add(fromNode.id);
queue.push({ node: fromNode, distance: distance + 1 });
}
}
}
return -1; // Not connected to transformer
}
calculateDownstreamPower(node) {
// Calculate total power of this node and all downstream nodes
let totalPower = node.data.powerKW || 0;
const visited = new Set();
const calculateRecursive = (currentNode) => {
if (visited.has(currentNode.id)) return 0;
visited.add(currentNode.id);
let downstreamPower = 0;
for (const connection of currentNode.connections.outputs) {
const toNode = connection.to;
downstreamPower += toNode.data.powerKW || 0;
downstreamPower += calculateRecursive(toNode);
}
return downstreamPower;
};
totalPower += calculateRecursive(node);
return totalPower;
}
}
// 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);