Initial commit

This commit is contained in:
2026-07-13 09:25:40 +07:00
parent c08ff54676
commit bccfb156d7
1938 changed files with 641646 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}