525 lines
20 KiB
Plaintext
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
|
|
}
|