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