Files
Denso/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp.Client/Components/Devices/LidarCard.razor
2026-07-03 16:31:37 +07:00

525 lines
20 KiB
Plaintext

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