/**
* @module Application/Services
* @description
* ⚡ **Application Layer - Circuit Services**
*
* Application services that orchestrate domain operations and provide the main API
* for circuit manipulation. These services act as the primary interface between
* the GUI layer and the domain logic.
*/
import { EventEmitter } from "../utils/EventEmitter.js";
import { Circuit } from "../domain/aggregates/Circuit.js";
import { Element } from "../domain/entities/Element.js";
import { generateId } from "../utils/idGenerator.js";
import { ElementRegistry } from "../config/registry.js";
import { Position } from "../domain/valueObjects/Position.js";
import { Properties } from "../domain/valueObjects/Properties.js";
import { Label } from "../domain/valueObjects/Label.js";
import { Logger } from "../utils/Logger.js";
/**
* @class CircuitService
* @extends EventEmitter
* @description
* **🔧 Primary Extension API - Circuit Operations**
*
* CircuitService is the main application service that developers will interact with
* to programmatically manipulate circuits. It provides a clean, event-driven API
* for all circuit operations while maintaining domain integrity.
*
* **Key Features:**
* - Event-driven architecture with real-time GUI updates
* - Domain validation and business rule enforcement
* - Element lifecycle management (add, update, delete)
* - Circuit state serialization and persistence
* - Undo/redo support through state management
*
* **Events Emitted:**
* - `elementAdded`: When a new element is added
* - `elementDeleted`: When an element is removed
* - `elementUpdated`: When element properties change
* - `update`: General circuit state change notification
*
* @example
* // Basic circuit manipulation
* const service = new CircuitService(new Circuit());
*
* // Listen for changes
* service.on('elementAdded', (element) => {
* console.log(`Added ${element.type} ${element.id}`);
* });
*
* // Add elements
* const resistor = new Resistor('R1', [pos1, pos2], null, new Properties({resistance: 1000}));
* service.addElement(resistor);
*
* @example
* // Advanced usage with properties
* service.updateElementProperties('R1', {
* resistance: 2200,
* label: 'Main Resistor'
* });
*
* const serialized = service.exportState();
* service.importState(serialized);
*/
export class CircuitService extends EventEmitter {
/**
* Constructs a new CircuitService.
*
* @param {Circuit} circuit - The circuit aggregate to manage.
*/
constructor(circuit, elementRegistry) {
super(); // Extend EventEmitter functionality
/**
* The circuit aggregate representing the current circuit design.
* @type {Circuit}
* @type {ElementRegistry}
*/
this.circuit = circuit;
this.elementRegistry = elementRegistry;
this.on("commandExecuted", (event) => {
if (event.type === "addElement") {
try {
const elementFactory = this.elementRegistry.get(event.elementType);
if (!elementFactory) {
throw new Error(
` No factory registered for element type: ${event.elementType}`,
);
}
// Ensure event.nodes is an array of Position instances
if (!Array.isArray(event.nodes)) {
throw new Error(" Nodes must be provided as an array.");
}
// We translate node payloads from the event into Position instances
const nodes = event.nodes.map((node) => new Position(node.x, node.y)); // Convert to Position instances
// We translate properties payloads into instances of Propertiies
const properties = event.properties
? new Properties(event.properties)
: new Properties(); // Convert to Properties instance
// Correctly call the factory function
const newElement = elementFactory(
undefined, // Auto-generate ID
nodes, // Correct nodes
null, // Label (default to null)
properties, // Properties (default to empty object)
);
this.addElement(newElement);
} catch (error) {
Logger.error(`Error creating element: ${error.message}`);
}
}
});
}
/**
* Adds an element to the circuit after validation.
*
* Delegates validation to the Circuit aggregate to ensure that the element
* adheres to all circuit-level rules, such as uniqueness of element ID and
* non-conflicting node positions.
*
* Emits an **"update" event** after successfully adding the element.
*
* @param {Element} element - The element to add.
* @throws {Error} If the element violates circuit rules.
*/
addElement(element) {
// Generate an ID if the element does not already have one
if (!element.id) {
const prefix = element.type.charAt(0).toUpperCase(); // e.g., "R" for Resistor
element.id = generateId(prefix);
}
this.circuit.validateAddElement(element); // Delegate validation to Circuit
this.circuit.elements.push(element); // Add the element to the circuit
// Notify subscribers (GUI, renderers) about the update
this.emit("update", { type: "addElement", element });
}
/**
* Deletes an element from the circuit.
*
* Removes the element from the list of elements and updates any connections
* involving the deleted element.
*
* Emits an **"update" event** after successfully deleting the element.
*
* @param {string} elementId - The unique ID of the element to delete.
*/
deleteElement(elementId) {
const element = this.circuit.elements.find((el) => el.id === elementId);
if (!element) {
return;
}
// Remove the element from the circuit
this.circuit.elements = this.circuit.elements.filter(
(el) => el.id !== elementId,
);
// Update connections after deletion
for (const [key, connectedElements] of this.circuit.connections.entries()) {
const updatedConnections = connectedElements.filter(
(el) => el.id !== elementId,
);
if (updatedConnections.length === 0) {
this.circuit.connections.delete(key); // Remove empty connections
} else {
this.circuit.connections.set(key, updatedConnections); // Update connections
}
}
// Notify subscribers about the update
this.emit("update", { type: "deleteElement", elementId });
}
/**
* Connects two elements in the circuit if the connection is valid.
*
* Delegates validation to the Circuit aggregate and establishes the connection
* if the rules are met.
*
* Emits an **"update" event** after successfully connecting the elements.
*
* @param {Element} element1 - The first element to connect.
* @param {Element} element2 - The second element to connect.
* @throws {Error} If the connection violates circuit rules.
*/
connectElements(element1, element2) {
this.circuit.validateConnection(element1, element2); // Delegate to Circuit
this.circuit.connections.set(element1.id, [
...(this.circuit.connections.get(element1.id) || []),
element2,
]);
this.circuit.connections.set(element2.id, [
...(this.circuit.connections.get(element2.id) || []),
element1,
]);
// Notify subscribers about the update
this.emit("update", {
type: "connectElements",
elements: [element1, element2],
});
}
/**
* Finds all elements connected to a given element.
*
* Searches through the circuit's connections map to identify and return
* all elements that share a connection with the specified element.
*
* @param {Element} element - The element whose connections to find.
* @returns {Element[]} List of connected elements.
*/
findConnections(element) {
const connectedElements = [];
for (const [key, elements] of this.circuit.connections.entries()) {
if (elements.includes(element)) {
connectedElements.push(...elements.filter((el) => el !== element));
}
}
return connectedElements;
}
/**
* Retrieves all elements in the circuit.
*
* This is a simple delegate method to provide read-only access
* to the elements of the circuit aggregate.
*
* @returns {Element[]} The list of elements in the circuit.
*/
getElements() {
return [...this.circuit.elements]; // Return a shallow copy to avoid direct modification
}
/**
* Gets a specific element by its ID.
*
* @param {string} elementId - The ID of the element to find.
* @returns {Element|null} The element with the given ID, or null if not found.
*/
getElementByID(elementId) {
return this.circuit.elements.find(el => el.id === elementId) || null;
}
/**
* Updates properties and label of an existing element through the service.
* This maintains proper aggregate boundary and ensures state consistency.
*
* @param {string} elementId - The ID of the element to update.
* @param {Object} newProperties - Object containing new property values and label.
* @returns {boolean} True if element was found and updated, false otherwise.
*/
updateElementProperties(elementId, newProperties) {
try {
const element = this.getElementByID(elementId);
if (!element) {
console.error(`Element with ID ${elementId} not found`);
return false;
}
// Update the label if provided
if (newProperties.label !== undefined) {
if (newProperties.label === null || newProperties.label === '') {
element.label = null;
} else {
element.label = new Label(newProperties.label);
}
}
// Update other properties
Object.keys(newProperties).forEach(key => {
if (key !== 'label') {
element.getProperties().updateProperty(key, newProperties[key]);
}
});
// Emit update to trigger canvas re-render
this.emit("update", { type: "updateElementProperties", elementId, newProperties });
return true;
} catch (error) {
console.error('Error updating element properties:', error);
return false;
}
} /**
* Serializes the entire state of the circuit for undo/redo or persistence.
*
* @returns {string} A JSON string representing the circuit state.
*/
exportState() {
return JSON.stringify({
elements: this.circuit.elements.map((el) => ({
id: el.id,
type: el.type,
label: el.label ? el.label.value : null, // Export label value, not Label object
nodes: el.nodes.map((pos) => ({ x: pos.x, y: pos.y })),
properties: { ...el.properties.values }, // flatten properties
}))
});
}
/**
* Restores the state of the circuit from a previously exported snapshot.
*
* @param {string} snapshot - A JSON string created by exportState().
*/
importState(snapshot) {
const data = JSON.parse(snapshot);
// Reset circuit state
this.circuit.elements = [];
// Reconstruct elements
const elementsById = {};
for (const elData of data.elements) {
// Direct lookup - element.type matches registry key (both lowercase)
const factory = this.elementRegistry.get(elData.type);
if (!factory) throw new Error(`No factory for type ${elData.type}`);
const nodes = elData.nodes.map((n) => new Position(n.x, n.y));
// Safely flatten nested "values" if present
const rawProps = elData.properties ?? {};
const cleanProps =
rawProps &&
typeof rawProps.values === "object" &&
rawProps.values !== null
? rawProps.values
: rawProps;
const properties = new Properties(cleanProps);
// Create Label object from label string if present
const labelObj = elData.label ? new Label(elData.label) : null;
const el = factory(elData.id, nodes, labelObj, properties);
elementsById[el.id] = el;
this.circuit.elements.push(el);
}
this.emit("update", { type: "restoredFromSnapshot" });
}
/**
* Rotates a group of elements around the center of their bounding box.
*
* @param {string[]} elementIds - Array of element IDs to rotate.
* @param {number} rotationAngleDegrees - The rotation angle in degrees (90, 180, 270, etc.).
*/
rotateElements(elementIds, rotationAngleDegrees) {
if (!elementIds || elementIds.length === 0) {
console.warn("No elements provided for rotation");
return;
}
const elements = elementIds.map(id =>
this.circuit.elements.find(el => el.id === id)
).filter(el => el !== undefined);
if (elements.length === 0) {
console.warn("No valid elements found for rotation");
return;
}
// Calculate the bounding box of all selected elements
const boundingBox = this.calculateBoundingBox(elements);
// Calculate the center of the bounding box
const centerX = (boundingBox.minX + boundingBox.maxX) / 2;
const centerY = (boundingBox.minY + boundingBox.maxY) / 2;
// Convert rotation angle to radians
const rotationAngle = (rotationAngleDegrees * Math.PI) / 180;
// Rotate all nodes of all elements around the bounding box center
elements.forEach(element => {
element.nodes.forEach(node => {
// Translate node to origin (relative to bounding box center)
const relativeX = node.x - centerX;
const relativeY = node.y - centerY;
// Apply rotation matrix
const cos = Math.cos(rotationAngle);
const sin = Math.sin(rotationAngle);
const rotatedX = relativeX * cos - relativeY * sin;
const rotatedY = relativeX * sin + relativeY * cos;
// Translate back to absolute coordinates
node.x = centerX + rotatedX;
node.y = centerY + rotatedY;
});
// Update the element's orientation property (for elements that track orientation)
const currentOrientation = element.properties?.values?.orientation || 0;
const newOrientation = (currentOrientation + rotationAngleDegrees) % 360;
if (element.properties && element.properties.updateProperty) {
element.properties.updateProperty('orientation', newOrientation);
}
});
// Emit update to trigger immediate re-render
this.emit("update", { type: "rotateElements", elementIds, rotationAngleDegrees, centerX, centerY });
}
/**
* Calculates the bounding box for a group of elements.
*
* @param {Element[]} elements - Array of elements to calculate bounding box for.
* @returns {Object} Bounding box with minX, minY, maxX, maxY properties.
*/
calculateBoundingBox(elements) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
elements.forEach(element => {
element.nodes.forEach(node => {
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x);
maxY = Math.max(maxY, node.y);
});
});
return { minX, minY, maxX, maxY };
}
/**
* Rotates an element to a new orientation.
*
* @param {string} elementId - The unique identifier of the element to rotate.
* @param {number} newOrientation - The new orientation (0, 90, 180, or 270 degrees).
*/
rotateElement(elementId, newOrientation) {
const element = this.circuit.elements.find(el => el.id === elementId);
if (!element) return;
// For single element rotation, rotate around first node
const currentOrientation = element?.properties?.values?.orientation || 0;
const rotationAngle = newOrientation - currentOrientation;
// Calculate rotation in radians
const rotationAngleRad = (rotationAngle * Math.PI) / 180;
// Use first node as rotation center for single element rotation
const centerX = element.nodes[0].x;
const centerY = element.nodes[0].y;
// Rotate all nodes around the first node
element.nodes.forEach(node => {
// Translate node to origin (relative to first node)
const relativeX = node.x - centerX;
const relativeY = node.y - centerY;
// Apply rotation matrix
const cos = Math.cos(rotationAngleRad);
const sin = Math.sin(rotationAngleRad);
const rotatedX = relativeX * cos - relativeY * sin;
const rotatedY = relativeX * sin + relativeY * cos;
// Translate back to absolute coordinates
node.x = centerX + rotatedX;
node.y = centerY + rotatedY;
});
// Update orientation property
if (element.properties && element.properties.updateProperty) {
element.properties.updateProperty('orientation', newOrientation);
}
// Emit update to trigger immediate re-render
this.emit("update", { type: "rotateElement", elementId, rotationAngle });
}
/**
* Moves an element to a new position.
*
* @param {string} elementId - The unique identifier of the element to move.
* @param {Position} newPosition - The new position for the reference terminal.
*/
moveElement(elementId, newPosition) {
const element = this.circuit.elements.find(el => el.id === elementId);
if (!element) {
console.warn(`Element with ID ${elementId} not found for move`);
return;
}
// Import ElementService dynamically to avoid circular dependencies
import('./ElementService.js').then(({ ElementService }) => {
ElementService.move(element, newPosition);
this.emit("update", { type: "moveElement", elementId, newPosition });
}).catch(error => {
console.error("Error importing ElementService for move:", error);
});
}
}