Files
elcalc/script.js

1691 lines
46 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"));
// 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));
// Window resize
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;
}
// 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 = "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";
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) {
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;
}
}
// Initialize the application
const designer = new ObjectFlowDesigner();