Files
I150/srcs/RobotNet10/Components/RobotNet10.MapEditor/Services/State/LayoutEditorState.cs
2026-07-03 16:37:12 +07:00

1644 lines
54 KiB
C#

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;
/// <summary>
/// Editor modes for the LayoutEditor canvas
/// </summary>
public enum EditorMode
{
Select,
Scanner,
CreateEdge1Way,
CreateEdge2Way,
Copy,
Move,
TrajectoryEditor,
Pan
}
/// <summary>
/// Viewport state for SVG canvas
/// </summary>
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}";
}
/// <summary>
/// Command for Undo/Redo operations
/// </summary>
public abstract class EditorCommand
{
public abstract void Execute();
public abstract void Undo();
}
/// <summary>
/// Move command for nodes
/// </summary>
public class MoveNodesCommand(
List<NodeDto> nodes,
Dictionary<Guid, (double X, double Y)> oldPositions,
Dictionary<Guid, (double X, double Y)> newPositions,
List<EdgeDto> edges,
Dictionary<Guid, Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)>> oldCpPositions,
Dictionary<Guid, Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)>> newCPPositions) : EditorCommand
{
public Guid Id { get; set; } = Guid.NewGuid();
private readonly List<NodeDto> _nodes = [.. nodes];
private readonly Dictionary<Guid, (double X, double Y)> _oldPositions = new(oldPositions);
private readonly Dictionary<Guid, (double X, double Y)> _newPositions = new(newPositions);
private readonly List<EdgeDto> _edges = edges;
private readonly Dictionary<Guid, Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)>> _oldCpPositions = new(oldCpPositions);
private readonly Dictionary<Guid, Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)>> _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;
}
}
}
}
}
/// <summary>
/// Get list of node IDs affected by this command
/// </summary>
public List<Guid> GetAffectedNodeIds() => [.. _nodes.Select(n => n.Id)];
/// <summary>
/// Get list of node IDs affected by this command
/// </summary>
public List<Guid> 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;
}
}
}
}
/// <summary>
/// State management for LayoutEditor page
/// </summary>
public class LayoutEditorState(MapManagerApiService apiService)
{
// ===== DATA =====
public Guid LevelId { get; private set; }
public LayoutLevelDto? Level { get; set; }
public List<NodeDto> Nodes { get; private set; } = [];
public List<EdgeDto> Edges { get; private set; } = [];
public List<StationDto> Stations { get; private set; } = [];
public List<VehicleTypeDto> VehicleTypes { get; private set; } = [];
public byte[]? BackgroundImage { get; private set; }
// ===== SELECTION =====
public HashSet<Guid> SelectedNodeIds { get; } = [];
public HashSet<Guid> 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<EditorCommand> UndoStack { get; } = new();
public Stack<EditorCommand> 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<Guid> _modifiedNodeIds = [];
private readonly HashSet<Guid> _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<NodeDto>? CopySourceNodes { get; set; } // Nodes to be copied
public List<EdgeDto>? 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<Guid, (double X, double Y)> DragNodesOriginalPositions = [];
public Dictionary<Guid, Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)>> DragEdgesOriginalPositions = [];
// ===== EVENTS =====
public event Action? OnStateChanged;
public event Action? OnTrajectoryChanged;
public event Action<Guid[]>? OnDraggingNodesChanged;
public event Action<Guid[]>? OnDragingEdgesChanged;
public event Action? OnCreateCopyChanged;
// ===== DEPENDENCIES =====
private readonly MapManagerApiService _apiService = apiService;
/// <summary>
/// Initialize editor with a specific level
/// </summary>
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();
}
/// <summary>
/// Check if layout/version is active (read-only mode)
/// </summary>
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
}
}
/// <summary>
/// Reload stations from API
/// </summary>
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();
}
}
/// <summary>
/// Initialize viewport to fit the image bounds
/// </summary>
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
// ==========================================
/// <summary>
/// Select a single node (clears previous selection)
/// </summary>
public void SelectNode(Guid nodeId, bool addToSelection = false)
{
if (!addToSelection)
{
SelectedNodeIds.Clear();
SelectedEdgeIds.Clear();
}
SelectedNodeIds.Add(nodeId);
NotifyStateChanged();
}
/// <summary>
/// Select a single edge (clears previous selection)
/// </summary>
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();
}
/// <summary>
/// Select multiple nodes (box select)
/// </summary>
public void SelectNodes(IEnumerable<Guid> nodeIds, bool addToSelection = false)
{
if (!addToSelection)
{
SelectedNodeIds.Clear();
SelectedEdgeIds.Clear();
}
foreach (var id in nodeIds)
{
SelectedNodeIds.Add(id);
}
NotifyStateChanged();
}
/// <summary>
/// Clear all selection
/// </summary>
public void ClearSelection()
{
SelectedNodeIds.Clear();
SelectedEdgeIds.Clear();
NotifyStateChanged();
}
/// <summary>
/// Get selected nodes
/// </summary>
public List<NodeDto> GetSelectedNodes() =>
[.. Nodes.Where(n => SelectedNodeIds.Contains(n.Id))];
/// <summary>
/// Get selected edges
/// </summary>
public List<EdgeDto> GetSelectedEdges() =>
[.. Edges.Where(e => SelectedEdgeIds.Contains(e.Id))];
// ==========================================
// VIEWPORT OPERATIONS
// ==========================================
/// <summary>
/// Pan the viewport by delta (in SVG units)
/// </summary>
public void Pan(double deltaX, double deltaY)
{
Viewport.ViewBoxX += deltaX;
Viewport.ViewBoxY += deltaY;
NotifyStateChanged();
}
/// <summary>
/// Zoom the viewport around a point (in SVG/world coordinates) - for mouse wheel
/// Keeps the point at svgCenterX, svgCenterY fixed in world space
/// </summary>
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();
}
/// <summary>
/// Zoom in/out centered at the viewport center (for zoom buttons)
/// </summary>
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();
}
/// <summary>
/// Fit viewport to image bounds
/// </summary>
public void FitToScreen()
{
InitializeViewport();
NotifyStateChanged();
}
/// <summary>
/// Get physical dimensions of the layout
/// </summary>
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
// ==========================================
/// <summary>
/// Transform world coordinates (layout) to SVG coordinates
/// Layout: Origin bottom-left, Y up (data from backend)
/// SVG: Origin top-left, Y down (for rendering)
/// </summary>
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));
}
/// <summary>
/// Transform SVG coordinates to world coordinates (layout)
/// </summary>
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
// ==========================================
/// <summary>
/// Execute a command and add to undo stack
/// </summary>
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();
}
/// <summary>
/// Undo last command
/// </summary>
public void Undo()
{
if (UndoStack.Count == 0) return;
var command = UndoStack.Pop();
command.Undo();
RedoStack.Push(command);
NotifyStateChanged();
}
/// <summary>
/// Redo last undone command
/// </summary>
public void Redo()
{
if (RedoStack.Count == 0) return;
var command = RedoStack.Pop();
command.Execute();
UndoStack.Push(command);
NotifyStateChanged();
}
// ==========================================
// SAVE
// ==========================================
/// <summary>
/// Mark a node as modified (for change tracking)
/// </summary>
public void MarkNodeModified(Guid nodeId)
{
_modifiedNodeIds.Add(nodeId);
HasUnsavedChanges = true;
NotifyStateChanged();
}
/// <summary>
/// Mark an edge as modified (for change tracking)
/// </summary>
public void MarkEdgeModified(Guid edgeId)
{
_modifiedEdgeIds.Add(edgeId);
HasUnsavedChanges = true;
NotifyStateChanged();
}
/// <summary>
/// Save all changes to server
/// </summary>
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
// ==========================================
/// <summary>
/// Create edge using world coordinates (1-Way or 2-Way)
/// API will automatically find or create nodes at these coordinates
/// </summary>
public async Task<bool> 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
// ==========================================
/// <summary>
/// Delete edges by IDs (nodes will be auto-deleted if orphaned)
/// </summary>
public async Task<bool> DeleteEdgesAsync(List<Guid> 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;
}
}
/// <summary>
/// Delete selected edges
/// </summary>
public async Task<bool> DeleteSelectedEdgesAsync()
{
if (SelectedEdgeIds.Count == 0) return false;
return await DeleteEdgesAsync([.. SelectedEdgeIds]);
}
// ==========================================
// MODE HELPERS
// ==========================================
/// <summary>
/// Set editor mode
/// </summary>
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();
}
/// <summary>
/// Start copy operation - enter Copy mode
/// </summary>
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();
}
/// <summary>
/// Complete copy operation - create new nodes and edges via API
/// </summary>
public async Task<bool> 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<Guid>();
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;
}
}
/// <summary>
/// Cancel copy operation
/// </summary>
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();
}
/// <summary>
/// Mark state as changed (dirty)
/// </summary>
public void MarkDirty()
{
HasUnsavedChanges = true;
NotifyStateChanged();
}
// ==========================================
// MERGE/SPLIT OPERATIONS
// ==========================================
/// <summary>
/// Merge multiple nodes into one node at center position
/// </summary>
public async Task<(bool Success, string? ErrorMessage, bool RequiresConfirmation)> MergeNodesAsync(
List<NodeDto> 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);
}
}
/// <summary>
/// Split a node into multiple nodes (one for each connected edge)
/// </summary>
public async Task<bool> 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
// ==========================================
/// <summary>
/// Align selected nodes horizontally to left
/// </summary>
public async Task<bool> AlignNodesLeftAsync()
{
if (SelectedNodeIds.Count < 2) return false;
var selectedNodes = GetSelectedNodes();
var leftmostX = selectedNodes.Min(n => n.X);
return await AlignNodesAsync(selectedNodes, leftmostX, null);
}
/// <summary>
/// Align selected nodes horizontally to right
/// </summary>
public async Task<bool> AlignNodesRightAsync()
{
if (SelectedNodeIds.Count < 2) return false;
var selectedNodes = GetSelectedNodes();
var rightmostX = selectedNodes.Max(n => n.X);
return await AlignNodesAsync(selectedNodes, rightmostX, null);
}
/// <summary>
/// Align selected nodes horizontally to center
/// </summary>
public async Task<bool> AlignNodesCenterHorizontalAsync()
{
if (SelectedNodeIds.Count < 2) return false;
var selectedNodes = GetSelectedNodes();
var centerX = selectedNodes.Average(n => n.X);
return await AlignNodesAsync(selectedNodes, centerX, null);
}
/// <summary>
/// Align selected nodes vertically to top
/// </summary>
public async Task<bool> AlignNodesTopAsync()
{
if (SelectedNodeIds.Count < 2) return false;
var selectedNodes = GetSelectedNodes();
var topmostY = selectedNodes.Max(n => n.Y);
return await AlignNodesAsync(selectedNodes, null, topmostY);
}
/// <summary>
/// Align selected nodes vertically to bottom
/// </summary>
public async Task<bool> AlignNodesBottomAsync()
{
if (SelectedNodeIds.Count < 2) return false;
var selectedNodes = GetSelectedNodes();
var bottommostY = selectedNodes.Min(n => n.Y);
return await AlignNodesAsync(selectedNodes, null, bottommostY);
}
/// <summary>
/// Align selected nodes vertically to center
/// </summary>
public async Task<bool> AlignNodesCenterVerticalAsync()
{
if (SelectedNodeIds.Count < 2) return false;
var selectedNodes = GetSelectedNodes();
var centerY = selectedNodes.Average(n => n.Y);
return await AlignNodesAsync(selectedNodes, null, centerY);
}
/// <summary>
/// Helper method to align nodes
/// </summary>
private async Task<bool> AlignNodesAsync(List<NodeDto> 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
// ==========================================
/// <summary>
/// Apply auto format to selected nodes
/// </summary>
public async Task<bool> 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;
}
}
/// <summary>
/// Apply smart auto format using pre-analyzed results
/// </summary>
public async Task<bool> 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();
}
/// <summary>
/// Calculate offset line for reverse edges (2-way edges)
/// </summary>
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)
);
}
/// <summary>
/// Calculate line end point (stop at arrow base, not tip)
/// </summary>
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();
}