1644 lines
54 KiB
C#
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();
|
|
}
|
|
|