Initial commit
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
@page "/motion/manualcontrol"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Components.Motion
|
||||
@using RobotNet10.RobotApp.Client.Shared.Motion
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>Manual Control</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
@* Disconnected Overlay *@
|
||||
<MudOverlay Visible="@(!isHubReady)" DarkBackground="true" ZIndex="9999" AutoClose="false">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Typo="Typo.h5" Color="Color.Primary">MotionHub Disconnected</MudText>
|
||||
<MudText Typo="Typo.body1">Waiting for connection...</MudText>
|
||||
</MudStack>
|
||||
</MudOverlay>
|
||||
|
||||
<MudStack Spacing="4">
|
||||
@* 4 Cards Grid *@
|
||||
<MudGrid>
|
||||
@* Card 1: Manual Control *@
|
||||
<MudItem xs="12" md="6">
|
||||
<ManualControlCard HubClient="motionHubClient" IsHubReady="isHubReady" />
|
||||
</MudItem>
|
||||
|
||||
@* Card 2: Odometry (from OdometryService → XLOC) *@
|
||||
<MudItem xs="12" md="6">
|
||||
<OdometryCard Odometry="currentOdometry" IsHubReady="odometryHubReady" />
|
||||
</MudItem>
|
||||
|
||||
@* Card 3: Lift Module *@
|
||||
<MudItem xs="12" md="6">
|
||||
<LiftModuleCard HubClient="motionHubClient" IsHubReady="isHubReady" />
|
||||
</MudItem>
|
||||
|
||||
@* Card 4: Rotation Module *@
|
||||
<MudItem xs="12" md="6">
|
||||
<RotationModuleCard HubClient="motionHubClient" IsHubReady="isHubReady" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Inject] private MotionHubClient MotionHubClientInjected { get; set; } = null!;
|
||||
[Inject] private OdometryHubClient OdometryHubClientInjected { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private MotionHubClient motionHubClient = null!;
|
||||
private bool isHubReady = false;
|
||||
|
||||
private OdometryHubClient odometryHubClient = null!;
|
||||
private bool odometryHubReady = false;
|
||||
private OdometryDto? currentOdometry;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
motionHubClient = MotionHubClientInjected;
|
||||
odometryHubClient = OdometryHubClientInjected;
|
||||
|
||||
// Subscribe to connection state changes
|
||||
motionHubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
odometryHubClient.ConnectionStateChanged += OnOdometryConnectionStateChanged;
|
||||
odometryHubClient.OdometryReceived += OnOdometryReceived;
|
||||
|
||||
try
|
||||
{
|
||||
await motionHubClient.StartAsync();
|
||||
isHubReady = motionHubClient.IsConnected;
|
||||
|
||||
// Wait a bit for connection to establish
|
||||
await Task.Delay(500);
|
||||
isHubReady = motionHubClient.IsConnected;
|
||||
|
||||
await odometryHubClient.StartAsync();
|
||||
odometryHubReady = odometryHubClient.IsConnected;
|
||||
|
||||
if (odometryHubReady)
|
||||
{
|
||||
currentOdometry = await odometryHubClient.GetCurrentOdometryAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error initializing: {ex.Message}", Severity.Error);
|
||||
isHubReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
isHubReady = state == HubConnectionState.Connected;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnOdometryConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
odometryHubReady = state == HubConnectionState.Connected;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnOdometryReceived(OdometryDto dto)
|
||||
{
|
||||
currentOdometry = dto;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (motionHubClient != null)
|
||||
{
|
||||
motionHubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
}
|
||||
|
||||
if (odometryHubClient != null)
|
||||
{
|
||||
odometryHubClient.ConnectionStateChanged -= OnOdometryConnectionStateChanged;
|
||||
odometryHubClient.OdometryReceived -= OnOdometryReceived;
|
||||
}
|
||||
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
@page "/motion/navigation-monitor"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Shared.NavigationMonitor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using MudBlazor
|
||||
@using ApexCharts
|
||||
@using Color = MudBlazor.Color
|
||||
@using Size = MudBlazor.Size
|
||||
|
||||
<PageTitle>Navigation Monitor</PageTitle>
|
||||
<div style="height: 100%; width:100%; display: flex; flex-direction: column; overflow-x: hidden; overflow: auto" class="p-2">
|
||||
@* Connection status *@
|
||||
@if (!hubClient.IsConnected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||||
Disconnected from Navigation Monitor hub. Reconnecting...
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* Controls *@
|
||||
<MudPaper Class="pa-3 mb-2" Elevation="1">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudSwitch Value="telemetryEnabled" Color="Color.Primary"
|
||||
Label="Enable Telemetry" Disabled="@(!hubClient.IsConnected)"
|
||||
ValueChanged="@((bool v) => OnTelemetryToggle(v))" T="bool" />
|
||||
<MudSwitch Value="safetyStopEnabled" Color="Color.Error"
|
||||
Label="Enable Safety Stop" Disabled="@(!hubClient.IsConnected)"
|
||||
ValueChanged="@((bool v) => OnSafetyStopToggle(v))" T="bool" />
|
||||
<MudSpacer />
|
||||
@if (telemetryEnabled)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Success" Size="Size.Small">@($"{updateFrequencyHz:F0} Hz")</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small">Disabled</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* Safety Stop Latch Banner *@
|
||||
@if (safetyStopLatched)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="false" Class="mb-2" NoIcon="false">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>SAFETY STOP ACTIVE</strong> — @safetyStopReason
|
||||
</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Warning"
|
||||
OnClick="OnReleaseSafetyStop"
|
||||
Disabled="@(!hubClient.IsConnected)">
|
||||
Release Safety Stop
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* DockTo Telemetry - special panel when docking *@
|
||||
@if (telemetry.DockTo is not null)
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-3" Elevation="2" Style="border-left: 4px solid var(--mud-palette-info);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.GpsFixed" Color="Color.Info" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Info">Docking Telemetry</MudText>
|
||||
<MudSpacer />
|
||||
<MudChip T="string" Color="@GetDockPhaseColor()" Size="Size.Small">@telemetry.DockTo.Phase</MudChip>
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Outlined">@telemetry.DockTo.Direction</MudChip>
|
||||
</MudStack>
|
||||
@{
|
||||
var dock = telemetry.DockTo!;
|
||||
var svgBounds = GetDockSvgBounds(dock, telemetry.X, telemetry.Y);
|
||||
var vb = svgBounds;
|
||||
}
|
||||
<MudGrid>
|
||||
@* SVG Visualization - left side *@
|
||||
<MudItem xs="12" md="7">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Docking Path</MudText>
|
||||
<div style="background: #1e1e2e; border-radius: 4px; padding: 8px; height: 100%;">
|
||||
<svg viewBox="@FormatViewBox(vb)"
|
||||
width="100%" height="280" preserveAspectRatio="xMidYMid meet"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@* Grid reference lines *@
|
||||
<line x1="@vb.MinX.ToString("F3")" y1="0" x2="@((vb.MinX + vb.Width).ToString("F3"))" y2="0"
|
||||
stroke="#444" stroke-width="@((vb.Width * 0.003).ToString("F4"))" stroke-dasharray="@((vb.Width * 0.01).ToString("F4"))" />
|
||||
<line x1="0" y1="@vb.MinY.ToString("F3")" x2="0" y2="@((vb.MinY + vb.Height).ToString("F3"))"
|
||||
stroke="#444" stroke-width="@((vb.Width * 0.003).ToString("F4"))" stroke-dasharray="@((vb.Width * 0.01).ToString("F4"))" />
|
||||
|
||||
@* Waypoints path *@
|
||||
@if (dock.Waypoints.Count >= 2)
|
||||
{
|
||||
var pathPoints = string.Join(" ", dock.Waypoints.Select(w => $"{w.X.ToString("F3")},{FlipY(w.Y).ToString("F3")}"));
|
||||
<polyline points="@pathPoints"
|
||||
fill="none" stroke="#5b9bd5" stroke-width="@((vb.Width * 0.008).ToString("F4"))"
|
||||
stroke-dasharray="@((vb.Width * 0.015).ToString("F4"))" stroke-linecap="round" opacity="0.8" />
|
||||
}
|
||||
|
||||
@* Distance to goal line *@
|
||||
<line x1="@telemetry.X.ToString("F3")" y1="@FlipY(telemetry.Y).ToString("F3")"
|
||||
x2="@dock.GoalX.ToString("F3")" y2="@FlipY(dock.GoalY).ToString("F3")"
|
||||
stroke="#888" stroke-width="@((vb.Width * 0.003).ToString("F4"))"
|
||||
stroke-dasharray="@((vb.Width * 0.008).ToString("F4"))" opacity="0.5" />
|
||||
|
||||
@* Start Node *@
|
||||
<circle cx="@dock.StartX.ToString("F3")" cy="@FlipY(dock.StartY).ToString("F3")"
|
||||
r="@((vb.Width * 0.025).ToString("F4"))" fill="#4caf50" stroke="#fff" stroke-width="@((vb.Width * 0.005).ToString("F4"))" />
|
||||
<text x="@dock.StartX.ToString("F3")" y="@((FlipY(dock.StartY) - vb.Width * 0.04).ToString("F3"))"
|
||||
text-anchor="middle" fill="#4caf50" font-size="@((vb.Width * 0.04).ToString("F4"))" font-weight="bold">Start</text>
|
||||
|
||||
@* Goal Node with direction arrow *@
|
||||
@{
|
||||
var goalSvgY = FlipY(dock.GoalY);
|
||||
var arrowLen = vb.Width * 0.06;
|
||||
var goalArrowX = dock.GoalX + arrowLen * Math.Cos(dock.GoalTheta);
|
||||
var goalArrowY = goalSvgY - arrowLen * Math.Sin(dock.GoalTheta);
|
||||
}
|
||||
<circle cx="@dock.GoalX.ToString("F3")" cy="@goalSvgY.ToString("F3")"
|
||||
r="@((vb.Width * 0.025).ToString("F4"))" fill="#f44336" stroke="#fff" stroke-width="@((vb.Width * 0.005).ToString("F4"))" />
|
||||
<line x1="@dock.GoalX.ToString("F3")" y1="@goalSvgY.ToString("F3")"
|
||||
x2="@goalArrowX.ToString("F3")" y2="@goalArrowY.ToString("F3")"
|
||||
stroke="#f44336" stroke-width="@((vb.Width * 0.008).ToString("F4"))" marker-end="url(#arrowGoal)" />
|
||||
<text x="@dock.GoalX.ToString("F3")" y="@((goalSvgY - vb.Width * 0.04).ToString("F3"))"
|
||||
text-anchor="middle" fill="#f44336" font-size="@((vb.Width * 0.04).ToString("F4"))" font-weight="bold">Goal</text>
|
||||
|
||||
@* Robot current position (triangle pointing in Theta direction) *@
|
||||
@{
|
||||
var rSvgY = FlipY(telemetry.Y);
|
||||
var rSize = vb.Width * 0.03;
|
||||
var rTheta = telemetry.Theta;
|
||||
// Triangle vertices: tip forward, two rear corners
|
||||
var tipX = telemetry.X + rSize * 1.5 * Math.Cos(rTheta);
|
||||
var tipY = rSvgY - rSize * 1.5 * Math.Sin(rTheta);
|
||||
var leftX = telemetry.X + rSize * Math.Cos(rTheta + 2.5);
|
||||
var leftY = rSvgY - rSize * Math.Sin(rTheta + 2.5);
|
||||
var rightX = telemetry.X + rSize * Math.Cos(rTheta - 2.5);
|
||||
var rightY = rSvgY - rSize * Math.Sin(rTheta - 2.5);
|
||||
}
|
||||
<polygon points="@($"{tipX.ToString("F3")},{tipY.ToString("F3")} {leftX.ToString("F3")},{leftY.ToString("F3")} {rightX.ToString("F3")},{rightY.ToString("F3")}")"
|
||||
fill="#2196f3" stroke="#fff" stroke-width="@((vb.Width * 0.004).ToString("F4"))" />
|
||||
<text x="@telemetry.X.ToString("F3")" y="@((rSvgY - vb.Width * 0.045).ToString("F3"))"
|
||||
text-anchor="middle" fill="#2196f3" font-size="@((vb.Width * 0.035).ToString("F4"))">Robot</text>
|
||||
|
||||
@* Arrow marker definition *@
|
||||
<defs>
|
||||
<marker id="arrowGoal" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#f44336" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
@* Docking Info - right side *@
|
||||
<MudItem xs="12" md="5">
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mb-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Phase</td>
|
||||
<td class="text-right">
|
||||
<MudChip T="string" Color="@GetDockPhaseColor()" Size="Size.Small">@telemetry.DockTo.Phase</MudChip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Direction</td><td class="text-right"><strong>@telemetry.DockTo.Direction</strong></td></tr>
|
||||
<tr>
|
||||
<td>Fine Positioning Retries</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(telemetry.DockTo.RetryCount > 0 ? Color.Warning : Color.Default)">
|
||||
<strong>@telemetry.DockTo.RetryCount / @telemetry.DockTo.MaxRetries</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Waypoints</td><td class="text-right"><strong>@telemetry.DockTo.TotalWaypoints</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr><td>Goal X</td><td class="text-right"><strong>@telemetry.DockTo.GoalX.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Goal Y</td><td class="text-right"><strong>@telemetry.DockTo.GoalY.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Goal Theta</td><td class="text-right"><strong>@((telemetry.DockTo.GoalTheta * 180 / Math.PI).ToString("F2"))°</strong></td></tr>
|
||||
<tr>
|
||||
<td>Distance to Goal</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(telemetry.DistanceToGoal < 0.1 ? Color.Success : Color.Info)">
|
||||
<strong>@telemetry.DistanceToGoal.ToString("F3") m</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@* Telemetry Data Cards *@
|
||||
<MudGrid>
|
||||
@* Position *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="1" Style="height:180px">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Position</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr><td>X</td><td class="text-right"><strong>@telemetry.X.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Y</td><td class="text-right"><strong>@telemetry.Y.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Theta</td><td class="text-right"><strong>@((telemetry.Theta * 180 / Math.PI).ToString("F2"))°</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Velocity *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="1" Style="height:180px">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Velocity</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr><td>Linear</td><td class="text-right"><strong>@telemetry.LinearVelocity.ToString("F3") m/s</strong></td></tr>
|
||||
<tr><td>Angular</td><td class="text-right"><strong>@telemetry.AngularVelocity.ToString("F3") rad/s</strong></td></tr>
|
||||
<tr><td>Confidence</td><td class="text-right"><strong>@telemetry.ModelConfidence.ToString("F2")</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Navigation State *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="1" Style="height:180px">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Navigation</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>State</td>
|
||||
<td class="text-right">
|
||||
<MudChip T="string" Color="@GetNavStateColor()" Size="Size.Small">@telemetry.NavigationState</MudChip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Driving</td><td class="text-right"><strong>@(telemetry.Driving ? "Yes" : "No")</strong></td></tr>
|
||||
<tr><td>To Goal</td><td class="text-right"><strong>@telemetry.DistanceToGoal.ToString("F3") m</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Tracking (CTE) *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="1">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Tracking</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cross-Track Error</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(telemetry.CrossTrackError > safetyConfig.MaxCrossTrackError ? Color.Error : Color.Default)">
|
||||
<strong>@telemetry.CrossTrackError.ToString("F3") m</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Heading Error</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(Math.Abs(telemetry.HeadingError) * 180 / Math.PI > safetyConfig.MaxHeadingError ? Color.Error : Color.Default)">
|
||||
<strong>@((telemetry.HeadingError * 180 / Math.PI).ToString("F2"))°</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Acceleration *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="1">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Acceleration</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Linear</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(Math.Abs(telemetry.LinearAcceleration) > safetyConfig.MaxLinearAcceleration ? Color.Warning : Color.Default)">
|
||||
<strong>@telemetry.LinearAcceleration.ToString("F2") m/s²</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Angular</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(Math.Abs(telemetry.AngularAcceleration) > safetyConfig.MaxAngularVelocity ? Color.Warning : Color.Default)">
|
||||
<strong>@telemetry.AngularAcceleration.ToString("F2") m/s²</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* Telemetry Charts *@
|
||||
<MudText Typo="Typo.h6" Class="mt-2 mb-2">Telemetry Charts</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Cross-Track Error</MudText>
|
||||
<ApexChart @ref="_cteChart" TItem="TelemetryPoint" Options="_cteOptions" Height="220">
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="CTE (m)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.Cte)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Velocity</MudText>
|
||||
<ApexChart @ref="_velocityChart" TItem="TelemetryPoint" Options="_velocityOptions" Height="220">
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="Linear (m/s)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.LinearVel)"
|
||||
OrderBy="p => p.X" />
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="Angular (rad/s)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.AngularVel)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Heading Error</MudText>
|
||||
<ApexChart @ref="_headingChart" TItem="TelemetryPoint" Options="_headingOptions" Height="220">
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="Heading (deg)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.HeadingErrorDeg)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* Navigation Path Visualization *@
|
||||
@if (_cachedWaypoints is { Count: >= 2 })
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-3 mt-2" Elevation="2" Style="border-left: 4px solid var(--mud-palette-primary);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Route" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">Navigation Path</MudText>
|
||||
<MudSpacer />
|
||||
<MudChip T="string" Color="Color.Info" Size="Size.Small">@_cachedWaypoints.Count waypoints</MudChip>
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Outlined">@telemetry.NavigationState</MudChip>
|
||||
</MudStack>
|
||||
@{
|
||||
var wp = _cachedWaypoints;
|
||||
var pvb = GetPathSvgBounds(wp, _robotTrail);
|
||||
var pStroke = (pvb.Width * 0.005).ToString("F4");
|
||||
var pThin = (pvb.Width * 0.003).ToString("F4");
|
||||
var pDash = (pvb.Width * 0.01).ToString("F4");
|
||||
}
|
||||
<div style="background: #1e1e2e; border-radius: 4px; padding: 8px;">
|
||||
<svg viewBox="@FormatViewBox(pvb)"
|
||||
width="100%" height="350" preserveAspectRatio="xMidYMid meet"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@* Grid reference lines *@
|
||||
<line x1="@pvb.MinX.ToString("F3")" y1="0" x2="@((pvb.MinX + pvb.Width).ToString("F3"))" y2="0"
|
||||
stroke="#444" stroke-width="@pThin" stroke-dasharray="@pDash" />
|
||||
<line x1="0" y1="@pvb.MinY.ToString("F3")" x2="0" y2="@((pvb.MinY + pvb.Height).ToString("F3"))"
|
||||
stroke="#444" stroke-width="@pThin" stroke-dasharray="@pDash" />
|
||||
|
||||
@* Waypoints path line *@
|
||||
@{
|
||||
var pathLine = string.Join(" ", wp.Select(w => $"{w.X.ToString("F3")},{FlipY(w.Y).ToString("F3")}"));
|
||||
}
|
||||
<polyline points="@pathLine"
|
||||
fill="none" stroke="#5b9bd5" stroke-width="@pStroke"
|
||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.7" />
|
||||
|
||||
@* Waypoint dots *@
|
||||
@for (int wi = 0; wi < wp.Count; wi++)
|
||||
{
|
||||
var wpt = wp[wi];
|
||||
var wColor = wpt.Direction == "BACKWARD" ? "#ff9800" : "#5b9bd5";
|
||||
var wRadius = (pvb.Width * 0.008).ToString("F4");
|
||||
<circle cx="@wpt.X.ToString("F3")" cy="@FlipY(wpt.Y).ToString("F3")"
|
||||
r="@wRadius" fill="@wColor" opacity="0.8" />
|
||||
}
|
||||
|
||||
@* Start waypoint *@
|
||||
@{
|
||||
var wpStart = wp[0];
|
||||
var startR = (pvb.Width * 0.02).ToString("F4");
|
||||
}
|
||||
<circle cx="@wpStart.X.ToString("F3")" cy="@FlipY(wpStart.Y).ToString("F3")"
|
||||
r="@startR" fill="#4caf50" stroke="#fff"
|
||||
stroke-width="@((pvb.Width * 0.004).ToString("F4"))" />
|
||||
<text x="@wpStart.X.ToString("F3")"
|
||||
y="@((FlipY(wpStart.Y) - pvb.Width * 0.035).ToString("F3"))"
|
||||
text-anchor="middle" fill="#4caf50"
|
||||
font-size="@((pvb.Width * 0.035).ToString("F4"))" font-weight="bold">Start</text>
|
||||
|
||||
@* Goal waypoint *@
|
||||
@{
|
||||
var wpGoal = wp[^1];
|
||||
var goalR = (pvb.Width * 0.02).ToString("F4");
|
||||
}
|
||||
<circle cx="@wpGoal.X.ToString("F3")" cy="@FlipY(wpGoal.Y).ToString("F3")"
|
||||
r="@goalR" fill="#f44336" stroke="#fff"
|
||||
stroke-width="@((pvb.Width * 0.004).ToString("F4"))" />
|
||||
<text x="@wpGoal.X.ToString("F3")"
|
||||
y="@((FlipY(wpGoal.Y) - pvb.Width * 0.035).ToString("F3"))"
|
||||
text-anchor="middle" fill="#f44336"
|
||||
font-size="@((pvb.Width * 0.035).ToString("F4"))" font-weight="bold">Goal</text>
|
||||
|
||||
@* Robot trail (breadcrumb) *@
|
||||
@if (_robotTrail.Count >= 2)
|
||||
{
|
||||
var trailLine = string.Join(" ", _robotTrail.Select(t => $"{t.X.ToString("F3")},{FlipY(t.Y).ToString("F3")}"));
|
||||
<polyline points="@trailLine"
|
||||
fill="none" stroke="#66bb6a" stroke-width="@pThin"
|
||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.6"
|
||||
stroke-dasharray="@((pvb.Width * 0.008).ToString("F4"))" />
|
||||
}
|
||||
|
||||
@* Legend *@
|
||||
@{
|
||||
var legendX = pvb.MinX + pvb.Width * 0.02;
|
||||
var legendY = pvb.MinY + pvb.Height * 0.06;
|
||||
var legendFs = (pvb.Width * 0.025).ToString("F4");
|
||||
var legendR = (pvb.Width * 0.008).ToString("F4");
|
||||
var legendStep = pvb.Height * 0.05;
|
||||
}
|
||||
<circle cx="@legendX.ToString("F3")" cy="@legendY.ToString("F3")" r="@legendR" fill="#5b9bd5" />
|
||||
<text x="@((legendX + pvb.Width * 0.02).ToString("F3"))" y="@((legendY + pvb.Height * 0.012).ToString("F3"))"
|
||||
fill="#aaa" font-size="@legendFs">Forward</text>
|
||||
<circle cx="@legendX.ToString("F3")" cy="@((legendY + legendStep).ToString("F3"))" r="@legendR" fill="#ff9800" />
|
||||
<text x="@((legendX + pvb.Width * 0.02).ToString("F3"))" y="@((legendY + legendStep + pvb.Height * 0.012).ToString("F3"))"
|
||||
fill="#aaa" font-size="@legendFs">Backward</text>
|
||||
<line x1="@legendX.ToString("F3")" y1="@((legendY + 2 * legendStep).ToString("F3"))"
|
||||
x2="@((legendX + pvb.Width * 0.03).ToString("F3"))" y2="@((legendY + 2 * legendStep).ToString("F3"))"
|
||||
stroke="#66bb6a" stroke-width="@pThin" stroke-dasharray="@((pvb.Width * 0.008).ToString("F4"))" />
|
||||
<text x="@((legendX + pvb.Width * 0.04).ToString("F3"))" y="@((legendY + 2 * legendStep + pvb.Height * 0.012).ToString("F3"))"
|
||||
fill="#aaa" font-size="@legendFs">Robot Trail</text>
|
||||
</svg>
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@* Safety Configuration *@
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel>
|
||||
<TitleContent>
|
||||
<div class="d-flex flex-row">
|
||||
<MudText>Safety Configuration</MudText>
|
||||
<MudButton Class="ms-4" Variant="Variant.Text" Color="Color.Primary" OnClick="ApplySafetyConfig"
|
||||
Disabled="@(!hubClient.IsConnected)" Size="Size.Small">Apply</MudButton>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxLinearVelocity" Label="Max Linear Velocity (m/s)"
|
||||
Step="0.1" Min="0.1" Max="5.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxAngularVelocity" Label="Max Angular Velocity (rad/s)"
|
||||
Step="0.5" Min="0.5" Max="15.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxLinearAcceleration" Label="Max Linear Accel (m/s2)"
|
||||
Step="0.5" Min="0.5" Max="10.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxCrossTrackError" Label="Max CTE (m)"
|
||||
Step="0.05" Min="0.05" Max="2.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxHeadingError" Label="Max Heading Error (deg)"
|
||||
Step="5.0" Min="5.0" Max="180.0" Format="F1" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
|
||||
@* Safety Violations *@
|
||||
<MudPaper Class="pa-3 mt-3" Elevation="1">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudText Typo="Typo.h6">Safety Violations</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="ClearViolations">Clear</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@if (violations.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No violations recorded.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height: 300px; overflow-y: auto;">
|
||||
@foreach (var v in violations)
|
||||
{
|
||||
<MudAlert Severity="@(v.Severity == SafetyViolationSeverity.Critical ? Severity.Error : Severity.Warning)"
|
||||
Dense="true" Class="mb-1" NoIcon="false">
|
||||
<MudText Typo="Typo.caption">
|
||||
@DateTimeOffset.FromUnixTimeMilliseconds(v.TimestampMs).ToLocalTime().ToString("HH:mm:ss.fff")
|
||||
— @v.Message
|
||||
</MudText>
|
||||
</MudAlert>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
</div>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Inject] private NavigationMonitorHubClient hubClient { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private NavigationTelemetryDto telemetry = new();
|
||||
private NavigationSafetyConfigDto safetyConfig = new();
|
||||
private List<NavigationSafetyViolationDto> violations = new();
|
||||
private bool telemetryEnabled;
|
||||
private bool safetyStopEnabled;
|
||||
private bool safetyStopLatched;
|
||||
private string safetyStopReason = "";
|
||||
private double updateFrequencyHz = 10;
|
||||
private const int MaxViolations = 100;
|
||||
|
||||
// UI render throttle: render at 2Hz
|
||||
private const int UiRefreshIntervalMs = 500;
|
||||
private System.Threading.Timer? _uiRefreshTimer;
|
||||
private volatile bool _telemetryDirty;
|
||||
|
||||
// Chart data & ApexCharts
|
||||
private List<TelemetryPoint> chartDataPoints = new();
|
||||
private const int MaxChartPoints = 120;
|
||||
private long chartStartTimeMs;
|
||||
private volatile bool _chartDirty;
|
||||
|
||||
// Path visualization: cached waypoints persist after navigation ends
|
||||
private List<WaypointDto> _cachedWaypoints = new();
|
||||
private List<(double X, double Y)> _robotTrail = new();
|
||||
private const int MaxTrailPoints = 120;
|
||||
|
||||
private ApexChart<TelemetryPoint>? _cteChart;
|
||||
private ApexChart<TelemetryPoint>? _velocityChart;
|
||||
private ApexChart<TelemetryPoint>? _headingChart;
|
||||
|
||||
private ApexChartOptions<TelemetryPoint> _cteOptions = new();
|
||||
private ApexChartOptions<TelemetryPoint> _velocityOptions = new();
|
||||
private ApexChartOptions<TelemetryPoint> _headingOptions = new();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
ConfigureChartOptions();
|
||||
|
||||
hubClient.TelemetryReceived += OnTelemetryReceived;
|
||||
hubClient.SafetyViolationReceived += OnSafetyViolationReceived;
|
||||
hubClient.MonitorStateChanged += OnMonitorStateChanged;
|
||||
hubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
_uiRefreshTimer = new System.Threading.Timer(OnUiRefreshTick, null, UiRefreshIntervalMs, UiRefreshIntervalMs);
|
||||
|
||||
try
|
||||
{
|
||||
await hubClient.StartAsync();
|
||||
var state = await hubClient.GetStateAsync();
|
||||
ApplyMonitorState(state);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTelemetryReceived(NavigationTelemetryDto dto)
|
||||
{
|
||||
telemetry = dto;
|
||||
|
||||
// Cache waypoints: update when new path arrives, persist after navigation ends
|
||||
if (dto.Waypoints is { Count: >= 2 })
|
||||
{
|
||||
// New waypoints = new navigation task → reset trail
|
||||
if (!WaypointsMatch(_cachedWaypoints, dto.Waypoints))
|
||||
{
|
||||
_cachedWaypoints = dto.Waypoints;
|
||||
_robotTrail.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Track robot trail (keep trail after navigation ends for visualization)
|
||||
if (dto.Driving)
|
||||
{
|
||||
_robotTrail.Add((dto.X, dto.Y));
|
||||
if (_robotTrail.Count > MaxTrailPoints)
|
||||
_robotTrail.RemoveAt(0);
|
||||
}
|
||||
|
||||
// Add chart data point every telemetry tick (now 2Hz from server)
|
||||
if (chartStartTimeMs == 0) chartStartTimeMs = dto.TimestampMs;
|
||||
chartDataPoints.Add(new TelemetryPoint
|
||||
{
|
||||
TimeSec = (dto.TimestampMs - chartStartTimeMs) / 1000.0,
|
||||
Cte = dto.CrossTrackError,
|
||||
LinearVel = dto.LinearVelocity,
|
||||
AngularVel = dto.AngularVelocity,
|
||||
HeadingErrorDeg = dto.HeadingError * 180 / Math.PI
|
||||
});
|
||||
if (chartDataPoints.Count > MaxChartPoints)
|
||||
chartDataPoints.RemoveRange(0, chartDataPoints.Count - MaxChartPoints);
|
||||
|
||||
_telemetryDirty = true;
|
||||
_chartDirty = true;
|
||||
}
|
||||
|
||||
private void OnUiRefreshTick(object? state)
|
||||
{
|
||||
if (!_telemetryDirty) return;
|
||||
_telemetryDirty = false;
|
||||
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
if (_chartDirty)
|
||||
{
|
||||
_chartDirty = false;
|
||||
try
|
||||
{
|
||||
if (_cteChart is not null) await _cteChart.UpdateSeriesAsync(true);
|
||||
if (_velocityChart is not null) await _velocityChart.UpdateSeriesAsync(true);
|
||||
if (_headingChart is not null) await _headingChart.UpdateSeriesAsync(true);
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnSafetyViolationReceived(NavigationSafetyViolationDto dto)
|
||||
{
|
||||
violations.Insert(0, dto);
|
||||
if (violations.Count > MaxViolations)
|
||||
violations.RemoveRange(MaxViolations, violations.Count - MaxViolations);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnMonitorStateChanged(NavigationMonitorStateDto state)
|
||||
{
|
||||
ApplyMonitorState(state);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void ApplyMonitorState(NavigationMonitorStateDto state)
|
||||
{
|
||||
telemetryEnabled = state.TelemetryEnabled;
|
||||
safetyStopEnabled = state.SafetyStopEnabled;
|
||||
safetyStopLatched = state.SafetyStopLatched;
|
||||
safetyStopReason = state.SafetyStopReason;
|
||||
updateFrequencyHz = state.UpdateFrequencyHz;
|
||||
safetyConfig = state.SafetyConfig;
|
||||
}
|
||||
|
||||
private async Task OnTelemetryToggle(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
telemetryEnabled = enabled;
|
||||
if (enabled)
|
||||
{
|
||||
// Reset chart history when re-enabling
|
||||
chartDataPoints.Clear();
|
||||
chartStartTimeMs = 0;
|
||||
_cachedWaypoints.Clear();
|
||||
_robotTrail.Clear();
|
||||
}
|
||||
await hubClient.SetTelemetryEnabledAsync(enabled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSafetyStopToggle(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
safetyStopEnabled = enabled;
|
||||
await hubClient.SetSafetyStopEnabledAsync(enabled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnReleaseSafetyStop()
|
||||
{
|
||||
try
|
||||
{
|
||||
await hubClient.ReleaseSafetyStopAsync();
|
||||
Snackbar.Add("Safety stop released", Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplySafetyConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
await hubClient.UpdateSafetyConfigAsync(safetyConfig);
|
||||
Snackbar.Add("Safety config updated", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearViolations()
|
||||
{
|
||||
violations.Clear();
|
||||
}
|
||||
|
||||
private MudBlazor.Color GetNavStateColor() => telemetry.NavigationState switch
|
||||
{
|
||||
"Moving" or "Rotating" => MudBlazor.Color.Info,
|
||||
"Docking" or "FinePositioning" => MudBlazor.Color.Tertiary,
|
||||
"Completed" => MudBlazor.Color.Success,
|
||||
"Error" or "Canceled" or "SafetyStop" => MudBlazor.Color.Error,
|
||||
"Paused" => MudBlazor.Color.Warning,
|
||||
_ => MudBlazor.Color.Default
|
||||
};
|
||||
|
||||
private MudBlazor.Color GetDockPhaseColor() => telemetry.DockTo?.Phase switch
|
||||
{
|
||||
"Approaching" => MudBlazor.Color.Info,
|
||||
"Aligning" => MudBlazor.Color.Warning,
|
||||
"Advancing" => MudBlazor.Color.Success,
|
||||
_ => MudBlazor.Color.Default
|
||||
};
|
||||
|
||||
// SVG helpers for docking visualization
|
||||
private record struct SvgBounds(double MinX, double MinY, double Width, double Height);
|
||||
|
||||
private SvgBounds GetDockSvgBounds(DockToTelemetryDto dock, double robotX, double robotY)
|
||||
{
|
||||
var xs = new List<double> { dock.StartX, dock.GoalX, robotX };
|
||||
var ys = new List<double> { dock.StartY, dock.GoalY, robotY };
|
||||
foreach (var w in dock.Waypoints) { xs.Add(w.X); ys.Add(w.Y); }
|
||||
|
||||
double minX = xs.Min(), maxX = xs.Max();
|
||||
double minY = ys.Min(), maxY = ys.Max();
|
||||
|
||||
// Ensure minimum size and add padding
|
||||
double rangeX = maxX - minX;
|
||||
double rangeY = maxY - minY;
|
||||
if (rangeX < 0.5) { minX -= 0.25; rangeX = 0.5; maxX = minX + rangeX; }
|
||||
if (rangeY < 0.5) { minY -= 0.25; rangeY = 0.5; maxY = minY + rangeY; }
|
||||
|
||||
double pad = Math.Max(rangeX, rangeY) * 0.15;
|
||||
// Flip Y: SVG Y-down, world Y-up. We flip individual Y coords with FlipY(),
|
||||
// so viewBox uses flipped min/max
|
||||
double svgMinX = minX - pad;
|
||||
double svgMinY = -(maxY + pad); // flipped
|
||||
double svgW = rangeX + 2 * pad;
|
||||
double svgH = rangeY + 2 * pad;
|
||||
|
||||
return new SvgBounds(svgMinX, svgMinY, svgW, svgH);
|
||||
}
|
||||
|
||||
private static string FormatViewBox(SvgBounds vb)
|
||||
=> $"{vb.MinX.ToString("F3")} {vb.MinY.ToString("F3")} {vb.Width.ToString("F3")} {vb.Height.ToString("F3")}";
|
||||
|
||||
private static double FlipY(double worldY) => -worldY;
|
||||
|
||||
// SVG helpers for navigation path visualization
|
||||
private static SvgBounds GetPathSvgBounds(List<WaypointDto> waypoints, List<(double X, double Y)> trail)
|
||||
{
|
||||
var xs = waypoints.Select(w => w.X).ToList();
|
||||
var ys = waypoints.Select(w => w.Y).ToList();
|
||||
foreach (var t in trail) { xs.Add(t.X); ys.Add(t.Y); }
|
||||
|
||||
double minX = xs.Min(), maxX = xs.Max();
|
||||
double minY = ys.Min(), maxY = ys.Max();
|
||||
|
||||
double rangeX = maxX - minX;
|
||||
double rangeY = maxY - minY;
|
||||
if (rangeX < 0.5) { minX -= 0.25; rangeX = 0.5; maxX = minX + rangeX; }
|
||||
if (rangeY < 0.5) { minY -= 0.25; rangeY = 0.5; maxY = minY + rangeY; }
|
||||
|
||||
double pad = Math.Max(rangeX, rangeY) * 0.15;
|
||||
double svgMinX = minX - pad;
|
||||
double svgMinY = -(maxY + pad);
|
||||
double svgW = rangeX + 2 * pad;
|
||||
double svgH = rangeY + 2 * pad;
|
||||
|
||||
return new SvgBounds(svgMinX, svgMinY, svgW, svgH);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick check if waypoints are the same path (compare first/last + count)
|
||||
/// </summary>
|
||||
private static bool WaypointsMatch(List<WaypointDto> a, List<WaypointDto> b)
|
||||
{
|
||||
if (a.Count != b.Count || a.Count == 0) return false;
|
||||
return Math.Abs(a[0].X - b[0].X) < 0.001
|
||||
&& Math.Abs(a[0].Y - b[0].Y) < 0.001
|
||||
&& Math.Abs(a[^1].X - b[^1].X) < 0.001
|
||||
&& Math.Abs(a[^1].Y - b[^1].Y) < 0.001;
|
||||
}
|
||||
|
||||
// ApexCharts configuration (following TelemetryChartPanel pattern)
|
||||
private void ConfigureChartOptions()
|
||||
{
|
||||
var axisColor = "#009933";
|
||||
|
||||
var baseGrid = () => new Grid
|
||||
{
|
||||
BorderColor = "#00993330",
|
||||
StrokeDashArray = 4,
|
||||
Xaxis = new GridXAxis { Lines = new Lines { Show = false } },
|
||||
Yaxis = new GridYAxis { Lines = new Lines { Show = true } }
|
||||
};
|
||||
|
||||
var baseChart = () => new Chart
|
||||
{
|
||||
ForeColor = axisColor,
|
||||
Animations = new Animations { Enabled = false },
|
||||
Toolbar = new Toolbar { Show = false },
|
||||
Zoom = new Zoom { Enabled = false }
|
||||
};
|
||||
|
||||
var baseXAxis = () => new XAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Time (s)", Style = new AxisTitleStyle { Color = axisColor } },
|
||||
Labels = new XAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return parseFloat(val).toFixed(0); }",
|
||||
Style = new AxisLabelStyle { Colors = axisColor }
|
||||
}
|
||||
};
|
||||
|
||||
var baseTooltip = () => new Tooltip
|
||||
{
|
||||
Shared = true,
|
||||
X = new TooltipX { Format = "0.1f" },
|
||||
Y = new TooltipY { Formatter = @"function(val) { return val.toFixed(4); }" }
|
||||
};
|
||||
|
||||
var yAxisStyle = new AxisTitleStyle { Color = axisColor };
|
||||
var yLabelStyle = new AxisLabelStyle { Colors = axisColor };
|
||||
|
||||
// CTE chart — blue
|
||||
_cteOptions.Chart = baseChart();
|
||||
_cteOptions.Grid = baseGrid();
|
||||
_cteOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_cteOptions.Xaxis = baseXAxis();
|
||||
_cteOptions.Colors = new List<string> { "#0D47A1" };
|
||||
_cteOptions.Tooltip = baseTooltip();
|
||||
_cteOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "CTE (m)", Style = yAxisStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = yLabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3,
|
||||
Min = 0
|
||||
}
|
||||
};
|
||||
|
||||
// Velocity chart — green (linear) + purple (angular)
|
||||
_velocityOptions.Chart = baseChart();
|
||||
_velocityOptions.Grid = baseGrid();
|
||||
_velocityOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_velocityOptions.Xaxis = baseXAxis();
|
||||
_velocityOptions.Colors = new List<string> { "#1B5E20", "#7B1FA2" };
|
||||
_velocityOptions.Tooltip = baseTooltip();
|
||||
_velocityOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Velocity", Style = yAxisStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = yLabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3
|
||||
}
|
||||
};
|
||||
|
||||
// Heading error chart — orange
|
||||
_headingOptions.Chart = baseChart();
|
||||
_headingOptions.Grid = baseGrid();
|
||||
_headingOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_headingOptions.Xaxis = baseXAxis();
|
||||
_headingOptions.Colors = new List<string> { "#E65100" };
|
||||
_headingOptions.Tooltip = baseTooltip();
|
||||
_headingOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Heading (deg)", Style = yAxisStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(2); }",
|
||||
Style = yLabelStyle
|
||||
},
|
||||
DecimalsInFloat = 2
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private class TelemetryPoint
|
||||
{
|
||||
public double TimeSec { get; set; }
|
||||
public double Cte { get; set; }
|
||||
public double LinearVel { get; set; }
|
||||
public double AngularVel { get; set; }
|
||||
public double HeadingErrorDeg { get; set; }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_uiRefreshTimer is not null)
|
||||
await _uiRefreshTimer.DisposeAsync();
|
||||
hubClient.TelemetryReceived -= OnTelemetryReceived;
|
||||
hubClient.SafetyViolationReceived -= OnSafetyViolationReceived;
|
||||
hubClient.MonitorStateChanged -= OnMonitorStateChanged;
|
||||
hubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
await hubClient.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
@page "/motion/odometry"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Motion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>Odometry (XLOC)</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-3 mb-4">
|
||||
@* Header *@
|
||||
<MudPaper Class="pa-4 mb-3 odom-page-header" Elevation="0">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Route" Color="Color.Primary" Style="font-size: 28px;" />
|
||||
<div>
|
||||
<MudText Typo="Typo.h6" Style="line-height: 1.2;">Odometry → XLOC</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">OdometryService → XlocIntegrationService · ~10 Hz</MudText>
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudChip T="string"
|
||||
Color="@(OdometryHubClient.IsConnected ? Color.Success : Color.Default)"
|
||||
Variant="Variant.Filled"
|
||||
Size="Size.Small"
|
||||
Icon="@(OdometryHubClient.IsConnected ? Icons.Material.Filled.Link : Icons.Material.Filled.LinkOff)"
|
||||
Style="font-weight: 500;">
|
||||
@(OdometryHubClient.IsConnected ? "Connected" : "Disconnected")
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@if (!OdometryHubClient.IsConnected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-4" Dense="true" Icon="@Icons.Material.Filled.Sync">
|
||||
Đang kết nối tới hub odometry...
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (lastOdom != null)
|
||||
{
|
||||
<MudGrid Spacing="3">
|
||||
@* Meta: frame_id, child_frame_id, stamp *@
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-meta">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Tag" Size="Size.Small" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.subtitle2">Header</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2" Wrap="Wrap.Wrap">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary">@lastOdom.FrameId</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary">@lastOdom.ChildFrameId</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary">@lastOdom.Timestamp.ToString("HH:mm:ss.fff")</MudChip>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Pose: Position + Orientation *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-position">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Place" Size="Size.Small" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.subtitle2">Position</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(m)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudTextField T="string" Label="X" Value="@Format(lastOdom.PositionX)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Y" Value="@Format(lastOdom.PositionY)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Z" Value="@Format(lastOdom.PositionZ)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-orientation">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Explore" Size="Size.Small" Color="Color.Secondary" />
|
||||
<MudText Typo="Typo.subtitle2">Orientation</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(yaw °)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2" Wrap="Wrap.Wrap" AlignItems="AlignItems.End">
|
||||
<MudTextField T="string" Label="Yaw" Value="@(FormatDegrees(YawDegrees))" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 100px;" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="pb-2">Q: (@Format(lastOdom.OrientationX), @Format(lastOdom.OrientationY), @Format(lastOdom.OrientationZ), @Format(lastOdom.OrientationW))</MudText>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Twist: Linear + Angular *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-linear">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Speed" Size="Size.Small" Color="Color.Info" />
|
||||
<MudText Typo="Typo.subtitle2">Linear velocity</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(m/s)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudTextField T="string" Label="Vx" Value="@Format(lastOdom.LinearVelocityX)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Vy" Value="@Format(lastOdom.LinearVelocityY)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Vz" Value="@Format(lastOdom.LinearVelocityZ)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-angular">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.RotateRight" Size="Size.Small" Color="Color.Warning" />
|
||||
<MudText Typo="Typo.subtitle2">Angular velocity</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(rad/s)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudTextField T="string" Label="ωx" Value="@Format(lastOdom.AngularVelocityX)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="ωy" Value="@Format(lastOdom.AngularVelocityY)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="ωz" Value="@Format(lastOdom.AngularVelocityZ)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-2" Elevation="0" Style="border-radius: 8px; background: var(--mud-palette-background-grey);">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Align="Align.Center">
|
||||
pose_position · pose_orientation · twist_linear · twist_angular → ToXlocOdometry()
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
else if (OdometryHubClient.IsConnected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.Schedule">
|
||||
Chưa nhận dữ liệu. Đang chờ broadcast từ server.
|
||||
</MudAlert>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
[Inject]
|
||||
private OdometryHubClient OdometryHubClient { get; set; } = null!;
|
||||
|
||||
private OdometryDto? lastOdom;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
OdometryHubClient.OdometryReceived += OnOdometryReceived;
|
||||
OdometryHubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
await OdometryHubClient.StartAsync();
|
||||
if (OdometryHubClient.IsConnected)
|
||||
{
|
||||
lastOdom = await OdometryHubClient.GetCurrentOdometryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOdometryReceived(OdometryDto dto)
|
||||
{
|
||||
lastOdom = dto;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState _)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private double YawDegrees
|
||||
{
|
||||
get
|
||||
{
|
||||
if (lastOdom == null) return 0;
|
||||
var qw = lastOdom.OrientationW;
|
||||
var qz = lastOdom.OrientationZ;
|
||||
var qx = lastOdom.OrientationX;
|
||||
var qy = lastOdom.OrientationY;
|
||||
var yaw = Math.Atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz));
|
||||
return yaw * 180.0 / Math.PI;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Format(double value) => value.ToString("F4");
|
||||
private static string FormatDegrees(double value) => value.ToString("F2") + " °";
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
OdometryHubClient.OdometryReceived -= OnOdometryReceived;
|
||||
OdometryHubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
await OdometryHubClient.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Odometry page – header và card có màu */
|
||||
.odom-page-header {
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--mud-palette-primary);
|
||||
background-color: var(--mud-palette-surface);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Card chung: bo góc, viền trái 4px màu */
|
||||
.odom-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mud-palette-lines-default);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.odom-card-meta {
|
||||
border-left-color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.odom-card-position {
|
||||
border-left-color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.odom-card-orientation {
|
||||
border-left-color: var(--mud-palette-secondary);
|
||||
}
|
||||
|
||||
.odom-card-linear {
|
||||
border-left-color: var(--mud-palette-info);
|
||||
}
|
||||
|
||||
.odom-card-angular {
|
||||
border-left-color: var(--mud-palette-warning);
|
||||
}
|
||||
Reference in New Issue
Block a user