// Polish translation system const translations = { en: { // UI Elements "Power System Designer": "Power System Designer", "Calculate Voltage Drops": "Calculate Voltage Drops", "Clear All": "Clear All", "Export Data": "Export Data", "Import Data": "Import Data", "Objects": "Objects", "Transformer": "Transformer", "Cable Box": "Cable Box", "Pole": "Pole", "End Connection": "End Connection", "Cables": "Cables", "Underground Cable": "Underground Cable", "Overhead Cable": "Overhead Cable", "Tools": "Tools", "Select Mode": "Select Mode", "Delete Mode": "Delete Mode", "Auto Arrange - One Click to Make Everything Look Nice and Tidy": "Auto Arrange - One Click to Make Everything Look Nice and Tidy", "Properties": "Properties", "No object selected": "No object selected", // Object Properties "Transformer Properties": "Transformer Properties", "Number:": "Number:", "Name:": "Name:", "Upper Voltage (V):": "Upper Voltage (V):", "Bottom Voltage (V):": "Bottom Voltage (V):", "Power (kVA):": "Power (kVA):", "Description:": "Description:", "Transformer Info": "Transformer Info", "Type:": "Type:", "ID:": "ID:", "Position:": "Position:", "Ratio:": "Ratio:", "Outputs:": "Outputs:", "Actions": "Actions", "Delete Transformer": "Delete Transformer", // Node Properties "Node Properties": "Node Properties", "Box": "Box", "End": "End", "Enter type description": "Enter type description", "3-Phase Consumers (7kW each):": "3-Phase Consumers (7kW each):", "1-Phase Consumers (3kW each):": "1-Phase Consumers (3kW each):", "Custom Power (kW):": "Custom Power (kW):", "Node Info": "Node Info", "Total Power:": "Total Power:", "3-Phase:": "3-Phase:", "consumers": "consumers", "1-Phase:": "1-Phase:", "Custom:": "Custom:", "Inputs:": "Inputs:", "Delete Node": "Delete Node", // Cable Properties "Power Cable Properties": "Power Cable Properties", "Label:": "Label:", "Cable Type:": "Cable Type:", "YAKY (Ground Cable)": "YAKY (Ground Cable)", "NA2XY-J (Ground Cable)": "NA2XY-J (Ground Cable)", "AL (Overhead Cable)": "AL (Overhead Cable)", "AsXSn (Overhead Cable)": "AsXSn (Overhead Cable)", "Cross Section (mm²):": "Cross Section (mm²):", "Length (m):": "Length (m):", "Cable Info": "Cable Info", "From:": "From:", "To:": "To:", "Specifications:": "Specifications:", "Section Resistance:": "Section Resistance:", "Section Current:": "Section Current:", "Voltage Drop:": "Voltage Drop:", "Total Consumers:": "Total Consumers:", "Diversity Factor:": "Diversity Factor:", "⚠️ Overhead cables can only connect to poles or end connections!": "⚠️ Overhead cables can only connect to poles or end connections!", "Delete Cable": "Delete Cable", // Error Messages "Cannot connect object to itself!": "Cannot connect object to itself!", "Connection already exists!": "Connection already exists!", "Transformer can only have one outgoing connection!": "Transformer can only have one outgoing connection!", "Node can only have one incoming connection!": "Node can only have one incoming connection!", "End connections cannot have outgoing connections!": "End connections cannot have outgoing connections!", "Cannot connect from end connections!": "Cannot connect from end connections!", "Cannot connect from node to transformer!": "Cannot connect from node to transformer!", "Failed to import file: Invalid format": "Failed to import file: Invalid format", "Need at least 2 objects to align": "Need at least 2 objects to align", "Select at least 2 objects to align": "Select at least 2 objects to align", "Need at least 3 objects to distribute": "Need at least 3 objects to distribute", "No transformer found for auto-arrangement": "No transformer found for auto-arrangement", // Confirmation Messages "Are you sure you want to clear all objects and connections?": "Are you sure you want to clear all objects and connections?", // Cable Names "Cable": "Cable", // Node Types Display "Ground": "Ground", "Overhead": "Overhead", "Node": "Node" }, pl: { // UI Elements "Power System Designer": "Analiza sieci nN", "Calculate Voltage Drops": "Oblicz Spadki Napięcia", "Clear All": "Wyczyść Wszystko", "Export Data": "Eksportuj Dane", "Import Data": "Importuj Dane", "Objects": "Obiekty", "Transformer": "Transformator", "Cable Box": "Złącze Kablowe", "Pole": "Słup", "End Connection": "Odbiorca (napowietrzny)", "Cables": "Kable", "Underground Cable": "Kabel Ziemny", "Overhead Cable": "Kabel Napowietrzny", "Tools": "Narzędzia", "Select Mode": "Tryb Wyboru", "Delete Mode": "Tryb Usuwania", "Auto Arrange - One Click to Make Everything Look Nice and Tidy": "Auto Rozmieszczenie", "Properties": "Właściwości", "No object selected": "Nie wybrano obiektu", // Object Properties "Transformer Properties": "Właściwości Transformatora", "Number:": "Numer:", "Name:": "Nazwa:", "Upper Voltage (V):": "Napięcie Górne (V):", "Bottom Voltage (V):": "Napięcie Dolne (V):", "Power (kVA):": "Moc (kVA):", "Description:": "Opis:", "Transformer Info": "Informacje o Transformatorze", "Type:": "Typ:", "ID:": "ID:", "Position:": "Pozycja:", "Ratio:": "Przekładnia:", "Outputs:": "Wyjścia:", "Actions": "Akcje", "Delete Transformer": "Usuń Transformator", // Node Properties "Node Properties": "Właściwości Węzła", "Box": "Złącze Kablowe", "End": "Koniec", "Enter type description": "Wprowadź opis typu", "3-Phase Consumers (7kW each):": "Odbiorcy 3-fazowi (7kW każdy):", "1-Phase Consumers (3kW each):": "Odbiorcy 1-fazowi (3kW każdy):", "Custom Power (kW):": "Niestandardowa Moc (kW):", "Node Info": "Informacje o Węźle", "Total Power:": "Moc Całkowita:", "3-Phase:": "3-fazowe:", "consumers": "odbiorców", "1-Phase:": "1-fazowe:", "Custom:": "Niestandardowe:", "Inputs:": "Wejścia:", "Delete Node": "Usuń Węzeł", // Cable Properties "Power Cable Properties": "Właściwości Kabla Zasilającego", "Label:": "Etykieta:", "Cable Type:": "Typ Kabla:", "YAKY (Ground Cable)": "YAKY", "NA2XY-J (Ground Cable)": "NA2XY-J", "AL (Overhead Cable)": "AL", "AsXSn (Overhead Cable)": "AsXSn", "Cross Section (mm²):": "Przekrój (mm²):", "Length (m):": "Długość (m):", "Cable Info": "Informacje o Kablu", "From:": "Od:", "To:": "Do:", "Specifications:": "Specyfikacje:", "Section Resistance:": "Rezystancja Odcinka:", "Section Current:": "Prąd Odcinka:", "Voltage Drop:": "Spadek Napięcia:", "Total Consumers:": "Łączna Liczba Odbiorców:", "Diversity Factor:": "Współczynnik Jednoczesności:", "⚠️ Overhead cables can only connect to poles or end connections!": "Kable napowietrzne mogą łączyć się tylko ze słupami lub końcowymi połączeniami!", "Delete Cable": "Usuń Kabel", // Error Messages "Cannot connect object to itself!": "Nie można połączyć obiektu z samym sobą!", "Connection already exists!": "Połączenie już istnieje!", "Transformer can only have one outgoing connection!": "Transformator może mieć tylko jedno połączenie wychodzące!", "Node can only have one incoming connection!": "Węzeł może mieć tylko jedno połączenie przychodzące!", "End connections cannot have outgoing connections!": "Końcowe połączenia nie mogą mieć połączeń wychodzących!", "Cannot connect from end connections!": "Nie można łączyć z końcowych połączeń!", "Cannot connect from node to transformer!": "Nie można łączyć z węzła do transformatora!", "Failed to import file: Invalid format": "Błąd importu pliku: Nieprawidłowy format", "Need at least 2 objects to align": "Potrzeba co najmniej 2 obiektów do wyrównania", "Select at least 2 objects to align": "Wybierz co najmniej 2 obiekty do wyrównania", "Need at least 3 objects to distribute": "Potrzeba co najmniej 3 obiektów do rozłożenia", "No transformer found for auto-arrangement": "Nie znaleziono transformatora do automatycznego rozmieszczenia", // Confirmation Messages "Are you sure you want to clear all objects and connections?": "Czy na pewno chcesz wyczyścić wszystkie obiekty i połączenia?", // Cable Names "Cable": "Kabel", // Node Types Display "Ground": "Podziemny", "Overhead": "Napowietrzny", "Node": "Węzeł" } }; // Current language setting let currentLanguage = 'pl'; // Default to Polish // Translation function function t(key) { return translations[currentLanguage][key] || translations.en[key] || key; } // Translate static HTML UI elements function translateStaticUI() { // Header and buttons const header = document.querySelector(".header h1"); if (header) header.textContent = t("Power System Designer"); const calculateBtn = document.getElementById("calculateBtn"); if (calculateBtn) calculateBtn.textContent = t("Calculate Voltage Drops"); const clearBtn = document.getElementById("clearBtn"); if (clearBtn) clearBtn.textContent = t("Clear All"); const exportBtn = document.getElementById("exportBtn"); if (exportBtn) exportBtn.textContent = t("Export Data"); const importBtn = document.getElementById("importBtn"); if (importBtn) importBtn.textContent = t("Import Data"); // Toolbar labels document.querySelectorAll(".toolbar-label").forEach((el) => { if (el.textContent.trim() === "Objects") el.textContent = t("Objects"); if (el.textContent.trim() === "Cables") el.textContent = t("Cables"); if (el.textContent.trim() === "Tools") el.textContent = t("Tools"); }); // Icon tooltips document.querySelectorAll(".icon-btn[data-type]").forEach((btn) => { const type = btn.getAttribute("data-type"); if (type === "triangle") btn.title = t("Transformer"); if (type === "box") btn.title = t("Cable Box"); if (type === "pole") btn.title = t("Pole"); if (type === "end") btn.title = t("End Connection"); if (type === "underground") btn.title = t("Underground Cable"); if (type === "overhead") btn.title = t("Overhead Cable"); }); // Tool buttons tooltips const selectBtn = document.getElementById("selectBtn"); if (selectBtn) selectBtn.title = t("Select Mode"); const deleteBtn = document.getElementById("deleteBtn"); if (deleteBtn) deleteBtn.title = t("Delete Mode"); const autoArrangeBtn = document.getElementById("autoArrangeBtn"); if (autoArrangeBtn) autoArrangeBtn.title = t("Auto Arrange - One Click to Make Everything Look Nice and Tidy"); // Properties panel const propertiesPanel = document.querySelector(".properties-panel h3"); if (propertiesPanel) propertiesPanel.textContent = t("Properties"); } // Call translation on DOMContentLoaded window.addEventListener("DOMContentLoaded", translateStaticUI); 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 = `

${t("No object selected")}

`; } } updateObjectProperties(panel) { const obj = this.selectedObject; if (obj.type === "triangle") { // Transformer properties panel.innerHTML = `

${t("Transformer Properties")}

${t("Transformer Info")}

${t("Type:")} ${t("Transformer")}

${t("ID:")} ${obj.id}

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

${t("Ratio:")} ${obj.data.upperVoltage}V / ${obj.data.bottomVoltage}V

${t("Outputs:")} ${obj.connections.outputs.length}/1

${t("Actions")}

`; // 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 = `

${t("Node Properties")}

${t("Node Info")}

${t("Type:")} ${obj.data.nodeType === "cable_box" ? t("Cable Box") : obj.data.nodeType === "pole" ? t("Pole") : t("End Connection")}

${t("ID:")} ${obj.id}

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

${t("Total Power:")} ${this.calculateNodeTotalPower(obj)} kW

${t("3-Phase:")} ${obj.data.consumers3Phase || 0} ${t("consumers")} (${(obj.data.consumers3Phase || 0) * 7}kW)

${t("1-Phase:")} ${obj.data.consumers1Phase || 0} ${t("consumers")} (${(obj.data.consumers1Phase || 0) * 3}kW)

${t("Custom:")} ${obj.data.customPowerKW || 0} kW

${t("Inputs:")} ${obj.connections.inputs.length}${obj.data.nodeType === "end_connection" ? "" : "/1"}

${t("Outputs:")} ${obj.connections.outputs.length}${obj.data.nodeType === "end_connection" ? "/0" : ""}

${t("Actions")}

`; // 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"; const hasElectricalData = conn.data.sectionCurrent !== null && conn.data.sectionCurrent !== undefined; panel.innerHTML = `

${t("Power Cable Properties")}

${t("Cable Info")}

${t("ID:")} ${conn.id}

${t("Type:")} ${conn.data.cableType} ${isOverheadCable ? `(${t("Overhead")})` : `(${t("Ground")})`}

${t("From:")} ${conn.from.data.number || conn.from.data.name} (${conn.from.type === "triangle" ? t("Transformer") : this.getNodeDisplayType(conn.from.data.nodeType)})

${t("To:")} ${conn.to.data.number || conn.to.data.name} (${conn.to.type === "triangle" ? t("Transformer") : this.getNodeDisplayType(conn.to.data.nodeType)})

${t("Specifications:")} ${conn.data.crossSection}mm² × ${conn.data.length}m

${hasElectricalData ? `

${t("Section Resistance:")} ${Math.round(conn.data.sectionResistance * 1000) / 1000} Ω

${t("Section Current:")} ${Math.round(conn.data.sectionCurrent * 10) / 10} A (per phase)

${t("Voltage Drop:")} ${Math.round(conn.data.voltageDrop * 1000) / 1000} V

${t("Total Consumers:")} ${this.getTotalConsumers(conn.to)}

${t("Diversity Factor:")} ${(this.getDiversityFactor(this.getTotalConsumers(conn.to)) * 100).toFixed(0)}%

` : ""} ${isOverheadCable ? `

${t("⚠️ Overhead cables can only connect to poles or end connections!")}

` : ""}

${t("Actions")}

`; // Add event listeners for property changes document.getElementById("connLabel").addEventListener("input", (e) => { conn.data.label = e.target.value; this.render(); // Re-render to show label changes }); document.getElementById("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 = t(message); document.body.appendChild(error); setTimeout(() => { error.remove(); }, 3000); } clearAll() { if (confirm(t("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();