2457 lines
74 KiB
JavaScript
2457 lines
74 KiB
JavaScript
// 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 = `<p class="no-selection">${t("No object selected")}</p>`;
|
||
}
|
||
}
|
||
|
||
updateObjectProperties(panel) {
|
||
const obj = this.selectedObject;
|
||
|
||
if (obj.type === "triangle") {
|
||
// Transformer properties
|
||
panel.innerHTML = `
|
||
<div class="property-group">
|
||
<h4>${t("Transformer Properties")}</h4>
|
||
<label>${t("Number:")}</label>
|
||
<input type="text" id="objNumber" value="${obj.data.number}">
|
||
<label>${t("Name:")}</label>
|
||
<input type="text" id="objName" value="${obj.data.name}">
|
||
<label>${t("Upper Voltage (V):")}</label>
|
||
<input type="number" id="objUpperVoltage" value="${obj.data.upperVoltage}">
|
||
<label>${t("Bottom Voltage (V):")}</label>
|
||
<input type="number" id="objBottomVoltage" value="${obj.data.bottomVoltage}">
|
||
<label>${t("Power (kVA):")}</label>
|
||
<input type="number" id="objPowerKVA" value="${obj.data.powerKVA}">
|
||
<label>${t("Description:")}</label>
|
||
<textarea id="objDescription">${obj.data.description}</textarea>
|
||
</div>
|
||
<div class="property-group">
|
||
<h4>${t("Transformer Info")}</h4>
|
||
<p><strong>${t("Type:")}</strong> ${t("Transformer")}</p>
|
||
<p><strong>${t("ID:")}</strong> ${obj.id}</p>
|
||
<p><strong>${t("Position:")}</strong> (${Math.round(obj.x)}, ${Math.round(obj.y)})</p>
|
||
<p><strong>${t("Ratio:")}</strong> ${obj.data.upperVoltage}V / ${obj.data.bottomVoltage}V</p>
|
||
<p><strong>${t("Outputs:")}</strong> ${obj.connections.outputs.length}/1</p>
|
||
</div>
|
||
<div class="property-group">
|
||
<h4>${t("Actions")}</h4>
|
||
<button class="btn btn-danger" onclick="designer.deleteObject(${obj.id})">${t("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>${t("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="${t("Cable Box")}">
|
||
<span class="node-icon">⬛</span>
|
||
<span class="node-label">${t("Box")}</span>
|
||
</button>
|
||
<button type="button" class="node-type-btn ${obj.data.nodeType === "pole" ? "active" : ""}" data-type="pole" title="${t("Pole")}">
|
||
<span class="node-icon">⬆</span>
|
||
<span class="node-label">${t("Pole")}</span>
|
||
</button>
|
||
<button type="button" class="node-type-btn ${obj.data.nodeType === "end_connection" ? "active" : ""}" data-type="end_connection" title="${t("End Connection")}">
|
||
<span class="node-icon">⬤</span>
|
||
<span class="node-label">${t("End")}</span>
|
||
</button>
|
||
</div>
|
||
<label>${t("Number:")}</label>
|
||
<input type="text" id="objNumber" value="${obj.data.number}">
|
||
<label>${t("Type:")}</label>
|
||
<input type="text" id="objBoxPoleType" value="${obj.data.boxPoleType}" placeholder="${t("Enter type description")}">
|
||
<label>${t("3-Phase Consumers (7kW each):")}</label>
|
||
<input type="number" id="objConsumers3Phase" value="${obj.data.consumers3Phase || 0}" min="0">
|
||
<label>${t("1-Phase Consumers (3kW each):")}</label>
|
||
<input type="number" id="objConsumers1Phase" value="${obj.data.consumers1Phase || 0}" min="0">
|
||
<label>${t("Custom Power (kW):")}</label>
|
||
<input type="number" id="objCustomPowerKW" value="${obj.data.customPowerKW || 0}" step="0.1" min="0">
|
||
<label>${t("Description:")}</label>
|
||
<textarea id="objDescription">${obj.data.description}</textarea>
|
||
</div>
|
||
<div class="property-group">
|
||
<h4>${t("Node Info")}</h4>
|
||
<p><strong>${t("Type:")}</strong> ${obj.data.nodeType === "cable_box" ? t("Cable Box") : obj.data.nodeType === "pole" ? t("Pole") : t("End Connection")}</p>
|
||
<p><strong>${t("ID:")}</strong> ${obj.id}</p>
|
||
<p><strong>${t("Position:")}</strong> (${Math.round(obj.x)}, ${Math.round(obj.y)})</p>
|
||
<p><strong>${t("Total Power:")}</strong> ${this.calculateNodeTotalPower(obj)} kW</p>
|
||
<p><strong>${t("3-Phase:")}</strong> ${obj.data.consumers3Phase || 0} ${t("consumers")} (${(obj.data.consumers3Phase || 0) * 7}kW)</p>
|
||
<p><strong>${t("1-Phase:")}</strong> ${obj.data.consumers1Phase || 0} ${t("consumers")} (${(obj.data.consumers1Phase || 0) * 3}kW)</p>
|
||
<p><strong>${t("Custom:")}</strong> ${obj.data.customPowerKW || 0} kW</p>
|
||
<p><strong>${t("Inputs:")}</strong> ${obj.connections.inputs.length}${obj.data.nodeType === "end_connection" ? "" : "/1"}</p>
|
||
<p><strong>${t("Outputs:")}</strong> ${obj.connections.outputs.length}${obj.data.nodeType === "end_connection" ? "/0" : ""}</p>
|
||
</div>
|
||
<div class="property-group">
|
||
<h4>${t("Actions")}</h4>
|
||
<button class="btn btn-danger" onclick="designer.deleteObject(${obj.id})">${t("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";
|
||
const hasElectricalData = conn.data.sectionCurrent !== null && conn.data.sectionCurrent !== undefined;
|
||
panel.innerHTML = `
|
||
<div class="property-group">
|
||
<h4>${t("Power Cable Properties")}</h4>
|
||
<label>${t("Label:")}</label>
|
||
<input type="text" id="connLabel" value="${conn.data.label}">
|
||
<label>${t("Cable Type:")}</label>
|
||
<select id="connCableType">
|
||
<option value="YAKY" ${conn.data.cableType === "YAKY" ? "selected" : ""}>${t("YAKY (Ground Cable)")}</option>
|
||
<option value="NA2XY-J" ${conn.data.cableType === "NA2XY-J" ? "selected" : ""}>${t("NA2XY-J (Ground Cable)")}</option>
|
||
<option value="AL" ${conn.data.cableType === "AL" ? "selected" : ""}>${t("AL (Overhead Cable)")}</option>
|
||
<option value="AsXSn" ${conn.data.cableType === "AsXSn" ? "selected" : ""}>${t("AsXSn (Overhead Cable)")}</option>
|
||
</select>
|
||
<label>${t("Cross Section (mm²):")}</label>
|
||
<input type="number" id="connCrossSection" value="${conn.data.crossSection}" min="1">
|
||
<label>${t("Length (m):")}</label>
|
||
<input type="number" id="connLength" value="${conn.data.length}" min="0.1" step="0.1">
|
||
<label>${t("Description:")}</label>
|
||
<textarea id="connDescription">${conn.data.description}</textarea>
|
||
</div>
|
||
<div class="property-group">
|
||
<h4>${t("Cable Info")}</h4>
|
||
<p><strong>${t("ID:")}</strong> ${conn.id}</p>
|
||
<p><strong>${t("Type:")}</strong> ${conn.data.cableType} ${isOverheadCable ? `(${t("Overhead")})` : `(${t("Ground")})`}</p>
|
||
<p><strong>${t("From:")}</strong> ${conn.from.data.number || conn.from.data.name} (${conn.from.type === "triangle" ? t("Transformer") : this.getNodeDisplayType(conn.from.data.nodeType)})</p>
|
||
<p><strong>${t("To:")}</strong> ${conn.to.data.number || conn.to.data.name} (${conn.to.type === "triangle" ? t("Transformer") : this.getNodeDisplayType(conn.to.data.nodeType)})</p>
|
||
<p><strong>${t("Specifications:")}</strong> ${conn.data.crossSection}mm² × ${conn.data.length}m</p>
|
||
${hasElectricalData ? `<p><strong>${t("Section Resistance:")}</strong> ${Math.round(conn.data.sectionResistance * 1000) / 1000} Ω</p><p><strong>${t("Section Current:")}</strong> ${Math.round(conn.data.sectionCurrent * 10) / 10} A (per phase)</p><p><strong>${t("Voltage Drop:")}</strong> ${Math.round(conn.data.voltageDrop * 1000) / 1000} V</p><p><strong>${t("Total Consumers:")}</strong> ${this.getTotalConsumers(conn.to)}</p><p><strong>${t("Diversity Factor:")}</strong> ${(this.getDiversityFactor(this.getTotalConsumers(conn.to)) * 100).toFixed(0)}%</p>` : ""}
|
||
${isOverheadCable ? `<p class="warning"><strong>${t("⚠️ Overhead cables can only connect to poles or end connections!")}</strong></p>` : ""}
|
||
</div>
|
||
<div class="property-group">
|
||
<h4>${t("Actions")}</h4>
|
||
<button class="btn btn-primary" onclick="designer.updateCalculations()">${t("Calculate Voltage Drops")}</button>
|
||
<button class="btn btn-danger" onclick="designer.deleteConnection(${conn.id})">${t("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 = 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();
|