Files
elcalc/script.js

2295 lines
64 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 };
// Smooth rendering
this.renderRequested = false;
this.lastRenderTime = 0;
this.renderThrottle = 16; // ~60fps
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)
);
// Icon toolbar events
document.querySelectorAll(".icon-btn[data-type]").forEach((btn) => {
btn.addEventListener("click", this.handleIconClick.bind(this));
});
// Tool buttons
document
.getElementById("selectBtn")
.addEventListener("click", () => this.setMode("select"));
document
.getElementById("deleteBtn")
.addEventListener("click", () => this.setMode("delete"));
// Auto-arrange button - one simple button to make everything tidy
document
.getElementById("autoArrangeBtn")
.addEventListener("click", () => this.autoArrangeObjects());
// Control buttons
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));
// Calculate button (if it exists)
const calculateBtn = document.getElementById("calculateBtn");
if (calculateBtn) {
calculateBtn.addEventListener("click", () => {
this.updateCalculations();
});
}
window.addEventListener("resize", this.resizeCanvas.bind(this));
}
// Smooth rendering to prevent jagged movement
requestRender() {
if (!this.renderRequested) {
this.renderRequested = true;
requestAnimationFrame(() => {
const now = performance.now();
if (now - this.lastRenderTime >= this.renderThrottle) {
this.render();
this.lastRenderTime = now;
}
this.renderRequested = false;
});
}
}
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 tool button states
document.querySelectorAll(".tool-btn").forEach((btn) => {
btn.classList.remove("active");
});
if (mode === "select") {
document.getElementById("selectBtn").classList.add("active");
} else if (mode === "delete") {
document.getElementById("deleteBtn").classList.add("active");
}
// Clear active state from object/cable buttons when switching to tools
if (mode === "select" || mode === "delete") {
document.querySelectorAll(".icon-btn[data-type]").forEach((btn) => {
btn.classList.remove("active");
});
}
this.render();
}
handleIconClick(event) {
const type = event.currentTarget.dataset.type;
// Clear other active states first
document.querySelectorAll(".icon-btn").forEach((btn) => {
btn.classList.remove("active");
});
// Set the clicked button as active
event.currentTarget.classList.add("active");
if (type === "triangle") {
// Check if triangle already exists
if (this.objects.some((obj) => obj.type === "triangle")) {
alert("Only one transformer is allowed in the system.");
event.currentTarget.classList.remove("active");
return;
}
this.setMode("place-triangle");
} else if (type === "box" || type === "pole" || type === "end") {
this.setMode(`place-${type}`);
} else if (type === "underground" || type === "overhead") {
this.setMode(`connect-${type}`);
}
}
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 if (type === "box" || type === "pole" || type === "end") {
// Node types - Cable box, Pole, or End connection
const nodeTypeMap = {
box: "cable_box",
pole: "pole",
end: "end_connection",
};
obj = {
id,
type: "square", // Keep internal type as square for compatibility
x: snappedPos.x,
y: snappedPos.y,
width: 50,
height: 50,
data: {
number: `N${id}`,
nodeType: nodeTypeMap[type],
boxPoleType: "", // Hand input field
consumers3Phase: 0, // Number of 3-phase consumers (7kW each)
consumers1Phase: 0, // Number of 1-phase consumers (3kW each)
customPowerKW: 0, // Additional custom power 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.startsWith("connect-") && clickedObject) {
if (!this.connectionStart) {
// Start connection
this.connectionStart = clickedObject;
} else if (this.connectionStart !== clickedObject) {
// End connection
const cableType = this.mode.replace("connect-", "");
this.createConnection(
this.connectionStart,
clickedObject,
`${cableType}-single`
);
this.connectionStart = null;
}
} 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 if (this.mode === "delete") {
if (clickedObject) {
this.deleteObject(clickedObject.id);
} else if (clickedConnection) {
this.deleteConnection(clickedConnection.id);
}
} 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.requestRender();
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.requestRender();
}
// Show tooltip
const hoveredObject = this.getObjectAt(x, y);
this.showTooltip(hoveredObject, screenX, screenY);
}
handleMouseUp() {
this.isDragging = false;
this.isPanning = false;
}
handleClick(event) {
const rect = this.canvas.getBoundingClientRect();
const screenX = event.clientX - rect.left;
const screenY = event.clientY - rect.top;
const { x, y } = this.screenToWorld(screenX, screenY);
// Handle placement modes
if (this.mode.startsWith("place-")) {
const type = this.mode.replace("place-", "");
const snappedPos = this.snapToGrid(x, y);
this.createObject(type, snappedPos.x, snappedPos.y);
return;
}
}
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.requestRender();
}
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, cableType = "YAKY") {
// 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;
}
// Set cable type based on mode
let defaultCableType = "YAKY";
if (cableType === "underground-single") {
defaultCableType = "YAKY";
} else if (cableType === "overhead-single") {
defaultCableType = "AL";
}
// Calculate distance between objects
const distance = Math.round(
Math.sqrt(
Math.pow(toObj.x - fromObj.x, 2) + Math.pow(toObj.y - fromObj.y, 2)
) / 10 // Convert from pixels to meters (rough approximation)
);
const connection = {
id: this.nextId++,
from: fromObj,
to: toObj,
data: {
label: `Cable ${this.nextId - 1}`,
cableType: defaultCableType,
crossSection: 50, // mm²
length: Math.max(10, distance), // minimum 10 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();
return connection;
}
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.startsWith("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 node 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 all node types)
const totalPower = this.calculateNodeTotalPower(obj);
if (totalPower > 0) {
this.ctx.fillText(`${totalPower}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;
}
// 5. Voltage information (if calculated)
const hasVoltageData =
obj.data.voltage !== null && obj.data.voltage !== undefined;
if (hasVoltageData) {
// Show voltage level
const voltage = Math.round(obj.data.voltage * 10) / 10; // Round to 1 decimal
this.ctx.fillText(`${voltage}V`, obj.x + obj.width / 2, yOffset);
yOffset += 13;
// Show voltage drop percentage with color coding
const dropPercentage = this.getVoltageDropPercentage(obj);
const dropText = `${Math.round(dropPercentage * 10) / 10}%`;
// Color code based on voltage drop severity
if (dropPercentage > 10) {
this.ctx.fillStyle = "#e53e3e"; // Red for excessive drop (>10%)
} else if (dropPercentage > 7) {
this.ctx.fillStyle = "#fbb040"; // Orange for warning (7-10%)
} else {
this.ctx.fillStyle = "#38a169"; // Green for acceptable (<7%)
}
this.ctx.fillText(`-${dropText}`, obj.x + obj.width / 2, yOffset);
yOffset += 13;
// Reset color for next text
this.ctx.fillStyle = isSelected ? "#fbb040" : "#2d3748";
}
// 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`);
// Add electrical calculations if available
if (
connection.data.sectionCurrent !== null &&
connection.data.voltageDrop !== null
) {
textLines.push(
`${Math.round(connection.data.sectionCurrent * 10) / 10}A/φ`
);
textLines.push(
`ΔU: ${Math.round(connection.data.voltageDrop * 1000) / 1000}V`
);
textLines.push(
`R: ${Math.round(connection.data.sectionResistance * 1000) / 1000}Ω`
);
}
if (textLines.length > 0) {
this.ctx.font = "11px Arial";
this.ctx.textAlign = "center";
const lineHeight = 14;
// Draw text lines with subtle outline for visibility
textLines.forEach((line, index) => {
const yOffset = (index - (textLines.length - 1) / 2) * lineHeight;
const textX = midPoint.x;
const textY = midPoint.y + yOffset + 4;
// Draw text outline (white stroke for visibility)
this.ctx.strokeStyle = "white";
this.ctx.lineWidth = 3;
this.ctx.strokeText(line, textX, textY);
// Draw text fill
this.ctx.fillStyle = "#2d3748";
this.ctx.fillText(line, textX, textY);
});
}
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>
<!-- Node Type Icons -->
<div class="node-type-selector">
<button type="button" class="node-type-btn ${
obj.data.nodeType === "cable_box" ? "active" : ""
}"
data-type="cable_box" title="Cable Box">
<span class="node-icon">⬛</span>
<span class="node-label">Box</span>
</button>
<button type="button" class="node-type-btn ${
obj.data.nodeType === "pole" ? "active" : ""
}"
data-type="pole" title="Pole">
<span class="node-icon">⬆</span>
<span class="node-label">Pole</span>
</button>
<button type="button" class="node-type-btn ${
obj.data.nodeType === "end_connection" ? "active" : ""
}"
data-type="end_connection" title="End Connection">
<span class="node-icon">⬤</span>
<span class="node-label">End</span>
</button>
</div>
<!-- Number -->
<label>Number:</label>
<input type="text" id="objNumber" value="${obj.data.number}">
<!-- Type Description -->
<label>Type:</label>
<input type="text" id="objBoxPoleType" value="${
obj.data.boxPoleType
}" placeholder="Enter type description">
<!-- Power Settings -->
<label>3-Phase Consumers (7kW each):</label>
<input type="number" id="objConsumers3Phase" value="${
obj.data.consumers3Phase || 0
}" min="0">
<label>1-Phase Consumers (3kW each):</label>
<input type="number" id="objConsumers1Phase" value="${
obj.data.consumers1Phase || 0
}" min="0">
<label>Custom Power (kW):</label>
<input type="number" id="objCustomPowerKW" value="${
obj.data.customPowerKW || 0
}" step="0.1" min="0">
<!-- Description -->
<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>
<p><strong>Total Power:</strong> ${this.calculateNodeTotalPower(obj)} kW</p>
<p><strong>3-Phase:</strong> ${obj.data.consumers3Phase || 0} consumers (${
(obj.data.consumers3Phase || 0) * 7
}kW)</p>
<p><strong>1-Phase:</strong> ${obj.data.consumers1Phase || 0} consumers (${
(obj.data.consumers1Phase || 0) * 3
}kW)</p>
<p><strong>Custom:</strong> ${obj.data.customPowerKW || 0} 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;
});
// Add event listeners for node type buttons
document.querySelectorAll(".node-type-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
const newType = e.currentTarget.dataset.type;
obj.data.nodeType = newType;
// Update active button styling
document
.querySelectorAll(".node-type-btn")
.forEach((b) => b.classList.remove("active"));
e.currentTarget.classList.add("active");
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
});
// Add consumer and power listeners for all node types
const consumers3PhaseField =
document.getElementById("objConsumers3Phase");
if (consumers3PhaseField) {
consumers3PhaseField.addEventListener("input", (e) => {
obj.data.consumers3Phase = parseInt(e.target.value) || 0;
this.updatePropertiesPanel(); // Refresh to update calculated power display
this.render(); // Re-render to update visual representation
});
}
const consumers1PhaseField =
document.getElementById("objConsumers1Phase");
if (consumers1PhaseField) {
consumers1PhaseField.addEventListener("input", (e) => {
obj.data.consumers1Phase = parseInt(e.target.value) || 0;
this.updatePropertiesPanel(); // Refresh to update calculated power display
this.render(); // Re-render to update visual representation
});
}
const customPowerField = document.getElementById("objCustomPowerKW");
if (customPowerField) {
customPowerField.addEventListener("input", (e) => {
obj.data.customPowerKW = parseFloat(e.target.value) || 0;
this.updatePropertiesPanel(); // Refresh to update calculated power display
this.render(); // Re-render to update visual representation
});
}
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";
// Calculate electrical properties for display
const hasElectricalData =
conn.data.sectionCurrent !== null &&
conn.data.sectionCurrent !== undefined;
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.number || conn.from.data.name} (${
conn.from.type === "triangle"
? "Transformer"
: this.getNodeDisplayType(conn.from.data.nodeType)
})</p>
<p><strong>To:</strong> ${conn.to.data.number || conn.to.data.name} (${
conn.to.type === "triangle"
? "Transformer"
: this.getNodeDisplayType(conn.to.data.nodeType)
})</p>
<p><strong>Specifications:</strong> ${conn.data.crossSection}mm² × ${
conn.data.length
}m</p>
${
hasElectricalData
? `
<p><strong>Section Resistance:</strong> ${
Math.round(conn.data.sectionResistance * 1000) / 1000
} Ω</p>
<p><strong>Section Current:</strong> ${
Math.round(conn.data.sectionCurrent * 10) / 10
} A (per phase)</p>
<p><strong>Voltage Drop:</strong> ${
Math.round(conn.data.voltageDrop * 1000) / 1000
} V</p>
<p><strong>Total Consumers:</strong> ${this.getTotalConsumers(conn.to)}</p>
<p><strong>Diversity Factor:</strong> ${(
this.getDiversityFactor(this.getTotalConsumers(conn.to)) * 100
).toFixed(0)}%</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-primary" onclick="designer.updateCalculations()">Calculate Voltage Drops</button>
<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) {
let tooltip = document.getElementById("tooltip");
// If tooltip doesn't exist or is not properly positioned, recreate it
if (!tooltip) {
tooltip = document.createElement("div");
tooltip.id = "tooltip";
tooltip.className = "tooltip";
document.body.appendChild(tooltip);
}
if (obj) {
tooltip.style.display = "block";
// Convert canvas coordinates to viewport coordinates
const canvasRect = this.canvas.getBoundingClientRect();
const viewportX = canvasRect.left + screenX;
const viewportY = canvasRect.top + screenY;
// Position tooltip with some offset and ensure it doesn't go off screen
const leftPos = Math.min(viewportX + 15, window.innerWidth - 200);
const topPos = Math.max(viewportY - 35, 10);
// Force all positioning styles
tooltip.style.position = "fixed";
tooltip.style.left = leftPos + "px";
tooltip.style.top = topPos + "px";
tooltip.style.zIndex = "999999";
tooltip.style.pointerEvents = "none";
tooltip.style.background = "rgba(0, 0, 0, 0.9)";
tooltip.style.color = "white";
tooltip.style.padding = "0.5rem";
tooltip.style.borderRadius = "4px";
tooltip.style.fontSize = "0.8rem";
tooltip.textContent = `${obj.data.number || obj.data.name || "Object"} (${
obj.type === "triangle" ? "Transformer" : "Node"
})`;
} 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";
// Convert canvas coordinates to viewport coordinates
const canvasRect = this.canvas.getBoundingClientRect();
const viewportX = canvasRect.left + screenX;
const viewportY = canvasRect.top + screenY;
// Position tooltip with some offset and ensure it doesn't go off screen
const leftPos = Math.min(viewportX + 15, window.innerWidth - 200);
const topPos = Math.max(viewportY - 35, 10);
// Force all positioning styles
tooltip.style.position = "fixed";
tooltip.style.left = leftPos + "px";
tooltip.style.top = topPos + "px";
tooltip.style.zIndex = "999999";
tooltip.style.pointerEvents = "none";
tooltip.style.background = "rgba(0, 0, 0, 0.9)";
tooltip.style.color = "white";
tooltip.style.padding = "0.5rem";
tooltip.style.borderRadius = "4px";
tooltip.style.fontSize = "0.8rem";
tooltip.textContent = `${connection.data.label || "Connection"} (${
connection.from.data.number || connection.from.data.name || "From"
}${connection.to.data.number || connection.to.data.name || "To"})`;
} 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 = this.calculateNodeTotalPower(node);
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 += this.calculateNodeTotalPower(toNode);
downstreamPower += calculateRecursive(toNode);
}
return downstreamPower;
};
totalPower += calculateRecursive(node);
return totalPower;
}
// Calculate total power for a node based on consumers and custom power
calculateNodeTotalPower(node) {
if (node.type === "triangle") {
return 0; // Transformers don't have power consumption
}
const power3Phase = (node.data.consumers3Phase || 0) * 7; // 7kW per 3-phase consumer
const power1Phase = (node.data.consumers1Phase || 0) * 3; // 3kW per 1-phase consumer
const customPower = node.data.customPowerKW || 0;
return power3Phase + power1Phase + customPower;
}
// Calculate voltage drop for the entire system
calculateVoltageDrops() {
// Reset all voltage calculations
this.objects.forEach((obj) => {
if (obj.data) {
obj.data.voltage = null;
obj.data.voltageDrop = null;
}
});
this.connections.forEach((conn) => {
if (conn.data) {
conn.data.sectionResistance = null;
conn.data.sectionCurrent = null;
conn.data.voltageDrop = null;
}
});
// Find transformer and set its voltage to 230V base
// Note: All calculations use 230V as reference voltage (Polish LV standard)
// regardless of transformer's rated bottom voltage
const transformer = this.objects.find((obj) => obj.type === "triangle");
if (!transformer) return;
transformer.data.voltage = 230; // Start calculations from 230V base
transformer.data.voltageDrop = 0; // No drop at transformer level for 230V base
// Calculate voltage drops working from transformer outward
this.calculateVoltageDropRecursive(transformer);
}
calculateVoltageDropRecursive(node) {
// Process all outgoing connections from this node
for (const connection of node.connections.outputs) {
const toNode = connection.to;
// Calculate current flowing through this section
const sectionCurrent = this.calculateSectionCurrent(toNode);
// Calculate section resistance = length / (crossSection * 35)
const sectionResistance =
connection.data.length / (connection.data.crossSection * 35);
// Calculate voltage drop = resistance * current
const voltageDrop = sectionResistance * sectionCurrent;
// Store calculation results in connection data
connection.data.sectionResistance = sectionResistance;
connection.data.sectionCurrent = sectionCurrent;
connection.data.voltageDrop = voltageDrop;
// Calculate voltage at destination node
const sourceVoltage = node.data.voltage || 230; // Always use 230V as base
toNode.data.voltage = sourceVoltage - voltageDrop;
toNode.data.voltageDrop = 230 - toNode.data.voltage; // Total drop from base 230V
// Continue recursively to downstream nodes
this.calculateVoltageDropRecursive(toNode);
}
}
calculateSectionCurrent(node) {
// Calculate total current flowing to this node and all downstream nodes
let totalPower = this.calculateDownstreamPower(node);
// Apply diversity factor based on total number of consumers
const totalConsumers = this.getTotalConsumers(node);
const diversityFactor = this.getDiversityFactor(totalConsumers);
const adjustedPower = totalPower * diversityFactor;
// Calculate current per phase using single-phase approach: I = P / (3 * 230V)
// This gives current per phase in a 3-phase system
const current = (adjustedPower * 1000) / (3 * 230);
return current;
}
// Get diversity factor based on total number of consumers
getDiversityFactor(totalConsumers) {
// Diversity factor lookup table - based on electrical engineering standards
const diversityFactors = {
1: 1.0,
2: 0.59,
3: 0.45,
4: 0.38,
5: 0.34,
6: 0.31,
7: 0.29,
8: 0.27,
9: 0.26,
10: 0.25,
11: 0.232,
12: 0.217,
13: 0.208,
14: 0.193,
15: 0.183,
16: 0.175,
17: 0.168,
18: 0.161,
19: 0.155,
20: 0.15,
21: 0.145,
22: 0.141,
23: 0.137,
24: 0.133,
25: 0.13,
26: 0.127,
27: 0.124,
28: 0.121,
29: 0.119,
30: 0.117,
31: 0.115,
32: 0.113,
33: 0.111,
34: 0.109,
35: 0.107,
36: 0.105,
37: 0.104,
38: 0.103,
39: 0.101,
40: 0.1,
};
// Return the specific factor or default to 0.1 (10%) for >40 consumers
return diversityFactors[totalConsumers] || 0.1;
}
// Calculate total number of consumers downstream from a node
getTotalConsumers(node) {
let totalConsumers = 0;
const visited = new Set();
const countRecursive = (currentNode) => {
if (visited.has(currentNode.id)) return 0;
visited.add(currentNode.id);
// Count consumers at this node
let consumers = 0;
if (currentNode.data.consumers3Phase)
consumers += currentNode.data.consumers3Phase;
if (currentNode.data.consumers1Phase)
consumers += Math.ceil(currentNode.data.consumers1Phase / 3); // Convert 1-phase to equivalent 3-phase units
// Count downstream consumers
for (const connection of currentNode.connections.outputs) {
consumers += countRecursive(connection.to);
}
return consumers;
};
totalConsumers = countRecursive(node);
return totalConsumers;
}
// Get voltage drop percentage for a node
getVoltageDropPercentage(node) {
if (!node.data.voltage) return 0;
const baseVoltage = 230; // Base voltage for drop calculations
const actualVoltage = node.data.voltage;
return ((baseVoltage - actualVoltage) / baseVoltage) * 100;
}
// Check if voltage drop exceeds acceptable limits
isVoltageDropExcessive(node) {
const dropPercentage = this.getVoltageDropPercentage(node);
return dropPercentage > 10; // 10% is the limit for 230V (23V)
}
// Update calculations and trigger re-render
updateCalculations() {
this.calculateVoltageDrops();
this.render();
}
// Simple Auto-Arrange Tool - One Button to Make Everything Tidy
getSelectedObjects() {
// For now, we'll work with all objects if none specifically selected
// Later this could be enhanced to work with multi-selection
return this.objects.filter((obj) => obj === this.selectedObject);
}
getAllObjects() {
return this.objects;
}
alignObjects(direction) {
const objectsToAlign = this.getSelectedObjects();
// If no specific selection, work with all objects
if (objectsToAlign.length === 0) {
if (this.objects.length < 2) {
this.showError("Need at least 2 objects to align");
return;
}
// Work with all objects
this.alignAllObjects(direction);
} else if (objectsToAlign.length < 2) {
this.showError("Select at least 2 objects to align");
return;
} else {
this.alignSelectedObjects(objectsToAlign, direction);
}
this.render();
}
alignAllObjects(direction) {
const objects = this.objects;
if (objects.length < 2) return;
// Calculate bounds
const bounds = this.calculateBounds(objects);
objects.forEach((obj) => {
switch (direction) {
case "left":
obj.x = bounds.minX;
break;
case "center":
obj.x = bounds.centerX - obj.width / 2;
break;
case "right":
obj.x = bounds.maxX - obj.width;
break;
case "top":
obj.y = bounds.minY;
break;
case "middle":
obj.y = bounds.centerY - obj.height / 2;
break;
case "bottom":
obj.y = bounds.maxY - obj.height;
break;
}
// Snap to grid
obj.x = Math.round(obj.x / this.gridSize) * this.gridSize;
obj.y = Math.round(obj.y / this.gridSize) * this.gridSize;
});
}
alignSelectedObjects(objects, direction) {
if (objects.length < 2) return;
// Use first object as reference
const reference = objects[0];
objects.slice(1).forEach((obj) => {
switch (direction) {
case "left":
obj.x = reference.x;
break;
case "center":
obj.x = reference.x + reference.width / 2 - obj.width / 2;
break;
case "right":
obj.x = reference.x + reference.width - obj.width;
break;
case "top":
obj.y = reference.y;
break;
case "middle":
obj.y = reference.y + reference.height / 2 - obj.height / 2;
break;
case "bottom":
obj.y = reference.y + reference.height - obj.height;
break;
}
// Snap to grid
obj.x = Math.round(obj.x / this.gridSize) * this.gridSize;
obj.y = Math.round(obj.y / this.gridSize) * this.gridSize;
});
}
distributeObjects(direction) {
const objects = this.objects;
if (objects.length < 3) {
this.showError("Need at least 3 objects to distribute");
return;
}
// Sort objects by position
const sortedObjects = [...objects].sort((a, b) => {
if (direction === "horizontal") {
return a.x - b.x;
} else {
return a.y - b.y;
}
});
const first = sortedObjects[0];
const last = sortedObjects[sortedObjects.length - 1];
if (direction === "horizontal") {
const totalSpace = last.x + last.width - first.x;
const objectsWidth = sortedObjects.reduce(
(sum, obj) => sum + obj.width,
0
);
const availableSpace = totalSpace - objectsWidth;
const spacing = availableSpace / (sortedObjects.length - 1);
let currentX = first.x + first.width;
for (let i = 1; i < sortedObjects.length - 1; i++) {
sortedObjects[i].x = currentX + spacing;
currentX = sortedObjects[i].x + sortedObjects[i].width;
// Snap to grid
sortedObjects[i].x =
Math.round(sortedObjects[i].x / this.gridSize) * this.gridSize;
}
} else {
const totalSpace = last.y + last.height - first.y;
const objectsHeight = sortedObjects.reduce(
(sum, obj) => sum + obj.height,
0
);
const availableSpace = totalSpace - objectsHeight;
const spacing = availableSpace / (sortedObjects.length - 1);
let currentY = first.y + first.height;
for (let i = 1; i < sortedObjects.length - 1; i++) {
sortedObjects[i].y = currentY + spacing;
currentY = sortedObjects[i].y + sortedObjects[i].height;
// Snap to grid
sortedObjects[i].y =
Math.round(sortedObjects[i].y / this.gridSize) * this.gridSize;
}
}
this.render();
}
autoArrangeObjects() {
if (this.objects.length === 0) return;
// Find transformer (should be at the top)
const transformer = this.objects.find((obj) => obj.type === "triangle");
if (!transformer) {
this.showError("No transformer found for auto-arrangement");
return;
}
// Position transformer at left center (for LEFT TO RIGHT flow)
transformer.x = Math.round(80 / this.gridSize) * this.gridSize;
transformer.y =
Math.round(
(this.canvas.height / 2 - transformer.height / 2) / this.gridSize
) * this.gridSize;
// Get all nodes (non-transformer objects)
const nodes = this.objects.filter((obj) => obj.type !== "triangle");
if (nodes.length === 0) {
this.render();
return;
}
// Arrange nodes in a tree-like structure based on connections
this.arrangeNodesInTree(transformer, nodes);
this.render();
}
arrangeNodesInTree(transformer, nodes) {
const arranged = new Set();
const levels = [];
// Level 0: directly connected to transformer
let currentLevel = [];
transformer.connections.outputs.forEach((conn) => {
if (nodes.includes(conn.to)) {
currentLevel.push(conn.to);
arranged.add(conn.to);
}
});
if (currentLevel.length > 0) {
levels.push(currentLevel);
}
// Build subsequent levels
while (currentLevel.length > 0) {
const nextLevel = [];
currentLevel.forEach((node) => {
node.connections.outputs.forEach((conn) => {
if (nodes.includes(conn.to) && !arranged.has(conn.to)) {
nextLevel.push(conn.to);
arranged.add(conn.to);
}
});
});
if (nextLevel.length > 0) {
levels.push(nextLevel);
currentLevel = nextLevel;
} else {
break;
}
}
// Add any remaining unconnected nodes to the last level
const unconnected = nodes.filter((node) => !arranged.has(node));
if (unconnected.length > 0) {
if (levels.length === 0) {
levels.push(unconnected);
} else {
levels[levels.length - 1].push(...unconnected);
}
}
// Position nodes level by level - LEFT TO RIGHT layout
const levelSpacing = 200; // Horizontal spacing between columns
const minNodeSpacing = 100; // Minimum vertical spacing between nodes
levels.forEach((level, levelIndex) => {
// Calculate X position - all nodes in same level have same X (horizontally aligned)
const x =
transformer.x + transformer.width + 50 + levelIndex * levelSpacing;
// Calculate vertical positioning for this level
const nodeCount = level.length;
if (nodeCount === 1) {
// Single node - center it vertically with transformer
const y = transformer.y + transformer.height / 2 - level[0].height / 2;
level[0].y = Math.round(y / this.gridSize) * this.gridSize;
level[0].x = Math.round(x / this.gridSize) * this.gridSize;
} else {
// Multiple nodes - distribute vertically across canvas height
const canvasHeight = this.canvas.height;
const margin = 60;
const availableHeight = canvasHeight - 2 * margin;
// Calculate spacing to use full height effectively
const nodeSpacing = Math.max(
minNodeSpacing,
availableHeight / (nodeCount + 1)
);
const totalUsedHeight = (nodeCount - 1) * nodeSpacing;
const startY = margin + (availableHeight - totalUsedHeight) / 2;
// Position each node in this level
level.forEach((node, nodeIndex) => {
// All nodes in same level have same X coordinate (horizontally aligned)
node.x = Math.round(x / this.gridSize) * this.gridSize;
// Distribute nodes vertically across available height
const y = startY + nodeIndex * nodeSpacing;
node.y = Math.round(y / this.gridSize) * this.gridSize;
});
}
});
// Optimize the tree layout to prevent overlaps and improve readability
this.optimizeTreeLayout(transformer, levels);
}
// Optimize the tree layout to prevent overlaps and improve readability
optimizeTreeLayout(transformer, levels) {
levels.forEach((level, levelIndex) => {
// Ensure nodes don't overlap horizontally with previous level
if (levelIndex > 0) {
const prevLevel = levels[levelIndex - 1];
const prevMaxX = Math.max(
...prevLevel.map((node) => node.x + node.width)
);
const currentMinX = Math.min(...level.map((node) => node.x));
if (currentMinX - prevMaxX < 40) {
const adjustment = 40 - (currentMinX - prevMaxX);
level.forEach((node) => {
node.x =
Math.round((node.x + adjustment) / this.gridSize) * this.gridSize;
});
}
}
// Ensure no nodes go off the top or bottom edge
level.forEach((node) => {
const minY = 20;
const maxY = this.canvas.height - node.height - 20;
if (node.y < minY) {
node.y = Math.round(minY / this.gridSize) * this.gridSize;
} else if (node.y > maxY) {
node.y = Math.round(maxY / this.gridSize) * this.gridSize;
}
});
});
}
calculateBounds(objects) {
if (objects.length === 0) return null;
let minX = objects[0].x;
let maxX = objects[0].x + objects[0].width;
let minY = objects[0].y;
let maxY = objects[0].y + objects[0].height;
objects.forEach((obj) => {
minX = Math.min(minX, obj.x);
maxX = Math.max(maxX, obj.x + obj.width);
minY = Math.min(minY, obj.y);
maxY = Math.max(maxY, obj.y + obj.height);
});
return {
minX,
maxX,
minY,
maxY,
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
width: maxX - minX,
height: maxY - minY,
};
}
}
// Initialize the application
const designer = new ObjectFlowDesigner();