Files
Denso/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp.Client/Pages/Motion/NavigationMonitor.razor
2026-07-03 16:31:37 +07:00

988 lines
46 KiB
Plaintext

@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> &mdash; @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"))&deg;</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"))&deg;</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"))&deg;</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&sup2;</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&sup2;</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")
&mdash; @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();
}
}