Initial commit

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

View File

@@ -0,0 +1,299 @@
@using MudBlazor
@using Microsoft.AspNetCore.SignalR.Client
@using RobotNet10.RobotApp.Client.Clients
@using RobotNet10.Shared.Sensor
@implements IAsyncDisposable
@inject BatteryHubClient BatteryHubClient
@inject ISnackbar Snackbar
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h5">@DeviceName</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudTooltip Text="Reload battery data">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Color="Color.Primary"
Size="Size.Small"
OnClick="ReloadBatteryDataAsync"
Disabled="@(!BatteryHubClient.IsConnected || IsReloading)">
</MudIconButton>
</MudTooltip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="position: relative;">
<MudGrid>
<!-- LEFT COLUMN : SVG BATTERY -->
<MudItem xs="12" md="4">
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
@* =================== SVG KHÔNG ĐỔI =================== *@
<svg width="100%" height="100%" viewBox="0 0 200 300" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 100%;">
<defs>
<linearGradient id="@($"healthGradient_{DeviceId}_{(int)BatteryData.Percentage}")" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:@GetHealthColor();stop-opacity:0.95" />
<stop offset="50%" style="stop-color:@GetHealthColor();stop-opacity:0.85" />
<stop offset="100%" style="stop-color:@GetHealthColor();stop-opacity:0.75" />
</linearGradient>
</defs>
<rect x="40" y="20" width="120" height="200" rx="8" ry="8"
fill="none" stroke="currentColor" stroke-width="4"
style="color: var(--mud-palette-text-primary);" />
<rect x="80" y="0" width="40" height="20" rx="4" ry="4"
fill="currentColor"
style="color: var(--mud-palette-text-primary);" />
<clipPath id="batteryClip">
<rect x="42" y="22" width="116" height="196" rx="6" ry="6" />
</clipPath>
@* ==== Charge Level Fill ==== *@
@{
var clipHeight = 196.0;
var clipY = 22.0;
var clipBottom = clipY + clipHeight;
var healthHeight = BatteryData.Percentage / 100.0 * clipHeight;
var healthY = clipBottom - healthHeight;
}
<g clip-path="url(#batteryClip)">
<rect x="42" y="@healthY"
width="116" height="@healthHeight"
rx="6" ry="6"
fill="@($"url(#healthGradient_{DeviceId}_{(int)BatteryData.Percentage})")"
opacity="0.9" />
<ellipse cx="100" cy="@(healthY + 6)"
rx="40" ry="8"
fill="white" opacity="0.3">
<animate attributeName="cy" dur="3s" repeatCount="indefinite"
values="@(healthY + 6);@(healthY + 12);@(healthY + 6)" />
<animate attributeName="opacity" dur="3s" repeatCount="indefinite"
values="0.3;0.4;0.3" />
</ellipse>
</g>
<text x="100" y="140"
text-anchor="middle" dominant-baseline="middle"
font-size="48" font-weight="bold"
fill="@GetHealthTextColor()">
@((int)BatteryData.Percentage)%
</text>
<text x="100" y="180"
text-anchor="middle" dominant-baseline="middle"
font-size="16"
fill="currentColor"
style="color: var(--mud-palette-text-secondary);">
Charge Level
</text>
</svg>
</div>
</MudItem>
<!-- RIGHT COLUMN -->
<MudItem xs="12" md="8">
<MudGrid>
<!-- HEALTH -->
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h4">@GetHealthPercentage()%</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Health (SOH)</MudText>
<MudProgressLinear Value="@GetHealthPercentage()" Color="@GetChargeLevelColor()" Class="mt-2" />
</MudCard>
</MudItem>
<!-- VOLTAGE -->
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h4">@BatteryData.Voltage.ToString("F2") V</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Voltage</MudText>
</MudCard>
</MudItem>
<!-- CURRENT -->
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h4" Color="@GetCurrentColor()">@BatteryData.Current.ToString("F2") A</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Current</MudText>
<MudChip T="string" Color="@(IsCharging()? Color.Success: Color.Default)" Size="Size.Small" Class="mt-2">
@(IsCharging() ? "Charging" : "Discharging")
</MudChip>
</MudCard>
</MudItem>
<!-- TEMPERATURE -->
@if (BatteryData.CellTemperature != null && BatteryData.CellTemperature.Length > 0)
{
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h4">@BatteryData.CellTemperature[0].ToString("F1") °C</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Temperature</MudText>
</MudCard>
</MudItem>
}
<!-- REMAIN CAPACITY -->
@if (!double.IsNaN(BatteryData.Charge))
{
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h4">@BatteryData.Charge.ToString("F2") Ah</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Remaining Capacity</MudText>
</MudCard>
</MudItem>
}
<!-- FULL CAPACITY -->
@if (!double.IsNaN(BatteryData.Capacity))
{
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h4">@BatteryData.Capacity.ToString("F2") Ah</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Full Capacity</MudText>
</MudCard>
</MudItem>
}
<!-- TIMESTAMP -->
<MudItem xs="12">
<MudText Typo="Typo.caption" Color="Color.Secondary">
Last Update: @BatteryData.Header.Stamp.ToString("yyyy-MM-dd HH:mm:ss")
</MudText>
</MudItem>
</MudGrid>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<MudOverlay Visible="@IsLoading" Absolute="true">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
</MudOverlay>
@code {
[Parameter, EditorRequired]
public string DeviceId { get; set; } = string.Empty;
private string DeviceName { get; set; } = string.Empty;
private BatteryState BatteryData = new();
private bool IsLoading => !BatteryHubClient.IsConnected;
private bool IsReloading = false;
protected override async Task OnInitializedAsync()
=> await base.OnInitializedAsync();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !string.IsNullOrEmpty(DeviceId))
await ConnectAsync();
await base.OnAfterRenderAsync(firstRender);
}
private async Task ConnectAsync()
{
try
{
await BatteryHubClient.StartAsync();
var deviceInfo = await BatteryHubClient.GetDeviceInfoAsync(DeviceId);
DeviceName = deviceInfo?.DeviceName ?? DeviceId;
BatteryData = await BatteryHubClient.GetBatteryDataAsync(DeviceId);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
}
}
private async Task DisconnectAsync()
{
try
{
await BatteryHubClient.StopAsync();
BatteryData = new BatteryState();
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
}
}
private async Task ReloadBatteryDataAsync()
{
if (!BatteryHubClient.IsConnected || IsReloading)
return;
try
{
IsReloading = true;
StateHasChanged();
BatteryData = await BatteryHubClient.GetBatteryDataAsync(DeviceId);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to reload battery data: {ex.Message}", Severity.Error);
}
finally
{
IsReloading = false;
StateHasChanged();
}
}
private Color GetChargeLevelColor() =>
GetHealthPercentage() switch
{
>= 70 => Color.Success,
>= 30 => Color.Warning,
_ => Color.Error
};
private Color GetCurrentColor() =>
IsCharging() ? Color.Success : Color.Info;
private string GetHealthColor() =>
BatteryData.Percentage switch
{
>= 70 => "#4caf50",
>= 30 => "#ff9800",
_ => "#f44336"
};
private bool IsCharging() =>
BatteryData.PowerSupplyStatus == BatteryState.PowerSupplyStatusCharging;
private double GetHealthPercentage()
{
return BatteryData.PowerSupplyHealth switch
{
BatteryState.PowerSupplyHealthGood => 100.0,
BatteryState.PowerSupplyHealthOverheat => 50.0,
BatteryState.PowerSupplyHealthDead => 0.0,
BatteryState.PowerSupplyHealthOvervoltage => 30.0,
BatteryState.PowerSupplyHealthUnspecifiedFailure => 20.0,
BatteryState.PowerSupplyHealthCold => 40.0,
_ => 0.0
};
}
private string GetHealthTextColor() =>
"var(--mud-palette-text-primary)";
public async ValueTask DisposeAsync()
=> await DisconnectAsync();
}

View File

@@ -0,0 +1,243 @@
@implements IAsyncDisposable
@using MudBlazor
@using Microsoft.AspNetCore.SignalR.Client
@using RobotNet10.RobotApp.Client.Clients
@using RobotNet10.RobotApp.Client.Shared.Devices
@inject CameraQrHubClient CameraQrHubClient
@inject ISnackbar Snackbar
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h5">@DeviceName</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudTooltip Text="Reload Camera QR data">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Color="Color.Primary"
Size="Size.Small"
OnClick="ReloadCameraQrDataAsync"
Disabled="@(!CameraQrHubClient.IsConnected || IsReloading)">
</MudIconButton>
</MudTooltip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="position: relative;" Class="pa-2">
<MudGrid Spacing="2">
<!-- Left Column: Camera Frame Visualization -->
<MudItem xs="12" md="6">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.subtitle2" Color="Color.Primary" Class="mb-2">Camera Frame</MudText>
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
<svg width="100%" height="100%" viewBox="0 0 640 480" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 400px; border: 2px solid var(--mud-palette-divider); border-radius: 4px;">
<defs>
<!-- Gradient cho camera frame -->
<linearGradient id="@($"cameraGradient_{DeviceId}")" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a1a;stop-opacity:1" />
<stop offset="50%" style="stop-color:#2a2a2a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1a1a1a;stop-opacity:1" />
</linearGradient>
<!-- Filter cho glow effect -->
<filter id="@($"glow_{DeviceId}")">
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Camera frame background -->
<rect x="0" y="0" width="640" height="480" fill="url(@($"#cameraGradient_{DeviceId}"))" />
</svg>
</div>
</MudCard>
</MudItem>
<!-- Right Column: Data -->
<MudItem xs="12" md="6">
<MudGrid Spacing="1">
<!-- Connection Status -->
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-2">
<MudGrid Spacing="1">
<MudItem xs="12" sm="6">
<MudChip T="string" Color="@(CameraQrData.IsConnected ? Color.Success : Color.Error)"
Size="Size.Small"
Variant="Variant.Filled">
@(CameraQrData.IsConnected ? "Connected" : "Disconnected")
</MudChip>
</MudItem>
</MudGrid>
</MudCard>
</MudItem>
<!-- Detection Status -->
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.subtitle2" Color="Color.Primary" Class="mb-1">Detection Status</MudText>
<MudGrid Spacing="1">
<MudItem xs="12">
@if (CameraQrData.Codes is { Count: > 0 })
{
<MudTable Dense="true" Hover="true" Bordered="true" Elevation="0" Items="CameraQrData.Codes">
<HeaderContent>
<MudTh>Code</MudTh>
<MudTh>X (m)</MudTh>
<MudTh>Y (m)</MudTh>
<MudTh>Z (m)</MudTh>
<MudTh>Yaw (deg)</MudTh>
<MudTh>Time</MudTh>
</HeaderContent>
<RowTemplate Context="kv">
<MudTd>@kv.Key</MudTd>
<MudTd>@kv.Value.Pose.Position.X.ToString("F3")</MudTd>
<MudTd>@kv.Value.Pose.Position.Y.ToString("F3")</MudTd>
<MudTd>@kv.Value.Pose.Position.Z.ToString("F3")</MudTd>
<MudTd>@kv.Value.Pose.Orientation.ToYawDegrees().ToString("F1")</MudTd>
<MudTd>@kv.Value.Header.Stamp.ToLocalTime().ToString("HH:mm:ss")</MudTd>
</RowTemplate>
<FooterContent>
<MudTd ColSpan="6">
Total: @CameraQrData.Codes.Count code(s)
</MudTd>
</FooterContent>
</MudTable>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
No QR detected.
</MudText>
}
</MudItem>
</MudGrid>
</MudCard>
</MudItem>
</MudGrid>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<MudOverlay Visible="@IsLoading" Absolute="true">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
</MudOverlay>
@code {
[Parameter, EditorRequired]
public string DeviceId { get; set; } = string.Empty;
private string DeviceName { get; set; } = string.Empty;
private CameraQrDataDto CameraQrData = new();
private bool IsLoading => !CameraQrHubClient.IsConnected;
private bool IsReloading = false;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !string.IsNullOrEmpty(DeviceId))
{
await ConnectAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task ConnectAsync()
{
try
{
await CameraQrHubClient.StartAsync();
// Lấy DeviceName từ CameraQrHub
var deviceInfo = await CameraQrHubClient.GetDeviceInfoAsync(DeviceId);
if (deviceInfo != null)
{
DeviceName = deviceInfo.DeviceName;
}
else
{
DeviceName = DeviceId; // Fallback to DeviceId if not found
}
CameraQrData = await CameraQrHubClient.GetCameraQrDataAsync(DeviceId);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
}
}
private async Task DisconnectAsync()
{
try
{
await CameraQrHubClient.StopAsync();
CameraQrData = new CameraQrDataDto(); // Reset về giá trị mặc định
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
}
}
private async Task ReloadCameraQrDataAsync()
{
if (!CameraQrHubClient.IsConnected || IsReloading)
return;
try
{
IsReloading = true;
StateHasChanged();
CameraQrData = await CameraQrHubClient.GetCameraQrDataAsync(DeviceId);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to reload Camera QR data: {ex.Message}", Severity.Error);
}
finally
{
IsReloading = false;
StateHasChanged();
}
}
private Color GetConfidenceColor(double confidence)
{
if (confidence >= 0.9)
{
return Color.Success;
}
else if (confidence >= 0.7)
{
return Color.Info;
}
else if (confidence >= 0.5)
{
return Color.Warning;
}
else
{
return Color.Error;
}
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
}
}

View File

@@ -0,0 +1,215 @@
@using RobotNet10.RobotApp.Client.Shared.Devices
@using Microsoft.AspNetCore.Components
@using MudBlazor
<MudCard Elevation="1" Class="mb-2">
<MudCardContent Class="pa-2">
@* Compact Header *@
<MudStack Row="true" AlignItems="@AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1" Class="mb-1">
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
<MudIcon Icon="@GetDeviceIcon()" Color="@GetStatusColor()" Size="Size.Small" />
<div>
<MudText Typo="Typo.body2" Style="font-weight: 600; line-height: 1.2;">@Device.DeviceName</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="line-height: 1;">@Device.DeviceId</MudText>
</div>
</MudStack>
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
<MudChip T="string" Size="Size.Small" Color="@GetStatusColor()" Variant="Variant.Filled" Style="font-size: 0.7rem;">
@Device.Status.ToString()
</MudChip>
<MudIconButton Icon="@Icons.Material.Filled.Settings"
Size="Size.Small"
Color="Color.Primary"
Variant="Variant.Text"
OnClick="NavigateToDeviceManagement"
title="Manage Device" />
</MudStack>
</MudStack>
@* Compact Info Grid - 2 columns *@
<MudGrid Spacing="1" Class="mt-1">
@* Device Type *@
<MudItem xs="6">
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Type:</MudText>
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Style="font-size: 0.7rem; height: 20px;">@Device.DeviceType</MudChip>
</MudStack>
</MudItem>
@* Connection Status *@
<MudItem xs="6">
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Status:</MudText>
<MudIcon Icon="@(Device.IsConnected ? Icons.Material.Filled.CheckCircle : Icons.Material.Filled.Cancel)"
Color="@(Device.IsConnected ? Color.Success : Color.Error)"
Size="Size.Small" Style="width: 16px; height: 16px;" />
<MudText Typo="Typo.caption">@(Device.IsConnected ? "Yes" : "No")</MudText>
</MudStack>
</MudItem>
@* Last Update - Compact format *@
<MudItem xs="12">
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Updated:</MudText>
<MudText Typo="Typo.caption">@Device.LastUpdateTime.ToString("MM-dd HH:mm:ss")</MudText>
</MudStack>
</MudItem>
@* Reconnect Attempts - Only show if > 0 *@
@if (Device.ReconnectAttemptCount > 0)
{
<MudItem xs="12">
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Retries:</MudText>
<MudChip T="int" Size="Size.Small" Color="Color.Warning" Style="font-size: 0.7rem; height: 20px;">@Device.ReconnectAttemptCount</MudChip>
</MudStack>
</MudItem>
}
</MudGrid>
@* Error Message - Compact *@
@if (!string.IsNullOrWhiteSpace(Device.LastError))
{
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-1" Style="padding: 4px 8px;">
<MudText Typo="Typo.caption" Style="line-height: 1.2;">@Device.LastError</MudText>
</MudAlert>
}
@* Properties - Collapsed by default, only show count *@
@if (Device.Properties.Any())
{
<MudExpansionPanels Dense="true" Class="mt-1">
<MudExpansionPanel Text="@($"Properties ({Device.Properties.Count})")" Icon="@Icons.Material.Filled.Info" Style="font-size: 0.75rem;">
<MudSimpleTable Dense="true" Style="font-size: 0.7rem;">
<thead>
<tr>
<th style="padding: 4px;">Property</th>
<th style="padding: 4px;">Value</th>
</tr>
</thead>
<tbody>
@foreach (var prop in GetDisplayedProperties().Take(5))
{
<tr>
<td style="padding: 2px 4px;">
<MudText Typo="Typo.caption">@prop.DisplayName</MudText>
</td>
<td style="padding: 2px 4px;">
@if (Device.Properties.TryGetValue(prop.Key, out var value))
{
<MudText Typo="Typo.caption">
@FormatPropertyValue(value, prop)
</MudText>
}
</td>
</tr>
}
@if (Device.Properties.Count > 5)
{
<tr>
<td colspan="2" style="padding: 2px 4px; text-align: center;">
<MudText Typo="Typo.caption" Color="Color.Secondary">
... and @(Device.Properties.Count - 5) more
</MudText>
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudExpansionPanel>
</MudExpansionPanels>
}
</MudCardContent>
</MudCard>
@code {
[Parameter, EditorRequired]
public DeviceDto Device { get; set; } = null!;
[Inject]
private NavigationManager NavigationManager { get; set; } = null!;
private void NavigateToDeviceManagement()
{
var deviceType = GetDeviceTypeRoute(Device.DeviceType);
var deviceId = Uri.EscapeDataString(Device.DeviceId);
NavigationManager.NavigateTo($"/devices/{deviceType}/{deviceId}");
}
private string GetDeviceTypeRoute(DeviceType deviceType)
{
return deviceType switch
{
DeviceType.Lidar => "lidar",
DeviceType.Imu => "imu",
DeviceType.Battery => "battery",
DeviceType.ModbusTcp => "modbustcp",
DeviceType.RfHandle => "rfhandle",
DeviceType.CameraQr => "cameraqr",
DeviceType.CiA402Servo => "cia402servo",
_ => deviceType.ToString().ToLowerInvariant()
};
}
private string GetDeviceIcon()
{
return Device.DeviceType switch
{
DeviceType.Lidar => Icons.Material.Filled.Radar,
DeviceType.Imu => Icons.Material.Filled.Explore,
DeviceType.Battery => Icons.Material.Filled.BatteryFull,
DeviceType.ModbusTcp => Icons.Material.Filled.Lan,
DeviceType.RfHandle => Icons.Material.Filled.RadioButtonChecked,
DeviceType.CameraQr => Icons.Material.Filled.QrCodeScanner,
DeviceType.CiA402Servo => Icons.Material.Filled.Settings,
_ => Icons.Material.Filled.Devices
};
}
private Color GetStatusColor()
{
return Device.Status switch
{
DeviceStatus.Connected => Color.Success,
DeviceStatus.Connecting => Color.Info,
DeviceStatus.Disconnecting => Color.Warning,
DeviceStatus.Disconnected => Color.Default,
DeviceStatus.Reconnecting => Color.Warning,
DeviceStatus.Error => Color.Error,
DeviceStatus.Initializing => Color.Info,
_ => Color.Default
};
}
private IEnumerable<PropertyDescription> GetDisplayedProperties()
{
return Device.PropertyDescriptions
.OrderBy(p => p.DisplayOrder)
.ThenBy(p => p.Category ?? "")
.ThenBy(p => p.DisplayName);
}
private string FormatPropertyValue(string value, PropertyDescription prop)
{
if (string.IsNullOrWhiteSpace(value))
return "-";
if (prop.DataType == "number" && double.TryParse(value, out var numValue))
{
if (!string.IsNullOrWhiteSpace(prop.Format))
{
try
{
return string.Format(prop.Format, numValue) + (prop.Unit != null ? $" {prop.Unit}" : "");
}
catch
{
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
}
}
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
}
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
}
}

View File

@@ -0,0 +1,507 @@
@using MudBlazor
@using Microsoft.AspNetCore.SignalR.Client
@using RobotNet10.RobotApp.Client.Clients
@using RobotNet10.RobotApp.Client.Shared.Devices
@using RobotNet10.Shared.Sensor
@using RobotNet10.Shared.Geometry
@using RobotNet10.Shared.Numbers
@using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion
@using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion
@implements IAsyncDisposable
@inject InertialMeasurementUnitHubClient ImuHubClient
@inject ISnackbar Snackbar
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h5">@DeviceName</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudTooltip Text="Reload IMU data">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Color="Color.Primary"
Size="Size.Small"
OnClick="ReloadImuDataAsync"
Disabled="@(!ImuHubClient.IsConnected || IsReloading)">
</MudIconButton>
</MudTooltip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="position: relative;">
<MudGrid>
<!-- Left Column: IMU SVG với 3D visualization -->
<MudItem xs="12" md="4">
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
<svg width="100%" height="100%" viewBox="0 0 300 300" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 100%;">
<defs>
<!-- Gradients cho các trục -->
<linearGradient id="@($"xAxisGradient_{DeviceId}")" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#f44336;stop-opacity:0.8" />
<stop offset="100%" style="stop-color:#f44336;stop-opacity:0.4" />
</linearGradient>
<linearGradient id="@($"yAxisGradient_{DeviceId}")" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4caf50;stop-opacity:0.8" />
<stop offset="100%" style="stop-color:#4caf50;stop-opacity:0.4" />
</linearGradient>
<linearGradient id="@($"zAxisGradient_{DeviceId}")" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2196f3;stop-opacity:0.8" />
<stop offset="100%" style="stop-color:#2196f3;stop-opacity:0.4" />
</linearGradient>
<!-- Filter cho glow effect -->
<filter id="@($"glow_{DeviceId}")">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Arrow markers -->
<marker id="@($"arrowhead-red-{DeviceId}")" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="#f44336" />
</marker>
<marker id="@($"arrowhead-green-{DeviceId}")" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="#4caf50" />
</marker>
<marker id="@($"arrowhead-blue-{DeviceId}")" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="#2196f3" />
</marker>
</defs>
<!-- Background circle -->
<circle cx="150" cy="150" r="120" fill="none" stroke="currentColor" stroke-width="2"
opacity="0.1" style="color: var(--mud-palette-text-primary);" />
<!-- IMU Device (3D box representation) -->
<g transform="translate(150, 150)">
<!-- Device box với perspective -->
<g transform="rotate(@(QuaternionToEuler(ImuData.Orientation).yaw * 180 / Math.PI), 0, 0)">
<!-- Top face -->
<polygon points="-30,-20 -10,-30 10,-30 30,-20 30,20 10,30 -10,30 -30,20"
fill="var(--mud-palette-surface)"
stroke="currentColor"
stroke-width="2"
opacity="0.9"
style="color: var(--mud-palette-text-primary);" />
<!-- Front face -->
<polygon points="-30,-20 30,-20 30,20 -30,20"
fill="var(--mud-palette-surface)"
stroke="currentColor"
stroke-width="2"
opacity="0.7"
style="color: var(--mud-palette-text-primary);" />
<!-- Side face -->
<polygon points="30,-20 10,-30 -10,-30 -30,-20 -30,20 -10,30 10,30 30,20"
fill="var(--mud-palette-surface)"
stroke="currentColor"
stroke-width="2"
opacity="0.5"
style="color: var(--mud-palette-text-primary);" />
</g>
<!-- X Axis (Red) -->
@{
var accelX = Math.Max(-50, Math.Min(50, ImuData.LinearAcceleration.X * 5));
var accelXEnd = 80 + accelX;
}
<line x1="0" y1="0" x2="@accelXEnd" y2="0"
stroke="#f44336"
stroke-width="4"
marker-end="@($"url(#arrowhead-red-{DeviceId})")"
filter="@($"url(#glow_{DeviceId})")"
opacity="0.8">
<animate attributeName="x2"
values="@(accelXEnd - 5);@(accelXEnd + 5);@(accelXEnd - 5)"
dur="1s"
repeatCount="indefinite" />
</line>
<text x="@(accelXEnd + 10)" y="5" font-size="12" fill="#f44336" font-weight="bold">X</text>
<!-- Y Axis (Green) -->
@{
var accelY = Math.Max(-50, Math.Min(50, ImuData.LinearAcceleration.Y * 5));
var accelYEnd = 80 + accelY;
}
<line x1="0" y1="0" x2="0" y2="@(-accelYEnd)"
stroke="#4caf50"
stroke-width="4"
marker-end="@($"url(#arrowhead-green-{DeviceId})")"
filter="@($"url(#glow_{DeviceId})")"
opacity="0.8">
<animate attributeName="y2"
values="@(-accelYEnd - 5);@(-accelYEnd + 5);@(-accelYEnd - 5)"
dur="1s"
repeatCount="indefinite" />
</line>
<text x="5" y="@(-accelYEnd - 10)" font-size="12" fill="#4caf50" font-weight="bold">Y</text>
<!-- Z Axis (Blue) - represented as depth -->
@{
var accelZ = Math.Max(-50, Math.Min(50, ImuData.LinearAcceleration.Z * 5));
var zOffset = accelZ * 0.5;
}
<line x1="0" y1="0" x2="@zOffset" y2="@(80 + zOffset)"
stroke="#2196f3"
stroke-width="4"
stroke-dasharray="5,5"
marker-end="@($"url(#arrowhead-blue-{DeviceId})")"
filter="@($"url(#glow_{DeviceId})")"
opacity="0.8">
<animate attributeName="x2"
values="@(zOffset - 3);@(zOffset + 3);@(zOffset - 3)"
dur="1s"
repeatCount="indefinite" />
<animate attributeName="y2"
values="@(80 + zOffset - 3);@(80 + zOffset + 3);@(80 + zOffset - 3)"
dur="1s"
repeatCount="indefinite" />
</line>
<text x="@(zOffset + 5)" y="@(80 + zOffset + 15)" font-size="12" fill="#2196f3" font-weight="bold">Z</text>
<!-- Angular velocity indicators (circular arrows) -->
@{
var angularVel = Math.Sqrt(ImuData.AngularVelocity.X * ImuData.AngularVelocity.X +
ImuData.AngularVelocity.Y * ImuData.AngularVelocity.Y +
ImuData.AngularVelocity.Z * ImuData.AngularVelocity.Z);
var angularVelNormalized = Math.Max(0, Math.Min(1, angularVel / 5.0));
var rotationSpeed = angularVelNormalized * 360;
}
<circle cx="0" cy="0" r="60"
fill="none"
stroke="#ff9800"
stroke-width="3"
stroke-dasharray="10,5"
opacity="@(0.3 + angularVelNormalized * 0.5)"
transform="rotate(@rotationSpeed)">
<animateTransform attributeName="transform"
type="rotate"
values="0;360"
dur="@(Math.Max(0.5, 5 - angularVelNormalized * 4.5))s"
repeatCount="indefinite" />
</circle>
<!-- Center point -->
<circle cx="0" cy="0" r="5" fill="currentColor"
style="color: var(--mud-palette-text-primary);" />
</g>
<!-- Orientation text -->
<text x="150" y="280"
text-anchor="middle"
font-size="14"
font-weight="bold"
fill="currentColor"
style="color: var(--mud-palette-text-primary);">
Orientation
</text>
<text x="150" y="295"
text-anchor="middle"
font-size="12"
fill="currentColor"
style="color: var(--mud-palette-text-secondary);">
R:@(QuaternionToEuler(ImuData.Orientation).roll.ToString("F2")) P:@(QuaternionToEuler(ImuData.Orientation).pitch.ToString("F2")) Y:@(QuaternionToEuler(ImuData.Orientation).yaw.ToString("F2"))
</text>
</svg>
</div>
</MudItem>
<!-- Right Column: Data parameters -->
<MudItem xs="12" md="8">
<MudGrid>
<!-- Status Row -->
<MudItem xs="12">
<MudGrid>
<!-- Status fields removed - not available in Imu struct -->
</MudGrid>
</MudItem>
<!-- Acceleration -->
<MudItem xs="12" md="4">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Primary">Acceleration</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">m/s²</MudText>
<MudText Typo="Typo.body1">X: <strong>@ImuData.LinearAcceleration.X.ToString("F3")</strong></MudText>
<MudText Typo="Typo.body1">Y: <strong>@ImuData.LinearAcceleration.Y.ToString("F3")</strong></MudText>
<MudText Typo="Typo.body1">Z: <strong>@ImuData.LinearAcceleration.Z.ToString("F3")</strong></MudText>
</MudCard>
</MudItem>
<!-- Angular Velocity -->
<MudItem xs="12" md="4">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Info">Angular Velocity</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">rad/s</MudText>
<MudText Typo="Typo.body1">Roll (X): <strong>@ImuData.AngularVelocity.X.ToString("F3")</strong></MudText>
<MudText Typo="Typo.body1">Pitch (Y): <strong>@ImuData.AngularVelocity.Y.ToString("F3")</strong></MudText>
<MudText Typo="Typo.body1">Yaw (Z): <strong>@ImuData.AngularVelocity.Z.ToString("F3")</strong></MudText>
</MudCard>
</MudItem>
<!-- Orientation -->
<MudItem xs="12" md="4">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Success">Orientation</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">rad (Euler)</MudText>
<MudText Typo="Typo.body1">Roll: <strong>@QuaternionToEuler(ImuData.Orientation).roll.ToString("F3")</strong></MudText>
<MudText Typo="Typo.body1">Pitch: <strong>@QuaternionToEuler(ImuData.Orientation).pitch.ToString("F3")</strong></MudText>
<MudText Typo="Typo.body1">Yaw: <strong>@QuaternionToEuler(ImuData.Orientation).yaw.ToString("F3")</strong></MudText>
</MudCard>
</MudItem>
<!-- Quaternion -->
<MudItem xs="12" md="4">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Secondary">Quaternion</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">Unit quaternion</MudText>
<MudText Typo="Typo.body1">W: <strong>@ImuData.Orientation.W.ToString("F3")</strong></MudText>
<MudText Typo="Typo.body1">X: <strong>@ImuData.Orientation.X.ToString("F3")</strong></MudText>
<MudText Typo="Typo.body1">Y: <strong>@ImuData.Orientation.Y.ToString("F3")</strong></MudText>
<MudText Typo="Typo.body1">Z: <strong>@ImuData.Orientation.Z.ToString("F3")</strong></MudText>
</MudCard>
</MudItem>
<!-- Last Update Time -->
<MudItem xs="12">
<MudText Typo="Typo.caption" Color="Color.Secondary">
Last Update: @ImuData.Header.Stamp.ToString("yyyy-MM-dd HH:mm:ss")
</MudText>
</MudItem>
<!-- Device Properties -->
@if (DeviceProperties.Any() && PropertyDescriptions.Any())
{
<MudItem xs="12">
<MudExpansionPanels Dense="true" Class="mt-2">
<MudExpansionPanel Text="@($"Device Properties ({DeviceProperties.Count})")"
Icon="@Icons.Material.Filled.Info"
Style="font-size: 0.875rem;">
<MudSimpleTable Dense="true" Hover="true" Striped="true">
<thead>
<tr>
<th style="padding: 8px; width: 30%;">Property</th>
<th style="padding: 8px; width: 70%;">Value</th>
</tr>
</thead>
<tbody>
@foreach (var prop in GetDisplayedProperties())
{
<tr>
<td style="padding: 6px 8px;">
<MudText Typo="Typo.body2" Style="font-weight: 500;">
@prop.DisplayName
</MudText>
@if (!string.IsNullOrWhiteSpace(prop.Description))
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="font-size: 0.7rem;">
@prop.Description
</MudText>
}
</td>
<td style="padding: 6px 8px;">
@if (DeviceProperties.TryGetValue(prop.Key, out var value))
{
<MudText Typo="Typo.body2">
@FormatPropertyValue(value, prop)
</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
}
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudExpansionPanel>
</MudExpansionPanels>
</MudItem>
}
</MudGrid>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<MudOverlay Visible="@IsLoading" Absolute="true">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
</MudOverlay>
@code {
[Parameter, EditorRequired]
public string DeviceId { get; set; } = string.Empty;
private string DeviceName { get; set; } = string.Empty;
private Imu ImuData = new();
private Dictionary<string, string> DeviceProperties = new();
private List<PropertyDescription> PropertyDescriptions = new();
private bool IsLoading => !ImuHubClient.IsConnected;
private bool IsReloading = false;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !string.IsNullOrEmpty(DeviceId))
{
await ConnectAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task ConnectAsync()
{
try
{
await ImuHubClient.StartAsync();
// Lấy DeviceName từ ImuHub
var deviceInfo = await ImuHubClient.GetDeviceInfoAsync(DeviceId);
if (deviceInfo != null)
{
DeviceName = deviceInfo.DeviceName;
}
else
{
DeviceName = DeviceId; // Fallback to DeviceId if not found
}
ImuData = await ImuHubClient.GetImuDataAsync(DeviceId);
// Lấy device properties và property descriptions
var properties = await ImuHubClient.GetDevicePropertiesAsync(DeviceId);
if (properties != null)
{
DeviceProperties = properties;
}
var propDescriptions = await ImuHubClient.GetDevicePropertyDescriptionsAsync(DeviceId);
if (propDescriptions != null)
{
PropertyDescriptions = propDescriptions;
}
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
}
}
private async Task DisconnectAsync()
{
try
{
await ImuHubClient.StopAsync();
ImuData = new Imu(); // Reset về giá trị mặc định
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
}
}
private async Task ReloadImuDataAsync()
{
if (!ImuHubClient.IsConnected || IsReloading)
return;
try
{
IsReloading = true;
StateHasChanged();
ImuData = await ImuHubClient.ReadAllDataAsync(DeviceId);
// Reload properties
var properties = await ImuHubClient.GetDevicePropertiesAsync(DeviceId);
if (properties != null)
{
DeviceProperties = properties;
}
}
catch (Exception ex)
{
Snackbar.Add($"Failed to reload IMU data: {ex.Message}", Severity.Error);
}
finally
{
IsReloading = false;
StateHasChanged();
}
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
}
/// <summary>
/// Convert Quaternion sang Euler angles (Roll, Pitch, Yaw)
/// </summary>
private (double roll, double pitch, double yaw) QuaternionToEuler(QuaternionGeometry q)
{
// Roll (x-axis rotation)
var sinr_cosp = 2 * (q.W * q.X + q.Y * q.Z);
var cosr_cosp = 1 - 2 * (q.X * q.X + q.Y * q.Y);
var roll = Math.Atan2(sinr_cosp, cosr_cosp);
// Pitch (y-axis rotation)
var sinp = 2 * (q.W * q.Y - q.Z * q.X);
double pitch;
if (Math.Abs(sinp) >= 1)
pitch = Math.CopySign(Math.PI / 2, sinp); // use 90 degrees if out of range
else
pitch = Math.Asin(sinp);
// Yaw (z-axis rotation)
var siny_cosp = 2 * (q.W * q.Z + q.X * q.Y);
var cosy_cosp = 1 - 2 * (q.Y * q.Y + q.Z * q.Z);
var yaw = Math.Atan2(siny_cosp, cosy_cosp);
return (roll, pitch, yaw);
}
/// <summary>
/// Lấy danh sách properties đã sắp xếp để hiển thị
/// </summary>
private IEnumerable<PropertyDescription> GetDisplayedProperties()
{
return PropertyDescriptions
.OrderBy(p => p.DisplayOrder)
.ThenBy(p => p.Category ?? "")
.ThenBy(p => p.DisplayName);
}
/// <summary>
/// Format giá trị property theo DataType và Format
/// </summary>
private string FormatPropertyValue(string value, PropertyDescription prop)
{
if (string.IsNullOrWhiteSpace(value))
return "-";
if (prop.DataType == "number" && double.TryParse(value, out var numValue))
{
if (!string.IsNullOrWhiteSpace(prop.Format))
{
try
{
return string.Format(prop.Format, numValue) + (prop.Unit != null ? $" {prop.Unit}" : "");
}
catch
{
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
}
}
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
}
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
}
}

View File

@@ -0,0 +1,524 @@
@using MudBlazor
@using Microsoft.AspNetCore.SignalR.Client
@using RobotNet10.RobotApp.Client.Clients
@using RobotNet10.Shared.Sensor
@implements IAsyncDisposable
@inject LidarHubClient LidarHubClient
@inject ISnackbar Snackbar
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h5">@DeviceName</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudTooltip Text="Toggle auto reload (1s interval)">
<MudIconButton Icon="@(AutoReloadEnabled ? Icons.Material.Filled.PauseCircle : Icons.Material.Filled.PlayCircle)"
Color="@(AutoReloadEnabled ? Color.Success : Color.Default)"
Size="Size.Small"
OnClick="ToggleAutoReloadAsync"
Disabled="@(!LidarHubClient.IsConnected)">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Reload lidar data">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Color="Color.Primary"
Size="Size.Small"
OnClick="ReloadLidarDataAsync"
Disabled="@(!LidarHubClient.IsConnected || IsReloading)">
</MudIconButton>
</MudTooltip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="position: relative;">
<MudGrid>
<!-- Left Column: Lidar Radar Plot SVG -->
<MudItem xs="12" md="8">
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
<svg width="100%" height="100%" viewBox="0 0 @SvgSize @SvgSize" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 100%;">
<defs>
<!-- Gradient cho scan line -->
<radialGradient id="@($"scanGradient_{DeviceId}")" cx="50%" cy="50%">
<stop offset="0%" style="stop-color:#2196f3;stop-opacity:0.8" />
<stop offset="100%" style="stop-color:#2196f3;stop-opacity:0.2" />
</radialGradient>
<!-- Filter cho glow effect -->
<filter id="@($"glow_{DeviceId}")">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background circles (range indicators) -->
@foreach (var radius in RangeIndicatorRadii)
{
<circle cx="@SvgCenter" cy="@SvgCenter" r="@radius" fill="none" stroke="currentColor" stroke-width="1"
opacity="0.2" style="color: var(--mud-palette-text-primary);" />
}
<!-- Center point (Lidar position) -->
<circle cx="@SvgCenter" cy="@SvgCenter" r="5" fill="#2196f3" />
<circle cx="@SvgCenter" cy="@SvgCenter" r="8" fill="#2196f3" opacity="0.3">
<animate attributeName="r" values="8;15;8" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.3;0;0.3" dur="2s" repeatCount="indefinite" />
</circle>
<!-- Grid lines (every 30 degrees) -->
@foreach (var gridLine in GridLines)
{
<line x1="@SvgCenter" y1="@SvgCenter" x2="@gridLine.X" y2="@gridLine.Y"
stroke="currentColor"
stroke-width="0.5"
opacity="0.1"
style="color: var(--mud-palette-text-primary);" />
}
<!-- Scan points và biên dạng -->
@if (ValidScanPoints.Count > 0)
{
var pathData = BuildScanPath();
<!-- Biên dạng (outline) - nối các điểm scan với đường mỏng -->
<path d="@pathData"
fill="none"
stroke="#2196f3"
stroke-width="1"
opacity="0.8"
filter="@($"url(#glow_{DeviceId})")" />
<!-- Fill area bên trong biên dạng -->
<path d="@pathData"
fill="url(@($"#scanGradient_{DeviceId}"))"
opacity="0.3" />
}
</svg>
</div>
</MudItem>
<!-- Right Column: Data parameters -->
<MudItem xs="12" md="4">
<MudGrid>
<!-- Status -->
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Primary">Status</MudText>
<MudText Typo="Typo.body1" Class="mt-2">
Points: <strong>@LidarData.Ranges.Length</strong>
</MudText>
@if (LidarData.ScanTime > 0)
{
<MudText Typo="Typo.body1">
Frequency: <strong>@((1.0 / LidarData.ScanTime).ToString("F1")) Hz</strong>
</MudText>
}
@if (LidarData.AngleIncrement > 0)
{
<MudText Typo="Typo.body1">
Resolution: <strong>@FormatAngleDegreesPrecise(LidarData.AngleIncrement)°</strong>
</MudText>
}
</MudCard>
</MudItem>
<!-- Device Specifications -->
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Success">Specifications</MudText>
<MudText Typo="Typo.body1" Class="mt-2">
Angle Range: <strong>@FormatAngleDegrees(LidarData.AngleMin)° to @FormatAngleDegrees(LidarData.AngleMax)°</strong>
</MudText>
<MudText Typo="Typo.body1">
FOV: <strong>@FormatAngleDegrees(LidarData.AngleMax - LidarData.AngleMin)°</strong>
</MudText>
<MudText Typo="Typo.body1">
Range: <strong>@LidarData.RangeMin.ToString("F2") m to @LidarData.RangeMax.ToString("F2") m</strong>
</MudText>
<MudText Typo="Typo.body1">
Intensity: <strong>@(LidarData.Intensities != null && LidarData.Intensities.Length > 0 ? "Supported" : "Not Supported")</strong>
</MudText>
</MudCard>
</MudItem>
<!-- Statistics -->
@if (Statistics != null)
{
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Info">Statistics</MudText>
<MudText Typo="Typo.body1" Class="mt-2">
Avg Distance: <strong>@Statistics.AvgDistance.ToString("F2") m</strong>
</MudText>
<MudText Typo="Typo.body1">
Min Distance: <strong>@Statistics.MinDistance.ToString("F2") m</strong>
</MudText>
<MudText Typo="Typo.body1">
Max Distance: <strong>@Statistics.MaxDistance.ToString("F2") m</strong>
</MudText>
<MudText Typo="Typo.body1">
Avg Intensity: <strong>@Statistics.AvgIntensity.ToString("F1")</strong>
</MudText>
</MudCard>
</MudItem>
}
<!-- Last Update Time -->
<MudItem xs="12">
<MudText Typo="Typo.caption" Color="Color.Secondary">
Last Update: @LidarData.Header.Stamp.ToString("yyyy-MM-dd HH:mm:ss.fff")
</MudText>
</MudItem>
</MudGrid>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<MudOverlay Visible="@IsLoading" Absolute="true">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
</MudOverlay>
@code {
#region Constants
private const int SvgSize = 400;
private const double SvgCenter = 200.0;
private const double SvgMaxRadius = 150.0;
private const double DefaultMaxDistanceM = 10.0;
private const int GridLineStepDegrees = 30;
private static readonly int[] RangeIndicatorRadii = { 150, 100, 50 };
#endregion
#region Parameters
[Parameter, EditorRequired]
public string DeviceId { get; set; } = string.Empty;
#endregion
#region Private Fields
private string DeviceName { get; set; } = string.Empty;
private LaserScan LidarData = new();
private bool IsReloading = false;
private bool AutoReloadEnabled = false;
private System.Threading.PeriodicTimer? _autoReloadTimer;
private readonly System.Threading.CancellationTokenSource _cancellationTokenSource = new();
#endregion
#region Computed Properties
private bool IsLoading => !LidarHubClient.IsConnected;
/// <summary>
/// Tính AngleIncrement thực tế dựa trên số lượng Ranges để hiển thị đúng
/// </summary>
private double ActualAngleIncrement
{
get
{
if (LidarData.Ranges == null || LidarData.Ranges.Length == 0)
return LidarData.AngleIncrement;
var angleSpan = LidarData.AngleMax - LidarData.AngleMin;
if (angleSpan <= 0 || LidarData.Ranges.Length <= 1)
return LidarData.AngleIncrement;
// Tính AngleIncrement thực tế dựa trên số lượng điểm
return angleSpan / (LidarData.Ranges.Length - 1);
}
}
private IReadOnlyList<ScanPoint> ValidScanPoints
{
get
{
if (LidarData.Ranges == null || LidarData.Ranges.Length == 0)
return Array.Empty<ScanPoint>();
var points = new List<ScanPoint>();
// Sử dụng ActualAngleIncrement để tính góc đúng cho hiển thị
var angleIncrement = ActualAngleIncrement;
for (int i = 0; i < LidarData.Ranges.Length; i++)
{
var range = LidarData.Ranges[i];
var angle = LidarData.AngleMin + i * angleIncrement;
var isValid = !double.IsNaN(range) && !double.IsInfinity(range) &&
range >= LidarData.RangeMin && range <= LidarData.RangeMax;
var intensity = LidarData.Intensities != null && i < LidarData.Intensities.Length
? LidarData.Intensities[i] : 0.0;
if (isValid)
{
points.Add(new ScanPoint
{
AngleRad = angle,
AngleDeg = angle * 180.0 / Math.PI,
DistanceM = range,
Intensity = intensity
});
}
}
return points.OrderBy(p => p.AngleDeg).ToList();
}
}
private double MaxDistanceM
{
get
{
var validPoints = ValidScanPoints;
if (validPoints.Count == 0)
return DefaultMaxDistanceM;
var max = validPoints.Max(p => p.DistanceM);
return max > 0 ? max : DefaultMaxDistanceM;
}
}
private double Scale => SvgMaxRadius / MaxDistanceM;
private ScanStatistics? Statistics
{
get
{
var validPoints = ValidScanPoints;
if (validPoints.Count == 0)
return null;
return new ScanStatistics
{
AvgDistance = validPoints.Average(p => p.DistanceM),
MinDistance = validPoints.Min(p => p.DistanceM),
MaxDistance = validPoints.Max(p => p.DistanceM),
AvgIntensity = validPoints.Average(p => p.Intensity)
};
}
}
private List<(double X, double Y)> GridLines
{
get
{
var lines = new List<(double X, double Y)>();
for (int angle = 0; angle < 360; angle += GridLineStepDegrees)
{
var rad = angle * Math.PI / 180.0;
var x = SvgCenter + Math.Cos(rad) * SvgMaxRadius;
var y = SvgCenter + Math.Sin(rad) * SvgMaxRadius;
lines.Add((x, y));
}
return lines;
}
}
#endregion
#region Lifecycle Methods
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !string.IsNullOrEmpty(DeviceId))
{
await ConnectAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
public async ValueTask DisposeAsync()
{
await StopAutoReloadAsync();
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
await DisconnectAsync();
}
#endregion
#region Connection Methods
private async Task ConnectAsync()
{
try
{
await LidarHubClient.StartAsync();
var deviceInfo = await LidarHubClient.GetDeviceInfoAsync(DeviceId);
DeviceName = deviceInfo?.DeviceName ?? DeviceId;
LidarData = await LidarHubClient.GetLidarDataAsync(DeviceId);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to connect xx: {ex.Message}", Severity.Error);
}
}
private async Task DisconnectAsync()
{
try
{
await LidarHubClient.StopAsync();
LidarData = new LaserScan();
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
}
}
private async Task ReloadLidarDataAsync()
{
if (!LidarHubClient.IsConnected || IsReloading)
return;
try
{
IsReloading = true;
StateHasChanged();
await InvokeAsync(async () =>
{
LidarData = await LidarHubClient.GetLidarDataAsync(DeviceId);
StateHasChanged();
IsReloading = false;
});
}
catch (Exception ex)
{
Snackbar.Add($"Failed to reload lidar data: {ex.Message}", Severity.Error);
IsReloading = false;
}
}
private async Task ToggleAutoReloadAsync()
{
if (AutoReloadEnabled)
{
await StopAutoReloadAsync();
}
else
{
await StartAutoReloadAsync();
}
}
private async Task StartAutoReloadAsync()
{
if (AutoReloadEnabled || !LidarHubClient.IsConnected)
return;
AutoReloadEnabled = true;
_autoReloadTimer = new System.Threading.PeriodicTimer(TimeSpan.FromSeconds(1));
_ = Task.Run(async () =>
{
try
{
while (await _autoReloadTimer.WaitForNextTickAsync(_cancellationTokenSource.Token))
{
if (!_cancellationTokenSource.Token.IsCancellationRequested && LidarHubClient.IsConnected)
{
await InvokeAsync(async () =>
{
await ReloadLidarDataAsync();
});
}
else
{
break;
}
}
}
catch (System.OperationCanceledException)
{
// Expected when cancelling
}
});
StateHasChanged();
}
private async Task StopAutoReloadAsync()
{
if (!AutoReloadEnabled)
return;
AutoReloadEnabled = false;
if (_autoReloadTimer != null)
{
_autoReloadTimer.Dispose();
_autoReloadTimer = null;
}
StateHasChanged();
}
#endregion
#region Helper Methods
private string BuildScanPath()
{
var validPoints = ValidScanPoints;
if (validPoints.Count == 0)
return string.Empty;
var pathBuilder = new System.Text.StringBuilder();
// Start from center
pathBuilder.Append($"M {SvgCenter:F2} {SvgCenter:F2}");
// Connect to all scan points
foreach (var point in validPoints)
{
var (x, y) = ConvertAngleToSvgCoordinates(point.AngleRad, point.DistanceM);
pathBuilder.Append($" L {x:F2} {y:F2}");
}
// Close path back to center
pathBuilder.Append($" L {SvgCenter:F2} {SvgCenter:F2}");
pathBuilder.Append(" Z");
return pathBuilder.ToString();
}
private (double x, double y) ConvertAngleToSvgCoordinates(double angleRad, double distanceM)
{
// Convert from SICK coordinate system to SVG coordinate system
// SICK: 0° = right, increases counter-clockwise
// SVG: 0° = up, increases clockwise
// Formula: x = center + cos(angle) * distance, y = center - sin(angle) * distance
var scaledDistance = distanceM * Scale;
var x = SvgCenter + Math.Cos(angleRad) * scaledDistance;
var y = SvgCenter - Math.Sin(angleRad) * scaledDistance;
return (x, y);
}
private string FormatAngleDegrees(double angleRad) => (angleRad * 180.0 / Math.PI).ToString("F1");
private string FormatAngleDegreesPrecise(double angleRad) => (angleRad * 180.0 / Math.PI).ToString("F3");
#endregion
#region Helper Classes
private class ScanPoint
{
public double AngleRad { get; set; }
public double AngleDeg { get; set; }
public double DistanceM { get; set; }
public double Intensity { get; set; }
}
#endregion
#region Helper Classes
private class ScanStatistics
{
public double AvgDistance { get; set; }
public double MinDistance { get; set; }
public double MaxDistance { get; set; }
public double AvgIntensity { get; set; }
}
#endregion
}

View File

@@ -0,0 +1,412 @@
@using MudBlazor
@using Microsoft.AspNetCore.SignalR.Client
@using RobotNet10.RobotApp.Client.Clients
@using RobotNet10.RobotApp.Client.Shared.Devices
@implements IAsyncDisposable
@inject ModbusTcpHubClient ModbusTcpHubClient
@inject ISnackbar Snackbar
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h5">@DeviceName</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudTooltip Text="Reload Modbus data">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Color="Color.Primary"
Size="Size.Small"
OnClick="ReloadModbusDataAsync"
Disabled="@(!ModbusTcpHubClient.IsConnected || IsReloading)">
</MudIconButton>
</MudTooltip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="position: relative;">
<MudGrid>
<!-- Connection Info -->
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-4">
<MudGrid>
<MudItem xs="12" sm="3">
<MudText Typo="Typo.body1">
<strong>IP:</strong> @ModbusData.IpAddress
</MudText>
</MudItem>
<MudItem xs="12" sm="3">
<MudText Typo="Typo.body1">
<strong>Port:</strong> @ModbusData.Port
</MudText>
</MudItem>
<MudItem xs="12" sm="3">
<MudText Typo="Typo.body1">
<strong>Slave ID:</strong> @ModbusData.SlaveId
</MudText>
</MudItem>
<MudItem xs="12" sm="3">
<MudChip T="string" Color="@(ModbusData.IsConnected ? Color.Success : Color.Error)"
Size="Size.Small"
Variant="Variant.Filled">
@(ModbusData.IsConnected ? "Connected" : "Disconnected")
</MudChip>
</MudItem>
</MudGrid>
</MudCard>
</MudItem>
<!-- Holding Registers -->
@if (ModbusData.HoldingRegisters != null && ModbusData.HoldingRegisters.Length > 0)
{
@foreach (var range in ModbusData.HoldingRegisters)
{
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Primary">
Holding Registers: @range.Name
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity registers)
</MudText>
<MudTable Items="@range.Values" Hover="true" Dense="true" Striped="true">
<HeaderContent>
<MudTh>Address</MudTh>
<MudTh>Index</MudTh>
<MudTh>Name</MudTh>
<MudTh>Value (Decimal)</MudTh>
<MudTh>Value (Hex)</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Address">@context.Address</MudTd>
<MudTd DataLabel="Index">@context.Index</MudTd>
<MudTd DataLabel="Name">
@if (!string.IsNullOrEmpty(context.Name))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
@context.Name
</MudChip>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
}
</MudTd>
<MudTd DataLabel="Value (Decimal)">
<strong>@context.Value</strong>
</MudTd>
<MudTd DataLabel="Value (Hex)">
<MudText Typo="Typo.body2" Color="Color.Secondary">0x@(context.Value.ToString("X4"))</MudText>
</MudTd>
</RowTemplate>
</MudTable>
</MudCard>
</MudItem>
}
}
<!-- Input Registers -->
@if (ModbusData.InputRegisters != null && ModbusData.InputRegisters.Length > 0)
{
@foreach (var range in ModbusData.InputRegisters)
{
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Info">
Input Registers: @range.Name
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity registers)
</MudText>
<MudTable Items="@range.Values" Hover="true" Dense="true" Striped="true">
<HeaderContent>
<MudTh>Address</MudTh>
<MudTh>Index</MudTh>
<MudTh>Name</MudTh>
<MudTh>Value (Decimal)</MudTh>
<MudTh>Value (Hex)</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Address">@context.Address</MudTd>
<MudTd DataLabel="Index">@context.Index</MudTd>
<MudTd DataLabel="Name">
@if (!string.IsNullOrEmpty(context.Name))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
@context.Name
</MudChip>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
}
</MudTd>
<MudTd DataLabel="Value (Decimal)">
<strong>@context.Value</strong>
</MudTd>
<MudTd DataLabel="Value (Hex)">
<MudText Typo="Typo.body2" Color="Color.Secondary">0x@(context.Value.ToString("X4"))</MudText>
</MudTd>
</RowTemplate>
</MudTable>
</MudCard>
</MudItem>
}
}
<!-- Coils -->
@if (ModbusData.Coils != null && ModbusData.Coils.Length > 0)
{
@foreach (var range in ModbusData.Coils)
{
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Success">
Coils: @range.Name
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity coils)
</MudText>
<MudTable Items="@range.BoolValues" Hover="true" Dense="true" Striped="true">
<HeaderContent>
<MudTh>Address</MudTh>
<MudTh>Index</MudTh>
<MudTh>Name</MudTh>
<MudTh>Value</MudTh>
<MudTh>Action</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Address">@context.Address</MudTd>
<MudTd DataLabel="Index">@context.Index</MudTd>
<MudTd DataLabel="Name">
@if (!string.IsNullOrEmpty(context.Name))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
@context.Name
</MudChip>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
}
</MudTd>
<MudTd DataLabel="Value">
<MudChip T="string" Size="Size.Small"
Variant="Variant.Filled"
Color="@(context.Value ? Color.Success : Color.Default)">
@(context.Value ? "ON" : "OFF")
</MudChip>
</MudTd>
<MudTd DataLabel="Action">
@{
var coilKey = $"{range.StartAddress}_{context.Index}";
var isWriting = WritingCoils.ContainsKey(coilKey) && WritingCoils[coilKey];
}
<MudSwitch Value="@context.Value"
Disabled="@(!ModbusTcpHubClient.IsConnected || isWriting)"
Color="Color.Success"
Size="Size.Small"
ValueChanged="@((bool value) => HandleCoilToggle(context.Address, value, coilKey))">
</MudSwitch>
@if (isWriting)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="ml-2" />
}
</MudTd>
</RowTemplate>
</MudTable>
</MudCard>
</MudItem>
}
}
<!-- Discrete Inputs -->
@if (ModbusData.DiscreteInputs != null && ModbusData.DiscreteInputs.Length > 0)
{
@foreach (var range in ModbusData.DiscreteInputs)
{
<MudItem xs="12">
<MudCard Elevation="0" Class="pa-4">
<MudText Typo="Typo.h6" Color="Color.Warning">
Discrete Inputs: @range.Name
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity inputs)
</MudText>
<MudTable Items="@range.BoolValues" Hover="true" Dense="true" Striped="true">
<HeaderContent>
<MudTh>Address</MudTh>
<MudTh>Index</MudTh>
<MudTh>Name</MudTh>
<MudTh>Value</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Address">@context.Address</MudTd>
<MudTd DataLabel="Index">@context.Index</MudTd>
<MudTd DataLabel="Name">
@if (!string.IsNullOrEmpty(context.Name))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
@context.Name
</MudChip>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
}
</MudTd>
<MudTd DataLabel="Value">
<MudChip T="string" Size="Size.Small"
Variant="Variant.Filled"
Color="@(context.Value ? Color.Success : Color.Default)">
@(context.Value ? "ON" : "OFF")
</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudCard>
</MudItem>
}
}
</MudGrid>
</MudCardContent>
</MudCard>
<MudOverlay Visible="@IsLoading" Absolute="true">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
</MudOverlay>
@code {
[Parameter, EditorRequired]
public string DeviceId { get; set; } = string.Empty;
private string DeviceName { get; set; } = string.Empty;
private ModbusTcpData ModbusData = new();
private bool IsLoading => !ModbusTcpHubClient.IsConnected;
private bool IsReloading = false;
private Dictionary<string, bool> WritingCoils = new();
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !string.IsNullOrEmpty(DeviceId))
{
await ConnectAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task ConnectAsync()
{
try
{
await ModbusTcpHubClient.StartAsync();
// Lấy DeviceName từ ModbusTcpHub
var deviceInfo = await ModbusTcpHubClient.GetDeviceInfoAsync(DeviceId);
if (deviceInfo != null)
{
DeviceName = deviceInfo.DeviceName;
}
else
{
DeviceName = DeviceId; // Fallback to DeviceId if not found
}
ModbusData = await ModbusTcpHubClient.GetModbusDataAsync(DeviceId);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
}
}
private async Task DisconnectAsync()
{
try
{
await ModbusTcpHubClient.StopAsync();
ModbusData = new ModbusTcpData(); // Reset về giá trị mặc định
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
}
}
private async Task ReloadModbusDataAsync()
{
if (!ModbusTcpHubClient.IsConnected || IsReloading)
return;
try
{
IsReloading = true;
StateHasChanged();
ModbusData = await ModbusTcpHubClient.GetModbusDataAsync(DeviceId);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to reload Modbus data: {ex.Message}", Severity.Error);
}
finally
{
IsReloading = false;
StateHasChanged();
}
}
private async Task HandleCoilToggle(ushort address, bool value, string coilKey)
{
if (!ModbusTcpHubClient.IsConnected)
{
Snackbar.Add("Not connected to Modbus device", Severity.Warning);
return;
}
// Set writing state
WritingCoils[coilKey] = true;
StateHasChanged();
try
{
var success = await ModbusTcpHubClient.WriteCoilAsync(DeviceId, address, value);
if (success)
{
Snackbar.Add($"Coil {address} set to {(value ? "ON" : "OFF")}", Severity.Success);
// Reload data để cập nhật giá trị mới nhất
await ReloadModbusDataAsync();
}
else
{
Snackbar.Add($"Failed to write coil {address}", Severity.Error);
// Reload để khôi phục giá trị cũ
await ReloadModbusDataAsync();
}
}
catch (Exception ex)
{
Snackbar.Add($"Error writing coil {address}: {ex.Message}", Severity.Error);
// Reload để khôi phục giá trị cũ
await ReloadModbusDataAsync();
}
finally
{
WritingCoils[coilKey] = false;
StateHasChanged();
}
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
}
}

View File

@@ -0,0 +1,359 @@
@implements IAsyncDisposable
@using MudBlazor
@using RobotNet10.RobotApp.Client.Clients
@using RobotNet10.RobotApp.Client.Shared.Devices
@inject RfHandleHubClient RfHandleHubClient
@inject ISnackbar Snackbar
<MudCard Class="pa-0">
<MudCardHeader Class="pb-0">
<CardHeaderContent>
<MudText Typo="Typo.h5">@DeviceName</MudText>
<MudText Typo="Typo.caption">@DeviceId</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudTooltip Text="Toggle auto reload (1s interval)">
<MudIconButton Icon="@(AutoReloadEnabled? Icons.Material.Filled.PauseCircle : Icons.Material.Filled.PlayCircle)"
Color="@(AutoReloadEnabled ? Color.Success : Color.Default)"
Size="Size.Small"
OnClick="ToggleAutoReloadAsync"
Disabled="@(!RfHandleHubClient.IsConnected)">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Reload RfHandle data">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Size="Size.Small"
Color="Color.Primary"
Disabled="@(!RfHandleHubClient.IsConnected || IsReloading)"
OnClick="ReloadRfHandleDataAsync" />
</MudTooltip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Class="pa-3">
<MudGrid>
<!-- LEFT PANEL (NO JOYSTICK FOR YNZDH) -->
<MudItem xs="12" md="5">
<MudPaper Elevation="1" Class="pa-3 d-flex flex-column">
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Joystick</MudText>
<MudItem xs="12">
<!-- ================= LEFT: JOYSTICK ================= -->
<svg width="400" height="400" viewBox="0 0 220 220">
<!-- Background -->
<circle cx="110" cy="110" r="95" fill="#2e2e2e" />
<!-- Deadzone -->
<circle cx="110" cy="110" r="25" fill="#3f3f3f" />
<!-- Axis cross -->
<line x1="110" y1="15" x2="110" y2="205"
stroke="#444" stroke-width="2" />
<line x1="15" y1="110" x2="205" y2="110"
stroke="#444" stroke-width="2" />
<!-- Joystick knob -->
<circle cx="@JoyX"
cy="@JoyY"
r="28"
fill="@JoyColor"
stroke="#111"
stroke-width="3" />
</svg>
<!-- ================= RIGHT: AXES ================= -->
<!-- LINEAR -->
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
Linear
</MudText>
<MudProgressLinear Value="@AxisToPercent(RfHandleData.Linear)"
Color="Color.Info"
Class="mb-1" />
<MudText Typo="Typo.caption" Class="mb-3">
@AxisText(RfHandleData.Linear)
</MudText>
<!-- ANGULAR -->
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
Angular
</MudText>
<MudProgressLinear Value="@AxisToPercent(RfHandleData.Angular)"
Color="Color.Info"
Class="mb-1" />
<MudText Typo="Typo.caption">
@AxisText(RfHandleData.Angular)
</MudText>
</MudItem>
</MudPaper>
</MudItem>
<!-- RIGHT PANELS -->
<MudItem xs="12" md="7">
<MudGrid Spacing="2">
<!-- STATUS PANEL -->
<MudItem xs="12">
<MudPaper Elevation="1" Class="pa-2">
<MudText Typo="Typo.subtitle2" Color="Color.Info">Status</MudText>
<MudStack Row="true" Spacing="2" Class="mt-2">
<MudChip T="string" Color="@(RfHandleData.Heartbeat != 0 ? Color.Success : Color.Default)"
Variant="@(RfHandleData.Heartbeat != 0 ? Variant.Filled : Variant.Outlined)"
Size="Size.Small">Heartbeat</MudChip>
<MudChip T="string" Color="@(RfHandleData.RemoteReady? Color.Warning: Color.Default)"
Variant="@(RfHandleData.RemoteReady ? Variant.Filled : Variant.Outlined)"
Size="Size.Small">RemoteReady</MudChip>
<MudChip T="string" Color="@(RfHandleData.EStop? Color.Error: Color.Default)"
Variant="@(RfHandleData.EStop ? Variant.Filled : Variant.Outlined)"
Size="Size.Small">EStop</MudChip>
</MudStack>
<MudText Class="mt-2">
<b>Last Update:</b> @RfHandleData.LastUpdateTime.ToString("HH:mm:ss")
</MudText>
</MudPaper>
</MudItem>
<!-- SPEED PANEL -->
<MudItem xs="12">
<MudPaper Elevation="1" Class="pa-2">
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Speed</MudText>
<MudProgressLinear Value="@RfHandleData.Speed" Color="Color.Info" Class="mt-2" />
<MudText Typo="Typo.caption">Speed: @RfHandleData.Speed%</MudText>
</MudPaper>
</MudItem>
<!-- MOTION PANEL -->
<MudItem xs="12">
<MudPaper Elevation="1" Class="pa-2">
<MudText Typo="Typo.subtitle2" Color="Color.Warning">Motion</MudText>
<MudStack Row="true" Spacing="2" Class="mt-2">
<MudChip T="string" Color="@(RfHandleData.LiftUp? Color.Info: Color.Default)"
Variant="@(RfHandleData.LiftUp ? Variant.Filled : Variant.Outlined)"
Size="Size.Small">LiftUp</MudChip>
<MudChip T="string" Color="@(RfHandleData.LiftDown? Color.Info: Color.Default)"
Variant="@(RfHandleData.LiftDown ? Variant.Filled : Variant.Outlined)"
Size="Size.Small">LiftDown</MudChip>
<MudChip T="string" Color="@(RfHandleData.RotateLeft? Color.Secondary: Color.Default)"
Variant="@(RfHandleData.RotateLeft ? Variant.Filled : Variant.Outlined)"
Size="Size.Small">Rot L</MudChip>
<MudChip T="string" Color="@(RfHandleData.RotateRight? Color.Secondary: Color.Default)"
Variant="@(RfHandleData.RotateRight ? Variant.Filled : Variant.Outlined)"
Size="Size.Small">Rot R</MudChip>
</MudStack>
</MudPaper>
</MudItem>
<!-- MODE PANEL -->
<MudItem xs="12">
<MudPaper Elevation="1" Class="pa-2">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Mode & Flags</MudText>
<MudStack Row="true" Spacing="2" Class="mt-2">
<MudChip T="string" Color="Color.Info" Variant="Variant.Filled">
Mode: @RfHandleData.Mode
</MudChip>
<MudChip T="string" Color="@(RfHandleData.ModeSelect? Color.Warning: Color.Default)"
Variant="@(RfHandleData.ModeSelect ? Variant.Filled : Variant.Outlined)"
Size="Size.Small">ModeSelect</MudChip>
<MudChip T="string" Color="@(RfHandleData.Enable? Color.Success: Color.Default)"
Variant="@(RfHandleData.Enable ? Variant.Filled : Variant.Outlined)"
Size="Size.Small">Enable</MudChip>
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<MudOverlay Visible="@IsLoading" Absolute="true">
<MudProgressCircular Indeterminate="true" />
</MudOverlay>
@code {
// ===== Joystick visual helpers =====
private const double JoyRadius = 70f; // px
private const double JoyCenter = 110f;
private double SafeLinear =>
(!RfHandleData.RemoteReady || RfHandleData.EStop)
? 0
: Math.Clamp(RfHandleData.Linear, -1, 1);
private double SafeAngular =>
(!RfHandleData.RemoteReady || RfHandleData.EStop)
? 0
: Math.Clamp(RfHandleData.Angular, -1, 1);
private double JoyX =>
JoyCenter + SafeAngular * JoyRadius;
private double JoyY =>
JoyCenter - SafeLinear * JoyRadius;
private string JoyColor =>
(!RfHandleData.RemoteReady || RfHandleData.EStop)
? "#666"
: "#bdbdbd";
private RfHandleDataDto RfHandleData = new();
private bool IsReloading = false;
private bool AutoReloadEnabled = false;
private string DeviceName = "";
private System.Threading.PeriodicTimer? _autoReloadTimer;
private readonly System.Threading.CancellationTokenSource _cancellationTokenSource = new();
[Parameter] public string DeviceId { get; set; } = "";
private bool IsLoading => !RfHandleHubClient.IsConnected;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
return;
try
{
await RfHandleHubClient.StartAsync();
var info = await RfHandleHubClient.GetDeviceInfoAsync(DeviceId);
DeviceName = info?.DeviceName ?? DeviceId;
RfHandleData = await RfHandleHubClient.GetRfHandleDataAsync(DeviceId);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Connect failed: {ex.Message}", Severity.Error);
}
}
private async Task ReloadRfHandleDataAsync()
{
if (!RfHandleHubClient.IsConnected || IsReloading)
return;
try
{
IsReloading = true;
StateHasChanged();
RfHandleData = await RfHandleHubClient.GetRfHandleDataAsync(DeviceId);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to reload RfHandle data: {ex.Message}", Severity.Error);
}
finally
{
IsReloading = false;
StateHasChanged();
}
}
private async Task ToggleAutoReloadAsync()
{
if (AutoReloadEnabled)
{
await StopAutoReloadAsync();
}
else
{
await StartAutoReloadAsync();
}
}
private async Task StartAutoReloadAsync()
{
if (AutoReloadEnabled || !RfHandleHubClient.IsConnected)
return;
AutoReloadEnabled = true;
_autoReloadTimer = new System.Threading.PeriodicTimer(TimeSpan.FromSeconds(0.05));
_ = Task.Run(async () =>
{
try
{
while (await _autoReloadTimer.WaitForNextTickAsync(_cancellationTokenSource.Token))
{
if (!_cancellationTokenSource.Token.IsCancellationRequested && RfHandleHubClient.IsConnected)
{
await InvokeAsync(async () =>
{
await ReloadRfHandleDataAsync();
});
}
else
{
break;
}
}
}
catch (System.OperationCanceledException)
{
// Expected when cancelling
}
});
StateHasChanged();
}
private async Task StopAutoReloadAsync()
{
if (!AutoReloadEnabled)
return;
AutoReloadEnabled = false;
if (_autoReloadTimer != null)
{
_autoReloadTimer.Dispose();
_autoReloadTimer = null;
}
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
await StopAutoReloadAsync();
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
try
{
await RfHandleHubClient.StopAsync();
}
catch { }
}
private int AxisToPercent(double v)
{
// v ∈ [-1, +1] → [0, 100]
return (int)Math.Clamp((v + 1) * 50f, 0, 100f);
}
private string AxisText(double v)
{
return v.ToString("F2");
}
}

View File

@@ -0,0 +1,337 @@
@using RobotNet10.RobotApp.Client.Clients
@using RobotNet10.RobotApp.Client.Shared.Modules
@using MudBlazor
<MudPaper Class="pa-4" Style="height: 100%;">
<MudStack Spacing="3">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">Lift Module</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Color="Color.Primary"
Size="Size.Small"
OnClick="RefreshStatus"
Disabled="@(!IsHubReady || isLoading)" />
</MudStack>
@* Lift Status Information *@
@if (liftStatus != null)
{
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h6" Class="mb-3">Module Status</MudText>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>State:</MudText>
<MudChip T="string" Color="@GetStateColor(liftStatus.State)" Size="Size.Small">
@liftStatus.State
</MudChip>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>Ready:</MudText>
<MudChip T="bool" Color="@(liftStatus.IsReady ? Color.Success : Color.Default)" Size="Size.Small">
@liftStatus.IsReady
</MudChip>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>Current Position:</MudText>
<MudText>
@($"{liftStatus.CurrentPosition:N0} counts")
(@($"{ConvertCountsToMeters(liftStatus.CurrentPosition):F3} m"))
</MudText>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
}
@* Lift Control Buttons *@
<MudStack Spacing="3">
<MudText Typo="Typo.h6">Homing & Movement Controls</MudText>
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="LiftHome"
Disabled="@(isLoading || !IsHubReady)">
<MudIcon Icon="@Icons.Material.Filled.HomeRepairService" Class="mr-2" />
<MudText>Homing</MudText>
</MudButton>
</MudStack>
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
<MudButton Variant="Variant.Filled"
Color="Color.Success"
OnClick="LiftUp"
Disabled="@(isLoading || !IsHubReady || liftStatus?.IsReady != true)">
<MudIcon Icon="@Icons.Material.Filled.ArrowUpward" Class="mr-2" />
<MudText>Up</MudText>
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Error"
OnClick="LiftDown"
Disabled="@(isLoading || !IsHubReady || liftStatus?.IsReady != true)">
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Class="mr-2" />
<MudText>Down</MudText>
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Warning"
OnClick="LiftStop"
Disabled="@(!IsHubReady || liftStatus?.IsReady != true || liftStatus?.State != "Moving")">
<MudIcon Icon="@Icons.Material.Filled.Stop" Class="mr-2" />
<MudText>Stop</MudText>
</MudButton>
</MudStack>
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
<MudNumericField @bind-Value="targetHeightMeters"
Label="Target Height (m)"
Variant="Variant.Outlined"
Min="0"
Class="flex-grow-1" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="MoveToPosition"
Disabled="@(isLoading || !IsHubReady || liftStatus?.IsReady != true)">
<MudIcon Icon="@Icons.Material.Filled.LocationOn" Class="mr-2" />
<MudText>Go</MudText>
</MudButton>
</MudStack>
<MudText Typo="Typo.caption">
Scale: 10,000 counts = 0.01 m (1,000,000 counts = 1 m)
</MudText>
</MudStack>
</MudStack>
</MudPaper>
@code {
[Parameter, EditorRequired] public MotionHubClient HubClient { get; set; } = null!;
[Parameter] public bool IsHubReady { get; set; }
[Inject] private ISnackbar Snackbar { get; set; } = null!;
[Inject] private CiA402ServoHubClient ServoHubClient { get; set; } = null!;
private LiftModuleStatusDto? liftStatus;
private bool isLoading = false;
private double targetHeightMeters = 0.0;
private bool _previousIsHubReady = false;
protected override async Task OnParametersSetAsync()
{
// Detect when IsHubReady changes from false to true
if (IsHubReady && !_previousIsHubReady)
{
await RefreshStatus();
}
_previousIsHubReady = IsHubReady;
// Đảm bảo kết nối tới CiA402ServoHub để homing trực tiếp
if (!ServoHubClient.IsConnected)
{
try
{
await ServoHubClient.StartAsync();
}
catch
{
// Nếu connect lỗi, homing sẽ báo lỗi qua Snackbar
}
}
}
public async Task RefreshStatus()
{
if (isLoading || !IsHubReady) return;
try
{
isLoading = true;
liftStatus = await HubClient.GetLiftStatusAsync();
if (liftStatus != null)
{
targetHeightMeters = ConvertCountsToMeters(liftStatus.CurrentPosition);
}
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Error refreshing lift status: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
}
}
private Color GetStateColor(string state)
{
return state switch
{
"Ready" => Color.Success,
"Moving" => Color.Info,
"Error" => Color.Error,
"Homing" => Color.Warning,
"Initializing" => Color.Warning,
_ => Color.Default
};
}
private async Task LiftUp()
{
if (!IsHubReady || isLoading || liftStatus?.IsReady != true)
return;
try
{
isLoading = true;
Snackbar.Add("Lifting up...", Severity.Info);
await HubClient.LiftUpAsync();
Snackbar.Add("Lift up command sent successfully", Severity.Success);
await Task.Delay(500);
await RefreshStatus();
}
catch (Exception ex)
{
Snackbar.Add($"Error lifting up: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task LiftDown()
{
if (!IsHubReady || isLoading || liftStatus?.IsReady != true)
return;
try
{
isLoading = true;
Snackbar.Add("Lifting down...", Severity.Info);
await HubClient.LiftDownAsync();
Snackbar.Add("Lift down command sent successfully", Severity.Success);
await Task.Delay(500);
await RefreshStatus();
}
catch (Exception ex)
{
Snackbar.Add($"Error lifting down: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task LiftStop()
{
if (!IsHubReady || liftStatus?.IsReady != true)
return;
try
{
await HubClient.LiftStopAsync();
Snackbar.Add("Lift stop command sent", Severity.Info);
await Task.Delay(300);
await RefreshStatus();
}
catch (Exception ex)
{
Snackbar.Add($"Error stopping lift: {ex.Message}", Severity.Error);
}
finally
{
StateHasChanged();
}
}
private async Task MoveToPosition()
{
if (!IsHubReady || isLoading || liftStatus?.IsReady != true)
return;
try
{
isLoading = true;
var targetCounts = ConvertMetersToCounts(targetHeightMeters);
Snackbar.Add($"Moving to height {targetHeightMeters:F3} m (~{targetCounts} counts)...", Severity.Info);
await HubClient.LiftToPositionAsync(targetCounts);
Snackbar.Add($"Move to height {targetHeightMeters:F3} m command sent successfully", Severity.Success);
await Task.Delay(500);
await RefreshStatus();
}
catch (Exception ex)
{
Snackbar.Add($"Error moving to position: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task LiftHome()
{
if (isLoading)
return;
try
{
isLoading = true;
Snackbar.Add("Starting lift homing (direct device)...", Severity.Info);
const string liftDeviceId = "lift-motor";
// Dùng cùng tham số như appsettings (hoặc theo nhu cầu của bạn)
const byte homingMethod = 21;
const int homingSpeed = 20000;
const int homingOffset = 0;
// Ghi homing params xuống drive giống CiA402ServoCard
await ServoHubClient.SetHomingMethodAsync(liftDeviceId, homingMethod);
await ServoHubClient.SetHomingSpeedAsync(liftDeviceId, homingSpeed);
await ServoHubClient.SetHomingOffsetAsync(liftDeviceId, homingOffset);
// Start homing trực tiếp trên thiết bị
await ServoHubClient.StartHomingAsync(liftDeviceId, homingMethod, homingSpeed);
Snackbar.Add("Lift homing command sent to lift-motor", Severity.Success);
await Task.Delay(500);
await RefreshStatus();
}
catch (Exception ex)
{
Snackbar.Add($"Error homing lift: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private static double ConvertCountsToMeters(int counts)
{
// 10,000 counts = 0.01 m => 1,000,000 counts = 1 m
return counts / 1_000_000.0;
}
private static int ConvertMetersToCounts(double meters)
{
return (int)Math.Round(meters * 1_000_000.0);
}
}

View File

@@ -0,0 +1,146 @@
@using RobotNet10.RobotApp.Client.Clients
@using MudBlazor
<MudPaper Class="pa-4" Style="height: 100%;">
<MudStack Spacing="3">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">Manual Control</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Color="Color.Primary"
Size="Size.Small"
OnClick="RefreshStatus"
Disabled="@(isLoading)" />
</MudStack>
@* Enable / Disable PS5 Controller *@
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
<MudButton Variant="Variant.Filled"
Color="@(ps5Enabled ? Color.Error : Color.Success)"
OnClick="TogglePs5Control"
Disabled="@isLoading">
@if (ps5Enabled)
{
<MudIcon Icon="@Icons.Material.Filled.Stop" Class="mr-2" />
<MudText>Disable PS5 Controller</MudText>
}
else
{
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Class="mr-2" />
<MudText>Enable PS5 Controller</MudText>
}
</MudButton>
</MudStack>
@* Status of PS5 Controller *@
@if (ps5State != null)
{
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h6" Class="mb-3">PS5 Controller Status</MudText>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>State:</MudText>
<MudChip T="string" Color="@(ps5Enabled ? Color.Success : Color.Default)" Size="Size.Small">
@ps5State
</MudChip>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
}
</MudStack>
</MudPaper>
@code {
[Parameter] public MotionHubClient? HubClient { get; set; }
[Parameter] public bool IsHubReady { get; set; }
[Inject] private HttpClient Http { get; set; } = null!;
[Inject] private ISnackbar Snackbar { get; set; } = null!;
private string? ps5State;
private bool ps5Enabled => string.Equals(ps5State, "Active", StringComparison.OrdinalIgnoreCase);
private bool isLoading = false;
protected override async Task OnInitializedAsync()
{
await RefreshStatus();
}
public async Task RefreshStatus()
{
if (isLoading) return;
try
{
isLoading = true;
var resp = await Http.GetAsync("/api/motion/ps5/status");
if (resp.IsSuccessStatusCode)
{
var json = await resp.Content.ReadFromJsonAsync<Ps5StatusResponse>();
ps5State = json?.State ?? "Unknown";
}
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Failed to refresh PS controller status: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
}
}
private async Task TogglePs5Control()
{
if (isLoading) return;
try
{
isLoading = true;
if (ps5Enabled)
{
var resp = await Http.PostAsync("/api/motion/ps5/disable", null);
if (resp.IsSuccessStatusCode)
{
Snackbar.Add("PS controller disabled", Severity.Info);
ps5State = "Disabled";
}
else
Snackbar.Add("Failed to disable PS controller", Severity.Error);
}
else
{
var resp = await Http.PostAsync("/api/motion/ps5/enable", null);
if (resp.IsSuccessStatusCode)
{
Snackbar.Add("PS controller enabled", Severity.Success);
ps5State = "Active";
}
else
Snackbar.Add("Failed to enable PS controller", Severity.Error);
}
for (int i = 0; i < 3; i++)
{
await Task.Delay(100);
await RefreshStatus();
if (ps5State != null) break;
}
}
catch (Exception ex)
{
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private sealed class Ps5StatusResponse
{
public string State { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,121 @@
@using RobotNet10.RobotApp.Client.Shared.Motion
@using RobotNet10.Shared.Geometry
@using RobotNet10.Shared.Numbers
@using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion
@using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion
@using MudBlazor
@implements IDisposable
<MudPaper Class="pa-4" Style="height: 100%;">
<MudStack Spacing="3">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">Odometry</MudText>
</MudStack>
@* Odometry Information *@
@if (Odometry != null)
{
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h6" Class="mb-3">Position</MudText>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>X:</MudText>
<MudText>@($"{Odometry.PositionX:F3} m")</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>Y:</MudText>
<MudText>@($"{Odometry.PositionY:F3} m")</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>Z:</MudText>
<MudText>@($"{Odometry.PositionZ:F3} m")</MudText>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h6" Class="mb-3">Orientation</MudText>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>Yaw:</MudText>
<MudText>@($"{yawDegrees:F2}°")</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>Update Freq:</MudText>
<MudChip T="double" Color="@(Odometry.UpdateFrequency > 0 ? Color.Success : Color.Default)" Size="Size.Small">
@($"{Odometry.UpdateFrequency:F2} Hz")
</MudChip>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
}
else
{
<MudAlert Severity="Severity.Info">No odometry data available.</MudAlert>
}
</MudStack>
</MudPaper>
@code {
[Parameter, EditorRequired] public OdometryDto? Odometry { get; set; }
[Parameter] public bool IsHubReady { get; set; }
[Inject] private ISnackbar Snackbar { get; set; } = null!;
private double _smoothedYawDegrees;
private bool _hasYaw;
private double yawDegrees => _smoothedYawDegrees;
protected override void OnParametersSet()
{
if (Odometry == null)
return;
// Nếu robot gần như đứng yên (ít quay, ít chạy thẳng) thì giữ nguyên yaw
var angularZ = Odometry.AngularVelocityZ; // rad/s
var linearX = Odometry.LinearVelocityX; // m/s
const double angularDeadZone = 0.01; // ~0.57°
const double linearDeadZone = 0.005; // 5 mm/s
var isAlmostStopped =
Math.Abs(angularZ) < angularDeadZone &&
Math.Abs(linearX) < linearDeadZone;
// Lần đầu vẫn phải init giá trị
if (!_hasYaw)
{
var q0 = new QuaternionGeometry(Odometry.OrientationX, Odometry.OrientationY, Odometry.OrientationZ, Odometry.OrientationW);
_smoothedYawDegrees = q0.ToYawDegrees();
_hasYaw = true;
return;
}
if (isAlmostStopped)
{
// Robot đứng yên: không cập nhật yaw để tránh drift chậm
return;
}
// Robot đang quay / di chuyển: cho phép yaw thay đổi nhưng có lọc
var quaternion = new QuaternionGeometry(Odometry.OrientationX, Odometry.OrientationY, Odometry.OrientationZ, Odometry.OrientationW);
var newYaw = quaternion.ToYawDegrees();
// Normalize delta để tránh nhảy 360° khi wrap-around
var delta = newYaw - _smoothedYawDegrees;
while (delta > 180.0) delta -= 360.0;
while (delta < -180.0) delta += 360.0;
// Low-pass filter để làm mượt (alpha càng nhỏ càng mượt)
const double alpha = 0.3;
_smoothedYawDegrees = _smoothedYawDegrees + alpha * delta;
}
public void Dispose()
{
// Nothing to dispose currently
}
}

View File

@@ -0,0 +1,233 @@
@using RobotNet10.RobotApp.Client.Clients
@using RobotNet10.RobotApp.Client.Shared.Modules
@using MudBlazor
<MudPaper Class="pa-4" Style="height: 100%;">
<MudStack Spacing="3">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">Rotation Module</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Color="Color.Primary"
Size="Size.Small"
OnClick="RefreshStatus"
Disabled="@(!IsHubReady || isLoading)" />
</MudStack>
@* Rotation Status Information *@
@if (rotationStatus != null)
{
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h6" Class="mb-3">Module Status</MudText>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>State:</MudText>
<MudChip T="string" Color="@GetStateColor(rotationStatus.State)" Size="Size.Small">
@rotationStatus.State
</MudChip>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>Ready:</MudText>
<MudChip T="bool" Color="@(rotationStatus.IsReady ? Color.Success : Color.Default)" Size="Size.Small">
@rotationStatus.IsReady
</MudChip>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>Current Angle:</MudText>
<MudText>@($"{rotationStatus.CurrentAngle:F2}°")</MudText>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
}
@* Rotation Control Buttons *@
<MudStack Spacing="3">
<MudText Typo="Typo.h6">Rotation Controls</MudText>
@* Rotate to Absolute Angle *@
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
<MudNumericField @bind-Value="targetAngle"
Label="Target Angle (°)"
Variant="Variant.Outlined"
Class="flex-grow-1" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="RotateToAngle"
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
<MudIcon Icon="@Icons.Material.Filled.LocationOn" Class="mr-2" />
<MudText>Go</MudText>
</MudButton>
</MudStack>
@* Rotate Offset *@
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
<MudNumericField @bind-Value="angleOffset"
Label="Offset (°)"
Variant="Variant.Outlined"
HelperText="+/- for CW/CCW"
Class="flex-grow-1" />
<MudButton Variant="Variant.Filled"
Color="Color.Info"
OnClick="RotateOffset"
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
<MudIcon Icon="@Icons.Material.Filled.RotateRight" Class="mr-2" />
<MudText>Offset</MudText>
</MudButton>
</MudStack>
@* Quick Rotation Buttons *@
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center" Wrap="Wrap.Wrap">
<MudButton Variant="Variant.Outlined"
Color="Color.Secondary"
OnClick="() => RotateOffsetQuick(-90)"
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
<MudIcon Icon="@Icons.Material.Filled.RotateLeft" Class="mr-2" />
<MudText>-90°</MudText>
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Secondary"
OnClick="() => RotateOffsetQuick(-45)"
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
<MudIcon Icon="@Icons.Material.Filled.RotateLeft" Class="mr-2" />
<MudText>-45°</MudText>
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Secondary"
OnClick="() => RotateOffsetQuick(45)"
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
<MudIcon Icon="@Icons.Material.Filled.RotateRight" Class="mr-2" />
<MudText>+45°</MudText>
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Secondary"
OnClick="() => RotateOffsetQuick(90)"
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
<MudIcon Icon="@Icons.Material.Filled.RotateRight" Class="mr-2" />
<MudText>+90°</MudText>
</MudButton>
</MudStack>
</MudStack>
</MudStack>
</MudPaper>
@code {
[Parameter, EditorRequired] public MotionHubClient HubClient { get; set; } = null!;
[Parameter] public bool IsHubReady { get; set; }
[Inject] private ISnackbar Snackbar { get; set; } = null!;
private RotationModuleStatusDto? rotationStatus;
private bool isLoading = false;
private double targetAngle = 0;
private double angleOffset = 0;
private bool _previousIsHubReady = false;
protected override async Task OnParametersSetAsync()
{
// Detect when IsHubReady changes from false to true
if (IsHubReady && !_previousIsHubReady)
{
await RefreshStatus();
}
_previousIsHubReady = IsHubReady;
}
public async Task RefreshStatus()
{
if (isLoading || !IsHubReady) return;
try
{
isLoading = true;
rotationStatus = await HubClient.GetRotationStatusAsync();
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Error refreshing rotation status: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
}
}
private Color GetStateColor(string state)
{
return state switch
{
"Ready" => Color.Success,
"Moving" => Color.Info,
"Error" => Color.Error,
"Homing" => Color.Warning,
"Initializing" => Color.Warning,
_ => Color.Default
};
}
private async Task RotateToAngle()
{
if (!IsHubReady || isLoading || rotationStatus?.IsReady != true)
return;
try
{
isLoading = true;
Snackbar.Add($"Rotating to angle {targetAngle}°...", Severity.Info);
await HubClient.RotateToAngleAsync(targetAngle);
Snackbar.Add($"Rotate to angle {targetAngle}° command sent successfully", Severity.Success);
await Task.Delay(500);
await RefreshStatus();
}
catch (Exception ex)
{
Snackbar.Add($"Error rotating to angle: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task RotateOffset()
{
if (!IsHubReady || isLoading || rotationStatus?.IsReady != true)
return;
try
{
isLoading = true;
var offsetText = angleOffset >= 0 ? $"+{angleOffset}°" : $"{angleOffset}°";
Snackbar.Add($"Rotating offset {offsetText}...", Severity.Info);
await HubClient.RotateOffsetAsync(angleOffset);
Snackbar.Add($"Rotate offset {offsetText} command sent successfully", Severity.Success);
await Task.Delay(500);
await RefreshStatus();
}
catch (Exception ex)
{
Snackbar.Add($"Error rotating offset: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task RotateOffsetQuick(double offset)
{
angleOffset = offset;
await RotateOffset();
}
}

View File

@@ -0,0 +1,22 @@
@using MudBlazor
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Delete Map</MudText>
</TitleContent>
<DialogContent>
<MudText>Are you sure you want to delete map "@MapName"? This action cannot be undone.</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Submit">Delete</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public string MapName { get; set; } = string.Empty;
void Cancel() => MudDialog.Cancel();
void Submit() => MudDialog.Close(DialogResult.Ok(true));
}

View File

@@ -0,0 +1,121 @@
@using Microsoft.AspNetCore.Components
<!-- Robot goal pose: line robot->goal, line goal->mouse (with arrow), circle at goal with radius = distance(goal,mouse).
_goalOrientationRad được tính trong SetMousePosition: hướng từ goal đến mouse; 0 khi goal trùng mouse. -->
<g visibility="@_visibility" @ref="_groupRef">
<defs>
<marker id="@_arrowMarkerId" markerWidth="0.15" markerHeight="0.15" refX="0" refY="0.075" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 0 0 L 0.15 0.075 L 0 0.15 Z" fill="#FF0000" stroke="none" />
</marker>
</defs>
<!-- Line 1: RobotPosition -> GoalPosition (red, dashed, 0.1); zero length when robot==goal is valid -->
<line x1="@_robotX" y1="@_robotY" x2="@_goalX" y2="@_goalY"
stroke="green" stroke-width="0.03" stroke-dasharray="0.3,0.2" fill="none" />
<!-- Line 2: GoalPosition -> current mouse (blue, dashed, 0.1, arrow at mouse) -->
<line x1="@_goalX" y1="@_goalY" x2="@_mouseX" y2="@_mouseY"
stroke="#FF0000" stroke-width="0.03" stroke-dasharray="0.3,0.2" fill="none"
marker-end="url(#@_arrowMarkerId)" />
<!-- Circle: center GoalPosition, radius = distance(Goal, mouse); r=0 when goal==mouse is valid -->
<circle cx="@_goalX" cy="@_goalY" r="@_radius"
stroke="green" stroke-width="0.03" stroke-dasharray="0.3,0.2" fill="none" />
</g>
@code {
private ElementReference _groupRef;
private string _arrowMarkerId = "goal-mouse-arrow-" + Guid.NewGuid().ToString("N")[..8];
private double _robotX;
private double _robotY;
private double _goalX;
private double _goalY;
private double _goalOrientationRad;
private double _mouseX;
private double _mouseY;
private string _visibility = "hidden";
/// <summary>Tọa độ X điểm đích (world coordinates).</summary>
public double GoalX => _goalX;
/// <summary>Tọa độ Y điểm đích (world coordinates).</summary>
public double GoalY => _goalY;
/// <summary>Hướng điểm đích (yaw, radians) — hướng từ goal đến mouse; 0 khi goal trùng mouse.</summary>
public double GoalYaw => _goalOrientationRad;
/// <summary>Bán kính circle = khoảng cách Goal -> mouse; 0 khi goal trùng mouse (hợp lệ).</summary>
private double _radius => Math.Sqrt((_mouseX - _goalX) * (_mouseX - _goalX) + (_mouseY - _goalY) * (_mouseY - _goalY));
/// <summary>
/// Hiển thị nhóm line và circle (robot->goal, goal->mouse, circle).
/// </summary>
public void Show()
{
_visibility = "visible";
StateHasChanged();
}
/// <summary>
/// Ẩn nhóm line và circle.
/// </summary>
public void Hide()
{
_visibility = "hidden";
StateHasChanged();
}
/// <summary>
/// Đặt tọa độ robot (world coordinates).
/// </summary>
public void SetRobotPosition(double x, double y)
{
_robotX = x;
_robotY = y;
StateHasChanged();
}
/// <summary>
/// Đặt tọa độ điểm đích (world coordinates).
/// </summary>
public void SetGoalPosition(double x, double y)
{
_goalX = x;
_goalY = y;
StateHasChanged();
}
/// <summary>
/// Cập nhật vị trí chuột (world coordinates). Parent gọi từ OnMouseMove để vẽ line goal->mouse và circle.
/// Tự tính _goalOrientationRad = hướng từ goal đến mouse (radians); 0 khi goal trùng mouse.
/// </summary>
public void SetMousePosition(double x, double y)
{
_mouseX = x;
_mouseY = y;
var dx = _mouseX - _goalX;
var dy = _mouseY - _goalY;
_goalOrientationRad = (Math.Abs(dx) < 1e-9 && Math.Abs(dy) < 1e-9) ? 0 : Math.Atan2(dy, dx);
StateHasChanged();
}
private const double OptimizeMinDistanceMeters = 1.0;
/// <summary>
/// Nếu khoảng cách từ (GoalX, GoalY) đến (mouseX, mouseY) &lt; 1 m thì gọi Hide().
/// Nếu khoảng cách &gt;= 1 m thì đặt lại _mouseX, _mouseY sao cho khoảng cách đúng 1 m (giữ hướng).
/// Tọa độ trong SVG là mét (viewBox + scaleY(-1) trong MapLocalization).
/// </summary>
public void Optimize()
{
var dx = _mouseX - _goalX;
var dy = _mouseY - _goalY;
var d = Math.Sqrt(dx * dx + dy * dy);
if (d < OptimizeMinDistanceMeters)
{
Hide();
return;
}
var scale = OptimizeMinDistanceMeters / d;
_mouseX = _goalX + dx * scale;
_mouseY = _goalY + dy * scale;
_goalOrientationRad = (Math.Abs(dx) < 1e-9 && Math.Abs(dy) < 1e-9) ? 0 : Math.Atan2(dy, dx);
StateHasChanged();
}
}

View File

@@ -0,0 +1,404 @@
@page "/map/{MapName}"
@rendermode InteractiveWebAssemblyNoPrerender
@implements IAsyncDisposable
@using MudBlazor
@using RobotNet10.RobotApp.Client.Shared.SLAM
@using Microsoft.JSInterop
@inject IJSRuntime JSRuntime
@inject ISnackbar Snackbar
<PageTitle>Map: @MapName</PageTitle>
<MudThemeProvider IsDarkMode />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<div class="map-edit-page">
@* Control Bar *@
<div class="control-bar">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudTooltip Text="Back to Maps">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack"
Color="Color.Default"
Size="Size.Medium" Href="/maps" />
</MudTooltip>
<MudDivider Vertical="true" FlexItem="true" Class="my-1" />
<MudTooltip Text="Fit to View">
<MudIconButton Icon="@Icons.Material.Filled.FitScreen"
Color="Color.Primary"
Size="Size.Medium"
OnClick="FitViewAsync"
Disabled="@(!IsMapLoaded)" />
</MudTooltip>
</MudStack>
</div>
<div class="content-area">
@* Processing Overlay *@
<MudOverlay Visible="@MapInfo.IsProcessing" Absolute AutoClose="false" DarkBackground="true" ZIndex="1000">
<MudStack AlignItems="AlignItems.Center" Spacing="2">
<MudProgressCircular Indeterminate="true" Size="Size.Large" Color="Color.Primary" />
<MudText Typo="Typo.h6" Color="Color.Primary">Processing map...</MudText>
</MudStack>
</MudOverlay>
@* Info Bar *@
<div class="info-bar">
<MudText Typo="Typo.h6" Class="mb-2">Map Information</MudText>
<MudStack Spacing="1">
<div class="info-item">
<MudIcon Icon="@Icons.Material.Filled.Map" Size="Size.Small" Class="mr-1" />
<MudText Typo="Typo.body2"><strong>Name:</strong> @MapInfo.Name</MudText>
</div>
<div class="info-item">
<MudIcon Icon="@Icons.Material.Filled.CalendarToday" Size="Size.Small" Class="mr-1" />
<MudText Typo="Typo.body2"><strong>Created:</strong> @MapInfo.CreatedDate.ToString("yyyy-MM-dd HH:mm")</MudText>
</div>
<div class="info-item">
<MudIcon Icon="@Icons.Material.Filled.Straighten" Size="Size.Small" Class="mr-1" />
<MudText Typo="Typo.body2"><strong>Resolution:</strong> @MapInfo.Resolution.ToString("F3") m/p</MudText>
</div>
<div class="info-item">
<MudIcon Icon="@Icons.Material.Filled.AspectRatio" Size="Size.Small" Class="mr-1" />
<MudText Typo="Typo.body2"><strong>Size:</strong> @MapInfo.Width.ToString("F1") x @MapInfo.Height.ToString("F1") m</MudText>
</div>
<div class="info-item">
<MudIcon Icon="@Icons.Material.Filled.Timeline" Size="Size.Small" Class="mr-1" />
<MudText Typo="Typo.body2"><strong>Nodes:</strong> @MapInfo.TrajectoryNodeCount</MudText>
</div>
<div class="info-item info-item-origin">
<MudIcon Icon="@Icons.Material.Filled.MyLocation" Size="Size.Small" Class="mr-1" />
<MudText Typo="Typo.body2"><strong>Origin:</strong> (@MapInfo.OriginX.ToString("F3"), @MapInfo.OriginY.ToString("F3"))</MudText>
<MudSpacer />
<MudTooltip Text="Edit Origin">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
OnClick="OpenEditOriginDialog"
Disabled="@(!IsMapLoaded)" />
</MudTooltip>
</div>
</MudStack>
<MudDivider Class="my-2" />
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Refresh"
OnClick="OpenRerenderDialog"
Disabled="@(!IsMapLoaded || MapInfo.IsProcessing)"
FullWidth="true"
Size="Size.Small">
Rerender Map
</MudButton>
<MudOverlay Visible="@IsLoading" Absolute AutoClose="false">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
</MudOverlay>
</div>
@* Map View - structure similar to MapLocalization *@
<div class="map-localization-container" @ref="containerRef" tabindex="1">
<div @ref="viewMovementRef" class="map-view-movement">
@* Map image with Y-flip (like map-canvas in MapLocalization) *@
<img @ref="mapImageRef"
src="@MapImageUrl"
alt="Map"
class="map-canvas" />
@* SVG overlay for origin marker (like map-editor in MapLocalization) *@
<svg @ref="mapContainerRef" class="map-editor" viewBox="0 0 0 0">
<defs>
<marker id="originvector" markerWidth="2.4" markerHeight="2.4" refX="0.1" refY="0.1">
<line x1="0" y1="0.1" x2="0.5" y2="0.1" stroke="red" stroke-width="0.02" />
<path d="M 0.5 0.15 L 0.6 0.1 L 0.5 0.05 Z" fill="red" stroke-width="0" />
<line x1="0.1" y1="0" x2="0.1" y2="0.5" stroke="blue" stroke-width="0.02" />
<path d="M 0.05 0.5 L 0.1 0.6 L 0.15 0.5 Z" fill="blue" stroke-width="0" />
</marker>
</defs>
@* Robot goal pose component *@
<GoalPose @ref="GoalPoseRef" />
@* Grid origin marker *@
<line class="origin" marker-end="url(#originvector)" />
</svg>
</div>
<MapMousePosition @ref="MapMousePositionRef" />
</div>
</div>
</div>
@* Edit Origin Dialog *@
<MudDialog @bind-Visible="_editOriginDialogVisible" Options="_editOriginDialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.MyLocation" Class="mr-2" />
Edit Map Origin
</MudText>
</TitleContent>
<DialogContent>
<MudText Typo="Typo.body2" Class="mb-3">
Set the new origin position from the selected goal pose on the map.
</MudText>
<MudStack Spacing="2">
<MudNumericField @bind-Value="_editOriginX" Label="X (meters)" Variant="Variant.Outlined" />
<MudNumericField @bind-Value="_editOriginY" Label="Y (meters)" Variant="Variant.Outlined" />
<MudNumericField @bind-Value="_editOriginYaw" Label="@(_editOriginYawUseRadian ? "Yaw (radians)" : "Yaw (degrees)")" Variant="Variant.Outlined"
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.CompareArrows"
OnAdornmentClick="ToggleRadianDegree" AdornmentAriaLabel="Toggle radian/degree" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseEditOriginDialog">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="ConfirmEditOrigin">Confirm</MudButton>
</DialogActions>
</MudDialog>
@* Rerender Map Dialog *@
<MudDialog @bind-Visible="_rerenderDialogVisible" Options="_rerenderDialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Refresh" Class="mr-2" />
Rerender Map with Custom Config
</MudText>
</TitleContent>
<DialogContent>
<MudText Typo="Typo.body2" Class="mb-3">
Configure occupancy grid settings and rerender map image files (PNG, JPG, PGM).
</MudText>
<MudStack Spacing="2">
@* Merge Strategy *@
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Merge Strategy</MudText>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudSelect T="SubmapMergeStrategyDto" @bind-Value="_rerenderConfig.MergeStrategy" Label="Merge Strategy" Variant="Variant.Outlined" Dense="true">
<MudSelectItem Value="SubmapMergeStrategyDto.LogOddsSum">Log-Odds Sum (Bayesian)</MudSelectItem>
<MudSelectItem Value="SubmapMergeStrategyDto.MaxProbability">Max Probability (Conservative)</MudSelectItem>
<MudSelectItem Value="SubmapMergeStrategyDto.PorterDuff">Porter-Duff (Cairo-style)</MudSelectItem>
</MudSelect>
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 300px;">
<strong>Submap merge strategy:</strong><br/>
• <b>Log-Odds Sum:</b> Sum Bayesian log-odds — clearer walls, less noise<br/>
• <b>Max Probability:</b> Take max probability — safer for navigation, thicker walls<br/>
• <b>Porter-Duff:</b> Cairo-style blending — matches the original C++ behavior; may blur in overlap areas
</MudText>
</TooltipContent>
</MudTooltip>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudNumericField @bind-Value="_rerenderConfig.LogOddsClamp" Label="Log-Odds Clamp (1-20)" Variant="Variant.Outlined" Min="1.0" Max="20.0" Step="0.5" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Log-odds clamp:</strong><br/>
• Low (1-5): smoother image, lower contrast<br/>
• High (10-20): sharper walls, higher contrast<br/>
• Default: 10 — balance between clarity and noise
</MudText>
</TooltipContent>
</MudTooltip>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudCheckBox @bind-Value="_rerenderConfig.UseLogOddsAverage" Label="Use Log-Odds Average" Dense="true" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Use log-odds average:</strong><br/>
• <b>On:</b> Divide log-odds by observation count — more uniform in overlap areas<br/>
• <b>Off:</b> Sum directly — areas scanned more often become darker/lighter
</MudText>
</TooltipContent>
</MudTooltip>
<MudDivider Class="my-1" />
@* Threshold Configuration *@
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Threshold Configuration</MudText>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudNumericField @bind-Value="_rerenderConfig.FreeSpaceThreshold" Label="Free Space Threshold (0-255)" Variant="Variant.Outlined" Min="0" Max="255" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Free-space threshold:</strong><br/>
Pixels with texture value >= this threshold are marked as FREE (white).<br/>
• Low (50-80): more areas become free<br/>
• High (150-200): only very certain areas become free<br/>
• Default: 100
</MudText>
</TooltipContent>
</MudTooltip>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudNumericField @bind-Value="_rerenderConfig.OccupiedSpaceThreshold" Label="Occupied Space Threshold (0-255)" Variant="Variant.Outlined" Min="0" Max="255" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Occupied-space threshold:</strong><br/>
Pixels with alpha value > this threshold are marked as OCCUPIED (black).<br/>
• Low (1-10): thicker walls, more sensitive to obstacles<br/>
• High (50-100): only strong walls are drawn<br/>
• Default: 1 (most sensitive)
</MudText>
</TooltipContent>
</MudTooltip>
<MudDivider Class="my-1" />
@* Output Mode *@
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Output Mode</MudText>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudCheckBox @bind-Value="_rerenderConfig.UseBinaryOutput" Label="Binary Output (0/100/-1)" Dense="true" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Binary output mode:</strong><br/>
• <b>On:</b> Only 3 values: 0 (white/free), 100 (black/wall), -1 (gray/unknown). Suitable for MCL/Navigation.<br/>
• <b>Off:</b> Gradient 0-100 values. Shows detailed occupancy probability.
</MudText>
</TooltipContent>
</MudTooltip>
<MudDivider Class="my-1" />
@* Wall Thinning *@
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Wall Thinning (Post-processing)</MudText>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudCheckBox @bind-Value="_rerenderConfig.EnableWallThinning" Label="Enable Wall Thinning" Dense="true" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Wall thinning:</strong><br/>
Applies morphological erosion to reduce wall thickness.<br/>
• <b>On:</b> Thinner walls; the robot can pass narrow corridors more easily<br/>
• <b>Off:</b> Keep original wall thickness
</MudText>
</TooltipContent>
</MudTooltip>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudNumericField @bind-Value="_rerenderConfig.WallThinningIterations" Label="Thinning Iterations (1-5)" Variant="Variant.Outlined" Min="1" Max="5" Disabled="@(!_rerenderConfig.EnableWallThinning)" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Thinning iterations:</strong><br/>
Each iteration erodes ~1 pixel from the wall boundary.<br/>
• 1 iteration: slightly thinner (~1 pixel)<br/>
• 3-5 iterations: significantly thinner; walls may break/disconnect
</MudText>
</TooltipContent>
</MudTooltip>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudNumericField @bind-Value="_rerenderConfig.MinWallThicknessPixels" Label="Min Wall Thickness (pixels, 1-10)" Variant="Variant.Outlined" Min="1" Max="10" Disabled="@(!_rerenderConfig.EnableWallThinning)" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Minimum wall thickness:</strong><br/>
Do not erode if the wall is thinner than this value.<br/>
• 1 pixel: allows very thin walls (may break)<br/>
• 2-3 pixels: safer, preserves wall structure<br/>
• With 0.05 m resolution: 2 pixels ≈ 10 cm real wall thickness
</MudText>
</TooltipContent>
</MudTooltip>
<MudDivider Class="my-1" />
@* Ambiguous Cell Handling *@
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Ambiguous Cell Handling</MudText>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudSelect T="sbyte" @bind-Value="_rerenderConfig.AmbiguousCellValue" Label="Ambiguous Cell Value" Variant="Variant.Outlined" Dense="true">
<MudSelectItem Value="@((sbyte)-1)">Unknown (-1)</MudSelectItem>
<MudSelectItem Value="@((sbyte)0)">Free (0)</MudSelectItem>
<MudSelectItem Value="@((sbyte)100)">Occupied (100)</MudSelectItem>
</MudSelect>
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Ambiguous cell value:</strong><br/>
Cells with probability in the ambiguous range are assigned this value.<br/>
• <b>Unknown (-1):</b> Gray; ignored by MCL — safest<br/>
• <b>Free (0):</b> White; robot may pass — riskier<br/>
• <b>Occupied (100):</b> Black; robot avoids — conservative
</MudText>
</TooltipContent>
</MudTooltip>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudNumericField @bind-Value="_rerenderConfig.AmbiguousRangeLower" Label="Ambiguous Range Lower (0-1)" Variant="Variant.Outlined" Min="0.0" Max="1.0" Step="0.05" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Ambiguous range lower:</strong><br/>
Probabilities below this value are considered FREE.<br/>
• 0.35 (default): 035% is free<br/>
• Lower to 0.2: stricter, fewer free areas<br/>
• Raise to 0.45: more areas become free
</MudText>
</TooltipContent>
</MudTooltip>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudNumericField @bind-Value="_rerenderConfig.AmbiguousRangeUpper" Label="Ambiguous Range Upper (0-1)" Variant="Variant.Outlined" Min="0.0" Max="1.0" Step="0.05" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Ambiguous range upper:</strong><br/>
Probabilities above this value are considered OCCUPIED.<br/>
• 0.65 (default): 65100% is wall<br/>
• Lower to 0.55: more walls (safer)<br/>
• Raise to 0.8: only very certain areas become walls
</MudText>
</TooltipContent>
</MudTooltip>
<MudDivider Class="my-1" />
@* Advanced Options *@
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Advanced Options</MudText>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudCheckBox @bind-Value="_rerenderConfig.EnableMedianFilter" Label="Enable Median Filter" Dense="true" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Median filter:</strong><br/>
Reduces salt-and-pepper noise.<br/>
• <b>On:</b> Smoother image, removes isolated noisy pixels<br/>
• <b>Off:</b> Keeps original details, may contain noise
</MudText>
</TooltipContent>
</MudTooltip>
<MudTooltip Placement="Placement.Right" Arrow="true">
<ChildContent>
<MudNumericField @bind-Value="_rerenderConfig.MedianFilterKernelSize" Label="Median Filter Kernel Size (3,5,7)" Variant="Variant.Outlined" Min="3" Max="7" Step="2" Disabled="@(!_rerenderConfig.EnableMedianFilter)" />
</ChildContent>
<TooltipContent>
<MudText Typo="Typo.body2" Style="max-width: 280px;">
<strong>Filter kernel size:</strong><br/>
Neighborhood used to compute the median.<br/>
• 3x3: light filtering, preserves details<br/>
• 5x5: medium filtering<br/>
• 7x7: strong filtering, may blur wall edges
</MudText>
</TooltipContent>
</MudTooltip>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseRerenderDialog">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="ConfirmRerender">Rerender</MudButton>
</DialogActions>
</MudDialog>

View File

@@ -0,0 +1,604 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
using RobotNet10.RobotApp.Client.Clients;
using RobotNet10.RobotApp.Client.Shared.SLAM;
using RobotNet10.Shared.Geometry;
using RobotNet10.Shared.Numbers;
using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion;
using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion;
namespace RobotNet10.RobotApp.Client.Components.SLAM;
public partial class MapEdit
{
private const double MinFitScale = 0.5;
[Parameter]
public string MapName { get; set; } = string.Empty;
[Inject]
private SLAMClient SLAMClient { get; set; } = null!;
private IJSObjectReference _jsModule = null!;
private DotNetObjectReference<MapEdit> _dotNetObj = null!;
// Element references
private ElementReference containerRef;
private ElementReference viewMovementRef;
private ElementReference mapContainerRef;
private ElementReference mapImageRef;
private MapMousePosition MapMousePositionRef = null!;
private GoalPose GoalPoseRef = null!;
// State
private MapInfoDto MapInfo { get; set; } = new();
private bool IsLoading { get; set; } = true;
private bool IsMapLoaded => MapInfo != null && _imageLoaded;
private bool _imageLoaded = false;
// Map image URL with cache busting
private long _imageCacheBuster = DateTime.Now.Ticks;
private string MapImageUrl => $"/api/maps/{MapName}/image?v={_imageCacheBuster}";
// Container rect state
private double _containerRectX = 0.0;
private double _containerRectY = 0.0;
private double _containerRectWidth = 0.0;
private double _containerRectHeight = 0.0;
private double _containerRectTop = 0.0;
private double _containerRectRight = 0.0;
private double _containerRectBottom = 0.0;
private double _containerRectLeft = 0.0;
// View state
public double CursorX { get; private set; } = 0.0;
public double CursorY { get; private set; } = 0.0;
private double _clientOriginX = 0.0;
private double _clientOriginY = 0.0;
private double _scale = 1.0;
private double _left = 0.0;
private double _top = 0.0;
private double _fitScale = 1.0;
// Map data
private double _resolution = 1.0;
private double _originX = 0.0;
private double _originY = 0.0; // Transformed origin Y (like MapLocalization)
private double _mapOriginY = 0.0; // Original origin Y from map (like MapLocalization._mapOriginY)
private double _imageWidth = 0.0;
private double _imageHeight = 0.0;
private int _imagePixelWidth = 0;
private int _imagePixelHeight = 0;
// SVG viewBox origin (like MapLocalization._svgOriginX and _svgOriginY)
private double _svgOriginX = 0.0;
private double _svgOriginY = 0.0;
#region Lifecycle
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
// Load JavaScript module
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "/js/mapLocalization.js");
// Setup event listeners
_dotNetObj = DotNetObjectReference.Create(this);
await _jsModule.InvokeVoidAsync("updateContainerRect", _dotNetObj, containerRef, nameof(OnContainerResize));
await _jsModule.InvokeVoidAsync("registerResizeObserver", _dotNetObj, containerRef, nameof(OnContainerResize));
await _jsModule.InvokeVoidAsync("addMouseWheelEventListener", _dotNetObj, containerRef, nameof(OnMouseWheel));
await _jsModule.InvokeVoidAsync("addMouseMoveEventListener", _dotNetObj, containerRef, nameof(OnMouseMove));
await _jsModule.InvokeVoidAsync("addMouseDownEventListener", _dotNetObj, containerRef, nameof(OnMouseDown));
await _jsModule.InvokeVoidAsync("addMouseUpEventListener", _dotNetObj, containerRef, nameof(OnMouseUp));
// Start SLAMClient and register event handler
await SLAMClient.StartAsync();
SLAMClient.IsProcessingChanged += OnIsProcessingChanged;
// Load map info
await LoadMapInfoAsync();
await OnImageLoaded();
}
private async Task LoadMapInfoAsync()
{
try
{
IsLoading = true;
StateHasChanged();
MapInfo = await SLAMClient.GetMapInfoAndSubscribeProcessingAsync(MapName) ?? new();
if (!string.IsNullOrEmpty(MapInfo.Name))
{
_resolution = MapInfo.Resolution;
_imageWidth = MapInfo.Width;
_imageHeight = MapInfo.Height;
_originX = MapInfo.OriginX;
_mapOriginY = MapInfo.OriginY; // Original origin Y from map
// Transformed origin Y (like MapLocalization: _originY = -imageHeight - mapOriginY)
_originY = -_imageHeight - _mapOriginY;
// Calculate pixel dimensions from world dimensions and resolution
if (_resolution > 0)
{
_imagePixelWidth = (int)Math.Round(_imageWidth / _resolution);
_imagePixelHeight = (int)Math.Round(_imageHeight / _resolution);
}
}
}
catch (Exception ex)
{
Snackbar.Add($"Failed to load map info: {ex.Message}", MudBlazor.Severity.Error);
}
finally
{
IsLoading = false;
StateHasChanged();
}
}
private void OnIsProcessingChanged(string mapName, bool isProcessing)
{
if (mapName == MapName)
{
var wasProcessing = MapInfo.IsProcessing;
MapInfo.IsProcessing = isProcessing;
// When processing completes (true -> false), reload map info and update image
if (wasProcessing && !isProcessing)
{
InvokeAsync(async () =>
{
// Update cache buster to force image reload
_imageCacheBuster = DateTime.Now.Ticks;
// Reload map info to get updated data
await LoadMapInfoAsync();
// Force reload image with reset CSS styles and get natural dimensions
await ReloadImageAsync();
GoalPoseRef.Hide();
});
}
else
{
InvokeAsync(StateHasChanged);
}
}
}
private async Task ReloadImageAsync()
{
try
{
// Pixel dimensions already calculated in LoadMapInfoAsync from MapInfo
_imageLoaded = true;
StateHasChanged();
// Wait for DOM update and image reload
await Task.Delay(100);
// Configure SVG viewBox with world coordinates first
await ConfigureSvgViewBoxAsync();
// Then fit to view (scales SVG to pixel coords)
await FitViewAsync();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[MapEdit] ReloadImageAsync: Exception - {ex.Message}");
}
}
private async Task OnImageLoaded()
{
try
{
// Pixel dimensions already calculated in LoadMapInfoAsync from MapInfo
_imageLoaded = true;
StateHasChanged();
// Wait for DOM update and image load
await Task.Delay(100);
// Configure SVG viewBox with world coordinates first
await ConfigureSvgViewBoxAsync();
// Then fit to view (scales SVG to pixel coords)
await FitViewAsync();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[MapEdit] OnImageLoaded: Exception - {ex.Message}");
}
}
#endregion
#region View & Scale
public async Task FitViewAsync()
{
if (!IsMapLoaded || _imageWidth <= 0 || _imageHeight <= 0) return;
// Recalculate fit scale
if (_containerRectWidth > 0 && _containerRectHeight > 0)
{
_fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight);
if (_fitScale < MinFitScale)
_fitScale = MinFitScale;
}
await ScaleFitContentAsync();
}
private void UpdateClientOrigin()
{
if (!IsMapLoaded) return;
_clientOriginX = _containerRectLeft + _left - _originX * _scale;
_clientOriginY = _containerRectTop + _top - _originY * _scale;
}
private async Task ScaleFitContentAsync()
{
if (!IsMapLoaded) return;
_scale = _fitScale;
var wrapperWidth = _imageWidth * _scale;
var wrapperHeight = _imageHeight * _scale;
var centerLeft = (_containerRectWidth - wrapperWidth) / 2;
var centerTop = (_containerRectHeight - wrapperHeight) / 2;
await SetViewMovement(centerLeft, centerTop);
// Set SVG rect (pixel coords) and image sizes (like MapLocalization.ScaleFitContentAsync)
await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, wrapperWidth, wrapperHeight);
await _jsModule.InvokeVoidAsync("setCanvasRect", mapImageRef, wrapperWidth, wrapperHeight);
}
/// <summary>
/// Configure SVG viewBox with world coordinates (like MapLocalization.DrawOccupancyGridAsync)
/// </summary>
private async Task ConfigureSvgViewBoxAsync()
{
if (!IsMapLoaded || _jsModule == null) return;
// Update SVG viewBox origin
_svgOriginX = _originX;
_svgOriginY = _mapOriginY;
// Set SVG config with world coordinates for viewBox
await _jsModule.InvokeVoidAsync("setSvgConfig", mapContainerRef, _imageWidth, _imageHeight, _svgOriginX, _svgOriginY);
}
private async Task SetViewMovement(double left, double top)
{
_top = top;
_left = left;
UpdateClientOrigin();
if (_jsModule != null && IsMapLoaded)
{
var width = _imageWidth * _scale;
var height = _imageHeight * _scale;
await _jsModule.InvokeVoidAsync("setMapMovement", viewMovementRef, _top, _left, width, height);
}
}
#endregion
#region Event Handlers (JSInvokable)
[JSInvokable]
public void OnContainerResize(double x, double y, double width, double height, double top, double right, double bottom, double left)
{
_containerRectX = x;
_containerRectY = y;
_containerRectWidth = width;
_containerRectHeight = height;
_containerRectTop = top;
_containerRectRight = right;
_containerRectBottom = bottom;
_containerRectLeft = left;
UpdateClientOrigin();
// Recalculate fit scale
if (_imageWidth > 0 && _imageHeight > 0)
{
_fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight);
}
}
[JSInvokable]
public async Task OnMouseWheel(double deltaY, double clientX, double clientY)
{
if (!IsMapLoaded) return;
// Calculate scale change
double scaleChange;
if (deltaY > 0)
{
if (_scale <= _fitScale / 2) return;
scaleChange = _scale > _fitScale ? -(_scale / _fitScale) : -0.1;
}
else
{
if (_scale >= _fitScale * 100) return;
scaleChange = _scale < _fitScale ? 0.5 : (_scale / _fitScale);
}
double oldScale = _scale;
_scale += scaleChange;
// Set SVG rect (pixel coords) and image sizes (like MapLocalization)
await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, _imageWidth * _scale, _imageHeight * _scale);
await _jsModule.InvokeVoidAsync("setCanvasRect", mapImageRef, _imageWidth * _scale, _imageHeight * _scale);
// Calculate cursor position in world coordinates (exactly like MapLocalization)
CursorX = (clientX - _clientOriginX) / oldScale;
CursorY = (_clientOriginY - clientY) / oldScale;
MapMousePositionRef?.Update(CursorX, CursorY);
// Calculate mouse position relative to map origin (exactly like MapLocalization)
// MapLocalization: mouseX = CursorX - OriginX
// MapLocalization: mouseY = CursorY - MapData.OriginY (use original origin Y, not transformed)
double mouseX = CursorX - _originX;
double mouseY = CursorY - _mapOriginY;
// Calculate movement adjustment (exactly like MapLocalization)
await SetViewMovement(_left - mouseX * scaleChange, _top - (_imageHeight - mouseY) * scaleChange);
}
[JSInvokable]
public async Task OnMouseMove(double clientX, double clientY, long buttons, bool ctrlKey, double movementX, double movementY)
{
// Calculate cursor position in world coordinates
CursorX = (clientX - _clientOriginX) / _scale;
CursorY = (_clientOriginY - clientY) / _scale;
MapMousePositionRef?.Update(CursorX, CursorY);
// Left mouse button down: update goal pose to current mouse
if (buttons == 1)
{
GoalPoseRef?.SetMousePosition(CursorX, CursorY);
}
// Middle mouse button for panning
else if (buttons == 4)
{
await SetViewMovement(_left + movementX, _top + movementY);
}
}
[JSInvokable]
public async Task OnMouseDown(int button, bool altKey, bool ctrlKey, bool shiftKey)
{
if (button == 0) // Left mouse button: set goal pose
{
// For MapEdit, robot position is at origin (0, 0) since we don't have live robot pose
GoalPoseRef?.SetRobotPosition(0, 0);
GoalPoseRef?.SetGoalPosition(CursorX, CursorY);
GoalPoseRef?.SetMousePosition(CursorX, CursorY);
GoalPoseRef?.Show();
}
await Task.CompletedTask;
}
[JSInvokable]
public async Task OnMouseUp(int button, bool altKey, bool ctrlKey, bool shiftKey)
{
if (button == 0) // Left mouse button up: apply Optimize
{
GoalPoseRef?.Optimize();
}
await Task.CompletedTask;
}
#endregion
#region Edit Origin Dialog
// Dialog state
private bool _editOriginDialogVisible = false;
private readonly DialogOptions _editOriginDialogOptions = new()
{
CloseOnEscapeKey = false,
CloseButton = true,
BackdropClick = false,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
// Dialog form values
private double _editOriginX = 0.0;
private double _editOriginY = 0.0;
private double _editOriginYaw = 0.0;
private bool _editOriginYawUseRadian = true;
private void OpenEditOriginDialog()
{
// Reset to radians when opening dialog (GoalYaw is in radians)
_editOriginYawUseRadian = true;
if (GoalPoseRef == null)
{
_editOriginX = MapInfo.OriginX;
_editOriginY = MapInfo.OriginY;
_editOriginYaw = 0;
}
else
{
// Get values from GoalPoseRef
_editOriginX = GoalPoseRef.GoalX;
_editOriginY = GoalPoseRef.GoalY;
_editOriginYaw = GoalPoseRef.GoalYaw;
}
_editOriginDialogVisible = true;
}
private void CloseEditOriginDialog()
{
_editOriginDialogVisible = false;
}
private void ToggleRadianDegree()
{
if (_editOriginYawUseRadian)
{
// Convert from radians to degrees
_editOriginYaw = _editOriginYaw * 180.0 / Math.PI;
}
else
{
// Convert from degrees to radians
_editOriginYaw = _editOriginYaw * Math.PI / 180.0;
}
_editOriginYawUseRadian = !_editOriginYawUseRadian;
}
private async Task ConfirmEditOrigin()
{
try
{
// Convert yaw to radians if needed
var yawRadians = _editOriginYawUseRadian
? _editOriginYaw
: _editOriginYaw * Math.PI / 180.0;
// Create pose with new origin position and orientation
var q = QuaternionNumbers.FromYawRadian(yawRadians);
var newOriginPose = new PoseDto
{
Position = new RobotNet10.Shared.Numbers.Vector3 { X = _editOriginX, Y = _editOriginY, Z = 0 },
Orientation = new QuaternionGeometry(q.X, q.Y, q.Z, q.W),
Timestamp = DateTime.UtcNow
};
// Call SLAMClient to transform map origin
var success = await SLAMClient.TransformMapOriginAsync(MapName, newOriginPose);
if (success)
{
Snackbar.Add($"Origin updated to ({_editOriginX:F3}, {_editOriginY:F3}) with yaw {yawRadians:F3} rad", Severity.Success);
_editOriginDialogVisible = false;
// Reload map info to reflect changes
await LoadMapInfoAsync();
}
else
{
Snackbar.Add("Failed to update map origin", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Failed to update origin: {ex.Message}", Severity.Error);
}
}
#endregion
#region Rerender Map Dialog
// Dialog state
private bool _rerenderDialogVisible = false;
private readonly DialogOptions _rerenderDialogOptions = new()
{
CloseOnEscapeKey = false,
CloseButton = true,
BackdropClick = false,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
// Rerender config
private OccupancyGridConfigurationDto _rerenderConfig = new();
private void OpenRerenderDialog()
{
// Reset to default values
_rerenderConfig = new OccupancyGridConfigurationDto();
_rerenderDialogVisible = true;
}
private void CloseRerenderDialog()
{
_rerenderDialogVisible = false;
}
private async Task ConfirmRerender()
{
try
{
// Close dialog
_rerenderDialogVisible = false;
// Call SLAMClient to rerender map
var success = await SLAMClient.RerenderMapWithConfigAsync(MapName, _rerenderConfig);
if (success)
{
Snackbar.Add("Map rerender started. Please wait...", Severity.Info);
}
else
{
Snackbar.Add("Failed to start map rerender (map may already be processing)", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Failed to rerender map: {ex.Message}", Severity.Error);
}
}
#endregion
#region Dispose
public async ValueTask DisposeAsync()
{
// Unsubscribe from event
SLAMClient.IsProcessingChanged -= OnIsProcessingChanged;
// Unsubscribe from map processing group
try
{
if (SLAMClient.IsConnected && !string.IsNullOrEmpty(MapName))
{
await SLAMClient.UnsubscribeMapProcessingAsync(MapName);
}
}
catch
{
// Ignore errors during dispose
}
// Stop SLAMClient
try
{
await SLAMClient.StopAsync();
}
catch
{
// Ignore errors during dispose
}
if (_jsModule != null)
{
await _jsModule.DisposeAsync();
}
}
#endregion
}

View File

@@ -0,0 +1,82 @@
.map-edit-page {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
background-color: #1e1e1e;
}
.control-bar {
background-color: #2d2d2d;
padding: 8px 16px;
border-bottom: 1px solid #404040;
flex-shrink: 0;
}
.content-area {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.info-bar {
width: 280px;
background-color: #2d2d2d;
padding: 16px;
border-right: 1px solid #404040;
overflow-y: auto;
flex-shrink: 0;
}
.info-item {
display: flex;
align-items: center;
padding: 4px 0;
}
.info-item-origin {
flex-wrap: nowrap;
}
/* Reuse same CSS classes as MapLocalization for consistency */
.map-localization-container {
background-color: #CCCCCC;
flex: 1;
cursor: grab;
overflow: hidden;
position: relative;
min-height: 0;
display: flex;
flex-direction: column;
}
.map-localization-container:active {
cursor: grabbing;
}
.map-view-movement {
width: fit-content;
height: fit-content;
position: absolute;
cursor: default;
overflow: hidden;
}
.map-canvas {
position: absolute;
top: 0;
left: 0;
transform: scale(1, 1);
transform-origin: center;
pointer-events: none;
image-rendering: pixelated;
}
.map-editor {
position: absolute;
top: 0;
left: 0;
transform: scale(1, -1);
transform-origin: center;
}

View File

@@ -0,0 +1,64 @@
@implements IAsyncDisposable
@using MudBlazor
@using RobotNet10.RobotApp.Client.Clients
@using RobotNet10.RobotApp.Client.Shared.SLAM
@using RobotNet10.Shared.Geometry
@using RobotNet10.Shared.Localization
@using Microsoft.JSInterop
@using System.Linq
@inject SLAMClient CartographerClient
@inject ISnackbar Snackbar
@inject IJSRuntime JSRuntime
<div class="map-localization-container" @ref="containerRef" tabindex="1">
<div @ref="viewMovementRef" class="map-view-movement">
<!-- Canvas for base occupancy grid (background layer) -->
<canvas @ref="mapCanvasBaseRef" class="map-canvas">
</canvas>
<!-- Canvas for laser scan points (middle layer) -->
<canvas @ref="laserScanCanvasRef" class="map-canvas">
</canvas>
<!-- SVG overlay for robot position, trajectory, etc. (foreground layer) -->
<svg @ref="mapContainerRef" class="map-editor" viewBox="0 0 0 0">
<defs>
<marker id="originvector" markerWidth="2.4" markerHeight="2.4" refX="0.1" refY="0.1">
<line x1="0" y1="0.1" x2="0.5" y2="0.1" stroke="red" stroke-width="0.02" />
<path d="M 0.5 0.15 L 0.6 0.1 L 0.5 0.05 Z" fill="red" stroke-width="0" />
<line x1="0.1" y1="0" x2="0.1" y2="0.5" stroke="blue" stroke-width="0.02" />
<path d="M 0.05 0.5 L 0.1 0.6 L 0.15 0.5 Z" fill="blue" stroke-width="0" />
</marker>
</defs>
<!-- Trajectory polyline (ScanMapping) - updated via JsInvoke, no id -->
<polyline @ref="trajectoryPolylineRef" points="" fill="none" stroke="#00FF00" stroke-width="0.05" opacity="0.6" />
<GoalPose @ref="GoalPoseRef" />
<!-- Robot pose component -->
<RobotPose @ref="RobotPoseRef" />
<!-- Grid origin marker -->
<line class="origin" marker-end="url(#originvector)" />
@Elements
</svg>
</div>
<MapMousePosition @ref="MapMousePositionRef" />
<RobotPoseInfo @ref="RobotPoseInfoRef" />
</div>
@code {
[Parameter]
public RenderFragment? Elements { get; set; }
private ElementReference containerRef;
private ElementReference viewMovementRef;
private ElementReference mapContainerRef;
private ElementReference mapCanvasBaseRef;
private ElementReference laserScanCanvasRef;
private ElementReference trajectoryPolylineRef;
private MapMousePosition MapMousePositionRef = null!;
private GoalPose GoalPoseRef = null!;
private RobotPose RobotPoseRef = null!;
private RobotPoseInfo RobotPoseInfoRef = null!;
private bool ShowMap => CurrentGrid != null;
}

View File

@@ -0,0 +1,949 @@
using Microsoft.JSInterop;
using RobotNet10.RobotApp.Client.Clients;
using RobotNet10.RobotApp.Client.Shared.SLAM;
using RobotNet10.RobotApp.Shared.Enums;
using RobotNet10.Shared.Geometry;
using RobotNet10.Shared.Numbers;
using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion;
using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion;
namespace RobotNet10.RobotApp.Client.Components.SLAM;
public partial class MapLocalization
{
private const int GridPollIntervalMs = 3000;
private const int PoseLaserPollIntervalMs = 500;
private const int GridRequestMaxRetries = 10;
private const int GridRequestRetryDelayMs = 500;
private const double MinFitScale = 0.5;
private IJSObjectReference _jsModule = null!;
private DotNetObjectReference<MapLocalization> _dotNetObj = null!;
// Container rect state
private double _containerRectX = 0.0;
private double _containerRectY = 0.0;
private double _containerRectWidth = 0.0;
private double _containerRectHeight = 0.0;
private double _containerRectTop = 0.0;
private double _containerRectRight = 0.0;
private double _containerRectBottom = 0.0;
private double _containerRectLeft = 0.0;
// View state
public double CursorX { get; private set; } = 0.0;
public double CursorY { get; private set; } = 0.0;
private double _clientOriginX = 0.0;
private double _clientOriginY = 0.0;
private double _scale = 1.0;
private double _left = 0.0;
private double _top = 0.0;
private double _fitScale = 1.0;
// Map data
private double _resolution = 1.0;
private double _originX = 0.0;
private double _originY = 0.0; // Transformed origin Y (like MapContainer.OriginY)
private double _mapOriginY = 0.0; // Original origin Y from grid (like MapContainer MapData.OriginY)
private double _imageWidth = 0.0;
private double _imageHeight = 0.0;
// Grid data
public OccupancyGridDto? CurrentGrid { get; private set; }
private OccupancyGridDto? _lastDrawnGrid; // Track last drawn grid
private DateTime _lastGridUpdateTime = DateTime.MinValue;
// Trajectory path for polyline (ScanMapping only) - world coordinates for SVG viewBox
private List<Vector2> _trajectoryPath = [];
// SVG viewBox origin (similar to MapContainer.OriginX and OriginY)
private double _svgOriginX = 0.0; // SVG viewBox X origin (world coordinates)
private double _svgOriginY = 0.0; // SVG viewBox Y origin (world coordinates)
// State
private SLAMState? _currentState;
/// <summary>True when grid/pose/laser should be requested and displayed (Localizing or ScanMapping).</summary>
private bool IsMapDisplayActive => _currentState == SLAMState.Relocalizing || _currentState == SLAMState.Localizing || _currentState == SLAMState.ScanMapping;
/// <summary>Reference grid for view.</summary>
private OccupancyGridDto? ReferenceGrid => CurrentGrid;
private System.Timers.Timer? _updateTimer;
private System.Timers.Timer? _poseLaserTimer; // Timer for pose and laser scan updates (0.5s)
private readonly Lock _timerLock = new();
private readonly Lock _poseLaserTimerLock = new();
private bool _hasRequestedGridForLocalization = false;
// Robot pose and laser scan data
private PoseDto _currentPose = new()
{
Position = new RobotNet10.Shared.Numbers.Vector3 { X = 0, Y = 0, Z = 0 },
Orientation = new QuaternionGeometry { W = 1, X = 0, Y = 0, Z = 0 },
Timestamp = DateTime.UtcNow
};
private RobotNet10.Shared.Numbers.Vector3[]? _currentLaserScanPoints;
#region Lifecycle
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
// Load JavaScript module (canvas + view/events)
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "/js/mapLocalization.js");
// Setup zoom and pan event listeners
_dotNetObj = DotNetObjectReference.Create(this);
await _jsModule.InvokeVoidAsync("updateContainerRect", _dotNetObj, containerRef, nameof(OnContainerResize));
await _jsModule.InvokeVoidAsync("registerResizeObserver", _dotNetObj, containerRef, nameof(OnContainerResize));
await _jsModule.InvokeVoidAsync("addMouseWheelEventListener", _dotNetObj, containerRef, nameof(OnMouseWheel));
await _jsModule.InvokeVoidAsync("addMouseMoveEventListener", _dotNetObj, containerRef, nameof(OnMouseMove));
await _jsModule.InvokeVoidAsync("addMouseDownEventListener", _dotNetObj, containerRef, nameof(OnMouseDown));
await _jsModule.InvokeVoidAsync("addMouseUpEventListener", _dotNetObj, containerRef, nameof(OnMouseUp));
await CartographerClient.StartAsync();
// Get initial state and trigger OnStateChanged to handle grid loading and timer setup
var state = await CartographerClient.GetCurrentStateAsync();
OnStateChanged(state);
// Subscribe to events
CartographerClient.StateChanged += OnStateChanged;
}
#endregion
#region State & Grid
private void OnStateChanged(SLAMState state)
{
_currentState = state;
UpdateTimerBasedOnState();
// When entering InitializingLocalizing, Localizing or ScanMapping state, request OccupancyGrid once
if ((state == SLAMState.Relocalizing || state == SLAMState.Localizing || state == SLAMState.ScanMapping) && !_hasRequestedGridForLocalization)
{
_hasRequestedGridForLocalization = true;
_ = InvokeAsync(async () =>
{
await RequestOccupancyGridForLocalizationAsync();
// Start pose/laser scan timer after requesting grid
UpdatePoseLaserTimerBasedOnState();
});
}
else if (state == SLAMState.Relocalizing || state == SLAMState.Localizing || state == SLAMState.ScanMapping)
{
// Already requested grid, only update timer if grid is available
// This handles state transitions like Relocalizing -> Localizing where grid request is still in progress
if (CurrentGrid != null)
{
UpdatePoseLaserTimerBasedOnState();
}
// If CurrentGrid is null, the timer will be started by the InvokeAsync above when grid is loaded
}
else
{
UpdatePoseLaserTimerBasedOnState();
}
// Reset flag when leaving map-display states (InitializingLocalizing, Localizing, ScanMapping)
if (state != SLAMState.Relocalizing && state != SLAMState.Localizing && state != SLAMState.ScanMapping)
{
_hasRequestedGridForLocalization = false;
_trajectoryPath.Clear();
_ = InvokeAsync(async () =>
{
await ClearLaserScanAsync();
await UpdateTrajectoryPolylineAsync(); // Clear polyline via JsInvoke
RobotPoseRef.UpdatePose(0, 0, 0);
RobotPoseInfoRef.Update(0, 0, 0, 0);
});
}
else if (state == SLAMState.ScanMapping)
{
// Entering ScanMapping: clear trajectory until timer fetches nodes
_trajectoryPath.Clear();
_ = InvokeAsync(UpdateTrajectoryPolylineAsync);
}
}
#endregion
#region Grid & Map
private async Task LoadMapFromGrid(OccupancyGridDto grid)
{
CurrentGrid = grid;
_resolution = grid.Resolution;
_originX = grid.Origin.Position.X;
_mapOriginY = grid.Origin.Position.Y; // Original origin Y from grid
// Similar to MapContainer: OriginY = -ImageHeight * Resolution - mapData.OriginY
_originY = -grid.Height * grid.Resolution - grid.Origin.Position.Y;
_imageWidth = grid.Width * grid.Resolution;
_imageHeight = grid.Height * grid.Resolution;
// Update SVG origin from grid origin
UpdateSvgOrigin();
// Draw the grid on canvas
await DrawOccupancyGrid();
}
private void UpdateSvgOrigin()
{
var referenceGrid = ReferenceGrid;
if (referenceGrid == null)
{
_svgOriginX = 0.0;
_svgOriginY = 0.0;
return;
}
// SVG viewBox origin uses world coordinates from grid origin
_svgOriginX = referenceGrid.Origin.Position.X;
_svgOriginY = referenceGrid.Origin.Position.Y;
}
/// <summary>
/// Apply origin and image size from a grid.
/// </summary>
private void ApplyOriginFromGrid(OccupancyGridDto grid)
{
_resolution = grid.Resolution;
_originX = grid.Origin.Position.X;
_mapOriginY = grid.Origin.Position.Y;
_originY = -grid.Height * grid.Resolution - grid.Origin.Position.Y;
_imageWidth = grid.Width * grid.Resolution;
_imageHeight = grid.Height * grid.Resolution;
UpdateSvgOrigin();
}
/// <summary>
/// Apply trajectory from grid DTO to _trajectoryPath and update polyline if changed.
/// </summary>
/// <returns>True if trajectory was updated.</returns>
private async Task<bool> TryApplyTrajectoryFromGrid(OccupancyGridDto? grid)
{
if (grid?.TrajectoryNodes == null || grid.TrajectoryNodes.Length == 0)
return false;
var newPath = grid.TrajectoryNodes.Select(n => new Vector2(n.Pose.Position.X, n.Pose.Position.Y)).ToList();
if (newPath.Count == _trajectoryPath.Count && newPath.SequenceEqual(_trajectoryPath))
return false;
_trajectoryPath = newPath;
await UpdateTrajectoryPolylineAsync();
return true;
}
#endregion
#region Drawing
/// <summary>
/// Create a snapshot of grid for _lastDrawnGrid* tracking.
/// </summary>
private static OccupancyGridDto CreateDrawnGridSnapshot(OccupancyGridDto grid)
{
return new OccupancyGridDto
{
Resolution = grid.Resolution,
Width = grid.Width,
Height = grid.Height,
Origin = grid.Origin,
Version = grid.Version,
KnownCells = grid.KnownCells ?? [],
LastBaseUpdated = grid.LastBaseUpdated,
LastUpdated = grid.LastUpdated,
TrajectoryNodes = grid.TrajectoryNodes
};
}
private async Task DrawOccupancyGrid()
{
var referenceGrid = ReferenceGrid;
if (referenceGrid == null) return;
var grid = CurrentGrid;
// Check if we need to redraw grid
bool needsRedraw = false;
if (grid != null && (_lastDrawnGrid == null || _lastDrawnGrid.Version != grid.Version))
{
needsRedraw = true;
}
if (!needsRedraw)
{
return;
}
try
{
// Get container size to calculate scale for auto-fit
await Task.Delay(10);
var containerSize = await _jsModule.InvokeAsync<double[]>("getElementSize", containerRef);
var containerWidth = containerSize[0];
var containerHeight = containerSize[1];
if (containerWidth <= 0 || containerHeight <= 0)
{
return;
}
// Use reference grid for fit scale (base or updating)
var refW = referenceGrid.Width;
var refH = referenceGrid.Height;
if (_imageWidth <= 0 || _imageHeight <= 0)
{
_imageWidth = refW * referenceGrid.Resolution;
_imageHeight = refH * referenceGrid.Resolution;
_originX = referenceGrid.Origin.Position.X;
_mapOriginY = referenceGrid.Origin.Position.Y;
_originY = -referenceGrid.Height * referenceGrid.Resolution - referenceGrid.Origin.Position.Y;
}
if (_imageWidth > 0 && _imageHeight > 0)
{
_fitScale = Math.Min(containerWidth / _imageWidth, containerHeight / _imageHeight);
}
else
{
var scaleX = containerWidth / refW;
var scaleY = containerHeight / refH;
_fitScale = Math.Min(scaleX, scaleY);
}
if (_fitScale < MinFitScale)
_fitScale = MinFitScale;
if (_scale <= 0)
_scale = _fitScale;
// Draw grid
if (needsRedraw && grid != null)
{
await _jsModule.InvokeVoidAsync("setCanvasSize", mapCanvasBaseRef, grid.Width, grid.Height);
if (grid.KnownCells != null && grid.KnownCells.Length > 0)
{
await _jsModule.InvokeVoidAsync("drawOccupancyGrid",
mapCanvasBaseRef, grid.Width, grid.Height, grid.KnownCells);
}
else
{
await _jsModule.InvokeVoidAsync("clearCanvas", mapCanvasBaseRef);
}
_lastDrawnGrid = CreateDrawnGridSnapshot(grid);
}
var svgWidth = referenceGrid.Width * referenceGrid.Resolution;
var svgHeight = referenceGrid.Height * referenceGrid.Resolution;
await _jsModule.InvokeVoidAsync("setSvgConfig", mapContainerRef, svgWidth, svgHeight, _svgOriginX, _svgOriginY);
await ScaleFitContentAsync();
if (IsMapDisplayActive)
{
await UpdateRobotPoseSvgAsync();
if (_currentLaserScanPoints != null && _currentLaserScanPoints.Length > 0)
await DrawLaserScanAsync();
if (_currentState == SLAMState.ScanMapping)
await UpdateTrajectoryPolylineAsync();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[MapLocalization] DrawOccupancyGrid: Exception - {ex.Message}");
}
}
#endregion
#region View & Scale
/// <summary>
/// Public method to fit and center the map view.
/// Can be called from external components/pages.
/// </summary>
public async Task FitViewAsync()
{
if (ReferenceGrid == null) return;
// Recalculate fit scale based on current container and image dimensions
if (_imageWidth > 0 && _imageHeight > 0 && _containerRectWidth > 0 && _containerRectHeight > 0)
{
_fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight);
if (_fitScale < MinFitScale)
_fitScale = MinFitScale;
}
await ScaleFitContentAsync();
}
private void UpdateClientOrigin()
{
if (ReferenceGrid == null) return;
// Calculate client origin (exactly like MapContainer.SetViewMovement and ViewContainerResize)
// MapContainer: ClientOriginX = ViewContainerRectLeft + Left - OriginX * Scale
// MapContainer: ClientOriginY = ViewContainerRectTop + Top - OriginY * Scale
//
// MapLocalization now has the same structure as MapContainer:
// - scaleY(-1) is on SVG (class="map-editor"), not on wrapper div
// - This matches MapContainer's structure exactly
_clientOriginX = _containerRectLeft + _left - _originX * _scale;
_clientOriginY = _containerRectTop + _top - _originY * _scale;
}
public async Task ScaleFitContentAsync()
{
if (ReferenceGrid == null) return;
_scale = _fitScale;
var wrapperWidth = _imageWidth * _scale;
var wrapperHeight = _imageHeight * _scale;
var centerLeft = (_containerRectWidth - wrapperWidth) / 2;
var centerTop = (_containerRectHeight - wrapperHeight) / 2;
await SetViewMovement(centerLeft, centerTop);
await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, wrapperWidth, wrapperHeight);
await _jsModule.InvokeVoidAsync("setCanvasRect", mapCanvasBaseRef, wrapperWidth, wrapperHeight);
await _jsModule.InvokeVoidAsync("setCanvasRect", laserScanCanvasRef, wrapperWidth, wrapperHeight);
}
private async Task SetViewMovement(double left, double top)
{
_top = top;
_left = left;
// Update client origin (exactly like MapContainer.SetViewMovement)
UpdateClientOrigin();
if (_jsModule != null && ReferenceGrid != null)
{
var width = _imageWidth * _scale;
var height = _imageHeight * _scale;
await _jsModule.InvokeVoidAsync("setMapMovement", viewMovementRef, _top, _left, width, height);
}
}
#endregion
#region Event Handlers (JSInvokable)
[JSInvokable]
public void OnContainerResize(double x, double y, double width, double height, double top, double right, double bottom, double left)
{
_containerRectX = x;
_containerRectY = y;
_containerRectWidth = width;
_containerRectHeight = height;
_containerRectTop = top;
_containerRectRight = right;
_containerRectBottom = bottom;
_containerRectLeft = left;
// Update client origin (exactly like MapContainer.ViewContainerResize)
UpdateClientOrigin();
// Recalculate fit scale (exactly like MapContainer.ViewContainerResize)
// MapContainer: FitScale = Math.Min(ViewContainerRectWidth / ImageWidth, ViewContainerRectHeight / ImageHeight)
if (_imageWidth > 0 && _imageHeight > 0)
{
_fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight);
}
}
[JSInvokable]
public async Task OnMouseWheel(double deltaY, double clientX, double clientY)
{
if (ReferenceGrid == null) return;
// Calculate scale change (exactly like MapContainer.MouseWheelOnMapContainer)
double scaleChange;
if (deltaY > 0)
{
if (_scale <= _fitScale / 2) return;
scaleChange = _scale > _fitScale ? -(_scale / _fitScale) : -0.1;
}
else
{
if (_scale >= _fitScale * 100) return;
scaleChange = _scale < _fitScale ? 0.5 : (_scale / _fitScale);
}
// Store old scale before updating (exactly like MapContainer)
double oldScale = _scale;
// Update scale (exactly like MapContainer: Scale += scaleChange)
_scale += scaleChange;
// Set SVG rect first (exactly like MapContainer: ImageWidth * Scale, ImageHeight * Scale)
await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, _imageWidth * _scale, _imageHeight * _scale);
await _jsModule.InvokeVoidAsync("setCanvasRect", mapCanvasBaseRef, _imageWidth * _scale, _imageHeight * _scale);
await _jsModule.InvokeVoidAsync("setCanvasRect", laserScanCanvasRef, _imageWidth * _scale, _imageHeight * _scale);
// Calculate cursor position in world coordinates (exactly like MapContainer)
// MapContainer: CursorX = (clientX - ClientOriginX) / Scale
// MapContainer: CursorY = (ClientOriginY - clientY) / Scale
// Update CursorX/CursorY (they are in world coordinates)
CursorX = (clientX - _clientOriginX) / oldScale;
CursorY = (_clientOriginY - clientY) / oldScale;
MapMousePositionRef.Update(CursorX, CursorY);
// Calculate mouse position relative to map origin (exactly like MapContainer)
// MapContainer: mouseX = CursorX - OriginX
// MapContainer: mouseY = CursorY - MapData.OriginY (use original origin Y, not transformed)
double mouseX = CursorX - _originX;
double mouseY = CursorY - _mapOriginY;
// Calculate movement adjustment (exactly like MapContainer.MouseWheelOnMapContainer)
// MapContainer: Left - mouseX * scaleChange, Top - (ImageHeight - mouseY) * scaleChange
await SetViewMovement(_left - mouseX * scaleChange, _top - (_imageHeight - mouseY) * scaleChange);
// Update robot pose after scale change
if (IsMapDisplayActive)
{
await UpdateRobotPoseSvgAsync();
}
}
[JSInvokable]
public async Task OnMouseMove(double clientX, double clientY, long buttons, bool ctrlKey, double movementX, double movementY)
{
// Calculate cursor position in world coordinates (exactly like MapContainer.MouseMoveOnMapContainer)
// MapContainer: CursorX = (clientX - ClientOriginX) / Scale (world coordinates in meters)
// MapContainer: CursorY = (ClientOriginY - clientY) / Scale (world coordinates in meters)
CursorX = (clientX - _clientOriginX) / _scale;
CursorY = (_clientOriginY - clientY) / _scale;
MapMousePositionRef.Update(CursorX, CursorY);
// Update RobotGoalPose mouse position (line goal->mouse and circle)
if (buttons == 1) // Right mouse button down: update goal pose to current mouse
{
GoalPoseRef?.SetMousePosition(CursorX, CursorY);
}
else if (buttons == 4) // Middle mouse button
{
await SetViewMovement(_left + movementX, _top + movementY);
// Update robot pose after pan (pose position in SVG doesn't change, but we update to ensure consistency)
if (IsMapDisplayActive)
{
await UpdateRobotPoseSvgAsync();
}
}
}
[JSInvokable]
public async Task OnMouseDown(int button, bool altKey, bool ctrlKey, bool shiftKey)
{
Console.WriteLine($"OnMouseDown: button={button}, altKey={altKey}, ctrlKey={ctrlKey}, shiftKey={shiftKey}");
if (button == 0) // Right mouse button: set robot pose and goal pose for RobotGoalPose
{
var robotX = _currentPose.Position.X;
var robotY = _currentPose.Position.Y;
GoalPoseRef?.SetRobotPosition(robotX, robotY);
GoalPoseRef?.SetGoalPosition(CursorX, CursorY);
GoalPoseRef?.SetMousePosition(CursorX, CursorY);
GoalPoseRef?.Show();
}
await Task.CompletedTask;
}
[JSInvokable]
public async Task OnMouseUp(int button, bool altKey, bool ctrlKey, bool shiftKey)
{
if (button == 0) // Right mouse button up: áp dụng Optimize (ẩn nếu goalmouse < 1 m, ngược lại clamp 1 m)
GoalPoseRef?.Optimize();
await Task.CompletedTask;
}
#endregion
#region Timers
/// <summary>
/// Localizing: grid base requested once only (no timer).
/// ScanMapping: timer 3s to poll grid base + grid updating + trajectory nodes.
/// </summary>
private void UpdateTimerBasedOnState()
{
lock (_timerLock)
{
if (_currentState == SLAMState.ScanMapping)
{
if (_updateTimer == null)
{
_updateTimer = new System.Timers.Timer(GridPollIntervalMs)
{
AutoReset = false
};
_updateTimer.Elapsed += OnTimerElapsed;
_updateTimer.Start();
}
else if (!_updateTimer.Enabled)
{
_updateTimer.Start();
}
}
else
{
if (_updateTimer != null)
{
_updateTimer.Stop();
_updateTimer.Elapsed -= OnTimerElapsed;
_updateTimer.Dispose();
_updateTimer = null;
}
}
}
}
/// <summary>
private void OnTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
_ = InvokeAsync(async () =>
{
try
{
if (_currentState != SLAMState.ScanMapping)
{
lock (_timerLock)
{
if (_updateTimer != null && _currentState == SLAMState.ScanMapping)
_updateTimer.Start();
}
return;
}
bool hasUpdates = false;
var grid = await CartographerClient.GetOccupancyGridAsync(_lastGridUpdateTime);
if (grid != null)
{
CurrentGrid = grid;
_lastGridUpdateTime = grid.LastBaseUpdated;
hasUpdates = true;
UpdateSvgOrigin();
await TryApplyTrajectoryFromGrid(grid);
}
if (hasUpdates && ReferenceGrid != null)
{
// BUG FIX: Always update origin when grid changes, not just when dimensions change significantly
// Grid origin can change when grid grows even if dimensions stay similar
if (CurrentGrid != null)
{
// Check if origin changed (which indicates grid grow/shift)
var originChanged = Math.Abs(_originX - CurrentGrid.Origin.Position.X) > 0.0001 ||
Math.Abs(_mapOriginY - CurrentGrid.Origin.Position.Y) > 0.0001;
// Check if dimensions changed significantly
var dimensionsChanged = _imageWidth == 0 || _imageHeight == 0 ||
Math.Abs(_imageWidth - CurrentGrid.Width * CurrentGrid.Resolution) > 0.001 ||
Math.Abs(_imageHeight - CurrentGrid.Height * CurrentGrid.Resolution) > 0.001;
if (dimensionsChanged || originChanged)
{
Console.WriteLine($"[MapLocalization] Grid changed: dims={dimensionsChanged}, origin={originChanged}, " +
$"size={CurrentGrid.Width}x{CurrentGrid.Height}, " +
$"origin=[{CurrentGrid.Origin.Position.X:F3},{CurrentGrid.Origin.Position.Y:F3}], " +
$"prevOrigin=[{_originX:F3},{_mapOriginY:F3}]");
await LoadMapFromGrid(CurrentGrid);
}
else
await DrawOccupancyGrid();
}
StateHasChanged();
}
lock (_timerLock)
{
if (_updateTimer != null && _currentState == SLAMState.ScanMapping)
_updateTimer.Start();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[MapLocalization] Error in OnTimerElapsed: {ex.Message}");
}
});
}
private async Task RequestOccupancyGridForLocalizationAsync()
{
try
{
var requestTime = DateTime.MinValue;
for (int i = 0; i < GridRequestMaxRetries; i++)
{
if (!IsMapDisplayActive)
break;
var grid = await CartographerClient.GetOccupancyGridAsync(requestTime);
if (grid == null)
{
await Task.Delay(GridRequestRetryDelayMs);
continue;
}
CurrentGrid = grid;
_lastGridUpdateTime = grid.LastBaseUpdated;
await LoadMapFromGrid(grid);
await TryApplyTrajectoryFromGrid(grid);
break;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[MapLocalization] Error requesting OccupancyGrid for localization: {ex.Message}");
}
}
#endregion
private void UpdatePoseLaserTimerBasedOnState()
{
lock (_poseLaserTimerLock)
{
if (IsMapDisplayActive)
{
if (_poseLaserTimer == null)
{
_poseLaserTimer = new System.Timers.Timer(PoseLaserPollIntervalMs)
{
AutoReset = false
};
_poseLaserTimer.Elapsed += OnPoseLaserTimerElapsed;
_poseLaserTimer.Start();
}
else if (!_poseLaserTimer.Enabled)
{
_poseLaserTimer.Start();
}
}
else
{
if (_poseLaserTimer != null)
{
_poseLaserTimer.Stop();
_poseLaserTimer.Elapsed -= OnPoseLaserTimerElapsed;
_poseLaserTimer.Dispose();
_poseLaserTimer = null;
}
}
}
}
private void OnPoseLaserTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
_ = InvokeAsync(async () =>
{
try
{
// Get robot pose
var pose = await CartographerClient.GetCurrentPoseAsync();
if (pose != null)
{
_currentPose = pose;
await UpdateRobotPoseSvgAsync();
}
// Get laser scan
var laserScanPoints = await CartographerClient.GetSamplePointCloudAsync();
if (laserScanPoints != null && laserScanPoints.Length > 0)
{
_currentLaserScanPoints = laserScanPoints;
var refGrid = ReferenceGrid;
if (refGrid != null && refGrid.Width > 0 && refGrid.Height > 0)
await DrawLaserScanAsync();
}
lock (_poseLaserTimerLock)
{
if (_poseLaserTimer != null && IsMapDisplayActive)
{
_poseLaserTimer.Start();
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[MapLocalization] Error in OnPoseLaserTimerElapsed: {ex.Message}");
}
});
}
#region Helpers
/// <summary>
/// Lấy goal pose từ RobotGoalPose (GoalX, GoalY, GoalYaw) dưới dạng PoseDto để gửi SetInitialPose.
/// </summary>
public PoseDto? GetGoalPoseDto()
{
if (GoalPoseRef == null)
return null;
var q = QuaternionNumbers.FromYawRadian(GoalPoseRef.GoalYaw);
return new PoseDto
{
Position = new RobotNet10.Shared.Numbers.Vector3 { X = GoalPoseRef.GoalX, Y = GoalPoseRef.GoalY, Z = 0 },
Orientation = new QuaternionGeometry(q.X, q.Y, q.Z, q.W),
Timestamp = DateTime.UtcNow
};
}
private async Task UpdateRobotPoseSvgAsync()
{
try
{
// Hide robot pose if scale is invalid or no grid (base or updating for ScanMapping)
var referenceGrid = CurrentGrid;
if (_scale <= 0 || referenceGrid == null)
{
return;
}
var robotX = _currentPose.Position.X;
var robotY = _currentPose.Position.Y;
var yaw = _currentPose.Orientation.ToYawRadian();
RobotPoseRef.UpdatePose(robotX, robotY, yaw);
RobotPoseInfoRef.Update(robotX, robotY, yaw, _currentPose.Score);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[MapLocalization] UpdateRobotPoseSvgAsync: Exception - {ex.Message}");
}
}
private (int x, int y) WorldToGrid(double worldX, double worldY)
{
var grid = ReferenceGrid;
if (grid == null)
return (0, 0);
var relativeX = worldX - grid.Origin.Position.X;
var relativeY = worldY - grid.Origin.Position.Y;
var gridX = (int)Math.Floor(relativeX / grid.Resolution);
var gridY = (int)Math.Floor(relativeY / grid.Resolution);
return (gridX, gridY);
}
/// <summary>
/// Update trajectory polyline via JsInvoke (ElementReference, no StateHasChanged).
/// Only updates points data; other attributes (fill, stroke, stroke-width, opacity) are fixed in markup.
/// </summary>
private async Task UpdateTrajectoryPolylineAsync()
{
var pointsStr = "";
if (_trajectoryPath.Count > 1)
{
var worldPoints = _trajectoryPath.Select(p => $"{p.X},{p.Y}");
pointsStr = string.Join(" ", worldPoints);
}
try
{
await _jsModule.InvokeVoidAsync("setPolylinePointsOnly",
trajectoryPolylineRef,
pointsStr);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[MapLocalization] UpdateTrajectoryPolylineAsync: {ex.Message}");
}
}
private async Task DrawLaserScanAsync()
{
var referenceGrid = ReferenceGrid;
if (_currentLaserScanPoints == null || _currentLaserScanPoints.Length == 0 || referenceGrid == null)
{
return;
}
try
{
if (referenceGrid.Width <= 0 || referenceGrid.Height <= 0)
{
return;
}
// Set canvas size
await _jsModule.InvokeVoidAsync("setCanvasSize", laserScanCanvasRef, referenceGrid.Width, referenceGrid.Height);
// laserScanPoints from GetSamplePointCloudAsync() are already in global (map) coordinates.
// Only need to convert to grid coordinates via WorldToGrid (uses grid.Origin).
// laserScanCanvasRef uses class "map-canvas" with CSS transform: scale(1, -1), so the canvas
// is rendered with Y flipped: buffer y=0 (top) appears at bottom, buffer y=height-1 (bottom) at top.
// We pass (gridX, gridY) directly: grid Y increases upward. Drawing at (gridX, gridY) puts
// world-Y-up at large buffer y; after scale(1,-1) that displays at visual top. Correct.
var mapPoints = new List<(double X, double Y)>();
foreach (var point in _currentLaserScanPoints)
{
// point.X, point.Y are already world coordinates; WorldToGrid applies Origin transform
var (gridX, gridY) = WorldToGrid(point.X, point.Y);
if (gridX >= 0 && gridX < referenceGrid.Width && gridY >= 0 && gridY < referenceGrid.Height)
{
// Pass grid coords (Y-up); map-canvas CSS scale(1,-1) handles display flip
mapPoints.Add((gridX, gridY));
}
}
if (mapPoints.Count > 0)
{
var pointsArray = mapPoints.Select(p => new double[] { p.X, p.Y }).ToArray();
await _jsModule.InvokeVoidAsync("drawPointsOnCanvas", laserScanCanvasRef, pointsArray, "#FF0000", 0.5);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[MapLocalization] DrawLaserScanAsync: Exception - {ex.Message}");
}
}
private async Task ClearLaserScanAsync()
{
await _jsModule.InvokeVoidAsync("clearCanvas", laserScanCanvasRef);
}
#endregion
#region Dispose
public async ValueTask DisposeAsync()
{
CartographerClient.StateChanged -= OnStateChanged;
lock (_timerLock)
{
if (_updateTimer != null)
{
_updateTimer.Stop();
_updateTimer.Elapsed -= OnTimerElapsed;
_updateTimer.Dispose();
_updateTimer = null;
}
}
lock (_poseLaserTimerLock)
{
if (_poseLaserTimer != null)
{
_poseLaserTimer.Stop();
_poseLaserTimer.Elapsed -= OnPoseLaserTimerElapsed;
_poseLaserTimer.Dispose();
_poseLaserTimer = null;
}
}
await _jsModule.DisposeAsync();
_dotNetObj?.Dispose();
}
#endregion
}

View File

@@ -0,0 +1,55 @@
.map-localization-container {
background-color: #bfbfbf;
width: 100%;
height: 100%;
cursor: not-allowed;
border-top: solid 2px #808080;
overflow: hidden;
position: relative;
min-height: 0;
display: flex;
flex-direction: column;
}
.map-view-movement {
width: fit-content;
height: fit-content;
position: absolute;
cursor: default;
overflow: hidden;
}
.map-canvas {
position: absolute;
top: 0;
left: 0;
transform: scale(1, -1);
transform-origin: center;
pointer-events: none;
image-rendering: pixelated;
}
.map-editor {
position: absolute;
top: 0;
left: 0;
transform: scale(1, -1);
transform-origin: center;
}
.map-mouse-position {
position: absolute;
bottom: 10px;
left: 10px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
display: flex;
gap: 10px;
}
.map-mouse-position span {
white-space: nowrap;
}

View File

@@ -0,0 +1,17 @@
<div style="position: absolute; top: 5px; left: 5px; background-color: white; border-radius: 4px; color: #00cc66; font-size: 15px; font-weight: bold;">
<div class="px-1 pt-1">
<span class="mdi mdi-cursor-default-outline" /> @X, @Y
</div>
</div>
@code {
private string X = "0.000";
private string Y = "0.000";
public void Update(double x, double y)
{
X = x.ToString("N3");
Y = y.ToString("N3");
StateHasChanged();
}
}

View File

@@ -0,0 +1,41 @@
@using RobotNet10.Shared.Geometry
@if (_visible)
{
<!-- Goal crosshair -->
<circle cx="@_gx" cy="@_gy" r="0.08" fill="none" stroke="#FF4500" stroke-width="0.03" />
<line x1="@_gxLeft" y1="@_gy" x2="@_gxRight" y2="@_gy" stroke="#FF4500" stroke-width="0.02" />
<line x1="@_gx" y1="@_gyBottom" x2="@_gx" y2="@_gyTop" stroke="#FF4500" stroke-width="0.02" />
<!-- Reference points -->
@foreach (var pt in _referencePoints)
{
<line x1="@_gx" y1="@_gy" x2="@pt.X" y2="@pt.Y" stroke="#00FFFF" stroke-width="0.01" stroke-dasharray="0.05,0.03" />
<circle cx="@pt.X" cy="@pt.Y" r="0.04" fill="#00FFFF" stroke="#008B8B" stroke-width="0.015" />
}
}
@code {
private bool _visible;
private double _gx, _gy;
private double _gxLeft, _gxRight, _gyBottom, _gyTop;
private List<(double X, double Y)> _referencePoints = [];
public void Update(Pose goal, List<(double X, double Y)> referencePoints)
{
_gx = goal.Position.X;
_gy = goal.Position.Y;
_gxLeft = _gx - 0.12;
_gxRight = _gx + 0.12;
_gyBottom = _gy - 0.12;
_gyTop = _gy + 0.12;
_referencePoints = referencePoints;
_visible = true;
StateHasChanged();
}
public void Clear()
{
_visible = false;
_referencePoints = [];
StateHasChanged();
}
}

View File

@@ -0,0 +1,35 @@
@using Microsoft.AspNetCore.Components
<!-- Robot pose visualization component. SVG trong MapLocalization dùng viewBox (met), scaleY(-1); x, y, r đơn vị mét. -->
<g transform="@Transform">
<!-- Robot image (width: 1.106m, height: 0.606m) -->
<image href="images/AS_AGV SLAM V3-CK 02.png"
width="1.106"
height="0.606"
transform="translate(-0.553, -0.303)" />
</g>
@code {
private double _currentX = -1000;
private double _currentY = -1000;
private double _yaw = 0;
/// <summary>
/// Transform string for SVG group element
/// </summary>
private string Transform => $"translate({_currentX:F6},{_currentY:F6}) rotate({_yaw * 180.0 / Math.PI:F2})";
/// <summary>
/// Update robot pose với vị trí và góc quay
/// </summary>
/// <param name="x">X position trong world coordinates</param>
/// <param name="y">Y position trong world coordinates</param>
/// <param name="yaw">Yaw angle trong radians</param>
public void UpdatePose(double x, double y, double yaw)
{
_currentX = x;
_currentY = y;
_yaw = yaw;
StateHasChanged();
}
}

View File

@@ -0,0 +1,21 @@
<div style="position: absolute; top: 5px; right: 5px; background-color: white; border-radius: 4px; color: #00cc66; font-size: 15px; font-weight: bold;">
<div class="px-1 pt-1">
@X, @Y <span class="mdi mdi-compass-outline" />@Yaw - @Score
</div>
</div>
@code {
private string X = "0.000";
private string Y = "0.000";
private string Yaw = "0.000";
private string Score = "00.00%";
public void Update(double x, double y, double yaw, double score)
{
X = x.ToString("N3");
Y = y.ToString("N3");
Yaw = (yaw * 180.0 / Math.PI).ToString("N3"); // Convert radians to degrees
Score = score.ToString("P1", System.Globalization.CultureInfo.InvariantCulture);
StateHasChanged();
}
}

View File

@@ -0,0 +1,45 @@
@using MudBlazor
@using RobotNet10.RobotApp.Client.Shared.SLAM
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Select Map for Localization</MudText>
</TitleContent>
<DialogContent>
@if (Maps == null || Maps.Length == 0)
{
<MudText Typo="Typo.body1" Color="Color.Secondary">
No maps available. Please create a map first.
</MudText>
}
else
{
<MudList T="MapInfoDto">
@foreach (var map in Maps)
{
<MudListItem OnClick="@(() => SelectMap(map.Name))">
<MudText Typo="Typo.body1">@map.Name</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
Created: @map.CreatedDate.ToString("yyyy-MM-dd HH:mm:ss")
</MudText>
</MudListItem>
}
</MudList>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
[Parameter] public MapInfoDto[] Maps { get; set; } = Array.Empty<MapInfoDto>();
private void Cancel() => Dialog.Cancel();
private void SelectMap(string mapName)
{
Dialog.Close(DialogResult.Ok(mapName));
}
}