Initial commit
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
|
||||
namespace RobotNet10.MapEditor.Services.State;
|
||||
|
||||
/// <summary>
|
||||
/// Direction/pattern type for a group of nodes
|
||||
/// </summary>
|
||||
public enum GroupPattern
|
||||
{
|
||||
/// <summary>Nodes form a horizontal line (similar Y values)</summary>
|
||||
HorizontalLine,
|
||||
/// <summary>Nodes form a vertical line (similar X values)</summary>
|
||||
VerticalLine,
|
||||
/// <summary>Nodes are scattered, no clear pattern</summary>
|
||||
Scattered,
|
||||
/// <summary>Single node, no pattern applicable</summary>
|
||||
Single
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a group of nodes that can be aligned/distributed together
|
||||
/// </summary>
|
||||
public class NodeGroup
|
||||
{
|
||||
public List<NodeDto> Nodes { get; set; } = [];
|
||||
public GroupPattern Pattern { get; set; }
|
||||
public bool WillAlign { get; set; }
|
||||
public bool WillDistribute { get; set; }
|
||||
|
||||
public string GetDescription()
|
||||
{
|
||||
var actions = new List<string>();
|
||||
if (WillAlign) actions.Add("align");
|
||||
if (WillDistribute) actions.Add("distribute");
|
||||
|
||||
var patternName = Pattern switch
|
||||
{
|
||||
GroupPattern.HorizontalLine => "horizontal",
|
||||
GroupPattern.VerticalLine => "vertical",
|
||||
GroupPattern.Scattered => "scattered",
|
||||
GroupPattern.Single => "single",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
if (actions.Count == 0)
|
||||
return $"{Nodes.Count} nodes ({patternName}) - no action";
|
||||
|
||||
return $"{Nodes.Count} nodes ({patternName}) → {string.Join(", ", actions)}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of smart auto-format analysis
|
||||
/// </summary>
|
||||
public class SmartAutoFormatResult
|
||||
{
|
||||
public List<NodeGroup> Groups { get; set; } = [];
|
||||
public int TotalNodes { get; set; }
|
||||
public bool WillSnap { get; set; }
|
||||
public int AlignCount { get; set; }
|
||||
public int DistributeCount { get; set; }
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public bool HasChanges => WillSnap || AlignCount > 0 || DistributeCount > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result returned from AutoFormatDialog
|
||||
/// </summary>
|
||||
public class SmartAutoFormatDialogResult
|
||||
{
|
||||
public SmartAutoFormatResult Analysis { get; set; } = new();
|
||||
public SmartAutoFormatConfig Config { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for smart auto-format (user-adjustable)
|
||||
/// </summary>
|
||||
public class SmartAutoFormatConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable snap to grid
|
||||
/// </summary>
|
||||
public bool EnableSnap { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Grid size for snapping (meters)
|
||||
/// </summary>
|
||||
public double SnapGridSize { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Enable alignment
|
||||
/// </summary>
|
||||
public bool EnableAlign { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable distribute equally
|
||||
/// </summary>
|
||||
public bool EnableDistribute { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum spread (length) for a group of nodes to be considered a "line" (meters)
|
||||
/// Nodes must span at least this distance along the line direction.
|
||||
/// Example: if MinLineSpread=1.0m, 3 nodes at x=0, x=0.5, x=0.8 won't form a line (spread=0.8m)
|
||||
/// </summary>
|
||||
public double MinLineSpread { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum deviation from the line for nodes to be grouped together (meters)
|
||||
/// Lower = stricter line detection (nodes must be more precisely aligned)
|
||||
/// Example: if PatternThreshold=0.3m, nodes with Y varying by ±0.3m can form horizontal line
|
||||
/// </summary>
|
||||
public double PatternThreshold { get; set; } = 0.3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Smart analyzer that detects groups and determines operations
|
||||
/// NEW APPROACH: Group by LINE PATTERN first, not by distance
|
||||
/// - Find nodes that form horizontal lines (similar Y)
|
||||
/// - Find nodes that form vertical lines (similar X)
|
||||
/// - Each node belongs to at most one group
|
||||
/// - Scattered nodes are NOT aligned/distributed
|
||||
/// </summary>
|
||||
public static class SmartAutoFormatAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyze nodes and create groups with determined operations
|
||||
/// </summary>
|
||||
public static SmartAutoFormatResult Analyze(List<NodeDto> nodes, SmartAutoFormatConfig config)
|
||||
{
|
||||
var result = new SmartAutoFormatResult { TotalNodes = nodes.Count };
|
||||
|
||||
if (nodes.Count < 2)
|
||||
{
|
||||
result.Summary = "Need at least 2 nodes to format";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Step 1: Check if snap is enabled
|
||||
result.WillSnap = config.EnableSnap;
|
||||
|
||||
// Step 2: Detect line patterns and create groups
|
||||
if (config.EnableAlign || config.EnableDistribute)
|
||||
{
|
||||
var groups = DetectLineGroups(nodes, config);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
result.Groups.Add(group);
|
||||
if (group.WillAlign) result.AlignCount++;
|
||||
if (group.WillDistribute) result.DistributeCount++;
|
||||
}
|
||||
|
||||
// Add ungrouped nodes as scattered (snap only)
|
||||
var groupedIds = groups.SelectMany(g => g.Nodes).Select(n => n.Id).ToHashSet();
|
||||
var ungrouped = nodes.Where(n => !groupedIds.Contains(n.Id)).ToList();
|
||||
if (ungrouped.Count > 0)
|
||||
{
|
||||
result.Groups.Add(new NodeGroup
|
||||
{
|
||||
Nodes = ungrouped,
|
||||
Pattern = GroupPattern.Scattered,
|
||||
WillAlign = false,
|
||||
WillDistribute = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Build summary
|
||||
result.Summary = BuildSummary(result, config);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect line groups by finding nodes with similar coordinates
|
||||
/// A horizontal line = nodes with similar Y (spread along X)
|
||||
/// A vertical line = nodes with similar X (spread along Y)
|
||||
/// </summary>
|
||||
private static List<NodeGroup> DetectLineGroups(List<NodeDto> nodes, SmartAutoFormatConfig config)
|
||||
{
|
||||
var threshold = config.PatternThreshold;
|
||||
var minSpread = config.MinLineSpread; // Minimum spread to be considered a line
|
||||
|
||||
// Find all potential horizontal lines
|
||||
var horizontalLines = FindLinesAlongAxis(nodes, isHorizontal: true, threshold, minSpread);
|
||||
|
||||
// Find all potential vertical lines
|
||||
var verticalLines = FindLinesAlongAxis(nodes, isHorizontal: false, threshold, minSpread);
|
||||
|
||||
// Resolve conflicts: each node can only belong to one group
|
||||
// Priority: line with more nodes wins, then line with less deviation
|
||||
var allLines = new List<(NodeGroup group, double score)>();
|
||||
|
||||
foreach (var line in horizontalLines)
|
||||
{
|
||||
var score = CalculateLineScore(line, isHorizontal: true);
|
||||
allLines.Add((line, score));
|
||||
}
|
||||
foreach (var line in verticalLines)
|
||||
{
|
||||
var score = CalculateLineScore(line, isHorizontal: false);
|
||||
allLines.Add((line, score));
|
||||
}
|
||||
|
||||
// Sort by score descending (higher = better)
|
||||
allLines = allLines.OrderByDescending(x => x.score).ToList();
|
||||
|
||||
// Assign nodes to best fitting line (greedy)
|
||||
var assigned = new HashSet<Guid>();
|
||||
var result = new List<NodeGroup>();
|
||||
|
||||
foreach (var (group, _) in allLines)
|
||||
{
|
||||
// Filter out already assigned nodes
|
||||
var availableNodes = group.Nodes.Where(n => !assigned.Contains(n.Id)).ToList();
|
||||
|
||||
if (availableNodes.Count >= 2)
|
||||
{
|
||||
// Recalculate if we should still align/distribute
|
||||
var newGroup = new NodeGroup
|
||||
{
|
||||
Nodes = availableNodes,
|
||||
Pattern = group.Pattern,
|
||||
WillAlign = config.EnableAlign,
|
||||
WillDistribute = config.EnableDistribute && availableNodes.Count >= 3
|
||||
};
|
||||
|
||||
result.Add(newGroup);
|
||||
foreach (var node in availableNodes)
|
||||
{
|
||||
assigned.Add(node.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find nodes that form lines along an axis
|
||||
/// For horizontal: group nodes with similar Y values
|
||||
/// For vertical: group nodes with similar X values
|
||||
/// </summary>
|
||||
private static List<NodeGroup> FindLinesAlongAxis(
|
||||
List<NodeDto> nodes, bool isHorizontal, double threshold, double minSpread)
|
||||
{
|
||||
var result = new List<NodeGroup>();
|
||||
if (nodes.Count < 2) return result;
|
||||
|
||||
// Sort by the coordinate we're grouping on
|
||||
var sorted = isHorizontal
|
||||
? nodes.OrderBy(n => n.Y).ToList()
|
||||
: nodes.OrderBy(n => n.X).ToList();
|
||||
|
||||
var currentGroup = new List<NodeDto> { sorted[0] };
|
||||
|
||||
for (int i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var current = sorted[i];
|
||||
var groupAvg = isHorizontal
|
||||
? currentGroup.Average(n => n.Y)
|
||||
: currentGroup.Average(n => n.X);
|
||||
var currentCoord = isHorizontal ? current.Y : current.X;
|
||||
|
||||
// Check if this node fits in current group
|
||||
if (Math.Abs(currentCoord - groupAvg) <= threshold)
|
||||
{
|
||||
currentGroup.Add(current);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Finalize current group if valid
|
||||
TryAddLineGroup(result, currentGroup, isHorizontal, threshold, minSpread);
|
||||
|
||||
// Start new group
|
||||
currentGroup = new List<NodeDto> { current };
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget last group
|
||||
TryAddLineGroup(result, currentGroup, isHorizontal, threshold, minSpread);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a line group if it meets criteria:
|
||||
/// - At least 2 nodes
|
||||
/// - Spread along the other axis >= minSpread
|
||||
/// - Deviation along grouping axis is within threshold
|
||||
/// </summary>
|
||||
private static void TryAddLineGroup(
|
||||
List<NodeGroup> result, List<NodeDto> nodes,
|
||||
bool isHorizontal, double threshold, double minSpread)
|
||||
{
|
||||
if (nodes.Count < 2) return;
|
||||
|
||||
// Calculate spread along the OTHER axis (perpendicular to grouping)
|
||||
var spread = isHorizontal
|
||||
? nodes.Max(n => n.X) - nodes.Min(n => n.X)
|
||||
: nodes.Max(n => n.Y) - nodes.Min(n => n.Y);
|
||||
|
||||
// Calculate deviation along grouping axis
|
||||
var groupCoord = isHorizontal
|
||||
? nodes.Average(n => n.Y)
|
||||
: nodes.Average(n => n.X);
|
||||
var maxDeviation = isHorizontal
|
||||
? nodes.Max(n => Math.Abs(n.Y - groupCoord))
|
||||
: nodes.Max(n => Math.Abs(n.X - groupCoord));
|
||||
|
||||
// Must have reasonable spread and be well-aligned
|
||||
if (spread >= minSpread && maxDeviation <= threshold)
|
||||
{
|
||||
result.Add(new NodeGroup
|
||||
{
|
||||
Nodes = nodes.ToList(),
|
||||
Pattern = isHorizontal ? GroupPattern.HorizontalLine : GroupPattern.VerticalLine,
|
||||
WillAlign = true,
|
||||
WillDistribute = nodes.Count >= 3
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate score for a line group (higher = better fit)
|
||||
/// Score = (node count) * (1 / (1 + avg_deviation))
|
||||
/// </summary>
|
||||
private static double CalculateLineScore(NodeGroup group, bool isHorizontal)
|
||||
{
|
||||
var nodes = group.Nodes;
|
||||
var avgCoord = isHorizontal
|
||||
? nodes.Average(n => n.Y)
|
||||
: nodes.Average(n => n.X);
|
||||
var avgDeviation = isHorizontal
|
||||
? nodes.Average(n => Math.Abs(n.Y - avgCoord))
|
||||
: nodes.Average(n => Math.Abs(n.X - avgCoord));
|
||||
|
||||
// More nodes and less deviation = higher score
|
||||
return nodes.Count * (1.0 / (1.0 + avgDeviation));
|
||||
}
|
||||
|
||||
private static double CalculateDistance(NodeDto a, NodeDto b)
|
||||
{
|
||||
var dx = a.X - b.X;
|
||||
var dy = a.Y - b.Y;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
private static string BuildSummary(SmartAutoFormatResult result, SmartAutoFormatConfig config)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (result.WillSnap)
|
||||
parts.Add($"Snap to {config.SnapGridSize}m grid");
|
||||
|
||||
if (result.AlignCount > 0)
|
||||
parts.Add($"Align {result.AlignCount} group(s)");
|
||||
|
||||
if (result.DistributeCount > 0)
|
||||
parts.Add($"Distribute {result.DistributeCount} group(s)");
|
||||
|
||||
if (parts.Count == 0)
|
||||
return "No operations to perform";
|
||||
|
||||
return string.Join(" → ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor for applying auto-format operations
|
||||
/// Order: Snap → Align → Distribute
|
||||
/// </summary>
|
||||
public static class SmartAutoFormatExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Apply auto-format operations in order: Snap → Align → Distribute
|
||||
/// </summary>
|
||||
public static Dictionary<Guid, (double X, double Y)> ApplyFormat(
|
||||
List<NodeDto> nodes,
|
||||
SmartAutoFormatResult analysis,
|
||||
SmartAutoFormatConfig config)
|
||||
{
|
||||
// Start with current positions
|
||||
var positions = nodes.ToDictionary(n => n.Id, n => (X: n.X, Y: n.Y));
|
||||
|
||||
// Step 1: SNAP TO GRID (all nodes)
|
||||
if (config.EnableSnap)
|
||||
{
|
||||
positions = ApplySnapToGrid(positions, config.SnapGridSize);
|
||||
}
|
||||
|
||||
// Step 2 & 3: ALIGN and DISTRIBUTE (per group)
|
||||
foreach (var group in analysis.Groups)
|
||||
{
|
||||
if (group.WillAlign)
|
||||
{
|
||||
positions = ApplyAlign(group.Nodes, positions, group.Pattern);
|
||||
}
|
||||
|
||||
if (group.WillDistribute)
|
||||
{
|
||||
positions = ApplyDistribute(group.Nodes, positions, group.Pattern);
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snap all coordinates to grid
|
||||
/// </summary>
|
||||
private static Dictionary<Guid, (double X, double Y)> ApplySnapToGrid(
|
||||
Dictionary<Guid, (double X, double Y)> positions,
|
||||
double gridSize)
|
||||
{
|
||||
var result = new Dictionary<Guid, (double X, double Y)>();
|
||||
|
||||
foreach (var kvp in positions)
|
||||
{
|
||||
var snappedX = Math.Round(kvp.Value.X / gridSize) * gridSize;
|
||||
var snappedY = Math.Round(kvp.Value.Y / gridSize) * gridSize;
|
||||
result[kvp.Key] = (snappedX, snappedY);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Align nodes in a group
|
||||
/// </summary>
|
||||
private static Dictionary<Guid, (double X, double Y)> ApplyAlign(
|
||||
List<NodeDto> nodes,
|
||||
Dictionary<Guid, (double X, double Y)> positions,
|
||||
GroupPattern pattern)
|
||||
{
|
||||
var result = new Dictionary<Guid, (double X, double Y)>(positions);
|
||||
|
||||
// Get current positions for these nodes
|
||||
var nodePositions = nodes.Select(n => positions[n.Id]).ToList();
|
||||
|
||||
if (pattern == GroupPattern.HorizontalLine)
|
||||
{
|
||||
// Align to same Y (average Y)
|
||||
var avgY = nodePositions.Average(p => p.Y);
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var pos = result[node.Id];
|
||||
result[node.Id] = (pos.X, avgY);
|
||||
}
|
||||
}
|
||||
else if (pattern == GroupPattern.VerticalLine)
|
||||
{
|
||||
// Align to same X (average X)
|
||||
var avgX = nodePositions.Average(p => p.X);
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var pos = result[node.Id];
|
||||
result[node.Id] = (avgX, pos.Y);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distribute nodes evenly in a group
|
||||
/// </summary>
|
||||
private static Dictionary<Guid, (double X, double Y)> ApplyDistribute(
|
||||
List<NodeDto> nodes,
|
||||
Dictionary<Guid, (double X, double Y)> positions,
|
||||
GroupPattern pattern)
|
||||
{
|
||||
if (nodes.Count < 3) return positions;
|
||||
|
||||
var result = new Dictionary<Guid, (double X, double Y)>(positions);
|
||||
bool distributeOnX = pattern == GroupPattern.HorizontalLine;
|
||||
|
||||
// Sort nodes by current position on distribution axis
|
||||
var sorted = distributeOnX
|
||||
? nodes.OrderBy(n => positions[n.Id].X).ToList()
|
||||
: nodes.OrderBy(n => positions[n.Id].Y).ToList();
|
||||
|
||||
var firstPos = positions[sorted.First().Id];
|
||||
var lastPos = positions[sorted.Last().Id];
|
||||
|
||||
double minVal = distributeOnX ? firstPos.X : firstPos.Y;
|
||||
double maxVal = distributeOnX ? lastPos.X : lastPos.Y;
|
||||
|
||||
// Calculate even spacing
|
||||
var spacing = (maxVal - minVal) / (sorted.Count - 1);
|
||||
|
||||
// Apply new positions (keep first and last fixed)
|
||||
for (int i = 1; i < sorted.Count - 1; i++)
|
||||
{
|
||||
var node = sorted[i];
|
||||
var pos = positions[node.Id];
|
||||
var newVal = minVal + (i * spacing);
|
||||
|
||||
result[node.Id] = distributeOnX
|
||||
? (newVal, pos.Y)
|
||||
: (pos.X, newVal);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LEGACY SUPPORT (for backward compatibility)
|
||||
// ============================================
|
||||
|
||||
public enum AlignDirection { Auto, Horizontal, Vertical }
|
||||
|
||||
public class AutoFormatOptions
|
||||
{
|
||||
public bool AlignEnabled { get; set; }
|
||||
public AlignDirection AlignDirection { get; set; } = AlignDirection.Auto;
|
||||
public bool DistributeEnabled { get; set; }
|
||||
public bool RoundEnabled { get; set; }
|
||||
public int RoundDecimalPlaces { get; set; } = 2;
|
||||
public bool SnapToGridEnabled { get; set; }
|
||||
public double SnapGridSize { get; set; } = 0.5;
|
||||
}
|
||||
|
||||
public class AutoFormatAnalysisResult
|
||||
{
|
||||
public bool SuggestAlign { get; set; }
|
||||
public AlignDirection SuggestedAlignDirection { get; set; } = AlignDirection.Auto;
|
||||
public bool SuggestDistribute { get; set; }
|
||||
public bool SuggestRound { get; set; }
|
||||
public int SuggestedDecimalPlaces { get; set; } = 2;
|
||||
public string PatternDescription { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class AutoFormatAnalyzer
|
||||
{
|
||||
public static AutoFormatAnalysisResult Analyze(List<NodeDto> nodes)
|
||||
{
|
||||
var config = new SmartAutoFormatConfig();
|
||||
var smartResult = SmartAutoFormatAnalyzer.Analyze(nodes, config);
|
||||
var result = new AutoFormatAnalysisResult();
|
||||
|
||||
if (smartResult.Groups.Count > 0)
|
||||
{
|
||||
var mainGroup = smartResult.Groups.OrderByDescending(g => g.Nodes.Count).First();
|
||||
result.SuggestAlign = mainGroup.WillAlign;
|
||||
result.SuggestDistribute = mainGroup.WillDistribute;
|
||||
result.SuggestedAlignDirection = mainGroup.Pattern == GroupPattern.HorizontalLine
|
||||
? AlignDirection.Horizontal
|
||||
: mainGroup.Pattern == GroupPattern.VerticalLine
|
||||
? AlignDirection.Vertical
|
||||
: AlignDirection.Auto;
|
||||
result.PatternDescription = smartResult.Summary;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AutoFormatExecutor
|
||||
{
|
||||
public static Dictionary<Guid, (double X, double Y)> ApplyFormat(
|
||||
List<NodeDto> nodes,
|
||||
AutoFormatOptions options)
|
||||
{
|
||||
var config = new SmartAutoFormatConfig
|
||||
{
|
||||
EnableSnap = options.SnapToGridEnabled,
|
||||
SnapGridSize = options.SnapGridSize,
|
||||
EnableAlign = options.AlignEnabled,
|
||||
EnableDistribute = options.DistributeEnabled
|
||||
};
|
||||
|
||||
var analysis = SmartAutoFormatAnalyzer.Analyze(nodes, config);
|
||||
return SmartAutoFormatExecutor.ApplyFormat(nodes, analysis, config);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Layout;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.LayoutData;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Services.API;
|
||||
|
||||
namespace RobotNet10.MapEditor.Services.State;
|
||||
|
||||
/// <summary>
|
||||
/// State management for LayoutManager page
|
||||
/// </summary>
|
||||
public class LayoutManagerState
|
||||
{
|
||||
// ===== DATA =====
|
||||
public List<LayoutDto> Layouts { get; set; } = new();
|
||||
|
||||
// ===== SELECTION =====
|
||||
public LayoutDto? SelectedLayout { get; set; }
|
||||
public LayoutVersionDto? SelectedVersion { get; set; }
|
||||
public LayoutLevelDto? SelectedLevel { get; set; }
|
||||
|
||||
// ===== PREVIEW DATA =====
|
||||
public LayoutDataDto? PreviewData { get; set; }
|
||||
public byte[]? PreviewImage { get; set; }
|
||||
|
||||
// ===== UI STATE =====
|
||||
public bool IsLoading { get; set; }
|
||||
public bool IsLoadingPreview { get; set; }
|
||||
public string? SearchText { get; set; }
|
||||
|
||||
// ===== EVENTS =====
|
||||
public event Action? OnStateChanged;
|
||||
|
||||
// ===== DEPENDENCIES =====
|
||||
private readonly MapManagerApiService _apiService;
|
||||
|
||||
public LayoutManagerState(MapManagerApiService apiService)
|
||||
{
|
||||
_apiService = apiService;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PUBLIC METHODS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Load all layouts with nested versions and levels
|
||||
/// </summary>
|
||||
public async Task LoadLayoutsAsync(string? searchText = null)
|
||||
{
|
||||
IsLoading = true;
|
||||
SearchText = searchText;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
Layouts = await _apiService.SearchLayoutsAsync(searchText);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error loading layouts: {ex.Message}");
|
||||
Layouts = new();
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select a level and load preview data
|
||||
/// </summary>
|
||||
public async Task SelectLevelAsync(LayoutLevelDto level)
|
||||
{
|
||||
SelectedLevel = level;
|
||||
|
||||
// Find parent version and layout
|
||||
foreach (var layout in Layouts)
|
||||
{
|
||||
if (layout.Versions == null) continue;
|
||||
|
||||
foreach (var version in layout.Versions)
|
||||
{
|
||||
if (version.Levels?.Any(l => l.Id == level.Id) == true)
|
||||
{
|
||||
SelectedLayout = layout;
|
||||
SelectedVersion = version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (SelectedVersion != null) break;
|
||||
}
|
||||
|
||||
await LoadPreviewAsync(level.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear selection
|
||||
/// </summary>
|
||||
public void ClearSelection()
|
||||
{
|
||||
SelectedLayout = null;
|
||||
SelectedVersion = null;
|
||||
SelectedLevel = null;
|
||||
PreviewData = null;
|
||||
PreviewImage = null;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create new layout
|
||||
/// </summary>
|
||||
public async Task<LayoutDto> CreateLayoutAsync(CreateLayoutRequest request)
|
||||
{
|
||||
var layout = await _apiService.CreateLayoutAsync(request);
|
||||
await LoadLayoutsAsync(SearchText); // Reload
|
||||
return layout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete layout
|
||||
/// </summary>
|
||||
public async Task DeleteLayoutAsync(Guid layoutId)
|
||||
{
|
||||
await _apiService.DeleteLayoutAsync(layoutId);
|
||||
await LoadLayoutsAsync(SearchText); // Reload
|
||||
|
||||
// Clear selection if deleted
|
||||
if (SelectedLayout?.Id == layoutId)
|
||||
{
|
||||
ClearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create new version
|
||||
/// </summary>
|
||||
public async Task<LayoutVersionDto> CreateVersionAsync(Guid layoutId, CreateLayoutVersionRequest request)
|
||||
{
|
||||
var version = await _apiService.CreateVersionAsync(layoutId, request);
|
||||
await LoadLayoutsAsync(SearchText); // Reload
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete version
|
||||
/// </summary>
|
||||
public async Task DeleteVersionAsync(Guid versionId)
|
||||
{
|
||||
await _apiService.DeleteVersionAsync(versionId);
|
||||
await LoadLayoutsAsync(SearchText); // Reload
|
||||
|
||||
// Clear selection if deleted
|
||||
if (SelectedVersion?.Id == versionId)
|
||||
{
|
||||
ClearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create new level
|
||||
/// </summary>
|
||||
public async Task<LayoutLevelDto> CreateLevelAsync(Guid versionId, CreateLayoutLevelRequest request)
|
||||
{
|
||||
var level = await _apiService.CreateLevelAsync(versionId, request);
|
||||
await LoadLayoutsAsync(SearchText); // Reload
|
||||
return level;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete level
|
||||
/// </summary>
|
||||
public async Task DeleteLevelAsync(Guid levelId)
|
||||
{
|
||||
await _apiService.DeleteLevelAsync(levelId);
|
||||
await LoadLayoutsAsync(SearchText); // Reload
|
||||
|
||||
// Clear selection if deleted
|
||||
if (SelectedLevel?.Id == levelId)
|
||||
{
|
||||
ClearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activate layout
|
||||
/// </summary>
|
||||
public async Task ActivateLayoutAsync(Guid layoutId)
|
||||
{
|
||||
await _apiService.ActivateLayoutAsync(layoutId);
|
||||
await LoadLayoutsAsync(SearchText); // Reload to get updated IsActive status
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivate layout
|
||||
/// </summary>
|
||||
public async Task DeactivateLayoutAsync(Guid layoutId)
|
||||
{
|
||||
await _apiService.DeactivateLayoutAsync(layoutId);
|
||||
await LoadLayoutsAsync(SearchText); // Reload
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PRIVATE METHODS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Load preview data (nodes, edges, image)
|
||||
/// </summary>
|
||||
private async Task LoadPreviewAsync(Guid levelId)
|
||||
{
|
||||
IsLoadingPreview = true;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Load layout data
|
||||
PreviewData = await _apiService.GetLayoutDataAsync(levelId);
|
||||
|
||||
// Load background image
|
||||
try
|
||||
{
|
||||
PreviewImage = await _apiService.GetLayoutImageAsync(levelId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
PreviewImage = null; // Image might not exist yet
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error loading preview: {ex.Message}");
|
||||
PreviewData = null;
|
||||
PreviewImage = null;
|
||||
}
|
||||
|
||||
IsLoadingPreview = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
private void NotifyStateChanged() => OnStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Station;
|
||||
using RobotNet10.MapEditor.Services.API;
|
||||
|
||||
namespace RobotNet10.MapEditor.Services.State;
|
||||
|
||||
/// <summary>
|
||||
/// State management for Station Manager
|
||||
/// </summary>
|
||||
public class StationManagerState
|
||||
{
|
||||
private readonly MapManagerApiService _apiService;
|
||||
|
||||
// Context
|
||||
public Guid? CurrentLayoutLevelId { get; private set; }
|
||||
|
||||
// Data
|
||||
public List<StationDto> Stations { get; private set; } = new();
|
||||
public StationDto? SelectedStation { get; private set; }
|
||||
|
||||
// Filters & Search
|
||||
public string? SearchQuery { get; set; }
|
||||
|
||||
// UI State
|
||||
public bool IsLoading { get; private set; }
|
||||
public bool IsSaving { get; private set; }
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
// Events
|
||||
public event Action? OnStateChanged;
|
||||
|
||||
public StationManagerState(MapManagerApiService apiService)
|
||||
{
|
||||
_apiService = apiService;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PUBLIC METHODS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Initialize or switch to a different layout level
|
||||
/// </summary>
|
||||
public async Task InitializeAsync(Guid layoutLevelId)
|
||||
{
|
||||
CurrentLayoutLevelId = layoutLevelId;
|
||||
await LoadStationsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load all stations for the current layout level
|
||||
/// </summary>
|
||||
public async Task LoadStationsAsync()
|
||||
{
|
||||
if (!CurrentLayoutLevelId.HasValue)
|
||||
{
|
||||
ErrorMessage = "No layout level selected";
|
||||
NotifyStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
Stations = await _apiService.GetStationsByLevelAsync(CurrentLayoutLevelId.Value);
|
||||
NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
Stations = new();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search stations by StationId or StationName
|
||||
/// </summary>
|
||||
public void Search(string? query)
|
||||
{
|
||||
SearchQuery = query;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select a station for viewing/editing
|
||||
/// </summary>
|
||||
public async Task SelectStationAsync(Guid? stationId)
|
||||
{
|
||||
if (!stationId.HasValue)
|
||||
{
|
||||
SelectedStation = null;
|
||||
NotifyStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find in local list first
|
||||
SelectedStation = Stations.FirstOrDefault(s => s.Id == stationId.Value);
|
||||
|
||||
// If not found or need fresh data, fetch from API
|
||||
if (SelectedStation == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
SelectedStation = await _apiService.GetStationAsync(stationId.Value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
SelectedStation = null;
|
||||
}
|
||||
}
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear selected station
|
||||
/// </summary>
|
||||
public void ClearSelection()
|
||||
{
|
||||
SelectedStation = null;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new station
|
||||
/// </summary>
|
||||
public async Task<StationDto> CreateStationAsync(RobotNet10.MapEditor.Shared.DTOs.Requests.CreateStationRequest request)
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var created = await _apiService.CreateStationAsync(request);
|
||||
await LoadStationsAsync();
|
||||
await SelectStationAsync(created.Id);
|
||||
return created;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
NotifyStateChanged();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update an existing station
|
||||
/// </summary>
|
||||
public async Task<StationDto> UpdateStationAsync(Guid stationId, RobotNet10.MapEditor.Shared.DTOs.Requests.UpdateStationRequest request)
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await _apiService.UpdateStationAsync(stationId, request);
|
||||
|
||||
// Update local cache
|
||||
var index = Stations.FindIndex(s => s.Id == stationId);
|
||||
if (index >= 0)
|
||||
{
|
||||
Stations[index] = updated;
|
||||
}
|
||||
|
||||
// Update selected if it's the same station
|
||||
if (SelectedStation?.Id == stationId)
|
||||
{
|
||||
SelectedStation = updated;
|
||||
}
|
||||
|
||||
NotifyStateChanged();
|
||||
return updated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
NotifyStateChanged();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a station
|
||||
/// </summary>
|
||||
public async Task DeleteStationAsync(Guid stationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _apiService.DeleteStationAsync(stationId);
|
||||
|
||||
// Remove from local list
|
||||
Stations.RemoveAll(s => s.Id == stationId);
|
||||
|
||||
// Clear selection if it was the deleted station
|
||||
if (SelectedStation?.Id == stationId)
|
||||
{
|
||||
SelectedStation = null;
|
||||
}
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
NotifyStateChanged();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// HELPER METHODS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Get filtered stations based on search query
|
||||
/// </summary>
|
||||
public List<StationDto> GetFilteredStations()
|
||||
{
|
||||
var query = Stations.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SearchQuery))
|
||||
{
|
||||
var searchLower = SearchQuery.ToLowerInvariant();
|
||||
query = query.Where(s =>
|
||||
s.StationId.ToLower().Contains(searchLower) ||
|
||||
(s.StationName != null && s.StationName.ToLower().Contains(searchLower)));
|
||||
}
|
||||
|
||||
return query.OrderBy(s => s.StationId).ToList();
|
||||
}
|
||||
|
||||
private void NotifyStateChanged()
|
||||
{
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet10.MapEditor.Services.API;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.VehicleType;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.MapEditor.Services.State;
|
||||
|
||||
/// <summary>
|
||||
/// State management for VehicleType Edit page
|
||||
/// </summary>
|
||||
public class VehicleTypeEditState
|
||||
{
|
||||
private readonly MapManagerApiService _apiService;
|
||||
|
||||
// Edit Data
|
||||
public Guid? VehicleTypeId { get; private set; }
|
||||
public string VehicleTypeIdString { get; set; } = string.Empty;
|
||||
public string VehicleTypeName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public List<ActionDto> Actions { get; set; } = new();
|
||||
|
||||
// Original data for change detection
|
||||
private string? _originalData;
|
||||
|
||||
// UI State
|
||||
public bool IsLoading { get; private set; }
|
||||
public bool IsSaving { get; private set; }
|
||||
public bool HasUnsavedChanges => GetCurrentDataJson() != _originalData;
|
||||
public Dictionary<string, string> ValidationErrors { get; private set; } = new();
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
// Events
|
||||
public event Action? OnStateChanged;
|
||||
|
||||
public VehicleTypeEditState(MapManagerApiService apiService)
|
||||
{
|
||||
_apiService = apiService;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PUBLIC METHODS
|
||||
// ==========================================
|
||||
|
||||
public async Task LoadVehicleTypeAsync(Guid id)
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var vehicleType = await _apiService.GetVehicleTypeAsync(id);
|
||||
if (vehicleType == null)
|
||||
{
|
||||
ErrorMessage = "Vehicle type not found";
|
||||
return;
|
||||
}
|
||||
|
||||
VehicleTypeId = vehicleType.Id;
|
||||
VehicleTypeIdString = vehicleType.VehicleTypeId;
|
||||
VehicleTypeName = vehicleType.VehicleTypeName;
|
||||
Description = vehicleType.Description;
|
||||
|
||||
LoadActionsFromJson(vehicleType.Actions);
|
||||
|
||||
_originalData = GetCurrentDataJson();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void InitializeForCreate()
|
||||
{
|
||||
VehicleTypeId = null;
|
||||
VehicleTypeIdString = string.Empty;
|
||||
VehicleTypeName = string.Empty;
|
||||
Description = null;
|
||||
Actions.Clear();
|
||||
_originalData = GetCurrentDataJson();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
public async Task<bool> SaveAsync()
|
||||
{
|
||||
if (!Validate())
|
||||
{
|
||||
NotifyStateChanged();
|
||||
return false;
|
||||
}
|
||||
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var actionsJson = GetActionsJson();
|
||||
|
||||
if (VehicleTypeId.HasValue)
|
||||
{
|
||||
// Update
|
||||
var request = new UpdateVehicleTypeRequest
|
||||
{
|
||||
VehicleTypeName = VehicleTypeName.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
||||
Actions = actionsJson
|
||||
};
|
||||
|
||||
await _apiService.UpdateVehicleTypeAsync(VehicleTypeId.Value, request);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create
|
||||
var request = new CreateVehicleTypeRequest
|
||||
{
|
||||
VehicleTypeId = VehicleTypeIdString.Trim(),
|
||||
VehicleTypeName = VehicleTypeName.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
||||
Actions = actionsJson
|
||||
};
|
||||
|
||||
await _apiService.CreateVehicleTypeAsync(request);
|
||||
}
|
||||
|
||||
_originalData = GetCurrentDataJson();
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
NotifyStateChanged();
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveAction(int index)
|
||||
{
|
||||
if (index >= 0 && index < Actions.Count)
|
||||
{
|
||||
Actions.RemoveAt(index);
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void AddParameter(int actionIndex, string key, string value)
|
||||
{
|
||||
if (actionIndex >= 0 && actionIndex < Actions.Count)
|
||||
{
|
||||
Actions[actionIndex].ActionParameters ??= [];
|
||||
Actions[actionIndex].ActionParameters.Add(new ActionParameterDto
|
||||
{
|
||||
Key = key,
|
||||
Value = value
|
||||
});
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveParameter(int actionIndex, int paramIndex)
|
||||
{
|
||||
if (actionIndex >= 0 && actionIndex < Actions.Count &&
|
||||
Actions[actionIndex].ActionParameters != null &&
|
||||
paramIndex >= 0 && paramIndex < Actions[actionIndex].ActionParameters.Count)
|
||||
{
|
||||
Actions[actionIndex].ActionParameters.RemoveAt(paramIndex);
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string GetActionsJsonPreview()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Actions.Count == 0)
|
||||
return "[]";
|
||||
|
||||
return JsonSerializer.Serialize(Actions, JsonOptionExtends.Write);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Error generating preview";
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyChange()
|
||||
{
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PRIVATE METHODS
|
||||
// ==========================================
|
||||
|
||||
private bool Validate()
|
||||
{
|
||||
ValidationErrors.Clear();
|
||||
|
||||
if (!VehicleTypeId.HasValue && string.IsNullOrWhiteSpace(VehicleTypeIdString))
|
||||
{
|
||||
ValidationErrors["VehicleTypeId"] = "Vehicle Type ID is required";
|
||||
}
|
||||
else if (!VehicleTypeId.HasValue && VehicleTypeIdString.Length > 64)
|
||||
{
|
||||
ValidationErrors["VehicleTypeId"] = "Vehicle Type ID must be 64 characters or less";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(VehicleTypeName))
|
||||
{
|
||||
ValidationErrors["VehicleTypeName"] = "Vehicle Type Name is required";
|
||||
}
|
||||
else if (VehicleTypeName.Length > 256)
|
||||
{
|
||||
ValidationErrors["VehicleTypeName"] = "Vehicle Type Name must be 256 characters or less";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Description) && Description.Length > 10000)
|
||||
{
|
||||
ValidationErrors["Description"] = "Description must be 10000 characters or less";
|
||||
}
|
||||
|
||||
// Validate Actions
|
||||
for (int i = 0; i < Actions.Count; i++)
|
||||
{
|
||||
var action = Actions[i];
|
||||
var prefix = $"Actions[{i}]";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(action.ActionType))
|
||||
{
|
||||
ValidationErrors[$"{prefix}.ActionType"] = "Action Type is required";
|
||||
}
|
||||
|
||||
// Validate ActionParameters
|
||||
if (action.ActionParameters != null)
|
||||
{
|
||||
for (int j = 0; j < action.ActionParameters.Count; j++)
|
||||
{
|
||||
var param = action.ActionParameters[j];
|
||||
if (string.IsNullOrWhiteSpace(param.Key))
|
||||
{
|
||||
ValidationErrors[$"{prefix}.Parameters[{j}].Key"] = "Parameter Key is required when Value is provided";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationErrors.Count == 0;
|
||||
}
|
||||
|
||||
private void LoadActionsFromJson(string? json)
|
||||
{
|
||||
Actions.Clear();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return;
|
||||
|
||||
var actions = JsonSerializer.Deserialize<List<ActionDto>?>(json, JsonOptionExtends.Read);
|
||||
if (actions is not null) Actions = actions;
|
||||
}
|
||||
|
||||
private string? GetActionsJson()
|
||||
{
|
||||
if (Actions.Count == 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(Actions, JsonOptionExtends.Write);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetCurrentDataJson()
|
||||
{
|
||||
var data = new
|
||||
{
|
||||
VehicleTypeIdString,
|
||||
VehicleTypeName,
|
||||
Description,
|
||||
Actions = GetActionsJson()
|
||||
};
|
||||
return JsonSerializer.Serialize(data, JsonOptionExtends.Write);
|
||||
}
|
||||
|
||||
private void NotifyStateChanged()
|
||||
{
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.VehicleType;
|
||||
using RobotNet10.MapEditor.Services.API;
|
||||
|
||||
namespace RobotNet10.MapEditor.Services.State;
|
||||
|
||||
/// <summary>
|
||||
/// State management for VehicleType Manager main page
|
||||
/// </summary>
|
||||
public class VehicleTypeManagerState
|
||||
{
|
||||
private readonly MapManagerApiService _apiService;
|
||||
|
||||
// Data
|
||||
public List<VehicleTypeDto> VehicleTypes { get; private set; } = new();
|
||||
public VehicleTypeDto? SelectedVehicleType { get; private set; }
|
||||
public VehicleTypeUsageInfoDto? SelectedUsageInfo { get; private set; }
|
||||
public HashSet<Guid> SelectedIds { get; private set; } = new();
|
||||
|
||||
// Filters & Search
|
||||
public string? SearchQuery { get; set; }
|
||||
public bool? FilterIsActive { get; set; }
|
||||
|
||||
// Pagination
|
||||
public int CurrentPage { get; set; } = 1;
|
||||
public int ItemsPerPage { get; set; } = 20;
|
||||
public int TotalItems => GetFilteredVehicleTypes().Count;
|
||||
public int TotalPages => TotalItems > 0 ? (int)Math.Ceiling(1.0 * TotalItems / ItemsPerPage) : 1;
|
||||
|
||||
// UI State
|
||||
public bool IsLoading { get; private set; }
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
// Events
|
||||
public event Action? OnStateChanged;
|
||||
|
||||
public VehicleTypeManagerState(MapManagerApiService apiService)
|
||||
{
|
||||
_apiService = apiService;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PUBLIC METHODS
|
||||
// ==========================================
|
||||
|
||||
public async Task LoadVehicleTypesAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
VehicleTypes = await _apiService.GetVehicleTypesAsync();
|
||||
CurrentPage = 1;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
VehicleTypes = new();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SearchAsync(string? query)
|
||||
{
|
||||
SearchQuery = query;
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
await LoadVehicleTypesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
VehicleTypes = await _apiService.SearchVehicleTypesAsync(query);
|
||||
CurrentPage = 1;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SelectVehicleTypeAsync(Guid id)
|
||||
{
|
||||
SelectedVehicleType = VehicleTypes.FirstOrDefault(vt => vt.Id == id);
|
||||
|
||||
if (SelectedVehicleType != null)
|
||||
{
|
||||
await LoadUsageInfoAsync(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedUsageInfo = null;
|
||||
}
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
public async Task SelectVehiclesTypeAsync(Guid[] ids)
|
||||
{
|
||||
SelectedIds = [.. ids];
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
public void ClearSelection()
|
||||
{
|
||||
SelectedVehicleType = null;
|
||||
SelectedUsageInfo = null;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
public async Task DeleteVehicleTypeAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _apiService.DeleteVehicleTypeAsync(id);
|
||||
|
||||
SelectedIds.RemoveWhere(i => i == id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
NotifyStateChanged();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public List<VehicleTypeDto> GetSelectedVehicleTypes()
|
||||
{
|
||||
return VehicleTypes.Where(vt => SelectedIds.Contains(vt.Id)).ToList();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// HELPER METHODS
|
||||
// ==========================================
|
||||
|
||||
private async Task LoadUsageInfoAsync(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
SelectedUsageInfo = await _apiService.GetVehicleTypeUsageAsync(id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
SelectedUsageInfo = null;
|
||||
}
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
private List<VehicleTypeDto> GetFilteredVehicleTypes()
|
||||
{
|
||||
var query = VehicleTypes.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SearchQuery))
|
||||
{
|
||||
var searchLower = SearchQuery.ToLowerInvariant();
|
||||
query = query.Where(vt =>
|
||||
vt.VehicleTypeId.ToLower().Contains(searchLower) ||
|
||||
vt.VehicleTypeName.ToLower().Contains(searchLower));
|
||||
}
|
||||
|
||||
if (FilterIsActive.HasValue)
|
||||
{
|
||||
query = query.Where(vt => vt.IsActive == FilterIsActive.Value);
|
||||
}
|
||||
|
||||
return query.OrderBy(vt => vt.VehicleTypeName).ToList();
|
||||
}
|
||||
|
||||
public List<VehicleTypeDto> GetPagedVehicleTypes()
|
||||
{
|
||||
var filtered = GetFilteredVehicleTypes();
|
||||
var skip = (CurrentPage - 1) * ItemsPerPage;
|
||||
return filtered.Skip(skip).Take(ItemsPerPage).ToList();
|
||||
}
|
||||
|
||||
private void NotifyStateChanged()
|
||||
{
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user