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,198 @@
@using MudBlazor
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Services.State
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AutoFixHigh" />
<MudText Typo="Typo.h6">Auto Format</MudText>
</MudStack>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<!-- Summary -->
<MudAlert Severity="@(AnalysisResult.HasChanges ? Severity.Info : Severity.Success)"
Dense="true" Variant="Variant.Outlined">
<MudText Typo="Typo.body2">@AnalysisResult.Summary</MudText>
</MudAlert>
@if (AnalysisResult.TotalNodes >= 2)
{
<!-- Operation Toggles -->
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2">Operations (Order: Snap → Align → Distribute)</MudText>
<!-- Snap to Grid -->
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudCheckBox T="bool" @bind-Value="Config.EnableSnap"
Label="Snap to Grid" Dense="true" />
<MudNumericField T="double" @bind-Value="Config.SnapGridSize"
Variant="Variant.Outlined" Margin="Margin.Dense"
Min="0.1" Max="2.0" Step="0.1"
Style="width: 100px;"
Adornment="Adornment.End" AdornmentText="m"
Disabled="@(!Config.EnableSnap)" />
</MudStack>
<!-- Align -->
<MudCheckBox T="bool" @bind-Value="Config.EnableAlign"
Label="Align nodes in groups" Dense="true" />
<!-- Distribute -->
<MudCheckBox T="bool" @bind-Value="Config.EnableDistribute"
Label="Distribute nodes evenly (3+ nodes)" Dense="true" />
</MudStack>
</MudPaper>
<!-- Groups List -->
@if (AnalysisResult.Groups.Count > 0)
{
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">
Detected Groups (@AnalysisResult.Groups.Count)
</MudText>
<MudStack Spacing="1">
@foreach (var group in AnalysisResult.Groups)
{
<MudPaper Elevation="0" Class="pa-2"
Style="background-color: var(--mud-palette-background-grey);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@GetPatternIcon(group.Pattern)"
Size="Size.Small"
Color="@GetPatternColor(group.Pattern)" />
<MudText Typo="Typo.body2">
@group.GetDescription()
</MudText>
</MudStack>
</MudPaper>
}
</MudStack>
}
<!-- Advanced Configuration -->
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="Advanced Settings" Dense="true" Expanded="false">
<MudStack Spacing="2">
<MudNumericField T="double" @bind-Value="Config.PatternThreshold"
Label="Line Tolerance (m)"
Variant="Variant.Outlined" Margin="Margin.Dense"
Min="0.1" Max="2.0" Step="0.1"
HelperText="Max deviation from line (lower = stricter)" />
<MudNumericField T="double" @bind-Value="Config.MinLineSpread"
Label="Min Line Length (m)"
Variant="Variant.Outlined" Margin="Margin.Dense"
Min="0.5" Max="10" Step="0.5"
HelperText="Minimum span to form a line group" />
<MudButton Variant="Variant.Text" Color="Color.Primary"
Size="Size.Small" OnClick="ReAnalyze">
Re-analyze with new settings
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
<!-- Operations Preview -->
<MudText Typo="Typo.caption" Color="Color.Secondary">
@GetOperationsPreview()
</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
Select at least 2 nodes to use Auto Format.
</MudText>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled"
OnClick="Apply" Disabled="@(!HasOperations)">
Apply
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public List<NodeDto> SelectedNodes { get; set; } = new();
private SmartAutoFormatResult AnalysisResult { get; set; } = new();
private SmartAutoFormatConfig Config { get; set; } = new();
private bool HasOperations => Config.EnableSnap || Config.EnableAlign || Config.EnableDistribute;
protected override void OnInitialized()
{
Analyze();
}
private void Analyze()
{
AnalysisResult = SmartAutoFormatAnalyzer.Analyze(SelectedNodes, Config);
}
private void ReAnalyze()
{
Analyze();
StateHasChanged();
Snackbar.Add("Re-analyzed with new settings", Severity.Info);
}
private string GetPatternIcon(GroupPattern pattern) => pattern switch
{
GroupPattern.HorizontalLine => Icons.Material.Filled.HorizontalRule,
GroupPattern.VerticalLine => Icons.Material.Filled.VerticalAlignCenter,
GroupPattern.Scattered => Icons.Material.Filled.ScatterPlot,
GroupPattern.Single => Icons.Material.Filled.FiberManualRecord,
_ => Icons.Material.Filled.Help
};
private Color GetPatternColor(GroupPattern pattern) => pattern switch
{
GroupPattern.HorizontalLine => Color.Primary,
GroupPattern.VerticalLine => Color.Secondary,
GroupPattern.Scattered => Color.Warning,
GroupPattern.Single => Color.Default,
_ => Color.Default
};
private string GetOperationsPreview()
{
var parts = new List<string>();
if (Config.EnableSnap)
parts.Add($"Snap to {Config.SnapGridSize}m grid");
if (Config.EnableAlign && AnalysisResult.AlignCount > 0)
parts.Add($"Align {AnalysisResult.AlignCount} group(s)");
if (Config.EnableDistribute && AnalysisResult.DistributeCount > 0)
parts.Add($"Distribute {AnalysisResult.DistributeCount} group(s)");
return parts.Count > 0
? $"Will: {string.Join(" → ", parts)}"
: "No operations selected";
}
private void Cancel() => MudDialog?.Cancel();
private void Apply()
{
// Return both the analysis result and config for execution
var result = new SmartAutoFormatDialogResult
{
Analysis = AnalysisResult,
Config = Config
};
MudDialog?.Close(DialogResult.Ok(result));
}
}

View File

@@ -0,0 +1,651 @@
@using MudBlazor
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel
@using RobotNet10.MapEditor.Services.State
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<MudPaper Class="editor-toolbar pa-2" Elevation="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="toolbar-content">
<!-- Mode Selection Group -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Scanner (Box Select)">
<MudIconButton Icon="@Icons.Material.Filled.SelectAll"
Color="@GetModeColor(EditorMode.Scanner)"
Variant="@GetModeVariant(EditorMode.Scanner)"
Size="Size.Small"
Disabled="@State.IsReadOnly"
OnClick="() => SetMode(EditorMode.Scanner)" />
</MudTooltip>
<MudMenu Icon="@Icons.Material.Filled.Timeline"
Color="@GetCreateEdgeModeColor()"
Variant="@GetCreateEdgeModeVariant()"
Size="Size.Small"
Dense="true"
Disabled="@State.IsReadOnly">
<MudMenuItem OnClick="() => SetMode(EditorMode.CreateEdge1Way)" Disabled="@State.IsReadOnly">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.TrendingFlat" Size="Size.Small" />
<MudText>Create Edge (1-Way)</MudText>
</MudStack>
</MudMenuItem>
<MudMenuItem OnClick="() => SetMode(EditorMode.CreateEdge2Way)" Disabled="@State.IsReadOnly">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.SwapHoriz" Size="Size.Small" />
<MudText>Create Edge (2-Way)</MudText>
</MudStack>
</MudMenuItem>
</MudMenu>
<MudTooltip Text="Select">
<MudIconButton Icon="@Icons.Material.Filled.NearMe"
Color="@GetModeColor(EditorMode.Select)"
Variant="@GetModeVariant(EditorMode.Select)"
Size="Size.Small"
Disabled="@State.IsReadOnly"
OnClick="() => SetMode(EditorMode.Select)" />
</MudTooltip>
</MudButtonGroup>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- View Controls -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Zoom In">
<MudIconButton Icon="@Icons.Material.Filled.ZoomIn"
Size="Size.Small"
OnClick="ZoomIn" />
</MudTooltip>
<MudTooltip Text="Zoom Out">
<MudIconButton Icon="@Icons.Material.Filled.ZoomOut"
Size="Size.Small"
OnClick="ZoomOut" />
</MudTooltip>
<MudTooltip Text="Fit to Screen">
<MudIconButton Icon="@Icons.Material.Filled.FitScreen"
Size="Size.Small"
OnClick="FitToScreen" />
</MudTooltip>
</MudButtonGroup>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- Alignment Group -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Align Horizontal Left">
<MudIconButton Icon="@Icons.Material.Filled.AlignHorizontalLeft"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesLeft" />
</MudTooltip>
<MudTooltip Text="Align Horizontal Center">
<MudIconButton Icon="@Icons.Material.Filled.AlignHorizontalCenter"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesCenter" />
</MudTooltip>
<MudTooltip Text="Align Horizontal Right">
<MudIconButton Icon="@Icons.Material.Filled.AlignHorizontalRight"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesRight" />
</MudTooltip>
<MudTooltip Text="Align Vertical Top">
<MudIconButton Icon="@Icons.Material.Filled.AlignVerticalTop"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesTop" />
</MudTooltip>
<MudTooltip Text="Align Vertical Center">
<MudIconButton Icon="@Icons.Material.Filled.AlignVerticalCenter"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesMiddle" />
</MudTooltip>
<MudTooltip Text="Align Vertical Bottom">
<MudIconButton Icon="@Icons.Material.Filled.AlignVerticalBottom"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesBottom" />
</MudTooltip>
<MudTooltip Text="Auto Format">
<MudIconButton Icon="@Icons.Material.Filled.AutoFixHigh"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="OpenAutoFormatDialog" />
</MudTooltip>
</MudButtonGroup>
<!-- Copy/Move -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Copy Selected">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasSelection)"
OnClick="CopySelected" />
</MudTooltip>
<MudTooltip Text="Move Mode">
<MudIconButton Icon="@Icons.Material.Filled.OpenWith"
Size="Size.Small"
Color="@GetModeColor(EditorMode.Move)"
Variant="@GetModeVariant(EditorMode.Move)"
Disabled="@(State.IsReadOnly || !HasNodesSelected)"
OnClick="() => SetMode(EditorMode.Move)" />
</MudTooltip>
</MudButtonGroup>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- Merge/Split -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Merge Nodes">
<MudIconButton Icon="@Icons.Material.Filled.CallMerge"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="MergeNodes" />
</MudTooltip>
<MudTooltip Text="Split Node">
<MudIconButton Icon="@Icons.Material.Filled.CallSplit"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasSingleNodeSelected)"
OnClick="SplitNode" />
</MudTooltip>
</MudButtonGroup>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- Vehicle Type Selector -->
<MudSelect T="Guid ?" @bind-Value="State.SelectedVehicleTypeId"
@bind-Value:after="State.NotifyStateChanged"
Label="VehicleType"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Style="width: 150px;">
@foreach (var vt in State.VehicleTypes)
{
<MudSelectItem Value="@((Guid?)vt.Id)">@vt.VehicleTypeName</MudSelectItem>
}
<MudSelectItem Value="@((Guid?)null)">No Vehicle Types</MudSelectItem>
</MudSelect>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- Actions Group -->
<MudButtonGroup OverrideStyles="false">
<MudTooltip Text="Undo (Ctrl+Z)">
<MudIconButton Icon="@Icons.Material.Filled.Undo"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !State.CanUndo)"
OnClick="() => OnUndo.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Redo (Ctrl+Y)">
<MudIconButton Icon="@Icons.Material.Filled.Redo"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !State.CanRedo)"
OnClick="() => OnRedo.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Save (Ctrl+S)">
<MudIconButton Icon="@Icons.Material.Filled.Save"
Size="Size.Small"
Color="@(State.HasUnsavedChanges ? Color.Warning : Color.Default)"
Disabled="@State.IsReadOnly"
OnClick="() => OnSave.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Delete Selected (Del)">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Disabled="@(State.IsReadOnly || !HasSelection)"
OnClick="() => OnDelete.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Check Layout">
<MudIconButton Icon="@Icons.Material.Filled.FactCheck"
Size="Size.Small"
OnClick="() => OnCheck.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Exit">
<MudIconButton Icon="@Icons.Material.Filled.ExitToApp"
Size="Size.Small"
OnClick="() => OnExit.InvokeAsync()" />
</MudTooltip>
</MudButtonGroup>
</MudStack>
</MudPaper>
@code {
[Parameter] public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EventCallback OnUndo { get; set; }
[Parameter]
public EventCallback OnRedo { get; set; }
[Parameter]
public EventCallback OnSave { get; set; }
[Parameter]
public EventCallback OnDelete { get; set; }
[Parameter]
public EventCallback OnCheck { get; set; }
[Parameter]
public EventCallback OnExit { get; set; }
private bool HasSelection => State.SelectedNodeIds.Count > 0 || State.SelectedEdgeIds.Count > 0;
private bool HasNodesSelected => State.SelectedNodeIds.Count > 0;
private bool HasMultipleNodesSelected => State.SelectedNodeIds.Count > 1;
private bool HasSingleNodeSelected => State.SelectedNodeIds.Count == 1;
private void SetMode(EditorMode mode)
{
State.SetMode(mode);
}
private Color GetModeColor(EditorMode mode) =>
State.Mode == mode ? Color.Primary : Color.Default;
private Variant GetModeVariant(EditorMode mode) =>
State.Mode == mode ? Variant.Filled : Variant.Text;
private Color GetCreateEdgeModeColor() =>
State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way
? Color.Primary : Color.Default;
private Variant GetCreateEdgeModeVariant() =>
State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way
? Variant.Filled : Variant.Text;
private void ZoomIn()
{
State.ZoomAtCenter(1.2);
}
private void ZoomOut()
{
State.ZoomAtCenter(1.0 / 1.2);
}
private void FitToScreen()
{
State.FitToScreen();
}
private async Task CheckLayout()
{
var issues = new List<string>();
var warnings = new List<string>();
// Get editor settings
var minEdgeLength = State.Level?.EditorSettings?.EdgeMinLengthCreate ?? 0.1;
// 1. Check for isolated nodes (nodes without any edges)
var nodesWithEdges = new HashSet<Guid>();
foreach (var edge in State.Edges)
{
nodesWithEdges.Add(edge.StartNodeId);
nodesWithEdges.Add(edge.EndNodeId);
}
var isolatedNodes = State.Nodes
.Where(n => !nodesWithEdges.Contains(n.Id))
.ToList();
if (isolatedNodes.Count > 0)
{
warnings.Add($"{isolatedNodes.Count} isolated node(s) found (nodes without edges): {string.Join(", ", isolatedNodes.Take(5).Select(n => n.NodeName ?? n.NodeId))}{(isolatedNodes.Count > 5 ? "..." : "")}");
}
// 2. Check edge minimum length
var shortEdges = new List<(EdgeDto Edge, double Length)>();
foreach (var edge in State.Edges)
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
if (startNode != null && endNode != null)
{
var dx = endNode.X - startNode.X;
var dy = endNode.Y - startNode.Y;
var length = Math.Sqrt(dx * dx + dy * dy);
if (length < minEdgeLength)
{
shortEdges.Add((edge, length));
}
}
}
if (shortEdges.Count > 0)
{
issues.Add($"{shortEdges.Count} edge(s) shorter than minimum length ({minEdgeLength:F2}m): {string.Join(", ", shortEdges.Take(5).Select(e => $"{e.Edge.EdgeName ?? e.Edge.EdgeId} ({e.Length:F2}m)"))}{(shortEdges.Count > 5 ? "..." : "")}");
}
// 3. Check for duplicate node positions (nodes too close)
var duplicatePositions = new List<(NodeDto Node1, NodeDto Node2, double Distance)>();
for (int i = 0; i < State.Nodes.Count; i++)
{
for (int j = i + 1; j < State.Nodes.Count; j++)
{
var node1 = State.Nodes[i];
var node2 = State.Nodes[j];
var dx = node2.X - node1.X;
var dy = node2.Y - node1.Y;
var distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < 0.01) // Less than 1cm apart
{
duplicatePositions.Add((node1, node2, distance));
}
}
}
if (duplicatePositions.Count > 0)
{
warnings.Add($"{duplicatePositions.Count} pair(s) of nodes are very close (< 1cm): {string.Join(", ", duplicatePositions.Take(3).Select(p => $"{p.Node1.NodeName ?? p.Node1.NodeId} & {p.Node2.NodeName ?? p.Node2.NodeId}"))}{(duplicatePositions.Count > 3 ? "..." : "")}");
}
// 4. Check for edges with same start and end node
var selfLoops = State.Edges
.Where(e => e.StartNodeId == e.EndNodeId)
.ToList();
if (selfLoops.Count > 0)
{
warnings.Add($"{selfLoops.Count} self-loop edge(s) found (start = end): {string.Join(", ", selfLoops.Take(5).Select(e => e.EdgeName ?? e.EdgeId))}{(selfLoops.Count > 5 ? "..." : "")}");
}
// Display results
if (issues.Count == 0 && warnings.Count == 0)
{
Snackbar.Add("Layout check completed - No issues found", Severity.Success);
}
else
{
var message = new System.Text.StringBuilder();
if (issues.Count > 0)
{
message.AppendLine($"<strong>{issues.Count} Issue(s) Found:</strong>");
foreach (var issue in issues)
{
message.AppendLine($"• {issue}");
}
}
if (warnings.Count > 0)
{
if (issues.Count > 0) message.AppendLine();
message.AppendLine($"<strong>{warnings.Count} Warning(s):</strong>");
foreach (var warning in warnings)
{
message.AppendLine($"• {warning}");
}
}
await DialogService.ShowMessageBoxAsync(
issues.Count > 0 ? "Layout Check - Issues Found" : "Layout Check - Warnings",
message.ToString(),
yesText: "OK",
cancelText: null);
}
}
private async Task AlignNodesLeft()
{
await State.AlignNodesLeftAsync();
Snackbar.Add("Nodes aligned to left", Severity.Success);
}
private async Task AlignNodesCenter()
{
await State.AlignNodesCenterHorizontalAsync();
Snackbar.Add("Nodes aligned to center", Severity.Success);
}
private async Task AlignNodesRight()
{
await State.AlignNodesRightAsync();
Snackbar.Add("Nodes aligned to right", Severity.Success);
}
private async Task AlignNodesTop()
{
await State.AlignNodesTopAsync();
Snackbar.Add("Nodes aligned to top", Severity.Success);
}
private async Task AlignNodesMiddle()
{
await State.AlignNodesCenterVerticalAsync();
Snackbar.Add("Nodes aligned to middle", Severity.Success);
}
private async Task AlignNodesBottom()
{
await State.AlignNodesBottomAsync();
Snackbar.Add("Nodes aligned to bottom", Severity.Success);
}
private async Task OpenAutoFormatDialog()
{
var selectedNodes = State.GetSelectedNodes();
if (selectedNodes.Count < 2)
{
Snackbar.Add("Select at least 2 nodes to auto format", Severity.Warning);
return;
}
var parameters = new DialogParameters<AutoFormatDialog>
{
{ nameof(AutoFormatDialog.SelectedNodes), selectedNodes }
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<AutoFormatDialog>(
"Auto Format",
parameters,
options);
var result = await dialog.Result;
if (result == null || result.Canceled)
{
return;
}
if (result.Data is not SmartAutoFormatDialogResult dialogResult)
{
return;
}
var success = await State.SmartAutoFormatNodesAsync(dialogResult.Analysis, dialogResult.Config);
if (success)
{
var analysis = dialogResult.Analysis;
var config = dialogResult.Config;
var parts = new List<string>();
if (config.EnableSnap) parts.Add($"snapped to {config.SnapGridSize}m grid");
if (config.EnableAlign && analysis.AlignCount > 0) parts.Add($"aligned {analysis.AlignCount} group(s)");
if (config.EnableDistribute && analysis.DistributeCount > 0) parts.Add($"distributed {analysis.DistributeCount} group(s)");
var message = parts.Count > 0 ? string.Join(", ", parts) : "completed";
Snackbar.Add($"Auto format: {message}", Severity.Success);
}
else
{
Snackbar.Add(State.ErrorMessage ?? "Failed to format nodes", Severity.Error);
}
}
private async Task CopyNodes()
{
// TODO: Implement copy functionality
Snackbar.Add("Copy functionality not yet implemented", Severity.Info);
await Task.CompletedTask;
}
private async Task MergeNodes()
{
var selectedNodes = State.GetSelectedNodes();
if (selectedNodes.Count < 2)
{
return;
}
// Check distance and show confirmation if needed
var (success, errorMessage, requiresConfirmation) = await State.MergeNodesAsync(selectedNodes, forceConfirm: false);
if (requiresConfirmation)
{
// Show confirmation dialog for distance warning
var confirmResult = await DialogService.ShowMessageBoxAsync(
"Merge Nodes - Distance Warning",
$"{errorMessage}\n\n" +
$"Are you sure you want to proceed with merging these nodes?",
yesText: "Yes, Merge Anyway",
cancelText: "Cancel");
if (confirmResult != true)
{
return;
}
// Retry with force confirm
(success, errorMessage, _) = await State.MergeNodesAsync(selectedNodes, forceConfirm: true);
}
else if (!success)
{
// Show initial confirmation dialog
var result = await DialogService.ShowMessageBoxAsync(
"Merge Nodes",
$"Are you sure you want to merge {selectedNodes.Count} nodes into one?\n\n" +
$"All edges connected to these nodes will be redirected to the new merged node.\n" +
$"The merged node will be placed at the center of the selected nodes.",
yesText: "Merge",
cancelText: "Cancel");
if (result != true)
{
return;
}
// Retry merge
(success, errorMessage, _) = await State.MergeNodesAsync(selectedNodes, forceConfirm: true);
}
if (success)
{
Snackbar.Add($"Successfully merged {selectedNodes.Count} nodes", Severity.Success);
}
else
{
Snackbar.Add(errorMessage ?? State.ErrorMessage ?? "Failed to merge nodes", Severity.Error);
}
}
private async Task SplitNode()
{
var selectedNodes = State.GetSelectedNodes();
if (selectedNodes.Count != 1)
{
return;
}
var nodeToSplit = selectedNodes[0];
var connectedEdges = State.Edges
.Count(e => e.StartNodeId == nodeToSplit.Id || e.EndNodeId == nodeToSplit.Id);
if (connectedEdges < 2)
{
Snackbar.Add("Cannot split node: Node must have at least 2 connected edges", Severity.Warning);
return;
}
// Check if node has station
var hasStation = State.Stations.Any(s => s.InteractionNodes?.Any(i => i.NodeId == nodeToSplit.Id) == true);
Guid? stationNodeId = null;
if (hasStation)
{
// Show dialog to select which new node should receive the station
// We need to predict how many nodes will be created (one per edge)
var dialogParameters = new DialogParameters<SplitNodeStationDialog>
{
{ nameof(SplitNodeStationDialog.EdgeCount), connectedEdges },
{ nameof(SplitNodeStationDialog.NodeName), nodeToSplit.NodeName ?? nodeToSplit.NodeId }
};
var dialogOptions = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<SplitNodeStationDialog>(
"Split Node - Select Station Node",
dialogParameters,
dialogOptions);
var result = await dialog.Result;
if (result == null || result.Canceled)
{
return; // User cancelled
}
// Get the selected node index (0-based)
// Note: Backend will create nodes in order, so we'll need to get the actual node ID after split
// For now, pass null and backend will assign to first node (index 0)
var selectedIndex = result.Data as int?;
stationNodeId = null; // Will be handled by backend based on node creation order
}
else
{
// Show confirmation dialog
var result = await DialogService.ShowMessageBoxAsync(
"Split Node",
$"Are you sure you want to split this node?\n\n" +
$"The node will be split into {connectedEdges} nodes (one for each connected edge).\n" +
$"Each new node will be offset from the original position.",
yesText: "Split",
cancelText: "Cancel");
if (result != true)
{
return;
}
}
var success = await State.SplitNodeAsync(nodeToSplit, stationNodeId);
if (success)
{
Snackbar.Add($"Successfully split node into {connectedEdges} nodes", Severity.Success);
}
else
{
Snackbar.Add(State.ErrorMessage ?? "Failed to split node", Severity.Error);
}
}
private void CopySelected()
{
State.StartCopy();
}
}

View File

@@ -0,0 +1,27 @@
.editor-toolbar {
flex-shrink: 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
background-color: var(--mud-palette-surface);
}
.toolbar-content {
flex-wrap: wrap;
min-height: 48px;
}
::deep .mud-button-group {
gap: 2px;
}
::deep .mud-checkbox {
margin: 0;
}
::deep .mud-checkbox .mud-typography {
font-size: 0.75rem;
}
::deep .mud-input-control {
margin: 0;
}

View File

@@ -0,0 +1,97 @@
@inject ISnackbar Snackbar
@implements IDisposable
@if (State.Mode == EditorMode.Copy && State.CopySourceNodes != null && State.CopySourceNodes.Count > 0 &&
State.CopyOffsetX.HasValue && State.CopyOffsetY.HasValue)
{
var offsetX = State.CopyOffsetX.Value;
var offsetY = State.CopyOffsetY.Value;
<!-- Preview nodes -->
@foreach (var sourceNode in State.CopySourceNodes)
{
var newX = sourceNode.X + offsetX;
var newY = sourceNode.Y + offsetY;
var svg = State.WorldToSvg(newX, newY);
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<circle class="copy-preview-node"
cx="@svg.X.ToString("F2")"
cy="@svg.Y.ToString("F2")"
r="@nodeRadius.ToString("F2")"
fill="rgba(255, 152, 0, 0.3)"
stroke="#ff9800"
stroke-width="0.02"
stroke-dasharray="0.05,0.05" />
}
<!-- Preview edges -->
@if (State.CopySourceEdges != null)
{
@foreach (var sourceEdge in State.CopySourceEdges)
{
var sourceStartNode = State.CopySourceNodes?.FirstOrDefault(n => n.Id == sourceEdge.StartNodeId);
var sourceEndNode = State.CopySourceNodes?.FirstOrDefault(n => n.Id == sourceEdge.EndNodeId);
if (sourceStartNode != null && sourceEndNode != null)
{
var startSvg = State.WorldToSvg(sourceStartNode.X + offsetX, sourceStartNode.Y + offsetY);
var endSvg = State.WorldToSvg(sourceEndNode.X + offsetX, sourceEndNode.Y + offsetY);
<line class="copy-preview-edge"
x1="@startSvg.X.ToString("F2")"
y1="@startSvg.Y.ToString("F2")"
x2="@endSvg.X.ToString("F2")"
y2="@endSvg.Y.ToString("F2")"
stroke="#ff9800"
stroke-width="0.03"
stroke-dasharray="0.1,0.05"
opacity="0.6" />
}
}
}
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
public EdgeDto Model { get; set; } = null!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnCreateCopyChanged += StateHasChanged;
}
public void Dispose()
{
State.OnCreateCopyChanged -= StateHasChanged;
}
/// <summary>
/// Start copy drag - called when user clicks to start dragging
/// </summary>
public void StartCopyDrag(double worldX, double worldY)
{
State.CopyStartX = worldX;
State.CopyStartY = worldY;
State.CopyOffsetX = 0;
State.CopyOffsetY = 0;
StateHasChanged();
}
/// <summary>
/// Update copy drag - called during mouse move
/// </summary>
public void UpdateCopyDrag(double worldX, double worldY)
{
if (State.CopyStartX.HasValue && State.CopyStartY.HasValue)
{
State.CopyOffsetX = worldX - State.CopyStartX.Value;
State.CopyOffsetY = worldY - State.CopyStartY.Value;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,114 @@
@implements IDisposable
@{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == Model.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == Model.EndNodeId);
if (startNode != null && endNode != null)
{
var isSelected = State.SelectedEdgeIds.Contains(Model.Id);
var isEditor = State.Mode == EditorMode.TrajectoryEditor && isSelected && State.SelectedVehicleTypeId == State.EdgeVehicleEditor?.VehicleTypeId;
// Check if this is a reverse edge (2-way edge) to offset it
var hasReverseEdge = State.Edges.Any(e =>
e.Id != Model.Id &&
e.StartNodeId == Model.EndNodeId &&
e.EndNodeId == Model.StartNodeId);
var vehicleProp = Model.VehicleProperties?.FirstOrDefault(prop => prop.VehicleTypeId == State.SelectedVehicleTypeId);
var TrajectoryPath = State.GetTrajectoryPath(startNode, endNode,
vehicleProp?.TrajectoryDegree ?? 1,
vehicleProp?.TrajectoryControlPoint1X,
vehicleProp?.TrajectoryControlPoint1Y,
vehicleProp?.TrajectoryControlPoint2X,
vehicleProp?.TrajectoryControlPoint2Y,
hasReverseEdge);
<path class="edge @(isSelected ? "selected" : "")"
marker-end="@(isSelected ? "url(#arrowhead-selected)" : "url(#arrowhead)")"
stroke="@(isSelected ? "#1976d2" : "#4caf50")"
stroke-width="@(isSelected ? "0.1" : "0.07")"
data-id="@Model.Id"
fill="none"
@onclick="() => HandleEdgeClick(Model.Id)"
@onclick:stopPropagation="true"
d="@TrajectoryPath" />
<!-- Edge name (above center) -->
@if (State.ShowEdgeNames && !string.IsNullOrEmpty(Model.EdgeName))
{
var startSvg = State.WorldToSvg(startNode.X, startNode.Y);
var endSvg = State.WorldToSvg(endNode.X, endNode.Y);
var midX = (startSvg.X + endSvg.X) / 2;
var midY = (startSvg.Y + endSvg.Y) / 2;
// Font size: base size in world coordinates (meters), adjusted for Resolution and ZoomLevel
// Resolution is meters per pixel, so fontSizeSVG = baseFontSizeWorld / (Resolution * ZoomLevel)
var baseFontSizeWorld = 0.4; // meters
var fontSize = baseFontSizeWorld / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
var baseOffsetWorld = 0.08; // meters
var offset = baseOffsetWorld;
@RenderSvgText(Model.EdgeName, midX, midY - offset, fontSize, "edge-name")
}
}
}
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EdgeDto Model { get; set; } = null!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnDraggingNodesChanged += OnDraggingNodesChanged;
}
private void OnDraggingNodesChanged(Guid[] nodeIds)
{
if (nodeIds.Any(n => Model.StartNodeId == n || Model.EndNodeId == n)) StateHasChanged();
}
public void Dispose()
{
State.OnDraggingNodesChanged -= OnDraggingNodesChanged;
}
/// <summary>
/// Render SVG text element (workaround for Blazor text directive conflict)
/// </summary>
private RenderFragment RenderSvgText(string content, double x, double y, double fontSize, string cssClass) => builder =>
{
builder.OpenElement(0, "text");
builder.AddAttribute(1, "class", cssClass);
builder.AddAttribute(2, "x", x.ToString("F2"));
builder.AddAttribute(3, "y", y.ToString("F2"));
builder.AddAttribute(4, "text-anchor", "middle");
builder.AddAttribute(5, "font-size", fontSize.ToString("F3"));
builder.AddAttribute(6, "fill", "#f44336");
builder.AddAttribute(7, "font-weight", "500");
builder.AddAttribute(8, "font-family", "Segoe UI, sans-serif");
builder.AddAttribute(9, "letter-spacing", "-0.02em");
builder.AddAttribute(10, "pointer-events", "none");
builder.AddAttribute(11, "style", "user-select: none;");
builder.AddContent(12, content);
builder.CloseElement();
};
private void HandleEdgeClick(Guid edgeId)
{
// In ReadOnly mode, only allow selection (view mode)
if (State.IsReadOnly)
{
State.SelectEdge(edgeId, false);
return;
}
if (State.Mode == EditorMode.Select)
{
State.SelectEdge(edgeId);
}
}
}

View File

@@ -0,0 +1,26 @@
.edge {
cursor: pointer;
transition: opacity 0.15s;
/*stroke-dasharray: 5 5;*/
/*animation: dash 1s linear infinite;*/
}
.edge:hover {
opacity: 0.8;
}
.edge.selected {
filter: drop-shadow(0 0 2px rgba(25, 118, 210, 0.6));
}
.edge.editor {
visibility: hidden;
}
.edge-name {
pointer-events: none;
user-select: none;
font-family: 'Segoe UI', sans-serif;
letter-spacing: -0.01em;
}

View File

@@ -0,0 +1,123 @@
@inject ISnackbar Snackbar
@if ((State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way)
&& startNodePreview is not null
&& endNodePreview is not null
&& isCreating)
{
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<!-- Preview start node -->
<circle class="preview-node"
cx="@startNodePreview.Value.X.ToString("F2")"
cy="@startNodePreview.Value.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="#ff9800"
fill-opacity="0.5"
stroke="#ff9800"
stroke-width="0.03"
pointer-events="none" />
<!-- Preview end node -->
<circle class="preview-node"
cx="@endNodePreview.Value.X.ToString("F2")"
cy="@endNodePreview.Value.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="#ff9800"
fill-opacity="0.5"
stroke="#ff9800"
stroke-width="0.03"
pointer-events="none" />
<!-- Preview line -->
<line class="edge-preview"
x1="@startNodePreview.Value.X.ToString("F2")"
y1="@startNodePreview.Value.Y.ToString("F2")"
x2="@endNodePreview.Value.X.ToString("F2")"
y2="@endNodePreview.Value.Y.ToString("F2")"
stroke="#ff9800"
stroke-width="0.1"
stroke-dasharray="0.2,0.1"
pointer-events="none" />
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
private (double X, double Y)? startNodePreview = new();
private (double X, double Y)? endNodePreview = new();
private bool isCreating = false;
public async Task CreateEdge(double svgX, double svgY)
{
if (!isCreating)
{
isCreating = true;
startNodePreview = (svgX, svgY);
endNodePreview = (svgX, svgY);
StateHasChanged();
}
else
{
await HandleCreateEdgeClick(svgX, svgY);
}
}
public void UpdateEdge(double svgX, double svgY)
{
if (!isCreating) return;
endNodePreview = (svgX, svgY);
StateHasChanged();
}
public void CancelCreateEdge()
{
isCreating = false;
startNodePreview = null;
endNodePreview = null;
StateHasChanged();
}
private async Task HandleCreateEdgeClick(double svgX, double svgY)
{
// Disable create edge in ReadOnly mode
if (State.IsReadOnly)
{
Snackbar.Add("Cannot create edge: Layout is read-only (Active)", Severity.Warning);
return;
}
if (!startNodePreview.HasValue)
{
Snackbar.Add("Cannot create edge: Start node is not existed", Severity.Warning);
return;
}
// Convert SVG coordinates to World coordinates
(double worldEndX, double worldEndY) = State.SvgToWorld(svgX, svgY);
(double worldStartX, double worldStartY) = State.SvgToWorld(startNodePreview.Value.X, startNodePreview.Value.Y);
var isTwoWay = State.Mode == EditorMode.CreateEdge2Way;
var success = await State.CreateEdgeAsync(
worldStartX,
worldStartY,
worldEndX,
worldEndY,
isTwoWay);
if (success)
{
Snackbar.Add(isTwoWay ? "Created 2-way edge successfully" : "Created edge successfully", Severity.Success);
}
else
{
Snackbar.Add(State.ErrorMessage ?? "Failed to create edge", Severity.Error);
}
// Reset
CancelCreateEdge();
}
}

View File

@@ -0,0 +1,8 @@
.edge-preview {
pointer-events: none;
}
.preview-node {
pointer-events: none;
animation: pulse-preview 1s infinite;
}

View File

@@ -0,0 +1,123 @@
@inject ISnackbar Snackbar
@implements IDisposable
<marker id="arrowhead-control" markerWidth="5" markerHeight="5" refX="0" refY="1.5" orient="auto-start-reverse">
<polygon class="edge-arrow"
points="0 0, 3 1.5, 0 3"
fill="#ff9800" />
</marker>
@if (State.Mode == EditorMode.TrajectoryEditor && State.EdgeVehicleEditor is not null)
{
var edge = State.Edges.FirstOrDefault(e => e.Id == State.EdgeVehicleEditor.EdgeId);
var startNode = edge != null ? State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId) : null;
var endNode = edge != null ? State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId) : null;
@if (startNode is not null && endNode is not null)
{
var TrajectoryPath = State.GetTrajectoryPath(startNode, endNode,
State.EdgeVehicleEditor.TrajectoryDegree ?? 1,
State.EdgeVehicleEditor.TrajectoryControlPoint1X,
State.EdgeVehicleEditor.TrajectoryControlPoint1Y,
State.EdgeVehicleEditor.TrajectoryControlPoint2X,
State.EdgeVehicleEditor.TrajectoryControlPoint2Y, false);
<path d="@TrajectoryPath"
stroke="#ff9800"
marker-end="url(#arrowhead-control)"
stroke-width="0.1"
pointer-events="none"
fill="none" />
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
@if (State.EdgeVehicleEditor.TrajectoryDegree == 3 && State.EdgeVehicleEditor.TrajectoryControlPoint2X.HasValue && State.EdgeVehicleEditor.TrajectoryControlPoint2Y.HasValue)
{
var controlpoint2 = State.WorldToSvg(State.EdgeVehicleEditor.TrajectoryControlPoint2X.Value, State.EdgeVehicleEditor.TrajectoryControlPoint2Y.Value);
<circle class="preview-control-node"
cx="@controlpoint2.X.ToString("F2")"
cy="@controlpoint2.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="#ff9800"
stroke="#ff9800"
stroke-opacity="0.5"
stroke-width="0.03"
marker-end="url(#arrowhead-control)"
@onmousedown="@((e) => HandleControlPointDown(2, e))"
@onmousedown:stopPropagation="true" />
}
@if (State.EdgeVehicleEditor.TrajectoryDegree > 1 && State.EdgeVehicleEditor.TrajectoryControlPoint1X.HasValue && State.EdgeVehicleEditor.TrajectoryControlPoint1Y.HasValue)
{
var controlpoint1 = State.WorldToSvg(State.EdgeVehicleEditor.TrajectoryControlPoint1X.Value, State.EdgeVehicleEditor.TrajectoryControlPoint1Y.Value);
<circle class="preview-control-node"
cx="@controlpoint1.X.ToString("F2")"
cy="@controlpoint1.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="#ff9800"
stroke="#ff9800"
stroke-opacity="0.5"
stroke-width="0.03"
@onmousedown="@((e) => HandleControlPointDown(1, e))"
@onmousedown:stopPropagation="true" />
}
}
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
private (double X, double Y)? dragControlPointStartWorld;
private int controlPointSelected = -1;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnTrajectoryChanged += StateHasChanged;
}
public void Dispose()
{
State.OnTrajectoryChanged -= StateHasChanged;
}
private void HandleControlPointDown(int controlPointNumber, MouseEventArgs e)
{
// Disable trajectory editing in ReadOnly mode
if (State.IsReadOnly)
{
Snackbar.Add("Cannot edit trajectory: Layout is read-only (Active)", Severity.Warning);
return;
}
{
if (controlPointSelected != controlPointNumber && State.Mode == EditorMode.TrajectoryEditor && State.EdgeVehicleEditor != null && e.CtrlKey)
{
if (controlPointNumber == 1 && State.EdgeVehicleEditor.TrajectoryControlPoint1X.HasValue && State.EdgeVehicleEditor.TrajectoryControlPoint1Y.HasValue)
{
dragControlPointStartWorld = (State.EdgeVehicleEditor.TrajectoryControlPoint1X.Value, State.EdgeVehicleEditor.TrajectoryControlPoint1Y.Value);
controlPointSelected = controlPointNumber;
}
else if (controlPointNumber == 2 && State.EdgeVehicleEditor.TrajectoryControlPoint2X.HasValue && State.EdgeVehicleEditor.TrajectoryControlPoint2Y.HasValue)
{
dragControlPointStartWorld = (State.EdgeVehicleEditor.TrajectoryControlPoint2X.Value, State.EdgeVehicleEditor.TrajectoryControlPoint2Y.Value);
controlPointSelected = controlPointNumber;
}
}
}
}
public void Update(double svgX, double svgY)
{
if (!dragControlPointStartWorld.HasValue || State.EdgeVehicleEditor is null) return;
var currentWorld = State.SvgToWorld(svgX, svgY);
if (controlPointSelected == 1) (State.EdgeVehicleEditor.TrajectoryControlPoint1X, State.EdgeVehicleEditor.TrajectoryControlPoint1Y) = currentWorld;
else if (controlPointSelected == 2) (State.EdgeVehicleEditor.TrajectoryControlPoint2X, State.EdgeVehicleEditor.TrajectoryControlPoint2Y) = currentWorld;
State.NotifyTrajectoryChanged();
StateHasChanged();
}
public void Cancel()
{
controlPointSelected = -1;
}
}

View File

@@ -0,0 +1,6 @@
.preview-control-node {
cursor: pointer;
animation: pulse-preview 1s infinite;
}

View File

@@ -0,0 +1,63 @@
@if (State.ShowGrid && State.Level?.EditorSettings != null)
{
var (physicalWidth, physicalHeight) = State.GetPhysicalDimensions();
var settings = State.Level.EditorSettings;
var originX = settings.OriginX;
var originY = settings.OriginY;
<g id="grid-layer" stroke="#999" stroke-width="0.04" opacity="0.7" stroke-dasharray="0.1,0.1">
@* Vertical lines: Grid bắt đầu từ gốc tọa độ World (0, 0) *@
@{
// Image bounds trong World coordinates:
// Top-left của image trong World: (originX, originY + physicalHeight)
// Bottom-right của image trong World: (originX + physicalWidth, originY)
// Vậy X: từ originX đến originX + physicalWidth
var worldMinX = originX;
var worldMaxX = originX + physicalWidth;
// Tính grid line đầu tiên và cuối cùng trong World coordinates
// Grid lines tại các vị trí: 0, ±GridSpacing, ±2*GridSpacing, ...
var firstGridXWorld = Math.Floor(worldMinX / State.GridSpacing) * State.GridSpacing;
var lastGridXWorld = Math.Ceiling(worldMaxX / State.GridSpacing) * State.GridSpacing;
// Vẽ vertical lines
for (double worldX = firstGridXWorld; worldX <= lastGridXWorld; worldX += State.GridSpacing)
{
var svgX = State.WorldToSvg(worldX, 0).X;
// Chỉ vẽ nếu nằm trong image bounds [0, physicalWidth]
if (svgX >= 0 && svgX <= physicalWidth)
{
<line x1="@svgX.ToString("F2")" y1="0" x2="@svgX.ToString("F2")" y2="@physicalHeight.ToString("F2")" />
}
}
}
@* Horizontal lines: Grid bắt đầu từ gốc tọa độ World (0, 0) *@
@{
// Image bounds trong World coordinates cho Y:
// Y: từ originY (bottom) đến originY + physicalHeight (top)
var worldMinY = originY;
var worldMaxY = originY + physicalHeight;
// Tính grid line đầu tiên và cuối cùng trong World coordinates
var firstGridYWorld = Math.Floor(worldMinY / State.GridSpacing) * State.GridSpacing;
var lastGridYWorld = Math.Ceiling(worldMaxY / State.GridSpacing) * State.GridSpacing;
// Vẽ horizontal lines
for (double worldY = firstGridYWorld; worldY <= lastGridYWorld; worldY += State.GridSpacing)
{
var svgY = State.WorldToSvg(0, worldY).Y;
// Chỉ vẽ nếu nằm trong image bounds [0, physicalHeight]
if (svgY >= 0 && svgY <= physicalHeight)
{
<line x1="0" y1="@svgY.ToString("F2")" x2="@physicalWidth.ToString("F2")" y2="@svgY.ToString("F2")" />
}
}
}
</g>
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,30 @@
<marker id="arrowhead" markerWidth="5" markerHeight="5" refX="0" refY="1.5" orient="auto-start-reverse">
<polygon class="edge-arrow"
points="0 0, 3 1.5, 0 3"
fill="#4caf50" />
</marker>
<marker id="arrowhead-selected" markerWidth="5" markerHeight="5" refX="0" refY="1.5" orient="auto-start-reverse">
<polygon class="edge-arrow"
points="0 0, 3 1.5, 0 3"
fill="#1976d2" />
</marker>
<g id="edges-layer">
@{
// Filter edges based on SelectedVehicleTypeId
// If VehicleType is selected: only show edges that have VehicleProperties for that type
// If no VehicleType selected: show all edges
var edgesToRender = State.SelectedVehicleTypeId.HasValue
? State.Edges.Where(e => e.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true).ToList()
: State.Edges.ToList();
}
@foreach (var edge in edgesToRender)
{
<Edge State="State" Model="edge"/>
}
</g>
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,52 @@
<g id="nodes-layer">
@{
// Filter edges based on SelectedVehicleTypeId
// If VehicleType is selected: only show edges that have VehicleProperties for that type
// If no VehicleType selected: show all edges
var edgesToRender = State.SelectedVehicleTypeId.HasValue
? State.Edges.Where(e => e.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true).ToList()
: State.Edges.ToList();
}
@{
// Filter nodes based on SelectedVehicleTypeId
// If VehicleType is selected: show nodes that have VehicleProperties for that type OR are connected to visible edges
var nodesToRender = new List<NodeDto>();
if (State.SelectedVehicleTypeId.HasValue)
{
// Get nodes with VehicleProperties for selected VehicleType
var nodesWithVehicleType = State.Nodes
.Where(n => n.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true)
.ToList();
// Get nodes that are start/end of visible edges
var visibleEdgeNodeIds = edgesToRender
.SelectMany(e => new[] { e.StartNodeId, e.EndNodeId })
.Distinct()
.ToHashSet();
var nodesConnectedToVisibleEdges = State.Nodes
.Where(n => visibleEdgeNodeIds.Contains(n.Id))
.ToList();
// Combine: nodes with VehicleType OR nodes connected to visible edges
nodesToRender = nodesWithVehicleType
.Union(nodesConnectedToVisibleEdges)
.DistinctBy(n => n.Id)
.ToList();
}
else
{
// No filter: show all nodes
nodesToRender = State.Nodes.ToList();
}
}
@foreach (var node in nodesToRender)
{
<Node State="State" Model="node"/>
}
</g>
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,174 @@
@implements IDisposable
@{
var svg = State.WorldToSvg(Model.X, Model.Y);
var isSelected = State.SelectedNodeIds.Contains(Model.Id);
var hasStation = State.Stations.Any(s => s.InteractionNodes?.Any(i => i.NodeId == Model.Id) == true);
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<!-- Selection ring -->
@if (isSelected)
{
<circle class="node-selection"
cx="@svg.X.ToString("F2")"
cy="@svg.Y.ToString("F2")"
r="@((nodeRadius * 1.5).ToString("F3"))"
fill="none"
stroke="#1976d2"
stroke-width="0.04"
stroke-dasharray="0.1,0.05" />
}
<!-- Node circle -->
<circle class="node @(isSelected ? "selected" : "") @(hasStation ? "has-station" : "")"
data-id="@Model.Id"
cx="@svg.X.ToString("F2")"
cy="@svg.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="@(hasStation ? "#4caf50" : "#2196f3")"
stroke="@(isSelected ? "#1976d2" : "#fff")"
stroke-width="0.03"
style="cursor: pointer; pointer-events: @(State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way ? "none" : "all");"
@onmousedown="(e) => HandleNodeMouseDown(Model.Id, e)"
@onmousedown:stopPropagation="true"
@onclick="(e) => HandleNodeClick(Model.Id, e)"
@onclick:stopPropagation="true" />
<!-- Node name (below center) -->
@if (State.ShowNodeNames && !string.IsNullOrEmpty(Model.NodeName))
{
// Font size: base size in world coordinates (meters), adjusted for Resolution and ZoomLevel
// Resolution is meters per pixel, so fontSizeSVG = baseFontSizeWorld / (Resolution * ZoomLevel)
var baseFontSizeWorld = 0.3; // meters
var fontSize = baseFontSizeWorld / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
// nodeRadius is already in SVG coordinates, so offset = nodeRadius + (baseOffsetWorld / resolution)
var baseOffsetWorld = 0.2; // meters
var offset = nodeRadius + baseOffsetWorld;
@RenderSvgText(Model.NodeName, svg.X, svg.Y + offset, fontSize, "node-name")
}
}
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public NodeDto Model { get; set; } = null!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnDraggingNodesChanged += OnDraggingNodesChanged;
}
private void OnDraggingNodesChanged(Guid[] nodeIds)
{
if (nodeIds.Any(n => Model.Id == n)) StateHasChanged();
}
public void Dispose()
{
State.OnDraggingNodesChanged -= OnDraggingNodesChanged;
}
/// <summary>
/// Render SVG text element (workaround for Blazor text directive conflict)
/// </summary>
private RenderFragment RenderSvgText(string content, double x, double y, double fontSize, string cssClass) => builder =>
{
builder.OpenElement(0, "text");
builder.AddAttribute(1, "class", cssClass);
builder.AddAttribute(2, "x", x.ToString("F2"));
builder.AddAttribute(3, "y", y.ToString("F2"));
builder.AddAttribute(4, "text-anchor", "middle");
builder.AddAttribute(5, "font-size", fontSize.ToString("F3"));
builder.AddAttribute(6, "fill", "#f44336");
builder.AddAttribute(7, "font-weight", "500");
builder.AddAttribute(8, "font-family", "Segoe UI, sans-serif");
builder.AddAttribute(9, "letter-spacing", "-0.02em");
builder.AddAttribute(10, "pointer-events", "none");
builder.AddAttribute(11, "style", "user-select: none;");
builder.AddContent(12, content);
builder.CloseElement();
};
private void HandleNodeClick(Guid nodeId, MouseEventArgs e)
{
// In ReadOnly mode, only allow selection (view mode)
if (State.IsReadOnly)
{
State.SelectNode(nodeId, e.ShiftKey);
return;
}
if (State.Mode == EditorMode.Select)
{
State.SelectNode(nodeId, e.ShiftKey);
}
}
private void HandleNodeMouseDown(Guid nodeId, MouseEventArgs e)
{
// Disable drag in ReadOnly mode
if (State.IsReadOnly) return;
// Handle left button in Select mode (Ctrl+drag) or Move mode (direct drag)
if (e.Button != 0) return;
// In Move mode, allow drag without Ctrl
// In Select mode, require Ctrl for drag
var canDrag = (State.Mode == EditorMode.Move || State.Mode == EditorMode.Select) && e.CtrlKey;
if (!canDrag)
{
return; // Not in a mode that allows dragging
}
// Start drag operation
if (State.Mode == EditorMode.Move || e.CtrlKey)
{
// If node is not selected, select it first
if (!State.SelectedNodeIds.Contains(nodeId))
{
State.SelectNode(nodeId, false); // Select without toggling
}
// Start drag
var node = State.Nodes.FirstOrDefault(n => n.Id == nodeId);
if (node != null)
{
State.IsDraggingNodes = true;
State.DraggedNodeId = nodeId;
State.DragStartWorld = (node.X, node.Y);
// Store original positions of all selected nodes
State.DragNodesOriginalPositions.Clear();
foreach (var selectedId in State.SelectedNodeIds)
{
var selectedNode = State.Nodes.FirstOrDefault(n => n.Id == selectedId);
if (selectedNode != null)
{
State.DragNodesOriginalPositions[selectedId] = (selectedNode.X, selectedNode.Y);
}
}
State.DragEdgesOriginalPositions.Clear();
foreach (var selectedId in State.SelectedEdgeIds)
{
var selectedEdge = State.Edges.FirstOrDefault(n => n.Id == selectedId);
if (selectedEdge != null)
{
if (selectedEdge.VehicleProperties is null) continue;
Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)> vehicleOriginCP = [];
foreach (var vehicle in selectedEdge.VehicleProperties)
{
vehicleOriginCP[vehicle.Id] = (vehicle.TrajectoryControlPoint1X, vehicle.TrajectoryControlPoint1Y, vehicle.TrajectoryControlPoint2X, vehicle.TrajectoryControlPoint2Y);
}
State.DragEdgesOriginalPositions[selectedId] = vehicleOriginCP;
}
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
/* Nodes */
.node {
cursor: pointer;
transition: opacity 0.15s;
}
.node:hover {
opacity: 0.8;
}
.node.selected {
filter: drop-shadow(0 0 3px rgba(25, 118, 210, 0.8));
}
.node.has-station {
/* Green for station nodes */
}
.node-name {
pointer-events: none;
user-select: none;
font-family: 'Segoe UI', sans-serif;
letter-spacing: -0.01em;
}
.node-selection {
pointer-events: none;
animation: pulse 1.5s infinite;
}

View File

@@ -0,0 +1,19 @@
<marker id="originvector" markerWidth="2.4" markerHeight="2.4" refX="0.4" refY="2">
<line x1="0" y1="2" x2="2" y2="2" stroke="red" stroke-width="0.15" />
<path d="M 2 2.2 L 2.4 2 L 2 1.8 Z" fill="red" stroke-width="0" />
<line x1="0.4" y1="2.4" x2="0.4" y2="0.4" stroke="blue" stroke-width="0.15" />
<path d="M 0.6 0.4 L 0.4 0 L 0.2 0.4 Z" fill="blue" stroke-width="0" />
</marker>
@if (State.Level is not null && State.Level.EditorSettings != null)
{
var (_, physicalHeight) = State.GetPhysicalDimensions();
var svgOriginY = physicalHeight + State.Level.EditorSettings.OriginY;
var width = 0.4 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<line x1="@(-State.Level.EditorSettings.OriginX)" y1="@(svgOriginY)" x2="@(-State.Level.EditorSettings.OriginX)" y2="@(svgOriginY)" fill="none" marker-end="url(#originvector)" stroke-width="@width" />
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,161 @@
<!-- Layer 6: Box Select Rectangle -->
@if (isBoxSelecting && boxSelectStart.HasValue && boxSelectEnd.HasValue)
{
var x = Math.Min(boxSelectStart.Value.X, boxSelectEnd.Value.X);
var y = Math.Min(boxSelectStart.Value.Y, boxSelectEnd.Value.Y);
var w = Math.Abs(boxSelectEnd.Value.X - boxSelectStart.Value.X);
var h = Math.Abs(boxSelectEnd.Value.Y - boxSelectStart.Value.Y);
<rect class="box-select"
x="@x.ToString("F2")"
y="@y.ToString("F2")"
width="@w.ToString("F2")"
height="@h.ToString("F2")"
fill="rgba(25, 118, 210, 0.1)"
stroke="#1976d2"
stroke-width="0.02"
stroke-dasharray="0.1,0.05" />
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
private bool isBoxSelecting;
private (double X, double Y)? boxSelectStart;
private (double X, double Y)? boxSelectEnd;
public void UpdateStart(double x, double y)
{
boxSelectStart = (x, y);
isBoxSelecting = true;
StateHasChanged();
}
public void UpdateEnd(double x, double y)
{
if (!isBoxSelecting) return;
boxSelectEnd = (x, y);
StateHasChanged();
}
public void FinishBox()
{
FinishBoxSelect();
}
public void CancelBox()
{
isBoxSelecting = false;
boxSelectStart = null;
boxSelectEnd = null;
StateHasChanged();
}
public void FinishBoxSelect()
{
if (!boxSelectStart.HasValue || !boxSelectEnd.HasValue)
{
isBoxSelecting = false;
return;
}
var minX = Math.Min(boxSelectStart.Value.X, boxSelectEnd.Value.X);
var maxX = Math.Max(boxSelectStart.Value.X, boxSelectEnd.Value.X);
var minY = Math.Min(boxSelectStart.Value.Y, boxSelectEnd.Value.Y);
var maxY = Math.Max(boxSelectStart.Value.Y, boxSelectEnd.Value.Y);
// Get filtered nodes and edges based on SelectedVehicleTypeId (same logic as rendering)
var filteredEdges = State.SelectedVehicleTypeId.HasValue
? State.Edges.Where(e => e.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true).ToList()
: State.Edges.ToList();
var filteredNodes = new List<NodeDto>();
if (State.SelectedVehicleTypeId.HasValue)
{
var nodesWithVehicleType = State.Nodes
.Where(n => n.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true)
.ToList();
var visibleEdgeNodeIds = filteredEdges
.SelectMany(e => new[] { e.StartNodeId, e.EndNodeId })
.Distinct()
.ToHashSet();
var nodesConnectedToVisibleEdges = State.Nodes
.Where(n => visibleEdgeNodeIds.Contains(n.Id))
.ToList();
filteredNodes = nodesWithVehicleType
.Union(nodesConnectedToVisibleEdges)
.DistinctBy(n => n.Id)
.ToList();
}
else
{
filteredNodes = State.Nodes.ToList();
}
// Select nodes completely within the rectangle (only from filtered nodes)
var selectedNodeIds = new List<Guid>();
foreach (var node in filteredNodes)
{
var svg = State.WorldToSvg(node.X, node.Y);
if (svg.X >= minX && svg.X <= maxX && svg.Y >= minY && svg.Y <= maxY)
{
selectedNodeIds.Add(node.Id);
}
}
// Select edges where both start and end nodes are within the rectangle (only from filtered edges)
var selectedEdgeIds = new List<Guid>();
foreach (var edge in filteredEdges)
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
if (startNode != null && endNode != null)
{
var startSvg = State.WorldToSvg(startNode.X, startNode.Y);
var endSvg = State.WorldToSvg(endNode.X, endNode.Y);
// Edge is selected if both nodes are within the rectangle
var startInBox = startSvg.X >= minX && startSvg.X <= maxX && startSvg.Y >= minY && startSvg.Y <= maxY;
var endInBox = endSvg.X >= minX && endSvg.X <= maxX && endSvg.Y >= minY && endSvg.Y <= maxY;
if (startInBox && endInBox)
{
selectedEdgeIds.Add(edge.Id);
}
}
}
// Clear previous selection and set new selection
State.SelectedNodeIds.Clear();
State.SelectedEdgeIds.Clear();
if (selectedNodeIds.Count > 0)
{
foreach (var id in selectedNodeIds)
{
State.SelectedNodeIds.Add(id);
}
}
if (selectedEdgeIds.Count > 0)
{
foreach (var id in selectedEdgeIds)
{
State.SelectedEdgeIds.Add(id);
}
}
State.NotifyStateChanged();
isBoxSelecting = false;
boxSelectStart = null;
boxSelectEnd = null;
StateHasChanged();
}
}

View File

@@ -0,0 +1,4 @@
/* Box select */
.box-select {
pointer-events: none;
}

View File

@@ -0,0 +1,433 @@
@using Microsoft.JSInterop
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel
@inject LayoutEditorState State
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IJSRuntime JSRuntime
@inject NavigationManager Nav
@implements IDisposable
<div class="layout-editor-container" Elevation="0">
<!-- Read-Only Banner -->
@if (State.IsReadOnly && !State.IsLoading)
{
<MudAlert Severity="Severity.Warning"
Variant="Variant.Filled"
Dense="true"
Class="readonly-banner mb-2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Lock" />
<MudText Typo="Typo.body2">
<strong>Read-Only Mode:</strong> @State.ErrorMessage
</MudText>
</MudStack>
</MudAlert>
}
<!-- Top Toolbar -->
<EditorToolbar State="@State"
OnUndo="HandleUndo"
OnRedo="HandleRedo"
OnSave="HandleSave"
OnDelete="HandleDelete"
OnCheck="HandleCheckLayout"
OnExit="HandleExit"/>
<!-- Main Content Area -->
<div class="editor-main-content">
<!-- Left: SVG Canvas -->
<div class="editor-canvas-container" style="width: @(IsPanelOpen ? $"calc(100% - {PanelWidth}px - 8px)" : "100%")">
<!-- Toggle Button - Inside Canvas Container -->
<div class="panel-toggle-button-container">
<MudFab StartIcon="@(IsPanelOpen? Icons.Material.Filled.ChevronRight : Icons.Material.Filled.ChevronLeft)"
Color="Color.Primary"
Size="Size.Small"
OnClick="TogglePanel"/>
</div>
<!-- SVG Canvas -->
@if (State.IsLoading)
{
<div class="loading-overlay">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
<MudText Typo="Typo.body1" Class="mt-4">Loading layout...</MudText>
</div>
}
else if (!string.IsNullOrEmpty(State.ErrorMessage) && !State.IsReadOnly)
{
<div class="error-overlay">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Large" />
<MudText Typo="Typo.h6" Color="Color.Error" Class="mt-4">@State.ErrorMessage</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="HandleExit" Class="mt-4">
Back to Layout Manager
</MudButton>
</div>
}
else
{
<SvgEditorCanvas State="@State"
OnUndo="HandleUndo"
OnRedo="HandleRedo"
OnSave="HandleSave"
OnDelete="HandleDelete"/>
}
</div>
@if (IsPanelOpen)
{
<!-- Resizable Divider -->
<div class="resizable-divider"
@onmousedown="StartResize"
@onmouseup:preventDefault="true">
<div class="resizable-divider-handle"></div>
</div>
<!-- Right: Properties Panel -->
<div class="editor-right-panel" style="width: @($"{PanelWidth}px")">
<EditorRightPanel State="@State" />
</div>
}
<!-- Resize Overlay (shown when resizing) -->
@if (IsResizing)
{
<div class="resize-overlay"
@onmousemove="OnResizeMouseMove"
@onmouseup="StopResize"
@onmouseleave="StopResize"></div>
}
</div>
</div>
@code {
[Parameter]
public Guid LevelId { get; set; }
// Panel state
private double PanelWidth { get; set; } = 350;
private bool IsPanelOpen { get; set; } = true;
private const double MinPanelWidth = 250;
private const double MaxPanelWidth = 600;
// Resize state
private bool IsResizing { get; set; } = false;
private double ResizeStartX { get; set; }
private double ResizeStartWidth { get; set; }
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += HandleStateChanged;
await State.InitializeAsync(LevelId);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Setup keyboard shortcuts
await SetupKeyboardShortcuts();
}
}
public void Dispose()
{
State.OnStateChanged -= HandleStateChanged;
}
private void HandleStateChanged()
{
InvokeAsync(StateHasChanged);
}
private async Task SetupKeyboardShortcuts()
{
// Keyboard shortcuts are handled in svgEditor.js
// This method can be used for additional setup if needed
}
private void HandleUndo()
{
State.Undo();
Snackbar.Add("Undo", Severity.Info, config => config.VisibleStateDuration = 1000);
}
private void HandleRedo()
{
State.Redo();
Snackbar.Add("Redo", Severity.Info, config => config.VisibleStateDuration = 1000);
}
private async Task HandleSave()
{
await State.SaveAsync();
if (string.IsNullOrEmpty(State.ErrorMessage))
{
Snackbar.Add("Saved successfully", Severity.Success);
}
else
{
Snackbar.Add(State.ErrorMessage, Severity.Error);
}
}
private async Task HandleDelete()
{
var selectedNodes = State.GetSelectedNodes();
var selectedEdges = State.GetSelectedEdges();
if (selectedNodes.Count == 0 && selectedEdges.Count == 0)
{
Snackbar.Add("Nothing selected to delete", Severity.Warning);
return;
}
// Collect all edges to delete:
// 1. Selected edges
// 2. Edges connected to selected nodes
var edgesToDelete = new HashSet<Guid>(selectedEdges.Select(e => e.Id));
foreach (var node in selectedNodes)
{
var connectedEdges = State.Edges
.Where(e => e.StartNodeId == node.Id || e.EndNodeId == node.Id)
.Select(e => e.Id);
foreach (var edgeId in connectedEdges)
{
edgesToDelete.Add(edgeId);
}
}
if (edgesToDelete.Count == 0)
{
Snackbar.Add("Nothing to delete", Severity.Warning);
return;
}
// Show confirmation dialog
var message = $"Are you sure you want to delete the following items?\n\n" +
$"• {selectedNodes.Count} node(s)\n" +
$"• {edgesToDelete.Count} edge(s)\n\n" +
$"Orphaned nodes (nodes without edges) will be automatically removed.";
var result = await DialogService.ShowMessageBoxAsync(
"Confirm Deletion",
message,
yesText: "Delete",
cancelText: "Cancel");
if (result != true)
{
return;
}
// Delete edges (nodes will be auto-deleted if orphaned)
var success = await State.DeleteEdgesAsync(edgesToDelete.ToList());
if (success)
{
State.ClearSelection();
Snackbar.Add(
$"Deleted {edgesToDelete.Count} edge(s). Nodes were removed if orphaned.",
Severity.Success);
}
else
{
Snackbar.Add(
State.ErrorMessage ?? "Failed to delete",
Severity.Error);
}
}
private async Task HandleCheckLayout()
{
var issues = new List<string>();
var warnings = new List<string>();
// Get editor settings
var minEdgeLength = State.Level?.EditorSettings?.EdgeMinLengthCreate ?? 0.1;
// 1. Check for isolated nodes (nodes without any edges)
var nodesWithEdges = new HashSet<Guid>();
foreach (var edge in State.Edges)
{
nodesWithEdges.Add(edge.StartNodeId);
nodesWithEdges.Add(edge.EndNodeId);
}
var isolatedNodes = State.Nodes
.Where(n => !nodesWithEdges.Contains(n.Id))
.ToList();
if (isolatedNodes.Count > 0)
{
warnings.Add($"{isolatedNodes.Count} isolated node(s) found (nodes without edges): {string.Join(", ", isolatedNodes.Take(5).Select(n => n.NodeName ?? n.NodeId))}{(isolatedNodes.Count > 5 ? "..." : "")}");
}
// 2. Check edge minimum length
var shortEdges = new List<(EdgeDto Edge, double Length)>();
foreach (var edge in State.Edges)
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
if (startNode != null && endNode != null)
{
var dx = endNode.X - startNode.X;
var dy = endNode.Y - startNode.Y;
var length = Math.Sqrt(dx * dx + dy * dy);
if (length < minEdgeLength)
{
shortEdges.Add((edge, length));
}
}
}
if (shortEdges.Count > 0)
{
issues.Add($"{shortEdges.Count} edge(s) shorter than minimum length ({minEdgeLength:F2}m): {string.Join(", ", shortEdges.Take(5).Select(e => $"{e.Edge.EdgeName ?? e.Edge.EdgeId} ({e.Length:F2}m)"))}{(shortEdges.Count > 5 ? "..." : "")}");
}
// 3. Check for duplicate node positions (nodes too close)
var duplicatePositions = new List<(NodeDto Node1, NodeDto Node2, double Distance)>();
for (int i = 0; i < State.Nodes.Count; i++)
{
for (int j = i + 1; j < State.Nodes.Count; j++)
{
var node1 = State.Nodes[i];
var node2 = State.Nodes[j];
var dx = node2.X - node1.X;
var dy = node2.Y - node1.Y;
var distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < 0.01) // Less than 1cm apart
{
duplicatePositions.Add((node1, node2, distance));
}
}
}
if (duplicatePositions.Count > 0)
{
warnings.Add($"{duplicatePositions.Count} pair(s) of nodes are very close (< 1cm): {string.Join(", ", duplicatePositions.Take(3).Select(p => $"{p.Node1.NodeName ?? p.Node1.NodeId} & {p.Node2.NodeName ?? p.Node2.NodeId}"))}{(duplicatePositions.Count > 3 ? "..." : "")}");
}
// 4. Check for edges with same start and end node
var selfLoops = State.Edges
.Where(e => e.StartNodeId == e.EndNodeId)
.ToList();
if (selfLoops.Count > 0)
{
warnings.Add($"{selfLoops.Count} self-loop edge(s) found (start = end): {string.Join(", ", selfLoops.Take(5).Select(e => e.EdgeName ?? e.EdgeId))}{(selfLoops.Count > 5 ? "..." : "")}");
}
// Display results
if (issues.Count == 0 && warnings.Count == 0)
{
Snackbar.Add("Layout check completed - No issues found", Severity.Success);
}
else
{
var message = new System.Text.StringBuilder();
if (issues.Count > 0)
{
message.AppendLine($"<strong>{issues.Count} Issue(s) Found:</strong>");
foreach (var issue in issues)
{
message.AppendLine($"• {issue}");
}
}
if (warnings.Count > 0)
{
if (issues.Count > 0) message.AppendLine();
message.AppendLine($"<strong>{warnings.Count} Warning(s):</strong>");
foreach (var warning in warnings)
{
message.AppendLine($"• {warning}");
}
}
await DialogService.ShowMessageBoxAsync(
issues.Count > 0 ? "Layout Check - Issues Found" : "Layout Check - Warnings",
message.ToString(),
yesText: "OK",
cancelText: null);
}
}
private async Task HandleExit()
{
if (State.HasUnsavedChanges)
{
var result = await DialogService.ShowMessageBoxAsync(
"Unsaved Changes",
"You have unsaved changes. Are you sure you want to leave?",
yesText: "Leave",
cancelText: "Cancel");
if (result != true)
{
return;
}
}
State.ErrorMessage = null;
Nav.NavigateTo("/layout-manager");
State.NotifyStateChanged();
}
// ==========================================
// PANEL TOGGLE & RESIZE
// ==========================================
private void TogglePanel()
{
IsPanelOpen = !IsPanelOpen;
StateHasChanged();
}
private void StartResize(Microsoft.AspNetCore.Components.Web.MouseEventArgs e)
{
IsResizing = true;
ResizeStartX = e.ClientX;
ResizeStartWidth = PanelWidth;
StateHasChanged();
}
private void StopResize()
{
if (IsResizing)
{
IsResizing = false;
StateHasChanged();
}
}
private void OnResizeMouseMove(Microsoft.AspNetCore.Components.Web.MouseEventArgs e)
{
if (!IsResizing) return;
var deltaX = ResizeStartX - e.ClientX; // Inverted because we're dragging left
var newWidth = ResizeStartWidth + deltaX;
// Clamp to min/max
if (newWidth < MinPanelWidth)
{
PanelWidth = MinPanelWidth;
}
else if (newWidth > MaxPanelWidth)
{
PanelWidth = MaxPanelWidth;
}
else
{
PanelWidth = newWidth;
}
StateHasChanged();
}
}

View File

@@ -0,0 +1,98 @@
.layout-editor-container {
display: flex;
flex-direction: column;
height: calc(100vh - 50px); /* Subtract app header height */
width: 100%;
overflow: hidden;
background-color: var(--mud-palette-background);
}
.editor-main-content {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0; /* Important for flex child overflow */
}
.editor-canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background-color: #f0f0f0;
min-width: 0; /* Important for flex child overflow */
transition: width 0.2s ease;
}
.editor-right-panel {
overflow: hidden;
background-color: var(--mud-palette-surface);
border-left: 1px solid var(--mud-palette-lines-default);
flex-shrink: 0;
transition: width 0.2s ease;
}
.resizable-divider {
width: 8px;
cursor: col-resize;
background-color: var(--mud-palette-background);
border-left: 1px solid var(--mud-palette-lines-default);
border-right: 1px solid var(--mud-palette-lines-default);
position: relative;
flex-shrink: 0;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
}
.resizable-divider:hover {
background-color: var(--mud-palette-action-hover);
}
.resizable-divider-handle {
width: 2px;
height: 40px;
background-color: var(--mud-palette-text-secondary);
border-radius: 1px;
opacity: 0.5;
}
.resizable-divider:hover .resizable-divider-handle {
opacity: 1;
background-color: var(--mud-palette-primary);
}
.panel-toggle-button-container {
position: absolute;
right: 8px;
top: 8px;
z-index: 10;
display: flex;
align-items: center;
}
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.9);
z-index: 100;
}
.resize-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
cursor: col-resize;
user-select: none;
}

View File

@@ -0,0 +1,19 @@
<div class="mouse-position-display">
<span class="coord-label">X:</span>
<span class="coord-value">@X.ToString("F2") m</span>
<span class="coord-label">Y:</span>
<span class="coord-value">@Y.ToString("F2") m</span>
</div>
@code {
private double X { get; set; }
private double Y { get; set; }
public void Update(double x, double y)
{
X = x;
Y = y;
StateHasChanged();
}
}

View File

@@ -0,0 +1,28 @@
.mouse-position-display {
position: absolute;
top: 10px;
left: 10px;
z-index: 50;
background-color: rgba(33, 33, 33, 0.85);
color: white;
padding: 6px 12px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
display: flex;
gap: 8px;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.coord-label {
color: #aaa;
font-weight: 500;
}
.coord-value {
color: #4fc3f7;
font-weight: bold;
min-width: 70px;
}

View File

@@ -0,0 +1,267 @@
@using System.Text.Json
@using RobotNet.VDA5050
@using RobotNet.VDA5050.Type
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel.Dialogs
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<MudPaper Elevation="2" Class="pa-2">
<!-- Header with Add button -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Actions</MudText>
<MudStack Row="true" Spacing="1">
@if (DefaultActions != null && DefaultActions.Count > 0)
{
<MudTooltip Text="Add actions from vehicle type default">
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
Size="Size.Small"
Color="Color.Primary"
Disabled="@IsReadOnly"
OnClick="OpenAddFromDefaultDialog" />
</MudTooltip>
}
<MudTooltip Text="Create new action">
<MudIconButton Icon="@Icons.Material.Filled.Add"
Size="Size.Small"
Color="Color.Success"
Disabled="@IsReadOnly"
OnClick="OpenCreateActionDialog" />
</MudTooltip>
</MudStack>
</MudStack>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true" Class="my-2">@errorMessage</MudAlert>
}
<!-- Actions List -->
@if (actions.Count == 0)
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
No actions defined. Click + to add actions.
</MudText>
</MudPaper>
}
else
{
<MudList T="string" Dense="true">
@for (int i = 0; i < actions.Count; i++)
{
var index = i;
var action = actions[i];
<MudListItem T="string" Class="px-2">
<MudPaper Elevation="1" Class="pa-2">
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Text">
@action.ActionType
</MudChip>
<MudChip T="string" Size="Size.Small"
Color="@GetRequirementColor(action.RequirementType)"
Variant="Variant.Text">
@action.RequirementType
</MudChip>
</MudStack>
<MudStack Row="true" Spacing="0">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Disabled="@IsReadOnly"
OnClick="() => OpenEditActionDialog(index)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Disabled="@IsReadOnly"
OnClick="() => RemoveAction(index)" />
</MudStack>
</MudStack>
@if (!string.IsNullOrEmpty(action.ActionDescription))
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
@action.ActionDescription
</MudText>
}
<MudText Typo="Typo.caption">
Blocking: <strong>@action.BlockingType</strong>
</MudText>
@if (action.ActionParameters != null && action.ActionParameters.Count > 0)
{
<MudText Typo="Typo.caption">
Parameters: @string.Join(", ", action.ActionParameters.Select(p => $"{p.Key}={p.Value}"))
</MudText>
}
</MudStack>
</MudPaper>
</MudListItem>
}
</MudList>
}
</MudPaper>
@code {
[Parameter]
public string? ActionsJson { get; set; }
[Parameter]
public EventCallback<string?> ActionsJsonChanged { get; set; }
[Parameter]
public List<ActionDto>? DefaultActions { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private List<ActionDto> actions = new();
private string? errorMessage;
protected override void OnParametersSet()
{
ParseJson();
base.OnParametersSet();
}
private void ParseJson()
{
actions.Clear();
errorMessage = null;
if (string.IsNullOrWhiteSpace(ActionsJson))
{
return;
}
try
{
var parsed = JsonSerializer.Deserialize<List<ActionDto>>(ActionsJson, JsonOptionExtends.Read);
if (parsed != null)
{
actions.AddRange(parsed);
}
}
catch (Exception ex)
{
errorMessage = $"Invalid JSON: {ex.Message}";
Snackbar.Add(errorMessage, Severity.Error);
}
}
private async Task UpdateJson()
{
errorMessage = null;
if (actions.Count == 0)
{
await ActionsJsonChanged.InvokeAsync(null);
}
else
{
try
{
var json = JsonSerializer.Serialize(actions, JsonOptionExtends.Write);
await ActionsJsonChanged.InvokeAsync(json);
}
catch (Exception ex)
{
errorMessage = $"Failed to serialize: {ex.Message}";
Snackbar.Add(errorMessage, Severity.Error);
}
}
}
private Color GetRequirementColor(RequirementType requirementType)
{
return requirementType switch
{
RequirementType.REQUIRED => Color.Error,
RequirementType.CONDITIONAL => Color.Warning,
RequirementType.OPTIONAL => Color.Info,
_ => Color.Default
};
}
private async Task OpenAddFromDefaultDialog()
{
if (DefaultActions == null || DefaultActions.Count == 0) return;
var parameters = new DialogParameters
{
["DefaultActions"] = DefaultActions,
["ExistingActions"] = actions
};
var dialog = await DialogService.ShowAsync<AddActionsFromDefaultDialog>(
"Add Actions from Vehicle Type",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is List<ActionDto> selectedActions)
{
foreach (var action in selectedActions)
{
actions.Add(action);
}
await UpdateJson();
StateHasChanged();
Snackbar.Add($"Added {selectedActions.Count} action(s)", Severity.Success);
}
}
private async Task OpenCreateActionDialog()
{
var dialog = await DialogService.ShowAsync<EditActionDialog>(
"Create New Action",
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is ActionDto newAction)
{
actions.Add(newAction);
await UpdateJson();
StateHasChanged();
Snackbar.Add("Action created", Severity.Success);
}
}
private async Task OpenEditActionDialog(int index)
{
var actionToEdit = actions[index];
var parameters = new DialogParameters
{
["Action"] = actionToEdit
};
var dialog = await DialogService.ShowAsync<EditActionDialog>(
"Edit Action",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is ActionDto editedAction)
{
actions[index] = editedAction;
await UpdateJson();
StateHasChanged();
Snackbar.Add("Action updated", Severity.Success);
}
}
private async Task RemoveAction(int index)
{
actions.RemoveAt(index);
await UpdateJson();
StateHasChanged();
Snackbar.Add("Action removed", Severity.Info);
}
}

View File

@@ -0,0 +1,102 @@
@using RobotNet.VDA5050.Type
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Add Actions from Vehicle Type</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Select actions to add from vehicle type default actions.
</MudText>
@if (DefaultActions == null || DefaultActions.Count == 0)
{
<MudAlert Severity="Severity.Info">
No default actions available for this vehicle type.
</MudAlert>
}
else
{
<MudList T="ActionDto" Dense="true" @bind-SelectedValues="selectedActions" SelectionMode="SelectionMode.MultiSelection" CheckBoxColor="Color.Tertiary">
@foreach (var action in DefaultActions)
{
var isAlreadyAdded = ExistingActions.Any(a => a.ActionType == action.ActionType);
<MudListItem T="ActionDto" Value="@action">
<MudStack Row="true" Spacing="2" Justify="Justify.FlexStart" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body1">
<strong>@action.ActionType</strong>
</MudText>
<MudChip T="string" Size="Size.Small"
Color="@GetRequirementColor(action.RequirementType)"
Variant="Variant.Text">
@action.RequirementType
</MudChip>
@if (isAlreadyAdded)
{
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">
Already added
</MudChip>
}
</MudStack>
</MudListItem>
}
</MudList>
<MudText Typo="Typo.caption" Color="Color.Secondary">
Selected: @selectedActions.Count / @DefaultActions.Count
</MudText>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(selectedActions.Count == 0)">
Add Selected
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public List<ActionDto> DefaultActions { get; set; } = new();
[Parameter]
public List<ActionDto> ExistingActions { get; set; } = new();
private IReadOnlyCollection<ActionDto> selectedActions = [];
public IReadOnlyCollection<string> SelectedValues = ["Milk", "Cafe Latte"];
private Color GetRequirementColor(RequirementType requirementType)
{
return requirementType switch
{
RequirementType.REQUIRED => Color.Error,
RequirementType.CONDITIONAL => Color.Warning,
RequirementType.OPTIONAL => Color.Info,
_ => Color.Default
};
}
private void Cancel()
{
MudDialog?.Cancel();
}
private void Submit()
{
MudDialog?.Close(DialogResult.Ok(selectedActions));
}
}

View File

@@ -0,0 +1,92 @@
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Add Vehicle Type</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Select a vehicle type to add properties for this node.
</MudText>
@if (AvailableVehicleTypes == null || AvailableVehicleTypes.Count == 0)
{
<MudAlert Severity="Severity.Info">
No available vehicle types.
</MudAlert>
}
else
{
<MudList T="VehicleTypeDto" Dense="true">
@foreach (var vehicleType in AvailableVehicleTypes)
{
<MudListItem T="VehicleTypeDto"
OnClick="() => SelectVehicleType(vehicleType)"
Class="@(selectedVehicleType?.Id == vehicleType.Id ? "mud-primary-text" : "")">
<MudStack Spacing="1">
<MudText Typo="Typo.body1">
<strong>@vehicleType.VehicleTypeName</strong>
</MudText>
@if (!string.IsNullOrWhiteSpace(vehicleType.VehicleTypeId))
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
ID: @vehicleType.VehicleTypeId
</MudText>
}
@if (!string.IsNullOrWhiteSpace(vehicleType.Description))
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
@vehicleType.Description
</MudText>
}
</MudStack>
</MudListItem>
}
</MudList>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(selectedVehicleType == null)">
Add
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public List<VehicleTypeDto> AvailableVehicleTypes { get; set; } = new();
private VehicleTypeDto? selectedVehicleType;
private void SelectVehicleType(VehicleTypeDto vehicleType)
{
selectedVehicleType = vehicleType;
StateHasChanged();
}
private void Cancel()
{
MudDialog?.Cancel();
}
private void Submit()
{
if (selectedVehicleType != null)
{
MudDialog?.Close(DialogResult.Ok(selectedVehicleType));
}
}
}

View File

@@ -0,0 +1,159 @@
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Services.API
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Create New Station</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudTextField @bind-Value="request.StationId"
Label="Station ID *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Unique identifier (must be unique within this level)" />
<MudTextField @bind-Value="request.StationName"
Label="Station Name"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Display name for this station" />
<MudTextField @bind-Value="request.StationDescription"
Label="Description"
Lines="2"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Optional description" />
<MudDivider Class="my-2" />
<MudText Typo="Typo.subtitle2">Position</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="request.X"
Label="X (meters) *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3" />
<MudNumericField @bind-Value="request.Y"
Label="Y (meters) *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3" />
</MudStack>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="request.Theta"
Label="Theta (radians)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="-3.14159"
Max="3.14159"
HelperText="Range: [-π ... π]" />
<MudNumericField @bind-Value="request.StationHeight"
Label="Height (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
HelperText="Optional" />
</MudStack>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isSubmitting)">
@if (isSubmitting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Creating...</MudText>
}
else
{
<MudText>Create</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public Guid LayoutLevelId { get; set; }
[Parameter]
public double? DefaultX { get; set; }
[Parameter]
public double? DefaultY { get; set; }
[Parameter]
public List<Guid>? DefaultInteractionNodeIds { get; set; }
private CreateStationRequest request = new();
private bool isSubmitting = false;
private string? errorMessage;
protected override void OnParametersSet()
{
request.LayoutLevelId = LayoutLevelId;
request.X = DefaultX ?? 0.0;
request.Y = DefaultY ?? 0.0;
request.InteractionNodeIds = DefaultInteractionNodeIds ?? new List<Guid>();
}
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(request.StationId);
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!IsValid())
{
errorMessage = "Please fill in all required fields";
return;
}
isSubmitting = true;
errorMessage = null;
StateHasChanged();
try
{
var createdStation = await ApiService.CreateStationAsync(request);
MudDialog?.Close(DialogResult.Ok(createdStation));
}
catch (Exception ex)
{
errorMessage = ex.Message;
isSubmitting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,181 @@
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet.VDA5050.Type
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">@(Action == null ? "Create" : "Edit") Action</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudTextField @bind-Value="actionType"
Label="Action Type"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Required="true"
Placeholder="e.g., pick, drop, charge"
Error="@(!string.IsNullOrEmpty(errorActionType))"
ErrorText="@errorActionType" />
<MudTextField @bind-Value="actionDescription"
Label="Action Description"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2"
Placeholder="Optional description of the action" />
<MudSelect T="RequirementType" @bind-Value="requirementType"
Label="Requirement Type"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true">
<MudSelectItem Value="@RequirementType.REQUIRED">REQUIRED</MudSelectItem>
<MudSelectItem Value="@RequirementType.CONDITIONAL">CONDITIONAL</MudSelectItem>
<MudSelectItem Value="@RequirementType.OPTIONAL">OPTIONAL</MudSelectItem>
</MudSelect>
<MudSelect T="BlockingType" @bind-Value="blockingType"
Label="Blocking Type"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true">
<MudSelectItem Value="@BlockingType.NONE">NONE</MudSelectItem>
<MudSelectItem Value="@BlockingType.SOFT">SOFT</MudSelectItem>
<MudSelectItem Value="@BlockingType.HARD">HARD</MudSelectItem>
</MudSelect>
<MudDivider />
<!-- Action Parameters -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Parameters</MudText>
<MudButton Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add"
OnClick="AddParameter">
Add Parameter
</MudButton>
</MudStack>
@if (parameters.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
No parameters. Click "Add Parameter" to add.
</MudText>
}
else
{
@for (int i = 0; i < parameters.Count; i++)
{
var index = i;
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudTextField @bind-Value="parameters[index].Key"
Label="Key"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex: 1;" />
<MudTextField @bind-Value="parameters[index].Value"
Label="Value"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex: 1;" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="() => RemoveParameter(index)" />
</MudStack>
}
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit">
@(Action == null ? "Create" : "Save")
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public ActionDto? Action { get; set; }
private string actionType = string.Empty;
private string? actionDescription;
private RequirementType requirementType;
private BlockingType blockingType;
private List<ActionParameterDto> parameters = new();
private string? errorActionType;
protected override void OnParametersSet()
{
if (Action != null)
{
actionType = Action.ActionType;
actionDescription = Action.ActionDescription;
requirementType = Action.RequirementType;
blockingType = Action.BlockingType;
if (Action.ActionParameters != null)
{
parameters = new List<ActionParameterDto>(Action.ActionParameters);
}
}
}
private void AddParameter()
{
parameters.Add(new ActionParameterDto());
}
private void RemoveParameter(int index)
{
parameters.RemoveAt(index);
}
private void Cancel()
{
MudDialog?.Cancel();
}
private void Submit()
{
errorActionType = null;
// Validate
if (string.IsNullOrWhiteSpace(actionType))
{
errorActionType = "Action type is required";
Snackbar.Add(errorActionType, Severity.Error);
return;
}
var result = new ActionDto
{
ActionType = actionType.Trim(),
ActionDescription = string.IsNullOrWhiteSpace(actionDescription) ? null : actionDescription.Trim(),
RequirementType = requirementType,
BlockingType = blockingType,
ActionParameters = parameters
.Where(p => !string.IsNullOrWhiteSpace(p.Key))
.Select(p => new ActionParameterDto
{
Key = p.Key.Trim(),
Value = p.Value?.Trim() ?? string.Empty
})
.ToList()
};
MudDialog?.Close(DialogResult.Ok(result));
}
}

View File

@@ -0,0 +1,185 @@
@using RobotNet10.MapEditor.Shared.DTOs.Station
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Services.API
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Edit Station</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudTextField Value="@Station.StationId"
Label="Station ID"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true"
HelperText="Station ID cannot be changed" />
<MudTextField @bind-Value="stationName"
Label="Station Name"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Display name for this station" />
<MudTextField @bind-Value="stationDescription"
Label="Description"
Lines="2"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Optional description" />
<MudDivider Class="my-2" />
<MudText Typo="Typo.subtitle2">Position</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="x"
Label="X (meters) *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3" />
<MudNumericField @bind-Value="y"
Label="Y (meters) *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3" />
</MudStack>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="theta"
Label="Theta (radians)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="-3.14159"
Max="3.14159"
HelperText="Range: [-π ... π]" />
<MudNumericField @bind-Value="stationHeight"
Label="Height (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
HelperText="Optional" />
</MudStack>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isSubmitting)">
@if (isSubmitting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Saving...</MudText>
}
else
{
<MudText>Save</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public StationDto Station { get; set; } = null!;
[Parameter]
public Guid LayoutLevelId { get; set; }
private string? stationName;
private string? stationDescription;
private double x;
private double y;
private double? theta;
private double? stationHeight;
private bool isSubmitting = false;
private string? errorMessage;
protected override void OnParametersSet()
{
stationName = Station.StationName;
stationDescription = Station.StationDescription;
x = Station.X;
y = Station.Y;
theta = Station.Theta;
stationHeight = Station.StationHeight;
}
private bool IsValid()
{
return x != 0 || y != 0; // At least one coordinate must be set
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!IsValid())
{
errorMessage = "Position coordinates are required";
return;
}
// Validate theta range
if (theta.HasValue && (theta.Value < -Math.PI || theta.Value > Math.PI))
{
errorMessage = "Theta must be between -π and π";
return;
}
isSubmitting = true;
errorMessage = null;
StateHasChanged();
try
{
// Preserve existing interaction nodes
var interactionNodeIds = Station.InteractionNodes?
.Select(i => i.NodeId)
.ToList() ?? new List<Guid>();
var updateRequest = new UpdateStationRequest
{
StationName = stationName,
StationDescription = stationDescription,
X = x,
Y = y,
Theta = theta,
StationHeight = stationHeight,
InteractionNodeIds = interactionNodeIds
};
var updatedStation = await ApiService.UpdateStationAsync(Station.Id, updateRequest);
MudDialog?.Close(DialogResult.Ok(updatedStation));
}
catch (Exception ex)
{
errorMessage = ex.Message;
isSubmitting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,92 @@
@using RobotNet10.MapEditor.Shared.DTOs.Station
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Link Existing Station</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Select a station to link to this node.
</MudText>
@if (AvailableStations == null || AvailableStations.Count == 0)
{
<MudAlert Severity="Severity.Info">
No available stations to link.
</MudAlert>
}
else
{
<MudList T="StationDto" Dense="true">
@foreach (var station in AvailableStations)
{
<MudListItem T="StationDto"
OnClick="() => SelectStation(station)"
Class="@(selectedStation?.Id == station.Id ? "mud-primary-text" : "")">
<MudStack Spacing="1">
<MudText Typo="Typo.body1">
<strong>@(station.StationName ?? station.StationId)</strong>
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
ID: @station.StationId
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
Position: (@station.X.ToString("F2"), @station.Y.ToString("F2"))
</MudText>
@if (station.InteractionNodes != null && station.InteractionNodes.Count > 0)
{
<MudText Typo="Typo.caption" Color="Color.Info">
Currently linked to @station.InteractionNodes.Count node(s)
</MudText>
}
</MudStack>
</MudListItem>
}
</MudList>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(selectedStation == null)">
Link
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public List<StationDto> AvailableStations { get; set; } = new();
private StationDto? selectedStation;
private void SelectStation(StationDto station)
{
selectedStation = station;
StateHasChanged();
}
private void Cancel()
{
MudDialog?.Cancel();
}
private void Submit()
{
if (selectedStation != null)
{
MudDialog?.Close(DialogResult.Ok(selectedStation));
}
}
}

View File

@@ -0,0 +1,668 @@
@using RobotNet.VDA5050
@using RobotNet.VDA5050.Order
@using RobotNet.VDA5050.Type
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel.Dialogs
@using System.Text.Json
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<MudStack Spacing="2">
<MudStack Row="true" Spacing="1" Class="d-flex justify-content-between">
<MudText Typo="Typo.subtitle1" Color="Color.Primary">
<MudIcon Icon="@Icons.Material.Filled.RadioButtonChecked" Size="Size.Small" Class="mr-1" />
Edge Properties
</MudText>
<!-- Save Button -->
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
StartIcon="@Icons.Material.Filled.Save"
Size="Size.Small"
Disabled="@IsReadOnly"
OnClick="HandleSave">
Save
</MudButton>
</div>
</MudStack>
<MudDivider />
<MudPaper style="overflow-y: auto; height: calc(100vh - 237px)" Elevation="0">
<!-- Basic Info (always visible) -->
<MudStack Spacing="2">
<MudTextField @bind-Value="Edge.EdgeId"
Label="Edge ID"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Tag" />
<MudTextField @bind-Value="Edge.EdgeName"
Label="Edge Name"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="@IsReadOnly" />
<MudTextField @bind-Value="Edge.EdgeDescription"
Label="Description"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2"
ReadOnly="@IsReadOnly" />
<!-- Nodes (read-only) -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Connected Nodes</MudText>
<MudStack Row="true" Spacing="2">
<MudTextField Value="@GetStartNodeName()"
Label="Start Node"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true" />
<MudTextField Value="@GetEndNodeName()"
Label="End Node"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true" />
</MudStack>
<!-- Length (auto-calculated) -->
<MudTextField Value="@GetEdgeLength()"
Label="Length (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true" />
</MudStack>
<!-- Expandable Sections -->
<MudExpansionPanels Dense="true">
<!-- Vehicle Properties -->
@if (State.VehicleTypes.Count > 0)
{
<MudExpansionPanel Text="Vehicle Properties" Expanded="false">
<MudStack Spacing="2" Style="overflow-y: hidden">
<!-- Vehicle Properties Table -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Vehicle Properties</MudText>
<MudButton Size="Size.Small"
Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Add"
Disabled="@IsReadOnly"
OnClick="OpenAddVehicleTypeDialog">
Add
</MudButton>
</MudStack>
@if (Edge.VehicleProperties == null || Edge.VehicleProperties.Count == 0)
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
No vehicle properties defined. Click "Add Vehicle Type" to add.
</MudText>
</MudPaper>
}
else
{
<MudTable Items="@Edge.VehicleProperties" Dense="true" Hover="true" Striped="true" Class="mb-1">
<HeaderContent>
<MudTh>Vehicle Type</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
@{
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == context.VehicleTypeId);
var isSelected = selectedVehicleProperty is not null && selectedVehicleProperty.Id == context.Id;
}
<MudTd DataLabel="Vehicle Type">
<MudText Typo="Typo.body2" Color="@(isSelected? Color.Primary: Color.Default)">
@(vehicleType?.VehicleTypeName ?? "Unknown")
</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="@(isSelected ? Color.Primary : Color.Default)"
Disabled="@IsReadOnly"
OnClick="() => SelectVehicleType(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Disabled="@IsReadOnly"
OnClick="() => RemoveVehicleProperty(context)" />
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
}
<!-- Edit Form for Selected Vehicle Type -->
@if (selectedVehicleProperty is not null)
{
var VehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == selectedVehicleProperty.VehicleTypeId);
<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
Editing: @(VehicleType?.VehicleTypeName ?? "Unknown")
</MudText>
<!-- Trajectory -->
<TrajectoryEditor State="State"
Degree="@selectedVehicleProperty?.TrajectoryDegree"
StartNode="@Edge.StartNode"
EndNode="@Edge.EndNode"
ControlPoint1X="@selectedVehicleProperty?.TrajectoryControlPoint1X"
ControlPoint1Y="@selectedVehicleProperty?.TrajectoryControlPoint1Y"
ControlPoint2X="@selectedVehicleProperty?.TrajectoryControlPoint2X"
ControlPoint2Y="@selectedVehicleProperty?.TrajectoryControlPoint2Y"
TrajectoryFieldsChanged="(fields) => UpdateTrajectoryFields(fields)"
IsReadOnly="@IsReadOnly" />
<!-- Actions -->
<ActionsEditor ActionsJson="@selectedVehicleProperty?.Actions"
ActionsJsonChanged="(json) => UpdateVehicleActions(json)"
DefaultActions="@GetDefaultActions()"
IsReadOnly="@IsReadOnly" />
<!-- Orientation -->
<MudSelect T="OrientationType ?"
Value="@selectedVehicleProperty?.OrientationType"
ValueChanged="(v) => UpdateVehicleOrientationType(v)"
Label="Orientation Type"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Clearable="true"
Disabled="@IsReadOnly">
<MudSelectItem Value="@((OrientationType?)OrientationType.GLOBAL)">GLOBAL</MudSelectItem>
<MudSelectItem Value="@((OrientationType?)OrientationType.TANGENTIAL)">TANGENTIAL</MudSelectItem>
</MudSelect>
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.VehicleOrientation"
ValueChanged="(v) => UpdateVehicleOrientation(v)"
Label="Vehicle Orientation (degrees)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
Max="360"
ReadOnly="@IsReadOnly" />
<!-- Speed -->
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.MaxSpeed"
ValueChanged="(v) => UpdateVehicleMaxSpeed(v)"
Label="Max Speed (m/s)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly" />
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.MaxRotationSpeed"
ValueChanged="(v) => UpdateVehicleMaxRotationSpeed(v)"
Label="Max Rotation Speed (rad/s)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly" />
<!-- Height Restrictions -->
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.MinHeight"
ValueChanged="(v) => UpdateVehicleMinHeight(v)"
Label="Min Height (m)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly" />
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.MaxHeight"
ValueChanged="(v) => UpdateVehicleMaxHeight(v)"
Label="Max Height (m)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly" />
<!-- Rotation allowed -->
<MudCheckBox T="bool?"
Value="@selectedVehicleProperty?.RotationAllowed"
ValueChanged="(v) => UpdateVehicleRotationAllowed(v)"
Disabled="@IsReadOnly"
Label="Rotation Allowed">
</MudCheckBox>
<MudSelect T="RotationDirection ?"
Value="@selectedVehicleProperty?.RotationAtStartNodeAllowed"
ValueChanged="(v) => UpdateRotationAtStart(v)"
Label="Rotation at Start"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Clearable="true"
Disabled="@IsReadOnly">
<MudSelectItem Value="@((RotationDirection?)RotationDirection.NONE)">NONE</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.CCW)">CCW</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.CW)">CW</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.BOTH)">BOTH</MudSelectItem>
</MudSelect>
<MudSelect T="RotationDirection ?"
Value="@selectedVehicleProperty?.RotationAtEndNodeAllowed"
ValueChanged="(v) => UpdateRotationAtEnd(v)"
Label="Rotation at End"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Clearable="true"
Disabled="@IsReadOnly">
<MudSelectItem Value="@((RotationDirection?)RotationDirection.NONE)">NONE</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.CCW)">CCW</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.CW)">CW</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.BOTH)">BOTH</MudSelectItem>
</MudSelect>
<!-- Load Restriction -->
<MudText Typo="Typo.subtitle2">Load Restriction</MudText>
<MudStack Spacing="2" Class="pa-2">
<MudStack Row="true" Spacing="2">
<MudCheckBox T="bool?" Value="@(selectedVehicleProperty?.LoadRestriction?.Unloaded)"
ValueChanged="(v) => UpdateLoadRestrictionUnloaded(v)"
Disabled="@IsReadOnly"
Label="Unloaded">
</MudCheckBox>
<MudCheckBox T="bool?"
Value="@(selectedVehicleProperty?.LoadRestriction?.Loaded)"
ValueChanged="(v) => UpdateLoadRestrictionLoaded(v)"
Disabled="@IsReadOnly"
Label="Loaded">
</MudCheckBox>
</MudStack>
<LoadSetNamesEditor LoadSetNames="@selectedVehicleProperty?.LoadRestriction?.LoadSetNames"
LoadSetNamesChanged="(list) => UpdateLoadSetNames(list)"
IsReadOnly="@IsReadOnly" />
</MudStack>
<!-- Corridor (VDA5050) -->
<MudText Typo="Typo.subtitle2" Class="mt-3">Corridor (VDA5050)</MudText>
<MudStack Spacing="2" Class="pa-2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Definition of boundaries in which a vehicle can deviate from its trajectory
</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField T="double?"
Value="@(selectedVehicleProperty?.CorridorLeftWidth)"
ValueChanged="(v) => UpdateCorridorLeftWidth(v)"
Label="Left Width (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly"
HelperText="Width to the left of trajectory" />
<MudNumericField T="double?"
Value="@(selectedVehicleProperty?.CorridorRightWidth)"
ValueChanged="(v) => UpdateCorridorRightWidth(v)"
Label="Right Width (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly"
HelperText="Width to the right of trajectory" />
</MudStack>
<MudSelect T="CorridorRefPoint ?"
Value="@selectedVehicleProperty?.CorridorRefPoint"
ValueChanged="(v) => UpdateCorridorRefPoint(v)"
Label="Corridor Reference Point"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Clearable="true"
Disabled="@IsReadOnly">
<MudSelectItem Value="@((CorridorRefPoint?)null)">None</MudSelectItem>
<MudSelectItem Value="@((CorridorRefPoint?)CorridorRefPoint.KINEMATICCENTER)">@CorridorRefPoint.KINEMATICCENTER</MudSelectItem>
<MudSelectItem Value="@((CorridorRefPoint?)CorridorRefPoint.CONTOUR)">@CorridorRefPoint.CONTOUR</MudSelectItem>
</MudSelect>
</MudStack>
}
</MudStack>
</MudExpansionPanel>
}
</MudExpansionPanels>
</MudPaper>
</MudStack>
@code {
[Parameter]
public EdgeDto Edge { get; set; } = null!;
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EventCallback<EdgeDto> OnSave { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private EdgeVehiclePropertyDto? selectedVehicleProperty;
private string GetStartNodeName()
{
var node = State.Nodes.FirstOrDefault(n => n.Id == Edge.StartNodeId);
return node?.NodeName ?? node?.NodeId ?? Edge.StartNodeId.ToString()[..8];
}
private string GetEndNodeName()
{
var node = State.Nodes.FirstOrDefault(n => n.Id == Edge.EndNodeId);
return node?.NodeName ?? node?.NodeId ?? Edge.EndNodeId.ToString()[..8];
}
private string GetEdgeLength()
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == Edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == Edge.EndNodeId);
if (startNode == null || endNode == null) return "N/A";
var dx = endNode.X - startNode.X;
var dy = endNode.Y - startNode.Y;
var length = Math.Sqrt(dx * dx + dy * dy);
return length.ToString("F3");
}
private void SelectVehicleType(EdgeVehiclePropertyDto vehicle)
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == Edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == Edge.EndNodeId);
if (startNode is not null && endNode is not null)
{
// Initialize default trajectory if not set
if (!vehicle.TrajectoryDegree.HasValue)
{
vehicle.TrajectoryDegree = 1;
}
selectedVehicleProperty = vehicle;
State.ChangeEdgeVehicleEditor(vehicle);
State.SetMode(EditorMode.TrajectoryEditor);
StateHasChanged();
}
}
private async Task OpenAddVehicleTypeDialog()
{
// Get vehicle types that are not already added
var existingVehicleTypeIds = Edge.VehicleProperties?.Select(vp => vp.VehicleTypeId).ToHashSet() ?? new HashSet<Guid>();
var availableVehicleTypes = State.VehicleTypes.Where(vt => !existingVehicleTypeIds.Contains(vt.Id)).ToList();
if (availableVehicleTypes.Count == 0)
{
Snackbar.Add("All vehicle types have been added", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AvailableVehicleTypes"] = availableVehicleTypes
};
var dialog = await DialogService.ShowAsync<AddVehicleTypeDialog>(
"Add Vehicle Type",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is VehicleTypeDto selectedVehicleProperty)
{
// Add new vehicle property
Edge.VehicleProperties ??= new List<EdgeVehiclePropertyDto>();
var newProp = new EdgeVehiclePropertyDto
{
Id = Guid.NewGuid(),
EdgeId = Edge.Id,
VehicleTypeId = selectedVehicleProperty.Id
};
Edge.VehicleProperties.Add(newProp);
StateHasChanged();
Snackbar.Add($"Added {selectedVehicleProperty.VehicleTypeName}", Severity.Success);
}
}
private async Task RemoveVehicleProperty(EdgeVehiclePropertyDto vehicle)
{
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == vehicle.VehicleTypeId);
var vehicleTypeName = vehicleType?.VehicleTypeName ?? "Unknown";
var result = await DialogService.ShowMessageBoxAsync(
"Remove Vehicle Properties",
$"Are you sure you want to remove vehicle properties for '{vehicleTypeName}'?",
yesText: "Remove",
cancelText: "Cancel");
if (result == true)
{
Edge.VehicleProperties?.RemoveAll(vp => vp.VehicleTypeId == vehicle.VehicleTypeId);
// Clear selection if it was the removed one
if (selectedVehicleProperty is not null && selectedVehicleProperty.Id == vehicle.Id)
{
selectedVehicleProperty = null;
}
StateHasChanged();
Snackbar.Add($"Removed vehicle properties for {vehicleTypeName}", Severity.Info);
}
}
private int GetActionsCount(string? actionsJson)
{
if (string.IsNullOrWhiteSpace(actionsJson)) return 0;
try
{
var actions = System.Text.Json.JsonSerializer.Deserialize<List<ActionDto>>(actionsJson, JsonOptionExtends.Read);
return actions?.Count ?? 0;
}
catch
{
return 0;
}
}
private void UpdateVehicleOrientationType(OrientationType? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.OrientationType = value;
}
private void UpdateVehicleOrientation(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.VehicleOrientation = value;
}
private void UpdateVehicleMaxSpeed(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.MaxSpeed = value;
}
private void UpdateVehicleMaxRotationSpeed(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.MaxRotationSpeed = value;
}
private void UpdateVehicleMinHeight(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.MinHeight = value;
}
private void UpdateVehicleMaxHeight(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.MaxHeight = value;
}
private void UpdateVehicleRotationAllowed(bool? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.RotationAllowed = value;
}
private void UpdateRotationAtStart(RotationDirection? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.RotationAtStartNodeAllowed = value;
}
private void UpdateRotationAtEnd(RotationDirection? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.RotationAtEndNodeAllowed = value;
}
private void UpdateLoadRestrictionUnloaded(bool? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.LoadRestriction ??= new LoadRestrictionDto();
selectedVehicleProperty.LoadRestriction.Unloaded = value;
}
}
private void UpdateLoadRestrictionLoaded(bool? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.LoadRestriction ??= new LoadRestrictionDto();
selectedVehicleProperty.LoadRestriction.Loaded = value;
}
}
private void UpdateLoadSetNames(List<string>? list)
{
if (selectedVehicleProperty != null)
{
if (list == null || list.Count == 0)
{
if (selectedVehicleProperty.LoadRestriction != null)
{
selectedVehicleProperty.LoadRestriction.LoadSetNames = null;
// If both Unloaded and Loaded are null, remove LoadRestriction entirely
if (!selectedVehicleProperty.LoadRestriction.Unloaded.HasValue && !selectedVehicleProperty.LoadRestriction.Loaded.HasValue)
{
selectedVehicleProperty.LoadRestriction = null;
}
}
}
else
{
selectedVehicleProperty.LoadRestriction ??= new LoadRestrictionDto();
selectedVehicleProperty.LoadRestriction.LoadSetNames = list;
}
}
}
private void UpdateTrajectoryFields((int? Degree, double? ControlPoint1X, double? ControlPoint1Y, double? ControlPoint2X, double? ControlPoint2Y) fields)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.TrajectoryDegree = fields.Degree;
selectedVehicleProperty.TrajectoryControlPoint1X = fields.ControlPoint1X;
selectedVehicleProperty.TrajectoryControlPoint1Y = fields.ControlPoint1Y;
selectedVehicleProperty.TrajectoryControlPoint2X = fields.ControlPoint2X;
selectedVehicleProperty.TrajectoryControlPoint2Y = fields.ControlPoint2Y;
State.NotifyTrajectoryChanged();
}
}
private void UpdateVehicleActions(string? json)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.Actions = json;
}
private void UpdateCorridorLeftWidth(double? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.CorridorLeftWidth = value;
// If all corridor fields are empty, clear them
if (!selectedVehicleProperty.CorridorLeftWidth.HasValue &&
!selectedVehicleProperty.CorridorRightWidth.HasValue &&
selectedVehicleProperty.CorridorRefPoint == null)
{
// Already null, nothing to clear
}
}
}
private void UpdateCorridorRightWidth(double? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.CorridorRightWidth = value;
// If all corridor fields are empty, clear them
if (!selectedVehicleProperty.CorridorLeftWidth.HasValue &&
!selectedVehicleProperty.CorridorRightWidth.HasValue &&
selectedVehicleProperty.CorridorRefPoint == null)
{
// Already null, nothing to clear
}
}
}
private void UpdateCorridorRefPoint(CorridorRefPoint? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.CorridorRefPoint = value;
}
}
private List<ActionDto>? GetDefaultActions()
{
if (selectedVehicleProperty is null) return null;
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == selectedVehicleProperty.VehicleTypeId);
if (vehicleType == null || string.IsNullOrWhiteSpace(vehicleType.Actions))
{
return null;
}
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<ActionDto>>(vehicleType.Actions, JsonOptionExtends.Read);
}
catch
{
return null;
}
}
private async Task HandleSave()
{
await OnSave.InvokeAsync(Edge);
State.SetMode(EditorMode.Select);
}
}

View File

@@ -0,0 +1,16 @@
@using RobotNet10.MapEditor.Services.State
<MudTabs Elevation="0" Rounded="false" ApplyEffectsToContainer="true" TabPanelsClass="pa-3">
<MudTabPanel Text="Properties" Icon="@Icons.Material.Filled.Settings">
<PropertiesTab State="@State" />
</MudTabPanel>
<MudTabPanel Text="Settings" Icon="@Icons.Material.Filled.Tune">
<SettingsTab State="@State" />
</MudTabPanel>
</MudTabs>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,116 @@
@using System.Text.Json
@inject ISnackbar Snackbar
<MudStack Spacing="2">
<MudText Typo="Typo.caption" Color="Color.Secondary">
Load Set Names (comma-separated)
</MudText>
<MudTextField @bind-Value="loadSetNamesText"
Label="Load Set Names"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2"
Placeholder='Enter load set names, e.g. "pallet, box, tray"'
HelperText="Press Enter or comma to separate items"
Error="@(!string.IsNullOrEmpty(errorMessage))"
ErrorText="@errorMessage"
Immediate="false"
ReadOnly="@IsReadOnly"
OnBlur="HandleBlur" />
@if (loadSetNames.Count > 0)
{
<MudStack Row="true" Spacing="1" Style="flex-wrap: wrap;">
@foreach (var name in loadSetNames)
{
<MudChip T="string" Size="Size.Small"
OnClick="@(async () => await RemoveItem(name))"
Color="Color.Primary"
Disabled="IsReadOnly"
Variant="Variant.Text">
@name
</MudChip>
}
</MudStack>
}
</MudStack>
@code {
[Parameter]
public List<string>? LoadSetNames { get; set; }
[Parameter]
public EventCallback<List<string>?> LoadSetNamesChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private string loadSetNamesText = string.Empty;
private List<string> loadSetNames = new();
private string? errorMessage;
protected override void OnParametersSet()
{
LoadFromParameter();
loadSetNamesText = string.Join(", ", loadSetNames);
}
private void LoadFromParameter()
{
loadSetNames.Clear();
errorMessage = null;
if (LoadSetNames != null && LoadSetNames.Count > 0)
{
loadSetNames.AddRange(LoadSetNames.Where(s => !string.IsNullOrWhiteSpace(s)));
}
}
private async Task HandleBlur()
{
ParseText();
await UpdateList();
}
private void ParseText()
{
loadSetNames.Clear();
errorMessage = null;
if (string.IsNullOrWhiteSpace(loadSetNamesText))
{
return;
}
var items = loadSetNamesText.Split(new[] { ',', ';', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var item in items)
{
var trimmed = item.Trim();
if (!string.IsNullOrWhiteSpace(trimmed) && !loadSetNames.Contains(trimmed))
{
loadSetNames.Add(trimmed);
}
}
}
private async Task RemoveItem(string name)
{
loadSetNames.Remove(name);
loadSetNamesText = string.Join(", ", loadSetNames);
await UpdateList();
}
private async Task UpdateList()
{
if (loadSetNames.Count == 0)
{
await LoadSetNamesChanged.InvokeAsync(null);
}
else
{
await LoadSetNamesChanged.InvokeAsync(new List<string>(loadSetNames));
}
}
}

View File

@@ -0,0 +1,693 @@
@using RobotNet.VDA5050
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Station
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel.Dialogs
@implements IDisposable
<MudStack Spacing="2">
<MudStack Row="true" Spacing="1" Class="d-flex justify-content-between">
<MudText Typo="Typo.subtitle1" Color="Color.Primary">
<MudIcon Icon="@Icons.Material.Filled.RadioButtonChecked" Size="Size.Small" Class="mr-1" />
Node Properties
</MudText>
<!-- Save Button -->
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
StartIcon="@Icons.Material.Filled.Save"
Size="Size.Small"
Disabled="@IsReadOnly"
OnClick="HandleSave">
Save
</MudButton>
</div>
</MudStack>
<MudDivider />
<!-- Basic Info (always visible) -->
<MudPaper style="overflow-y: auto; height: calc(100vh - 237px)" Elevation="0">
<MudStack Spacing="2">
<MudTextField @bind-Value="Node.NodeId"
Label="Node ID"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Tag" />
<MudTextField @bind-Value="Node.NodeName"
Label="Node Name"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="@IsReadOnly" />
<MudTextField @bind-Value="Node.NodeDescription"
Label="Description"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2"
ReadOnly="@IsReadOnly" />
<!-- Position -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Position (meters)</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="Node.X"
Label="X"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly" />
<MudNumericField @bind-Value="Node.Y"
Label="Y"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly" />
</MudStack>
</MudStack>
<!-- Expandable Sections -->
<MudExpansionPanels Dense="true" Elevation="4">
<!-- Vehicle Properties -->
@if (State.VehicleTypes.Count > 0)
{
<MudExpansionPanel Text="Vehicle Properties" Expanded="false">
<MudStack Spacing="2" Class="pa-2">
<!-- Vehicle Properties Table -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Vehicle Properties</MudText>
<MudButton Size="Size.Small"
Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Add"
Disabled="@IsReadOnly"
OnClick="OpenAddVehicleTypeDialog">
Add
</MudButton>
</MudStack>
@if (Node.VehicleProperties == null || Node.VehicleProperties.Count == 0)
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
No vehicle properties defined. Click "Add Vehicle Type" to add.
</MudText>
</MudPaper>
}
else
{
<MudTable Items="@Node.VehicleProperties" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Vehicle Type</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
@{
var prop = context;
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == prop.VehicleTypeId);
var isSelected = selectedVehicleTypeId == prop.VehicleTypeId;
}
<MudTd DataLabel="Vehicle Type">
<MudText Typo="Typo.body2" Style="@(isSelected ? "font-weight: bold; color: var(--mud-palette-primary);" : "")">
@(vehicleType?.VehicleTypeName ?? "Unknown")
</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="@(isSelected ? Color.Primary : Color.Default)"
Disabled="@IsReadOnly"
OnClick="() => SelectVehicleType(prop.VehicleTypeId)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Disabled="@IsReadOnly"
OnClick="() => RemoveVehicleProperty(prop.VehicleTypeId)" />
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
}
<!-- Edit Form for Selected Vehicle Type -->
@if (selectedVehicleTypeId.HasValue)
{
var vehicleProps = GetOrCreateVehicleProps();
var selectedVehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == selectedVehicleTypeId.Value);
<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
Editing: @(selectedVehicleType?.VehicleTypeName ?? "Unknown")
</MudText>
<MudNumericField T="double?"
Value="@vehicleProps?.Theta"
ValueChanged="(v) => UpdateVehicleTheta(v)"
Label="Theta (radians)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="-3.14159"
Max="3.14159"
ReadOnly="@IsReadOnly"
HelperText="Orientation range: [-π ... π]" />
<!-- Deviation Settings (VDA5050) -->
<MudText Typo="Typo.subtitle2" Color="Color.Secondary" Class="mt-2">
Deviation Settings (VDA5050)
</MudText>
<MudNumericField T="double?"
Value="@vehicleProps?.AllowedDeviationXY"
ValueChanged="(v) => UpdateVehicleAllowedDeviationXY(v)"
Label="Allowed Deviation XY (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="0"
ReadOnly="@IsReadOnly"
HelperText="Allowed deviation radius in meters. 0 = no deviation allowed." />
<MudNumericField T="double?"
Value="@vehicleProps?.AllowedDeviationTheta"
ValueChanged="(v) => UpdateVehicleAllowedDeviationTheta(v)"
Label="Allowed Deviation Theta (radians)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="0"
Max="3.14159"
ReadOnly="@IsReadOnly"
HelperText="Allowed theta deviation range: [0 ... π]" />
}
</MudStack>
@if (selectedVehicleTypeId.HasValue)
{
var vehicleProps = GetOrCreateVehicleProps();
<MudStack Spacing="2" Class="pa-2">
<ActionsEditor ActionsJson="@vehicleProps?.Actions"
ActionsJsonChanged="(json) => UpdateVehicleActions(json)"
DefaultActions="@GetDefaultActions()"
IsReadOnly="@IsReadOnly" />
</MudStack>
}
</MudExpansionPanel>
}
<!-- Station Management -->
<MudExpansionPanel Text="Stations" Expanded="false">
<MudStack Spacing="2" Class="pa-2">
<!-- Action Buttons -->
<MudStack Row="true" Spacing="1" Justify="Justify.SpaceBetween">
<MudButton Size="Size.Small"
Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Add"
Disabled="@IsReadOnly"
OnClick="OpenCreateStationDialog">
Create
</MudButton>
<MudButton Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Link"
Disabled="@IsReadOnly"
OnClick="OpenLinkStationDialog">
Link
</MudButton>
</MudStack>
<!-- Stations Table -->
@if (linkedStations.Count == 0)
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
No stations linked to this node. Click buttons above to add.
</MudText>
</MudPaper>
}
else
{
<MudTable Items="@linkedStations" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Station ID</MudTh>
<MudTh>Name</MudTh>
<MudTh>Position</MudTh>
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
@{
var station = context;
}
<MudTd DataLabel="Station ID">
<MudText Typo="Typo.body2">@station.StationId</MudText>
</MudTd>
<MudTd DataLabel="Name">
<MudText Typo="Typo.body2">@(station.StationName ?? "-")</MudText>
</MudTd>
<MudTd DataLabel="Position">
<MudText Typo="Typo.body2">
(@station.X.ToString("F2"), @station.Y.ToString("F2"))
</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Disabled="@IsReadOnly"
OnClick="() => OpenEditStationDialog(station)" />
<MudIconButton Icon="@Icons.Material.Filled.LinkOff"
Size="Size.Small"
Color="Color.Error"
Disabled="@IsReadOnly"
OnClick="() => UnlinkStation(station)" />
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudPaper>
</MudStack>
@code {
[Parameter]
public NodeDto Node { get; set; } = null!;
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EventCallback<NodeDto> OnSave { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IDialogService DialogService { get; set; } = null!;
[Inject]
private ISnackbar Snackbar { get; set; } = null!;
[Inject]
private MapManagerApiService ApiService { get; set; } = null!;
private Guid? selectedVehicleTypeId;
private List<StationDto> linkedStations = new();
private List<ActionDto> Actions = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnDraggingNodesChanged += OnDraggingNodesChanged;
}
private void OnDraggingNodesChanged(Guid[] nodeIds)
{
if (nodeIds.Any(n => Node.Id == n)) StateHasChanged();
}
public void Dispose()
{
State.OnDraggingNodesChanged -= OnDraggingNodesChanged;
}
protected override void OnParametersSet()
{
// Find all linked stations
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
// Set default vehicle type if none selected and we have properties
if (selectedVehicleTypeId == null && Node.VehicleProperties != null && Node.VehicleProperties.Count > 0)
{
selectedVehicleTypeId = Node.VehicleProperties.First().VehicleTypeId;
}
else if (selectedVehicleTypeId == null && State.VehicleTypes.Count > 0)
{
// If no properties exist, don't auto-select
selectedVehicleTypeId = null;
}
}
private NodeVehiclePropertyDto? GetOrCreateVehicleProps()
{
if (!selectedVehicleTypeId.HasValue) return null;
Node.VehicleProperties ??= new List<NodeVehiclePropertyDto>();
var props = Node.VehicleProperties.FirstOrDefault(vp => vp.VehicleTypeId == selectedVehicleTypeId);
return props;
}
private void SelectVehicleType(Guid vehicleTypeId)
{
selectedVehicleTypeId = vehicleTypeId;
StateHasChanged();
}
private async Task OpenAddVehicleTypeDialog()
{
// Get vehicle types that are not already added
var existingVehicleTypeIds = Node.VehicleProperties?.Select(vp => vp.VehicleTypeId).ToHashSet() ?? new HashSet<Guid>();
var availableVehicleTypes = State.VehicleTypes.Where(vt => !existingVehicleTypeIds.Contains(vt.Id)).ToList();
if (availableVehicleTypes.Count == 0)
{
Snackbar.Add("All vehicle types have been added", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AvailableVehicleTypes"] = availableVehicleTypes
};
var dialog = await DialogService.ShowAsync<AddVehicleTypeDialog>(
"Add Vehicle Type",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is VehicleTypeDto selectedVehicleType)
{
// Add new vehicle property
Node.VehicleProperties ??= new List<NodeVehiclePropertyDto>();
var newProp = new NodeVehiclePropertyDto
{
Id = Guid.NewGuid(),
NodeId = Node.Id,
VehicleTypeId = selectedVehicleType.Id
};
Node.VehicleProperties.Add(newProp);
// Select the newly added vehicle type
selectedVehicleTypeId = selectedVehicleType.Id;
StateHasChanged();
Snackbar.Add($"Added {selectedVehicleType.VehicleTypeName}", Severity.Success);
}
}
private async Task RemoveVehicleProperty(Guid vehicleTypeId)
{
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == vehicleTypeId);
var vehicleTypeName = vehicleType?.VehicleTypeName ?? "Unknown";
var result = await DialogService.ShowMessageBoxAsync(
"Remove Vehicle Properties",
$"Are you sure you want to remove vehicle properties for '{vehicleTypeName}'?",
yesText: "Remove",
cancelText: "Cancel");
if (result == true)
{
Node.VehicleProperties?.RemoveAll(vp => vp.VehicleTypeId == vehicleTypeId);
// Clear selection if it was the removed one
if (selectedVehicleTypeId == vehicleTypeId)
{
selectedVehicleTypeId = null;
}
StateHasChanged();
Snackbar.Add($"Removed vehicle properties for {vehicleTypeName}", Severity.Info);
}
}
private int GetActionsCount(string? actionsJson)
{
if (string.IsNullOrWhiteSpace(actionsJson)) return 0;
try
{
var actions = System.Text.Json.JsonSerializer.Deserialize<List<ActionDto>>(actionsJson, JsonOptionExtends.Read);
return actions?.Count ?? 0;
}
catch
{
return 0;
}
}
private void UpdateVehicleTheta(double? value)
{
var props = GetOrCreateVehicleProps();
if (props != null)
{
props.Theta = value;
}
}
private void UpdateVehicleActions(string? json)
{
var props = GetOrCreateVehicleProps();
if (props != null)
{
props.Actions = json;
}
}
private void UpdateVehicleAllowedDeviationXY(double? value)
{
var props = GetOrCreateVehicleProps();
if (props != null)
{
props.AllowedDeviationXY = value;
}
}
private void UpdateVehicleAllowedDeviationTheta(double? value)
{
var props = GetOrCreateVehicleProps();
if (props != null)
{
props.AllowedDeviationTheta = value;
}
}
private List<ActionDto>? GetDefaultActions()
{
if (!selectedVehicleTypeId.HasValue) return null;
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == selectedVehicleTypeId.Value);
if (vehicleType == null || string.IsNullOrWhiteSpace(vehicleType.Actions))
{
return null;
}
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<ActionDto>>(vehicleType.Actions, JsonOptionExtends.Read);
}
catch
{
return null;
}
}
private async Task HandleSave()
{
await OnSave.InvokeAsync(Node);
}
// ==========================================
// STATION MANAGEMENT
// ==========================================
private async Task OpenCreateStationDialog()
{
var parameters = new DialogParameters
{
["LayoutLevelId"] = State.LevelId,
["DefaultX"] = Node.X,
["DefaultY"] = Node.Y,
["DefaultInteractionNodeIds"] = new List<Guid> { Node.Id }
};
var dialog = await DialogService.ShowAsync<CreateStationDialog>(
"Create New Station",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is StationDto newStation)
{
// Reload stations from API
await State.ReloadStationsAsync();
// Update linked stations list
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
StateHasChanged();
Snackbar.Add($"Created and linked station '{newStation.StationId}'", Severity.Success);
}
}
private async Task OpenLinkStationDialog()
{
// Get stations that are not already linked
var linkedStationIds = linkedStations.Select(s => s.Id).ToHashSet();
var availableStations = State.Stations.Where(s => !linkedStationIds.Contains(s.Id)).ToList();
if (availableStations.Count == 0)
{
Snackbar.Add("No available stations to link", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AvailableStations"] = availableStations
};
var dialog = await DialogService.ShowAsync<LinkStationDialog>(
"Link Existing Station",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is StationDto selectedStation)
{
// Add this node to station's interaction nodes
var currentInteractionNodeIds = selectedStation.InteractionNodes?
.Select(i => i.NodeId)
.ToList() ?? new List<Guid>();
if (!currentInteractionNodeIds.Contains(Node.Id))
{
currentInteractionNodeIds.Add(Node.Id);
}
var updateRequest = new UpdateStationRequest
{
StationName = selectedStation.StationName,
StationDescription = selectedStation.StationDescription,
StationHeight = selectedStation.StationHeight,
X = selectedStation.X,
Y = selectedStation.Y,
Theta = selectedStation.Theta,
InteractionNodeIds = currentInteractionNodeIds
};
try
{
await ApiService.UpdateStationAsync(selectedStation.Id, updateRequest);
// Reload stations from API
await State.ReloadStationsAsync();
// Update linked stations list
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
StateHasChanged();
Snackbar.Add($"Linked station '{selectedStation.StationId}' to node", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to link station: {ex.Message}", Severity.Error);
}
}
}
private async Task OpenEditStationDialog(StationDto station)
{
var parameters = new DialogParameters
{
["Station"] = station,
["LayoutLevelId"] = State.LevelId
};
var dialog = await DialogService.ShowAsync<EditStationDialog>(
"Edit Station",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is StationDto updatedStation)
{
// Reload stations from API
await State.ReloadStationsAsync();
// Update linked stations list
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
StateHasChanged();
Snackbar.Add($"Updated station '{updatedStation.StationId}'", Severity.Success);
}
}
private async Task UnlinkStation(StationDto station)
{
var result = await DialogService.ShowMessageBoxAsync(
"Unlink Station",
$"Are you sure you want to unlink station '{station.StationId}' from this node?\n\n" +
"Note: The station will not be deleted, only the link will be removed.",
yesText: "Unlink",
cancelText: "Cancel");
if (result == true)
{
// Remove this node from station's interaction nodes
var currentInteractionNodeIds = station.InteractionNodes?
.Where(i => i.NodeId != Node.Id)
.Select(i => i.NodeId)
.ToList() ?? new List<Guid>();
var updateRequest = new UpdateStationRequest
{
StationName = station.StationName,
StationDescription = station.StationDescription,
StationHeight = station.StationHeight,
X = station.X,
Y = station.Y,
Theta = station.Theta,
InteractionNodeIds = currentInteractionNodeIds
};
try
{
await ApiService.UpdateStationAsync(station.Id, updateRequest);
// Reload stations from API
await State.ReloadStationsAsync();
// Update linked stations list
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
StateHasChanged();
Snackbar.Add($"Unlinked station '{station.StationId}' from node", Severity.Info);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to unlink station: {ex.Message}", Severity.Error);
}
}
}
}

View File

@@ -0,0 +1,300 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel.Dialogs
@using MudBlazor
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<MudStack Spacing="3">
@if (State.SelectedNodeIds.Count == 0 && State.SelectedEdgeIds.Count == 0)
{
<!-- Nothing selected -->
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
Select a node or edge to view properties
</MudAlert>
}
else if (State.SelectedNodeIds.Count == 1 && State.SelectedEdgeIds.Count == 0)
{
<!-- Single node selected -->
var node = State.GetSelectedNodes().FirstOrDefault();
if (node != null)
{
<NodePropertiesEditor Node="@node" State="@State" OnSave="SaveNode" IsReadOnly="@State.IsReadOnly" />
}
}
else if (State.SelectedEdgeIds.Count == 1 && State.SelectedNodeIds.Count == 0)
{
<!-- Single edge selected -->
var edge = State.GetSelectedEdges().FirstOrDefault();
if (edge != null)
{
<EdgePropertiesEditor Edge="@edge" State="@State" OnSave="SaveEdge" IsReadOnly="@State.IsReadOnly" />
}
}
else
{
<!-- Multiple selection -->
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
<strong>Multiple Selection:</strong>
<br />
@State.SelectedNodeIds.Count nodes, @State.SelectedEdgeIds.Count edges
</MudAlert>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Select a single item to edit properties.
Use toolbar buttons for bulk operations.
</MudText>
<!-- Add VehicleType for Multiple Selection -->
@if (State.VehicleTypes.Count > 0)
{
<MudPaper Elevation="1" Class="pa-3">
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
<MudIcon Icon="@Icons.Material.Filled.DirectionsCar" Size="Size.Small" Class="mr-1" />
Bulk Add Vehicle Type
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
Add a vehicle type to all selected nodes and edges. Items that already have this vehicle type will be skipped.
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddVehicleTypeForMultipleDialog"
FullWidth="true">
Add Vehicle Type
</MudButton>
</MudStack>
</MudPaper>
}
}
</MudStack>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
private Task SaveNode(NodeDto node)
{
// Mark node as modified (will be saved via Save button on toolbar)
State.MarkNodeModified(node.Id);
// Update local state
var index = State.Nodes.FindIndex(n => n.Id == node.Id);
if (index >= 0)
{
State.Nodes[index] = node;
}
State.NotifyStateChanged();
Snackbar.Add("Node properties updated (will be saved with Save button)", Severity.Info);
return Task.CompletedTask;
}
private Task SaveEdge(EdgeDto edge)
{
// Mark edge as modified (will be saved via Save button on toolbar)
State.MarkEdgeModified(edge.Id);
// Update local state
var index = State.Edges.FindIndex(e => e.Id == edge.Id);
if (index >= 0)
{
State.Edges[index] = edge;
}
State.NotifyStateChanged();
Snackbar.Add("Edge properties updated (will be saved with Save button)", Severity.Info);
return Task.CompletedTask;
}
private async Task OpenAddVehicleTypeForMultipleDialog()
{
var selectedNodes = State.GetSelectedNodes();
var selectedEdges = State.GetSelectedEdges();
if (selectedNodes.Count == 0 && selectedEdges.Count == 0)
{
Snackbar.Add("No items selected", Severity.Warning);
return;
}
// Collect all VehicleTypeIds that are already present in ALL selected items
var allVehicleTypeIds = State.VehicleTypes.Select(vt => vt.Id).ToList();
var vehicleTypesInAllItems = new HashSet<Guid>();
foreach (var vehicleTypeId in allVehicleTypeIds)
{
bool inAllNodes = selectedNodes.Count == 0 || selectedNodes.All(node =>
node.VehicleProperties?.Any(vp => vp.VehicleTypeId == vehicleTypeId) == true);
bool inAllEdges = selectedEdges.Count == 0 || selectedEdges.All(edge =>
edge.VehicleProperties?.Any(vp => vp.VehicleTypeId == vehicleTypeId) == true);
if (inAllNodes && inAllEdges)
{
vehicleTypesInAllItems.Add(vehicleTypeId);
}
}
// Get available vehicle types (at least one item doesn't have it)
var availableVehicleTypes = State.VehicleTypes
.Where(vt => !vehicleTypesInAllItems.Contains(vt.Id))
.ToList();
if (availableVehicleTypes.Count == 0)
{
// Show which vehicle types all items already have
if (vehicleTypesInAllItems.Count > 0)
{
var vehicleTypeNames = State.VehicleTypes
.Where(vt => vehicleTypesInAllItems.Contains(vt.Id))
.Select(vt => vt.VehicleTypeName)
.ToList();
Snackbar.Add(
$"All selected items already have these vehicle types: {string.Join(", ", vehicleTypeNames)}",
Severity.Info);
}
else
{
Snackbar.Add("No available vehicle types to add", Severity.Info);
}
return;
}
var parameters = new DialogParameters
{
["AvailableVehicleTypes"] = availableVehicleTypes
};
var dialog = await DialogService.ShowAsync<AddVehicleTypeDialog>(
"Add Vehicle Type to Multiple Items",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is VehicleTypeDto selectedVehicleType)
{
await AddVehicleTypeToMultipleItems(selectedVehicleType, selectedNodes, selectedEdges);
}
}
private Task AddVehicleTypeToMultipleItems(
VehicleTypeDto vehicleType,
List<NodeDto> selectedNodes,
List<EdgeDto> selectedEdges)
{
int nodesAdded = 0;
int nodesSkipped = 0;
int edgesAdded = 0;
int edgesSkipped = 0;
// Add to nodes
foreach (var node in selectedNodes)
{
// Check if node already has this vehicle type
var hasVehicleType = node.VehicleProperties?.Any(vp => vp.VehicleTypeId == vehicleType.Id) == true;
if (hasVehicleType)
{
nodesSkipped++;
}
else
{
// Add new vehicle property
node.VehicleProperties ??= new List<NodeVehiclePropertyDto>();
var newProp = new NodeVehiclePropertyDto
{
Id = Guid.NewGuid(),
NodeId = node.Id,
VehicleTypeId = vehicleType.Id
};
node.VehicleProperties.Add(newProp);
// Mark node as modified
State.MarkNodeModified(node.Id);
// Update local state
var index = State.Nodes.FindIndex(n => n.Id == node.Id);
if (index >= 0)
{
State.Nodes[index] = node;
}
nodesAdded++;
}
}
// Add to edges
foreach (var edge in selectedEdges)
{
// Check if edge already has this vehicle type
var hasVehicleType = edge.VehicleProperties?.Any(vp => vp.VehicleTypeId == vehicleType.Id) == true;
if (hasVehicleType)
{
edgesSkipped++;
}
else
{
// Add new vehicle property
edge.VehicleProperties ??= new List<EdgeVehiclePropertyDto>();
var newProp = new EdgeVehiclePropertyDto
{
Id = Guid.NewGuid(),
EdgeId = edge.Id,
VehicleTypeId = vehicleType.Id
};
edge.VehicleProperties.Add(newProp);
// Mark edge as modified
State.MarkEdgeModified(edge.Id);
// Update local state
var index = State.Edges.FindIndex(e => e.Id == edge.Id);
if (index >= 0)
{
State.Edges[index] = edge;
}
edgesAdded++;
}
}
// Notify state changed
State.NotifyStateChanged();
// Show detailed snackbar message
var messageParts = new List<string>();
if (nodesAdded > 0) messageParts.Add($"{nodesAdded} node(s)");
if (edgesAdded > 0) messageParts.Add($"{edgesAdded} edge(s)");
if (messageParts.Count > 0)
{
var addedMessage = $"Added '{vehicleType.VehicleTypeName}' to {string.Join(" and ", messageParts)}";
var skippedParts = new List<string>();
if (nodesSkipped > 0) skippedParts.Add($"{nodesSkipped} node(s)");
if (edgesSkipped > 0) skippedParts.Add($"{edgesSkipped} edge(s)");
if (skippedParts.Count > 0)
{
addedMessage += $". Skipped {string.Join(" and ", skippedParts)} (already have this vehicle type)";
}
Snackbar.Add(addedMessage, Severity.Success);
}
else
{
Snackbar.Add($"All selected items already have '{vehicleType.VehicleTypeName}'", Severity.Info);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,215 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Shared.Models
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudStack Spacing="3">
@if (State.Level?.EditorSettings != null)
{
var settings = State.Level.EditorSettings;
<!-- Grid Settings -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid Settings</MudText>
<MudSelect T="double" @bind-Value="State.GridSpacing"
@bind-Value:after="State.NotifyStateChanged"
Label="Grid Spacing"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true">
<MudSelectItem Value="0.25">0.25 m</MudSelectItem>
<MudSelectItem Value="0.5">0.5 m</MudSelectItem>
<MudSelectItem Value="1.0">1.0 m</MudSelectItem>
<MudSelectItem Value="2.0">2.0 m</MudSelectItem>
<MudSelectItem Value="5.0">5.0 m</MudSelectItem>
<MudSelectItem Value="10.0">10.0 m</MudSelectItem>
</MudSelect>
<MudDivider Class="mt-2" />
<!-- Display Options -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Display Options</MudText>
<MudStack Spacing="2">
<MudCheckBox T="bool" @bind-Value="State.ShowEdgeNames"
@bind-Value:after="State.NotifyStateChanged"
Label="Show Edge Names"
Size="Size.Small"
Dense="true" />
<MudCheckBox T="bool" @bind-Value="State.ShowNodeNames"
@bind-Value:after="State.NotifyStateChanged"
Label="Show Node Names"
Size="Size.Small"
Dense="true" />
<MudCheckBox T="bool" @bind-Value="State.ShowGrid"
@bind-Value:after="State.NotifyStateChanged"
Label="Show Grid"
Size="Size.Small"
Dense="true" />
<MudCheckBox T="bool" @bind-Value="State.ShowBackgroundImage"
@bind-Value:after="State.NotifyStateChanged"
Label="Show Background Map"
Size="Size.Small"
Dense="true" />
</MudStack>
<MudDivider Class="mt-2" />
<!-- Auto-generation Settings (Editable) - MOVED UP -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Auto-generation Settings</MudText>
<MudStack Spacing="2">
<MudCheckBox T="bool" @bind-Value="nodeNameAutoGenerate"
Label="Auto-generate Node Names"
Size="Size.Small"
Dense="true" />
<MudCheckBox T="bool" @bind-Value="edgeNameAutoGenerate"
Label="Auto-generate Edge Names"
Size="Size.Small"
Dense="true" />
<MudNumericField T="double" @bind-Value="edgeMinLengthCreate"
Label="Edge Min Length (m)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="0.01"
Step="0.01"
Format="F2" />
<MudNumericField T="double" @bind-Value="nodeProximityRadius"
Label="Node Proximity Radius (m)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="0.01"
Step="0.01"
Format="F2" />
</MudStack>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Save"
OnClick="SaveEditorSettings"
Disabled="@isSaving"
FullWidth="true"
Class="mt-2">
@if (isSaving)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Saving...</MudText>
}
else
{
<MudText>Save Settings</MudText>
}
</MudButton>
<MudDivider Class="mt-2" />
<!-- Layout Level Info (read-only) -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Layout Level Info</MudText>
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2"><strong>Level ID:</strong> @State.Level.LayoutLevelId</MudText>
<MudText Typo="Typo.body2"><strong>Resolution:</strong> @settings.Resolution.ToString("F4") m/px</MudText>
<MudText Typo="Typo.body2"><strong>Origin:</strong> (@settings.OriginX.ToString("F2"), @settings.OriginY.ToString("F2")) m</MudText>
@if (settings.ImageWidth.HasValue && settings.ImageHeight.HasValue)
{
<MudText Typo="Typo.body2"><strong>Image Size:</strong> @settings.ImageWidth × @settings.ImageHeight px</MudText>
var physicalW = settings.ImageWidth.Value * settings.Resolution;
var physicalH = settings.ImageHeight.Value * settings.Resolution;
<MudText Typo="Typo.body2"><strong>Physical Size:</strong> @physicalW.ToString("F2") × @physicalH.ToString("F2") m</MudText>
}
</MudPaper>
@if (settings.BoundsMinX.HasValue || settings.BoundsMaxX.HasValue)
{
<MudPaper Elevation="0" Class="pa-2 mt-2" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2"><strong>Bounds X:</strong> @(settings.BoundsMinX?.ToString("F2") ?? "∞") to @(settings.BoundsMaxX?.ToString("F2") ?? "∞") m</MudText>
<MudText Typo="Typo.body2"><strong>Bounds Y:</strong> @(settings.BoundsMinY?.ToString("F2") ?? "∞") to @(settings.BoundsMaxY?.ToString("F2") ?? "∞") m</MudText>
</MudPaper>
}
<MudDivider Class="mt-2" />
<!-- Statistics -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Statistics</MudText>
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2"><strong>Nodes:</strong> @State.Nodes.Count</MudText>
<MudText Typo="Typo.body2"><strong>Edges:</strong> @State.Edges.Count</MudText>
<MudText Typo="Typo.body2"><strong>Stations:</strong> @State.Stations.Count</MudText>
</MudPaper>
}
else
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
Editor settings not available
</MudAlert>
}
</MudStack>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
// Local editable state
private bool nodeNameAutoGenerate;
private bool edgeNameAutoGenerate;
private double edgeMinLengthCreate;
private double nodeProximityRadius;
private bool isSaving = false;
protected override void OnParametersSet()
{
// Initialize local state from settings
if (State.Level?.EditorSettings != null)
{
var settings = State.Level.EditorSettings;
nodeNameAutoGenerate = settings.NodeNameAutoGenerate;
edgeNameAutoGenerate = settings.EdgeNameAutoGenerate;
edgeMinLengthCreate = settings.EdgeMinLengthCreate;
nodeProximityRadius = settings.NodeProximityRadius;
}
}
private async Task SaveEditorSettings()
{
if (State.Level == null)
{
Snackbar.Add("Invalid level data", Severity.Error);
return;
}
isSaving = true;
try
{
var request = new UpdateLayoutLevelRequest
{
EditorSettings = new EditorSettingsInfo
{
NodeNameAutoGenerate = nodeNameAutoGenerate,
EdgeNameAutoGenerate = edgeNameAutoGenerate,
EdgeMinLengthCreate = edgeMinLengthCreate,
NodeProximityRadius = nodeProximityRadius
}
};
var updatedLevel = await ApiService.UpdateLevelAsync(State.Level.Id, request);
// Update State with new settings
State.Level = updatedLevel;
Snackbar.Add("Editor settings saved successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error saving settings: {ex.Message}", Severity.Error);
}
finally
{
isSaving = false;
}
}
}

View File

@@ -0,0 +1,234 @@
@using RobotNet10.MapEditor.Services.State
@inject ISnackbar Snackbar
@implements IDisposable
<MudPaper Elevation="2" Class="pa-2">
<!-- Header -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Trajectory</MudText>
</MudStack>
<!-- Degree Selector -->
<MudSelect T="int?"
Value="@Degree"
ValueChanged="(v) => OnDegreeChanged(v)"
Label="Degree"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Disabled="@IsReadOnly"
HelperText="Curve degree (1=linear, 2=quadratic, 3=cubic)">
<MudSelectItem Value="@((int?)1)">1 - Linear</MudSelectItem>
<MudSelectItem Value="@((int?)2)">2 - Quadratic</MudSelectItem>
<MudSelectItem Value="@((int?)3)">3 - Cubic</MudSelectItem>
</MudSelect>
<!-- Control Point 1 (for degree 2 and 3) -->
@if (Degree.HasValue && Degree.Value > 1)
{
<MudText Typo="Typo.body2" Class="mt-2 mb-1">
Control Point 1 (Quadratic/Cubic)
</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField T="double?"
Value="@ControlPoint1X"
ValueChanged="(v) => OnControlPoint1XChanged(v)"
Label="X (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly"
Style="flex: 1;" />
<MudNumericField T="double?"
Value="@ControlPoint1Y"
ValueChanged="(v) => OnControlPoint1YChanged(v)"
Label="Y (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly"
Style="flex: 1;" />
</MudStack>
}
<!-- Control Point 2 (for degree 3 only) -->
@if (Degree.HasValue && Degree.Value == 3)
{
<MudText Typo="Typo.body2" Class="mt-2 mb-1">
Control Point 2 (Cubic only)
</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField T="double?"
Value="@ControlPoint2X"
ValueChanged="(v) => OnControlPoint2XChanged(v)"
Label="X (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly"
Style="flex: 1;" />
<MudNumericField T="double?"
Value="@ControlPoint2Y"
ValueChanged="(v) => OnControlPoint2YChanged(v)"
Label="Y (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly"
Style="flex: 1;" />
</MudStack>
}
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
Note: Start and end nodes are determined by the edge's connected nodes and cannot be edited here.
</MudText>
</MudPaper>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public NodeDto StartNode { get; set; } = null!;
[Parameter]
public NodeDto EndNode { get; set; } = null!;
[Parameter]
public int? Degree { get; set; }
[Parameter]
public double? ControlPoint1X { get; set; }
[Parameter]
public double? ControlPoint1Y { get; set; }
[Parameter]
public double? ControlPoint2X { get; set; }
[Parameter]
public double? ControlPoint2Y { get; set; }
[Parameter]
public EventCallback<(int? Degree, double? ControlPoint1X, double? ControlPoint1Y, double? ControlPoint2X, double? ControlPoint2Y)> TrajectoryFieldsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnTrajectoryChanged += OnTrajectoryChanged;
}
public void OnTrajectoryChanged()
{
if (State.EdgeVehicleEditor is null) return;
ControlPoint1X = State.EdgeVehicleEditor.TrajectoryControlPoint1X;
ControlPoint1Y = State.EdgeVehicleEditor.TrajectoryControlPoint1Y;
ControlPoint2X = State.EdgeVehicleEditor.TrajectoryControlPoint2X;
ControlPoint2Y = State.EdgeVehicleEditor.TrajectoryControlPoint2Y;
StateHasChanged();
}
public void Dispose()
{
State.OnTrajectoryChanged -= OnTrajectoryChanged;
}
private void OnDegreeChanged(int? degree)
{
var oldDegree = Degree ?? 1;
var newDegree = degree ?? 1;
if (newDegree > oldDegree)
{
ElevateDegree(oldDegree, newDegree);
}
Degree = degree;
NotifyChanged();
}
private void OnControlPoint1XChanged(double? value)
{
ControlPoint1X = value;
NotifyChanged();
}
private void OnControlPoint1YChanged(double? value)
{
ControlPoint1Y = value;
NotifyChanged();
}
private void OnControlPoint2XChanged(double? value)
{
ControlPoint2X = value;
NotifyChanged();
}
private void OnControlPoint2YChanged(double? value)
{
ControlPoint2Y = value;
NotifyChanged();
}
private void NotifyChanged()
{
TrajectoryFieldsChanged.InvokeAsync((Degree, ControlPoint1X, ControlPoint1Y, ControlPoint2X, ControlPoint2Y));
}
private void ElevateDegree(int from, int to)
{
if (from == 1 && to == 2)
{
if (!ControlPoint1X.HasValue || !ControlPoint1Y.HasValue)
{
(ControlPoint1X, ControlPoint1Y) = CalculateNewCP_1to2();
}
}
else if (from == 2 && to == 3)
{
if (!ControlPoint2X.HasValue || !ControlPoint2Y.HasValue)
{
(ControlPoint2X, ControlPoint2Y) = CalculateNewCP_2to3();
}
}
else if (from == 1 && to == 3)
{
if (!ControlPoint1X.HasValue || !ControlPoint1Y.HasValue)
{
(ControlPoint1X, ControlPoint1Y) = CalculateNewCP_1to2();
}
if (!ControlPoint2X.HasValue || !ControlPoint2Y.HasValue)
{
(ControlPoint2X, ControlPoint2Y) = CalculateNewCP_2to3();
}
}
}
private (double X, double Y) CalculateNewCP_1to2()
{
double newX = (StartNode.X + EndNode.X) / 2;
double newY = (StartNode.Y + EndNode.Y) / 2;
return (newX, newY);
}
private (double X, double Y) CalculateNewCP_2to3()
{
if (!ControlPoint1X.HasValue || !ControlPoint1Y.HasValue)
{
(ControlPoint1X, ControlPoint1Y) = CalculateNewCP_1to2();
}
double newX = (ControlPoint1X.Value + EndNode.X) / 2;
double newY = (ControlPoint1Y.Value + EndNode.Y) / 2;
return (newX, newY);
}
}

View File

@@ -0,0 +1,42 @@
@using MudBlazor
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Select Station Node</MudText>
</TitleContent>
<DialogContent>
<MudText>
The node "@NodeName" has a station. After splitting, which new node should receive the station?
</MudText>
<MudSelect T="int?" @bind-Value="SelectedNodeIndex"
Label="New Node Index"
Variant="Variant.Outlined"
Class="mt-4">
@for (int i = 0; i < EdgeCount; i++)
{
<MudSelectItem Value="@i">Node @(i + 1)</MudSelectItem>
}
</MudSelect>
<MudText Typo="Typo.caption" Class="mt-2">
Note: The node index corresponds to the order of connected edges (0-based).
</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit" Disabled="@(SelectedNodeIndex == null)">Split</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public int EdgeCount { get; set; }
[Parameter] public string NodeName { get; set; } = string.Empty;
public int? SelectedNodeIndex { get; set; } = 0; // Default to first node
private void Cancel() => MudDialog?.Cancel();
private void Submit() => MudDialog?.Close(DialogResult.Ok(SelectedNodeIndex));
}

View File

@@ -0,0 +1,565 @@
@using Microsoft.JSInterop
@using RobotNet.VDA5050.Order
@using RobotNet10.MapEditor.Components.LayoutEditor.Element
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Shared.DTOs.LayoutData
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using MudBlazor
@inject IJSRuntime JSRuntime
@inject ISnackbar Snackbar
@inject MapManagerApiService ApiService
@implements IAsyncDisposable
<div class="svg-editor-container" @ref="containerRef">
<!-- Mouse Position Display -->
<MousePositionDisplay @ref="MousePositionDisplayRef" />
<svg @ref="svgRef"
id="editor-svg"
class="editor-svg"
viewBox="@State.Viewport.ToViewBoxString()"
preserveAspectRatio="xMidYMid meet">
<!-- Layer 1: Background Image -->
@if (State.ShowBackgroundImage && State.BackgroundImage != null && State.Level?.EditorSettings != null)
{
var (physicalWidth, physicalHeight) = State.GetPhysicalDimensions();
<!-- No transform needed - image is rendered in SVG coordinate system (Y down) -->
<!-- Nodes/edges are transformed via WorldToSvg to match -->
<image href="@GetImageDataUrl()"
x="0"
y="0"
width="@physicalWidth.ToString("F2")"
height="@physicalHeight.ToString("F2")"
opacity="0.7"
preserveAspectRatio="none" />
}
<CascadingValue Value="State">
<!-- Layer 2: Grid -->
<Grid />
<!-- Origin Marker -->
<OriginMarker/>
<!-- Layer 3: Edges -->
<MapEdge />
<!-- Layer 4: Create Edge Preview -->
<EdgeCreatePreview @ref="EdgeCreatePreviewRef" />
<!-- Layer 5: Nodes -->
<MapNode />
<!-- Layer 5 +: Nodes ControlPoints -->
<EdgeEditing @ref="EdgeEditingRef"/>
<!-- Layer 6: Box Select Rectangle -->
<ScanView @ref="ScanViewRef"/>
<!-- Layer 7: Copy Preview -->
<CopyPreview @ref="CopyPreviewRef" />
</CascadingValue>
@* @if (State.Mode == EditorMode.Copy && State.CopySourceNodes != null && State.CopySourceNodes.Count > 0 &&
State.CopyOffsetX.HasValue && State.CopyOffsetY.HasValue)
{
var offsetX = State.CopyOffsetX.Value;
var offsetY = State.CopyOffsetY.Value;
<!-- Preview nodes -->
@foreach (var sourceNode in State.CopySourceNodes)
{
var newX = sourceNode.X + offsetX;
var newY = sourceNode.Y + offsetY;
var svg = State.WorldToSvg(newX, newY);
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<circle class="copy-preview-node"
cx="@svg.X.ToString("F2")"
cy="@svg.Y.ToString("F2")"
r="@nodeRadius.ToString("F2")"
fill="rgba(255, 152, 0, 0.3)"
stroke="#ff9800"
stroke-width="0.02"
stroke-dasharray="0.05,0.05" />
}
<!-- Preview edges -->
@if (State.CopySourceEdges != null)
{
@foreach (var sourceEdge in State.CopySourceEdges)
{
var sourceStartNode = State.CopySourceNodes?.FirstOrDefault(n => n.Id == sourceEdge.StartNodeId);
var sourceEndNode = State.CopySourceNodes?.FirstOrDefault(n => n.Id == sourceEdge.EndNodeId);
if (sourceStartNode != null && sourceEndNode != null)
{
var startSvg = State.WorldToSvg(sourceStartNode.X + offsetX, sourceStartNode.Y + offsetY);
var endSvg = State.WorldToSvg(sourceEndNode.X + offsetX, sourceEndNode.Y + offsetY);
<line class="copy-preview-edge"
x1="@startSvg.X.ToString("F2")"
y1="@startSvg.Y.ToString("F2")"
x2="@endSvg.X.ToString("F2")"
y2="@endSvg.Y.ToString("F2")"
stroke="#ff9800"
stroke-width="0.03"
stroke-dasharray="0.1,0.05"
opacity="0.6" />
}
}
}
} *@
</svg>
</div>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EventCallback OnUndo { get; set; }
[Parameter]
public EventCallback OnRedo { get; set; }
[Parameter]
public EventCallback OnSave { get; set; }
[Parameter]
public EventCallback OnDelete { get; set; }
private ElementReference containerRef;
private ElementReference svgRef;
private IJSObjectReference? jsModule;
private DotNetObjectReference<SvgEditorCanvas>? dotNetRef;
private ScanView ScanViewRef = default!;
private EdgeEditing EdgeEditingRef = default!;
private EdgeCreatePreview EdgeCreatePreviewRef = default!;
private MousePositionDisplay MousePositionDisplayRef = default!;
private CopyPreview CopyPreviewRef = default!;
// Pan state
private bool isPanning;
private (double X, double Y)? panLastScreen; // Last screen coordinates (for incremental delta calculation)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
dotNetRef = DotNetObjectReference.Create(this);
try
{
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import", "./_content/RobotNet10.MapEditor/js/svgEditor.js");
await jsModule.InvokeVoidAsync("initEditor", svgRef, dotNetRef);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to initialize JS module: {ex.Message}");
}
}
}
public async ValueTask DisposeAsync()
{
if (jsModule != null)
{
try
{
await jsModule.InvokeVoidAsync("disposeEditor");
await jsModule.DisposeAsync();
}
catch { }
}
dotNetRef?.Dispose();
}
private string GetImageDataUrl()
{
if (State.BackgroundImage == null) return "";
return $"data:image/png;base64,{Convert.ToBase64String(State.BackgroundImage)}";
}
// Called from JavaScript
[JSInvokable]
public void OnMouseMove(double svgX, double svgY, double screenX = 0, double screenY = 0, bool ctrlKey = false)
{
// Update mouse position (always allowed)
var (worldX, worldY) = State.SvgToWorld(svgX, svgY);
MousePositionDisplayRef.Update(worldX, worldY);
// Pan is always allowed (even in ReadOnly mode) - handle it first
if (isPanning && panLastScreen.HasValue)
{
// Calculate incremental delta: from last position to current position
// This avoids accumulation because we're always calculating relative to the last move
if (jsModule != null)
{
_ = PanIncrementalAsync(panLastScreen.Value.X, panLastScreen.Value.Y, screenX, screenY);
}
// Update last position for next move
panLastScreen = (screenX, screenY);
return; // Pan handled, skip other operations
}
// Disable editing operations in ReadOnly mode (but allow box select for viewing)
if (State.IsReadOnly) return;
else
{
// Handle copy drag
if (State.Mode == EditorMode.Copy)
{
if (ctrlKey && State.CopyStartX.HasValue) CopyPreviewRef.UpdateCopyDrag(worldX, worldY);
return;
}
if (State.EdgeVehicleEditor != null && State.Mode == EditorMode.TrajectoryEditor)
{
if (State.Mode == EditorMode.Select && !ctrlKey) EdgeEditingRef.Cancel();
EdgeEditingRef.Update(svgX, svgY);
return;
}
// Handle node dragging
if (State.IsDraggingNodes && State.DraggedNodeId.HasValue && State.DragStartWorld.HasValue)
{
// Calculate delta from original position (in world coordinates)
var deltaX = worldX - State.DragStartWorld.Value.X;
var deltaY = worldY - State.DragStartWorld.Value.Y;
// In Select mode, finish drag if Ctrl key is released (keep nodes at current position)
// In Move mode, continue dragging regardless of Ctrl key
if (State.Mode == EditorMode.Select && !ctrlKey)
{
// Collect all nodes that were moved
var movedNodes = new List<NodeDto>();
var newPositions = new Dictionary<Guid, (double X, double Y)>();
foreach (var selectedId in State.SelectedNodeIds)
{
var node = State.Nodes.FirstOrDefault(n => n.Id == selectedId);
if (node != null && State.DragNodesOriginalPositions.ContainsKey(selectedId))
{
movedNodes.Add(node);
newPositions[selectedId] = (node.X, node.Y);
}
}
var movedEdges = new List<EdgeDto>();
var newCpPositions = new Dictionary<Guid, Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)>>();
foreach (var selectedId in State.SelectedEdgeIds)
{
var edge = State.Edges.FirstOrDefault(n => n.Id == selectedId);
if (edge != null)
{
if (edge.VehicleProperties is null) continue;
Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)> vehicleOriginCP = [];
foreach (var vehicle in edge.VehicleProperties)
{
vehicleOriginCP[vehicle.Id] = (vehicle.TrajectoryControlPoint1X, vehicle.TrajectoryControlPoint1Y, vehicle.TrajectoryControlPoint2X, vehicle.TrajectoryControlPoint2Y);
}
movedEdges.Add(edge);
newCpPositions[selectedId] = vehicleOriginCP;
}
}
// Create undo command if nodes were moved
if (movedNodes.Count > 0)
{
var command = new MoveNodesCommand(
movedNodes,
State.DragNodesOriginalPositions,
newPositions,
movedEdges,
State.DragEdgesOriginalPositions,
newCpPositions);
State.ExecuteCommand(command);
State.MarkDirty();
}
// Reset drag state
State.IsDraggingNodes = false;
State.DraggedNodeId = null;
State.DragStartWorld = null;
State.DragNodesOriginalPositions.Clear();
return;
}
var draggedNode = State.Nodes.FirstOrDefault(n => n.Id == State.DraggedNodeId.Value);
if (draggedNode != null)
{
// Update all selected nodes
foreach (var selectedId in State.SelectedNodeIds)
{
var node = State.Nodes.FirstOrDefault(n => n.Id == selectedId);
if (node != null && State.DragNodesOriginalPositions.ContainsKey(selectedId))
{
var originalPos = State.DragNodesOriginalPositions[selectedId];
node.X = originalPos.X + deltaX;
node.Y = originalPos.Y + deltaY;
}
}
// Update edge trajectories in real-time
foreach (var selectedId in State.SelectedEdgeIds)
{
var edge = State.Edges.FirstOrDefault(n => n.Id == selectedId);
if (edge != null && State.DragEdgesOriginalPositions.ContainsKey(selectedId))
{
if (edge.VehicleProperties is null) continue;
var originalEdge = State.DragEdgesOriginalPositions[selectedId];
foreach (var vehicle in edge.VehicleProperties)
{
if (!originalEdge.ContainsKey(vehicle.Id)) continue;
var originalPos = originalEdge[vehicle.Id];
if (vehicle.TrajectoryControlPoint1X.HasValue && originalPos.CP1X.HasValue) vehicle.TrajectoryControlPoint1X = originalPos.CP1X + deltaX;
if (vehicle.TrajectoryControlPoint1Y.HasValue && originalPos.CP1Y.HasValue) vehicle.TrajectoryControlPoint1Y = originalPos.CP1Y + deltaY;
if (vehicle.TrajectoryControlPoint2X.HasValue && originalPos.CP2X.HasValue) vehicle.TrajectoryControlPoint2X = originalPos.CP2X + deltaX;
if (vehicle.TrajectoryControlPoint2Y.HasValue && originalPos.CP2Y.HasValue) vehicle.TrajectoryControlPoint2Y = originalPos.CP2Y + deltaY;
}
}
}
State.NotifyDraggingNodesChanged();
State.NotifyDraggingEdgesChanged();
return;
}
}
// Update create edge preview
if ((State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way))
{
EdgeCreatePreviewRef.UpdateEdge(svgX, svgY);
}
}
// Update box select
ScanViewRef.UpdateEnd(svgX, svgY);
}
private async Task PanIncrementalAsync(double lastScreenX, double lastScreenY, double currentScreenX, double currentScreenY)
{
if (jsModule == null) return;
try
{
// Convert last and current screen positions to SVG coordinates
// using the CURRENT viewBox (before this pan)
var lastSvg = await jsModule.InvokeAsync<double[]>("screenToSvgArray", lastScreenX, lastScreenY);
var currentSvg = await jsModule.InvokeAsync<double[]>("screenToSvgArray", currentScreenX, currentScreenY);
if (lastSvg.Length >= 2 && currentSvg.Length >= 2)
{
// Calculate incremental delta: how much the mouse moved since last position
// Pan moves viewBox in opposite direction of mouse movement
var dx = lastSvg[0] - currentSvg[0];
var dy = lastSvg[1] - currentSvg[1];
State.Pan(dx, dy);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error in PanIncrementalAsync: {ex.Message}");
}
}
[JSInvokable]
public async Task OnMouseDown(double svgX, double svgY, int button, bool ctrlKey, double screenX = 0, double screenY = 0)
{
// Middle mouse button - start pan (always allowed, even in ReadOnly)
if (button == 1)
{
isPanning = true;
panLastScreen = (screenX, screenY);
return;
}
if (State.Mode == EditorMode.Scanner && button == 0)
{
ScanViewRef.UpdateStart(svgX, svgY);
ScanViewRef.UpdateEnd(svgX, svgY);
}
// Disable editing operations in ReadOnly mode
if (State.IsReadOnly) return;
else
{
// Left mouse button
if (button == 0)
{
if (State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way)
{
// Handle create edge - click on canvas
await EdgeCreatePreviewRef.CreateEdge(svgX, svgY);
}
else if (State.Mode == EditorMode.Copy)
{
// Start copy drag
var (worldX, worldY) = State.SvgToWorld(svgX, svgY);
CopyPreviewRef.StartCopyDrag(worldX, worldY);
}
else if (State.Mode == EditorMode.Select && !ctrlKey)
{
// Click on empty space - clear selection
// State.ClearSelection();
}
}
}
}
[JSInvokable]
public async Task OnMouseUp(double svgX, double svgY, int button)
{
// End pan (always allowed)
if (button == 1)
{
isPanning = false;
panLastScreen = null;
return;
}
if (State.Mode == EditorMode.Scanner && button == 0)
{
ScanViewRef.UpdateEnd(svgX, svgY);
ScanViewRef.FinishBox();
}
// Disable editing operations in ReadOnly mode
if (State.IsReadOnly) return;
else
{
// Finish copy drag
if (button == 0 && State.Mode == EditorMode.Copy && State.CopyStartX.HasValue)
{
await State.CompleteCopyAsync();
}
// Finish node dragging
if (button == 0 && State.IsDraggingNodes && State.DraggedNodeId.HasValue && State.DragStartWorld.HasValue)
{
// Collect all nodes that were moved
var movedNodes = new List<NodeDto>();
var newPositions = new Dictionary<Guid, (double X, double Y)>();
foreach (var selectedId in State.SelectedNodeIds)
{
var node = State.Nodes.FirstOrDefault(n => n.Id == selectedId);
if (node != null && State.DragNodesOriginalPositions.ContainsKey(selectedId))
{
movedNodes.Add(node);
newPositions[selectedId] = (node.X, node.Y);
}
}
var movedEdges = new List<EdgeDto>();
var newCpPositions = new Dictionary<Guid, Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)>>();
foreach (var selectedId in State.SelectedEdgeIds)
{
var edge = State.Edges.FirstOrDefault(n => n.Id == selectedId);
if (edge != null)
{
if (edge.VehicleProperties is null) continue;
Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)> vehicleOriginCP = [];
foreach (var vehicle in edge.VehicleProperties)
{
vehicleOriginCP[vehicle.Id] = (vehicle.TrajectoryControlPoint1X, vehicle.TrajectoryControlPoint1Y, vehicle.TrajectoryControlPoint2X, vehicle.TrajectoryControlPoint2Y);
}
movedEdges.Add(edge);
newCpPositions[selectedId] = vehicleOriginCP;
}
}
// Create undo command (no API save yet, will save on button Save)
if (movedNodes.Count > 0)
{
var (worldX, worldY) = State.SvgToWorld(svgX, svgY);
var deltaX = worldX - State.DragStartWorld.Value.X;
var deltaY = worldY - State.DragStartWorld.Value.Y;
var command = new MoveNodesCommand(
movedNodes,
State.DragNodesOriginalPositions,
newPositions,
movedEdges,
State.DragEdgesOriginalPositions,
newCpPositions);
State.ExecuteCommand(command);
State.MarkDirty();
}
// Reset drag state
State.IsDraggingNodes = false;
State.DraggedNodeId = null;
State.DragStartWorld = null;
State.DragNodesOriginalPositions.Clear();
}
// Cancel Move Control Point
EdgeEditingRef.Cancel();
}
}
[JSInvokable]
public void OnWheel(double svgX, double svgY, double deltaY)
{
var factor = deltaY > 0 ? 0.9 : 1.1;
State.Zoom(factor, svgX, svgY);
}
[JSInvokable]
public void OnKeyDown(string key, bool ctrlKey)
{
switch (key)
{
case "z":
if (ctrlKey) OnUndo.InvokeAsync();
break;
case "y":
if (ctrlKey) OnRedo.InvokeAsync();
break;
case "s":
if (ctrlKey) OnSave.InvokeAsync();
break;
case "Delete":
OnDelete.InvokeAsync();
break;
case "Escape":
if (State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way)
{
EdgeCreatePreviewRef.CancelCreateEdge();
State.SetMode(EditorMode.Select);
}
else if (State.Mode == EditorMode.Copy)
{
State.CancelCopy();
}
else
{
State.ClearSelection();
ScanViewRef.CancelBox();
}
break;
case "c":
if (ctrlKey && (State.SelectedNodeIds.Count > 0 || State.SelectedEdgeIds.Count > 0))
{
State.StartCopy();
}
break;
case "m":
if (ctrlKey && State.SelectedNodeIds.Count > 0)
{
State.SetMode(EditorMode.Move);
}
break;
}
}
}

View File

@@ -0,0 +1,53 @@
.svg-editor-container {
width: 100%;
height: 100%;
overflow: hidden;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.editor-svg {
width: 100%;
height: 100%;
display: block;
cursor: default;
background-color: #e8e8e8;
image-rendering: pixelated;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Edges */
@keyframes dash {
to {
stroke-dashoffset: -20;
}
}
@keyframes pulse-preview {
0%, 100% {
opacity: 0.7;
}
50% {
opacity: 1;
}
}
/* Grid */
#grid-layer line {
pointer-events: none;
}

View File

@@ -0,0 +1,58 @@
@inject LayoutManagerState State
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.LayoutId"
Label="Layout ID"
Required="true"
HelperText="Unique identifier for the layout" />
<MudTextField @bind-Value="request.LayoutName"
Label="Layout Name"
Required="true"
HelperText="Display name for the layout" />
<MudTextField @bind-Value="request.Description"
Label="Description"
Lines="3"
HelperText="Optional description" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success" Variant="Variant.Filled" OnClick="Submit" Disabled="@(!IsValid())">
Create
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
private CreateLayoutRequest request = new();
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(request.LayoutId) &&
!string.IsNullOrWhiteSpace(request.LayoutName);
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
try
{
await State.CreateLayoutAsync(request);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error creating layout: {ex.Message}", Severity.Error);
}
}
}

View File

@@ -0,0 +1,243 @@
@using Microsoft.AspNetCore.Components.Forms
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-2" />
Create New Level
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<!-- Basic Info -->
<MudTextField @bind-Value="layoutLevelId"
Label="Level ID *"
Required="true"
Variant="Variant.Outlined"
HelperText="Unique identifier (e.g., floor_1)" />
<MudNumericField @bind-Value="levelOrder"
Label="Level Order *"
Required="true"
Variant="Variant.Outlined"
HelperText="Display order (0-based)" />
<MudDivider Class="my-2" />
<!-- Image Upload (REQUIRED) -->
<MudText Typo="Typo.subtitle2">
<MudIcon Icon="@Icons.Material.Filled.Image" Class="mr-1" />
Background Image *
</MudText>
<MudFileUpload T="IBrowserFile"
@bind-Files="selectedFile"
Accept=".png"
OnFilesChanged="OnImageSelected"
MaximumFileCount="1">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Choose PNG File
</MudButton>
</CustomContent>
</MudFileUpload>
@if (selectedFile != null)
{
<MudPaper Class="pa-3" Elevation="1">
<MudStack Spacing="1">
<MudText Typo="Typo.body2">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" Class="mr-1" />
Selected: <strong>@selectedFile.Name</strong>
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
Size: @((selectedFile.Size / 1024.0).ToString("F2")) KB
</MudText>
@if (imageWidth > 0 && imageHeight > 0)
{
<MudText Typo="Typo.caption" Color="Color.Info">
<MudIcon Icon="@Icons.Material.Filled.AspectRatio" Size="Size.Small" />
Dimensions: <strong>@imageWidth × @imageHeight pixels</strong>
</MudText>
}
</MudStack>
</MudPaper>
}
<MudDivider Class="my-2" />
<!-- Coordinate System -->
<MudText Typo="Typo.subtitle2">Coordinate System *</MudText>
<MudNumericField @bind-Value="resolution"
Label="Resolution (m/pixel) *"
Variant="Variant.Outlined"
Step="0.001"
Min="0.001"
Required="true"
HelperText="Meters per pixel"
Format="F3" />
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="originX"
Label="Origin X (m) *"
Variant="Variant.Outlined"
Step="0.1"
Required="true"
Format="F2" />
<MudNumericField @bind-Value="originY"
Label="Origin Y (m) *"
Variant="Variant.Outlined"
Step="0.1"
Required="true"
Format="F2" />
</MudStack>
<!-- Calculated Map Size -->
@if (selectedFile != null && imageWidth > 0 && imageHeight > 0)
{
<MudPaper Class="pa-3" Elevation="0" Style="background-color: var(--mud-palette-success-lighten);">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Success">
<MudIcon Icon="@Icons.Material.Filled.Calculate" Size="Size.Small" /> Calculated Physical Map Size
</MudText>
<MudText Typo="Typo.body2">
<strong>@((imageWidth * resolution).ToString("F2")) × @((imageHeight * resolution).ToString("F2")) meters</strong>
</MudText>
</MudStack>
</MudPaper>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isCreating)">
@if (isCreating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Creating...</MudText>
}
else
{
<MudText>Create Level</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter, EditorRequired] public Guid VersionId { get; set; }
private string layoutLevelId = string.Empty;
private int levelOrder = 0;
private double resolution = 0.05;
private double originX = 0.0;
private double originY = 0.0;
private IBrowserFile? selectedFile;
private int imageWidth = 0;
private int imageHeight = 0;
private bool isCreating = false;
private async Task OnImageSelected(InputFileChangeEventArgs e)
{
var file = e.File;
await ProcessSelectedImage(file);
}
private async Task ProcessSelectedImage(IBrowserFile? file)
{
selectedFile = file;
if (file == null)
{
imageWidth = 0;
imageHeight = 0;
return;
}
// Extract image dimensions using JavaScript
try
{
// Read file as base64 for dimension extraction
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
// Simple PNG dimension extraction (bytes 16-23 contain width and height)
if (bytes.Length > 24 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
{
imageWidth = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
imageHeight = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
}
else
{
Snackbar.Add("Invalid PNG file format", Severity.Warning);
}
}
catch (Exception ex)
{
Snackbar.Add($"Error reading image: {ex.Message}", Severity.Error);
}
}
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(layoutLevelId) && selectedFile != null && imageWidth > 0 && imageHeight > 0;
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!IsValid() || selectedFile == null)
{
Snackbar.Add("Please fill all required fields and select an image", Severity.Warning);
return;
}
isCreating = true;
try
{
// Upload image and create level in one request
using var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
var createdLevel = await ApiService.CreateLevelWithImageAsync(
VersionId,
layoutLevelId,
levelOrder,
resolution,
originX,
originY,
stream,
selectedFile.Name);
Snackbar.Add($"Level '{layoutLevelId}' created successfully with image", Severity.Success);
MudDialog?.Close(DialogResult.Ok(createdLevel));
}
catch (Exception ex)
{
Snackbar.Add($"Error creating level: {ex.Message}", Severity.Error);
}
finally
{
isCreating = false;
}
}
}

View File

@@ -0,0 +1,55 @@
@inject LayoutManagerState State
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.Version"
Label="Version"
Required="true"
HelperText="Version number (e.g., 1.0, 2.1)" />
<MudTextField @bind-Value="request.LayoutDescription"
Label="Description"
Lines="3"
HelperText="Optional description for this version" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success" Variant="Variant.Filled" OnClick="Submit" Disabled="@(!IsValid())">
Create
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public Guid LayoutId { get; set; }
private CreateLayoutVersionRequest request = new();
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(request.Version);
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
try
{
await State.CreateVersionAsync(LayoutId, request);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error creating version: {ex.Message}", Severity.Error);
}
}
}

View File

@@ -0,0 +1,168 @@
@using RobotNet10.MapEditor.Shared.Models
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Edit" Class="mr-2" />
Edit Level Settings
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<!-- Level Info (Read-only) -->
<MudPaper Class="pa-3" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">Level ID</MudText>
<MudText Typo="Typo.body2"><strong>@Level?.LayoutLevelId</strong></MudText>
</MudStack>
</MudPaper>
<!-- Coordinate System Settings -->
<MudText Typo="Typo.subtitle2" Class="mt-2">Coordinate System</MudText>
<MudNumericField @bind-Value="resolution"
Label="Resolution (m/pixel)"
Variant="Variant.Outlined"
Step="0.001"
Min="0.001"
Required="true"
HelperText="Meters per pixel"
Format="F3" />
<MudNumericField @bind-Value="originX"
Label="Origin X (meters)"
Variant="Variant.Outlined"
Step="0.1"
Required="true"
HelperText="X coordinate of origin point"
Format="F2" />
<MudNumericField @bind-Value="originY"
Label="Origin Y (meters)"
Variant="Variant.Outlined"
Step="0.1"
Required="true"
HelperText="Y coordinate of origin point"
Format="F2" />
<!-- Current Map Size (Read-only) -->
@if (Level?.EditorSettings != null)
{
<MudPaper Class="pa-3 mt-2" Elevation="0" Style="background-color: var(--mud-palette-info-lighten);">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Info">
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Small" /> Current Map Information
</MudText>
@if (Level.EditorSettings.ImageWidth.HasValue && Level.EditorSettings.ImageHeight.HasValue)
{
<MudText Typo="Typo.body2">
Image Size: <strong>@Level.EditorSettings.ImageWidth × @Level.EditorSettings.ImageHeight px</strong>
</MudText>
<MudText Typo="Typo.body2">
Physical Size: <strong>@((Level.EditorSettings.ImageWidth.Value * resolution).ToString("F2")) × @((Level.EditorSettings.ImageHeight.Value * resolution).ToString("F2")) m</strong>
</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Warning">No image uploaded yet</MudText>
}
</MudStack>
</MudPaper>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save" Disabled="@isSaving">
@if (isSaving)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Saving...</MudText>
}
else
{
<MudText>Save Changes</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public LayoutLevelDto? Level { get; set; }
private double resolution;
private double originX;
private double originY;
private bool isSaving = false;
protected override void OnInitialized()
{
if (Level?.EditorSettings != null)
{
resolution = Level.EditorSettings.Resolution;
originX = Level.EditorSettings.OriginX;
originY = Level.EditorSettings.OriginY;
}
else
{
// Default values
resolution = 0.05;
originX = 0;
originY = 0;
}
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Save()
{
if (Level == null)
{
Snackbar.Add("Invalid level data", Severity.Error);
return;
}
isSaving = true;
try
{
var request = new UpdateLayoutLevelRequest
{
CoordinateSystem = new CoordinateSystemInfo
{
Resolution = resolution,
OriginX = originX,
OriginY = originY,
// Keep existing image dimensions and bounds
ImageWidth = Level.EditorSettings?.ImageWidth,
ImageHeight = Level.EditorSettings?.ImageHeight,
BoundsMinX = originX,
BoundsMaxX = Level.EditorSettings?.ImageWidth * resolution + originX,
BoundsMinY = originY,
BoundsMaxY = Level.EditorSettings?.ImageHeight * resolution + originY
}
};
var updatedLevel = await ApiService.UpdateLevelAsync(Level.Id, request);
Snackbar.Add("Level settings updated successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(updatedLevel));
}
catch (Exception ex)
{
Snackbar.Add($"Error updating level: {ex.Message}", Severity.Error);
}
finally
{
isSaving = false;
}
}
}

View File

@@ -0,0 +1,87 @@
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body1">
Export layout to VDMA LIF JSON file.
</MudText>
<MudDivider />
<MudTextField Value="@LayoutId.ToString()"
Label="Layout ID"
ReadOnly="true" />
<MudTextField Value="@Version"
Label="Version"
ReadOnly="true" />
<MudDivider />
<MudText Typo="Typo.caption" Color="Color.Default">
This will generate a VDMA LIF JSON file containing all levels and data for this layout version.
</MudText>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isExporting">
@if (isExporting)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>Exporting...</span>
}
else
{
<span>Export</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public Guid LayoutId { get; set; }
[Parameter, EditorRequired]
public string Version { get; set; } = "";
private bool isExporting;
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
isExporting = true;
StateHasChanged();
try
{
// TODO: Implement export via API
// var jsonData = await ApiService.ExportLIFAsync(LayoutId, Version);
// Download file...
await Task.Delay(1000); // Simulate export
Snackbar.Add("Export functionality not yet implemented", Severity.Warning);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error exporting layout: {ex.Message}", Severity.Error);
}
finally
{
isExporting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,110 @@
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body1">
Import VDMA LIF JSON file to create a new layout.
</MudText>
<MudFileUpload T="IBrowserFile" Accept=".json" FilesChanged="HandleFileSelected">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Select JSON File
</MudButton>
</CustomContent>
</MudFileUpload>
@if (selectedFile != null)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.AttachFile">
@selectedFile.Name (@FormatFileSize(selectedFile.Size))
</MudChip>
}
<MudDivider />
<MudText Typo="Typo.caption" Color="Color.Default">
The file should be a valid VDMA LIF JSON file containing layout, version, and level data.
</MudText>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(selectedFile == null || isUploading)">
@if (isUploading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>Importing...</span>
}
else
{
<span>Import</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
private IBrowserFile? selectedFile;
private bool isUploading;
private void HandleFileSelected(IBrowserFile? file)
{
selectedFile = file;
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
if (selectedFile == null) return;
isUploading = true;
StateHasChanged();
try
{
// TODO: Implement import via API
// var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
// await ApiService.ImportLIFAsync(stream);
await Task.Delay(1000); // Simulate upload
Snackbar.Add("Import functionality not yet implemented", Severity.Warning);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error importing file: {ex.Message}", Severity.Error);
}
finally
{
isUploading = false;
StateHasChanged();
}
}
private string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}

View File

@@ -0,0 +1,179 @@
@inject LayoutManagerState State
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@implements IDisposable
<!-- Toolbar -->
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<MudPaper Class="pa-4 mb-4" MinHeight="80px">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="3">
<MudText Typo="Typo.h5">Layout Manager</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<!-- Search Box -->
<MudTextField Value="searchText"
Placeholder="Search layouts..."
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Margin="Margin.Dense"
Style="min-width: 250px;"
Immediate="false"
T="string"
ValueChanged="TextSearchChanged"
Clearable="true" />
<!-- Import Button -->
<MudButton StartIcon="@Icons.Material.Filled.FileDownload"
Variant="Variant.Filled"
Color="Color.Primary"
OnClick="OpenImportDialog">
Import
</MudButton>
<!-- Add Layout Button -->
<MudButton StartIcon="@Icons.Material.Filled.Add"
Variant="Variant.Filled"
Color="Color.Success"
OnClick="OpenCreateLayoutDialog">
Add Layout
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
<!-- Loading State -->
@if (State.IsLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
<!-- Main Content -->
@if (State.IsLoading)
{
<MudPaper Class="pa-16 text-center">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
<MudText Typo="Typo.body1" Align="Align.Center" Class="mt-4">
Loading layouts...
</MudText>
</MudPaper>
}
else
{
<MudGrid Spacing="3">
<!-- Left Panel: Tree -->
<MudItem xs="12" md="4">
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 200px); overflow-y: auto;">
<LayoutTreePanel State="@State" />
</MudPaper>
</MudItem>
<!-- Right Panel: Preview -->
<MudItem xs="12" md="8">
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 200px); overflow-y: auto;">
@if (State.SelectedLevel != null)
{
<LayoutPreviewPanel State="@State"
OnEdit="NavigateToEditor"
OnStation="NavigateToStation"
OnExport="ExportLayout" />
}
else
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="height: 100%;">
<MudIcon Icon="@Icons.Material.Filled.Layers" Size="Size.Large" Color="Color.Secondary" Style="font-size: 100px;" />
<MudText Typo="Typo.h6" Color="Color.Secondary" Align="Align.Center" Class="mt-4">
Select a layout level to view preview
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
Choose a level from the tree on the left
</MudText>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
}
</MudContainer>
@code {
private string? searchText;
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += StateHasChanged;
await State.LoadLayoutsAsync();
}
public void Dispose()
{
State.OnStateChanged -= StateHasChanged;
}
private async Task TextSearchChanged(string text)
{
searchText = text;
await State.LoadLayoutsAsync(searchText);
}
private async Task OpenCreateLayoutDialog()
{
var dialog = await DialogService.ShowAsync<CreateLayoutDialog>("Create New Layout");
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Layout created successfully", Severity.Success);
}
}
private async Task OpenImportDialog()
{
var dialog = await DialogService.ShowAsync<ImportLayoutDialog>("Import VDMA LIF");
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Layout imported successfully", Severity.Success);
}
}
private void NavigateToEditor()
{
if (State.SelectedLevel != null)
{
Navigation.NavigateTo($"/layout-editor/{State.SelectedLevel.Id}");
}
}
private void NavigateToStation()
{
if (State.SelectedLevel != null)
{
Navigation.NavigateTo($"/station-manager/{State.SelectedLevel.Id}");
}
}
private async Task ExportLayout()
{
if (State.SelectedLayout == null || State.SelectedVersion == null)
{
Snackbar.Add("No layout selected", Severity.Warning);
return;
}
var dialog = await DialogService.ShowAsync<ExportLayoutDialog>("Export Layout", new DialogParameters
{
["LayoutId"] = State.SelectedLayout.Id,
["Version"] = State.SelectedVersion.Version
});
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Layout exported successfully", Severity.Success);
}
}
}

View File

@@ -0,0 +1,267 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.JSInterop
@inject MapManagerApiService ApiService
@inject IJSRuntime JS
@inject ISnackbar Snackbar
<MudStack Spacing="4">
<!-- Preview Canvas -->
<MudPaper Elevation="0" Style="height: 420px; border: 1px solid #ddd; position: relative;">
@if (State.IsLoadingPreview)
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="height: 100%;">
<MudProgressCircular Indeterminate="true" />
<MudText Typo="Typo.body2" Class="mt-2">Loading preview...</MudText>
</MudStack>
}
else if (State.PreviewData != null)
{
<SvgPreviewCanvas LayoutData="@State.PreviewData"
BackgroundImage="@State.PreviewImage"
EditorSettings="@State.SelectedLevel?.EditorSettings" />
}
else
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="height: 100%;">
<MudIcon Icon="@Icons.Material.Filled.ImageNotSupported" Size="Size.Large" Style="font-size: 60px; opacity: 0.3;" />
<MudText Typo="Typo.body2" Color="Color.Default" Style="opacity: 0.5;">
No preview data available
</MudText>
</MudStack>
}
</MudPaper>
<!-- Image Actions -->
<MudStack Row="true" Justify="Justify.Center" Spacing="2">
<MudButton StartIcon="@Icons.Material.Filled.Download"
Variant="Variant.Outlined"
Color="Color.Info"
Size="Size.Small"
OnClick="DownloadImage"
Disabled="@(State.PreviewImage == null)">
Download Image
</MudButton>
<MudFileUpload T="IBrowserFile"
Accept=".png"
OnFilesChanged="OnImageReplaceSelected"
MaximumFileCount="1">
<CustomContent>
<MudButton StartIcon="@Icons.Material.Filled.Upload"
Variant="Variant.Outlined"
Color="Color.Warning"
Size="Size.Small"
OnClick="@context.OpenFilePickerAsync">
Replace Image
</MudButton>
</CustomContent>
</MudFileUpload>
</MudStack>
<!-- Layout & Layout Information -->
<MudPaper Class="pa-3" Elevation="1">
<MudGrid>
<!-- Layout Info -->
<MudItem xs="12" md="4">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">LAYOUT INFO</MudText>
<MudText Typo="Typo.body2">
<strong>Layout:</strong> @State.SelectedLayout?.LayoutName
</MudText>
<MudText Typo="Typo.body2">
<strong>Version:</strong> @State.SelectedVersion?.Version
</MudText>
<MudText Typo="Typo.body2">
<strong>Level:</strong> @State.SelectedLevel?.LayoutLevelId
</MudText>
</MudStack>
</MudItem>
<!-- Element Counts -->
<MudItem xs="12" md="4">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">ELEMENTS</MudText>
<MudText Typo="Typo.body2">
<strong>Nodes:</strong> @(State.PreviewData?.Nodes.Count ?? 0)
</MudText>
<MudText Typo="Typo.body2">
<strong>Edges:</strong> @(State.PreviewData?.Edges.Count ?? 0)
</MudText>
<MudText Typo="Typo.body2">
<strong>Stations:</strong> @(State.PreviewData?.Stations.Count ?? 0)
</MudText>
</MudStack>
</MudItem>
<!-- Layout Settings -->
@if (State.SelectedLevel?.EditorSettings != null)
{
var settings = State.SelectedLevel.EditorSettings;
<MudItem xs="12" md="4">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">LAYOUT SETTINGS</MudText>
<MudText Typo="Typo.body2">
<strong>Resolution:</strong> @settings.Resolution.ToString("F3") m/px
</MudText>
<MudText Typo="Typo.body2">
<strong>Origin:</strong> (@settings.OriginX.ToString("F2"), @settings.OriginY.ToString("F2")) m
</MudText>
@if (settings.ImageWidth.HasValue && settings.ImageHeight.HasValue)
{
<MudText Typo="Typo.body2">
<strong>Image:</strong> @settings.ImageWidth × @settings.ImageHeight px
</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">
<strong>Physical:</strong> @((settings.ImageWidth.Value * settings.Resolution).ToString("F2")) × @((settings.ImageHeight.Value * settings.Resolution).ToString("F2")) m
</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Warning">
<MudIcon Icon="@Icons.Material.Filled.Warning" Size="Size.Small" Class="mr-1" />
No image
</MudText>
}
</MudStack>
</MudItem>
}
</MudGrid>
</MudPaper>
<!-- Action Buttons -->
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2">
<MudButton StartIcon="@Icons.Material.Filled.LocationOn"
Variant="Variant.Filled"
Color="Color.Tertiary"
Size="Size.Large"
OnClick="OnStation">
Station
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Edit"
Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
OnClick="OnEdit">
Edit Layout
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.FileUpload"
Variant="Variant.Outlined"
Color="Color.Secondary"
Size="Size.Large"
OnClick="OnExport">
Export LIF
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Refresh"
Variant="Variant.Text"
Color="Color.Default"
OnClick="RefreshPreview">
Refresh
</MudButton>
</MudStack>
</MudStack>
@code {
[Parameter, EditorRequired]
public LayoutManagerState State { get; set; } = null!;
[Parameter]
public EventCallback OnEdit { get; set; }
[Parameter]
public EventCallback OnStation { get; set; }
[Parameter]
public EventCallback OnExport { get; set; }
private async Task RefreshPreview()
{
if (State.SelectedLevel != null)
{
await State.SelectLevelAsync(State.SelectedLevel);
Snackbar.Add("Preview refreshed", Severity.Info);
}
}
private async Task DownloadImage()
{
if (State.SelectedLevel == null || State.PreviewImage == null)
{
Snackbar.Add("No image available to download", Severity.Warning);
return;
}
try
{
// Convert byte array to base64
var base64 = Convert.ToBase64String(State.PreviewImage);
var fileName = $"{State.SelectedLevel.LayoutLevelId}_background.png";
// Use JavaScript to trigger download
await JS.InvokeVoidAsync("eval",
$@"
const link = document.createElement('a');
link.href = 'data:image/png;base64,{base64}';
link.download = '{fileName}';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
");
Snackbar.Add($"Downloaded {fileName}", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error downloading image: {ex.Message}", Severity.Error);
}
}
private async Task OnImageReplaceSelected(InputFileChangeEventArgs e)
{
var file = e.File;
await ProcessImageReplace(file);
}
private async Task ProcessImageReplace(IBrowserFile? file)
{
if (file == null || State.SelectedLevel == null)
return;
// Validate file type
if (!file.ContentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add("Only PNG images are supported", Severity.Warning);
return;
}
// Validate file size (max 10MB)
const long maxSize = 10 * 1024 * 1024;
if (file.Size > maxSize)
{
Snackbar.Add($"File size exceeds maximum of 10MB", Severity.Warning);
return;
}
try
{
Snackbar.Add("Uploading image...", Severity.Info);
// Upload new image
using var stream = file.OpenReadStream(maxSize);
await ApiService.UploadLayoutImageAsync(State.SelectedLevel.Id, stream, file.Name);
// Refresh preview to show new image
await RefreshPreview();
Snackbar.Add("Image replaced successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error uploading image: {ex.Message}", Severity.Error);
}
}
}

View File

@@ -0,0 +1,317 @@
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<MudStack>
<!-- Header -->
<MudText Typo="Typo.h6" Class="mb-2">Layouts</MudText>
@if (State.Layouts.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Default" Align="Align.Center" Class="mt-8" Style="opacity: 0.6;">
No layouts found. Click "Add Layout" to create one.
</MudText>
}
else
{
<!-- Custom Tree Implementation -->
<MudPaper Elevation="0">
@foreach (var layout in State.Layouts)
{
<MudStack Spacing="0">
<!-- Layout Item -->
<MudPaper Elevation="0" Class="pa-2 hover-highlight" Style="cursor: pointer;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIconButton Icon="@(expandedLayouts.Contains(layout.Id) ? Icons.Material.Filled.ExpandMore : Icons.Material.Filled.ChevronRight)"
Size="Size.Small"
OnClick="() => ToggleLayout(layout.Id)" />
<MudIcon Icon="@Icons.Material.Filled.Map" Size="Size.Small" />
<MudText Typo="Typo.body2"><strong>@(layout.LayoutName)</strong></MudText>
@if (layout.IsActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text">Active</MudChip>
}
</MudStack>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true">
<MudMenuItem Icon="@Icons.Material.Filled.Add" OnClick="() => OpenCreateVersionDialog(layout)">
Add Version
</MudMenuItem>
@if (layout.IsActive)
{
<MudMenuItem Icon="@Icons.Material.Filled.ToggleOff" OnClick="() => DeactivateLayout(layout)">
Deactivate
</MudMenuItem>
}
else
{
<MudMenuItem Icon="@Icons.Material.Filled.ToggleOn" OnClick="() => ActivateLayout(layout)">
Activate
</MudMenuItem>
}
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="() => DeleteLayout(layout)">
Delete
</MudMenuItem>
</MudMenu>
</MudStack>
</MudPaper>
<!-- Versions (Nested) -->
@if (expandedLayouts.Contains(layout.Id) && layout.Versions != null)
{
<MudStack Spacing="0" Class="ml-6">
@foreach (var version in layout.Versions)
{
<!-- Version Item -->
<MudPaper Elevation="0" Class="pa-2 hover-highlight" Style="cursor: pointer;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIconButton Icon="@(expandedVersions.Contains(version.Id) ? Icons.Material.Filled.ExpandMore : Icons.Material.Filled.ChevronRight)"
Size="Size.Small"
OnClick="() => ToggleVersion(version.Id)" />
<MudIcon Icon="@Icons.Material.Filled.History" Size="Size.Small" />
<MudText Typo="Typo.body2">@version.Version</MudText>
@if (version.IsActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Text">Active</MudChip>
}
</MudStack>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true">
<MudMenuItem Icon="@Icons.Material.Filled.Add" OnClick="() => OpenCreateLevelDialog(version)">
Add Level
</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="() => DeleteVersion(version)">
Delete
</MudMenuItem>
</MudMenu>
</MudStack>
</MudPaper>
<!-- Levels (Nested) -->
@if (expandedVersions.Contains(version.Id) && version.Levels != null)
{
<MudStack Spacing="0" Class="ml-6">
@foreach (var level in version.Levels.OrderBy(l => l.LevelOrder))
{
<!-- Level Item -->
<MudPaper Elevation="@(State.SelectedLevel?.Id == level.Id ? 1 : 0)"
Class="@GetLevelItemClass(level)"
Style="cursor: pointer;"
@onclick="() => SelectLevel(level)">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="ml-4">
<MudIcon Icon="@Icons.Material.Filled.Layers" Size="Size.Small" />
<MudText Typo="Typo.body2">@level.LayoutLevelId</MudText>
</MudStack>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true">
<MudMenuItem Icon="@Icons.Material.Filled.Edit" OnClick="() => OpenEditLevelDialog(level)">
Edit Settings
</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="() => DeleteLevel(level)">
Delete
</MudMenuItem>
</MudMenu>
</MudStack>
</MudPaper>
}
</MudStack>
}
}
</MudStack>
}
</MudStack>
}
</MudPaper>
}
</MudStack>
@code {
[Parameter, EditorRequired]
public LayoutManagerState State { get; set; } = null!;
private HashSet<Guid> expandedLayouts = new();
private HashSet<Guid> expandedVersions = new();
private void ToggleLayout(Guid layoutId)
{
if (expandedLayouts.Contains(layoutId))
expandedLayouts.Remove(layoutId);
else
expandedLayouts.Add(layoutId);
}
private void ToggleVersion(Guid versionId)
{
if (expandedVersions.Contains(versionId))
expandedVersions.Remove(versionId);
else
expandedVersions.Add(versionId);
}
private async Task SelectLevel(LayoutLevelDto level)
{
await State.SelectLevelAsync(level);
}
private string GetLevelItemClass(LayoutLevelDto level)
{
var baseClass = "pa-2 hover-highlight";
return State.SelectedLevel?.Id == level.Id
? $"{baseClass} selected-item"
: baseClass;
}
// ===== LAYOUT ACTIONS =====
private async Task ActivateLayout(LayoutDto layout)
{
try
{
await State.ActivateLayoutAsync(layout.Id);
Snackbar.Add($"Layout '{layout.LayoutName}' activated", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error activating layout: {ex.Message}", Severity.Error);
}
}
private async Task DeactivateLayout(LayoutDto layout)
{
try
{
await State.DeactivateLayoutAsync(layout.Id);
Snackbar.Add($"Layout '{layout.LayoutName}' deactivated", Severity.Info);
}
catch (Exception ex)
{
Snackbar.Add($"Error deactivating layout: {ex.Message}", Severity.Error);
}
}
private async Task DeleteLayout(LayoutDto layout)
{
bool? confirm = await DialogService.ShowMessageBoxAsync(
"Confirm Delete",
$"Are you sure you want to delete layout '{layout.LayoutName}'? This will delete all versions and levels.",
yesText: "Delete", cancelText: "Cancel");
if (confirm == true)
{
try
{
await State.DeleteLayoutAsync(layout.Id);
Snackbar.Add($"Layout '{layout.LayoutName}' deleted", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting layout: {ex.Message}", Severity.Error);
}
}
}
// ===== VERSION ACTIONS =====
private async Task OpenCreateVersionDialog(LayoutDto layout)
{
var dialog = await DialogService.ShowAsync<CreateVersionDialog>("Create New Version", new DialogParameters
{
["LayoutId"] = layout.Id
});
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Version created successfully", Severity.Success);
}
}
private async Task DeleteVersion(LayoutVersionDto version)
{
bool? confirm = await DialogService.ShowMessageBoxAsync(
"Confirm Delete",
$"Are you sure you want to delete version '{version.Version}'? This will delete all levels.",
yesText: "Delete", cancelText: "Cancel");
if (confirm == true)
{
try
{
await State.DeleteVersionAsync(version.Id);
Snackbar.Add($"Version '{version.Version}' deleted", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting version: {ex.Message}", Severity.Error);
}
}
}
// ===== LEVEL ACTIONS =====
private async Task OpenCreateLevelDialog(LayoutVersionDto version)
{
var dialog = await DialogService.ShowAsync<CreateLevelDialog>("Create New Level", new DialogParameters
{
["VersionId"] = version.Id
});
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Level created successfully", Severity.Success);
await State.LoadLayoutsAsync(); // Refresh tree
}
}
private async Task OpenEditLevelDialog(LayoutLevelDto level)
{
var dialog = await DialogService.ShowAsync<EditLevelDialog>("Edit Level Settings", new DialogParameters
{
["Level"] = level
});
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Level settings updated successfully", Severity.Success);
await State.LoadLayoutsAsync(); // Refresh tree
}
}
private async Task DeleteLevel(LayoutLevelDto level)
{
bool? confirm = await DialogService.ShowMessageBoxAsync(
"Confirm Delete",
$"Are you sure you want to delete level '{level.LayoutLevelId}'?",
yesText: "Delete", cancelText: "Cancel");
if (confirm == true)
{
try
{
await State.DeleteLevelAsync(level.Id);
Snackbar.Add($"Level '{level.LayoutLevelId}' deleted", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting level: {ex.Message}", Severity.Error);
}
}
}
}
<style>
.hover-highlight:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.selected-item {
background-color: rgba(33, 150, 243, 0.12) !important;
}
</style>

View File

@@ -0,0 +1,243 @@
@using RobotNet10.MapEditor.Shared.DTOs.LayoutData
<svg width="100%" height="100%" viewBox="@ViewBoxString" style="background: #f5f5f5;">
<!-- Marker Definitions -->
<defs>
<marker id="originvector" markerWidth="4" markerHeight="4" refX="0.8" refY="3.5">
<!-- X-axis (red, horizontal) -->
<line x1="0" y1="3.5" x2="3" y2="3.5" stroke="red" stroke-width="0.3" />
<path d="M 3 3.8 L 3.5 3.5 L 3 3.2 Z" fill="red" stroke-width="0" />
<!-- Y-axis (blue, vertical) -->
<line x1="0.8" y1="4" x2="0.8" y2="1" stroke="blue" stroke-width="0.3" />
<path d="M 1.1 1 L 0.8 0.5 L 0.5 1 Z" fill="blue" stroke-width="0" />
</marker>
</defs>
<!-- Background Image -->
@if (BackgroundImage != null && BackgroundImage.Length > 0 && EditorSettings != null)
{
var imageSvgX = GetImageSvgX();
var imageSvgY = GetImageSvgY();
<image href="@GetImageDataUrl()"
x="@imageSvgX"
y="@imageSvgY"
width="@GetImageWidth()"
height="@GetImageHeight()"
opacity="0.7"
preserveAspectRatio="none" />
}
<!-- Origin Marker -->
@if (EditorSettings != null)
{
// Origin in world coordinates is (0, 0), convert to SVG coordinates
var originSvg = WorldToSvg(0, 0);
var originXStr = originSvg.X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
var originYStr = originSvg.Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
<line x1="@originXStr"
y1="@originYStr"
x2="@originXStr"
y2="@originYStr"
stroke="transparent"
marker-end="url(#originvector)"
stroke-width="0.6" />
}
<!-- Edges -->
<g id="edges">
@foreach (var edge in LayoutData?.Edges ?? new())
{
var startNode = LayoutData?.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
var endNode = LayoutData?.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
if (startNode != null && endNode != null)
{
var startSvg = WorldToSvg(startNode.X, startNode.Y);
var endSvg = WorldToSvg(endNode.X, endNode.Y);
<line x1="@startSvg.X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
y1="@startSvg.Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
x2="@endSvg.X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
y2="@endSvg.Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
stroke="#3498db"
stroke-width="0.2"
opacity="0.8" />
}
}
</g>
<!-- Nodes -->
<g id="nodes">
@foreach (var node in LayoutData?.Nodes ?? new())
{
var svg = WorldToSvg(node.X, node.Y);
<circle cx="@svg.X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
cy="@svg.Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
r="0.3"
fill="#e74c3c"
stroke="#c0392b"
stroke-width="0.1" />
}
</g>
<!-- Stations -->
<g id="stations">
@foreach (var station in LayoutData?.Stations ?? new())
{
var svg = WorldToSvg(station.X, station.Y);
var rectX = (svg.X - 0.3).ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
var rectY = (svg.Y - 0.3).ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
<rect x="@rectX"
y="@rectY"
width="0.6" height="0.6"
fill="#27ae60"
stroke="#229954"
stroke-width="0.1" />
}
</g>
</svg>
@code {
[Parameter]
public LayoutDataDto? LayoutData { get; set; }
[Parameter]
public byte[]? BackgroundImage { get; set; }
[Parameter]
public LayoutLevelEditorSettingsDto? EditorSettings { get; set; }
/// <summary>
/// Get physical dimensions of the layout in meters
/// </summary>
private (double Width, double Height) GetPhysicalDimensions()
{
if (EditorSettings == null)
return (50, 30);
return (
(EditorSettings.ImageWidth ?? 1000) * EditorSettings.Resolution,
(EditorSettings.ImageHeight ?? 500) * EditorSettings.Resolution
);
}
/// <summary>
/// Transform world coordinates (layout) to SVG coordinates
/// World: Origin at bottom-left (relative to image), Y up
/// SVG: Origin at top-left, Y down
/// </summary>
private (double X, double Y) WorldToSvg(double worldX, double worldY)
{
if (EditorSettings == null)
return (worldX, worldY);
var (_, physicalHeight) = GetPhysicalDimensions();
var originX = EditorSettings.OriginX;
var originY = EditorSettings.OriginY;
// X: subtract origin to shift coordinate system
// Y: flip vertically (world Y-up -> SVG Y-down)
return (
worldX - originX,
physicalHeight - (worldY - originY)
);
}
private string ViewBoxString
{
get
{
// If we have image dimensions, use those for viewBox to show the full map
if (EditorSettings?.ImageWidth.HasValue == true && EditorSettings?.ImageHeight.HasValue == true)
{
var width = EditorSettings.ImageWidth.Value * EditorSettings.Resolution;
var height = EditorSettings.ImageHeight.Value * EditorSettings.Resolution;
// ViewBox starts at (0, 0) in SVG coordinates (top-left)
return $"0 0 {width.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} {height.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}";
}
// Otherwise, calculate bounds from nodes in SVG coordinates
if (LayoutData != null && LayoutData.Nodes.Count > 0)
{
var svgCoords = LayoutData.Nodes.Select(n => WorldToSvg(n.X, n.Y)).ToList();
var minX = svgCoords.Min(c => c.X);
var maxX = svgCoords.Max(c => c.X);
var minY = svgCoords.Min(c => c.Y);
var maxY = svgCoords.Max(c => c.Y);
// Add padding
var padding = Math.Max((maxX - minX), (maxY - minY)) * 0.1;
minX -= padding;
minY -= padding;
var width = (maxX - minX) + 2 * padding;
var height = (maxY - minY) + 2 * padding;
return $"{minX.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} {minY.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} {width.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} {height.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}";
}
// Default fallback
return "0 0 100 50";
}
}
private string GetImageDataUrl()
{
if (BackgroundImage == null || BackgroundImage.Length == 0)
return "";
return $"data:image/png;base64,{Convert.ToBase64String(BackgroundImage)}";
}
/// <summary>
/// Get image width in SVG coordinates (meters)
/// </summary>
private string GetImageWidth()
{
if (EditorSettings?.ImageWidth.HasValue == true)
{
var width = EditorSettings.ImageWidth.Value * EditorSettings.Resolution;
return width.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
}
return "100";
}
/// <summary>
/// Get image height in SVG coordinates (meters)
/// </summary>
private string GetImageHeight()
{
if (EditorSettings?.ImageHeight.HasValue == true)
{
var height = EditorSettings.ImageHeight.Value * EditorSettings.Resolution;
return height.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
}
return "50";
}
/// <summary>
/// Get image X position in SVG coordinates
/// Image should be positioned at (0, 0) in SVG coordinates (top-left)
/// </summary>
private string GetImageSvgX()
{
if (EditorSettings == null)
return "0";
// Image starts at origin in SVG coordinates (which is 0 after transformation)
return "0";
}
/// <summary>
/// Get image Y position in SVG coordinates
/// Image should be positioned at (0, 0) in SVG coordinates (top-left)
/// </summary>
private string GetImageSvgY()
{
if (EditorSettings == null)
return "0";
// Image starts at origin in SVG coordinates (which is 0 after transformation)
return "0";
}
}

View File

@@ -0,0 +1,177 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@inject StationManagerState State
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-2" />
Create New Station
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<!-- Station ID -->
<MudTextField @bind-Value="request.StationId"
Label="Station ID *"
Required="true"
Variant="Variant.Outlined"
HelperText="Unique identifier (must be unique within this level)"
Validation="@(new Func<string, string?>(ValidateStationId))" />
<!-- Station Name -->
<MudTextField @bind-Value="request.StationName"
Label="Station Name"
Variant="Variant.Outlined"
HelperText="Display name for this station" />
<!-- Description -->
<MudTextField @bind-Value="request.StationDescription"
Label="Description"
Lines="3"
Variant="Variant.Outlined"
HelperText="Optional description" />
<MudDivider Class="my-2" />
<!-- Position Section -->
<MudText Typo="Typo.subtitle2">Position</MudText>
<MudGrid>
<MudItem xs="6">
<MudNumericField @bind-Value="request.X"
Label="X (meters) *"
Required="true"
Variant="Variant.Outlined"
Format="F3"
Step="0.1" />
</MudItem>
<MudItem xs="6">
<MudNumericField @bind-Value="request.Y"
Label="Y (meters) *"
Required="true"
Variant="Variant.Outlined"
Format="F3"
Step="0.1" />
</MudItem>
<MudItem xs="6">
<MudNumericField @bind-Value="request.Theta"
Label="Theta (radians)"
Variant="Variant.Outlined"
Format="F3"
Step="0.1"
HelperText="Optional, range: -π to π" />
</MudItem>
<MudItem xs="6">
<MudNumericField @bind-Value="request.StationHeight"
Label="Height (meters)"
Variant="Variant.Outlined"
Format="F2"
Step="0.1"
HelperText="Optional" />
</MudItem>
</MudGrid>
<!-- Validation Messages -->
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isSubmitting)">
@if (isSubmitting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Creating...</MudText>
}
else
{
<MudText>Create</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public Guid LayoutLevelId { get; set; }
private CreateStationRequest request = new();
private bool isSubmitting = false;
private string? errorMessage;
protected override void OnInitialized()
{
request.LayoutLevelId = LayoutLevelId;
// Set default position
request.X = 0;
request.Y = 0;
}
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(request.StationId);
}
private string? ValidateStationId(string value)
{
if (string.IsNullOrWhiteSpace(value))
return "Station ID is required";
if (value.Length > 128)
return "Station ID must be 128 characters or less";
// Check if already exists in current stations
if (State.Stations.Any(s => s.StationId.Equals(value, StringComparison.OrdinalIgnoreCase)))
return "Station ID already exists in this level";
return null;
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
errorMessage = null;
// Validate theta range
if (request.Theta.HasValue && (request.Theta.Value < -Math.PI || request.Theta.Value > Math.PI))
{
errorMessage = "Theta must be between -π and π";
return;
}
// Validate station height
if (request.StationHeight.HasValue && request.StationHeight.Value < 0)
{
errorMessage = "Station height must be >= 0";
return;
}
isSubmitting = true;
try
{
var created = await State.CreateStationAsync(request);
MudDialog.Close(DialogResult.Ok(created));
}
catch (Exception ex)
{
errorMessage = ex.Message;
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,115 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.Station
@inject StationManagerState State
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2" />
Delete Station
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudText>
Are you sure you want to delete this station?
</MudText>
<!-- Station Info -->
<MudPaper Class="pa-3" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Spacing="2">
<MudStack Row="true" Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">Station ID:</MudText>
<MudText Typo="Typo.body2"><strong>@Station.StationId</strong></MudText>
</MudStack>
@if (!string.IsNullOrEmpty(Station.StationName))
{
<MudStack Row="true" Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">Name:</MudText>
<MudText Typo="Typo.body2">@Station.StationName</MudText>
</MudStack>
}
<MudStack Row="true" Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">Position:</MudText>
<MudText Typo="Typo.body2">(@Station.X.ToString("F2"), @Station.Y.ToString("F2"))</MudText>
</MudStack>
<MudStack Row="true" Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">Interaction Nodes:</MudText>
<MudText Typo="Typo.body2">@(Station.InteractionNodes?.Count ?? 0)</MudText>
</MudStack>
</MudStack>
</MudPaper>
<!-- Warning about interaction nodes -->
@if (Station.InteractionNodes != null && Station.InteractionNodes.Count > 0)
{
<MudAlert Severity="Severity.Warning">
This station has @Station.InteractionNodes.Count interaction node(s).
The links to these nodes will be removed, but the nodes themselves will not be deleted.
</MudAlert>
}
<MudAlert Severity="Severity.Error">
<strong>This action cannot be undone.</strong>
</MudAlert>
<!-- Error Message -->
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error">
@errorMessage
</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="@isDeleting">Cancel</MudButton>
<MudButton Color="Color.Error"
Variant="Variant.Filled"
OnClick="Delete"
Disabled="@isDeleting">
@if (isDeleting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Deleting...</MudText>
}
else
{
<MudText>Delete Station</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public StationDto Station { get; set; } = null!;
private bool isDeleting = false;
private string? errorMessage;
private void Cancel() => MudDialog.Cancel();
private async Task Delete()
{
errorMessage = null;
isDeleting = true;
try
{
await State.DeleteStationAsync(Station.Id);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
errorMessage = ex.Message;
isDeleting = false;
}
}
}

View File

@@ -0,0 +1,392 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.Station
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Components.StationManager.Dialogs
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject MapManagerApiService ApiService
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 180px); overflow-y: auto;">
@if (State.SelectedStation == null)
{
<!-- No Selection State -->
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="min-height: 500px;">
<MudIcon Icon="@Icons.Material.Filled.TouchApp" Size="Size.Large" Color="Color.Secondary" />
<MudText Typo="Typo.h6" Color="Color.Secondary">No Station Selected</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
Select a station from the list to view and edit details.
</MudText>
</MudStack>
}
else
{
<!-- Station Details -->
<MudStack Spacing="3">
<!-- Header -->
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.h6">Station Details</MudText>
<MudStack Row="true" Spacing="1">
@if (isEditing)
{
<MudTooltip Text="Cancel">
<MudIconButton Icon="@Icons.Material.Filled.Close"
Size="Size.Small"
OnClick="CancelEdit" />
</MudTooltip>
<MudTooltip Text="Save Changes">
<MudIconButton Icon="@Icons.Material.Filled.Save"
Size="Size.Small"
Color="Color.Success"
OnClick="SaveChanges"
Disabled="@State.IsSaving" />
</MudTooltip>
}
else
{
<MudTooltip Text="Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
OnClick="StartEdit" />
</MudTooltip>
<MudTooltip Text="Delete">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="HandleDelete" />
</MudTooltip>
}
</MudStack>
</MudStack>
<MudDivider />
@if (State.IsSaving)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
<!-- Basic Info -->
<MudPaper Class="pa-3" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Spacing="2">
<MudText Typo="Typo.caption" Color="Color.Secondary">STATION ID (READ-ONLY)</MudText>
<MudText Typo="Typo.body1"><strong>@State.SelectedStation.StationId</strong></MudText>
</MudStack>
</MudPaper>
<!-- Editable Fields -->
<MudTextField Label="Station Name"
@bind-Value="editModel.StationName"
Variant="Variant.Outlined"
ReadOnly="@(!isEditing)"
HelperText="Display name for this station" />
<MudTextField Label="Description"
@bind-Value="editModel.StationDescription"
Variant="Variant.Outlined"
Lines="3"
ReadOnly="@(!isEditing)"
HelperText="Optional description" />
<!-- Position Section -->
<MudText Typo="Typo.subtitle2" Class="mt-2">Position</MudText>
<MudGrid>
<MudItem xs="6">
<MudNumericField Label="X (meters)"
@bind-Value="editModel.X"
Variant="Variant.Outlined"
ReadOnly="@(!isEditing)"
Format="F3"
Step="0.1" />
</MudItem>
<MudItem xs="6">
<MudNumericField Label="Y (meters)"
@bind-Value="editModel.Y"
Variant="Variant.Outlined"
ReadOnly="@(!isEditing)"
Format="F3"
Step="0.1" />
</MudItem>
<MudItem xs="6">
<MudNumericField Label="Theta (radians)"
@bind-Value="editModel.Theta"
Variant="Variant.Outlined"
ReadOnly="@(!isEditing)"
Format="F3"
Step="0.1"
HelperText="Range: -π to π" />
</MudItem>
<MudItem xs="6">
<MudNumericField Label="Height (meters)"
@bind-Value="editModel.StationHeight"
Variant="Variant.Outlined"
ReadOnly="@(!isEditing)"
Format="F2"
Step="0.1"
HelperText="Optional" />
</MudItem>
</MudGrid>
<!-- Interaction Nodes Section -->
<MudText Typo="Typo.subtitle2" Class="mt-2">
Interaction Nodes (@(editModel.InteractionNodeIds?.Count ?? 0))
</MudText>
@if (isEditing)
{
<MudPaper Class="pa-2" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Spacing="2">
@if (availableNodes == null)
{
<MudProgressLinear Indeterminate="true" Size="Size.Small" />
}
else if (editModel.InteractionNodeIds != null && editModel.InteractionNodeIds.Count > 0)
{
<MudStack Spacing="1">
@foreach (var nodeId in editModel.InteractionNodeIds.ToList())
{
var node = availableNodes.FirstOrDefault(n => n.Id == nodeId);
<MudChip T="string"
OnClose="() => RemoveInteractionNode(nodeId)"
Size="Size.Small"
Color="Color.Info">
@if (node != null)
{
<text>@node.NodeId (@node.X.ToString("F2"), @node.Y.ToString("F2"))</text>
}
else
{
<text>Node @nodeId</text>
}
</MudChip>
}
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No interaction nodes</MudText>
}
@if (availableNodes != null && availableNodes.Count > 0)
{
<MudAutocomplete T="NodeDto"
Label="Add Interaction Node"
SearchFunc="SearchNodes"
ToStringFunc="@(n => n == null ? "" : $"{n.NodeId} ({n.X:F2}, {n.Y:F2})")"
ValueChanged="AddInteractionNode"
Variant="Variant.Outlined"
Dense="true"
Clearable="true"
ResetValueOnEmptyText="true" />
}
</MudStack>
</MudPaper>
}
else
{
<MudPaper Class="pa-2" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
@if (State.SelectedStation.InteractionNodes != null && State.SelectedStation.InteractionNodes.Count > 0)
{
<MudStack Spacing="1">
@foreach (var interactionNode in State.SelectedStation.InteractionNodes)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">
@if (interactionNode.Node != null)
{
<text>@interactionNode.Node.NodeId (@interactionNode.Node.X.ToString("F2"), @interactionNode.Node.Y.ToString("F2"))</text>
}
else
{
<text>Node @interactionNode.NodeId</text>
}
</MudChip>
}
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No interaction nodes</MudText>
}
</MudPaper>
}
<!-- Metadata -->
</MudStack>
}
</MudPaper>
@code {
[Parameter, EditorRequired]
public StationManagerState State { get; set; } = null!;
[Parameter]
public EventCallback OnStationDeleted { get; set; }
private bool isEditing = false;
private EditModel editModel = new();
private List<NodeDto>? availableNodes;
private class EditModel
{
public string? StationName { get; set; }
public string? StationDescription { get; set; }
public double? X { get; set; }
public double? Y { get; set; }
public double? Theta { get; set; }
public double? StationHeight { get; set; }
public List<Guid>? InteractionNodeIds { get; set; }
}
protected override void OnParametersSet()
{
if (State.SelectedStation != null && !isEditing)
{
LoadEditModel();
}
}
private void LoadEditModel()
{
if (State.SelectedStation == null) return;
editModel = new EditModel
{
StationName = State.SelectedStation.StationName,
StationDescription = State.SelectedStation.StationDescription,
X = State.SelectedStation.X,
Y = State.SelectedStation.Y,
Theta = State.SelectedStation.Theta,
StationHeight = State.SelectedStation.StationHeight,
InteractionNodeIds = State.SelectedStation.InteractionNodes?
.Select(i => i.NodeId)
.ToList() ?? new List<Guid>()
};
}
private async Task StartEdit()
{
isEditing = true;
LoadEditModel();
// Load available nodes for autocomplete
if (State.CurrentLayoutLevelId.HasValue)
{
try
{
var layoutData = await ApiService.GetLayoutDataAsync(State.CurrentLayoutLevelId.Value);
availableNodes = layoutData.Nodes;
}
catch (Exception ex)
{
Snackbar.Add($"Failed to load nodes: {ex.Message}", Severity.Warning);
availableNodes = new List<NodeDto>();
}
}
}
private void CancelEdit()
{
isEditing = false;
LoadEditModel();
availableNodes = null;
}
private async Task SaveChanges()
{
if (State.SelectedStation == null) return;
// Validate theta range
if (editModel.Theta.HasValue && (editModel.Theta.Value < -Math.PI || editModel.Theta.Value > Math.PI))
{
Snackbar.Add("Theta must be between -π and π", Severity.Warning);
return;
}
// Validate station height
if (editModel.StationHeight.HasValue && editModel.StationHeight.Value < 0)
{
Snackbar.Add("Station height must be >= 0", Severity.Warning);
return;
}
var request = new UpdateStationRequest
{
StationName = editModel.StationName,
StationDescription = editModel.StationDescription,
X = editModel.X,
Y = editModel.Y,
Theta = editModel.Theta,
StationHeight = editModel.StationHeight,
InteractionNodeIds = editModel.InteractionNodeIds
};
try
{
await State.UpdateStationAsync(State.SelectedStation.Id, request);
isEditing = false;
availableNodes = null;
Snackbar.Add("Station updated successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to update station: {ex.Message}", Severity.Error);
}
}
private async Task HandleDelete()
{
if (State.SelectedStation == null) return;
var parameters = new DialogParameters<DeleteStationDialog>
{
{ x => x.Station, State.SelectedStation }
};
var dialog = await DialogService.ShowAsync<DeleteStationDialog>(
"Delete Station",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Small });
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await OnStationDeleted.InvokeAsync();
}
}
private Task<IEnumerable<NodeDto>> SearchNodes(string value, CancellationToken token)
{
if (availableNodes == null)
return Task.FromResult<IEnumerable<NodeDto>>(Array.Empty<NodeDto>());
if (string.IsNullOrWhiteSpace(value))
return Task.FromResult<IEnumerable<NodeDto>>(availableNodes);
var searchLower = value.ToLowerInvariant();
var filtered = availableNodes
.Where(n => n.NodeId.ToLower().Contains(searchLower) ||
(n.NodeName != null && n.NodeName.ToLower().Contains(searchLower)))
.ToList();
return Task.FromResult<IEnumerable<NodeDto>>(filtered);
}
private void AddInteractionNode(NodeDto? node)
{
if (node == null || editModel.InteractionNodeIds == null) return;
if (!editModel.InteractionNodeIds.Contains(node.Id))
{
editModel.InteractionNodeIds.Add(node.Id);
}
}
private void RemoveInteractionNode(Guid nodeId)
{
editModel.InteractionNodeIds?.Remove(nodeId);
}
}

View File

@@ -0,0 +1,120 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.Station
@inject ISnackbar Snackbar
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 180px); overflow-y: hidden;">
<MudStack Spacing="3">
<!-- Header -->
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.h6">
Stations
</MudText>
@if (State.IsLoading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
</MudStack>
<MudDivider />
<!-- Stations Table -->
@if (State.IsLoading && State.Stations.Count == 0)
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="min-height: 400px;">
<MudProgressCircular Size="Size.Large" Indeterminate="true" />
<MudText>Loading stations...</MudText>
</MudStack>
}
else if (!string.IsNullOrEmpty(State.ErrorMessage))
{
<MudAlert Severity="Severity.Error">
@State.ErrorMessage
</MudAlert>
}
else if (State.GetFilteredStations().Count == 0)
{
<!-- Empty State -->
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="min-height: 400px;">
<MudIcon Icon="@Icons.Material.Filled.Store" Size="Size.Large" Color="Color.Secondary" />
<MudText Typo="Typo.h6" Color="Color.Secondary">No Stations Found</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
@if (!string.IsNullOrWhiteSpace(State.SearchQuery))
{
<text>No stations match your search criteria.</text>
}
else
{
<text>Click "Add Station" to create your first station.</text>
}
</MudText>
</MudStack>
}
else
{
<MudTable Items="@State.GetFilteredStations()"
Hover="true"
Dense="true"
FixedHeader="true"
Height="calc(100vh - 342px)"
SelectedItem="@State.SelectedStation"
Elevation="0"
SelectedItemChanged="OnStationSelected"
T="StationDto">
<HeaderContent>
<MudTh>Station ID</MudTh>
<MudTh>Name</MudTh>
<MudTh Style="text-align: right;">Position (X, Y)</MudTh>
<MudTh Style="text-align: right;">Theta</MudTh>
<MudTh Style="text-align: center;">Nodes</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Station ID">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Store" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.body2"><strong>@context.StationId</strong></MudText>
</MudStack>
</MudTd>
<MudTd DataLabel="Name">
<MudText Typo="Typo.body2">
@(context.StationName ?? "-")
</MudText>
</MudTd>
<MudTd DataLabel="Position" Style="text-align: right;">
<MudText Typo="Typo.body2" Style="font-family: monospace;">
(@context.X.ToString("F2"), @context.Y.ToString("F2"))
</MudText>
</MudTd>
<MudTd DataLabel="Theta" Style="text-align: right;">
<MudText Typo="Typo.body2" Style="font-family: monospace;">
@(context.Theta.HasValue ? context.Theta.Value.ToString("F3") : "-")
</MudText>
</MudTd>
<MudTd DataLabel="Nodes" Style="text-align: center;">
<MudChip T="string" Size="Size.Small" Color="Color.Info">
@(context.InteractionNodes?.Count ?? 0)
</MudChip>
</MudTd>
</RowTemplate>
<PagerContent>
<div class="d-flex w-100 flex-row-reverse">
<MudTablePager HideRowsPerPage Style="width: 100%;" PageSizeOptions="new[] { 25, 100, 200 }" />
</div>
</PagerContent>
</MudTable>
}
</MudStack>
</MudPaper>
@code {
[Parameter, EditorRequired]
public StationManagerState State { get; set; } = null!;
private async Task OnStationSelected(StationDto? station)
{
if (station != null)
{
await State.SelectStationAsync(station.Id);
}
}
}

View File

@@ -0,0 +1,140 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.Station
@using RobotNet10.MapEditor.Components.StationManager
@using RobotNet10.MapEditor.Components.StationManager.Dialogs
@inject StationManagerState State
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@implements IDisposable
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<!-- Header -->
<MudPaper Class="pa-4 mb-4" MinHeight="80px">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="3">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Store" Size="Size.Large" />
<MudText Typo="Typo.h5">Station Management</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudTextField @bind-Value="searchText"
Placeholder="Search stations..."
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Margin="Margin.Dense"
Style="min-width: 250px;"
Immediate="true"
DebounceInterval="300"
Clearable="true" />
<MudButton StartIcon="@Icons.Material.Filled.Add"
Variant="Variant.Filled"
Color="Color.Primary"
OnClick="HandleCreate"
Disabled="@(!State.CurrentLayoutLevelId.HasValue)">
Add Station
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
@if (!State.CurrentLayoutLevelId.HasValue)
{
<!-- No Level Selected -->
<MudPaper Class="pa-8 text-center" Elevation="0">
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Large" Color="Color.Info" Class="mb-4" />
<MudText Typo="Typo.h6" Class="mb-2">No Layout Level Selected</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary">
Please open this component from the Layout Editor or select a layout level.
</MudText>
</MudPaper>
}
else
{
<!-- Main Content -->
<MudGrid>
<!-- Left: List Panel -->
<MudItem xs="12" md="7">
<StationListPanel @ref="StationListPanelRef" State="@State" />
</MudItem>
<!-- Right: Details Panel -->
<MudItem xs="12" md="5">
<StationDetailsPanel State="@State" OnStationDeleted="HandleStationDeleted" />
</MudItem>
</MudGrid>
}
</MudContainer>
@code {
[Parameter]
public Guid? LayoutLevelId { get; set; }
private string _searchText = string.Empty;
private string searchText
{
get => _searchText;
set
{
if (_searchText != value)
{
_searchText = value;
State.Search(value);
}
}
}
private StationListPanel? StationListPanelRef;
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += StateHasChanged;
}
protected override async Task OnParametersSetAsync()
{
if (LayoutLevelId.HasValue && LayoutLevelId.Value != State.CurrentLayoutLevelId)
{
await State.InitializeAsync(LayoutLevelId.Value);
}
}
public void Dispose()
{
State.OnStateChanged -= StateHasChanged;
}
private async Task HandleCreate()
{
if (!State.CurrentLayoutLevelId.HasValue)
{
Snackbar.Add("No layout level selected", Severity.Warning);
return;
}
var parameters = new DialogParameters<CreateStationDialog>
{
{ x => x.LayoutLevelId, State.CurrentLayoutLevelId.Value }
};
var dialog = await DialogService.ShowAsync<CreateStationDialog>(
"Create Station",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await State.LoadStationsAsync();
Snackbar.Add("Station created successfully", Severity.Success);
}
}
private async Task HandleStationDeleted()
{
await State.LoadStationsAsync();
Snackbar.Add("Station deleted successfully", Severity.Success);
}
}

View File

@@ -0,0 +1,180 @@
@using RobotNet10.MapEditor.Components.VehicleTypeManager.Dialogs
@using RobotNet10.MapEditor.Services.State
@using RobotNet.VDA5050.Type
@implements IDisposable
@inject IDialogService DialogService
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 215px);">
@if (Action == null)
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="min-height: 200px;">
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Large" Style="font-size: 60px; opacity: 0.3;" />
<MudText Typo="Typo.body1" Style="opacity: 0.5;">
Select an action from the table to edit
</MudText>
</MudStack>
}
else
{
<MudStack Spacing="3">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h6">Edit Action</MudText>
</MudStack>
<MudDivider />
<MudTextField @bind-Value="Action.ActionType"
@bind-Value:after="EditState.NotifyChange"
Label="Action Type *"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Placeholder="e.g., pick, place, charge"
Required />
<MudTextField @bind-Value="Action.ActionDescription"
@bind-Value:after="EditState.NotifyChange"
Label="Description"
Margin="Margin.Dense"
Variant="Variant.Outlined"
Lines="2" />
<MudSelect T="RequirementType"
@bind-Value="Action.RequirementType"
@bind-Value:after="EditState.NotifyChange"
Label="Requirement Type *"
Margin="Margin.Dense"
Variant="Variant.Outlined">
<MudSelectItem Value="@RequirementType.REQUIRED">REQUIRED</MudSelectItem>
<MudSelectItem Value="@RequirementType.CONDITIONAL">CONDITIONAL</MudSelectItem>
<MudSelectItem Value="@RequirementType.OPTIONAL">OPTIONAL</MudSelectItem>
</MudSelect>
<MudSelect T="BlockingType"
@bind-Value="Action.BlockingType"
@bind-Value:after="EditState.NotifyChange"
Label="Blocking Type *"
Margin="Margin.Dense"
Variant="Variant.Outlined">
<MudSelectItem Value="@BlockingType.NONE">NONE</MudSelectItem>
<MudSelectItem Value="@BlockingType.SOFT">SOFT</MudSelectItem>
<MudSelectItem Value="@BlockingType.HARD">HARD</MudSelectItem>
</MudSelect>
<MudDivider />
<!-- Parameters Section -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Parameters</MudText>
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.Add"
Size="Size.Small"
OnClick="AddParameter">
Add Parameter
</MudButton>
</MudStack>
@if (Action.ActionParameters == null || Action.ActionParameters.Count == 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text">
No parameters defined. Click "Add Parameter" to add one.
</MudAlert>
}
else
{
<div style="overflow-y: auto">
@for (int i = 0; i < Action.ActionParameters.Count; i++)
{
var paramIndex = i;
var param = Action.ActionParameters[paramIndex];
<MudCard Elevation="1">
<MudCardContent Class="p-0">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudTextField @bind-Value="param.Key"
@bind-Value:after="EditState.NotifyChange"
Label="Key"
Margin="Margin.Dense"
Variant="Variant.Outlined"
Style="flex: 1;"
Required />
<MudTextField @bind-Value="param.Value"
@bind-Value:after="EditState.NotifyChange"
Label="Value"
Margin="Margin.Dense"
Variant="Variant.Outlined"
Style="flex: 1;"
Required />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => RemoveParameter(paramIndex))" />
</MudStack>
</MudCardContent>
</MudCard>
}
</div>
}
</MudStack>
}
</MudPaper>
@code {
[Parameter]
public ActionDto? Action { get; set; }
[Parameter]
public VehicleTypeEditState EditState { get; set; } = null!;
protected override void OnInitialized()
{
EditState.OnStateChanged += StateHasChanged;
}
private int GetActionIndex()
{
return EditState.Actions.IndexOf(Action ?? new());
}
private async Task AddParameter()
{
var parameters = new DialogParameters
{
["EditState"] = EditState,
["Action"] = Action
};
var dialog = await DialogService.ShowAsync<AddParameterDialog>("Add New Parameter", parameters);
var result = await dialog.Result;
}
private void RemoveParameter(int paramIndex)
{
if (Action != null)
{
var actionIndex = GetActionIndex();
if (actionIndex >= 0)
{
EditState.RemoveParameter(actionIndex, paramIndex);
}
}
}
private string? GetParameterValidationError(int paramIndex, string fieldName)
{
var actionIndex = GetActionIndex();
if (actionIndex < 0)
return null;
var key = $"Actions[{actionIndex}].Parameters[{paramIndex}].{fieldName}";
return EditState.ValidationErrors.TryGetValue(key, out var error) ? error : null;
}
public void Dispose()
{
EditState.OnStateChanged -= StateHasChanged;
}
}

View File

@@ -0,0 +1,74 @@
@using RobotNet10.MapEditor.Services.State
@inject IJSRuntime JSRuntime
@inject ISnackbar Snackbar
@implements IDisposable
<MudStack Spacing="2" Style="height: calc(100vh - 477px);">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Actions JSON (Read-Only)</MudText>
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ContentCopy"
Size="Size.Small"
OnClick="CopyToClipboard">
Copy
</MudButton>
</MudStack>
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey); max-height: 432px; overflow-y: auto;">
<pre style="margin: 0; font-family: 'Courier New', monospace; font-size: 0.875rem; white-space: pre-wrap; word-wrap: break-word; height: 100%">@jsonPreview</pre>
</MudPaper>
@if (EditState.Actions.Count > 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text">
<MudText Typo="Typo.caption">
Total Actions: <strong>@EditState.Actions.Count</strong>
</MudText>
</MudAlert>
}
</MudStack>
@code {
[Parameter]
public VehicleTypeEditState EditState { get; set; } = null!;
private string jsonPreview = "[]";
protected override void OnInitialized()
{
EditState.OnStateChanged += HandleStateChanged;
UpdatePreview();
}
private void HandleStateChanged()
{
UpdatePreview();
StateHasChanged();
}
private void UpdatePreview()
{
jsonPreview = EditState.GetActionsJsonPreview();
}
private async Task CopyToClipboard()
{
try
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", jsonPreview);
Snackbar.Add("Copied to clipboard", Severity.Success);
}
catch
{
Snackbar.Add("Failed to copy to clipboard", Severity.Error);
}
}
public void Dispose()
{
EditState.OnStateChanged -= HandleStateChanged;
}
}

View File

@@ -0,0 +1,187 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet.VDA5050.Type
@using RobotNet10.MapEditor.Components.VehicleTypeManager.Dialogs
@inject IDialogService DialogService
@implements IDisposable
<style>
.selected {
background-color: #3399ff !important;
}
.selected > td {
color: white !important;
}
.selected > td .mud-input {
color: white !important;
}
</style>
<MudPaper Elevation="2" Style="height: calc(100vh - 215px); overflow-y: auto;">
<MudTable Items="@EditState.Actions"
@ref="Table"
Hover="true"
Dense="true"
FixedHeader=true
RowClass="cursor-pointer"
Elevation="0"
T="ActionDto"
SelectedItem="@SelectedAction"
Height="calc(100vh - 267px)"
RowClassFunc="@SelectedRowClassFunc" OnRowClick="RowClickEvent"
SelectedItemChanged="@((ActionDto action) => HandleSelectionChanged(action))">
<HeaderContent>
<MudTh>#</MudTh>
<MudTh>Action Type</MudTh>
<MudTh>Requirement</MudTh>
<MudTh>
<MudIconButton Icon="@Icons.Material.Filled.Add"
Color="Color.Success"
Size="Size.Small"
OnClick="OpenAddActionDialog" />
</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@(EditState.Actions.IndexOf(context) + 1)</MudTd>
<MudTd DataLabel="Action Type">
<MudText Typo="Typo.body2">
@(string.IsNullOrWhiteSpace(context.ActionType) ? "-" : context.ActionType)
</MudText>
</MudTd>
<MudTd DataLabel="Requirement">
<MudChip T="string" Size="Size.Small" Color="@GetRequirementColor(context.RequirementType)">
@context.RequirementType.ToString()
</MudChip>
</MudTd>
<MudTd >
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => RemoveAction(context))" />
</MudTd>
</RowTemplate>
<PagerContent>
<div class="d-flex w-100 flex-row-reverse">
<MudTablePager HideRowsPerPage Style="width: 100%;" PageSizeOptions="new[] { 25, 100, 200 }" />
</div>
</PagerContent>
</MudTable>
</MudPaper>
@code {
[Parameter]
public ActionDto? SelectedAction { get; set; }
[Parameter]
public VehicleTypeEditState EditState { get; set; } = null!;
[Parameter]
public EventCallback<ActionDto> SelectedActionChanged { get; set; }
private int selectedRowNumber = -1;
private MudTable<ActionDto>? Table;
protected override void OnInitialized()
{
EditState.OnStateChanged += StateHasChanged;
}
private async Task OpenAddActionDialog()
{
var parameters = new DialogParameters
{
["EditState"] = EditState
};
var dialog = await DialogService.ShowAsync<AddActionDialog>("Add New Action", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is ActionDto newAction)
{
SelectAction(newAction);
}
}
private void SelectAction(ActionDto action)
{
SelectedAction = action;
SelectedActionChanged.InvokeAsync(action);
StateHasChanged();
}
private void RemoveAction(ActionDto action)
{
var index = EditState.Actions.IndexOf(action);
if (index >= 0)
{
EditState.RemoveAction(index);
// Clear selection if removed action was selected
if (SelectedAction == action)
{
SelectedAction = null;
SelectedActionChanged.InvokeAsync(null);
}
StateHasChanged();
}
}
private void HandleSelectionChanged(ActionDto? action)
{
SelectedAction = action;
_ = SelectedActionChanged.InvokeAsync(action);
}
private void RowClickEvent(TableRowClickEventArgs<ActionDto> tableRowClickEventArgs) { }
private string SelectedRowClassFunc(ActionDto element, int rowNumber)
{
if (selectedRowNumber == rowNumber && Table?.SelectedItem != null && !Table.SelectedItem.Equals(element))
{
return string.Empty;
}
else if (selectedRowNumber == rowNumber && Table?.SelectedItem != null && Table.SelectedItem.Equals(element))
{
return "selected";
}
else if (Table?.SelectedItem != null && Table.SelectedItem.Equals(element))
{
selectedRowNumber = rowNumber;
return "selected";
}
else
{
return string.Empty;
}
}
private Color GetRequirementColor(RequirementType requirementType)
{
return requirementType switch
{
RequirementType.REQUIRED => Color.Error,
RequirementType.CONDITIONAL => Color.Warning,
RequirementType.OPTIONAL => Color.Success,
_ => Color.Default
};
}
private Color GetBlockingColor(BlockingType blockingType)
{
return blockingType switch
{
BlockingType.HARD => Color.Error,
BlockingType.SOFT => Color.Warning,
BlockingType.NONE => Color.Success,
_ => Color.Default
};
}
public void Dispose()
{
EditState.OnStateChanged -= StateHasChanged;
}
}

View File

@@ -0,0 +1,86 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet.VDA5050.Type
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Class="mr-2" />
Add New Action
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="actionType"
Label="Action Type *"
Variant="Variant.Outlined"
Placeholder="e.g., pick, place, charge"
HelperText="Enter the action type name"
ErrorText="@GetValidationError()" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@string.IsNullOrWhiteSpace(actionType)">
Add
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter, EditorRequired]
public VehicleTypeEditState EditState { get; set; } = null!;
private string actionType = string.Empty;
private string? GetValidationError()
{
if (string.IsNullOrWhiteSpace(actionType))
{
return "Action Type is required";
}
return null;
}
private void Cancel()
{
MudDialog?.Cancel();
}
private void Submit()
{
if (string.IsNullOrWhiteSpace(actionType))
{
Snackbar.Add("Please enter an action type", Severity.Warning);
return;
}
if (EditState.Actions.Any(a => a.ActionType == actionType))
{
actionType = "";
Snackbar.Add("Action Type is exited", Severity.Warning);
return;
}
// Create new action with the provided action type
var newAction = new ActionDto
{
ActionType = actionType.Trim(),
ActionDescription = "",
RequirementType = RequirementType.OPTIONAL,
BlockingType = BlockingType.SOFT,
ActionParameters = new List<ActionParameterDto>()
};
EditState.Actions.Add(newAction);
EditState.NotifyChange();
MudDialog?.Close(DialogResult.Ok(newAction));
}
}

View File

@@ -0,0 +1,85 @@
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Class="mr-2" />
Add Parameter
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="key"
Label="Key *"
Variant="Variant.Outlined"
HelperText="Enter the key"
Required />
<MudTextField @bind-Value="value"
Label="Value *"
Variant="Variant.Outlined"
HelperText="Enter the value"
Required />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))">
Add
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter, EditorRequired]
public VehicleTypeEditState EditState { get; set; } = null!;
[Parameter]
public ActionDto? Action { get; set; }
private string key = string.Empty;
private string value = string.Empty;
private void Cancel()
{
MudDialog?.Cancel();
}
private int GetActionIndex()
{
return EditState.Actions.IndexOf(Action ?? new());
}
private void Submit()
{
if (string.IsNullOrWhiteSpace(key))
{
Snackbar.Add("Please enter an Key", Severity.Warning);
return;
}
if (string.IsNullOrWhiteSpace(value))
{
Snackbar.Add("Please enter an Value", Severity.Warning);
return;
}
// Create new action with the provided action type
if (Action != null)
{
var actionIndex = GetActionIndex();
if (actionIndex >= 0)
{
EditState.AddParameter(actionIndex, key, value);
EditState.NotifyChange();
MudDialog?.Close(DialogResult.Ok(""));
}
}
}
}

View File

@@ -0,0 +1,143 @@
@using RobotNet.VDA5050.Type
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Class="mr-2" />
Create Vehicle Type
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="vehicleTypeId"
Label="Vehicle Type ID *"
Variant="Variant.Outlined"
HelperText="Unique identifier (e.g., AMR-T800)"
ErrorText="@GetValidationError("VehicleTypeId")" />
<MudTextField @bind-Value="vehicleTypeName"
Label="Vehicle Type Name *"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("VehicleTypeName")" />
<MudTextField @bind-Value="description"
Label="Description"
Variant="Variant.Outlined"
Lines="3" />
<MudDivider />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isCreating">
@if (isCreating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Creating...</span>
}
else
{
<span>Create</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
private string vehicleTypeId = string.Empty;
private string vehicleTypeName = string.Empty;
private string? description;
private Dictionary<string, string> validationErrors = new();
private bool isCreating = false;
private bool Validate()
{
validationErrors.Clear();
if (string.IsNullOrWhiteSpace(vehicleTypeId))
{
validationErrors["VehicleTypeId"] = "Vehicle Type ID is required";
}
else if (vehicleTypeId.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";
}
return validationErrors.Count == 0;
}
private string? GetValidationError(string fieldName)
{
return validationErrors.TryGetValue(fieldName, out var error) ? error : null;
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!Validate())
{
Snackbar.Add("Please fix validation errors", Severity.Warning);
StateHasChanged();
return;
}
isCreating = true;
StateHasChanged();
try
{
var request = new CreateVehicleTypeRequest
{
VehicleTypeId = vehicleTypeId.Trim(),
VehicleTypeName = vehicleTypeName.Trim(),
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(),
Actions = "",
};
var created = await ApiService.CreateVehicleTypeAsync(request);
Snackbar.Add($"Vehicle type '{vehicleTypeName}' created successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(created));
}
catch (Exception ex)
{
Snackbar.Add($"Error creating vehicle type: {ex.Message}", Severity.Error);
}
finally
{
isCreating = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,116 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject VehicleTypeManagerState State
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Class="mr-2" />
Delete Vehicle Type
</MudText>
</TitleContent>
<DialogContent>
@if (VehicleType == null)
{
<MudAlert Severity="Severity.Error">Vehicle type not found</MudAlert>
}
else
{
<MudStack Spacing="3">
<MudText>
Are you sure you want to delete the vehicle type <strong>@VehicleType.VehicleTypeName</strong>?
</MudText>
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
This action cannot be undone
</MudAlert>
@if (UsageInfo != null)
{
<MudDivider />
<MudText Typo="Typo.subtitle2">Usage Information</MudText>
<MudGrid>
<MudItem xs="6">
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.caption" Color="Color.Secondary">Node Properties</MudText>
<MudText Typo="Typo.h6">@UsageInfo.NodePropertiesCount</MudText>
</MudPaper>
</MudItem>
<MudItem xs="6">
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.caption" Color="Color.Secondary">Edge Properties</MudText>
<MudText Typo="Typo.h6">@UsageInfo.EdgePropertiesCount</MudText>
</MudPaper>
</MudItem>
</MudGrid>
@if (!UsageInfo.CanDelete)
{
<MudAlert Severity="Severity.Error" Dense="true">
<strong>Cannot delete:</strong> This vehicle type is currently in use (@UsageInfo.TotalUsageCount references).
Please remove all references before deleting.
</MudAlert>
}
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Error"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(VehicleType == null || (UsageInfo != null && !UsageInfo.CanDelete) || isDeleting)">
@if (isDeleting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Deleting...</span>
}
else
{
<span>Delete</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter, EditorRequired] public VehicleTypeDto VehicleType { get; set; } = null!;
[Parameter] public VehicleTypeUsageInfoDto? UsageInfo { get; set; }
private bool isDeleting = false;
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (VehicleType == null || (UsageInfo != null && !UsageInfo.CanDelete))
return;
isDeleting = true;
StateHasChanged();
try
{
await State.DeleteVehicleTypeAsync(VehicleType.Id);
MudDialog?.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting vehicle type: {ex.Message}", Severity.Error);
}
finally
{
isDeleting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,140 @@
@using RobotNet.VDA5050
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject VehicleTypeManagerState State
@inject IJSRuntime JSRuntime
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.FileDownload" Color="Color.Info" Class="mr-2" />
Export Vehicle Types
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Export selected vehicle types to a JSON file.
</MudText>
<MudPaper Class="pa-3" Elevation="1">
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">Selected Items: @State.SelectedIds.Count</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
The following vehicle types will be exported:
</MudText>
</MudStack>
</MudPaper>
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey); max-height: 200px; overflow-y: auto;">
<MudList T="string" Dense="true">
@foreach (var vt in State.GetSelectedVehicleTypes())
{
<MudListItem T="string">
<MudText Typo="Typo.body2">
<strong>@vt.VehicleTypeId</strong> - @vt.VehicleTypeName
</MudText>
</MudListItem>
}
</MudList>
</MudPaper>
<MudTextField @bind-Value="fileName"
Label="File Name"
Variant="Variant.Outlined"
Adornment="Adornment.End"
AdornmentText=".json" />
<MudExpansionPanels>
<MudExpansionPanel Text="Preview JSON">
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey); max-height: 300px; overflow-y: auto;">
<pre style="margin: 0; font-family: monospace; font-size: 0.875rem;">@GetPreviewJson()</pre>
</MudPaper>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Info"
Variant="Variant.Filled"
StartIcon="@Icons.Material.Filled.Download"
OnClick="Export"
Disabled="@isExporting">
@if (isExporting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Exporting...</span>
}
else
{
<span>Export</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
private string fileName = $"vehicle-types-{DateTime.Now:yyyyMMdd-HHmmss}";
private bool isExporting = false;
private string GetPreviewJson()
{
try
{
var selected = State.GetSelectedVehicleTypes();
return System.Text.Json.JsonSerializer.Serialize(selected, JsonOptionExtends.Read);
}
catch
{
return "Error generating preview";
}
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Export()
{
isExporting = true;
StateHasChanged();
try
{
var selected = State.GetSelectedVehicleTypes();
var json = System.Text.Json.JsonSerializer.Serialize(selected, JsonOptionExtends.Read);
var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json));
var fullFileName = fileName.EndsWith(".json") ? fileName : $"{fileName}.json";
await JSRuntime.InvokeVoidAsync("eval",
$@"const blob = new Blob([atob('{base64}')], {{ type: 'application/json' }});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = '{fullFileName}';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);");
Snackbar.Add($"Exported {selected.Count} vehicle type(s) to {fullFileName}", Severity.Success);
MudDialog?.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error exporting: {ex.Message}", Severity.Error);
}
finally
{
isExporting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,243 @@
@using RobotNet.VDA5050
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.FileUpload" Color="Color.Success" Class="mr-2" />
Import Vehicle Types
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Import vehicle types from a JSON file. The file should contain an array of vehicle type objects.
</MudText>
<MudFileUpload T="IBrowserFile"
@bind-Files="selectedFile"
@bind-Files:after="OnFileSelected"
Accept=".json"
MaximumFileCount="1">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Choose JSON File
</MudButton>
</CustomContent>
</MudFileUpload>
@if (selectedFile != null)
{
<MudPaper Class="pa-3" Elevation="1">
<MudStack Spacing="1">
<MudText Typo="Typo.body2">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" Class="mr-1" />
Selected: <strong>@selectedFile.Name</strong>
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
Size: @((selectedFile.Size / 1024.0).ToString("F2")) KB
</MudText>
</MudStack>
</MudPaper>
@if (!string.IsNullOrWhiteSpace(previewJson))
{
<MudExpansionPanels>
<MudExpansionPanel Text="@($"Preview ({previewCount} items)")">
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey); max-height: 300px; overflow-y: auto;">
<pre style="margin: 0; font-family: monospace; font-size: 0.875rem;">@previewJson</pre>
</MudPaper>
</MudExpansionPanel>
</MudExpansionPanels>
}
}
@if (validationErrors.Count > 0)
{
<MudAlert Severity="Severity.Error">
<MudText Typo="Typo.subtitle2">Validation Errors:</MudText>
<MudList T="string" Dense="true">
@foreach (var error in validationErrors)
{
<MudListItem T="string" Text="@error" />
}
</MudList>
</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(selectedFile == null || validationErrors.Count > 0 || isImporting)">
@if (isImporting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Importing...</span>
}
else
{
<span>Import</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
private IBrowserFile? selectedFile;
private string previewJson = string.Empty;
private int previewCount = 0;
private List<string> validationErrors = new();
private bool isImporting = false;
private List<VehicleTypeDto>? parsedVehicleTypes;
private async Task OnFileSelected()
{
validationErrors.Clear();
previewJson = string.Empty;
previewCount = 0;
parsedVehicleTypes = null;
if (selectedFile == null)
{
StateHasChanged();
return;
}
try
{
using var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync();
try
{
parsedVehicleTypes = System.Text.Json.JsonSerializer.Deserialize<List<VehicleTypeDto>>(content, JsonOptionExtends.Read);
if (parsedVehicleTypes == null || parsedVehicleTypes.Count == 0)
{
validationErrors.Add("File contains no vehicle types");
}
else
{
previewCount = parsedVehicleTypes.Count;
ValidateVehicleTypes(parsedVehicleTypes);
// Generate preview
var jsonDoc = System.Text.Json.JsonDocument.Parse(content);
previewJson = System.Text.Json.JsonSerializer.Serialize(jsonDoc, JsonOptionExtends.Read);
}
}
catch (System.Text.Json.JsonException)
{
validationErrors.Add("Invalid JSON format");
}
StateHasChanged();
}
catch (Exception ex)
{
validationErrors.Add($"Error reading file: {ex.Message}");
StateHasChanged();
}
}
private void ValidateVehicleTypes(List<VehicleTypeDto> vehicleTypes)
{
for (int i = 0; i < vehicleTypes.Count; i++)
{
var vt = vehicleTypes[i];
if (string.IsNullOrWhiteSpace(vt.VehicleTypeId))
validationErrors.Add($"Item {i + 1}: VehicleTypeId is required");
if (string.IsNullOrWhiteSpace(vt.VehicleTypeName))
validationErrors.Add($"Item {i + 1}: VehicleTypeName is required");
}
// Check for duplicate IDs
var duplicates = vehicleTypes
.GroupBy(vt => vt.VehicleTypeId)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
foreach (var dup in duplicates)
{
validationErrors.Add($"Duplicate VehicleTypeId: {dup}");
}
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (selectedFile == null || parsedVehicleTypes == null)
return;
isImporting = true;
StateHasChanged();
try
{
var importResults = new List<string>();
var successCount = 0;
foreach (var vt in parsedVehicleTypes)
{
try
{
var request = new CreateVehicleTypeRequest
{
VehicleTypeId = vt.VehicleTypeId,
VehicleTypeName = vt.VehicleTypeName,
Description = vt.Description,
Actions = vt.Actions
};
await ApiService.CreateVehicleTypeAsync(request);
successCount++;
}
catch (Exception ex)
{
importResults.Add($"Failed to import '{vt.VehicleTypeId}': {ex.Message}");
}
}
if (importResults.Count > 0)
{
Snackbar.Add($"Imported {successCount} of {parsedVehicleTypes.Count} vehicle types. {importResults.Count} failed.", Severity.Warning);
}
else
{
Snackbar.Add($"Successfully imported {successCount} vehicle types", Severity.Success);
}
MudDialog?.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error importing: {ex.Message}", Severity.Error);
}
finally
{
isImporting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,203 @@
@using RobotNet.VDA5050
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Components.VehicleTypeManager.Dialogs
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@implements IDisposable
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 200px); overflow-y: auto;">
@if (State.SelectedVehicleType == null)
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="min-height: 400px;">
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Large" Style="font-size: 60px; opacity: 0.3;" />
<MudText Typo="Typo.h6" Style="opacity: 0.5;">Select a vehicle type to view details</MudText>
</MudStack>
}
else
{
<MudStack Spacing="3">
<!-- Header with Actions -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h6">Details</MudText>
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
OnClick="HandleEdit" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="HandleDelete" />
</MudStack>
</MudStack>
<MudDivider />
<MudStack Spacing="2">
<div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Status</MudText>
<MudChip T="string"
Size="Size.Small"
Color="@(State.SelectedVehicleType.IsActive ? Color.Success : Color.Default)">
@(State.SelectedVehicleType.IsActive ? "Active" : "Inactive")
</MudChip>
</div>
@if (!string.IsNullOrWhiteSpace(State.SelectedVehicleType.Description))
{
<div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Description</MudText>
<MudText Typo="Typo.body2">@State.SelectedVehicleType.Description</MudText>
</div>
}
<div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Created Date</MudText>
<MudText Typo="Typo.body2">@State.SelectedVehicleType.CreatedDate.ToString("yyyy-MM-dd HH:mm")</MudText>
</div>
</MudStack>
<MudDivider />
<!-- Usage Statistics -->
@if (State.SelectedUsageInfo != null)
{
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Usage Statistics</MudText>
<MudGrid>
<MudItem xs="6">
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.caption" Color="Color.Secondary">Node Properties</MudText>
<MudText Typo="Typo.h5">@State.SelectedUsageInfo.NodePropertiesCount</MudText>
</MudPaper>
</MudItem>
<MudItem xs="6">
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.caption" Color="Color.Secondary">Edge Properties</MudText>
<MudText Typo="Typo.h5">@State.SelectedUsageInfo.EdgePropertiesCount</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-info-lighten);">
<MudText Typo="Typo.caption">Total Usage</MudText>
<MudText Typo="Typo.h4">@State.SelectedUsageInfo.TotalUsageCount</MudText>
</MudPaper>
@if (!State.SelectedUsageInfo.CanDelete)
{
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
This vehicle type is currently in use and cannot be deleted
</MudAlert>
}
}
<MudDivider />
<!-- Actions Preview -->
@if (!string.IsNullOrWhiteSpace(State.SelectedVehicleType.Actions))
{
<MudExpansionPanels>
<MudExpansionPanel Text="@($"Actions ({GetActionsCount(State.SelectedVehicleType.Actions)})")">
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey); max-height: 259px; overflow-y: auto;">
<pre style="margin: 0; font-family: monospace; font-size: 0.875rem;">@FormatJson(State.SelectedVehicleType.Actions)</pre>
</MudPaper>
</MudExpansionPanel>
</MudExpansionPanels>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text">
No actions defined
</MudAlert>
}
</MudStack>
}
</MudPaper>
@code {
[Parameter]
public VehicleTypeManagerState State { get; set; } = null!;
[Parameter] public EventCallback OnDeletedVehicleType { get; set; }
protected override void OnInitialized()
{
State.OnStateChanged += StateHasChanged;
}
private void HandleEdit()
{
if (State.SelectedVehicleType != null)
{
Navigation.NavigateTo($"/vehicletypes/edit/{State.SelectedVehicleType.Id}");
}
}
private async Task HandleDelete()
{
if (State.SelectedVehicleType == null)
return;
var parameters = new DialogParameters
{
["VehicleType"] = State.SelectedVehicleType,
["UsageInfo"] = State.SelectedUsageInfo
};
var dialog = await DialogService.ShowAsync<DeleteVehicleTypeDialog>("Delete Vehicle Type", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add($"Vehicle type '{State.SelectedVehicleType.VehicleTypeName}' deleted successfully", Severity.Success);
await State.LoadVehicleTypesAsync();
State.ClearSelection();
await OnDeletedVehicleType.InvokeAsync();
}
}
private string FormatJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return "";
try
{
var doc = System.Text.Json.JsonDocument.Parse(json);
return System.Text.Json.JsonSerializer.Serialize(doc, JsonOptionExtends.Read);
}
catch
{
return json;
}
}
private int GetActionsCount(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return 0;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
return doc.RootElement.GetArrayLength();
}
}
catch { }
return 0;
}
public void Dispose()
{
State.OnStateChanged -= StateHasChanged;
}
}

View File

@@ -0,0 +1,197 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Components.VehicleTypeManager
@inject VehicleTypeEditState EditState
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@implements IDisposable
<PageTitle>Vehicle Type</PageTitle>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<!-- Header -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack"
OnClick="HandleBack" />
<MudText Typo="Typo.h5">
Edit Vehicle Type
</MudText>
@if (EditState.HasUnsavedChanges)
{
<MudChip T="string" Color="Color.Warning" Size="Size.Small">Unsaved Changes</MudChip>
}
</MudStack>
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Outlined"
OnClick="HandleCancel">
Cancel
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@EditState.IsSaving">
@if (EditState.IsSaving)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>Saving...</span>
}
else
{
<span>Save</span>
}
</MudButton>
</MudStack>
</MudStack>
@if (EditState.IsLoading)
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="min-height: 400px;">
<MudProgressCircular Indeterminate="true" />
<MudText>Loading...</MudText>
</MudStack>
}
else
{
<!-- Main Content -->
<MudGrid>
<!-- Left: Form -->
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 135px); overflow-y: auto;">
<MudStack Spacing="3">
<!-- Actions Section -->
<MudText Typo="Typo.h6" Color="Color.Primary">Actions</MudText>
<MudGrid>
<!-- Left: Actions Table -->
<MudItem xs="12" md="6">
<ActionsTable EditState="EditState" SelectedAction="@selectedAction"
SelectedActionChanged="@((ActionDto? action) => selectedAction = action)" />
</MudItem>
<!-- Right: Action Edit Form -->
<MudItem xs="12" md="6">
<ActionEditForm EditState="EditState" Action="@selectedAction" />
</MudItem>
</MudGrid>
</MudStack>
</MudPaper>
</MudItem>
<!-- Right: Preview -->
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-4" Style="position: sticky; top: 20px; height: calc(100vh - 135px); ">
<MudStack Spacing="3">
<MudText Typo="Typo.h6" Color="Color.Primary">Vehicle Type Information</MudText>
<!-- Editable Fields -->
<MudStack Spacing="2">
<MudTextField @bind-Value="EditState.VehicleTypeIdString"
@bind-Value:after="EditState.NotifyChange"
Label="Vehicle Type Id *"
Variant="Variant.Outlined"
ReadOnly="true"
Margin="Margin.Dense" />
<MudTextField @bind-Value="EditState.VehicleTypeName"
@bind-Value:after="EditState.NotifyChange"
Label="Vehicle Type Name *"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Validation="@(new Func<string, IEnumerable<string>>(GetValidationVehicleTypeNameError))"/>
<MudTextField @bind-Value="EditState.Description"
@bind-Value:after="EditState.NotifyChange"
Label="Description"
Variant="Variant.Outlined"
Lines="2"
Margin="Margin.Dense"
Validation="@(new Func<string, IEnumerable<string>>(GetValidationDescriptionError))" />
</MudStack>
<MudDivider />
<!-- Actions JSON Preview -->
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Actions JSON Preview</MudText>
<ActionsJsonPreview EditState="EditState" />
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
}
</MudContainer>
@code {
[Parameter]
public Guid Id { get; set; }
private ActionDto? selectedAction;
protected override async Task OnInitializedAsync()
{
EditState.OnStateChanged += StateHasChanged;
await EditState.LoadVehicleTypeAsync(Id);
}
private async Task HandleBack()
{
if (EditState.HasUnsavedChanges)
{
var result = await DialogService.ShowMessageBoxAsync(
"Unsaved Changes",
"You have unsaved changes. Are you sure you want to leave?",
yesText: "Leave", cancelText: "Stay");
if (result != true)
return;
}
Navigation.NavigateTo("/vehicle-manager");
}
private async Task HandleSave()
{
var success = await EditState.SaveAsync();
if (success)
{
Snackbar.Add("Vehicle type updated successfully", Severity.Success);
}
else if (!string.IsNullOrEmpty(EditState.ErrorMessage))
{
Snackbar.Add(EditState.ErrorMessage, Severity.Error);
}
}
private async Task HandleCancel()
{
}
private IEnumerable<string> GetValidationVehicleTypeNameError(string data)
{
if (string.IsNullOrWhiteSpace(data))
{
yield return "Vehicle Type Name is required";
}
if (data.Length > 256)
{
yield return "Vehicle Type Name must be 256 characters or less";
}
}
private IEnumerable<string> GetValidationDescriptionError(string data)
{
if (!string.IsNullOrWhiteSpace(data) && data.Length > 10000)
{
yield return "Description must be 10000 characters or less";
}
}
public void Dispose()
{
EditState.OnStateChanged -= StateHasChanged;
}
}

View File

@@ -0,0 +1,178 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Components.VehicleTypeManager.Dialogs
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IJSRuntime JSRuntime
@implements IDisposable
<style>
.selected {
background-color: #3399ff !important;
}
.selected > td {
color: white !important;
}
.selected > td .mud-input {
color: white !important;
}
</style>
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 200px); overflow-y: hidden;">
<!-- Loading -->
@if (State.IsLoading)
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="min-height: 200px;">
<MudProgressCircular Indeterminate="true" />
<MudText>Loading...</MudText>
</MudStack>
}
else
{
<!-- Table -->
<MudTable Items="@VehiclesShow"
@ref="@Table"
T="VehicleTypeDto"
Hover="true"
Dense="true"
FixedHeader="true"
MultiSelection="true"
SelectOnRowClick=true
SelectedItemsChanged="HandleSelectedItemsChanged"
SelectedItemChanged="HandleSelectedItemChanged"
RowClassFunc="@SelectedRowClassFunc"
OnRowClick="RowClickEvent"
ServerData="ReloadData"
RowClass="cursor-pointer"
Elevation="0"
Height="calc(100vh - 265px)">
<HeaderContent>
<MudTh>Vehicle Type ID</MudTh>
<MudTh>Vehicle Type Name</MudTh>
<MudTh>Created Date</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Vehicle Type ID">
@context.VehicleTypeId
</MudTd>
<MudTd DataLabel="Name">
@context.VehicleTypeName
</MudTd>
<MudTd DataLabel="Created">
@context.CreatedDate.ToString("yyyy-MM-dd")
</MudTd>
</RowTemplate>
<PagerContent>
<div class="d-flex w-100 flex-row-reverse">
<MudTablePager Style="width: 100%;" PageSizeOptions="new[] { 25, 100, 200 }" />
</div>
</PagerContent>
</MudTable>
}
</MudPaper>
@code {
[Parameter]
public VehicleTypeManagerState State { get; set; } = null!;
private int selectedRowNumber = -1;
private List<VehicleTypeDto> Vehicles = [];
private List<VehicleTypeDto> VehiclesShow = [];
private MudTable<VehicleTypeDto>? Table;
private string txtSearch = string.Empty;
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += StateHasChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
await LoadVehicleTypes();
}
public void Dispose()
{
State.OnStateChanged -= StateHasChanged;
}
public void TextSearchChanged(string text)
{
txtSearch = text;
Table?.ReloadServerData();
}
public async Task LoadVehicleTypes()
{
await State.LoadVehicleTypesAsync();
Vehicles = State.GetPagedVehicleTypes();
Table?.ReloadServerData();
}
private bool FilterFunc(VehicleTypeDto vehicle)
{
if (string.IsNullOrWhiteSpace(txtSearch))
return true;
if (vehicle.VehicleTypeName is not null && vehicle.VehicleTypeName.Contains(txtSearch, StringComparison.OrdinalIgnoreCase))
return true;
if ($"{vehicle.VehicleTypeName}".Contains(txtSearch, StringComparison.OrdinalIgnoreCase))
return true;
if ($"{vehicle.VehicleTypeId}".Contains(txtSearch, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private Task<TableData<VehicleTypeDto>> ReloadData(TableState state, CancellationToken _)
{
VehiclesShow.Clear();
var vehicles = new List<VehicleTypeDto>();
Vehicles.ForEach(vehicle =>
{
if (FilterFunc(vehicle)) vehicles.Add(vehicle);
});
VehiclesShow = vehicles.Skip(state.Page * state.PageSize).Take(state.PageSize).ToList();
return Task.FromResult(new TableData<VehicleTypeDto>() { TotalItems = vehicles.Count, Items = VehiclesShow });
}
private void HandleSelectedItemsChanged(HashSet<VehicleTypeDto> vehiclesType)
{
_ = State.SelectVehiclesTypeAsync([.. vehiclesType.Select(vt => vt.Id)]);
}
private void HandleSelectedItemChanged(VehicleTypeDto vehiclesType)
{
_ = State.SelectVehicleTypeAsync(vehiclesType.Id);
}
private void RowClickEvent(TableRowClickEventArgs<VehicleTypeDto> tableRowClickEventArgs) { }
private string SelectedRowClassFunc(VehicleTypeDto element, int rowNumber)
{
if (selectedRowNumber == rowNumber && Table?.SelectedItem != null && !Table.SelectedItem.Equals(element))
{
return string.Empty;
}
else if (selectedRowNumber == rowNumber && Table?.SelectedItem != null && Table.SelectedItem.Equals(element))
{
return "selected";
}
else if (Table?.SelectedItem != null && Table.SelectedItem.Equals(element))
{
selectedRowNumber = rowNumber;
return "selected";
}
else
{
return string.Empty;
}
}
}

View File

@@ -0,0 +1,122 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Components.VehicleTypeManager
@using RobotNet10.MapEditor.Components.VehicleTypeManager.Dialogs
@inject VehicleTypeManagerState State
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IJSRuntime JSRuntime
@implements IDisposable
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<!-- Header -->
<MudPaper Class="pa-4 mb-4 align-content-center" MinHeight="80px">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="3">
<MudText Typo="Typo.h5">Vehicle Type Management</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudTextField Value="searchText"
Placeholder="Search layouts..."
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Margin="Margin.Dense"
Style="min-width: 250px;"
Immediate="false"
T="string"
ValueChanged="TextSearchChanged"
Clearable="true" />
<MudButton Variant="Variant.Outlined"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.FileUpload"
OnClick="HandleImport">
Import
</MudButton>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="HandleExport"
Disabled="@(State.SelectedIds.Count == 0)">
Export (@State.SelectedIds.Count)
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Add"
Variant="Variant.Filled"
Color="Color.Success"
OnClick="HandleCreate">
Add Vehicle
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
<!-- Main Content -->
<MudGrid>
<!-- Left: List Panel -->
<MudItem xs="12" md="7">
<VehicleTypeListPanel @ref="VehicleTypeListPanelRef" State="@State" />
</MudItem>
<!-- Right: Details Panel -->
<MudItem xs="12" md="5">
<VehicleTypeDetailsPanel State="@State" OnDeletedVehicleType="VehicleTypeListPanelRef.LoadVehicleTypes" />
</MudItem>
</MudGrid>
</MudContainer>
@code {
private string searchText = string.Empty;
private VehicleTypeListPanel VehicleTypeListPanelRef = default!;
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += StateHasChanged;
}
public void Dispose()
{
State.OnStateChanged -= StateHasChanged;
}
private async Task TextSearchChanged(string text)
{
searchText = text;
VehicleTypeListPanelRef.TextSearchChanged(text);
}
private async Task HandleCreate()
{
var dialog = await DialogService.ShowAsync<CreateVehicleTypeDialog>("Create Vehicle Type",
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await State.LoadVehicleTypesAsync();
await VehicleTypeListPanelRef.LoadVehicleTypes();
}
}
private async Task HandleExport()
{
var dialog = await DialogService.ShowAsync<ExportVehicleTypesDialog>("Export Vehicle Types");
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add($"Exported {State.SelectedIds.Count} vehicle type(s)", Severity.Success);
}
}
private async Task HandleImport()
{
var dialog = await DialogService.ShowAsync<ImportVehicleTypesDialog>("Import Vehicle Types");
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await State.LoadVehicleTypesAsync();
await VehicleTypeListPanelRef.LoadVehicleTypes();
Snackbar.Add("Vehicle types imported successfully", Severity.Success);
}
}
}

View File

@@ -0,0 +1,32 @@
using MudBlazor;
namespace RobotNet10.MapEditor.Models;
/// <summary>
/// Model for tree item in LayoutTreePanel
/// </summary>
public class TreeItemModel
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Text { get; set; } = "";
public string Icon { get; set; } = "";
public TreeItemType Type { get; set; }
public object? Data { get; set; }
public bool IsExpanded { get; set; } = false;
public List<TreeItemModel> Children { get; set; } = new();
// UI Properties
public string? BadgeText { get; set; }
public Color BadgeColor { get; set; } = Color.Default;
}
/// <summary>
/// Type of tree item
/// </summary>
public enum TreeItemType
{
Layout,
Version,
Level
}

View File

@@ -0,0 +1,253 @@
# RobotNet10.MapEditor
Map Editor UI component library for RobotNet10 system.
## 📁 Project Structure
```
RobotNet10.MapEditor/
├── Pages/
│ └── LayoutManager.razor ⭐ Main page
├── Components/
│ ├── LayoutManager/
│ │ ├── LayoutTreePanel.razor ⭐ Tree hierarchy (Left panel)
│ │ ├── LayoutPreviewPanel.razor ⭐ Preview + Actions (Right panel)
│ │ └── Dialogs/
│ │ ├── CreateLayoutDialog.razor
│ │ ├── CreateVersionDialog.razor
│ │ ├── CreateLevelDialog.razor
│ │ ├── ImportLayoutDialog.razor
│ │ └── ExportLayoutDialog.razor
│ │
│ └── Shared/
│ └── SvgPreviewCanvas.razor ⭐ SVG mini preview
├── Services/
│ ├── API/
│ │ └── MapManagerApiService.cs ⭐ HTTP client wrapper
│ │
│ └── State/
│ └── LayoutManagerState.cs ⭐ State management
├── Models/
│ └── TreeItemModel.cs ⭐ Tree node model
├── _Imports.razor ⭐ Global imports
└── RobotNet10.MapEditor.csproj
```
## 🎯 Features
### LayoutManager Page
**Left Panel - Layout Tree:**
- ✅ Hierarchical view: Layout → Version → Level
- ✅ Context menu for each item
- ✅ Create/Delete operations
- ✅ Activate/Deactivate layouts
- ✅ Search functionality
**Right Panel - Preview:**
- ✅ SVG-based mini preview
- ✅ Shows background image, nodes, edges, stations
- ✅ Layout information display
- ✅ Editor settings (expandable)
- ✅ Edit and Export buttons
**Dialogs:**
- ✅ Create Layout (LayoutId, LayoutName, Description)
- ✅ Create Version (Version number, Description)
- ✅ Create Level (LevelId, Order, Editor settings)
- ✅ Import LIF (Upload JSON - placeholder)
- ✅ Export LIF (Download JSON - placeholder)
## 🔧 Dependencies
**NuGet Packages:**
- `Microsoft.AspNetCore.Components.Web` (10.0.0)
- `Microsoft.Extensions.Http` (10.0.0)
**Project References:**
- `RobotNet10.Components` (for MudBlazor)
- `RobotNet10.MapEditor.Shared` (for DTOs)
## 🚀 Usage
### 1. Register Services
In your Blazor application's `Program.cs`:
```csharp
using RobotNet10.MapEditor.Services.API;
using RobotNet10.MapEditor.Services.State;
var builder = WebApplication.CreateBuilder(args);
// Add HttpClient for MapManager API
builder.Services.AddHttpClient<MapManagerApiService>();
// Add State Management
builder.Services.AddScoped<LayoutManagerState>();
// Add MudBlazor (if not already added)
builder.Services.AddMudServices();
var app = builder.Build();
```
### 2. Configure API Base URL
In `appsettings.json`:
```json
{
"MapManagerApi": {
"BaseUrl": "https://localhost:5001"
}
}
```
### 3. Add Route to Navigation
Navigate to the page:
```csharp
Navigation.NavigateTo("/layout-manager");
```
Or add to navigation menu:
```razor
<MudNavLink Href="/layout-manager" Icon="@Icons.Material.Filled.Map">
Layout Manager
</MudNavLink>
```
## 📊 API Integration
The `MapManagerApiService` provides methods for:
**Layouts:**
- `SearchLayoutsAsync(search?)` - Get all layouts (with nested Versions & Levels)
- `CreateLayoutAsync(request)` - Create new layout
- `DeleteLayoutAsync(layoutId)` - Delete layout
- `ActivateLayoutAsync(layoutId)` - Activate layout
- `DeactivateLayoutAsync(layoutId)` - Deactivate layout
**Versions:**
- `CreateVersionAsync(layoutId, request)` - Create new version
- `GetVersionsAsync(layoutId)` - Get all versions
- `DeleteVersionAsync(versionId)` - Delete version
**Levels:**
- `CreateLevelAsync(versionId, request)` - Create new level
- `GetLevelsAsync(versionId)` - Get all levels
- `DeleteLevelAsync(levelId)` - Delete level
**Data & Images:**
- `GetLayoutDataAsync(levelId)` - Get nodes, edges, stations
- `GetLayoutImageAsync(levelId)` - Get background image
- `UploadLayoutImageAsync(levelId, stream, fileName)` - Upload image
## 🎨 State Management
The `LayoutManagerState` manages:
- **Data:** List of layouts with nested structure
- **Selection:** Currently selected layout, version, level
- **Preview:** Layout data and background image
- **UI State:** Loading states, search text
**Events:**
- `OnStateChanged` - Fired when state changes (for UI updates)
**Key Methods:**
- `LoadLayoutsAsync(search?)` - Load/reload all layouts
- `SelectLevelAsync(level)` - Select level and load preview
- `CreateLayoutAsync(request)` - Create and reload
- `DeleteLayoutAsync(layoutId)` - Delete and clear selection if needed
## 🔍 Component Details
### LayoutTreePanel
**Features:**
- MudTreeView with 3 levels (Layout → Version → Level)
- Context menu for each item
- Color-coded badges (Active status)
- Auto-expand when item is selected
**Context Menu Actions:**
- **Layout:** Add Version, Edit, Activate/Deactivate, Delete
- **Version:** Add Level, Delete
- **Level:** Edit, Upload Image, Delete
### LayoutPreviewPanel
**Features:**
- SVG preview canvas (auto-scaled to content)
- Layout information grid
- Expandable editor settings panel
- Action buttons (Edit, Export, Refresh)
### SvgPreviewCanvas
**Features:**
- Responsive SVG with auto-calculated viewBox
- Background image overlay (if available)
- Edges rendered as lines
- Nodes rendered as circles
- Stations rendered as rectangles
## ⚠️ Known Limitations
1. **Import/Export:** Backend endpoints not yet implemented
- Placeholders in `ImportLayoutDialog` and `ExportLayoutDialog`
- Will need to implement when backend is ready
2. **Upload Image:** Not yet implemented in tree context menu
- Need to create image upload dialog
3. **Edit Dialogs:** Update dialogs not yet created
- Edit Layout: TODO
- Edit Level: TODO
## 🔄 Next Steps
1. Implement Import/Export backend endpoints
2. Create image upload dialog component
3. Create update/edit dialogs
4. Add validation and error handling
5. Implement LayoutEditor page (SVG canvas editor)
## 📝 Notes
- All coordinates are in **meters** (VDMA LIF standard)
- Tree automatically expands to show selected item
- Preview auto-refreshes when level is selected
- State is scoped service (per user session)
## 🐛 Troubleshooting
**No layouts showing:**
- Check API base URL in `appsettings.json`
- Verify MapManager backend is running
- Check browser console for API errors
**Preview not loading:**
- Ensure level has data (nodes, edges)
- Check if background image exists
- Verify LayoutDataController is working
**Dialogs not working:**
- Ensure MudBlazor is properly configured
- Check DialogService is registered
- Verify Snackbar service is available
---
**Last Updated:** 2024-12-01
**Version:** 1.0
**Status:** ✅ MVP Complete (LayoutManager Page)

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\RobotNet.VDA5050\RobotNet.VDA5050.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet10.MapEditor.Shared\RobotNet10.MapEditor.Shared.csproj" />
<ProjectReference Include="..\RobotNet10.Components\RobotNet10.Components.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,721 @@
using RobotNet.VDA5050;
using RobotNet10.MapEditor.Shared.DTOs.Edge;
using RobotNet10.MapEditor.Shared.DTOs.Layout;
using RobotNet10.MapEditor.Shared.DTOs.LayoutData;
using RobotNet10.MapEditor.Shared.DTOs.Node;
using RobotNet10.MapEditor.Shared.DTOs.Requests;
using RobotNet10.MapEditor.Shared.DTOs.Responses;
using RobotNet10.MapEditor.Shared.DTOs.Station;
using RobotNet10.MapEditor.Shared.DTOs.VehicleType;
using System.Globalization;
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace RobotNet10.MapEditor.Services.API;
/// <summary>
/// Service for communicating with MapManager REST API
/// </summary>
public class MapManagerApiService(HttpClient httpClient)
{
private readonly HttpClient _httpClient = httpClient;
private readonly string? _baseUrl = httpClient.BaseAddress?.AbsoluteUri;
// ==========================================
// LAYOUTS
// ==========================================
/// <summary>
/// Search layouts (returns nested Versions and Levels)
/// </summary>
public async Task<List<LayoutDto>> SearchLayoutsAsync(string? search = null)
{
var url = $"{_baseUrl}api/layouts";
if (!string.IsNullOrEmpty(search))
url += $"?search={Uri.EscapeDataString(search)}";
try
{
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
var errorMessage = await GetDetailedErrorMessageAsync(response, url);
throw new HttpRequestException(errorMessage);
}
return await response.Content.ReadFromJsonAsync<List<LayoutDto>>() ?? new();
}
catch (HttpRequestException)
{
throw; // Re-throw with detailed message
}
catch (Exception ex)
{
throw new HttpRequestException($"Failed to load layouts from {url}: {ex.Message}", ex);
}
}
/// <summary>
/// Get detailed error message from HTTP response
/// </summary>
private async Task<string> GetDetailedErrorMessageAsync(HttpResponseMessage response, string url)
{
var statusCode = response.StatusCode;
var statusText = response.ReasonPhrase ?? statusCode.ToString();
// Try to read error message from response body
string? errorDetail = null;
try
{
var content = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrWhiteSpace(content) && content.Length < 500)
{
// Try to parse as JSON
try
{
using var doc = System.Text.Json.JsonDocument.Parse(content);
var root = doc.RootElement;
if (root.TryGetProperty("error", out var errorProp))
errorDetail = errorProp.GetString();
else if (root.TryGetProperty("message", out var messageProp))
errorDetail = messageProp.GetString();
else if (root.ValueKind == System.Text.Json.JsonValueKind.String)
errorDetail = root.GetString();
}
catch
{
// If not JSON, use content as-is if reasonable length
errorDetail = content;
}
}
}
catch
{
// Ignore errors reading response body
}
// Build detailed error message
var message = $"HTTP {(int)statusCode} {statusText}";
if (!string.IsNullOrWhiteSpace(errorDetail))
{
message += $": {errorDetail}";
}
message += $" (URL: {url})";
return message;
}
/// <summary>
/// Get layout by ID
/// </summary>
public async Task<LayoutDto?> GetLayoutAsync(Guid layoutId)
{
return await _httpClient.GetFromJsonAsync<LayoutDto>(
$"{_baseUrl}api/layouts/{layoutId}");
}
/// <summary>
/// Create new layout
/// </summary>
public async Task<LayoutDto> CreateLayoutAsync(CreateLayoutRequest request)
{
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}api/layouts", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LayoutDto>()
?? throw new Exception("Failed to create layout");
}
/// <summary>
/// Update layout
/// </summary>
public async Task<LayoutDto> UpdateLayoutAsync(Guid layoutId, UpdateLayoutRequest request)
{
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}api/layouts/{layoutId}", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LayoutDto>()
?? throw new Exception("Failed to update layout");
}
/// <summary>
/// Delete layout
/// </summary>
public async Task DeleteLayoutAsync(Guid layoutId)
{
var response = await _httpClient.DeleteAsync($"{_baseUrl}api/layouts/{layoutId}");
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Activate layout
/// </summary>
public async Task<LayoutDto> ActivateLayoutAsync(Guid layoutId)
{
var response = await _httpClient.PostAsync($"{_baseUrl}api/layouts/{layoutId}/activate", null);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LayoutDto>()
?? throw new Exception("Failed to activate layout");
}
/// <summary>
/// Deactivate layout
/// </summary>
public async Task<LayoutDto> DeactivateLayoutAsync(Guid layoutId)
{
var response = await _httpClient.PostAsync($"{_baseUrl}api/layouts/{layoutId}/deactivate", null);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LayoutDto>()
?? throw new Exception("Failed to deactivate layout");
}
// ==========================================
// VERSIONS
// ==========================================
/// <summary>
/// Create new version for a layout
/// </summary>
public async Task<LayoutVersionDto> CreateVersionAsync(Guid layoutId, CreateLayoutVersionRequest request)
{
var response = await _httpClient.PostAsJsonAsync(
$"{_baseUrl}api/layouts/{layoutId}/versions", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LayoutVersionDto>()
?? throw new Exception("Failed to create version");
}
/// <summary>
/// Get all versions for a layout
/// </summary>
public async Task<List<LayoutVersionDto>> GetVersionsAsync(Guid layoutId)
{
return await _httpClient.GetFromJsonAsync<List<LayoutVersionDto>>(
$"{_baseUrl}api/layouts/{layoutId}/versions") ?? new();
}
/// <summary>
/// Get version by ID
/// </summary>
public async Task<LayoutVersionDto?> GetVersionAsync(Guid versionId)
{
return await _httpClient.GetFromJsonAsync<LayoutVersionDto>(
$"{_baseUrl}api/layouts/versions/{versionId}");
}
/// <summary>
/// Delete version
/// </summary>
public async Task DeleteVersionAsync(Guid versionId)
{
var response = await _httpClient.DeleteAsync($"{_baseUrl}api/layouts/versions/{versionId}");
response.EnsureSuccessStatusCode();
}
// ==========================================
// LEVELS
// ==========================================
/// <summary>
/// Create new level for a version
/// </summary>
public async Task<LayoutLevelDto> CreateLevelAsync(Guid versionId, CreateLayoutLevelRequest request)
{
var response = await _httpClient.PostAsJsonAsync(
$"{_baseUrl}api/layouts/versions/{versionId}/levels", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LayoutLevelDto>()
?? throw new Exception("Failed to create level");
}
/// <summary>
/// Create a new level with background image in a single request
/// </summary>
/// <param name="versionId">Version ID</param>
/// <param name="layoutLevelId">Layout level identifier string</param>
/// <param name="levelOrder">Level order</param>
/// <param name="resolution">Resolution in meters per pixel</param>
/// <param name="originX">Origin X coordinate in meters</param>
/// <param name="originY">Origin Y coordinate in meters</param>
/// <param name="imageStream">Image stream (PNG format)</param>
/// <param name="fileName">Image file name</param>
/// <returns>Created layout level DTO</returns>
public async Task<LayoutLevelDto> CreateLevelWithImageAsync(
Guid versionId,
string layoutLevelId,
int levelOrder,
double resolution,
double originX,
double originY,
Stream imageStream,
string fileName)
{
using var content = new MultipartFormDataContent();
// Add form fields
content.Add(new StringContent(layoutLevelId), "layoutLevelId");
content.Add(new StringContent(levelOrder.ToString()), "levelOrder");
content.Add(new StringContent(resolution.ToString(CultureInfo.InvariantCulture)), "resolution");
content.Add(new StringContent(originX.ToString(CultureInfo.InvariantCulture)), "originX");
content.Add(new StringContent(originY.ToString(CultureInfo.InvariantCulture)), "originY");
// Add image file
var streamContent = new StreamContent(imageStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("image/png");
content.Add(streamContent, "file", fileName);
var response = await _httpClient.PostAsync(
$"{_baseUrl}api/layouts/versions/{versionId}/levels/with-image",
content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LayoutLevelDto>()
?? throw new Exception("Failed to create level with image");
}
/// <summary>
/// Get all levels for a version
/// </summary>
public async Task<List<LayoutLevelDto>> GetLevelsAsync(Guid versionId)
{
return await _httpClient.GetFromJsonAsync<List<LayoutLevelDto>>(
$"{_baseUrl}api/layouts/versions/{versionId}/levels") ?? new();
}
/// <summary>
/// Get level by ID
/// </summary>
public async Task<LayoutLevelDto?> GetLevelAsync(Guid levelId)
{
return await _httpClient.GetFromJsonAsync<LayoutLevelDto>(
$"{_baseUrl}api/layouts/levels/{levelId}");
}
/// <summary>
/// Update level
/// </summary>
public async Task<LayoutLevelDto> UpdateLevelAsync(Guid levelId, UpdateLayoutLevelRequest request)
{
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}api/layouts/levels/{levelId}", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LayoutLevelDto>()
?? throw new Exception("Failed to update level");
}
/// <summary>
/// Delete level
/// </summary>
public async Task DeleteLevelAsync(Guid levelId)
{
var response = await _httpClient.DeleteAsync($"{_baseUrl}api/layouts/levels/{levelId}");
response.EnsureSuccessStatusCode();
}
// ==========================================
// LAYOUT DATA (For Preview)
// ==========================================
/// <summary>
/// Get comprehensive layout data (nodes, edges, stations)
/// </summary>
public async Task<LayoutDataDto> GetLayoutDataAsync(Guid layoutLevelId)
{
return await _httpClient.GetFromJsonAsync<LayoutDataDto>(
$"{_baseUrl}api/layout-data/{layoutLevelId}") ?? new();
}
// ==========================================
// IMAGES
// ==========================================
/// <summary>
/// Get background image for a layout level
/// </summary>
public async Task<byte[]?> GetLayoutImageAsync(Guid layoutLevelId)
{
try
{
return await _httpClient.GetByteArrayAsync($"{_baseUrl}api/images/layout/{layoutLevelId}");
}
catch (HttpRequestException)
{
return null; // Image doesn't exist
}
}
/// <summary>
/// Upload background image for a layout level
/// </summary>
public async Task UploadLayoutImageAsync(Guid layoutLevelId, Stream imageStream, string fileName)
{
using var content = new MultipartFormDataContent();
var streamContent = new StreamContent(imageStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("image/png");
content.Add(streamContent, "file", fileName);
var response = await _httpClient.PostAsync(
$"{_baseUrl}api/images/layout/{layoutLevelId}", content);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Delete background image for a layout level
/// </summary>
public async Task DeleteLayoutImageAsync(Guid layoutLevelId)
{
var response = await _httpClient.DeleteAsync($"{_baseUrl}api/images/layout/{layoutLevelId}");
response.EnsureSuccessStatusCode();
}
// ==========================================
// NODES
// ==========================================
/// <summary>
/// Get node by ID
/// </summary>
public async Task<NodeDto?> GetNodeAsync(Guid nodeId)
{
return await _httpClient.GetFromJsonAsync<NodeDto>($"{_baseUrl}api/nodes/{nodeId}");
}
/// <summary>
/// Update node properties
/// </summary>
public async Task<NodeDto> UpdateNodeAsync(Guid nodeId, UpdateNodeRequest request)
{
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}api/nodes/{nodeId}", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<NodeDto>()
?? throw new Exception("Failed to update node");
}
// ==========================================
// EDGES
// ==========================================
/// <summary>
/// Create a new edge with automatic node detection/creation
/// </summary>
public async Task<EdgeDto> CreateEdgeAsync(CreateEdgeRequest request)
{
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}api/edges", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<EdgeDto>()
?? throw new Exception("Failed to create edge");
}
/// <summary>
/// Get edge by ID
/// </summary>
public async Task<EdgeDto?> GetEdgeAsync(Guid edgeId)
{
return await _httpClient.GetFromJsonAsync<EdgeDto>($"{_baseUrl}api/edges/{edgeId}");
}
/// <summary>
/// Update edge properties
/// </summary>
public async Task<EdgeDto> UpdateEdgeAsync(Guid edgeId, UpdateEdgeRequest request)
{
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}api/edges/{edgeId}", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<EdgeDto>()
?? throw new Exception("Failed to update edge");
}
/// <summary>
/// Delete edge
/// </summary>
public async Task DeleteEdgeAsync(Guid edgeId)
{
var response = await _httpClient.DeleteAsync($"{_baseUrl}api/edges/{edgeId}");
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Delete multiple edges in batch
/// </summary>
public async Task DeleteEdgesBatchAsync(List<Guid> edgeIds)
{
var request = new DeleteEdgesRequest { EdgeIds = edgeIds };
// Send DELETE request with body (non-standard but required by API)
var content = new StringContent(
System.Text.Json.JsonSerializer.Serialize(request, JsonOptionExtends.Write),
System.Text.Encoding.UTF8,
"application/json");
var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, $"{_baseUrl}api/edges/batch")
{
Content = content
};
var response = await _httpClient.SendAsync(deleteRequest);
response.EnsureSuccessStatusCode();
}
// ==========================================
// MERGE/SPLIT OPERATIONS
// ==========================================
/// <summary>
/// Merge multiple nodes into one node
/// </summary>
public async Task<MergeNodesResponse> MergeNodesAsync(MergeNodesRequest request)
{
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}api/layout-data/merge-nodes", request);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Failed to merge nodes: {errorContent}");
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<MergeNodesResponse>()
?? throw new Exception("Failed to merge nodes");
}
/// <summary>
/// Split a node into multiple nodes
/// </summary>
public async Task<SplitNodeResponse> SplitNodeAsync(SplitNodeRequest request)
{
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}api/layout-data/split-node", request);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Failed to split node: {errorContent}");
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SplitNodeResponse>()
?? throw new Exception("Failed to split node");
}
/// <summary>
/// Save all layout changes (nodes and edges) in a batch operation
/// </summary>
public async Task<SaveLayoutDataResponse> SaveLayoutDataAsync(SaveLayoutDataRequest request)
{
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}api/layout-data/save", request);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Failed to save layout data: {errorContent}");
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<SaveLayoutDataResponse>()
?? throw new Exception("Failed to save layout data");
}
/// <summary>
/// Copy selected nodes and edges with an offset
/// </summary>
public async Task<CopyNodesResponse> CopyNodesAsync(CopyNodesRequest request)
{
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}api/layout-data/copy-nodes", request);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Failed to copy nodes: {errorContent}");
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<CopyNodesResponse>()
?? throw new Exception("Failed to copy nodes");
}
// ==========================================
// VEHICLE TYPES
// ==========================================
/// <summary>
/// Get all vehicle types, optionally filtered by active status
/// </summary>
public async Task<List<VehicleTypeDto>> GetVehicleTypesAsync(bool? isActive = null)
{
var url = $"{_baseUrl}api/vehicles";
if (isActive.HasValue)
url += $"?isActive={isActive.Value}";
return await _httpClient.GetFromJsonAsync<List<VehicleTypeDto>>(url) ?? new();
}
/// <summary>
/// Get vehicle type by database ID
/// </summary>
public async Task<VehicleTypeDto?> GetVehicleTypeAsync(Guid id)
{
return await _httpClient.GetFromJsonAsync<VehicleTypeDto>($"{_baseUrl}api/vehicles/{id}");
}
/// <summary>
/// Get vehicle type by VehicleTypeId string
/// </summary>
public async Task<VehicleTypeDto?> GetVehicleTypeByStringIdAsync(string vehicleTypeId)
{
return await _httpClient.GetFromJsonAsync<VehicleTypeDto>(
$"{_baseUrl}api/vehicles/vehicleTypeId/{Uri.EscapeDataString(vehicleTypeId)}");
}
/// <summary>
/// Search vehicle types by query string
/// </summary>
public async Task<List<VehicleTypeDto>> SearchVehicleTypesAsync(string query)
{
if (string.IsNullOrWhiteSpace(query))
return new List<VehicleTypeDto>();
var url = $"{_baseUrl}api/vehicles/search?query={Uri.EscapeDataString(query)}";
return await _httpClient.GetFromJsonAsync<List<VehicleTypeDto>>(url) ?? new();
}
/// <summary>
/// Create a new vehicle type
/// </summary>
public async Task<VehicleTypeDto> CreateVehicleTypeAsync(CreateVehicleTypeRequest request)
{
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}api/vehicles", request);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Failed to create vehicle type: {errorContent}");
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<VehicleTypeDto>()
?? throw new Exception("Failed to create vehicle type");
}
/// <summary>
/// Update an existing vehicle type
/// </summary>
public async Task<VehicleTypeDto> UpdateVehicleTypeAsync(Guid id, UpdateVehicleTypeRequest request)
{
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}api/vehicles/{id}", request);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Vehicle type not found: {errorContent}");
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<VehicleTypeDto>()
?? throw new Exception("Failed to update vehicle type");
}
/// <summary>
/// Delete a vehicle type
/// </summary>
public async Task DeleteVehicleTypeAsync(Guid id)
{
var response = await _httpClient.DeleteAsync($"{_baseUrl}api/vehicles/{id}");
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Cannot delete vehicle type: {errorContent}");
}
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Get usage information for a vehicle type
/// </summary>
public async Task<VehicleTypeUsageInfoDto> GetVehicleTypeUsageAsync(Guid id)
{
return await _httpClient.GetFromJsonAsync<VehicleTypeUsageInfoDto>(
$"{_baseUrl}api/vehicles/{id}/usage")
?? throw new Exception("Failed to get vehicle type usage info");
}
// ==========================================
// STATIONS
// ==========================================
/// <summary>
/// Get all stations for a layout level
/// </summary>
public async Task<List<StationDto>> GetStationsByLevelAsync(Guid layoutLevelId)
{
return await _httpClient.GetFromJsonAsync<List<StationDto>>(
$"{_baseUrl}api/stations/level/{layoutLevelId}") ?? new();
}
/// <summary>
/// Get station by database ID
/// </summary>
public async Task<StationDto?> GetStationAsync(Guid stationId)
{
return await _httpClient.GetFromJsonAsync<StationDto>(
$"{_baseUrl}api/stations/{stationId}");
}
/// <summary>
/// Create a new station
/// </summary>
public async Task<StationDto> CreateStationAsync(CreateStationRequest request)
{
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}api/stations", request);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Failed to create station: {errorContent}");
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<StationDto>()
?? throw new Exception("Failed to create station");
}
/// <summary>
/// Update an existing station
/// </summary>
public async Task<StationDto> UpdateStationAsync(Guid stationId, UpdateStationRequest request)
{
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}api/stations/{stationId}", request);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Station not found: {errorContent}");
}
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Failed to update station: {errorContent}");
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<StationDto>()
?? throw new Exception("Failed to update station");
}
/// <summary>
/// Delete a station
/// </summary>
public async Task DeleteStationAsync(Guid stationId)
{
var response = await _httpClient.DeleteAsync($"{_baseUrl}api/stations/{stationId}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Station not found: {errorContent}");
}
response.EnsureSuccessStatusCode();
}
}

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

View File

@@ -0,0 +1,22 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.Extensions.Configuration
@using Microsoft.Extensions.DependencyInjection
@using Microsoft.JSInterop
@using MudBlazor
@using RobotNet10.MapEditor.Shared.DTOs.Layout
@using RobotNet10.MapEditor.Shared.DTOs.LayoutData
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using RobotNet10.MapEditor.Shared.DTOs.Station
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Models
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Components.LayoutManager
@using RobotNet10.MapEditor.Components.LayoutManager.Dialogs
@using RobotNet10.MapEditor.Components.LayoutEditor
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel
@using RobotNet10.MapEditor.Components.Shared

View File

@@ -0,0 +1,242 @@
// SVG Editor JavaScript Module for LayoutEditor
// Handles mouse events, keyboard shortcuts, and coordinate transformations
let svgElement = null;
let dotNetRef = null;
let isInitialized = false;
/**
* Initialize the SVG editor with event listeners
* @param {SVGElement} svg - The SVG element
* @param {DotNetObjectReference} dotNet - Reference to Blazor component
*/
export function initEditor(svg, dotNet) {
svgElement = svg;
dotNetRef = dotNet;
if (!svgElement || !dotNetRef) {
console.error('SVG Editor: Invalid initialization parameters');
return;
}
// Mouse events
svgElement.addEventListener('mousemove', handleMouseMove);
svgElement.addEventListener('mousedown', handleMouseDown);
svgElement.addEventListener('mouseup', handleMouseUp);
svgElement.addEventListener('wheel', handleWheel, { passive: false });
svgElement.addEventListener('contextmenu', handleContextMenu);
// Keyboard events (on document to capture even when SVG not focused)
document.addEventListener('keydown', handleKeyDown);
// Prevent default drag behavior
svgElement.addEventListener('dragstart', (e) => e.preventDefault());
isInitialized = true;
console.log('SVG Editor initialized');
}
/**
* Dispose the editor and remove event listeners
*/
export function disposeEditor() {
if (svgElement) {
svgElement.removeEventListener('mousemove', handleMouseMove);
svgElement.removeEventListener('mousedown', handleMouseDown);
svgElement.removeEventListener('mouseup', handleMouseUp);
svgElement.removeEventListener('wheel', handleWheel);
svgElement.removeEventListener('contextmenu', handleContextMenu);
}
document.removeEventListener('keydown', handleKeyDown);
svgElement = null;
dotNetRef = null;
isInitialized = false;
console.log('SVG Editor disposed');
}
/**
* Convert screen coordinates to SVG coordinates
* @param {number} screenX - Screen X coordinate
* @param {number} screenY - Screen Y coordinate
* @returns {{x: number, y: number}} SVG coordinates
*/
function screenToSvg(screenX, screenY) {
if (!svgElement) return { x: 0, y: 0 };
const pt = svgElement.createSVGPoint();
pt.x = screenX;
pt.y = screenY;
const ctm = svgElement.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const svgPt = pt.matrixTransform(ctm.inverse());
return { x: svgPt.x, y: svgPt.y };
}
/**
* Export screenToSvg for use from Blazor
* @param {number} screenX - Screen X coordinate
* @param {number} screenY - Screen Y coordinate
* @returns {number[]} SVG coordinates as [x, y]
*/
export function screenToSvgArray(screenX, screenY) {
const coords = screenToSvg(screenX, screenY);
return [coords.x, coords.y];
}
/**
* Handle mouse move event
* @param {MouseEvent} e
*/
function handleMouseMove(e) {
if (!dotNetRef) return;
const svgCoords = screenToSvg(e.clientX, e.clientY);
// Also pass screen coordinates for panning and ctrl key state
dotNetRef.invokeMethodAsync('OnMouseMove', svgCoords.x, svgCoords.y, e.clientX, e.clientY, e.ctrlKey);
}
/**
* Handle mouse down event
* @param {MouseEvent} e
*/
function handleMouseDown(e) {
if (!dotNetRef) return;
const svgCoords = screenToSvg(e.clientX, e.clientY);
// For left clicks, always pass to Blazor
// For middle/right clicks, filter out node/edge clicks
const target = e.target;
if (e.button !== 0 && // Not left click
(target.classList.contains('node') ||
target.classList.contains('edge') ||
target.classList.contains('edge-arrow'))) {
return;
}
// Pass all left clicks to Blazor (CreateEdge mode needs clicks anywhere)
// Also pass screen coordinates for panning
dotNetRef.invokeMethodAsync('OnMouseDown', svgCoords.x, svgCoords.y, e.button, e.ctrlKey, e.clientX, e.clientY);
}
/**
* Handle mouse up event
* @param {MouseEvent} e
*/
function handleMouseUp(e) {
if (!dotNetRef) return;
const svgCoords = screenToSvg(e.clientX, e.clientY);
dotNetRef.invokeMethodAsync('OnMouseUp', svgCoords.x, svgCoords.y, e.button);
}
/**
* Handle mouse wheel event (zoom)
* @param {WheelEvent} e
*/
function handleWheel(e) {
if (!dotNetRef) return;
e.preventDefault();
const svgCoords = screenToSvg(e.clientX, e.clientY);
dotNetRef.invokeMethodAsync('OnWheel', svgCoords.x, svgCoords.y, e.deltaY);
}
/**
* Handle context menu (right click)
* @param {MouseEvent} e
*/
function handleContextMenu(e) {
// Prevent default context menu
e.preventDefault();
// TODO: Show custom context menu
}
/**
* Handle keyboard events
* @param {KeyboardEvent} e
*/
function handleKeyDown(e) {
if (!dotNetRef) return;
// Only handle if not typing in an input
const activeElement = document.activeElement;
if (activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable)) {
return;
}
// Check for shortcuts
if (e.ctrlKey || e.metaKey) {
switch (e.key.toLowerCase()) {
case 'z':
e.preventDefault();
dotNetRef.invokeMethodAsync('OnKeyDown', 'z', true);
break;
case 'y':
e.preventDefault();
dotNetRef.invokeMethodAsync('OnKeyDown', 'y', true);
break;
case 's':
e.preventDefault();
dotNetRef.invokeMethodAsync('OnKeyDown', 's', true);
break;
case 'c':
e.preventDefault();
dotNetRef.invokeMethodAsync('OnKeyDown', 'c', true);
break;
case 'm':
e.preventDefault();
dotNetRef.invokeMethodAsync('OnKeyDown', 'm', true);
break;
}
} else {
switch (e.key) {
case 'Delete':
e.preventDefault();
dotNetRef.invokeMethodAsync('OnKeyDown', 'Delete', false);
break;
case 'Escape':
e.preventDefault();
dotNetRef.invokeMethodAsync('OnKeyDown', 'Escape', false);
break;
}
}
}
/**
* Utility: Get current viewBox values
* @returns {{x: number, y: number, width: number, height: number}}
*/
export function getViewBox() {
if (!svgElement) return { x: 0, y: 0, width: 100, height: 100 };
const viewBox = svgElement.viewBox.baseVal;
return {
x: viewBox.x,
y: viewBox.y,
width: viewBox.width,
height: viewBox.height
};
}
/**
* Utility: Set viewBox values
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
*/
export function setViewBox(x, y, width, height) {
if (!svgElement) return;
svgElement.setAttribute('viewBox', `${x} ${y} ${width} ${height}`);
}