Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user