using RobotNet10.MapEditor.Services.API; using RobotNet10.MapEditor.Shared.DTOs.Edge; using RobotNet10.MapEditor.Shared.DTOs.Layout; using RobotNet10.MapEditor.Shared.DTOs.Node; using RobotNet10.MapEditor.Shared.DTOs.Requests; using RobotNet10.MapEditor.Shared.DTOs.Station; using RobotNet10.MapEditor.Shared.DTOs.VehicleType; namespace RobotNet10.MapEditor.Services.State; /// /// Editor modes for the LayoutEditor canvas /// public enum EditorMode { Select, Scanner, CreateEdge1Way, CreateEdge2Way, Copy, Move, TrajectoryEditor, Pan } /// /// Viewport state for SVG canvas /// public class ViewportState { public double ViewBoxX { get; set; } public double ViewBoxY { get; set; } public double ViewBoxWidth { get; set; } public double ViewBoxHeight { get; set; } public double ZoomLevel { get; set; } = 1.0; public string ToViewBoxString() => $"{ViewBoxX:F2} {ViewBoxY:F2} {ViewBoxWidth:F2} {ViewBoxHeight:F2}"; } /// /// Command for Undo/Redo operations /// public abstract class EditorCommand { public abstract void Execute(); public abstract void Undo(); } /// /// Move command for nodes /// public class MoveNodesCommand( List nodes, Dictionary oldPositions, Dictionary newPositions, List edges, Dictionary> oldCpPositions, Dictionary> newCPPositions) : EditorCommand { public Guid Id { get; set; } = Guid.NewGuid(); private readonly List _nodes = [.. nodes]; private readonly Dictionary _oldPositions = new(oldPositions); private readonly Dictionary _newPositions = new(newPositions); private readonly List _edges = edges; private readonly Dictionary> _oldCpPositions = new(oldCpPositions); private readonly Dictionary> _newCPPositions = new(newCPPositions); public override void Execute() { foreach (var node in _nodes) { if (_newPositions.TryGetValue(node.Id, out var pos)) { node.X = pos.X; node.Y = pos.Y; } } foreach (var edge in _edges) { if (edge.VehicleProperties is null) continue; if (_newCPPositions.TryGetValue(edge.Id, out var vehicles)) { foreach (var vehicle in edge.VehicleProperties) { if (vehicles.TryGetValue(vehicle.Id, out var pos)) { if(vehicle.TrajectoryControlPoint1X.HasValue && pos.CP1X.HasValue) vehicle.TrajectoryControlPoint1X = pos.CP1X; if (vehicle.TrajectoryControlPoint1Y.HasValue && pos.CP1Y.HasValue) vehicle.TrajectoryControlPoint1Y = pos.CP1Y; if (vehicle.TrajectoryControlPoint2X.HasValue && pos.CP2X.HasValue) vehicle.TrajectoryControlPoint2X = pos.CP2X; if (vehicle.TrajectoryControlPoint2Y.HasValue && pos.CP2Y.HasValue) vehicle.TrajectoryControlPoint2Y = pos.CP2Y; } } } } } /// /// Get list of node IDs affected by this command /// public List GetAffectedNodeIds() => [.. _nodes.Select(n => n.Id)]; /// /// Get list of node IDs affected by this command /// public List GetAffectedEdgeIds() => [.. _edges.Select(n => n.Id)]; public override void Undo() { // Restore edge positions foreach (var edge in _edges) { if (edge.VehicleProperties is null) continue; if (_oldCpPositions.TryGetValue(edge.Id, out var vehicles)) { foreach (var vehicle in edge.VehicleProperties) { if (vehicles.TryGetValue(vehicle.Id, out var pos)) { if (vehicle.TrajectoryControlPoint1X.HasValue && pos.CP1X.HasValue) vehicle.TrajectoryControlPoint1X = pos.CP1X; if (vehicle.TrajectoryControlPoint1Y.HasValue && pos.CP1Y.HasValue) vehicle.TrajectoryControlPoint1Y = pos.CP1Y; if (vehicle.TrajectoryControlPoint2X.HasValue && pos.CP2X.HasValue) vehicle.TrajectoryControlPoint2X = pos.CP2X; if (vehicle.TrajectoryControlPoint2Y.HasValue && pos.CP2Y.HasValue) vehicle.TrajectoryControlPoint2Y = pos.CP2Y; } } } } // Restore node positions foreach (var node in _nodes) { if (_oldPositions.TryGetValue(node.Id, out var pos)) { node.X = pos.X; node.Y = pos.Y; } } } } /// /// State management for LayoutEditor page /// public class LayoutEditorState(MapManagerApiService apiService) { // ===== DATA ===== public Guid LevelId { get; private set; } public LayoutLevelDto? Level { get; set; } public List Nodes { get; private set; } = []; public List Edges { get; private set; } = []; public List Stations { get; private set; } = []; public List VehicleTypes { get; private set; } = []; public byte[]? BackgroundImage { get; private set; } // ===== SELECTION ===== public HashSet SelectedNodeIds { get; } = []; public HashSet SelectedEdgeIds { get; } = []; public Guid? SelectedVehicleTypeId { get; set; } // ===== EDITOR MODE ===== public EditorMode Mode { get; set; } = EditorMode.Select; // ===== DISPLAY OPTIONS ===== public bool ShowEdgeNames { get; set; } = false; public bool ShowNodeNames { get; set; } = false; public bool ShowGrid { get; set; } = true; public bool ShowBackgroundImage { get; set; } = true; public double GridSpacing { get; set; } = 1.0; // meters // ===== VIEWPORT ===== public ViewportState Viewport { get; } = new(); // ===== UNDO/REDO ===== public Stack UndoStack { get; } = new(); public Stack RedoStack { get; } = new(); public bool CanUndo => UndoStack.Count > 0; public bool CanRedo => RedoStack.Count > 0; // ===== DIRTY FLAG ===== public bool HasUnsavedChanges { get; private set; } // ===== CHANGE TRACKING ===== private readonly HashSet _modifiedNodeIds = []; private readonly HashSet _modifiedEdgeIds = []; // ===== UI STATE ===== public bool IsLoading { get; private set; } public bool IsSaving { get; private set; } public string? ErrorMessage { get; set; } public bool IsReadOnly { get; private set; } // ===== EDIT EDGE STATE ===== public EdgeVehiclePropertyDto? EdgeVehicleEditor { get; set; } // ===== COPY STATE ===== public EditorMode? PreviousModeBeforeCopy { get; set; } // Store mode before entering Copy mode public double? CopyStartX { get; set; } // World X coordinate when copy drag starts public double? CopyStartY { get; set; } // World Y coordinate when copy drag starts public double? CopyOffsetX { get; set; } // Current offset X during drag public double? CopyOffsetY { get; set; } // Current offset Y during drag public List? CopySourceNodes { get; set; } // Nodes to be copied public List? CopySourceEdges { get; set; } // Edges to be copied public bool IsDraggingNodes = false; public Guid? DraggedNodeId; public (double X, double Y)? DragStartWorld; // World coordinates when drag started public Dictionary DragNodesOriginalPositions = []; public Dictionary> DragEdgesOriginalPositions = []; // ===== EVENTS ===== public event Action? OnStateChanged; public event Action? OnTrajectoryChanged; public event Action? OnDraggingNodesChanged; public event Action? OnDragingEdgesChanged; public event Action? OnCreateCopyChanged; // ===== DEPENDENCIES ===== private readonly MapManagerApiService _apiService = apiService; /// /// Initialize editor with a specific level /// public async Task InitializeAsync(Guid levelId) { LevelId = levelId; IsLoading = true; ErrorMessage = null; NotifyStateChanged(); try { // Load level info Level = await _apiService.GetLevelAsync(levelId); if (Level == null) { ErrorMessage = "Level not found"; IsLoading = false; NotifyStateChanged(); return; } // Check if layout/version is active (read-only mode) await CheckReadOnlyStatusAsync(); // Load layout data (nodes, edges, stations) var layoutData = await _apiService.GetLayoutDataAsync(levelId); Nodes = layoutData.Nodes?.ToList() ?? []; Edges = layoutData.Edges?.ToList() ?? []; Stations = layoutData.Stations?.ToList() ?? []; // Load background image try { BackgroundImage = await _apiService.GetLayoutImageAsync(levelId); } catch { BackgroundImage = null; } // TODO: Load vehicle types (need endpoint) VehicleTypes = await _apiService.GetVehicleTypesAsync(); // Initialize viewport InitializeViewport(); // Clear selection and history ClearSelection(); UndoStack.Clear(); RedoStack.Clear(); HasUnsavedChanges = false; } catch (Exception ex) { ErrorMessage = $"Failed to load layout: {ex.Message}"; } IsLoading = false; NotifyStateChanged(); } /// /// Check if layout/version is active (read-only mode) /// private async Task CheckReadOnlyStatusAsync() { if (Level == null) return; try { // Get version info var version = await _apiService.GetVersionAsync(Level.VersionId); if (version == null) return; // Check if version is active if (version.IsActive) { IsReadOnly = true; ErrorMessage = "This layout version is currently Active. Active layouts cannot be edited. Please deactivate the layout version first."; return; } // Get layout info var layout = await _apiService.GetLayoutAsync(version.LayoutId); if (layout == null) return; // Check if layout is active if (layout.IsActive) { IsReadOnly = true; ErrorMessage = "This layout is currently Active. Active layouts cannot be edited. Please deactivate the layout first."; return; } // Layout is editable IsReadOnly = false; } catch { // If we can't check status, allow editing (fail open) IsReadOnly = false; // Don't set ErrorMessage here, let the user proceed } } /// /// Reload stations from API /// public async Task ReloadStationsAsync() { try { var layoutData = await _apiService.GetLayoutDataAsync(LevelId); Stations = layoutData.Stations?.ToList() ?? []; NotifyStateChanged(); } catch (Exception ex) { ErrorMessage = $"Failed to reload stations: {ex.Message}"; NotifyStateChanged(); } } /// /// Initialize viewport to fit the image bounds /// private void InitializeViewport() { if (Level?.EditorSettings != null) { var settings = Level.EditorSettings; // Calculate physical dimensions double physicalWidth = (settings.ImageWidth ?? 1000) * settings.Resolution; double physicalHeight = (settings.ImageHeight ?? 500) * settings.Resolution; // Set viewport to fit image Viewport.ViewBoxX = 0; Viewport.ViewBoxY = 0; Viewport.ViewBoxWidth = physicalWidth; Viewport.ViewBoxHeight = physicalHeight; Viewport.ZoomLevel = 1.0; } else { // Default viewport Viewport.ViewBoxX = 0; Viewport.ViewBoxY = 0; Viewport.ViewBoxWidth = 50; Viewport.ViewBoxHeight = 30; Viewport.ZoomLevel = 1.0; } } // ========================================== // SELECTION // ========================================== /// /// Select a single node (clears previous selection) /// public void SelectNode(Guid nodeId, bool addToSelection = false) { if (!addToSelection) { SelectedNodeIds.Clear(); SelectedEdgeIds.Clear(); } SelectedNodeIds.Add(nodeId); NotifyStateChanged(); } /// /// Select a single edge (clears previous selection) /// public void SelectEdge(Guid edgeId, bool addToSelection = false) { if (SelectedEdgeIds.Any(eId => eId == edgeId)) return; if (!addToSelection) { SelectedNodeIds.Clear(); SelectedEdgeIds.Clear(); EdgeVehicleEditor = null; } SelectedEdgeIds.Add(edgeId); NotifyStateChanged(); } /// /// Select multiple nodes (box select) /// public void SelectNodes(IEnumerable nodeIds, bool addToSelection = false) { if (!addToSelection) { SelectedNodeIds.Clear(); SelectedEdgeIds.Clear(); } foreach (var id in nodeIds) { SelectedNodeIds.Add(id); } NotifyStateChanged(); } /// /// Clear all selection /// public void ClearSelection() { SelectedNodeIds.Clear(); SelectedEdgeIds.Clear(); NotifyStateChanged(); } /// /// Get selected nodes /// public List GetSelectedNodes() => [.. Nodes.Where(n => SelectedNodeIds.Contains(n.Id))]; /// /// Get selected edges /// public List GetSelectedEdges() => [.. Edges.Where(e => SelectedEdgeIds.Contains(e.Id))]; // ========================================== // VIEWPORT OPERATIONS // ========================================== /// /// Pan the viewport by delta (in SVG units) /// public void Pan(double deltaX, double deltaY) { Viewport.ViewBoxX += deltaX; Viewport.ViewBoxY += deltaY; NotifyStateChanged(); } /// /// Zoom the viewport around a point (in SVG/world coordinates) - for mouse wheel /// Keeps the point at svgCenterX, svgCenterY fixed in world space /// public void Zoom(double factor, double svgCenterX, double svgCenterY) { // Limit zoom level var newZoom = Viewport.ZoomLevel * factor; if (newZoom < 0.1 || newZoom > 10) return; // svgCenterX, svgCenterY are already in world coordinates (from screenToSvg) // Calculate the ratio of the cursor position within the current viewBox var ratioX = (svgCenterX - Viewport.ViewBoxX) / Viewport.ViewBoxWidth; var ratioY = (svgCenterY - Viewport.ViewBoxY) / Viewport.ViewBoxHeight; // New dimensions after zoom var newWidth = Viewport.ViewBoxWidth / factor; var newHeight = Viewport.ViewBoxHeight / factor; // Adjust ViewBox position so the cursor point stays at the same world position // The cursor point in world: svgCenterX = ViewBoxX + ratioX * ViewBoxWidth // After zoom: svgCenterX = ViewBoxX_new + ratioX * newWidth // Therefore: ViewBoxX_new = svgCenterX - ratioX * newWidth Viewport.ViewBoxX = svgCenterX - ratioX * newWidth; Viewport.ViewBoxY = svgCenterY - ratioY * newHeight; Viewport.ViewBoxWidth = newWidth; Viewport.ViewBoxHeight = newHeight; Viewport.ZoomLevel = newZoom; NotifyStateChanged(); } /// /// Zoom in/out centered at the viewport center (for zoom buttons) /// public void ZoomAtCenter(double factor) { // Limit zoom level var newZoom = Viewport.ZoomLevel * factor; if (newZoom < 0.1 || newZoom > 10) return; // Calculate center of current viewport var centerX = Viewport.ViewBoxWidth / 2; var centerY = Viewport.ViewBoxHeight / 2; // New dimensions after zoom var newWidth = Viewport.ViewBoxWidth / factor; var newHeight = Viewport.ViewBoxHeight / factor; // Adjust viewBox to keep the same center point Viewport.ViewBoxX += centerX * (1 - 1 / factor); Viewport.ViewBoxY += centerY * (1 - 1 / factor); Viewport.ViewBoxWidth = newWidth; Viewport.ViewBoxHeight = newHeight; Viewport.ZoomLevel = newZoom; NotifyStateChanged(); } /// /// Fit viewport to image bounds /// public void FitToScreen() { InitializeViewport(); NotifyStateChanged(); } /// /// Get physical dimensions of the layout /// public (double Width, double Height) GetPhysicalDimensions() { if (Level?.EditorSettings == null) return (50, 30); var settings = Level.EditorSettings; return ( (settings.ImageWidth ?? 1000) * settings.Resolution, (settings.ImageHeight ?? 500) * settings.Resolution ); } // ========================================== // COORDINATE TRANSFORM // ========================================== /// /// Transform world coordinates (layout) to SVG coordinates /// Layout: Origin bottom-left, Y up (data from backend) /// SVG: Origin top-left, Y down (for rendering) /// public (double X, double Y) WorldToSvg(double worldX, double worldY) { var (_, physicalHeight) = GetPhysicalDimensions(); // X stays the same, Y is flipped var originX = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginX : 0; var originY = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginY : 0; return (worldX - originX, physicalHeight - (worldY - originY)); } /// /// Transform SVG coordinates to world coordinates (layout) /// public (double X, double Y) SvgToWorld(double svgX, double svgY) { var (_, physicalHeight) = GetPhysicalDimensions(); // X stays the same, Y is flipped back var originX = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginX : 0; var originY = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginY : 0; return (svgX + originX, physicalHeight - svgY + originY); } // ========================================== // UNDO/REDO // ========================================== /// /// Execute a command and add to undo stack /// public void ExecuteCommand(EditorCommand command) { command.Execute(); // Mark affected nodes/edges as modified for change tracking if (command is MoveNodesCommand moveCommand) { foreach (var nodeId in moveCommand.GetAffectedNodeIds()) { MarkNodeModified(nodeId); } foreach (var edgeId in moveCommand.GetAffectedEdgeIds()) { MarkEdgeModified(edgeId); } } else { // For other commands, mark as unsaved but don't track specific items HasUnsavedChanges = true; } UndoStack.Push(command); RedoStack.Clear(); NotifyStateChanged(); } /// /// Undo last command /// public void Undo() { if (UndoStack.Count == 0) return; var command = UndoStack.Pop(); command.Undo(); RedoStack.Push(command); NotifyStateChanged(); } /// /// Redo last undone command /// public void Redo() { if (RedoStack.Count == 0) return; var command = RedoStack.Pop(); command.Execute(); UndoStack.Push(command); NotifyStateChanged(); } // ========================================== // SAVE // ========================================== /// /// Mark a node as modified (for change tracking) /// public void MarkNodeModified(Guid nodeId) { _modifiedNodeIds.Add(nodeId); HasUnsavedChanges = true; NotifyStateChanged(); } /// /// Mark an edge as modified (for change tracking) /// public void MarkEdgeModified(Guid edgeId) { _modifiedEdgeIds.Add(edgeId); HasUnsavedChanges = true; NotifyStateChanged(); } /// /// Save all changes to server /// public async Task SaveAsync() { if (IsReadOnly) { ErrorMessage = "Cannot save: Layout is read-only (Active)"; NotifyStateChanged(); return; } if (!HasUnsavedChanges && _modifiedNodeIds.Count == 0 && _modifiedEdgeIds.Count == 0) { return; // Nothing to save } IsSaving = true; ErrorMessage = null; NotifyStateChanged(); try { var request = new SaveLayoutDataRequest { LayoutLevelId = LevelId }; // Collect modified nodes foreach (var nodeId in _modifiedNodeIds) { var node = Nodes.FirstOrDefault(n => n.Id == nodeId); if (node != null) { request.Nodes.Add(new NodeUpdateItem { Id = node.Id, X = node.X, Y = node.Y, NodeName = node.NodeName, NodeDescription = node.NodeDescription, MapId = node.MapId, VehicleProperties = node.VehicleProperties?.ToList() }); } } // Collect modified edges foreach (var edgeId in _modifiedEdgeIds) { var edge = Edges.FirstOrDefault(e => e.Id == edgeId); if (edge != null) { request.Edges.Add(new EdgeUpdateItem { Id = edge.Id, EdgeName = edge.EdgeName, EdgeDescription = edge.EdgeDescription, VehicleProperties = edge.VehicleProperties?.ToList() }); } } // Call API var response = await _apiService.SaveLayoutDataAsync(request); if (!response.Success) { ErrorMessage = response.ErrorMessage ?? "Failed to save layout data"; throw new Exception(ErrorMessage); } // Clear change tracking _modifiedNodeIds.Clear(); _modifiedEdgeIds.Clear(); HasUnsavedChanges = false; // Show warnings for skipped items if any if (response.SkippedNodeIds.Count > 0 || response.SkippedEdgeIds.Count > 0) { var skippedMsg = ""; if (response.SkippedNodeIds.Count > 0) skippedMsg += $"{response.SkippedNodeIds.Count} node(s) not found. "; if (response.SkippedEdgeIds.Count > 0) skippedMsg += $"{response.SkippedEdgeIds.Count} edge(s) not found."; } } catch (Exception ex) { ErrorMessage = $"Failed to save: {ex.Message}"; } finally { IsSaving = false; NotifyStateChanged(); } } // ========================================== // CREATE EDGE // ========================================== /// /// Create edge using world coordinates (1-Way or 2-Way) /// API will automatically find or create nodes at these coordinates /// public async Task CreateEdgeAsync(double x1, double y1, double x2, double y2, bool isTwoWay = false) { if (IsReadOnly) { ErrorMessage = "Cannot create edge: Layout is read-only (Active)"; NotifyStateChanged(); return false; } if (Level == null || LevelId == Guid.Empty) { ErrorMessage = "Level not loaded"; NotifyStateChanged(); return false; } try { // Create first edge (start -> end) var request1 = new CreateEdgeRequest { LayoutLevelId = LevelId, X1 = x1, Y1 = y1, X2 = x2, Y2 = y2 }; var edge1 = await _apiService.CreateEdgeAsync(request1); // If 2-Way mode, create reverse edge (end -> start) if (isTwoWay) { var request2 = new CreateEdgeRequest { LayoutLevelId = LevelId, X1 = x2, Y1 = y2, X2 = x1, Y2 = y1 }; var edge2 = await _apiService.CreateEdgeAsync(request2); } // Reload layout data (nodes might have been created, edges added) var layoutData = await _apiService.GetLayoutDataAsync(LevelId); Nodes = layoutData.Nodes?.ToList() ?? Nodes; Edges = layoutData.Edges?.ToList() ?? Edges; // Note: No HasUnsavedChanges = true here because CreateEdgeAsync directly saves to database NotifyStateChanged(); return true; } catch (Exception ex) { ErrorMessage = $"Failed to create edge: {ex.Message}"; NotifyStateChanged(); return false; } } // ========================================== // DELETE OPERATIONS // ========================================== /// /// Delete edges by IDs (nodes will be auto-deleted if orphaned) /// public async Task DeleteEdgesAsync(List edgeIds) { if (IsReadOnly) { ErrorMessage = "Cannot delete edges: Layout is read-only (Active)"; NotifyStateChanged(); return false; } if (edgeIds == null || edgeIds.Count == 0) return false; try { if (edgeIds.Count == 1) { // Single delete await _apiService.DeleteEdgeAsync(edgeIds[0]); } else { // Batch delete await _apiService.DeleteEdgesBatchAsync(edgeIds); } // Reload layout data to get updated nodes and edges (orphan cleanup) var layoutData = await _apiService.GetLayoutDataAsync(LevelId); Nodes = layoutData.Nodes?.ToList() ?? Nodes; Edges = layoutData.Edges?.ToList() ?? Edges; // Remove deleted edges from selection SelectedEdgeIds.RemoveWhere(id => edgeIds.Contains(id)); // Note: No HasUnsavedChanges = true here because DeleteEdgesAsync directly saves to database NotifyStateChanged(); return true; } catch (Exception ex) { ErrorMessage = $"Failed to delete edges: {ex.Message}"; NotifyStateChanged(); return false; } } /// /// Delete selected edges /// public async Task DeleteSelectedEdgesAsync() { if (SelectedEdgeIds.Count == 0) return false; return await DeleteEdgesAsync([.. SelectedEdgeIds]); } // ========================================== // MODE HELPERS // ========================================== /// /// Set editor mode /// public void SetMode(EditorMode mode) { // Reset copy state when changing mode if (mode != EditorMode.Copy) { CancelCopy(); } if (mode == EditorMode.Select && Mode != EditorMode.Select) ClearSelection(); Mode = mode; NotifyStateChanged(); } /// /// Start copy operation - enter Copy mode /// public void StartCopy() { var selectedNodes = GetSelectedNodes(); var selectedEdges = GetSelectedEdges(); if (selectedNodes.Count == 0 && selectedEdges.Count == 0) { return; // Nothing to copy } // Store previous mode PreviousModeBeforeCopy = Mode; // Store source nodes and edges CopySourceNodes = [.. selectedNodes]; CopySourceEdges = [.. selectedEdges]; // Initialize copy state CopyStartX = null; CopyStartY = null; CopyOffsetX = null; CopyOffsetY = null; // Switch to Copy mode Mode = EditorMode.Copy; NotifyCopyChanged(); } /// /// Complete copy operation - create new nodes and edges via API /// public async Task CompleteCopyAsync() { if (IsReadOnly) { ErrorMessage = "Cannot copy: Layout is read-only (Active)"; CancelCopy(); return false; } if (CopySourceNodes == null || CopySourceNodes.Count == 0) { CancelCopy(); return false; } try { if (!CopyOffsetX.HasValue || !CopyOffsetY.HasValue) { CancelCopy(); return false; } var offsetDistance = Math.Sqrt(CopyOffsetX.Value * CopyOffsetX.Value + CopyOffsetY.Value * CopyOffsetY.Value); if (CopyOffsetX.HasValue && CopyOffsetY.HasValue && offsetDistance < Level?.EditorSettings?.EdgeMinLengthCreate) { ErrorMessage = $"Cannot copy nodes: Offset Distance ({offsetDistance:F3}m) is less than minimum required ({Level?.EditorSettings?.EdgeMinLengthCreate:F3}m)"; CancelCopy(); return false; } // Validate and filter nodes and edges before sending to server var selectedNodeIds = CopySourceNodes.Select(n => n.Id).ToHashSet(); // Filter edges: only keep edges where both StartNodeId and EndNodeId are in selected nodes var validEdges = CopySourceEdges? .Where(e => selectedNodeIds.Contains(e.StartNodeId) && selectedNodeIds.Contains(e.EndNodeId)) .ToList() ?? []; // Filter nodes: only keep nodes that have at least one valid edge connected var validNodeIds = new HashSet(); foreach (var edge in validEdges) { validNodeIds.Add(edge.StartNodeId); validNodeIds.Add(edge.EndNodeId); } var validNodes = CopySourceNodes .Where(n => validNodeIds.Contains(n.Id)) .ToList(); // If no valid nodes after filtering, cancel if (validNodes.Count == 0) { ErrorMessage = "No valid nodes to copy. Nodes must have at least one selected edge."; CancelCopy(); return false; } // Prepare request with validated node IDs and edge IDs var request = new CopyNodesRequest { LayoutLevelId = LevelId, NodeIds = [.. validNodes.Select(n => n.Id)], EdgeIds = [.. validEdges.Select(e => e.Id)], OffsetX = CopyOffsetX.Value, OffsetY = CopyOffsetY.Value }; // Call API to copy nodes and edges var response = await _apiService.CopyNodesAsync(request); if (!response.Success) { ErrorMessage = response.ErrorMessage ?? "Failed to copy nodes"; CancelCopy(); return false; } // Reload layout data to get new nodes and edges var layoutData = await _apiService.GetLayoutDataAsync(LevelId); Nodes = layoutData.Nodes?.ToList() ?? Nodes; Edges = layoutData.Edges?.ToList() ?? Edges; // Select new nodes SelectedNodeIds.Clear(); SelectedEdgeIds.Clear(); foreach (var newId in response.NodeIdMapping.Values) { SelectedNodeIds.Add(newId); } // Select new edges foreach (var newEdge in response.NewEdges) { SelectedEdgeIds.Add(newEdge.Id); } // Restore previous mode CancelCopy(); return true; } catch (Exception ex) { ErrorMessage = $"Failed to copy: {ex.Message}"; CancelCopy(); return false; } } /// /// Cancel copy operation /// public void CancelCopy() { var previousMode = PreviousModeBeforeCopy; PreviousModeBeforeCopy = null; CopyStartX = null; CopyStartY = null; CopyOffsetX = null; CopyOffsetY = null; CopySourceNodes = null; CopySourceEdges = null; // Restore previous mode if we were in Copy mode if (Mode == EditorMode.Copy && previousMode.HasValue) { Mode = previousMode.Value; } NotifyStateChanged(); } /// /// Mark state as changed (dirty) /// public void MarkDirty() { HasUnsavedChanges = true; NotifyStateChanged(); } // ========================================== // MERGE/SPLIT OPERATIONS // ========================================== /// /// Merge multiple nodes into one node at center position /// public async Task<(bool Success, string? ErrorMessage, bool RequiresConfirmation)> MergeNodesAsync( List nodesToMerge, bool forceConfirm = false) { if (IsReadOnly) { return (false, "Cannot merge nodes: Layout is read-only (Active)", false); } if (nodesToMerge.Count < 2) { return (false, "Need at least 2 nodes to merge", false); } try { // Calculate center position var centerX = nodesToMerge.Average(n => n.X); var centerY = nodesToMerge.Average(n => n.Y); var request = new MergeNodesRequest { LevelId = LevelId, NodeIds = [.. nodesToMerge.Select(n => n.Id)], CenterX = centerX, CenterY = centerY }; var response = await _apiService.MergeNodesAsync(request); // Update local state with response // Remove deleted nodes Nodes.RemoveAll(n => response.DeletedNodeIds.Contains(n.Id)); // Add merged node var existingMergedNode = Nodes.FirstOrDefault(n => n.Id == response.MergedNode.Id); if (existingMergedNode == null) { Nodes.Add(response.MergedNode); } else { var index = Nodes.IndexOf(existingMergedNode); Nodes[index] = response.MergedNode; } // Update edges foreach (var updatedEdge in response.UpdatedEdges) { var existingEdge = Edges.FirstOrDefault(e => e.Id == updatedEdge.Id); if (existingEdge != null) { var index = Edges.IndexOf(existingEdge); Edges[index] = updatedEdge; } } // Remove edges that were deleted (self-loops) var updatedEdgeIds = response.UpdatedEdges.Select(e => e.Id).ToHashSet(); var connectedEdgeIds = Edges .Where(e => nodesToMerge.Select(n => n.Id).Contains(e.StartNodeId) || nodesToMerge.Select(n => n.Id).Contains(e.EndNodeId)) .Select(e => e.Id) .ToHashSet(); Edges.RemoveAll(e => connectedEdgeIds.Contains(e.Id) && !updatedEdgeIds.Contains(e.Id)); // Clear selection and select merged node SelectedNodeIds.Clear(); SelectedNodeIds.Add(response.MergedNode.Id); // Note: No MarkDirty() here because MergeNodesAsync directly saves to database NotifyStateChanged(); return (true, null, false); } catch (InvalidOperationException ex) { // Check if this is a distance confirmation request if (ex.Message.Contains("exceeds proximity radius") && !forceConfirm) { return (false, ex.Message, true); // Requires confirmation } return (false, ex.Message, false); } catch (Exception ex) { return (false, $"Failed to merge nodes: {ex.Message}", false); } } /// /// Split a node into multiple nodes (one for each connected edge) /// public async Task SplitNodeAsync(NodeDto nodeToSplit, Guid? stationNodeId = null) { if (IsReadOnly) { ErrorMessage = "Cannot split node: Layout is read-only (Active)"; NotifyStateChanged(); return false; } try { // Get all edges connected to this node var connectedEdges = Edges .Where(e => e.StartNodeId == nodeToSplit.Id || e.EndNodeId == nodeToSplit.Id) .ToList(); if (connectedEdges.Count < 2) { ErrorMessage = "Node must have at least 2 connected edges to split"; NotifyStateChanged(); return false; } var request = new SplitNodeRequest { LevelId = LevelId, NodeId = nodeToSplit.Id, OffsetDistance = 0.1, // 10cm default StationNodeId = stationNodeId }; var response = await _apiService.SplitNodeAsync(request); // Update local state with response // Remove deleted node Nodes.RemoveAll(n => n.Id == response.DeletedNodeId); // Add new nodes foreach (var newNode in response.NewNodes) { var existingNode = Nodes.FirstOrDefault(n => n.Id == newNode.Id); if (existingNode == null) { Nodes.Add(newNode); } else { var index = Nodes.IndexOf(existingNode); Nodes[index] = newNode; } } // Update edges foreach (var updatedEdge in response.UpdatedEdges) { var existingEdge = Edges.FirstOrDefault(e => e.Id == updatedEdge.Id); if (existingEdge != null) { var index = Edges.IndexOf(existingEdge); Edges[index] = updatedEdge; } } // Select new nodes SelectedNodeIds.Clear(); foreach (var newNode in response.NewNodes) { SelectedNodeIds.Add(newNode.Id); } // Note: No MarkDirty() here because SplitNodeAsync directly saves to database NotifyStateChanged(); return true; } catch (InvalidOperationException ex) { ErrorMessage = ex.Message; NotifyStateChanged(); return false; } catch (Exception ex) { ErrorMessage = $"Failed to split node: {ex.Message}"; NotifyStateChanged(); return false; } } // ========================================== // ALIGNMENT OPERATIONS // ========================================== /// /// Align selected nodes horizontally to left /// public async Task AlignNodesLeftAsync() { if (SelectedNodeIds.Count < 2) return false; var selectedNodes = GetSelectedNodes(); var leftmostX = selectedNodes.Min(n => n.X); return await AlignNodesAsync(selectedNodes, leftmostX, null); } /// /// Align selected nodes horizontally to right /// public async Task AlignNodesRightAsync() { if (SelectedNodeIds.Count < 2) return false; var selectedNodes = GetSelectedNodes(); var rightmostX = selectedNodes.Max(n => n.X); return await AlignNodesAsync(selectedNodes, rightmostX, null); } /// /// Align selected nodes horizontally to center /// public async Task AlignNodesCenterHorizontalAsync() { if (SelectedNodeIds.Count < 2) return false; var selectedNodes = GetSelectedNodes(); var centerX = selectedNodes.Average(n => n.X); return await AlignNodesAsync(selectedNodes, centerX, null); } /// /// Align selected nodes vertically to top /// public async Task AlignNodesTopAsync() { if (SelectedNodeIds.Count < 2) return false; var selectedNodes = GetSelectedNodes(); var topmostY = selectedNodes.Max(n => n.Y); return await AlignNodesAsync(selectedNodes, null, topmostY); } /// /// Align selected nodes vertically to bottom /// public async Task AlignNodesBottomAsync() { if (SelectedNodeIds.Count < 2) return false; var selectedNodes = GetSelectedNodes(); var bottommostY = selectedNodes.Min(n => n.Y); return await AlignNodesAsync(selectedNodes, null, bottommostY); } /// /// Align selected nodes vertically to center /// public async Task AlignNodesCenterVerticalAsync() { if (SelectedNodeIds.Count < 2) return false; var selectedNodes = GetSelectedNodes(); var centerY = selectedNodes.Average(n => n.Y); return await AlignNodesAsync(selectedNodes, null, centerY); } /// /// Helper method to align nodes /// private async Task AlignNodesAsync(List nodes, double? targetX, double? targetY) { if (IsReadOnly) { ErrorMessage = "Cannot align nodes: Layout is read-only (Active)"; NotifyStateChanged(); return false; } try { // Store old positions for undo var oldPositions = nodes.ToDictionary(n => n.Id, n => (n.X, n.Y)); // Update nodes locally (don't call API - will be saved via Save button) foreach (var node in nodes) { if (targetX.HasValue) { node.X = targetX.Value; } if (targetY.HasValue) { node.Y = targetY.Value; } } // Create undo command (this will mark nodes as modified) var newPositions = nodes.ToDictionary(n => n.Id, n => (n.X, n.Y)); var command = new MoveNodesCommand( nodes, oldPositions, newPositions, [], [], []); ExecuteCommand(command); NotifyStateChanged(); return true; } catch (Exception ex) { ErrorMessage = $"Failed to align nodes: {ex.Message}"; NotifyStateChanged(); return false; } } // ========================================== // AUTO FORMAT OPERATIONS // ========================================== /// /// Apply auto format to selected nodes /// public async Task AutoFormatNodesAsync(AutoFormatOptions options) { if (IsReadOnly) { ErrorMessage = "Cannot format nodes: Layout is read-only (Active)"; NotifyStateChanged(); return false; } var selectedNodes = GetSelectedNodes(); if (selectedNodes.Count < 2) { ErrorMessage = "Select at least 2 nodes to format"; NotifyStateChanged(); return false; } try { // Store old positions for undo var oldPositions = selectedNodes.ToDictionary(n => n.Id, n => (n.X, n.Y)); // Calculate new positions using AutoFormatExecutor var newPositions = AutoFormatExecutor.ApplyFormat(selectedNodes, options); // Apply new positions to nodes foreach (var node in selectedNodes) { if (newPositions.TryGetValue(node.Id, out var pos)) { node.X = pos.X; node.Y = pos.Y; } } // Create undo command var command = new MoveNodesCommand( selectedNodes, oldPositions, newPositions, [], // No edge changes [], // No old CP positions [] // No new CP positions ); ExecuteCommand(command); NotifyStateChanged(); return true; } catch (Exception ex) { ErrorMessage = $"Failed to format nodes: {ex.Message}"; NotifyStateChanged(); return false; } } /// /// Apply smart auto format using pre-analyzed results /// public async Task SmartAutoFormatNodesAsync(SmartAutoFormatResult analysis, SmartAutoFormatConfig config) { if (IsReadOnly) { ErrorMessage = "Cannot format nodes: Layout is read-only (Active)"; NotifyStateChanged(); return false; } if (!analysis.HasChanges) { ErrorMessage = "No changes to apply"; NotifyStateChanged(); return false; } try { // Collect all nodes from all groups var allNodes = analysis.Groups.SelectMany(g => g.Nodes).ToList(); // Store old positions for undo var oldPositions = allNodes.ToDictionary(n => n.Id, n => (n.X, n.Y)); // Calculate new positions using SmartAutoFormatExecutor var newPositions = SmartAutoFormatExecutor.ApplyFormat(allNodes, analysis, config); // Apply new positions to nodes foreach (var node in allNodes) { if (newPositions.TryGetValue(node.Id, out var pos)) { node.X = pos.X; node.Y = pos.Y; } } // Create undo command var command = new MoveNodesCommand( allNodes, oldPositions, newPositions, [], // No edge changes [], // No old CP positions [] // No new CP positions ); ExecuteCommand(command); NotifyStateChanged(); return true; } catch (Exception ex) { ErrorMessage = $"Failed to format nodes: {ex.Message}"; NotifyStateChanged(); return false; } } public void ChangeEdgeVehicleEditor(EdgeVehiclePropertyDto edgeVehicle) { EdgeVehicleEditor = edgeVehicle; NotifyTrajectoryChanged(); } /// /// Calculate offset line for reverse edges (2-way edges) /// private static ((double X, double Y) start, (double X, double Y) end) CalculateOffsetLine( double x1, double y1, double x2, double y2, double offset) { var dx = x2 - x1; var dy = y2 - y1; var length = Math.Sqrt(dx * dx + dy * dy); if (length < 0.01) return ((x1, y1), (x2, y2)); // Perpendicular offset to the right var perpX = -dy / length * offset; var perpY = dx / length * offset; return ( (x1 + perpX, y1 + perpY), (x2 + perpX, y2 + perpY) ); } /// /// Calculate line end point (stop at arrow base, not tip) /// private ((double X, double Y) Start, (double X, double Y) End) CalculateLineEndBeforeArrow(NodeDto startNode, NodeDto endNode, int degree, double? cp1X, double? cp1Y, double? cp2X, double? cp2Y, bool hasReverseEdge, double tipOffset = 0.35) { double tangentX, tangentY; var startSvg = WorldToSvg(startNode.X, startNode.Y); var endSvg = WorldToSvg(endNode.X, endNode.Y); var (offsetStartSvg, offsetEndSvg) = hasReverseEdge ? CalculateOffsetLine(startSvg.X, startSvg.Y, endSvg.X, endSvg.Y, 0.08) : (startSvg, endSvg); if (degree == 2 && cp1X.HasValue && cp1Y.HasValue) { // Bezier bậc 2 (Quadratic): B'(1) = 2(P2 - C) // P2 = điểm cuối, C = control point var (X, Y) = WorldToSvg(cp1X.Value, cp1Y.Value); tangentX = 2 * (offsetEndSvg.X - X); tangentY = 2 * (offsetEndSvg.Y - Y); } else if (degree == 3 && cp2X.HasValue && cp2Y.HasValue) { // Bezier bậc 3 (Cubic): B'(1) = 3(P2 - C2) // P2 = điểm cuối, C2 = control point thứ 2 (gần điểm cuối) var (X, Y) = WorldToSvg(cp2X.Value, cp2Y.Value); tangentX = 3 * (offsetEndSvg.X - X); tangentY = 3 * (offsetEndSvg.Y - Y); } else { tangentX = offsetEndSvg.X - offsetStartSvg.X; tangentY = offsetEndSvg.Y - offsetStartSvg.Y; } // Normalize vector tiếp tuyến var length = Math.Sqrt(tangentX * tangentX + tangentY * tangentY); if (length < 0.01) return ((offsetStartSvg.X, offsetStartSvg.Y), (offsetEndSvg.X, offsetEndSvg.Y)); tangentX /= length; tangentY /= length; // Lùi lại theo hướng tiếp tuyến return ((offsetStartSvg.X, offsetStartSvg.Y), (offsetEndSvg.X - tangentX * tipOffset, offsetEndSvg.Y - tangentY * tipOffset)); } public string GetTrajectoryPath(NodeDto startNode, NodeDto endNode, int degree, double? cp1X, double? cp1Y, double? cp2X, double? cp2Y, bool hasReverseEdge) { var nodeRadius = 0.3 / Math.Sqrt(Viewport.ZoomLevel * 2) + 0.3; var startSvg = WorldToSvg(startNode.X, startNode.Y); var endSvg = WorldToSvg(endNode.X, endNode.Y); // Calculate offset for reverse edges (var offsetStartSvg, var offsetEndSvg) = CalculateLineEndBeforeArrow(startNode, endNode, degree, cp1X, cp1Y, cp2X, cp2Y, hasReverseEdge, nodeRadius); string data = $"M {offsetStartSvg.X} {offsetStartSvg.Y}"; if (degree == 1) { data = $"{data} L {offsetEndSvg.X} {offsetEndSvg.Y}"; } else if (degree == 2 && cp1X.HasValue && cp1Y.HasValue) { var (X, Y) = WorldToSvg(cp1X.Value, cp1Y.Value); data = $"{data} Q {X} {Y} {offsetEndSvg.X} {offsetEndSvg.Y}"; } else if (degree == 3 && cp1X.HasValue && cp1Y.HasValue && cp2X.HasValue && cp2Y.HasValue) { var controlPoint1 = WorldToSvg(cp1X.Value, cp1Y.Value); var controlPoint2 = WorldToSvg(cp2X.Value, cp2Y.Value); data = $"{data} C {controlPoint1.X} {controlPoint1.Y}, {controlPoint2.X} {controlPoint2.Y}, {offsetEndSvg.X} {offsetEndSvg.Y}"; } return data; } public void NotifyStateChanged() => OnStateChanged?.Invoke(); public void NotifyTrajectoryChanged() => OnTrajectoryChanged?.Invoke(); public void NotifyDraggingNodesChanged() => OnDraggingNodesChanged?.Invoke([.. SelectedNodeIds]); public void NotifyDraggingEdgesChanged() => OnDragingEdgesChanged?.Invoke([.. SelectedEdgeIds]); public void NotifyCopyChanged() => OnCreateCopyChanged?.Invoke(); }