4800 lines
219 KiB
Plaintext
4800 lines
219 KiB
Plaintext
@page "/xloc/map"
|
||
@rendermode InteractiveServer
|
||
@using Microsoft.AspNetCore.Components.Forms
|
||
@using System.Net.Http.Json
|
||
@using System.Net.Security
|
||
@using System.Security.Authentication
|
||
@using System.Text.Json
|
||
@using Microsoft.JSInterop
|
||
@using System.Threading
|
||
@using RobotNet10.RobotApp.Xloc
|
||
@attribute [Authorize]
|
||
@inject HttpClient Http
|
||
@inject NavigationManager Navigation
|
||
@inject ILogger<XlocMapView> Logger
|
||
@inject IJSRuntime JSRuntime
|
||
@inject IDialogService DialogService
|
||
@inject XlocIntegrationService XlocService
|
||
@implements IAsyncDisposable
|
||
|
||
<PageTitle>XLOC Map Visualization</PageTitle>
|
||
|
||
<style>
|
||
.xloc-map-container {
|
||
--xloc-bg: #e8edf4;
|
||
--xloc-bg-deep: #dce4f0;
|
||
--xloc-surface: rgba(255, 255, 255, 0.92);
|
||
--xloc-border: rgba(15, 23, 42, 0.08);
|
||
--xloc-text: #0f172a;
|
||
--xloc-muted: #64748b;
|
||
--xloc-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||
--xloc-shadow-md: 0 4px 14px rgba(15, 23, 42, 0.07);
|
||
--xloc-shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.1);
|
||
--xloc-radius: 12px;
|
||
--xloc-toolbar-h: 52px;
|
||
position: fixed;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: linear-gradient(165deg, var(--xloc-bg) 0%, #f1f5f9 42%, var(--xloc-bg-deep) 100%);
|
||
}
|
||
|
||
.xloc-map-container * {
|
||
color: var(--xloc-text) !important;
|
||
}
|
||
|
||
.xloc-map-container .mud-input-label,
|
||
.xloc-map-container .mud-input-control,
|
||
.xloc-map-container .mud-typography {
|
||
color: var(--xloc-text) !important;
|
||
}
|
||
|
||
.xloc-map-container input,
|
||
.xloc-map-container .mud-input {
|
||
color: var(--xloc-text) !important;
|
||
background-color: rgba(255, 255, 255, 0.97) !important;
|
||
}
|
||
|
||
.xloc-map-container .mud-select {
|
||
background-color: rgba(255, 255, 255, 0.97) !important;
|
||
}
|
||
|
||
.xloc-map-container .mud-select-input {
|
||
background-color: rgba(255, 255, 255, 0.97) !important;
|
||
color: var(--xloc-text) !important;
|
||
}
|
||
|
||
.mud-popover {
|
||
background-color: #ffffff !important;
|
||
}
|
||
|
||
.mud-popover-paper {
|
||
background-color: #ffffff !important;
|
||
box-shadow: var(--xloc-shadow-lg) !important;
|
||
border-radius: 10px !important;
|
||
border: 1px solid var(--xloc-border) !important;
|
||
}
|
||
|
||
.mud-list {
|
||
background-color: #ffffff !important;
|
||
}
|
||
|
||
.mud-list-item {
|
||
background-color: #ffffff !important;
|
||
color: var(--xloc-text) !important;
|
||
}
|
||
|
||
.mud-list-item:hover {
|
||
background-color: #eff6ff !important;
|
||
}
|
||
|
||
.mud-list-item-selected {
|
||
background-color: #dbeafe !important;
|
||
}
|
||
|
||
.mud-select-item {
|
||
background-color: #ffffff !important;
|
||
color: var(--xloc-text) !important;
|
||
}
|
||
|
||
/* Toolbar */
|
||
.xloc-toolbar {
|
||
flex-shrink: 0;
|
||
z-index: 1000;
|
||
min-height: var(--xloc-toolbar-h);
|
||
padding: 8px 14px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px 12px;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(248, 250, 252, 0.88) 100%);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
border-bottom: 1px solid var(--xloc-border);
|
||
box-shadow: var(--xloc-shadow-sm);
|
||
}
|
||
|
||
.xloc-toolbar__brand {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
padding-right: 8px;
|
||
margin-right: 4px;
|
||
border-right: 1px solid var(--xloc-border);
|
||
min-height: 36px;
|
||
}
|
||
|
||
.xloc-toolbar__title {
|
||
font-size: 0.8125rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
color: #1e3a5f !important;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.xloc-toolbar__subtitle {
|
||
font-size: 0.6875rem;
|
||
font-weight: 500;
|
||
color: var(--xloc-muted) !important;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.xloc-toolbar__meta {
|
||
margin-left: auto;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px 20px;
|
||
}
|
||
|
||
.xloc-toolbar__chip {
|
||
font-size: 0.8125rem !important;
|
||
font-weight: 500 !important;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.85) !important;
|
||
border: 1px solid var(--xloc-border) !important;
|
||
box-shadow: var(--xloc-shadow-sm);
|
||
}
|
||
|
||
/* Buttons */
|
||
.xloc-map-container .mud-button-root {
|
||
border-radius: 10px !important;
|
||
font-weight: 600 !important;
|
||
letter-spacing: 0.01em;
|
||
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease, background-color 0.18s ease !important;
|
||
}
|
||
|
||
.xloc-map-container .mud-button-root:not(.mud-disabled):hover {
|
||
transform: translateY(-1px);
|
||
filter: brightness(1.02);
|
||
}
|
||
|
||
.xloc-map-container .mud-button-root:not(.mud-disabled):active {
|
||
transform: translateY(0);
|
||
filter: brightness(0.98);
|
||
}
|
||
|
||
.xloc-map-container .mud-button-filled {
|
||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.16) !important;
|
||
}
|
||
|
||
.xloc-map-container .mud-button-filled:not(.mud-disabled):hover {
|
||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.2) !important;
|
||
}
|
||
|
||
.xloc-map-container .mud-button-outlined {
|
||
border-width: 1.5px !important;
|
||
background: rgba(255, 255, 255, 0.78) !important;
|
||
}
|
||
|
||
.xloc-map-container .mud-button-root .mud-button-label {
|
||
gap: 6px;
|
||
}
|
||
|
||
.xloc-toolbar .mud-button-root {
|
||
min-height: 34px;
|
||
padding: 0 14px !important;
|
||
border-radius: 999px !important;
|
||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12) !important;
|
||
}
|
||
|
||
.xloc-toolbar .mud-button-outlined {
|
||
background: rgba(255, 255, 255, 0.9) !important;
|
||
}
|
||
|
||
.xloc-sidebar .mud-button-root {
|
||
min-height: 34px;
|
||
}
|
||
|
||
.xloc-sidebar .mud-button-full-width {
|
||
justify-content: center;
|
||
}
|
||
|
||
/* Sidebars */
|
||
.xloc-sidebar {
|
||
transition: width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||
overflow: hidden;
|
||
z-index: 999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.72) 0%, rgba(241, 245, 249, 0.85) 100%);
|
||
backdrop-filter: blur(8px);
|
||
-webkit-backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.xloc-sidebar--left {
|
||
border-right: 1px solid var(--xloc-border);
|
||
box-shadow: 4px 0 24px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.xloc-sidebar--right {
|
||
border-left: 1px solid var(--xloc-border);
|
||
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.xloc-sidebar-body {
|
||
padding: 16px 14px 20px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(100, 116, 139, 0.45) transparent;
|
||
}
|
||
|
||
.xloc-sidebar-body::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.xloc-sidebar-body::-webkit-scrollbar-thumb {
|
||
background: rgba(100, 116, 139, 0.35);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.xloc-sidebar-body::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(100, 116, 139, 0.55);
|
||
}
|
||
|
||
/* Map stage */
|
||
.xloc-map-stage {
|
||
flex: 1;
|
||
position: relative;
|
||
background: linear-gradient(145deg, #fafbfc 0%, #eef2f7 50%, #e2e8f0 100%);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.xloc-map-stage::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
opacity: 0.35;
|
||
background-image:
|
||
linear-gradient(rgba(148, 163, 184, 0.12) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
|
||
background-size: 24px 24px;
|
||
}
|
||
|
||
#xloc-map-cursor-tooltip.xloc-map-tooltip {
|
||
pointer-events: none;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
line-height: 1.3;
|
||
white-space: nowrap;
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||
background: rgba(255, 255, 255, 0.96) !important;
|
||
color: var(--xloc-text) !important;
|
||
box-shadow: var(--xloc-shadow-md);
|
||
}
|
||
|
||
.xloc-chart-canvas {
|
||
width: 100%;
|
||
height: 150px;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.65) !important;
|
||
border: 1px solid var(--xloc-border);
|
||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.xloc-velocity-legend {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 12px;
|
||
padding: 10px 12px;
|
||
background: rgba(255, 255, 255, 0.55);
|
||
border-radius: 8px;
|
||
border: 1px solid var(--xloc-border);
|
||
}
|
||
|
||
.xloc-nested-field {
|
||
margin-bottom: 10px;
|
||
padding: 10px 10px 12px;
|
||
border: 1px solid var(--xloc-border);
|
||
border-radius: 10px;
|
||
background: rgba(255, 255, 255, 0.65);
|
||
box-shadow: var(--xloc-shadow-sm);
|
||
}
|
||
|
||
/* Panel cards */
|
||
.xloc-panel {
|
||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.94) 100%);
|
||
padding: 14px 16px;
|
||
border-radius: var(--xloc-radius);
|
||
margin-bottom: 14px;
|
||
border: 1px solid var(--xloc-border);
|
||
border-left-width: 4px;
|
||
box-shadow: var(--xloc-shadow-sm), var(--xloc-shadow-md);
|
||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||
}
|
||
|
||
.xloc-panel:hover {
|
||
box-shadow: var(--xloc-shadow-md), 0 8px 28px rgba(15, 23, 42, 0.08);
|
||
}
|
||
|
||
.xloc-panel--accent-green { border-left-color: #2e7d32; }
|
||
.xloc-panel--accent-amber { border-left-color: #f57c00; }
|
||
.xloc-panel--accent-blue { border-left-color: #1976d2; }
|
||
.xloc-panel--accent-orange { border-left-color: #ff9800; }
|
||
.xloc-panel--accent-purple { border-left-color: #7b1fa2; }
|
||
.xloc-panel--accent-red { border-left-color: #d32f2f; }
|
||
.xloc-panel--accent-violet { border-left-color: #8e24aa; }
|
||
.xloc-panel--accent-cyan { border-left-color: #00bcd4; }
|
||
.xloc-panel--accent-deep-blue { border-left-color: #1565c0; }
|
||
.xloc-panel--accent-forest { border-left-color: #388e3c; }
|
||
.xloc-panel--accent-plum { border-left-color: #9c27b0; }
|
||
|
||
/* Map selection layout */
|
||
.xloc-map-setup {
|
||
display: grid;
|
||
gap: 14px;
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.xloc-map-setup__block {
|
||
padding: 12px 12px 10px;
|
||
background: rgba(255, 255, 255, 0.55);
|
||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||
border-radius: 10px;
|
||
box-shadow: var(--xloc-shadow-sm);
|
||
}
|
||
|
||
.xloc-map-setup__actions {
|
||
padding: 12px 12px 10px;
|
||
background: rgba(255, 255, 255, 0.55);
|
||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||
border-radius: 10px;
|
||
box-shadow: var(--xloc-shadow-sm);
|
||
}
|
||
|
||
.xloc-map-setup__actions-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
gap: 12px;
|
||
}
|
||
|
||
.xloc-map-setup__badge {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 999px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(245, 124, 0, 0.12);
|
||
border: 1px solid rgba(245, 124, 0, 0.25);
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.xloc-map-setup__btn-icon {
|
||
margin-right: 10px;
|
||
}
|
||
|
||
.xloc-map-setup__btn-spinner {
|
||
margin-right: 10px;
|
||
}
|
||
|
||
.xloc-map-setup__buttons {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.xloc-map-setup__divider {
|
||
height: 1px;
|
||
width: 100%;
|
||
background: rgba(15, 23, 42, 0.12);
|
||
margin: 2px 0;
|
||
}
|
||
|
||
.xloc-map-setup__button-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
|
||
.xloc-map-setup__button-grid:last-of-type {
|
||
margin-bottom: 0;
|
||
}
|
||
</style>
|
||
|
||
<div class="xloc-map-container">
|
||
<!-- Top Toolbar -->
|
||
<div class="xloc-toolbar">
|
||
<div class="xloc-toolbar__brand">
|
||
<span class="xloc-toolbar__title">XLOC</span>
|
||
<span class="xloc-toolbar__subtitle">Map visualization</span>
|
||
</div>
|
||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
|
||
StartIcon="@Icons.Material.Filled.Map"
|
||
OnClick="ToggleFeaturePanel">
|
||
Feature
|
||
</MudButton>
|
||
<MudButton Variant="Variant.Filled" Color="Color.Success" Size="Size.Small"
|
||
StartIcon="@Icons.Material.Filled.Info"
|
||
OnClick="ToggleInformationPanel">
|
||
Information
|
||
</MudButton>
|
||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Size="Size.Small"
|
||
StartIcon="@Icons.Material.Filled.Visibility"
|
||
OnClick="ToggleViewPanel">
|
||
View
|
||
</MudButton>
|
||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
|
||
StartIcon="@Icons.Material.Filled.Refresh"
|
||
OnClick="LoadGridMap" Disabled="@(IsLoadingGridMap || IsProcessing)">
|
||
@if (IsLoadingGridMap)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||
<span>Loading...</span>
|
||
}
|
||
else
|
||
{
|
||
<span>Load Grid Map</span>
|
||
}
|
||
</MudButton>
|
||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
|
||
OnClick="ZoomIn" Style="min-width: 40px;">+</MudButton>
|
||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
|
||
OnClick="ZoomOut" Style="min-width: 40px;">-</MudButton>
|
||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
|
||
OnClick="ResetZoom">Reset</MudButton>
|
||
<div class="xloc-toolbar__meta">
|
||
@if (_gridMapData != null)
|
||
{
|
||
<MudText Typo="Typo.body2" Class="xloc-toolbar__chip">
|
||
Size @_gridMapData.Width × @_gridMapData.Height · @_gridMapData.Resolution m/cell
|
||
</MudText>
|
||
}
|
||
@if (_robotPose != null)
|
||
{
|
||
<MudText Typo="Typo.body2" Class="xloc-toolbar__chip">
|
||
Robot X @_robotPose.X.ToString("F2") m · Y @_robotPose.Y.ToString("F2") m · Yaw @(_robotPose.Yaw * 180.0 / Math.PI).ToString("F1")°
|
||
</MudText>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content Area -->
|
||
<div style="flex: 1; position: relative; display: flex; overflow: hidden;">
|
||
<!-- Left Sidebar - Feature Panel -->
|
||
<div class="xloc-sidebar xloc-sidebar--left" style="width: @(ShowFeaturePanel ? "350px" : "0px");">
|
||
@if (ShowFeaturePanel)
|
||
{
|
||
<div class="xloc-sidebar-body">
|
||
<!-- Section 1: Mapping Control -->
|
||
<div class="xloc-panel xloc-panel--accent-green">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 16px;" @onclick="() => IsMappingControlExpanded = !IsMappingControlExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #2e7d32 !important; font-weight: 600; margin: 0;"> Mapping Control</MudText>
|
||
<MudIcon Icon="@(IsMappingControlExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #2e7d32 !important;" />
|
||
</div>
|
||
|
||
@if (IsMappingControlExpanded)
|
||
{
|
||
<MudTextField @bind-Value="NewMapName" Label="Map Name" Variant="Variant.Filled" Class="mb-3"
|
||
Style="background-color: rgba(255, 255, 255, 0.95) !important;"
|
||
Placeholder="Enter new map name" />
|
||
|
||
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
|
||
<MudButton Variant="Variant.Filled" Color="Color.Success" FullWidth="true"
|
||
OnClick="StartMapping" Disabled="@(!CanStartMapping)">
|
||
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Class="mr-1" /> Start Mapping
|
||
</MudButton>
|
||
|
||
<MudButton Variant="Variant.Filled" Color="Color.Error" FullWidth="true"
|
||
OnClick="StopMapping" Disabled="@(!CanStopMapping)">
|
||
<MudIcon Icon="@Icons.Material.Filled.Stop" Class="mr-1" /> Stop & Save
|
||
</MudButton>
|
||
</div>
|
||
|
||
@if (IsMappingActive)
|
||
{
|
||
<MudAlert Severity="Severity.Info" Class="mt-2">
|
||
<strong>Mapping in progress...</strong> Drive the robot to create map.
|
||
@if (string.IsNullOrEmpty(NewMapName))
|
||
{
|
||
<br/><span style="color: #ff9800;">⚠️ Please enter map name before stopping</span>
|
||
}
|
||
</MudAlert>
|
||
}
|
||
else if (_diagnosticsData != null)
|
||
{
|
||
<MudAlert Severity="Severity.Normal" Class="mt-2" Style="background-color: #f5f5f5;">
|
||
<strong>XLOC State:</strong> @_diagnosticsData.StateString<br/>
|
||
<small>To start mapping, click "Start Mapping" button</small>
|
||
</MudAlert>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
<!-- Section 2: Map Selection & Activation -->
|
||
<div class="xloc-panel xloc-panel--accent-amber">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 16px;" @onclick="() => IsMapSelectionExpanded = !IsMapSelectionExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #f57c00 !important; font-weight: 600; margin: 0;"> Select & Activate Map</MudText>
|
||
<MudIcon Icon="@(IsMapSelectionExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #f57c00 !important;" />
|
||
</div>
|
||
|
||
@if (IsMapSelectionExpanded)
|
||
{
|
||
<div class="xloc-map-setup">
|
||
<div class="xloc-map-setup__block">
|
||
@if (AvailableMaps.Count > 0)
|
||
{
|
||
<MudSelect T="string"
|
||
Value="@SelectedMapName"
|
||
ValueChanged="@OnSelectedMapNameChanged"
|
||
Label="Available Maps"
|
||
Variant="Variant.Filled"
|
||
Class="mb-3"
|
||
Style="background-color: rgba(255, 255, 255, 0.95) !important;">
|
||
@foreach (var map in AvailableMaps)
|
||
{
|
||
<MudSelectItem T="string" Value="@map">@map</MudSelectItem>
|
||
}
|
||
</MudSelect>
|
||
|
||
<MudSelect T="string"
|
||
Value="@MapToDeleteName"
|
||
ValueChanged="@OnMapToDeleteNameChanged"
|
||
Label="Map to delete"
|
||
Variant="Variant.Filled"
|
||
Class="mb-3"
|
||
Clearable="true"
|
||
Style="background-color: rgba(255, 255, 255, 0.95) !important;">
|
||
@foreach (var map in AvailableMaps)
|
||
{
|
||
<MudSelectItem T="string" Value="@map">@map</MudSelectItem>
|
||
}
|
||
</MudSelect>
|
||
}
|
||
else
|
||
{
|
||
<MudText Typo="Typo.body2" Class="mb-3" Style="color: #ff9800 !important;">
|
||
No maps found. Loading from: @XlocPaths.GetMapsDirectory()
|
||
</MudText>
|
||
<MudButton Variant="Variant.Outlined"
|
||
Color="Color.Secondary"
|
||
Size="Size.Small"
|
||
OnClick="LoadAvailableMaps"
|
||
Class="mb-3">
|
||
Refresh Maps
|
||
</MudButton>
|
||
}
|
||
</div>
|
||
|
||
<div class="xloc-map-setup__actions">
|
||
<div class="xloc-map-setup__actions-title">
|
||
<MudText Typo="Typo.h6" Style="color: #f57c00 !important; font-weight: 600; margin: 0;">
|
||
Activate & Manage
|
||
</MudText>
|
||
<div class="xloc-map-setup__badge">
|
||
<MudIcon Icon="@(IsActivatingMap ? Icons.Material.Filled.Refresh : Icons.Material.Filled.CheckCircle)"
|
||
Style="color: #f57c00 !important;" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="xloc-map-setup__buttons">
|
||
<MudButton Variant="Variant.Filled"
|
||
Color="Color.Primary"
|
||
FullWidth="true"
|
||
OnClick="LoadSelectedMap"
|
||
Disabled="@(string.IsNullOrEmpty(SelectedMapName) || IsLoadingMap || IsProcessing)">
|
||
@if (IsLoadingMap)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="xloc-map-setup__btn-spinner" />
|
||
<span>Loading...</span>
|
||
}
|
||
else
|
||
{
|
||
<MudIcon Icon="@Icons.Material.Filled.Download" Class="xloc-map-setup__btn-icon" />
|
||
<span>Load Map (View)</span>
|
||
}
|
||
</MudButton>
|
||
|
||
<MudButton Variant="Variant.Filled"
|
||
Color="Color.Warning"
|
||
FullWidth="true"
|
||
OnClick="ActivateSelectedMap"
|
||
Disabled="@(!CanActivateMap)">
|
||
@if (IsActivatingMap)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="xloc-map-setup__btn-spinner" />
|
||
}
|
||
else
|
||
{
|
||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Class="xloc-map-setup__btn-icon" />
|
||
}
|
||
<span>Activate</span>
|
||
</MudButton>
|
||
|
||
<div class="xloc-map-setup__divider"></div>
|
||
|
||
<InputFile id="xloc-import-map-zip"
|
||
OnChange="OnImportMapZipAsync"
|
||
accept=".zip,application/zip"
|
||
style="display:none" />
|
||
|
||
<MudButton Variant="Variant.Outlined"
|
||
Color="Color.Info"
|
||
FullWidth="true"
|
||
OnClick="DownloadSelectedMapZipAsync"
|
||
Disabled="@(string.IsNullOrEmpty(SelectedMapName) || IsDownloadingMapZip)">
|
||
@if (IsDownloadingMapZip)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="xloc-map-setup__btn-spinner" />
|
||
}
|
||
else
|
||
{
|
||
<MudIcon Icon="@Icons.Material.Filled.Archive" Class="xloc-map-setup__btn-icon" />
|
||
}
|
||
<span>Download map (.zip)</span>
|
||
</MudButton>
|
||
|
||
<MudButton Variant="Variant.Outlined"
|
||
Color="Color.Secondary"
|
||
FullWidth="true"
|
||
OnClick="OpenImportMapPickerAsync"
|
||
Disabled="@(IsImportingMap)">
|
||
@if (IsImportingMap)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="xloc-map-setup__btn-spinner" />
|
||
}
|
||
else
|
||
{
|
||
<MudIcon Icon="@Icons.Material.Filled.Upload" Class="xloc-map-setup__btn-icon" />
|
||
}
|
||
<span>Import map (.zip)</span>
|
||
</MudButton>
|
||
|
||
<div class="xloc-map-setup__divider"></div>
|
||
|
||
<MudButton Variant="Variant.Outlined"
|
||
Color="Color.Error"
|
||
FullWidth="true"
|
||
OnClick="ConfirmDeleteMapAsync"
|
||
Disabled="@(string.IsNullOrEmpty(MapToDeleteName) || IsDeletingMap)">
|
||
@if (IsDeletingMap)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="xloc-map-setup__btn-spinner" />
|
||
}
|
||
else
|
||
{
|
||
<MudIcon Icon="@Icons.Material.Filled.DeleteForever" Class="xloc-map-setup__btn-icon" />
|
||
}
|
||
<span>Delete map</span>
|
||
</MudButton>
|
||
|
||
@if (!string.IsNullOrEmpty(CurrentActiveMap))
|
||
{
|
||
<MudAlert Severity="Severity.Success" Class="mt-2">
|
||
<strong>Active:</strong> @CurrentActiveMap
|
||
</MudAlert>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<!-- Section 3: Localization Control -->
|
||
<div class="xloc-panel xloc-panel--accent-blue">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 16px;" @onclick="() => IsLocalizationControlExpanded = !IsLocalizationControlExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #1976d2 !important; font-weight: 600; margin: 0;"> Localization Control</MudText>
|
||
<MudIcon Icon="@(IsLocalizationControlExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #1976d2 !important;" />
|
||
</div>
|
||
|
||
@if (IsLocalizationControlExpanded)
|
||
{
|
||
<div style="display: flex; gap: 8px;">
|
||
<MudButton Variant="Variant.Filled" Color="Color.Success" FullWidth="true"
|
||
OnClick="StartLocalization" Disabled="@(!CanStartLocalization)">
|
||
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Class="mr-1" /> Start
|
||
</MudButton>
|
||
|
||
<MudButton Variant="Variant.Filled" Color="Color.Error" FullWidth="true"
|
||
OnClick="StopLocalization" Disabled="@(!CanStopLocalization)">
|
||
<MudIcon Icon="@Icons.Material.Filled.Stop" Class="mr-1" /> Stop
|
||
</MudButton>
|
||
</div>
|
||
|
||
@if (IsLocalizationActive)
|
||
{
|
||
<MudAlert Severity="Severity.Info" Class="mt-2">
|
||
<strong>Localization active</strong> - Robot is tracking position on map.
|
||
</MudAlert>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
<!-- Section 3.5: Update Map Control -->
|
||
<div class="xloc-panel xloc-panel--accent-orange">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 16px;" @onclick="() => IsUpdateMapControlExpanded = !IsUpdateMapControlExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #ff9800 !important; font-weight: 600; margin: 0;"> Update Map Control</MudText>
|
||
<MudIcon Icon="@(IsUpdateMapControlExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #ff9800 !important;" />
|
||
</div>
|
||
|
||
@if (IsUpdateMapControlExpanded)
|
||
{
|
||
<div style="display: flex; gap: 8px;">
|
||
<MudButton Variant="Variant.Filled" Color="Color.Warning" FullWidth="true"
|
||
OnClick="StartUpdateMap" Disabled="@(!CanStartUpdateMap)">
|
||
<MudIcon Icon="@Icons.Material.Filled.Edit" Class="mr-1" /> Start Update
|
||
</MudButton>
|
||
|
||
<MudButton Variant="Variant.Filled" Color="Color.Error" FullWidth="true"
|
||
OnClick="StopUpdateMap" Disabled="@(!CanStopUpdateMap)">
|
||
<MudIcon Icon="@Icons.Material.Filled.Stop" Class="mr-1" /> Stop & Save
|
||
</MudButton>
|
||
</div>
|
||
|
||
@if (IsUpdateMapActive)
|
||
{
|
||
<MudAlert Severity="Severity.Warning" Class="mt-2">
|
||
<strong>Map update in progress...</strong> Online map is being overlaid on the active map.
|
||
</MudAlert>
|
||
}
|
||
else if (!IsLocalizationActive)
|
||
{
|
||
<MudAlert Severity="Severity.Info" Class="mt-2">
|
||
<strong>Note:</strong> Update map can only be started when localization is active.
|
||
</MudAlert>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
<!-- Section 4: Set Initial Pose -->
|
||
<div class="xloc-panel xloc-panel--accent-purple">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 12px;" @onclick="() => IsInitialPoseExpanded = !IsInitialPoseExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #7b1fa2 !important; font-weight: 600; margin: 0;"> Set Initial Pose</MudText>
|
||
<MudIcon Icon="@(IsInitialPoseExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #7b1fa2 !important;" />
|
||
</div>
|
||
|
||
@if (IsInitialPoseExpanded)
|
||
{
|
||
<div style="@GetInitialPoseModeBackgroundStyle()">
|
||
<MudSwitch T="bool"
|
||
Color="@(IsInitialPoseMode ? Color.Success : Color.Error)"
|
||
@bind-Value="IsInitialPoseMode"
|
||
@bind-Value:after="OnInitialPoseModeChanged">
|
||
</MudSwitch>
|
||
<MudText Typo="Typo.body2" Style="@GetInitialPoseModeTextStyle()">
|
||
<strong>Drag Arrow Mode: @(IsInitialPoseMode ? "ON" : "OFF")</strong>
|
||
</MudText>
|
||
</div>
|
||
|
||
<MudText Typo="Typo.caption" Class="mb-3" Style="color: #666 !important; display: block;">
|
||
@if (IsInitialPoseMode)
|
||
{
|
||
<span style="color: #4caf50 !important;">✓ Click and drag on map to set pose</span>
|
||
}
|
||
else
|
||
{
|
||
<span>Enter coordinates below or turn on drag mode</span>
|
||
}
|
||
</MudText>
|
||
|
||
<!-- Position Inputs -->
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 12px;">
|
||
<MudNumericField T="double" @bind-Value="InitialPoseX" Label="X (m)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F3" Step="0.1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
<MudNumericField T="double" @bind-Value="InitialPoseY" Label="Y (m)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F3" Step="0.1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
<MudNumericField T="double" @bind-Value="InitialPoseZ" Label="Z (m)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F3" Step="0.1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
</div>
|
||
|
||
<!-- Orientation Inputs -->
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 12px;">
|
||
<MudNumericField T="double" @bind-Value="InitialPoseRoll" Label="Roll (°)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F1" Step="1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
<MudNumericField T="double" @bind-Value="InitialPosePitch" Label="Pitch (°)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F1" Step="1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
<MudNumericField T="double" @bind-Value="InitialPoseYaw" Label="Yaw (°)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F1" Step="1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
</div>
|
||
|
||
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
|
||
OnClick="ApplyInitialPose" Disabled="@IsProcessing">
|
||
<MudIcon Icon="@Icons.Material.Filled.Navigation" Class="mr-1" /> Apply Initial Pose
|
||
</MudButton>
|
||
|
||
@if (!string.IsNullOrEmpty(InitialPoseMessage))
|
||
{
|
||
<MudAlert Severity="@(InitialPoseSuccess ? Severity.Success : Severity.Error)" Class="mt-2" Dense="true">
|
||
@InitialPoseMessage
|
||
</MudAlert>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
<!-- Section 5: Reset Error -->
|
||
<div class="xloc-panel xloc-panel--accent-red">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 16px;" @onclick="() => IsResetErrorExpanded = !IsResetErrorExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #d32f2f !important; font-weight: 600; margin: 0;"> Reset Error</MudText>
|
||
<MudIcon Icon="@(IsResetErrorExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #d32f2f !important;" />
|
||
</div>
|
||
|
||
@if (IsResetErrorExpanded)
|
||
{
|
||
<MudText Typo="Typo.body2" Class="mb-3" Style="color: #666 !important;">
|
||
Reset SLAM error state to clear previous trajectory state. This can help resolve mapping or localization issues.
|
||
</MudText>
|
||
|
||
<MudButton Variant="Variant.Filled" Color="Color.Error" FullWidth="true"
|
||
OnClick="ResetSlamError" Disabled="@IsProcessing">
|
||
<MudIcon Icon="@Icons.Material.Filled.Refresh" Class="mr-1" /> Reset SLAM Error
|
||
</MudButton>
|
||
|
||
@if (!string.IsNullOrEmpty(ResetErrorMessage))
|
||
{
|
||
<MudAlert Severity="@(ResetErrorSuccess ? Severity.Success : Severity.Error)" Class="mt-2" Dense="true">
|
||
@ResetErrorMessage
|
||
</MudAlert>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
<!-- Section 6: Change Map Origin -->
|
||
<div class="xloc-panel xloc-panel--accent-blue">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 12px;" @onclick="() => IsChangeOriginExpanded = !IsChangeOriginExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #1976d2 !important; font-weight: 600; margin: 0;"> Change Map Origin</MudText>
|
||
<MudIcon Icon="@(IsChangeOriginExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #1976d2 !important;" />
|
||
</div>
|
||
|
||
@if (IsChangeOriginExpanded)
|
||
{
|
||
<MudText Typo="Typo.caption" Class="mb-3" Style="color: #666 !important; display: block;">
|
||
Re-anchors the map coordinate system. Enter the new origin as a pose (x, y, z + yaw) in the current map frame.
|
||
</MudText>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 12px;">
|
||
<MudNumericField T="double" @bind-Value="ChangeOriginX" Label="X (m)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F3" Step="0.1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
<MudNumericField T="double" @bind-Value="ChangeOriginY" Label="Y (m)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F3" Step="0.1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
<MudNumericField T="double" @bind-Value="ChangeOriginZ" Label="Z (m)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F3" Step="0.1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
</div>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr; gap: 8px; margin-bottom: 12px;">
|
||
<MudNumericField T="double" @bind-Value="ChangeOriginYawDeg" Label="Yaw (°)" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||
Format="F1" Step="1" Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
|
||
</div>
|
||
|
||
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
|
||
OnClick="ApplyChangeMapOrigin" Disabled="@(IsProcessing || IsApplyingChangeOrigin)">
|
||
@if (IsApplyingChangeOrigin)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||
<span>Applying...</span>
|
||
}
|
||
else
|
||
{
|
||
<MudIcon Icon="@Icons.Material.Filled.MyLocation" Class="mr-1" />
|
||
<span>Apply New Origin</span>
|
||
}
|
||
</MudButton>
|
||
|
||
@if (!string.IsNullOrEmpty(ChangeOriginMessage))
|
||
{
|
||
<MudAlert Severity="@(ChangeOriginSuccess ? Severity.Success : Severity.Error)" Class="mt-2" Dense="true">
|
||
@ChangeOriginMessage
|
||
</MudAlert>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
<!-- Status Messages -->
|
||
@if (!string.IsNullOrEmpty(UploadMessage))
|
||
{
|
||
<MudAlert Severity="@(UploadSuccess ? Severity.Success : Severity.Error)" Class="mb-3">
|
||
@UploadMessage
|
||
</MudAlert>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<!-- Map Canvas - Takes remaining space -->
|
||
<div class="xloc-map-stage">
|
||
<canvas @ref="MapCanvas" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><line x1="0" y1="12" x2="24" y2="12" stroke="%231e3a5f" stroke-width="2"/><line x1="12" y1="0" x2="12" y2="24" stroke="%231e3a5f" stroke-width="2"/></svg>') 12 12, crosshair; display: block;"></canvas>
|
||
<div id="xloc-map-cursor-tooltip" class="xloc-map-tooltip" style="position: absolute; display: none; z-index: 2;">
|
||
Map: (0.000, 0.000)m
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right Sidebar - View Panel -->
|
||
<div class="xloc-sidebar xloc-sidebar--right" style="width: @(ShowViewPanel ? "300px" : "0px");">
|
||
@if (ShowViewPanel)
|
||
{
|
||
<div class="xloc-sidebar-body">
|
||
<div class="xloc-panel xloc-panel--accent-violet">
|
||
<MudText Typo="Typo.h6" Style="color: #8e24aa !important; font-weight: 600; margin-bottom: 12px;">
|
||
Robot Icon
|
||
</MudText>
|
||
<MudSwitch T="bool"
|
||
Value="@ShowRobotIconLayer"
|
||
ValueChanged="OnRobotIconVisibilityChanged"
|
||
Color="Color.Secondary"
|
||
Label="Show robot icon" />
|
||
</div>
|
||
|
||
<div class="xloc-panel xloc-panel--accent-cyan">
|
||
<MudText Typo="Typo.h6" Style="color: #00bcd4 !important; font-weight: 600; margin-bottom: 12px;">
|
||
Robot Footprint
|
||
</MudText>
|
||
<MudSwitch T="bool"
|
||
Value="@ShowRobotFootprintLayer"
|
||
ValueChanged="OnRobotFootprintVisibilityChanged"
|
||
Color="Color.Info"
|
||
Label="Show robot base footprint" />
|
||
</div>
|
||
|
||
<div class="xloc-panel xloc-panel--accent-purple">
|
||
<MudText Typo="Typo.h6" Style="color: #7b1fa2 !important; font-weight: 600; margin-bottom: 12px;">
|
||
Global Path
|
||
</MudText>
|
||
<MudSwitch T="bool"
|
||
Value="@ShowGlobalPathLayer"
|
||
ValueChanged="OnGlobalPathVisibilityChanged"
|
||
Color="Color.Primary"
|
||
Label="Show path from navigation_get_global_data" />
|
||
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; margin-top: 8px; line-height: 1.5;">
|
||
<strong>Source:</strong> PlannerDataOutput.plan<br />
|
||
<strong>Point count:</strong> @GlobalPathPointCount<br />
|
||
<strong>Status:</strong> @(string.IsNullOrEmpty(GlobalPathStatus) ? "Idle" : GlobalPathStatus)
|
||
</MudText>
|
||
|
||
<MudButton Variant="Variant.Outlined"
|
||
Color="Color.Primary"
|
||
Size="Size.Small"
|
||
Class="mt-2"
|
||
Disabled="@(!ShowGlobalPathLayer || IsGlobalPathLoading)"
|
||
OnClick="() => UpdateGlobalPath(force: true)">
|
||
@if (IsGlobalPathLoading)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||
<span>Refreshing...</span>
|
||
}
|
||
else
|
||
{
|
||
<span>Refresh Path</span>
|
||
}
|
||
</MudButton>
|
||
</div>
|
||
|
||
<div class="xloc-panel xloc-panel--accent-amber">
|
||
<MudText Typo="Typo.h6" Style="color: #f57c00 !important; font-weight: 600; margin-bottom: 12px;">
|
||
Cost Map
|
||
</MudText>
|
||
<MudSwitch T="bool"
|
||
Value="@ShowCostMapLayer"
|
||
ValueChanged="OnCostMapVisibilityChanged"
|
||
Color="Color.Warning"
|
||
Label="Show cost map from navigation_get_local_data" />
|
||
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; margin-top: 8px; line-height: 1.5;">
|
||
<strong>Source:</strong> PlannerDataOutput.costmap<br />
|
||
<strong>Resolution:</strong> @(CostMapResolution > 0 ? $"{CostMapResolution:F3} m/cell" : "N/A")<br />
|
||
<strong>Size:</strong> @CostMapSize<br />
|
||
<strong>Status:</strong> @(string.IsNullOrEmpty(CostMapStatus) ? "Idle" : CostMapStatus)
|
||
</MudText>
|
||
|
||
<MudButton Variant="Variant.Outlined"
|
||
Color="Color.Warning"
|
||
Size="Size.Small"
|
||
Class="mt-2"
|
||
Disabled="@(!ShowCostMapLayer || IsGlobalMapLoading)"
|
||
OnClick="() => UpdateCostMap(force: true)">
|
||
@if (IsGlobalMapLoading)
|
||
{
|
||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||
<span>Refreshing...</span>
|
||
}
|
||
else
|
||
{
|
||
<span>Refresh Cost Map</span>
|
||
}
|
||
</MudButton>
|
||
</div>
|
||
|
||
<div class="xloc-panel xloc-panel--accent-deep-blue">
|
||
<MudText Typo="Typo.h6" Style="color: #1565c0 !important; font-weight: 600; margin-bottom: 12px;">
|
||
LiDAR Display
|
||
</MudText>
|
||
|
||
<div class="xloc-nested-field">
|
||
<MudSwitch T="bool"
|
||
Value="@ShowLidar1Layer"
|
||
ValueChanged="OnLidar1VisibilityChanged"
|
||
Color="Color.Error"
|
||
Label="LiDAR 1" />
|
||
<MudSelect T="string"
|
||
Value="@Lidar1DisplayMode"
|
||
ValueChanged="OnLidar1DisplayModeChanged"
|
||
Label="Mode"
|
||
Dense="true"
|
||
Variant="Variant.Outlined">
|
||
<MudSelectItem T="string" Value="@LidarDisplayModeFull">full</MudSelectItem>
|
||
<MudSelectItem T="string" Value="@LidarDisplayModeMinimal">minimal</MudSelectItem>
|
||
</MudSelect>
|
||
</div>
|
||
|
||
<div class="xloc-nested-field">
|
||
<MudSwitch T="bool"
|
||
Value="@ShowLidar2Layer"
|
||
ValueChanged="OnLidar2VisibilityChanged"
|
||
Color="Color.Success"
|
||
Label="LiDAR 2" />
|
||
<MudSelect T="string"
|
||
Value="@Lidar2DisplayMode"
|
||
ValueChanged="OnLidar2DisplayModeChanged"
|
||
Label="Mode"
|
||
Dense="true"
|
||
Variant="Variant.Outlined">
|
||
<MudSelectItem T="string" Value="@LidarDisplayModeFull">full</MudSelectItem>
|
||
<MudSelectItem T="string" Value="@LidarDisplayModeMinimal">minimal</MudSelectItem>
|
||
</MudSelect>
|
||
</div>
|
||
|
||
<div class="xloc-nested-field" style="margin-bottom: 0;">
|
||
<MudSwitch T="bool"
|
||
Value="@ShowLidar3Layer"
|
||
ValueChanged="OnLidar3VisibilityChanged"
|
||
Color="Color.Primary"
|
||
Label="LiDAR 3" />
|
||
<MudSelect T="string"
|
||
Value="@Lidar3DisplayMode"
|
||
ValueChanged="OnLidar3DisplayModeChanged"
|
||
Label="Mode"
|
||
Dense="true"
|
||
Variant="Variant.Outlined">
|
||
<MudSelectItem T="string" Value="@LidarDisplayModeFull">full</MudSelectItem>
|
||
<MudSelectItem T="string" Value="@LidarDisplayModeMinimal">minimal</MudSelectItem>
|
||
</MudSelect>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
<div class="xloc-sidebar xloc-sidebar--right" style="width: @(ShowInformationPanel ? "350px" : "0px");">
|
||
@if (ShowInformationPanel)
|
||
{
|
||
<div class="xloc-sidebar-body">
|
||
<div class="xloc-panel xloc-panel--accent-blue">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 12px;" @onclick="() => IsMapInfoExpanded = !IsMapInfoExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #1976d2 !important; font-weight: 600; margin: 0;">Map Info</MudText>
|
||
<MudIcon Icon="@(IsMapInfoExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #1976d2 !important;" />
|
||
</div>
|
||
@if (IsMapInfoExpanded)
|
||
{
|
||
@if (_gridMapData != null)
|
||
{
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; line-height: 1.6;">
|
||
<strong>Resolution:</strong> @_gridMapData.Resolution m/cell<br />
|
||
<strong>Size:</strong> @_gridMapData.Width x @_gridMapData.Height cells<br />
|
||
<strong>Origin:</strong> (@_gridMapData.Origin.X.ToString("F2"), @_gridMapData.Origin.Y.ToString("F2"), @_gridMapData.Origin.Z.ToString("F2"))
|
||
</MudText>
|
||
}
|
||
else
|
||
{
|
||
<MudText Typo="Typo.body2" Style="color: #666 !important; line-height: 1.6;">
|
||
No map data available
|
||
</MudText>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
@if (_robotPose != null)
|
||
{
|
||
<div class="xloc-panel xloc-panel--accent-forest">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 12px;" @onclick="() => IsRobotPoseExpanded = !IsRobotPoseExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #388e3c !important; font-weight: 600; margin: 0;">Robot Pose</MudText>
|
||
<MudIcon Icon="@(IsRobotPoseExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #388e3c !important;" />
|
||
</div>
|
||
@if (IsRobotPoseExpanded)
|
||
{
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; line-height: 1.6;">
|
||
<strong>X:</strong> @_robotPose.X.ToString("F3")m<br />
|
||
<strong>Y:</strong> @_robotPose.Y.ToString("F3")m<br />
|
||
<strong>Yaw:</strong> @((_robotPose.Yaw * 180.0 / Math.PI).ToString("F1"))°
|
||
</MudText>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
<div class="xloc-panel xloc-panel--accent-amber">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 12px;" @onclick="() => IsVelocityExpanded = !IsVelocityExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #f57c00 !important; font-weight: 600; margin: 0;">Velocity</MudText>
|
||
<MudIcon Icon="@(IsVelocityExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #f57c00 !important;" />
|
||
</div>
|
||
@if (IsVelocityExpanded)
|
||
{
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; line-height: 1.6; margin-bottom: 12px;">
|
||
<strong>OdomVel:</strong><br />
|
||
Linear: @((_velocityData?.OdomLinearVel ?? 0.0).ToString("F3")) m/s<br />
|
||
Angular: @((_velocityData?.OdomAngularVel ?? 0.0).ToString("F3")) rad/s<br />
|
||
<strong>CmdVel:</strong><br />
|
||
Linear: @((_velocityData?.CmdLinearVel ?? 0.0).ToString("F3")) m/s<br />
|
||
Angular: @((_velocityData?.CmdAngularVel ?? 0.0).ToString("F3")) rad/s
|
||
</MudText>
|
||
|
||
<!-- Legend -->
|
||
<div class="xloc-velocity-legend">
|
||
<div style="display: flex; align-items: center; gap: 6px;">
|
||
<div style="width: 20px; height: 2px; background: #2196f3;"></div>
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; font-size: 12px;">OdomVel</MudText>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 6px;">
|
||
<div style="width: 20px; height: 2px; background: #4caf50;"></div>
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; font-size: 12px;">CmdVel</MudText>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Linear Velocity Chart -->
|
||
<div style="margin-bottom: 16px;">
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; font-weight: 500; margin-bottom: 8px;">Linear Velocity (m/s)</MudText>
|
||
<canvas @ref="LinearVelocityChartCanvas" class="xloc-chart-canvas"></canvas>
|
||
</div>
|
||
|
||
<!-- Angular Velocity Chart -->
|
||
<div>
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; font-weight: 500; margin-bottom: 8px;">Angular Velocity (rad/s)</MudText>
|
||
<canvas @ref="AngularVelocityChartCanvas" class="xloc-chart-canvas"></canvas>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
@if (_diagnosticsData != null)
|
||
{
|
||
<div class="xloc-panel xloc-panel--accent-plum">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 12px;" @onclick="() => IsXlocDiagnosticsExpanded = !IsXlocDiagnosticsExpanded">
|
||
<MudText Typo="Typo.h6" Style="color: #9c27b0 !important; font-weight: 600; margin: 0;">XLOC Diagnostics</MudText>
|
||
<MudIcon Icon="@(IsXlocDiagnosticsExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #9c27b0 !important;" />
|
||
</div>
|
||
@if (IsXlocDiagnosticsExpanded)
|
||
{
|
||
<MudText Typo="Typo.body2" Style="color: #000000 !important; line-height: 1.6;">
|
||
<strong>State:</strong> @_diagnosticsData.StateString<br />
|
||
<strong>Active Map:</strong> @(_diagnosticsData.CurrentActiveMap ?? "(none)")<br />
|
||
<strong>Reliability:</strong> @(_diagnosticsData.Reliability.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))%<br />
|
||
<strong>Matching Score:</strong> @(_diagnosticsData.MatchingScore.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))%<br />
|
||
<strong>Header Seq:</strong> @_diagnosticsData.HeaderSeq<br />
|
||
<strong>Frame ID:</strong> @(_diagnosticsData.HeaderFrameId ?? "(empty)")<br />
|
||
<strong>Timestamp:</strong> @_diagnosticsData.HeaderStampSec.@_diagnosticsData.HeaderStampNsec.ToString("D9")s
|
||
</MudText>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<MudPopoverProvider />
|
||
<MudDialogProvider />
|
||
|
||
@code {
|
||
private List<string> AvailableMaps = new();
|
||
private string? SelectedMapName;
|
||
private string? MapToDeleteName;
|
||
private bool IsDeletingMap = false;
|
||
private bool IsLoadingMap = false;
|
||
private string UploadMessage = "";
|
||
private bool UploadSuccess = false;
|
||
private bool IsLoadingGridMap = false;
|
||
private bool _isApplyingInitialPose = false; // Flag to prevent map reload during initial pose application
|
||
private GridMapData? _gridMapData;
|
||
private GridMapData? _staticMapData; // Static map data (for update map mode overlay)
|
||
private OriginData? _lockedMapOrigin; // LOCKED: Never changes after first map load
|
||
private RobotPoseData? _robotPose;
|
||
private DiagnosticsData? _diagnosticsData;
|
||
private VelocityData? _velocityData = new VelocityData { OdomLinearVel = 0.0, OdomAngularVel = 0.0, CmdLinearVel = 0.0, CmdAngularVel = 0.0 };
|
||
private ElementReference MapCanvas;
|
||
private ElementReference LinearVelocityChartCanvas;
|
||
private ElementReference AngularVelocityChartCanvas;
|
||
|
||
// Velocity history for charts (store last 100 data points)
|
||
private readonly List<VelocityHistoryPoint> _velocityHistory = new();
|
||
private const int MaxHistoryPoints = 100;
|
||
private DateTime _chartStartTime = DateTime.UtcNow;
|
||
private System.Threading.Timer? _poseUpdateTimer;
|
||
private System.Threading.Timer? _onlineMapUpdateTimer;
|
||
private int _poseUpdateInProgress = 0;
|
||
private int _onlineMapUpdateInProgress = 0;
|
||
private bool _isDisposed = false;
|
||
private DateTime _lastUiRefreshAt = DateTime.MinValue;
|
||
private DateTime _lastPathCostMapUpdateAt = DateTime.MinValue;
|
||
private const int UiRefreshIntervalMs = 400;
|
||
private const int PathCostMapUpdateIntervalMs = 1500;
|
||
private bool ShowFeaturePanel = false;
|
||
private bool ShowInformationPanel = false;
|
||
private bool ShowViewPanel = false;
|
||
private DotNetObjectReference<XlocMapView>? _dotNetRef;
|
||
|
||
/// <summary>
|
||
/// Builds the URL for server-side <see cref="HttpClient"/> calls into this same app.
|
||
/// Uses <c>127.0.0.1</c> as host while keeping scheme, port, path, and query from the client request.
|
||
/// If we used the browser's public/MOXA/WAN host here, the robot would call its own external IP from inside the LAN —
|
||
/// that often fails (hairpin NAT / no loopback on the gateway), so maps and pose never load.
|
||
/// </summary>
|
||
private Uri ClientApiUri(string pathAndQuery)
|
||
{
|
||
var p = pathAndQuery.StartsWith('/') ? pathAndQuery : "/" + pathAndQuery;
|
||
var absolute = Navigation.ToAbsoluteUri(p);
|
||
return new UriBuilder(absolute) { Host = "127.0.0.1" }.Uri;
|
||
}
|
||
|
||
// View toggles and data
|
||
private bool ShowRobotIconLayer = true;
|
||
private bool ShowRobotFootprintLayer = true;
|
||
private bool ShowGlobalPathLayer = false;
|
||
private bool ShowLocalPathLayer = true;
|
||
private bool IsGlobalPathLoading = false;
|
||
private string GlobalPathStatus = string.Empty;
|
||
private int GlobalPathPointCount = 0;
|
||
private DateTime _lastGlobalPathFetchAt = DateTime.MinValue;
|
||
private int _globalPathFetchInProgress = 0;
|
||
|
||
private bool IsLocalPathLoading = false;
|
||
private string LocalPathStatus = string.Empty;
|
||
private int LocalPathPointCount = 0;
|
||
private DateTime _lastLocalPathFetchAt = DateTime.MinValue;
|
||
private int _localPathFetchInProgress = 0;
|
||
|
||
// Cost map related properties
|
||
private bool ShowCostMapLayer = false;
|
||
private bool IsGlobalMapLoading = false;
|
||
private string CostMapStatus = string.Empty;
|
||
private double CostMapResolution = 0;
|
||
private string CostMapSize = "0x0";
|
||
private int _costMapFetchInProgress = 0;
|
||
|
||
// LiDAR display options
|
||
private const string LidarDisplayModeFull = "full";
|
||
private const string LidarDisplayModeMinimal = "minimal";
|
||
private bool ShowLidar1Layer = true;
|
||
private bool ShowLidar2Layer = true;
|
||
private bool ShowLidar3Layer = true;
|
||
private string Lidar1DisplayMode = LidarDisplayModeMinimal;
|
||
private string Lidar2DisplayMode = LidarDisplayModeMinimal;
|
||
private string Lidar3DisplayMode = LidarDisplayModeMinimal;
|
||
|
||
// Expand/Collapse states for feature panel sections
|
||
private bool IsMappingControlExpanded = true;
|
||
private bool IsMapSelectionExpanded = true;
|
||
private bool IsLocalizationControlExpanded = true;
|
||
private bool IsUpdateMapControlExpanded = true;
|
||
private bool IsInitialPoseExpanded = true;
|
||
private bool IsResetErrorExpanded = true;
|
||
private bool IsChangeOriginExpanded = false;
|
||
|
||
// Expand/Collapse states for information panel sections
|
||
private bool IsMapInfoExpanded = true;
|
||
private bool IsRobotPoseExpanded = true;
|
||
private bool IsVelocityExpanded = true;
|
||
private bool IsXlocDiagnosticsExpanded = true;
|
||
|
||
// New fields for SLAM control
|
||
private string NewMapName = "";
|
||
private bool IsMappingActive = false;
|
||
private bool IsLocalizationActive = false;
|
||
private bool IsUpdateMapActive = false;
|
||
private bool IsActivatingMap = false;
|
||
private bool IsDownloadingMapZip = false;
|
||
private bool IsImportingMap = false;
|
||
private string CurrentActiveMap = "";
|
||
private bool _isStoppingLocalization = false; // Flag to prevent state override when stopping
|
||
|
||
// Helper properties for state-based button enabling/disabling
|
||
// XlocState: 0=MAPPING, 1=LOCALIZATION, 2=PROCESSING, 3=READY, 4=ERROR
|
||
private bool IsProcessing => _diagnosticsData != null && _diagnosticsData.XlocState == 2;
|
||
private bool IsReady => _diagnosticsData != null && _diagnosticsData.XlocState == 3;
|
||
private bool IsMappingState => _diagnosticsData != null && _diagnosticsData.XlocState == 0;
|
||
private bool IsLocalizationState => _diagnosticsData != null && _diagnosticsData.XlocState == 1;
|
||
|
||
// Button enable/disable conditions
|
||
// Note: If _diagnosticsData is null, buttons are disabled (waiting for state)
|
||
private bool CanStartMapping => _diagnosticsData != null && IsReady && !IsProcessing && !IsMappingActive;
|
||
private bool CanStopMapping => _diagnosticsData != null && IsMappingState && IsMappingActive;
|
||
private bool CanActivateMap => _diagnosticsData != null && IsReady && !IsProcessing && !string.IsNullOrEmpty(SelectedMapName) && !IsActivatingMap;
|
||
private bool CanStartLocalization => _diagnosticsData != null && IsReady && !IsProcessing && !IsLocalizationActive && !string.IsNullOrEmpty(CurrentActiveMap);
|
||
private bool CanStopLocalization => _diagnosticsData != null && IsLocalizationState && IsLocalizationActive;
|
||
// Update map can only be started when localization is active
|
||
private bool CanStartUpdateMap => _diagnosticsData != null && IsLocalizationState && IsLocalizationActive && !IsUpdateMapActive;
|
||
private bool CanStopUpdateMap => IsUpdateMapActive;
|
||
|
||
// Initial pose control
|
||
private bool IsInitialPoseMode = false;
|
||
private double InitialPoseX = 0.0;
|
||
private double InitialPoseY = 0.0;
|
||
private double InitialPoseZ = 0.0;
|
||
private double InitialPoseRoll = 0.0; // degrees
|
||
private double InitialPosePitch = 0.0; // degrees
|
||
private double InitialPoseYaw = 0.0; // degrees
|
||
private string InitialPoseMessage = "";
|
||
private bool InitialPoseSuccess = false;
|
||
|
||
// Reset Error control
|
||
private string ResetErrorMessage = "";
|
||
private bool ResetErrorSuccess = false;
|
||
|
||
// Change Map Origin control
|
||
private double ChangeOriginX = 0.0;
|
||
private double ChangeOriginY = 0.0;
|
||
private double ChangeOriginZ = 0.0;
|
||
private double ChangeOriginYawDeg = 0.0;
|
||
private string ChangeOriginMessage = "";
|
||
private bool ChangeOriginSuccess = false;
|
||
private bool IsApplyingChangeOrigin = false;
|
||
|
||
// Helper methods for styling
|
||
private string GetInitialPoseModeBackgroundStyle()
|
||
{
|
||
var bgColor = IsInitialPoseMode ? "rgba(76, 175, 80, 0.1)" : "rgba(244, 67, 54, 0.1)";
|
||
return $"display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(15, 23, 42, 0.08); background: {bgColor}; margin-bottom: 12px;";
|
||
}
|
||
|
||
private string GetInitialPoseModeTextStyle()
|
||
{
|
||
var color = IsInitialPoseMode ? "#4caf50" : "#f44336";
|
||
return $"font-weight: 500; color: {color} !important;";
|
||
}
|
||
|
||
private async Task ToggleFeaturePanel()
|
||
{
|
||
ShowFeaturePanel = !ShowFeaturePanel;
|
||
StateHasChanged();
|
||
await Task.Delay(100); // Wait for DOM update
|
||
await ResizeCanvas();
|
||
}
|
||
|
||
private async Task ToggleInformationPanel()
|
||
{
|
||
ShowInformationPanel = !ShowInformationPanel;
|
||
StateHasChanged();
|
||
await Task.Delay(100); // Wait for DOM update
|
||
await ResizeCanvas();
|
||
}
|
||
|
||
private async Task ToggleViewPanel()
|
||
{
|
||
ShowViewPanel = !ShowViewPanel;
|
||
StateHasChanged();
|
||
await Task.Delay(100); // Wait for DOM update
|
||
await ResizeCanvas();
|
||
}
|
||
|
||
private async Task OnGlobalPathVisibilityChanged(bool visible)
|
||
{
|
||
ShowGlobalPathLayer = visible;
|
||
Logger.LogInformation("Global Path visibility changed to: {Visible}", visible);
|
||
|
||
try
|
||
{
|
||
if (ShowGlobalPathLayer)
|
||
{
|
||
// First fetch path data, THEN enable visibility
|
||
Logger.LogInformation("Fetching path data before enabling visibility...");
|
||
await UpdateGlobalPath(force: true);
|
||
|
||
// Enable visibility after data is loaded
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setGlobalPathVisible", true);
|
||
Logger.LogInformation("Path visibility enabled in renderer");
|
||
}
|
||
else
|
||
{
|
||
// Disable visibility immediately
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setGlobalPathVisible", false);
|
||
GlobalPathStatus = "Hidden";
|
||
Logger.LogInformation("Path visibility disabled");
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Error toggling global path visibility");
|
||
GlobalPathStatus = "Error toggling layer";
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
|
||
private async Task OnRobotIconVisibilityChanged(bool visible)
|
||
{
|
||
ShowRobotIconLayer = visible;
|
||
Logger.LogInformation("Robot icon visibility changed to: {Visible}", visible);
|
||
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotPoseVisible", visible);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Error toggling robot icon visibility");
|
||
}
|
||
}
|
||
|
||
private async Task OnRobotFootprintVisibilityChanged(bool visible)
|
||
{
|
||
ShowRobotFootprintLayer = visible;
|
||
Logger.LogInformation("Robot footprint visibility changed to: {Visible}", visible);
|
||
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotFootprintVisible", visible);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Error toggling robot footprint visibility");
|
||
}
|
||
}
|
||
|
||
private static string NormalizeLidarDisplayMode(string? mode)
|
||
{
|
||
if (string.Equals(mode, LidarDisplayModeFull, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return LidarDisplayModeFull;
|
||
}
|
||
|
||
return LidarDisplayModeMinimal;
|
||
}
|
||
|
||
private async Task ApplyLidarDisplayOptionsAsync()
|
||
{
|
||
try
|
||
{
|
||
var options = new
|
||
{
|
||
lidar1 = new { visible = ShowLidar1Layer, mode = Lidar1DisplayMode },
|
||
lidar2 = new { visible = ShowLidar2Layer, mode = Lidar2DisplayMode },
|
||
lidar3 = new { visible = ShowLidar3Layer, mode = Lidar3DisplayMode }
|
||
};
|
||
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setLidarDisplayOptions", options);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Error applying LiDAR display options");
|
||
}
|
||
}
|
||
|
||
private async Task OnLidar1VisibilityChanged(bool visible)
|
||
{
|
||
ShowLidar1Layer = visible;
|
||
await ApplyLidarDisplayOptionsAsync();
|
||
}
|
||
|
||
private async Task OnLidar2VisibilityChanged(bool visible)
|
||
{
|
||
ShowLidar2Layer = visible;
|
||
await ApplyLidarDisplayOptionsAsync();
|
||
}
|
||
|
||
private async Task OnLidar3VisibilityChanged(bool visible)
|
||
{
|
||
ShowLidar3Layer = visible;
|
||
await ApplyLidarDisplayOptionsAsync();
|
||
}
|
||
|
||
private async Task OnLidar1DisplayModeChanged(string mode)
|
||
{
|
||
Lidar1DisplayMode = NormalizeLidarDisplayMode(mode);
|
||
await ApplyLidarDisplayOptionsAsync();
|
||
}
|
||
|
||
private async Task OnLidar2DisplayModeChanged(string mode)
|
||
{
|
||
Lidar2DisplayMode = NormalizeLidarDisplayMode(mode);
|
||
await ApplyLidarDisplayOptionsAsync();
|
||
}
|
||
|
||
private async Task OnLidar3DisplayModeChanged(string mode)
|
||
{
|
||
Lidar3DisplayMode = NormalizeLidarDisplayMode(mode);
|
||
await ApplyLidarDisplayOptionsAsync();
|
||
}
|
||
|
||
private async Task UpdateGlobalPath(bool force = false)
|
||
{
|
||
if (!ShowGlobalPathLayer)
|
||
{
|
||
Logger.LogDebug("UpdateGlobalPath: Skipped - ShowGlobalPathLayer is disabled");
|
||
return;
|
||
}
|
||
|
||
if (!force && DateTime.UtcNow - _lastGlobalPathFetchAt < TimeSpan.FromMilliseconds(600))
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (System.Threading.Interlocked.CompareExchange(ref _globalPathFetchInProgress, 1, 0) != 0)
|
||
{
|
||
Logger.LogDebug("UpdateGlobalPath: Skipped - Already in progress");
|
||
return;
|
||
}
|
||
|
||
IsGlobalPathLoading = true;
|
||
@* Logger.LogInformation("UpdateGlobalPath: Fetching path data from /api/navigation/global_data/path"); *@
|
||
|
||
try
|
||
{
|
||
var response = await Http.GetAsync(ClientApiUri("/api/navigation/global_data/path"));
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
GlobalPathStatus = $"HTTP {(int)response.StatusCode}";
|
||
Logger.LogWarning("UpdateGlobalPath: HTTP error {StatusCode}", response.StatusCode);
|
||
return;
|
||
}
|
||
|
||
var json = await response.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (!result.TryGetProperty("status", out var status))
|
||
{
|
||
GlobalPathStatus = "Invalid response";
|
||
return;
|
||
}
|
||
|
||
var statusStr = status.GetString();
|
||
if (statusStr == "no_data")
|
||
{
|
||
// Navigation data not available yet - this is normal, don't log warning
|
||
GlobalPathStatus = "Waiting for data...";
|
||
return;
|
||
}
|
||
|
||
if (statusStr != "success")
|
||
{
|
||
GlobalPathStatus = "No planner data";
|
||
return;
|
||
}
|
||
|
||
if (!result.TryGetProperty("plan", out var plan))
|
||
{
|
||
GlobalPathStatus = "No plan payload";
|
||
return;
|
||
}
|
||
|
||
var points = new List<GlobalPathPoint>();
|
||
if (plan.TryGetProperty("points", out var pointsElement) && pointsElement.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var point in pointsElement.EnumerateArray())
|
||
{
|
||
points.Add(new GlobalPathPoint
|
||
{
|
||
X = point.TryGetProperty("x", out var xProp) ? xProp.GetDouble() : 0.0,
|
||
Y = point.TryGetProperty("y", out var yProp) ? yProp.GetDouble() : 0.0,
|
||
Theta = point.TryGetProperty("theta", out var thetaProp) ? thetaProp.GetDouble() : 0.0
|
||
});
|
||
}
|
||
}
|
||
|
||
GlobalPathPointCount = points.Count;
|
||
GlobalPathStatus = points.Count > 0 ? "Updated" : "Empty path";
|
||
_lastGlobalPathFetchAt = DateTime.UtcNow;
|
||
|
||
@* Logger.LogInformation("UpdateGlobalPath: Received {PointCount} path points. Sending to JavaScript renderer.", points.Count); *@
|
||
@* if (points.Count > 0)
|
||
{
|
||
Logger.LogInformation(" First point: ({X}, {Y})", points[0].X, points[0].Y);
|
||
Logger.LogInformation(" Last point: ({X}, {Y})", points[^1].X, points[^1].Y);
|
||
} *@
|
||
|
||
// Serialize to JSON string instead of anonymous objects (JSInterop handles this better)
|
||
var pointsArray = points.Select(p => new { x = p.X, y = p.Y, theta = p.Theta }).ToArray();
|
||
var jsonString = JsonSerializer.Serialize(pointsArray);
|
||
@* Logger.LogInformation("UpdateGlobalPath: Serialized {Count} points to JSON: {JsonLength} chars", points.Count, jsonString.Length); *@
|
||
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync(
|
||
"xlocMapRenderer.setGlobalPathDataJson",
|
||
jsonString);
|
||
@* Logger.LogInformation("UpdateGlobalPath: JSON data sent to JavaScript renderer"); *@
|
||
}
|
||
catch (Exception jsEx)
|
||
{
|
||
Logger.LogError(jsEx, "UpdateGlobalPath: Error sending path data to JavaScript");
|
||
GlobalPathStatus = "JS error";
|
||
throw;
|
||
}
|
||
}
|
||
catch (HttpRequestException ex)
|
||
{
|
||
GlobalPathStatus = "Connection error";
|
||
Logger.LogError(ex, "UpdateGlobalPath: HTTP connection error");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Error updating global path");
|
||
GlobalPathStatus = "Update error";
|
||
}
|
||
finally
|
||
{
|
||
IsGlobalPathLoading = false;
|
||
System.Threading.Interlocked.Exchange(ref _globalPathFetchInProgress, 0);
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
|
||
private async Task UpdateLocalPath(bool force = false)
|
||
{
|
||
if (!ShowLocalPathLayer)
|
||
return;
|
||
|
||
if (!force && DateTime.UtcNow - _lastLocalPathFetchAt < TimeSpan.FromMilliseconds(600))
|
||
return;
|
||
|
||
if (System.Threading.Interlocked.CompareExchange(ref _localPathFetchInProgress, 1, 0) != 0)
|
||
return;
|
||
|
||
IsLocalPathLoading = true;
|
||
|
||
try
|
||
{
|
||
var response = await Http.GetAsync(ClientApiUri("/api/navigation/local_data/path"));
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
LocalPathStatus = $"HTTP {(int)response.StatusCode}";
|
||
return;
|
||
}
|
||
|
||
var json = await response.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (!result.TryGetProperty("status", out var statusEl))
|
||
{
|
||
LocalPathStatus = "Invalid response";
|
||
return;
|
||
}
|
||
|
||
var status = statusEl.GetString() ?? "";
|
||
if (status != "success")
|
||
{
|
||
LocalPathStatus = status == "no_data" ? "Waiting for data..." : status;
|
||
LocalPathPointCount = 0;
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setLocalPathDataJson", "[]");
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setLocalPathVisible", true);
|
||
return;
|
||
}
|
||
|
||
if (!result.TryGetProperty("plan", out var planEl) || !planEl.TryGetProperty("points", out var pointsEl))
|
||
{
|
||
LocalPathStatus = "No plan payload";
|
||
LocalPathPointCount = 0;
|
||
return;
|
||
}
|
||
|
||
var points = new List<object>();
|
||
foreach (var p in pointsEl.EnumerateArray())
|
||
{
|
||
points.Add(new
|
||
{
|
||
x = p.GetProperty("x").GetDouble(),
|
||
y = p.GetProperty("y").GetDouble(),
|
||
theta = p.TryGetProperty("theta", out var t) ? t.GetDouble() : 0.0
|
||
});
|
||
}
|
||
|
||
LocalPathPointCount = points.Count;
|
||
LocalPathStatus = points.Count > 0 ? "Updated" : "Empty path";
|
||
_lastLocalPathFetchAt = DateTime.UtcNow;
|
||
|
||
var jsonString = JsonSerializer.Serialize(points);
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setLocalPathVisible", true);
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setLocalPathDataJson", jsonString);
|
||
}
|
||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||
{
|
||
LocalPathStatus = "Connection error";
|
||
}
|
||
catch
|
||
{
|
||
LocalPathStatus = "Update error";
|
||
}
|
||
finally
|
||
{
|
||
IsLocalPathLoading = false;
|
||
System.Threading.Interlocked.Exchange(ref _localPathFetchInProgress, 0);
|
||
}
|
||
}
|
||
|
||
private async Task OnCostMapVisibilityChanged(bool visible)
|
||
{
|
||
ShowCostMapLayer = visible;
|
||
Logger.LogInformation("Cost Map visibility changed to: {Visible}", visible);
|
||
|
||
try
|
||
{
|
||
if (ShowCostMapLayer)
|
||
{
|
||
// First fetch cost map data, THEN enable visibility
|
||
Logger.LogInformation("Fetching cost map data before enabling visibility...");
|
||
await UpdateCostMap(force: true);
|
||
|
||
// Enable visibility after data is loaded
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setCostMapVisible", true);
|
||
Logger.LogInformation("Cost map visibility enabled in renderer");
|
||
}
|
||
else
|
||
{
|
||
// Disable visibility immediately
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setCostMapVisible", false);
|
||
CostMapStatus = "Hidden";
|
||
Logger.LogInformation("Cost map visibility disabled");
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Error toggling cost map visibility");
|
||
CostMapStatus = "Error toggling layer";
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
|
||
private async Task UpdateCostMap(bool force = false)
|
||
{
|
||
if (!ShowCostMapLayer)
|
||
{
|
||
@* Logger.LogDebug("UpdateCostMap: Skipped - ShowCostMapLayer is disabled"); *@
|
||
return;
|
||
}
|
||
|
||
if (System.Threading.Interlocked.CompareExchange(ref _costMapFetchInProgress, 1, 0) != 0)
|
||
{
|
||
@* Logger.LogDebug("UpdateCostMap: Skipped - Already in progress"); *@
|
||
return;
|
||
}
|
||
|
||
IsGlobalMapLoading = true;
|
||
|
||
try
|
||
{
|
||
var response = await Http.GetAsync(ClientApiUri("/api/navigation/local_data/costmap"));
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
CostMapStatus = $"HTTP {(int)response.StatusCode}";
|
||
Logger.LogWarning("UpdateCostMap: HTTP error {StatusCode}", response.StatusCode);
|
||
return;
|
||
}
|
||
|
||
var json = await response.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (!result.TryGetProperty("status", out var status))
|
||
{
|
||
CostMapStatus = "Invalid response";
|
||
return;
|
||
}
|
||
|
||
var statusStr = status.GetString();
|
||
if (statusStr == "no_data")
|
||
{
|
||
// Navigation data not available yet - this is normal, don't log warning
|
||
CostMapStatus = "Waiting for data...";
|
||
return;
|
||
}
|
||
|
||
if (statusStr != "success")
|
||
{
|
||
CostMapStatus = "No cost map data";
|
||
return;
|
||
}
|
||
|
||
if (!result.TryGetProperty("costmap", out var costmapElement))
|
||
{
|
||
CostMapStatus = "No costmap payload";
|
||
@* Logger.LogWarning("UpdateCostMap: No costmap payload in response"); *@
|
||
return;
|
||
}
|
||
|
||
var hasFullMap = result.TryGetProperty("hasFullMap", out var hasFullMapProp) && hasFullMapProp.GetBoolean();
|
||
var isCostmapUpdated = result.TryGetProperty("isCostmapUpdated", out var isCostmapUpdatedProp) && isCostmapUpdatedProp.GetBoolean();
|
||
|
||
// Extract cost map metadata
|
||
var frameId = costmapElement.TryGetProperty("frameId", out var frameProp) ? frameProp.GetString() : "map";
|
||
var width = costmapElement.TryGetProperty("width", out var widthProp) ? widthProp.GetInt32() : 0;
|
||
var height = costmapElement.TryGetProperty("height", out var heightProp) ? heightProp.GetInt32() : 0;
|
||
var resolution = costmapElement.TryGetProperty("resolution", out var resProp) ? resProp.GetDouble() : 0;
|
||
var dataSize = costmapElement.TryGetProperty("dataSize", out var dataSizeProp) ? dataSizeProp.GetInt32() : 0;
|
||
var dataBase64 = costmapElement.TryGetProperty("data", out var dataProp) ? dataProp.GetString() : string.Empty;
|
||
|
||
// Extract origin (position of cost map in map frame)
|
||
double originX = 0, originY = 0, originTheta = 0;
|
||
if (costmapElement.TryGetProperty("origin", out var originElement))
|
||
{
|
||
originX = originElement.TryGetProperty("x", out var oxProp) ? oxProp.GetDouble() : 0;
|
||
originY = originElement.TryGetProperty("y", out var oyProp) ? oyProp.GetDouble() : 0;
|
||
originTheta = originElement.TryGetProperty("theta", out var otProp) ? otProp.GetDouble() : 0;
|
||
}
|
||
|
||
// Extract cost map update patch
|
||
string updateFrameId = frameId ?? "map";
|
||
int updateX = 0, updateY = 0, updateWidth = 0, updateHeight = 0, updateDataSize = 0;
|
||
string? updateDataBase64 = null;
|
||
if (result.TryGetProperty("costmapUpdate", out var costmapUpdateElement))
|
||
{
|
||
updateFrameId = costmapUpdateElement.TryGetProperty("frameId", out var updateFrameProp) ? updateFrameProp.GetString() ?? updateFrameId : updateFrameId;
|
||
updateX = costmapUpdateElement.TryGetProperty("x", out var updateXProp) ? updateXProp.GetInt32() : 0;
|
||
updateY = costmapUpdateElement.TryGetProperty("y", out var updateYProp) ? updateYProp.GetInt32() : 0;
|
||
updateWidth = costmapUpdateElement.TryGetProperty("width", out var updateWidthProp) ? updateWidthProp.GetInt32() : 0;
|
||
updateHeight = costmapUpdateElement.TryGetProperty("height", out var updateHeightProp) ? updateHeightProp.GetInt32() : 0;
|
||
updateDataSize = costmapUpdateElement.TryGetProperty("dataSize", out var updateDataSizeProp) ? updateDataSizeProp.GetInt32() : 0;
|
||
updateDataBase64 = costmapUpdateElement.TryGetProperty("data", out var updateDataProp) ? updateDataProp.GetString() : null;
|
||
}
|
||
|
||
// Extract odometry context (used to move local costmap window)
|
||
double odomX = 0, odomY = 0, odomYaw = 0;
|
||
if (result.TryGetProperty("odometry", out var odometryElement))
|
||
{
|
||
odomX = odometryElement.TryGetProperty("x", out var odomXProp) ? odomXProp.GetDouble() : 0;
|
||
odomY = odometryElement.TryGetProperty("y", out var odomYProp) ? odomYProp.GetDouble() : 0;
|
||
odomYaw = odometryElement.TryGetProperty("yaw", out var odomYawProp) ? odomYawProp.GetDouble() : 0;
|
||
}
|
||
|
||
CostMapResolution = resolution;
|
||
CostMapSize = $"{width}x{height}";
|
||
CostMapStatus = hasFullMap ? "Full map" : (isCostmapUpdated ? "Patched" : "Empty costmap");
|
||
|
||
@* Logger.LogInformation("UpdateCostMap: Full={HasFullMap}, Updated={IsCostmapUpdated}, Width={Width}, Height={Height}, Resolution={Resolution:F3}, DataSize={DataSize}, UpdateDataSize={UpdateDataSize}",
|
||
hasFullMap, isCostmapUpdated, width, height, resolution, dataSize, updateDataSize); *@
|
||
|
||
// Send cost map data to JavaScript renderer
|
||
if ((hasFullMap && !string.IsNullOrEmpty(dataBase64) && width > 0 && height > 0) ||
|
||
(isCostmapUpdated && !string.IsNullOrEmpty(updateDataBase64) && updateWidth > 0 && updateHeight > 0))
|
||
{
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync(
|
||
"xlocMapRenderer.setCostMapData",
|
||
new
|
||
{
|
||
frameId = frameId,
|
||
width = width,
|
||
height = height,
|
||
resolution = resolution,
|
||
originX = originX,
|
||
originY = originY,
|
||
originTheta = originTheta,
|
||
dataBase64 = hasFullMap ? dataBase64 : null,
|
||
hasFullMap = hasFullMap,
|
||
isCostmapUpdated = isCostmapUpdated,
|
||
localizationActive = IsLocalizationActive,
|
||
matchingScore = _diagnosticsData?.MatchingScore ?? -1.0,
|
||
xlocPose = _robotPose == null ? null : new
|
||
{
|
||
x = _robotPose.X,
|
||
y = _robotPose.Y,
|
||
yaw = _robotPose.Yaw
|
||
},
|
||
odometry = new
|
||
{
|
||
x = odomX,
|
||
y = odomY,
|
||
yaw = odomYaw
|
||
},
|
||
costmapUpdate = new
|
||
{
|
||
frameId = updateFrameId,
|
||
x = updateX,
|
||
y = updateY,
|
||
width = updateWidth,
|
||
height = updateHeight,
|
||
dataBase64 = isCostmapUpdated ? updateDataBase64 : null
|
||
}
|
||
});
|
||
@* Logger.LogInformation("UpdateCostMap: Cost map data sent to JavaScript renderer"); *@
|
||
}
|
||
catch (Exception jsEx)
|
||
{
|
||
Logger.LogError(jsEx, "UpdateCostMap: Error sending cost map data to JavaScript");
|
||
CostMapStatus = "JS error";
|
||
throw;
|
||
}
|
||
}
|
||
}
|
||
catch (HttpRequestException ex)
|
||
{
|
||
CostMapStatus = "Connection error";
|
||
Logger.LogError(ex, "UpdateCostMap: HTTP connection error");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Error updating cost map");
|
||
CostMapStatus = "Update error";
|
||
}
|
||
finally
|
||
{
|
||
IsGlobalMapLoading = false;
|
||
System.Threading.Interlocked.Exchange(ref _costMapFetchInProgress, 0);
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
|
||
|
||
private async Task ResizeCanvas()
|
||
{
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.resize");
|
||
}
|
||
catch (Microsoft.JSInterop.JSDisconnectedException)
|
||
{
|
||
// Circuit disconnected, ignore
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogDebug(ex, "Error resizing canvas");
|
||
}
|
||
}
|
||
|
||
private async Task OnInitialPoseModeChanged()
|
||
{
|
||
// Note: IsInitialPoseMode is already updated by @bind-Value
|
||
try
|
||
{
|
||
Logger.LogInformation("Initial pose mode changed to: {Mode}", IsInitialPoseMode ? "ON" : "OFF");
|
||
|
||
if (IsInitialPoseMode)
|
||
{
|
||
// Initialize from current robot pose if available, otherwise use origin
|
||
if (_robotPose != null)
|
||
{
|
||
InitialPoseX = _robotPose.X;
|
||
InitialPoseY = _robotPose.Y;
|
||
InitialPoseYaw = _robotPose.Yaw * 180.0 / Math.PI; // Convert rad to deg
|
||
Logger.LogInformation("Initial pose mode ON - Using current robot pose: ({X}, {Y}, {Yaw}°)",
|
||
InitialPoseX, InitialPoseY, InitialPoseYaw);
|
||
}
|
||
else
|
||
{
|
||
// Default to map origin (0, 0) if no robot pose
|
||
InitialPoseX = 0.0;
|
||
InitialPoseY = 0.0;
|
||
InitialPoseYaw = 0.0;
|
||
Logger.LogInformation("Initial pose mode ON - Using default pose at origin: ({X}, {Y}, {Yaw}°)",
|
||
InitialPoseX, InitialPoseY, InitialPoseYaw);
|
||
}
|
||
|
||
// Set initial pose in JavaScript renderer so arrow is visible
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setInitialPose", new
|
||
{
|
||
x = InitialPoseX,
|
||
y = InitialPoseY,
|
||
yaw = InitialPoseYaw * Math.PI / 180.0 // Convert deg to rad for JS
|
||
});
|
||
|
||
Logger.LogInformation("✓ Drag Arrow Mode enabled - Click and drag on map to set robot pose");
|
||
}
|
||
else
|
||
{
|
||
Logger.LogInformation("Initial pose mode OFF - Drag Arrow Mode disabled");
|
||
}
|
||
|
||
// Enable/disable drag mode in JavaScript
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setInitialPoseMode", IsInitialPoseMode);
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error toggling initial pose mode: {Message}", ex.Message);
|
||
InitialPoseMessage = $"Error: {ex.Message}";
|
||
InitialPoseSuccess = false;
|
||
// Revert the toggle if there was an error
|
||
IsInitialPoseMode = !IsInitialPoseMode;
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
|
||
[JSInvokable]
|
||
public async Task OnInitialPoseSelected(InitialPoseInput pose)
|
||
{
|
||
try
|
||
{
|
||
Logger.LogInformation("Initial pose selected from drag: X={X:F3}m, Y={Y:F3}m, Yaw={Yaw:F3}rad ({YawDeg:F1}°)",
|
||
pose.X, pose.Y, pose.Yaw, pose.Yaw * 180.0 / Math.PI);
|
||
|
||
// Update UI fields with the dragged pose
|
||
InitialPoseX = pose.X;
|
||
InitialPoseY = pose.Y;
|
||
InitialPoseYaw = pose.Yaw * 180.0 / Math.PI; // Convert rad to deg
|
||
|
||
// Keep Z, Roll, Pitch unchanged (use current values or 0)
|
||
// For 2D localization, these are typically 0
|
||
|
||
await InvokeAsync(StateHasChanged);
|
||
|
||
// Automatically apply the pose to the robot
|
||
await ApplyInitialPose();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error handling initial pose selection from drag");
|
||
InitialPoseMessage = $"Error: {ex.Message}";
|
||
InitialPoseSuccess = false;
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
|
||
private async Task ApplyInitialPose()
|
||
{
|
||
// Validate state: Cannot perform when PROCESSING
|
||
if (IsProcessing)
|
||
{
|
||
var currentState = _diagnosticsData?.StateString ?? "Unknown";
|
||
InitialPoseMessage = $"Cannot apply initial pose: XLOC is in PROCESSING state. Current state: {currentState}";
|
||
InitialPoseSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot apply initial pose: XLOC is PROCESSING. Current: {State}", currentState);
|
||
return;
|
||
}
|
||
|
||
InitialPoseMessage = "";
|
||
InitialPoseSuccess = false;
|
||
|
||
// CRITICAL: Set flag to prevent map reload during initial pose application
|
||
_isApplyingInitialPose = true;
|
||
|
||
try
|
||
{
|
||
// ====================================================================
|
||
// CRITICAL: Setting initial pose ONLY changes ROBOT position in map frame
|
||
// Map frame origin (0,0,0) is ABSOLUTELY FIXED and NEVER changes!
|
||
// ====================================================================
|
||
|
||
// Save current state BEFORE applying initial pose
|
||
var lockedOriginBefore = _lockedMapOrigin;
|
||
var gridMapOriginBefore = _gridMapData?.Origin;
|
||
|
||
// CRITICAL: Get current robot pose from XLOC BEFORE setting initial pose
|
||
var currentPoseBefore = await Http.GetAsync(ClientApiUri("/api/xloc/pose/current2d"));
|
||
(double x, double y, double yaw)? poseBefore = null;
|
||
if (currentPoseBefore.IsSuccessStatusCode)
|
||
{
|
||
var json = await currentPoseBefore.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
var pose = result.GetProperty("pose");
|
||
poseBefore = (
|
||
pose.GetProperty("x").GetDouble(),
|
||
pose.GetProperty("y").GetDouble(),
|
||
pose.GetProperty("yaw").GetDouble()
|
||
);
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Get current view state from JavaScript to prevent it from changing
|
||
var viewStateBeforeJson = await JSRuntime.InvokeAsync<string>("xlocMapRenderer.getViewState");
|
||
var viewStateBefore = JsonSerializer.Deserialize<JsonElement>(viewStateBeforeJson);
|
||
|
||
Logger.LogWarning("=== BEFORE Apply Initial Pose ===");
|
||
Logger.LogWarning("Current Robot Pose (from xloc_get_current_pose): ({X}, {Y}) Yaw:{Yaw}°",
|
||
poseBefore?.x ?? 0, poseBefore?.y ?? 0, (poseBefore?.yaw ?? 0) * 180.0 / Math.PI);
|
||
Logger.LogWarning("Locked Map Origin: ({X}, {Y}, {Z})",
|
||
lockedOriginBefore?.X ?? 0, lockedOriginBefore?.Y ?? 0, lockedOriginBefore?.Z ?? 0);
|
||
Logger.LogWarning("GridMap Origin: ({X}, {Y}, {Z})",
|
||
gridMapOriginBefore?.X ?? 0, gridMapOriginBefore?.Y ?? 0, gridMapOriginBefore?.Z ?? 0);
|
||
Logger.LogWarning("View State Before: offsetX={OffsetX}, offsetY={OffsetY}, scale={Scale}",
|
||
viewStateBefore.GetProperty("offsetX").GetDouble(),
|
||
viewStateBefore.GetProperty("offsetY").GetDouble(),
|
||
viewStateBefore.GetProperty("scale").GetDouble());
|
||
|
||
// Convert degrees to radians for API
|
||
var response = await Http.PostAsJsonAsync(ClientApiUri("/api/xloc/pose/initial"), new
|
||
{
|
||
x = InitialPoseX,
|
||
y = InitialPoseY,
|
||
z = InitialPoseZ,
|
||
roll = InitialPoseRoll * Math.PI / 180.0,
|
||
pitch = InitialPosePitch * Math.PI / 180.0,
|
||
yaw = InitialPoseYaw * Math.PI / 180.0
|
||
});
|
||
|
||
if (response.IsSuccessStatusCode)
|
||
{
|
||
// CRITICAL: Get current robot pose from XLOC AFTER setting initial pose
|
||
// Wait a bit for XLOC to process the initial pose
|
||
await Task.Delay(100);
|
||
var currentPoseAfter = await Http.GetAsync(ClientApiUri("/api/xloc/pose/current2d"));
|
||
(double x, double y, double yaw)? poseAfter = null;
|
||
if (currentPoseAfter.IsSuccessStatusCode)
|
||
{
|
||
var json = await currentPoseAfter.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
var pose = result.GetProperty("pose");
|
||
poseAfter = (
|
||
pose.GetProperty("x").GetDouble(),
|
||
pose.GetProperty("y").GetDouble(),
|
||
pose.GetProperty("yaw").GetDouble()
|
||
);
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Verify view state has NOT changed
|
||
var viewStateAfterJson = await JSRuntime.InvokeAsync<string>("xlocMapRenderer.getViewState");
|
||
var viewStateAfter = JsonSerializer.Deserialize<JsonElement>(viewStateAfterJson);
|
||
|
||
var offsetXBefore = viewStateBefore.GetProperty("offsetX").GetDouble();
|
||
var offsetYBefore = viewStateBefore.GetProperty("offsetY").GetDouble();
|
||
var scaleBefore = viewStateBefore.GetProperty("scale").GetDouble();
|
||
|
||
var offsetXAfter = viewStateAfter.GetProperty("offsetX").GetDouble();
|
||
var offsetYAfter = viewStateAfter.GetProperty("offsetY").GetDouble();
|
||
var scaleAfter = viewStateAfter.GetProperty("scale").GetDouble();
|
||
|
||
bool viewStateChanged = false;
|
||
if (Math.Abs(offsetXBefore - offsetXAfter) > 0.1 ||
|
||
Math.Abs(offsetYBefore - offsetYAfter) > 0.1 ||
|
||
Math.Abs(scaleBefore - scaleAfter) > 0.1)
|
||
{
|
||
viewStateChanged = true;
|
||
Logger.LogError("⚠️ CRITICAL ERROR: View state changed when applying initial pose!");
|
||
Logger.LogError(" Before: offsetX={OffsetX}, offsetY={OffsetY}, scale={Scale}",
|
||
offsetXBefore, offsetYBefore, scaleBefore);
|
||
Logger.LogError(" After: offsetX={OffsetX}, offsetY={OffsetY}, scale={Scale}",
|
||
offsetXAfter, offsetYAfter, scaleAfter);
|
||
// Restore view state
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setZoom", scaleBefore);
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setViewOffsets", offsetXBefore, offsetYBefore);
|
||
}
|
||
|
||
// CRITICAL: Verify map origin has NOT changed
|
||
var lockedOriginAfter = _lockedMapOrigin;
|
||
var gridMapOriginAfter = _gridMapData?.Origin;
|
||
|
||
bool originChanged = false;
|
||
if (lockedOriginBefore != null && lockedOriginAfter != null)
|
||
{
|
||
if (Math.Abs(lockedOriginBefore.X - lockedOriginAfter.X) > 0.001 ||
|
||
Math.Abs(lockedOriginBefore.Y - lockedOriginAfter.Y) > 0.001 ||
|
||
Math.Abs(lockedOriginBefore.Z - lockedOriginAfter.Z) > 0.001)
|
||
{
|
||
originChanged = true;
|
||
Logger.LogError("⚠️ CRITICAL ERROR: Locked map origin changed!");
|
||
Logger.LogError(" Before: ({X}, {Y}, {Z})", lockedOriginBefore.X, lockedOriginBefore.Y, lockedOriginBefore.Z);
|
||
Logger.LogError(" After: ({X}, {Y}, {Z})", lockedOriginAfter.X, lockedOriginAfter.Y, lockedOriginAfter.Z);
|
||
// Restore locked origin
|
||
_lockedMapOrigin = lockedOriginBefore;
|
||
}
|
||
}
|
||
|
||
if (gridMapOriginBefore != null && gridMapOriginAfter != null)
|
||
{
|
||
if (Math.Abs(gridMapOriginBefore.X - gridMapOriginAfter.X) > 0.001 ||
|
||
Math.Abs(gridMapOriginBefore.Y - gridMapOriginAfter.Y) > 0.001 ||
|
||
Math.Abs(gridMapOriginBefore.Z - gridMapOriginAfter.Z) > 0.001)
|
||
{
|
||
originChanged = true;
|
||
Logger.LogError("⚠️ CRITICAL ERROR: GridMap origin changed!");
|
||
Logger.LogError(" Before: ({X}, {Y}, {Z})", gridMapOriginBefore.X, gridMapOriginBefore.Y, gridMapOriginBefore.Z);
|
||
Logger.LogError(" After: ({X}, {Y}, {Z})", gridMapOriginAfter.X, gridMapOriginAfter.Y, gridMapOriginAfter.Z);
|
||
// Restore grid map origin
|
||
if (_gridMapData != null)
|
||
{
|
||
_gridMapData.Origin = gridMapOriginBefore;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (originChanged || viewStateChanged)
|
||
{
|
||
Logger.LogError("⚠️ CRITICAL: Map origin or view state was changed! This should NEVER happen!");
|
||
Logger.LogError("⚠️ DO NOT re-render map - this would cause map frame to move!");
|
||
Logger.LogError("⚠️ Map origin and view state have been restored. Map frame should remain fixed.");
|
||
// CRITICAL: DO NOT call RenderMap() here - it would reload map and potentially change origin again!
|
||
// The locked origin and restored view state should be sufficient.
|
||
}
|
||
|
||
InitialPoseSuccess = true;
|
||
InitialPoseMessage = $"Initial pose applied: ({InitialPoseX:F2}, {InitialPoseY:F2}, {InitialPoseZ:F2}) R:{InitialPoseRoll:F1}° P:{InitialPosePitch:F1}° Y:{InitialPoseYaw:F1}°";
|
||
|
||
Logger.LogWarning("=== AFTER Apply Initial Pose ===");
|
||
Logger.LogWarning("→ Initial Pose Applied: ({X}, {Y}, {Z}) Yaw:{Yaw}°",
|
||
InitialPoseX, InitialPoseY, InitialPoseZ, InitialPoseYaw);
|
||
Logger.LogWarning("→ Current Robot Pose (from xloc_get_current_pose): ({X}, {Y}) Yaw:{Yaw}°",
|
||
poseAfter?.x ?? 0, poseAfter?.y ?? 0, (poseAfter?.yaw ?? 0) * 180.0 / Math.PI);
|
||
|
||
// Compare pose before and after
|
||
if (poseBefore.HasValue && poseAfter.HasValue)
|
||
{
|
||
var dx = Math.Abs(poseBefore.Value.x - poseAfter.Value.x);
|
||
var dy = Math.Abs(poseBefore.Value.y - poseAfter.Value.y);
|
||
var dyaw = Math.Abs(poseBefore.Value.yaw - poseAfter.Value.yaw);
|
||
|
||
Logger.LogWarning("→ Pose Change: ΔX={DX:F3}m, ΔY={DY:F3}m, ΔYaw={DYaw:F3}rad ({DYawDeg:F1}°)",
|
||
dx, dy, dyaw, dyaw * 180.0 / Math.PI);
|
||
|
||
// Check if pose changed unexpectedly (should match initial pose we set)
|
||
var expectedX = InitialPoseX;
|
||
var expectedY = InitialPoseY;
|
||
var expectedYaw = InitialPoseYaw * Math.PI / 180.0;
|
||
|
||
var diffX = Math.Abs(poseAfter.Value.x - expectedX);
|
||
var diffY = Math.Abs(poseAfter.Value.y - expectedY);
|
||
var diffYaw = Math.Abs(poseAfter.Value.yaw - expectedYaw);
|
||
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var error = await response.Content.ReadAsStringAsync();
|
||
InitialPoseMessage = string.IsNullOrWhiteSpace(error) ? "Failed to set initial pose." : error;
|
||
Logger.LogError("Failed to apply initial pose: {Error}", error);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error applying initial pose");
|
||
InitialPoseMessage = $"Error: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
// Reset flag after initial pose application is complete
|
||
_isApplyingInitialPose = false;
|
||
}
|
||
}
|
||
|
||
private async Task ResetSlamError()
|
||
{
|
||
// Validate state: Cannot perform when PROCESSING
|
||
if (IsProcessing)
|
||
{
|
||
var currentState = _diagnosticsData?.StateString ?? "Unknown";
|
||
ResetErrorMessage = $"Cannot reset SLAM error: XLOC is in PROCESSING state. Current state: {currentState}";
|
||
ResetErrorSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot reset SLAM error: XLOC is PROCESSING. Current: {State}", currentState);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
ResetErrorMessage = "Resetting SLAM error...";
|
||
ResetErrorSuccess = false;
|
||
StateHasChanged();
|
||
|
||
var response = await Http.PostAsync(ClientApiUri("/api/xloc/reset-error"), null);
|
||
var result = await response.Content.ReadAsStringAsync();
|
||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||
|
||
if (response.IsSuccessStatusCode && json.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
ResetErrorMessage = "✅ SLAM error reset successfully! Previous trajectory state has been cleared.";
|
||
ResetErrorSuccess = true;
|
||
Logger.LogInformation("SLAM error reset successfully");
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown error";
|
||
ResetErrorMessage = $"❌ Failed to reset SLAM error: {message}";
|
||
ResetErrorSuccess = false;
|
||
Logger.LogWarning("Failed to reset SLAM error: {Message}", message);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ResetErrorMessage = $"❌ Error resetting SLAM error: {ex.Message}";
|
||
ResetErrorSuccess = false;
|
||
Logger.LogError(ex, "Error resetting SLAM error");
|
||
}
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task ApplyChangeMapOrigin()
|
||
{
|
||
if (IsProcessing)
|
||
{
|
||
var currentState = _diagnosticsData?.StateString ?? "Unknown";
|
||
ChangeOriginMessage = $"Cannot change map origin: XLOC is in PROCESSING state. Current state: {currentState}";
|
||
ChangeOriginSuccess = false;
|
||
StateHasChanged();
|
||
return;
|
||
}
|
||
|
||
IsApplyingChangeOrigin = true;
|
||
ChangeOriginMessage = "Applying new map origin...";
|
||
ChangeOriginSuccess = false;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
var yawRad = ChangeOriginYawDeg * Math.PI / 180.0;
|
||
|
||
// Call the native XLOC re-anchor directly via the integration service.
|
||
var ok = XlocService.ChangeMapOrigin(
|
||
ChangeOriginX,
|
||
ChangeOriginY,
|
||
ChangeOriginZ,
|
||
roll: 0.0,
|
||
pitch: 0.0,
|
||
yaw: yawRad);
|
||
|
||
if (!ok)
|
||
{
|
||
ChangeOriginMessage = "❌ Failed to change map origin (see server log).";
|
||
ChangeOriginSuccess = false;
|
||
Logger.LogWarning("ChangeMapOrigin returned false");
|
||
return;
|
||
}
|
||
|
||
// Drop the JS origin lock so LoadGridMap can pick up the new origin that native
|
||
// XLOC has now written into the map's YAML (via XlocIntegrationService).
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.clearMapOriginLock");
|
||
}
|
||
catch (Exception jsEx)
|
||
{
|
||
Logger.LogWarning(jsEx, "Failed to clear JS origin lock after ChangeMapOrigin");
|
||
}
|
||
|
||
_lockedMapOrigin = null;
|
||
await LoadGridMap();
|
||
|
||
ChangeOriginMessage = $"✅ Map origin updated to ({ChangeOriginX:F3}, {ChangeOriginY:F3}, {ChangeOriginZ:F3}) yaw={ChangeOriginYawDeg:F1}°";
|
||
ChangeOriginSuccess = true;
|
||
Logger.LogInformation("Map origin changed successfully to ({X}, {Y}, {Z}) yaw={YawDeg}°",
|
||
ChangeOriginX, ChangeOriginY, ChangeOriginZ, ChangeOriginYawDeg);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ChangeOriginMessage = $"❌ Error changing map origin: {ex.Message}";
|
||
ChangeOriginSuccess = false;
|
||
Logger.LogError(ex, "Error changing map origin");
|
||
}
|
||
finally
|
||
{
|
||
IsApplyingChangeOrigin = false;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
|
||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||
{
|
||
if (firstRender)
|
||
{
|
||
// Initialize map renderer
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.init", MapCanvas);
|
||
_dotNetRef = DotNetObjectReference.Create(this);
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setDotNetHelper", _dotNetRef);
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotPoseVisible", ShowRobotIconLayer);
|
||
await ApplyLidarDisplayOptionsAsync();
|
||
|
||
await TryLoadRobotFootprintAsync();
|
||
|
||
Logger.LogInformation("Map renderer initialized");
|
||
|
||
// Initialize velocity charts - wait a bit for canvas to be ready
|
||
await Task.Delay(100);
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("velocityChartRenderer.initLinearVelocityChart", LinearVelocityChartCanvas);
|
||
await JSRuntime.InvokeVoidAsync("velocityChartRenderer.initAngularVelocityChart", AngularVelocityChartCanvas);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to initialize velocity charts, will retry on first update");
|
||
}
|
||
|
||
// Restore mapping and localization state from localStorage
|
||
await RestoreMappingState();
|
||
await RestoreLocalizationState();
|
||
|
||
// Load available maps
|
||
await LoadAvailableMaps();
|
||
|
||
// Restore selected map from localStorage
|
||
var restoredActiveMap = await RestoreActiveMap();
|
||
|
||
if (!string.IsNullOrEmpty(restoredActiveMap) && AvailableMaps.Contains(restoredActiveMap))
|
||
{
|
||
// Use the restored active map as both selected and active
|
||
SelectedMapName = restoredActiveMap;
|
||
CurrentActiveMap = restoredActiveMap;
|
||
// Save to ensure consistency
|
||
await SaveSelectedMap();
|
||
// Load the previously active map
|
||
await LoadSelectedMap();
|
||
}
|
||
else
|
||
{
|
||
// Fallback: Try to restore selected map if active map not available
|
||
await RestoreSelectedMap();
|
||
if (!string.IsNullOrEmpty(SelectedMapName) && AvailableMaps.Contains(SelectedMapName))
|
||
{
|
||
await LoadSelectedMap();
|
||
}
|
||
else
|
||
{
|
||
// Final fallback: Auto-load grid map on startup
|
||
await LoadGridMap();
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Failed to initialize map renderer");
|
||
}
|
||
|
||
// Start timer to update robot pose and laser scan
|
||
// 100ms = 10Hz update rate for smooth real-time visualization
|
||
_poseUpdateTimer = new System.Threading.Timer(async _ => await UpdateRobotPose(), null,
|
||
TimeSpan.Zero, TimeSpan.FromMilliseconds(100));
|
||
|
||
// Auto-start localization when READY: see XlocAutoLocalizationHostedService (backend), not here.
|
||
}
|
||
}
|
||
|
||
private async Task TryLoadRobotFootprintAsync()
|
||
{
|
||
try
|
||
{
|
||
var loaded = await LoadRobotFootprintAsync(Http, ClientApiUri("/api/navigation/robot_footprint"));
|
||
if (loaded)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Logger.LogDebug("Robot footprint endpoint returned no usable footprint payload");
|
||
}
|
||
catch (HttpRequestException ex) when (IsUntrustedRootSslError(ex) && ShouldAllowInsecureSslFallback())
|
||
{
|
||
Logger.LogWarning("Robot footprint request failed due to untrusted certificate chain. Retrying with local-network insecure SSL fallback.");
|
||
|
||
try
|
||
{
|
||
using var insecureHandler = new HttpClientHandler
|
||
{
|
||
ServerCertificateCustomValidationCallback = static (_, _, _, _) => true
|
||
};
|
||
using var insecureClient = new HttpClient(insecureHandler)
|
||
{
|
||
BaseAddress = new UriBuilder(Navigation.ToAbsoluteUri("/")) { Host = "127.0.0.1" }.Uri
|
||
};
|
||
|
||
var loaded = await LoadRobotFootprintAsync(insecureClient, ClientApiUri("/api/navigation/robot_footprint"));
|
||
if (loaded)
|
||
{
|
||
Logger.LogInformation("Robot footprint loaded using insecure SSL fallback for local/private endpoint");
|
||
return;
|
||
}
|
||
|
||
Logger.LogWarning("Robot footprint fallback request completed but payload did not contain footprint data");
|
||
}
|
||
catch (Exception fallbackEx)
|
||
{
|
||
Logger.LogWarning(fallbackEx, "Failed to load robot footprint using insecure SSL fallback");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to load robot footprint");
|
||
}
|
||
}
|
||
|
||
private async Task<bool> LoadRobotFootprintAsync(HttpClient client, Uri requestUri)
|
||
{
|
||
var footprintResponse = await client.GetAsync(requestUri);
|
||
if (!footprintResponse.IsSuccessStatusCode)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var footprintJson = await footprintResponse.Content.ReadAsStringAsync();
|
||
using var footprintDoc = JsonDocument.Parse(footprintJson);
|
||
if (!footprintDoc.RootElement.TryGetProperty("footprint", out var footprint))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotFootprint", footprint.GetRawText());
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotFootprintVisible", ShowRobotFootprintLayer);
|
||
Logger.LogInformation("Robot footprint loaded");
|
||
return true;
|
||
}
|
||
|
||
private static bool IsUntrustedRootSslError(HttpRequestException ex)
|
||
{
|
||
return ex.InnerException is AuthenticationException authEx
|
||
&& authEx.Message.Contains("UntrustedRoot", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private bool ShouldAllowInsecureSslFallback()
|
||
{
|
||
var baseUri = Navigation.ToAbsoluteUri("/");
|
||
if (!string.Equals(baseUri.Scheme, "https", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Robotics / MOXA / self-signed dev certs: allow retry path for any HTTPS host (loopback, LAN, NAT, public IP).
|
||
return true;
|
||
}
|
||
|
||
private async Task LoadAvailableMaps()
|
||
{
|
||
try
|
||
{
|
||
// Try API first
|
||
try
|
||
{
|
||
var response = await Http.GetAsync(ClientApiUri("/api/xloc/maps/list"));
|
||
if (response.IsSuccessStatusCode)
|
||
{
|
||
var json = await response.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
if (result.TryGetProperty("maps", out var maps))
|
||
{
|
||
AvailableMaps = maps.EnumerateArray()
|
||
.Select(m => m.GetString() ?? "")
|
||
.Where(m => !string.IsNullOrEmpty(m))
|
||
.ToList();
|
||
|
||
Logger.LogInformation("Loaded {Count} maps from API", AvailableMaps.Count);
|
||
SyncMapToDeleteSelection();
|
||
await InvokeAsync(StateHasChanged);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (HttpRequestException ex)
|
||
{
|
||
Logger.LogDebug(ex, "API not available, trying direct filesystem access");
|
||
}
|
||
|
||
// Fallback: Load directly from filesystem
|
||
var mapsDir = XlocPaths.GetMapsDirectory();
|
||
if (Directory.Exists(mapsDir))
|
||
{
|
||
AvailableMaps = Directory.GetDirectories(mapsDir)
|
||
.Select(dir => Path.GetFileName(dir))
|
||
.Where(name => !string.IsNullOrEmpty(name) && name != "tmp")
|
||
.OrderBy(name => name)
|
||
.ToList();
|
||
|
||
Logger.LogInformation("Loaded {Count} maps from filesystem", AvailableMaps.Count);
|
||
SyncMapToDeleteSelection();
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
else
|
||
{
|
||
Logger.LogWarning("Maps directory not found: {Dir}", mapsDir);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error loading available maps");
|
||
}
|
||
}
|
||
|
||
|
||
private async Task LoadSelectedMap()
|
||
{
|
||
// Validate state: Cannot perform when PROCESSING
|
||
if (IsProcessing)
|
||
{
|
||
UploadMessage = $"Cannot load map: XLOC is in PROCESSING state. Current state: {_diagnosticsData?.StateString ?? "Unknown"}";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot load map: XLOC is PROCESSING");
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(SelectedMapName)) return;
|
||
|
||
IsLoadingMap = true;
|
||
UploadMessage = "";
|
||
_lockedMapOrigin = null; // Unlock when explicitly loading new map
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.resetLocks"); // Reset JS locks too
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
// Try API first
|
||
try
|
||
{
|
||
var response = await Http.GetAsync(ClientApiUri($"/api/xloc/maps/load/{Uri.EscapeDataString(SelectedMapName)}"));
|
||
var result = await response.Content.ReadAsStringAsync();
|
||
|
||
if (response.IsSuccessStatusCode)
|
||
{
|
||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||
|
||
if (json.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
_gridMapData = new GridMapData
|
||
{
|
||
Resolution = (float)json.GetProperty("resolution").GetDouble(),
|
||
Width = json.GetProperty("width").GetUInt32(),
|
||
Height = json.GetProperty("height").GetUInt32(),
|
||
Origin = new OriginData
|
||
{
|
||
X = json.GetProperty("origin").GetProperty("x").GetDouble(),
|
||
Y = json.GetProperty("origin").GetProperty("y").GetDouble(),
|
||
Z = json.GetProperty("origin").GetProperty("z").GetDouble(),
|
||
Qx = json.GetProperty("origin").GetProperty("qx").GetDouble(),
|
||
Qy = json.GetProperty("origin").GetProperty("qy").GetDouble(),
|
||
Qz = json.GetProperty("origin").GetProperty("qz").GetDouble(),
|
||
Qw = json.GetProperty("origin").GetProperty("qw").GetDouble()
|
||
},
|
||
Data = Convert.FromBase64String(json.GetProperty("data").GetString() ?? "")
|
||
};
|
||
|
||
UploadMessage = $"Map '{SelectedMapName}' loaded successfully!";
|
||
UploadSuccess = true;
|
||
|
||
// Save selected map name to localStorage
|
||
await SaveSelectedMap();
|
||
|
||
await RenderMap();
|
||
return; // Success
|
||
}
|
||
}
|
||
}
|
||
catch (HttpRequestException ex)
|
||
{
|
||
Logger.LogDebug(ex, "API not available, trying direct filesystem access");
|
||
}
|
||
|
||
// Fallback: Load directly from filesystem
|
||
var mapsDir = XlocPaths.GetMapsDirectory();
|
||
var mapFolder = Path.Combine(mapsDir, SelectedMapName);
|
||
|
||
if (!Directory.Exists(mapFolder))
|
||
{
|
||
UploadMessage = $"Map folder not found: {mapFolder}";
|
||
UploadSuccess = false;
|
||
return;
|
||
}
|
||
|
||
// Find YAML file
|
||
var yamlFiles = Directory.GetFiles(mapFolder, "*.yaml");
|
||
if (yamlFiles.Length == 0)
|
||
{
|
||
UploadMessage = "YAML file not found in map folder";
|
||
UploadSuccess = false;
|
||
return;
|
||
}
|
||
|
||
var yamlContent = File.ReadAllText(yamlFiles[0]);
|
||
float resolution = 0.05f;
|
||
double originX = 0.0, originY = 0.0, originZ = 0.0;
|
||
string? imageFile = null;
|
||
|
||
foreach (var line in yamlContent.Split('\n'))
|
||
{
|
||
var trimmed = line.Trim();
|
||
if (trimmed.StartsWith("resolution:"))
|
||
{
|
||
if (float.TryParse(trimmed.Substring("resolution:".Length).Trim(), out float res))
|
||
resolution = res;
|
||
}
|
||
else if (trimmed.StartsWith("origin:"))
|
||
{
|
||
var originStr = trimmed.Substring("origin:".Length).Trim();
|
||
if (originStr.StartsWith("[") && originStr.EndsWith("]"))
|
||
{
|
||
var coords = originStr.Substring(1, originStr.Length - 2).Split(',');
|
||
if (coords.Length >= 1) double.TryParse(coords[0].Trim(), out originX);
|
||
if (coords.Length >= 2) double.TryParse(coords[1].Trim(), out originY);
|
||
if (coords.Length >= 3) double.TryParse(coords[2].Trim(), out originZ);
|
||
}
|
||
}
|
||
else if (trimmed.StartsWith("image:"))
|
||
{
|
||
imageFile = trimmed.Substring("image:".Length).Trim();
|
||
}
|
||
}
|
||
|
||
// Find PGM file
|
||
var pgmFile = imageFile != null ? Path.Combine(mapFolder, imageFile) : null;
|
||
if (pgmFile == null || !File.Exists(pgmFile))
|
||
{
|
||
var pgmFiles = Directory.GetFiles(mapFolder, "*.pgm");
|
||
if (pgmFiles.Length > 0)
|
||
pgmFile = pgmFiles[0];
|
||
else
|
||
{
|
||
UploadMessage = "PGM file not found in map folder";
|
||
UploadSuccess = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Note: Fallback mode - API should handle the actual loading
|
||
// For now, just show that map was found
|
||
UploadMessage = $"Map '{SelectedMapName}' found but API is required to load it. Please ensure the server is running.";
|
||
UploadSuccess = false;
|
||
Logger.LogWarning("Map found in filesystem but API is required to load PGM data: {MapName}", SelectedMapName);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"Error: {ex.Message}";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Error loading map");
|
||
}
|
||
finally
|
||
{
|
||
IsLoadingMap = false;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
|
||
// SLAM Control Methods
|
||
|
||
private async Task StartMapping()
|
||
{
|
||
// Validate state: Only allow when state is READY (3) and not PROCESSING (2)
|
||
if (!IsReady || IsProcessing)
|
||
{
|
||
var currentState = _diagnosticsData?.StateString ?? "Unknown";
|
||
UploadMessage = $"Cannot start mapping: XLOC state must be READY. Current state: {currentState}";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot start mapping: Invalid state. Current: {State}", currentState);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
UploadMessage = "Starting mapping mode...";
|
||
UploadSuccess = true;
|
||
StateHasChanged();
|
||
|
||
var response = await Http.PostAsync(ClientApiUri("/api/xloc/mapping/start"), null);
|
||
var result = await response.Content.ReadAsStringAsync();
|
||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||
|
||
if (response.IsSuccessStatusCode && json.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
// Immediately set mapping active (don't wait for diagnostics)
|
||
IsMappingActive = true;
|
||
UploadMessage = "✅ Mapping started! Drive the robot to create the map.";
|
||
UploadSuccess = true;
|
||
Logger.LogInformation("Mapping started successfully - UI state set to active");
|
||
|
||
// Auto-generate map name if empty
|
||
if (string.IsNullOrEmpty(NewMapName))
|
||
{
|
||
NewMapName = $"map_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||
}
|
||
|
||
// Save state to localStorage
|
||
await SaveMappingState();
|
||
|
||
StateHasChanged();
|
||
|
||
// Wait for XLOC to transition to Mapping state (verify after 2 seconds)
|
||
await Task.Delay(2000);
|
||
|
||
// Check if XLOC actually transitioned to Mapping state
|
||
if (_diagnosticsData != null && _diagnosticsData.XlocState != 0 && IsMappingActive)
|
||
{
|
||
Logger.LogWarning("XLOC did not transition to Mapping state. Current state: {State}",
|
||
_diagnosticsData.StateString);
|
||
UploadMessage = $"⚠️ Mapping command sent, but XLOC state is '{_diagnosticsData.StateString}'. " +
|
||
"This may happen if XLOC is waiting for sensor data. Please ensure robot sensors are active.";
|
||
UploadSuccess = false;
|
||
}
|
||
|
||
StateHasChanged();
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown error";
|
||
UploadMessage = $"❌ Failed to start mapping: {message}";
|
||
UploadSuccess = false;
|
||
IsMappingActive = false;
|
||
await SaveMappingState(); // Save false state
|
||
Logger.LogWarning("Failed to start mapping: {Message}", message);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"❌ Error starting mapping: {ex.Message}";
|
||
UploadSuccess = false;
|
||
IsMappingActive = false;
|
||
await SaveMappingState(); // Save false state
|
||
Logger.LogError(ex, "Error starting mapping");
|
||
}
|
||
|
||
// Start online map update timer when mapping is active
|
||
if (IsMappingActive)
|
||
{
|
||
StartOnlineMapUpdateTimer();
|
||
}
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task StopMapping()
|
||
{
|
||
// Validate state: Only allow when state is MAPPING (0)
|
||
if (!IsMappingState)
|
||
{
|
||
var currentState = _diagnosticsData?.StateString ?? "Unknown";
|
||
UploadMessage = $"Cannot stop mapping: XLOC state must be MAPPING. Current state: {currentState}";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot stop mapping: Invalid state. Current: {State}", currentState);
|
||
return;
|
||
}
|
||
|
||
// Validate UI state
|
||
if (!IsMappingActive)
|
||
{
|
||
UploadMessage = "Cannot stop mapping: Mapping is not active. Current state: " +
|
||
(_diagnosticsData?.StateString ?? "Unknown");
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(NewMapName))
|
||
{
|
||
UploadMessage = "⚠️ Please enter a map name before stopping mapping";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
UploadMessage = "Stopping mapping and saving...";
|
||
UploadSuccess = true;
|
||
StateHasChanged();
|
||
|
||
var content = new StringContent(
|
||
JsonSerializer.Serialize(new { map_file_path = NewMapName }),
|
||
System.Text.Encoding.UTF8,
|
||
"application/json"
|
||
);
|
||
|
||
var response = await Http.PostAsync(ClientApiUri("/api/xloc/mapping/stop"), content);
|
||
var result = await response.Content.ReadAsStringAsync();
|
||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||
|
||
if (response.IsSuccessStatusCode && json.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
// Stop online map update timer when mapping stops
|
||
StopOnlineMapUpdateTimer();
|
||
|
||
IsMappingActive = false;
|
||
var mapFile = json.TryGetProperty("map_file", out var file) ? file.GetString() : NewMapName;
|
||
var savedMapName = NewMapName; // Save for later
|
||
|
||
UploadMessage = $"✅ Mapping stopped! Map '{mapFile}' saved successfully. Refreshing map list...";
|
||
UploadSuccess = true;
|
||
Logger.LogInformation("Mapping stopped and saved as: {MapFile}", mapFile);
|
||
|
||
// Save state to localStorage (mapping is now inactive)
|
||
await SaveMappingState();
|
||
|
||
StateHasChanged();
|
||
|
||
// Wait a bit for XLOC to finish writing map files
|
||
await Task.Delay(1000);
|
||
|
||
// Refresh available maps to show the new map
|
||
await LoadAvailableMaps();
|
||
|
||
// Auto-select the newly saved map
|
||
if (AvailableMaps.Contains(savedMapName))
|
||
{
|
||
SelectedMapName = savedMapName;
|
||
UploadMessage = $"✅ Map '{savedMapName}' saved and ready to activate!";
|
||
}
|
||
else
|
||
{
|
||
UploadMessage = $"✅ Map '{savedMapName}' saved! Reload the page if it doesn't appear.";
|
||
}
|
||
|
||
// Clear map name for next mapping session
|
||
NewMapName = "";
|
||
await SaveMappingState(); // Save cleared map name
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown error";
|
||
UploadMessage = $"❌ Failed to stop mapping: {message}";
|
||
UploadSuccess = false;
|
||
Logger.LogWarning("Failed to stop mapping: {Message}", message);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"❌ Error stopping mapping: {ex.Message}";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Error stopping mapping");
|
||
}
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task ActivateSelectedMap()
|
||
{
|
||
// Validate state: Only allow when state is READY (3) and not PROCESSING (2)
|
||
if (!IsReady || IsProcessing)
|
||
{
|
||
var currentState = _diagnosticsData?.StateString ?? "Unknown";
|
||
UploadMessage = $"Cannot activate map: XLOC state must be READY. Current state: {currentState}";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot activate map: Invalid state. Current: {State}", currentState);
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(SelectedMapName))
|
||
{
|
||
UploadMessage = "Please select a map first";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
return;
|
||
}
|
||
|
||
IsActivatingMap = true;
|
||
UploadMessage = "";
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
var content = new StringContent(
|
||
JsonSerializer.Serialize(new { map_file_path = SelectedMapName }),
|
||
System.Text.Encoding.UTF8,
|
||
"application/json"
|
||
);
|
||
|
||
var response = await Http.PostAsync(ClientApiUri("/api/xloc/map/activate"), content);
|
||
var result = await response.Content.ReadAsStringAsync();
|
||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||
|
||
if (response.IsSuccessStatusCode && json.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
CurrentActiveMap = SelectedMapName;
|
||
await SaveActiveMap();
|
||
UploadMessage = $"Map '{SelectedMapName}' activated successfully!";
|
||
//UploadSuccess = true;
|
||
Logger.LogInformation("Map activated: {MapName}", SelectedMapName);
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown error";
|
||
UploadMessage = $"Failed to activate map: {message}";
|
||
UploadSuccess = false;
|
||
Logger.LogWarning("Failed to activate map: {Message}", message);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"Error activating map: {ex.Message}";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Error activating map");
|
||
}
|
||
finally
|
||
{
|
||
IsActivatingMap = false;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
|
||
private async Task DownloadSelectedMapZipAsync()
|
||
{
|
||
if (string.IsNullOrEmpty(SelectedMapName))
|
||
return;
|
||
|
||
IsDownloadingMapZip = true;
|
||
UploadMessage = "";
|
||
StateHasChanged();
|
||
try
|
||
{
|
||
var url = Navigation.ToAbsoluteUri($"/api/xloc/maps/download/{Uri.EscapeDataString(SelectedMapName)}").AbsoluteUri;
|
||
await JSRuntime.InvokeVoidAsync("robotnet.triggerFileDownload", url);
|
||
UploadMessage = $"Downloading {SelectedMapName}.zip…";
|
||
UploadSuccess = true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"Download failed: {ex.Message}";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Map zip download failed");
|
||
}
|
||
finally
|
||
{
|
||
IsDownloadingMapZip = false;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
|
||
private async Task OpenImportMapPickerAsync()
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("robotnet.clickElementById", "xloc-import-map-zip");
|
||
}
|
||
|
||
private async Task OnImportMapZipAsync(InputFileChangeEventArgs e)
|
||
{
|
||
var file = e.File;
|
||
if (file == null)
|
||
return;
|
||
|
||
IsImportingMap = true;
|
||
UploadMessage = "Importing map…";
|
||
UploadSuccess = true;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await using var stream = file.OpenReadStream(maxAllowedSize: 200L * 1024 * 1024);
|
||
using var content = new MultipartFormDataContent();
|
||
content.Add(new StreamContent(stream), "file", file.Name);
|
||
|
||
var response = await Http.PostAsync(ClientApiUri("/api/xloc/maps/import"), content);
|
||
var body = await response.Content.ReadAsStringAsync();
|
||
JsonElement json;
|
||
try
|
||
{
|
||
json = JsonSerializer.Deserialize<JsonElement>(body);
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
UploadMessage = $"Import failed: {body}";
|
||
UploadSuccess = false;
|
||
return;
|
||
}
|
||
|
||
if (response.IsSuccessStatusCode
|
||
&& json.TryGetProperty("status", out var st)
|
||
&& st.GetString() == "success"
|
||
&& json.TryGetProperty("map_name", out var mn))
|
||
{
|
||
var name = mn.GetString() ?? "";
|
||
UploadMessage = $"Map imported as '{name}' (from zip file name). Refreshing list…";
|
||
await LoadAvailableMaps();
|
||
if (AvailableMaps.Contains(name))
|
||
SelectedMapName = name;
|
||
await SaveSelectedMap();
|
||
UploadSuccess = true;
|
||
}
|
||
else if (response.StatusCode == System.Net.HttpStatusCode.Conflict
|
||
&& json.TryGetProperty("message", out var conflictMsg))
|
||
{
|
||
UploadMessage = conflictMsg.GetString() ?? body;
|
||
UploadSuccess = false;
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : body;
|
||
UploadMessage = $"Import failed: {message}";
|
||
UploadSuccess = false;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"Import error: {ex.Message}";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Map zip import failed");
|
||
}
|
||
finally
|
||
{
|
||
IsImportingMap = false;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
|
||
private void SyncMapToDeleteSelection()
|
||
{
|
||
if (!string.IsNullOrEmpty(MapToDeleteName) && !AvailableMaps.Contains(MapToDeleteName))
|
||
MapToDeleteName = null;
|
||
}
|
||
|
||
private async Task OnMapToDeleteNameChanged(string? name)
|
||
{
|
||
MapToDeleteName = name;
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
|
||
private async Task ConfirmDeleteMapAsync()
|
||
{
|
||
if (string.IsNullOrEmpty(MapToDeleteName))
|
||
return;
|
||
|
||
var activeHint = string.Equals(MapToDeleteName, CurrentActiveMap, StringComparison.Ordinal)
|
||
? " This map is shown as active in the UI — you may need to activate another map or adjust localization after deletion."
|
||
: "";
|
||
|
||
var confirm = await DialogService.ShowMessageBoxAsync(
|
||
"Delete map",
|
||
$"Permanently delete map '{MapToDeleteName}' from disk?{activeHint}",
|
||
yesText: "Delete",
|
||
cancelText: "Cancel");
|
||
|
||
if (confirm != true)
|
||
return;
|
||
|
||
var target = MapToDeleteName;
|
||
IsDeletingMap = true;
|
||
UploadMessage = "";
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
var response = await Http.DeleteAsync(ClientApiUri($"/api/xloc/maps/delete/{Uri.EscapeDataString(target)}"));
|
||
var body = await response.Content.ReadAsStringAsync();
|
||
JsonElement json;
|
||
try
|
||
{
|
||
json = JsonSerializer.Deserialize<JsonElement>(body);
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
UploadMessage = $"Delete failed: {body}";
|
||
UploadSuccess = false;
|
||
return;
|
||
}
|
||
|
||
if (response.IsSuccessStatusCode
|
||
&& json.TryGetProperty("status", out var st)
|
||
&& st.GetString() == "success")
|
||
{
|
||
UploadMessage = $"Map '{target}' deleted.";
|
||
UploadSuccess = true;
|
||
MapToDeleteName = null;
|
||
|
||
if (string.Equals(CurrentActiveMap, target, StringComparison.Ordinal))
|
||
{
|
||
CurrentActiveMap = "";
|
||
await SaveActiveMap();
|
||
}
|
||
|
||
await LoadAvailableMaps();
|
||
|
||
if (!string.IsNullOrEmpty(SelectedMapName) && !AvailableMaps.Contains(SelectedMapName))
|
||
{
|
||
SelectedMapName = AvailableMaps.FirstOrDefault();
|
||
await SaveSelectedMap();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : body;
|
||
UploadMessage = $"Delete failed: {message}";
|
||
UploadSuccess = false;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"Delete error: {ex.Message}";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Map delete failed");
|
||
}
|
||
finally
|
||
{
|
||
IsDeletingMap = false;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
|
||
private async Task StartLocalization()
|
||
{
|
||
// Validate state: Only allow when state is READY (3), has active map, and not PROCESSING (2)
|
||
if (!IsReady || IsProcessing)
|
||
{
|
||
var currentState = _diagnosticsData?.StateString ?? "Unknown";
|
||
UploadMessage = $"Cannot start localization: XLOC state must be READY. Current state: {currentState}";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot start localization: Invalid state. Current: {State}", currentState);
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(CurrentActiveMap))
|
||
{
|
||
UploadMessage = "Cannot start localization: No active map. Please activate a map first.";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot start localization: No active map");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
UploadMessage = "Starting localization in 2 seconds...";
|
||
StateHasChanged();
|
||
//Logger.LogInformation("Waiting 2 seconds before starting localization");
|
||
|
||
// Wait 2 seconds before changing state
|
||
await Task.Delay(2000);
|
||
|
||
UploadMessage = "";
|
||
var response = await Http.PostAsync(ClientApiUri("/api/xloc/localization/start"), null);
|
||
var result = await response.Content.ReadAsStringAsync();
|
||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||
|
||
if (response.IsSuccessStatusCode && json.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
IsLocalizationActive = true;
|
||
UploadMessage = "Localization started! Robot is now tracking its position.";
|
||
UploadSuccess = true;
|
||
Logger.LogInformation("Localization started successfully");
|
||
|
||
// Save state to localStorage
|
||
await SaveLocalizationState();
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown error";
|
||
UploadMessage = $"Failed to start localization: {message}";
|
||
UploadSuccess = false;
|
||
Logger.LogWarning("Failed to start localization: {Message}", message);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"Error starting localization: {ex.Message}";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Error starting localization");
|
||
}
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task StopLocalization()
|
||
{
|
||
// Validate state: Only allow when state is LOCALIZATION (1)
|
||
if (!IsLocalizationState)
|
||
{
|
||
var currentState = _diagnosticsData?.StateString ?? "Unknown";
|
||
UploadMessage = $"Cannot stop localization: XLOC state must be LOCALIZATION. Current state: {currentState}";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot stop localization: Invalid state. Current: {State}", currentState);
|
||
return;
|
||
}
|
||
|
||
if (_isStoppingLocalization)
|
||
{
|
||
Logger.LogWarning("Stop localization already in progress, ignoring duplicate request");
|
||
return;
|
||
}
|
||
|
||
_isStoppingLocalization = true;
|
||
bool success = false;
|
||
CancellationTokenSource? cts = null;
|
||
|
||
try
|
||
{
|
||
UploadMessage = "Stopping localization in 2 seconds...";
|
||
StateHasChanged();
|
||
Logger.LogInformation("Waiting 2 seconds before stopping localization");
|
||
|
||
// Wait 2 seconds before changing state
|
||
await Task.Delay(2000);
|
||
|
||
UploadMessage = "";
|
||
Logger.LogInformation("Stopping localization...");
|
||
|
||
// Add timeout to prevent hanging requests (10 seconds)
|
||
cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||
|
||
var response = await Http.PostAsync(ClientApiUri("/api/xloc/localization/stop"), null, cts.Token);
|
||
var result = await response.Content.ReadAsStringAsync(cts.Token);
|
||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||
|
||
if (response.IsSuccessStatusCode && json.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
// Immediately set to false to disable button
|
||
IsLocalizationActive = false;
|
||
UploadMessage = "Localization stopped.";
|
||
UploadSuccess = true;
|
||
success = true;
|
||
Logger.LogInformation("Localization stopped successfully - UI state set to inactive");
|
||
|
||
// Save state to localStorage
|
||
await SaveLocalizationState();
|
||
|
||
// Wait a bit for XLOC to transition to READY state (idle)
|
||
await Task.Delay(500, cts.Token);
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown error";
|
||
UploadMessage = $"Failed to stop localization: {message}. Please try again.";
|
||
UploadSuccess = false;
|
||
Logger.LogWarning("Failed to stop localization: {Message}", message);
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
UploadMessage = "Stop localization request timed out. Please try again.";
|
||
UploadSuccess = false;
|
||
Logger.LogWarning("Stop localization request timed out or was cancelled after 10 seconds");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"Error stopping localization: {ex.Message}. Please try again.";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Error stopping localization");
|
||
}
|
||
finally
|
||
{
|
||
// Always reset flag, but delay if successful to prevent diagnostics from overriding
|
||
if (success)
|
||
{
|
||
// Delay reset to prevent diagnostics from overriding
|
||
// Use fire-and-forget with proper error handling
|
||
_ = ResetStoppingFlagAfterDelay();
|
||
}
|
||
else
|
||
{
|
||
// Reset flag immediately on error to allow retry
|
||
_isStoppingLocalization = false;
|
||
}
|
||
|
||
cts?.Dispose();
|
||
}
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task ResetStoppingFlagAfterDelay()
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(1000);
|
||
_isStoppingLocalization = false;
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Ensure flag is reset even if delay or InvokeAsync fails
|
||
_isStoppingLocalization = false;
|
||
Logger.LogError(ex, "Error in delayed flag reset for stop localization");
|
||
}
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task StartUpdateMap()
|
||
{
|
||
// Validate state: Only allow when localization is active
|
||
if (!IsLocalizationActive || !IsLocalizationState)
|
||
{
|
||
UploadMessage = "Cannot start update map: Robot must be in localization mode first.";
|
||
UploadSuccess = false;
|
||
StateHasChanged();
|
||
Logger.LogWarning("Cannot start update map: Localization must be active first");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
UploadMessage = "Starting map update...";
|
||
UploadSuccess = true;
|
||
StateHasChanged();
|
||
|
||
var response = await Http.PostAsync(ClientApiUri("/api/xloc/update-map/start"), null);
|
||
var result = await response.Content.ReadAsStringAsync();
|
||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||
|
||
if (response.IsSuccessStatusCode && json.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
// Immediately set update map active
|
||
IsUpdateMapActive = true;
|
||
//UploadMessage = "✅ Map update started! Online map will be overlaid on the active map.";
|
||
UploadSuccess = true;
|
||
Logger.LogInformation("Map update started successfully - UI state set to active");
|
||
|
||
StartOnlineMapUpdateTimer();
|
||
|
||
StateHasChanged();
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown error";
|
||
UploadMessage = $"Failed to start map update: {message}";
|
||
UploadSuccess = false;
|
||
Logger.LogWarning("Failed to start map update: {Message}", message);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"Error starting map update: {ex.Message}";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Error starting map update");
|
||
}
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task StopUpdateMap()
|
||
{
|
||
if (!IsUpdateMapActive)
|
||
{
|
||
Logger.LogWarning("Stop update map called but update map is not active");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
UploadMessage = "Stopping map update and saving...";
|
||
UploadSuccess = true;
|
||
StateHasChanged();
|
||
|
||
// Stop update map and save (save_updated_map = true)
|
||
var body = new Dictionary<string, object> { { "save_updated_map", true } };
|
||
var content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json");
|
||
|
||
var response = await Http.PostAsync(ClientApiUri("/api/xloc/update-map/stop"), content);
|
||
var result = await response.Content.ReadAsStringAsync();
|
||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||
|
||
if (response.IsSuccessStatusCode && json.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
// Immediately set to false
|
||
IsUpdateMapActive = false;
|
||
UploadMessage = "✅ Map update stopped and saved! The updated map has been saved over the active map.";
|
||
UploadSuccess = true;
|
||
Logger.LogInformation("Map update stopped successfully - updated map saved over active map");
|
||
// Stop online map update timer
|
||
StopOnlineMapUpdateTimer();
|
||
var preservedOrigin = _lockedMapOrigin;
|
||
await LoadGridMapPreservingOrigin(preservedOrigin);
|
||
|
||
StateHasChanged();
|
||
}
|
||
else
|
||
{
|
||
var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown error";
|
||
UploadMessage = $"Failed to stop map update: {message}";
|
||
UploadSuccess = false;
|
||
Logger.LogWarning("Failed to stop map update: {Message}", message);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
UploadMessage = $"Error stopping map update: {ex.Message}";
|
||
UploadSuccess = false;
|
||
Logger.LogError(ex, "Error stopping map update");
|
||
}
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task LoadGridMapPreservingOrigin(OriginData? preservedOrigin)
|
||
{
|
||
// Validate state: Cannot perform when PROCESSING
|
||
if (IsProcessing)
|
||
{
|
||
Logger.LogWarning("Cannot load grid map: XLOC is in PROCESSING state");
|
||
return;
|
||
}
|
||
|
||
// CRITICAL: Do NOT reload map while applying initial pose!
|
||
if (_isApplyingInitialPose)
|
||
{
|
||
Logger.LogWarning("⚠️ LoadGridMapPreservingOrigin() called while applying initial pose - IGNORED to prevent map frame movement!");
|
||
return;
|
||
}
|
||
|
||
IsLoadingGridMap = true;
|
||
// CRITICAL: Preserve origin lock to prevent map frame jumping after stop update map
|
||
// Do NOT reset _lockedMapOrigin or JS locks
|
||
if (preservedOrigin != null)
|
||
{
|
||
_lockedMapOrigin = preservedOrigin;
|
||
Logger.LogInformation("Preserving locked origin: ({X}, {Y}, {Z})",
|
||
_lockedMapOrigin.X, _lockedMapOrigin.Y, _lockedMapOrigin.Z);
|
||
}
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
// When mapping is active, only load online map
|
||
// When not mapping, try static first, then online
|
||
string[] endpoints;
|
||
if (IsMappingActive)
|
||
{
|
||
endpoints = new[] { "/api/xloc/gridmap/online" };
|
||
}
|
||
else
|
||
{
|
||
endpoints = new[] { "/api/xloc/gridmap/static", "/api/xloc/gridmap/online" };
|
||
}
|
||
|
||
foreach (var endpoint in endpoints)
|
||
{
|
||
try
|
||
{
|
||
var response = await Http.GetAsync(ClientApiUri(endpoint));
|
||
|
||
if (response.IsSuccessStatusCode)
|
||
{
|
||
var json = await response.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
var width = result.GetProperty("width").GetUInt32();
|
||
var height = result.GetProperty("height").GetUInt32();
|
||
var resolution = (float)result.GetProperty("resolution").GetDouble();
|
||
if (width > 0 && height > 0 && resolution > 1e-6f)
|
||
{
|
||
_gridMapData = new GridMapData
|
||
{
|
||
Resolution = resolution,
|
||
Width = width,
|
||
Height = height,
|
||
Origin = new OriginData
|
||
{
|
||
X = result.GetProperty("origin").GetProperty("x").GetDouble(),
|
||
Y = result.GetProperty("origin").GetProperty("y").GetDouble(),
|
||
Z = result.GetProperty("origin").GetProperty("z").GetDouble(),
|
||
Qx = result.GetProperty("origin").GetProperty("qx").GetDouble(),
|
||
Qy = result.GetProperty("origin").GetProperty("qy").GetDouble(),
|
||
Qz = result.GetProperty("origin").GetProperty("qz").GetDouble(),
|
||
Qw = result.GetProperty("origin").GetProperty("qw").GetDouble()
|
||
},
|
||
Data = Convert.FromBase64String(result.GetProperty("data").GetString() ?? "")
|
||
};
|
||
Logger.LogInformation("Grid map loaded: {Width}x{Height}, Resolution: {Resolution}",
|
||
_gridMapData.Width, _gridMapData.Height, _gridMapData.Resolution);
|
||
if (preservedOrigin != null)
|
||
{
|
||
_gridMapData.Origin = preservedOrigin;
|
||
Logger.LogInformation("Using preserved origin: ({X}, {Y}, {Z})",
|
||
preservedOrigin.X, preservedOrigin.Y, preservedOrigin.Z);
|
||
}
|
||
await RenderMap();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogDebug(ex, "Failed to load from {Endpoint}, trying next", endpoint);
|
||
continue; // Try next endpoint
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Error loading grid map - no map available");
|
||
}
|
||
finally
|
||
{
|
||
IsLoadingGridMap = false;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
|
||
private async Task LoadGridMap()
|
||
{
|
||
// Validate state: Cannot perform when PROCESSING
|
||
if (IsProcessing)
|
||
{
|
||
Logger.LogWarning("Cannot load grid map: XLOC is in PROCESSING state");
|
||
return;
|
||
}
|
||
|
||
// CRITICAL: Do NOT reload map while applying initial pose!
|
||
if (_isApplyingInitialPose)
|
||
{
|
||
Logger.LogWarning("⚠️ LoadGridMap() called while applying initial pose - IGNORED to prevent map frame movement!");
|
||
return;
|
||
}
|
||
|
||
IsLoadingGridMap = true;
|
||
_lockedMapOrigin = null; // Unlock when explicitly reloading grid map
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.resetLocks"); // Reset JS locks too
|
||
StateHasChanged();
|
||
try
|
||
{
|
||
// When mapping is active, only load online map
|
||
// When not mapping, try static first, then online
|
||
string[] endpoints;
|
||
if (IsMappingActive)
|
||
{
|
||
endpoints = new[] { "/api/xloc/gridmap/online" };
|
||
}
|
||
else
|
||
{
|
||
endpoints = new[] { "/api/xloc/gridmap/static", "/api/xloc/gridmap/online" };
|
||
}
|
||
|
||
foreach (var endpoint in endpoints)
|
||
{
|
||
try
|
||
{
|
||
var response = await Http.GetAsync(ClientApiUri(endpoint));
|
||
|
||
if (response.IsSuccessStatusCode)
|
||
{
|
||
var json = await response.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
var width = result.GetProperty("width").GetUInt32();
|
||
var height = result.GetProperty("height").GetUInt32();
|
||
var resolution = (float)result.GetProperty("resolution").GetDouble();
|
||
if (width > 0 && height > 0 && resolution > 1e-6f)
|
||
{
|
||
_gridMapData = new GridMapData
|
||
{
|
||
Resolution = resolution,
|
||
Width = width,
|
||
Height = height,
|
||
Origin = new OriginData
|
||
{
|
||
X = result.GetProperty("origin").GetProperty("x").GetDouble(),
|
||
Y = result.GetProperty("origin").GetProperty("y").GetDouble(),
|
||
Z = result.GetProperty("origin").GetProperty("z").GetDouble(),
|
||
Qx = result.GetProperty("origin").GetProperty("qx").GetDouble(),
|
||
Qy = result.GetProperty("origin").GetProperty("qy").GetDouble(),
|
||
Qz = result.GetProperty("origin").GetProperty("qz").GetDouble(),
|
||
Qw = result.GetProperty("origin").GetProperty("qw").GetDouble()
|
||
},
|
||
Data = Convert.FromBase64String(result.GetProperty("data").GetString() ?? "")
|
||
};
|
||
Logger.LogInformation("Grid map loaded: {Width}x{Height}, Resolution: {Resolution}",
|
||
_gridMapData.Width, _gridMapData.Height, _gridMapData.Resolution);
|
||
await RenderMap();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogDebug(ex, "Failed to load from {Endpoint}, trying next", endpoint);
|
||
continue; // Try next endpoint
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Error loading grid map - no map available");
|
||
}
|
||
finally
|
||
{
|
||
IsLoadingGridMap = false;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
private async Task UpdateRobotPose()
|
||
{
|
||
// Check if component is disposed
|
||
if (_isDisposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (System.Threading.Interlocked.CompareExchange(ref _poseUpdateInProgress, 1, 0) != 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// Check again after acquiring lock (component might have been disposed)
|
||
if (_isDisposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Fetch pose, diagnostics, velocity, and all 3 lidars data in parallel for better performance
|
||
var poseTask = Http.GetAsync(ClientApiUri("/api/xloc/pose/current2d"));
|
||
var diagnosticsTask = Http.GetAsync(ClientApiUri("/api/xloc/diagnostics"));
|
||
var laserTask = Http.GetAsync(ClientApiUri($"/api/xloc/laser/scan/all?mode1={Uri.EscapeDataString(Lidar1DisplayMode)}&mode2={Uri.EscapeDataString(Lidar2DisplayMode)}&mode3={Uri.EscapeDataString(Lidar3DisplayMode)}"));
|
||
var velocityTask = UpdateVelocityData();
|
||
|
||
await Task.WhenAll(poseTask, diagnosticsTask, laserTask, velocityTask);
|
||
|
||
// Update pose
|
||
var poseResponse = await poseTask;
|
||
if (poseResponse.IsSuccessStatusCode)
|
||
{
|
||
var json = await poseResponse.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
var pose = result.GetProperty("pose");
|
||
_robotPose = new RobotPoseData
|
||
{
|
||
X = pose.GetProperty("x").GetDouble(),
|
||
Y = pose.GetProperty("y").GetDouble(),
|
||
Yaw = pose.GetProperty("yaw").GetDouble()
|
||
};
|
||
}
|
||
}
|
||
|
||
// Update diagnostics
|
||
var diagnosticsResponse = await diagnosticsTask;
|
||
if (diagnosticsResponse.IsSuccessStatusCode)
|
||
{
|
||
var json = await diagnosticsResponse.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
var diag = result.GetProperty("diagnostics");
|
||
var header = diag.GetProperty("header");
|
||
var stamp = header.GetProperty("stamp");
|
||
|
||
_diagnosticsData = new DiagnosticsData
|
||
{
|
||
HeaderSeq = header.GetProperty("seq").GetUInt32(),
|
||
HeaderStampSec = stamp.GetProperty("sec").GetUInt32(),
|
||
HeaderStampNsec = stamp.GetProperty("nsec").GetUInt32(),
|
||
HeaderFrameId = header.GetProperty("frameId").GetString() ?? "",
|
||
XlocState = diag.GetProperty("xlocState").GetByte(),
|
||
StateString = diag.GetProperty("stateString").GetString() ?? "",
|
||
CurrentActiveMap = diag.GetProperty("currentActiveMap").GetString() ?? "",
|
||
Reliability = diag.GetProperty("reliability").GetDouble(),
|
||
MatchingScore = diag.GetProperty("matchingScore").GetDouble()
|
||
};
|
||
|
||
var newActiveMap = _diagnosticsData.CurrentActiveMap ?? "";
|
||
if (CurrentActiveMap != newActiveMap && !string.IsNullOrEmpty(newActiveMap))
|
||
{
|
||
CurrentActiveMap = newActiveMap;
|
||
// Save active map to localStorage when it changes from diagnostics
|
||
await SaveActiveMap();
|
||
}
|
||
else
|
||
{
|
||
CurrentActiveMap = newActiveMap;
|
||
}
|
||
var xlocState = _diagnosticsData.XlocState;
|
||
|
||
// Sync UI state with XLOC state
|
||
// Priority: If XLOC is in Mapping state (0), always ensure UI reflects that
|
||
if (xlocState == 0)
|
||
{
|
||
// XLOC confirms mapping is active - always set UI to active
|
||
if (!IsMappingActive)
|
||
{
|
||
// State was restored but XLOC confirms it's active - update UI and localStorage
|
||
IsMappingActive = true;
|
||
await SaveMappingState();
|
||
Logger.LogInformation("Mapping state synchronized: XLOC is in Mapping state, UI updated");
|
||
}
|
||
// If IsMappingActive is already true, keep it (matches XLOC state)
|
||
}
|
||
else if (xlocState == 3)
|
||
{
|
||
// XLOC is READY (idle) - reset mapping flag to allow new operations
|
||
if (IsMappingActive)
|
||
{
|
||
IsMappingActive = false;
|
||
await SaveMappingState();
|
||
Logger.LogInformation("Mapping state reset: XLOC is READY, mapping is not active");
|
||
}
|
||
}
|
||
// If xlocState == 1 (LOCALIZATION), don't change IsMappingActive
|
||
// If xlocState == 2 (PROCESSING), don't change IsMappingActive (wait for transition)
|
||
|
||
// CRITICAL: Don't override IsLocalizationActive if user just stopped localization
|
||
// Wait for XLOC to actually transition to READY state (xlocState == 3)
|
||
if (!_isStoppingLocalization)
|
||
{
|
||
// Only sync if we're not in the process of stopping
|
||
if (xlocState == 1)
|
||
{
|
||
// XLOC confirms localization is active - always set UI to active
|
||
if (!IsLocalizationActive)
|
||
{
|
||
// State was restored but XLOC confirms it's active - update UI and localStorage
|
||
IsLocalizationActive = true;
|
||
await SaveLocalizationState();
|
||
Logger.LogInformation("Localization state synchronized: XLOC is in Localization state, UI updated");
|
||
}
|
||
}
|
||
else if (xlocState == 3)
|
||
{
|
||
// XLOC is READY (idle) - reset localization flag to allow new operations
|
||
if (IsLocalizationActive)
|
||
{
|
||
IsLocalizationActive = false;
|
||
await SaveLocalizationState();
|
||
Logger.LogInformation("Localization state reset: XLOC is READY, localization is not active");
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// We're stopping - keep IsLocalizationActive = false and wait for XLOC to confirm
|
||
if (xlocState == 3)
|
||
{
|
||
// XLOC has confirmed it's stopped (READY state) - safe to clear flag
|
||
IsLocalizationActive = false;
|
||
await SaveLocalizationState();
|
||
_isStoppingLocalization = false; // Clear the stopping flag
|
||
}
|
||
// If xlocState is still 1, keep waiting (don't override our false state)
|
||
}
|
||
|
||
// Throttle UI refresh to reduce jank (max ~2.5 Hz for status/buttons)
|
||
var now = DateTime.UtcNow;
|
||
if ((now - _lastUiRefreshAt).TotalMilliseconds >= UiRefreshIntervalMs)
|
||
{
|
||
_lastUiRefreshAt = now;
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update laser scan data from all 3 lidars
|
||
var laserResponse = await laserTask;
|
||
if (laserResponse.IsSuccessStatusCode)
|
||
{
|
||
var json = await laserResponse.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
object? lidar1Data = null;
|
||
object? lidar2Data = null;
|
||
object? lidar3Data = null;
|
||
|
||
// Parse Lidar 1 data
|
||
if (result.TryGetProperty("lidar1", out var lidar1) && lidar1.ValueKind != JsonValueKind.Null)
|
||
{
|
||
var points1 = lidar1.GetProperty("points").EnumerateArray()
|
||
.Select(p => new {
|
||
angle = (float)p.GetProperty("angle").GetDouble(),
|
||
range = (float)p.GetProperty("range").GetDouble()
|
||
}).ToArray();
|
||
lidar1Data = new { points = points1 };
|
||
}
|
||
|
||
// Parse Lidar 2 data
|
||
if (result.TryGetProperty("lidar2", out var lidar2) && lidar2.ValueKind != JsonValueKind.Null)
|
||
{
|
||
var points2 = lidar2.GetProperty("points").EnumerateArray()
|
||
.Select(p => new {
|
||
angle = (float)p.GetProperty("angle").GetDouble(),
|
||
range = (float)p.GetProperty("range").GetDouble()
|
||
}).ToArray();
|
||
lidar2Data = new { points = points2 };
|
||
}
|
||
|
||
// Parse Lidar 3 data
|
||
if (result.TryGetProperty("lidar3", out var lidar3) && lidar3.ValueKind != JsonValueKind.Null)
|
||
{
|
||
var points3 = lidar3.GetProperty("points").EnumerateArray()
|
||
.Select(p => new {
|
||
angle = (float)p.GetProperty("angle").GetDouble(),
|
||
range = (float)p.GetProperty("range").GetDouble()
|
||
}).ToArray();
|
||
lidar3Data = new { points = points3 };
|
||
}
|
||
|
||
// Single JS call for pose + laser to reduce round-trips and one requestRender
|
||
var allLidarData = new { lidar1 = lidar1Data, lidar2 = lidar2Data, lidar3 = lidar3Data };
|
||
object? poseObj = null;
|
||
if (_robotPose != null && double.IsFinite(_robotPose.X) && double.IsFinite(_robotPose.Y) && double.IsFinite(_robotPose.Yaw))
|
||
poseObj = new { x = _robotPose.X, y = _robotPose.Y, yaw = _robotPose.Yaw, isLocalizing = _diagnosticsData != null && _diagnosticsData.MatchingScore == -1 };
|
||
await InvokeAsync(async () =>
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.updatePoseAndLaser", poseObj, allLidarData);
|
||
var now = DateTime.UtcNow;
|
||
if (ShowGlobalPathLayer || ShowLocalPathLayer || ShowCostMapLayer)
|
||
{
|
||
if ((now - _lastPathCostMapUpdateAt).TotalMilliseconds >= PathCostMapUpdateIntervalMs)
|
||
{
|
||
_lastPathCostMapUpdateAt = now;
|
||
if (ShowGlobalPathLayer) await UpdateGlobalPath();
|
||
if (ShowLocalPathLayer) await UpdateLocalPath();
|
||
if (ShowCostMapLayer) await UpdateCostMap();
|
||
}
|
||
}
|
||
if ((now - _lastUiRefreshAt).TotalMilliseconds >= UiRefreshIntervalMs)
|
||
{
|
||
_lastUiRefreshAt = now;
|
||
StateHasChanged();
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
// If laser data not available, just render pose (throttle UI refresh)
|
||
await InvokeAsync(async () =>
|
||
{
|
||
await RenderRobot();
|
||
var now = DateTime.UtcNow;
|
||
if (ShowGlobalPathLayer || ShowLocalPathLayer || ShowCostMapLayer)
|
||
{
|
||
if ((now - _lastPathCostMapUpdateAt).TotalMilliseconds >= PathCostMapUpdateIntervalMs)
|
||
{
|
||
_lastPathCostMapUpdateAt = now;
|
||
if (ShowGlobalPathLayer) await UpdateGlobalPath();
|
||
if (ShowLocalPathLayer) await UpdateLocalPath();
|
||
if (ShowCostMapLayer) await UpdateCostMap();
|
||
}
|
||
}
|
||
if ((now - _lastUiRefreshAt).TotalMilliseconds >= UiRefreshIntervalMs)
|
||
{
|
||
_lastUiRefreshAt = now;
|
||
StateHasChanged();
|
||
}
|
||
});
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
// Component is being disposed, ignore silently
|
||
return;
|
||
}
|
||
catch (HttpRequestException)
|
||
{
|
||
// Silently ignore connection errors - server might not be running
|
||
// Don't log to avoid spam
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Only log if component is not disposed
|
||
if (!_isDisposed && !ex.Message.Contains("Connection refused") && !ex.Message.Contains("localhost"))
|
||
{
|
||
Logger.LogWarning(ex, "Error updating robot pose");
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
System.Threading.Interlocked.Exchange(ref _poseUpdateInProgress, 0);
|
||
}
|
||
}
|
||
|
||
private async Task RenderMap()
|
||
{
|
||
if (_gridMapData == null) return;
|
||
|
||
// CRITICAL: Do NOT re-render map while applying initial pose!
|
||
if (_isApplyingInitialPose)
|
||
{
|
||
Logger.LogWarning("⚠️ RenderMap() called while applying initial pose - IGNORED to prevent map frame movement!");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// Handle origin based on mapping state
|
||
if (_lockedMapOrigin == null)
|
||
{
|
||
// First time: lock origin
|
||
_lockedMapOrigin = new OriginData
|
||
{
|
||
X = _gridMapData.Origin.X,
|
||
Y = _gridMapData.Origin.Y,
|
||
Z = _gridMapData.Origin.Z,
|
||
Qx = _gridMapData.Origin.Qx,
|
||
Qy = _gridMapData.Origin.Qy,
|
||
Qz = _gridMapData.Origin.Qz,
|
||
Qw = _gridMapData.Origin.Qw
|
||
};
|
||
<!-- Logger.LogWarning("🔒 MAP ORIGIN LOCKED at: ({X}, {Y}, {Z})",
|
||
_lockedMapOrigin.X, _lockedMapOrigin.Y, _lockedMapOrigin.Z); -->
|
||
}
|
||
else
|
||
{
|
||
// Check if origin changed
|
||
var dx = Math.Abs(_gridMapData.Origin.X - _lockedMapOrigin.X);
|
||
var dy = Math.Abs(_gridMapData.Origin.Y - _lockedMapOrigin.Y);
|
||
var dz = Math.Abs(_gridMapData.Origin.Z - _lockedMapOrigin.Z);
|
||
|
||
if (dx > 0.001 || dy > 0.001 || dz > 0.001)
|
||
{
|
||
if (IsMappingActive)
|
||
{
|
||
// During mapping: update locked origin to match online map (ensures alignment with LIDAR)
|
||
<!-- Logger.LogInformation("📍 Map origin updated during mapping:"); -->
|
||
Logger.LogInformation(" Previous: ({X}, {Y}, {Z})", _lockedMapOrigin.X, _lockedMapOrigin.Y, _lockedMapOrigin.Z);
|
||
Logger.LogInformation(" New: ({X}, {Y}, {Z})", _gridMapData.Origin.X, _gridMapData.Origin.Y, _gridMapData.Origin.Z);
|
||
Logger.LogInformation(" Delta: (ΔX={DX:F3}, ΔY={DY:F3}, ΔZ={DZ:F3})", dx, dy, dz);
|
||
|
||
// Update locked origin to match online map
|
||
_lockedMapOrigin = new OriginData
|
||
{
|
||
X = _gridMapData.Origin.X,
|
||
Y = _gridMapData.Origin.Y,
|
||
Z = _gridMapData.Origin.Z,
|
||
Qx = _gridMapData.Origin.Qx,
|
||
Qy = _gridMapData.Origin.Qy,
|
||
Qz = _gridMapData.Origin.Qz,
|
||
Qw = _gridMapData.Origin.Qw
|
||
};
|
||
}
|
||
else
|
||
{
|
||
// Not mapping: keep locked origin to prevent map frame movement
|
||
Logger.LogWarning("⚠️ Map origin changed but not mapping - keeping locked origin");
|
||
Logger.LogWarning(" Locked: ({X}, {Y}, {Z})", _lockedMapOrigin.X, _lockedMapOrigin.Y, _lockedMapOrigin.Z);
|
||
Logger.LogWarning(" Received: ({X}, {Y}, {Z})", _gridMapData.Origin.X, _gridMapData.Origin.Y, _gridMapData.Origin.Z);
|
||
|
||
// Use locked origin
|
||
_gridMapData.Origin = _lockedMapOrigin;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Convert byte array to base64 for JavaScript
|
||
var dataBase64 = Convert.ToBase64String(_gridMapData.Data);
|
||
|
||
// Always use current origin (may be updated during mapping)
|
||
var originToUse = _gridMapData.Origin;
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setGridMapWithOrigin",
|
||
_gridMapData.Width,
|
||
_gridMapData.Height,
|
||
_gridMapData.Resolution,
|
||
originToUse.X,
|
||
originToUse.Y,
|
||
originToUse.Z,
|
||
dataBase64,
|
||
IsMappingActive); // Pass mapping flag
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error rendering map: {Message}", ex.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Render map with overlay: static map as base, online map as overlay (for update map mode)
|
||
/// </summary>
|
||
private async Task RenderMapWithOverlay(GridMapData? staticMap, GridMapData? onlineMap)
|
||
{
|
||
if (staticMap == null || onlineMap == null) return;
|
||
|
||
// CRITICAL: Do NOT re-render map while applying initial pose!
|
||
if (_isApplyingInitialPose)
|
||
{
|
||
Logger.LogWarning("⚠️ RenderMapWithOverlay() called while applying initial pose - IGNORED to prevent map frame movement!");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// Convert byte arrays to base64 for JavaScript
|
||
var staticDataBase64 = Convert.ToBase64String(staticMap.Data);
|
||
var onlineDataBase64 = Convert.ToBase64String(onlineMap.Data);
|
||
|
||
// Use locked origin for both maps
|
||
var originToUse = _lockedMapOrigin ?? staticMap.Origin;
|
||
|
||
// Call JavaScript to render with overlay
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setGridMapWithOverlay",
|
||
staticMap.Width,
|
||
staticMap.Height,
|
||
staticMap.Resolution,
|
||
originToUse.X,
|
||
originToUse.Y,
|
||
originToUse.Z,
|
||
staticDataBase64,
|
||
onlineMap.Width,
|
||
onlineMap.Height,
|
||
onlineMap.Resolution,
|
||
onlineDataBase64);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error rendering map with overlay: {Message}", ex.Message);
|
||
}
|
||
}
|
||
/// <summary>
|
||
/// Render map by merging online map into current active map (for update map mode)
|
||
/// Similar to mapping but adds to existing map instead of clearing it
|
||
/// </summary>
|
||
private async Task RenderMapWithMerge(GridMapData? onlineMap)
|
||
{
|
||
if (onlineMap == null) return;
|
||
|
||
// CRITICAL: Do NOT re-render map while applying initial pose!
|
||
if (_isApplyingInitialPose)
|
||
{
|
||
Logger.LogWarning("⚠️ RenderMapWithMerge() called while applying initial pose - IGNORED to prevent map frame movement!");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// Convert byte array to base64 for JavaScript
|
||
var onlineDataBase64 = Convert.ToBase64String(onlineMap.Data);
|
||
|
||
// Use locked origin for update map mode (don't allow origin to change)
|
||
var originToUse = _lockedMapOrigin ?? onlineMap.Origin;
|
||
|
||
Logger.LogDebug("RenderMapWithMerge - Online map: {Width}x{Height}, Resolution: {Resolution}, Origin: ({X}, {Y}, {Z})",
|
||
onlineMap.Width, onlineMap.Height, onlineMap.Resolution,
|
||
originToUse.X, originToUse.Y, originToUse.Z);
|
||
|
||
// Call JavaScript to merge online map into current active map
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setGridMapWithMerge",
|
||
onlineMap.Width,
|
||
onlineMap.Height,
|
||
onlineMap.Resolution,
|
||
originToUse.X,
|
||
originToUse.Y,
|
||
originToUse.Z,
|
||
onlineDataBase64);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error rendering map with merge: {Message}", ex.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Start timer to update online map during mapping or update map mode (throttled to reduce load).
|
||
/// </summary>
|
||
private void StartOnlineMapUpdateTimer()
|
||
{
|
||
StopOnlineMapUpdateTimer(); // Stop any existing timer first
|
||
|
||
if (IsMappingActive || IsUpdateMapActive)
|
||
{
|
||
_onlineMapUpdateTimer = new System.Threading.Timer(async _ => await UpdateOnlineMap(), null,
|
||
TimeSpan.Zero, TimeSpan.FromMilliseconds(500));
|
||
Logger.LogInformation("Online map update timer started (every 500ms)");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stop the online map update timer
|
||
/// </summary>
|
||
private void StopOnlineMapUpdateTimer()
|
||
{
|
||
if (_onlineMapUpdateTimer != null)
|
||
{
|
||
_onlineMapUpdateTimer.Dispose();
|
||
_onlineMapUpdateTimer = null;
|
||
Logger.LogInformation("Online map update timer stopped");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Update online map from xloc_get_online_grid_map (called every 2 seconds during mapping)
|
||
/// Note: xloc_free_occupancy_grid is automatically called in XlocClient.GetOnlineGridMap()
|
||
/// </summary>
|
||
private async Task UpdateOnlineMap()
|
||
{
|
||
// Check if component is disposed
|
||
if (_isDisposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Prevent concurrent updates
|
||
if (System.Threading.Interlocked.CompareExchange(ref _onlineMapUpdateInProgress, 1, 0) != 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// Check again after acquiring lock (component might have been disposed)
|
||
if (_isDisposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Only update if mapping or update map is active
|
||
if (!IsMappingActive && !IsUpdateMapActive)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Load online map from API
|
||
// The API endpoint calls xloc_get_online_grid_map which internally calls xloc_free_occupancy_grid
|
||
// to free the previous map data before returning new data
|
||
var response = await Http.GetAsync(ClientApiUri("/api/xloc/gridmap/online"));
|
||
|
||
if (response.IsSuccessStatusCode)
|
||
{
|
||
var json = await response.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
var width = result.GetProperty("width").GetUInt32();
|
||
var height = result.GetProperty("height").GetUInt32();
|
||
var resolution = (float)result.GetProperty("resolution").GetDouble();
|
||
|
||
if (width > 0 && height > 0 && resolution > 1e-6f)
|
||
{
|
||
// Update grid map data with online map
|
||
// Note: We preserve the locked origin to prevent map frame movement
|
||
var newOrigin = new OriginData
|
||
{
|
||
X = result.GetProperty("origin").GetProperty("x").GetDouble(),
|
||
Y = result.GetProperty("origin").GetProperty("y").GetDouble(),
|
||
Z = result.GetProperty("origin").GetProperty("z").GetDouble(),
|
||
Qx = result.GetProperty("origin").GetProperty("qx").GetDouble(),
|
||
Qy = result.GetProperty("origin").GetProperty("qy").GetDouble(),
|
||
Qz = result.GetProperty("origin").GetProperty("qz").GetDouble(),
|
||
Qw = result.GetProperty("origin").GetProperty("qw").GetDouble()
|
||
};
|
||
|
||
var onlineMapData = new GridMapData
|
||
{
|
||
Resolution = resolution,
|
||
Width = width,
|
||
Height = height,
|
||
Origin = newOrigin,
|
||
Data = Convert.FromBase64String(result.GetProperty("data").GetString() ?? "")
|
||
};
|
||
|
||
// When in update map mode, keep static map and overlay online map
|
||
if (IsUpdateMapActive)
|
||
{
|
||
await RenderMapWithMerge(onlineMapData);
|
||
}
|
||
else if (IsMappingActive)
|
||
{
|
||
// When mapping, use origin from online map (may change as map grows)
|
||
// Don't lock origin during mapping to allow map to expand correctly
|
||
_gridMapData = onlineMapData;
|
||
|
||
// Use origin from online map directly (may change during mapping)
|
||
_gridMapData.Origin = newOrigin;
|
||
// Update locked origin if this is first online map, otherwise keep it flexible
|
||
if (_lockedMapOrigin == null)
|
||
{
|
||
_lockedMapOrigin = newOrigin;
|
||
}
|
||
// During mapping, allow origin to update to match online map
|
||
// This ensures map aligns with LIDAR scans
|
||
|
||
// Render the updated online map
|
||
await RenderMap();
|
||
}
|
||
else
|
||
{
|
||
// When not mapping/updating, use locked origin to prevent map frame movement
|
||
_gridMapData = onlineMapData;
|
||
if (_lockedMapOrigin != null)
|
||
{
|
||
_gridMapData.Origin = _lockedMapOrigin;
|
||
}
|
||
else
|
||
{
|
||
// Lock origin on first online map update
|
||
_lockedMapOrigin = newOrigin;
|
||
}
|
||
|
||
// Render the updated online map
|
||
await RenderMap();
|
||
}
|
||
|
||
Logger.LogDebug("Online map updated: {Width}x{Height}, Resolution: {Resolution}",
|
||
_gridMapData.Width, _gridMapData.Height, _gridMapData.Resolution);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
// Component is being disposed, ignore silently
|
||
return;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Only log if component is not disposed
|
||
if (!_isDisposed)
|
||
{
|
||
Logger.LogWarning(ex, "Error updating online map (will retry in 500ms)");
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
System.Threading.Interlocked.Exchange(ref _onlineMapUpdateInProgress, 0);
|
||
}
|
||
}
|
||
|
||
private async Task RenderRobot()
|
||
{
|
||
if (_robotPose == null) return;
|
||
|
||
try
|
||
{
|
||
// Keep last robot visualization visible while localization is computing.
|
||
// Skip updates only when pose contains invalid values.
|
||
if (!double.IsFinite(_robotPose.X) || !double.IsFinite(_robotPose.Y) || !double.IsFinite(_robotPose.Yaw))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var poseObj = new
|
||
{
|
||
x = _robotPose.X,
|
||
y = _robotPose.Y,
|
||
yaw = _robotPose.Yaw,
|
||
isLocalizing = _diagnosticsData != null && _diagnosticsData.MatchingScore == -1
|
||
};
|
||
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotPose", poseObj);
|
||
}
|
||
catch (OperationCanceledException) { /* component disposed */ }
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error rendering robot");
|
||
}
|
||
}
|
||
|
||
private async Task RenderAllLidarScans(object? lidar1Data, object? lidar2Data, object? lidar3Data)
|
||
{
|
||
try
|
||
{
|
||
var allLidarData = new {
|
||
lidar1 = lidar1Data,
|
||
lidar2 = lidar2Data,
|
||
lidar3 = lidar3Data
|
||
};
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setLaserScanData", allLidarData);
|
||
}
|
||
catch (Microsoft.JSInterop.JSDisconnectedException)
|
||
{
|
||
// Circuit disconnected, ignore
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogDebug(ex, "Error rendering laser scans");
|
||
}
|
||
}
|
||
|
||
private async Task ZoomIn()
|
||
{
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.zoomIn");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error zooming in");
|
||
}
|
||
}
|
||
|
||
private async Task ZoomOut()
|
||
{
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.zoomOut");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error zooming out");
|
||
}
|
||
}
|
||
|
||
private async Task ResetZoom()
|
||
{
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.resetZoom");
|
||
if (_gridMapData != null)
|
||
{
|
||
await RenderMap();
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error resetting zoom");
|
||
}
|
||
}
|
||
|
||
// State persistence methods
|
||
private async Task SaveMappingState()
|
||
{
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("robotnet.localStorageHelper.setItem", "xloc_mapping_active", IsMappingActive.ToString().ToLower());
|
||
await JSRuntime.InvokeVoidAsync("robotnet.localStorageHelper.setItem", "xloc_mapping_map_name", NewMapName ?? "");
|
||
Logger.LogDebug("Mapping state saved: Active={Active}, MapName={MapName}", IsMappingActive, NewMapName);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to save mapping state to localStorage");
|
||
}
|
||
}
|
||
|
||
private async Task RestoreMappingState()
|
||
{
|
||
try
|
||
{
|
||
var mappingActiveStr = await JSRuntime.InvokeAsync<string>("robotnet.localStorageHelper.getItem", "xloc_mapping_active");
|
||
var mapName = await JSRuntime.InvokeAsync<string>("robotnet.localStorageHelper.getItem", "xloc_mapping_map_name");
|
||
|
||
if (!string.IsNullOrEmpty(mappingActiveStr) && bool.TryParse(mappingActiveStr, out bool wasActive))
|
||
{
|
||
IsMappingActive = wasActive;
|
||
if (!string.IsNullOrEmpty(mapName))
|
||
{
|
||
NewMapName = mapName;
|
||
}
|
||
|
||
Logger.LogInformation("Mapping state restored from localStorage: Active={Active}, MapName={MapName}", IsMappingActive, NewMapName);
|
||
|
||
// Start online map update timer if mapping was active
|
||
if (IsMappingActive)
|
||
{
|
||
StartOnlineMapUpdateTimer();
|
||
}
|
||
|
||
// Update UI immediately after restore
|
||
await InvokeAsync(StateHasChanged);
|
||
|
||
// Don't verify immediately - let UpdateRobotPose() handle synchronization
|
||
// after diagnostics are loaded. This prevents false negatives when diagnostics
|
||
// haven't been loaded yet or are in transition.
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to restore mapping state from localStorage");
|
||
}
|
||
}
|
||
|
||
private async Task SaveLocalizationState()
|
||
{
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("robotnet.localStorageHelper.setItem", "xloc_localization_active", IsLocalizationActive.ToString().ToLower());
|
||
Logger.LogDebug("Localization state saved: Active={Active}", IsLocalizationActive);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to save localization state to localStorage");
|
||
}
|
||
}
|
||
|
||
private async Task RestoreLocalizationState()
|
||
{
|
||
try
|
||
{
|
||
var localizationActiveStr = await JSRuntime.InvokeAsync<string>("robotnet.localStorageHelper.getItem", "xloc_localization_active");
|
||
|
||
if (!string.IsNullOrEmpty(localizationActiveStr) && bool.TryParse(localizationActiveStr, out bool wasActive))
|
||
{
|
||
IsLocalizationActive = wasActive;
|
||
Logger.LogInformation("Localization state restored from localStorage: Active={Active}", IsLocalizationActive);
|
||
|
||
// Update UI immediately after restore
|
||
await InvokeAsync(StateHasChanged);
|
||
|
||
// Don't verify immediately - let UpdateRobotPose() handle synchronization
|
||
// after diagnostics are loaded. This prevents false negatives when diagnostics
|
||
// haven't been loaded yet or are in transition.
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to restore localization state from localStorage");
|
||
}
|
||
}
|
||
|
||
private async Task OnSelectedMapNameChanged(string? newMapName)
|
||
{
|
||
SelectedMapName = newMapName;
|
||
await SaveSelectedMap();
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task SaveSelectedMap()
|
||
{
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("robotnet.localStorageHelper.setItem", "xloc_selected_map_name", SelectedMapName ?? "");
|
||
Logger.LogDebug("Selected map name saved to localStorage: {MapName}", SelectedMapName);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to save selected map name to localStorage");
|
||
}
|
||
}
|
||
|
||
private async Task RestoreSelectedMap()
|
||
{
|
||
try
|
||
{
|
||
var savedMapName = await JSRuntime.InvokeAsync<string>("robotnet.localStorageHelper.getItem", "xloc_selected_map_name");
|
||
|
||
if (!string.IsNullOrEmpty(savedMapName))
|
||
{
|
||
SelectedMapName = savedMapName;
|
||
Logger.LogInformation("Selected map name restored from localStorage: {MapName}", SelectedMapName);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to restore selected map name from localStorage");
|
||
}
|
||
}
|
||
private async Task SaveActiveMap()
|
||
{
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("robotnet.localStorageHelper.setItem", "xloc_active_map_name", CurrentActiveMap ?? "");
|
||
Logger.LogDebug("Active map name saved to localStorage: {MapName}", CurrentActiveMap);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to save active map name to localStorage");
|
||
}
|
||
}
|
||
|
||
private async Task<string?> RestoreActiveMap()
|
||
{
|
||
try
|
||
{
|
||
var savedActiveMap = await JSRuntime.InvokeAsync<string>("robotnet.localStorageHelper.getItem", "xloc_active_map_name");
|
||
|
||
if (!string.IsNullOrEmpty(savedActiveMap))
|
||
{
|
||
Logger.LogInformation("Active map name restored from localStorage: {MapName}", savedActiveMap);
|
||
return savedActiveMap;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Failed to restore active map name from localStorage");
|
||
}
|
||
|
||
return null;
|
||
}
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
// Mark as disposed first to prevent new operations
|
||
_isDisposed = true;
|
||
|
||
// Stop all timers
|
||
_poseUpdateTimer?.Dispose();
|
||
_poseUpdateTimer = null;
|
||
|
||
StopOnlineMapUpdateTimer();
|
||
|
||
// Wait for any in-progress operations to complete
|
||
// Give them a moment to check _isDisposed flag
|
||
await Task.Delay(100);
|
||
|
||
// Dispose .NET object reference
|
||
_dotNetRef?.Dispose();
|
||
_dotNetRef = null;
|
||
|
||
await Task.CompletedTask;
|
||
}
|
||
|
||
// Data classes
|
||
private class GridMapData
|
||
{
|
||
public float Resolution { get; set; }
|
||
public uint Width { get; set; }
|
||
public uint Height { get; set; }
|
||
public OriginData Origin { get; set; } = new();
|
||
public byte[] Data { get; set; } = Array.Empty<byte>();
|
||
}
|
||
|
||
private class OriginData
|
||
{
|
||
public double X { get; set; }
|
||
public double Y { get; set; }
|
||
public double Z { get; set; }
|
||
public double Qx { get; set; }
|
||
public double Qy { get; set; }
|
||
public double Qz { get; set; }
|
||
public double Qw { get; set; }
|
||
}
|
||
|
||
private class RobotPoseData
|
||
{
|
||
public double X { get; set; }
|
||
public double Y { get; set; }
|
||
public double Yaw { get; set; }
|
||
}
|
||
|
||
private class DiagnosticsData
|
||
{
|
||
public uint HeaderSeq { get; set; }
|
||
public uint HeaderStampSec { get; set; }
|
||
public uint HeaderStampNsec { get; set; }
|
||
public string HeaderFrameId { get; set; } = string.Empty;
|
||
public byte XlocState { get; set; }
|
||
public string StateString { get; set; } = string.Empty;
|
||
public string CurrentActiveMap { get; set; } = string.Empty;
|
||
public double Reliability { get; set; }
|
||
public double MatchingScore { get; set; }
|
||
}
|
||
|
||
private class VelocityData
|
||
{
|
||
public double OdomLinearVel { get; set; }
|
||
public double OdomAngularVel { get; set; }
|
||
public double CmdLinearVel { get; set; }
|
||
public double CmdAngularVel { get; set; }
|
||
}
|
||
|
||
private class VelocityHistoryPoint
|
||
{
|
||
public DateTime Timestamp { get; set; }
|
||
public double OdomLinearVel { get; set; }
|
||
public double OdomAngularVel { get; set; }
|
||
public double CmdLinearVel { get; set; }
|
||
public double CmdAngularVel { get; set; }
|
||
}
|
||
|
||
private class GlobalPathPoint
|
||
{
|
||
public double X { get; set; }
|
||
public double Y { get; set; }
|
||
public double Theta { get; set; }
|
||
}
|
||
|
||
private async Task UpdateVelocityData()
|
||
{
|
||
try
|
||
{
|
||
var response = await Http.GetAsync(ClientApiUri("/api/xloc/velocity"));
|
||
if (response.IsSuccessStatusCode)
|
||
{
|
||
var json = await response.Content.ReadAsStringAsync();
|
||
var result = JsonSerializer.Deserialize<JsonElement>(json);
|
||
|
||
if (result.TryGetProperty("status", out var status) && status.GetString() == "success")
|
||
{
|
||
double odomLinear = 0.0;
|
||
double odomAngular = 0.0;
|
||
double cmdLinear = 0.0;
|
||
double cmdAngular = 0.0;
|
||
|
||
if (result.TryGetProperty("odomVel", out var odomVel))
|
||
{
|
||
if (odomVel.TryGetProperty("linear", out var odomLinearProp))
|
||
odomLinear = odomLinearProp.GetDouble();
|
||
if (odomVel.TryGetProperty("angular", out var odomAngularProp))
|
||
odomAngular = odomAngularProp.GetDouble();
|
||
}
|
||
|
||
if (result.TryGetProperty("cmdVel", out var cmdVel))
|
||
{
|
||
if (cmdVel.TryGetProperty("linear", out var cmdLinearProp))
|
||
cmdLinear = cmdLinearProp.GetDouble();
|
||
if (cmdVel.TryGetProperty("angular", out var cmdAngularProp))
|
||
cmdAngular = cmdAngularProp.GetDouble();
|
||
}
|
||
|
||
_velocityData = new VelocityData
|
||
{
|
||
OdomLinearVel = odomLinear,
|
||
OdomAngularVel = odomAngular,
|
||
CmdLinearVel = cmdLinear,
|
||
CmdAngularVel = cmdAngular
|
||
};
|
||
|
||
// Add to history
|
||
lock (_velocityHistory)
|
||
{
|
||
if (_velocityHistory.Count == 0)
|
||
{
|
||
_chartStartTime = DateTime.UtcNow;
|
||
}
|
||
|
||
_velocityHistory.Add(new VelocityHistoryPoint
|
||
{
|
||
Timestamp = DateTime.UtcNow,
|
||
OdomLinearVel = odomLinear,
|
||
OdomAngularVel = odomAngular,
|
||
CmdLinearVel = cmdLinear,
|
||
CmdAngularVel = cmdAngular
|
||
});
|
||
|
||
// Keep only last MaxHistoryPoints
|
||
if (_velocityHistory.Count > MaxHistoryPoints)
|
||
{
|
||
_velocityHistory.RemoveAt(0);
|
||
_chartStartTime = _velocityHistory[0].Timestamp;
|
||
}
|
||
}
|
||
|
||
// Update charts
|
||
await UpdateVelocityCharts();
|
||
}
|
||
}
|
||
}
|
||
catch (HttpRequestException)
|
||
{
|
||
// Silently ignore connection errors - server might not be running
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogDebug(ex, "Error updating velocity data");
|
||
}
|
||
}
|
||
|
||
private async Task UpdateVelocityCharts()
|
||
{
|
||
try
|
||
{
|
||
if (_velocityHistory.Count == 0) return;
|
||
|
||
var linearData = _velocityHistory.Select(v => new {
|
||
time = (v.Timestamp - _chartStartTime).TotalSeconds,
|
||
odomVel = v.OdomLinearVel,
|
||
cmdVel = v.CmdLinearVel
|
||
}).ToArray();
|
||
|
||
var angularData = _velocityHistory.Select(v => new {
|
||
time = (v.Timestamp - _chartStartTime).TotalSeconds,
|
||
odomVel = v.OdomAngularVel, // Keep in radians
|
||
cmdVel = v.CmdAngularVel
|
||
}).ToArray();
|
||
|
||
// Ensure charts are initialized before updating
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("velocityChartRenderer.initLinearVelocityChart", LinearVelocityChartCanvas);
|
||
}
|
||
catch { /* Already initialized or not ready yet */ }
|
||
|
||
try
|
||
{
|
||
await JSRuntime.InvokeVoidAsync("velocityChartRenderer.initAngularVelocityChart", AngularVelocityChartCanvas);
|
||
}
|
||
catch { /* Already initialized or not ready yet */ }
|
||
|
||
await JSRuntime.InvokeVoidAsync("velocityChartRenderer.updateLinearVelocityChart",
|
||
LinearVelocityChartCanvas,
|
||
linearData);
|
||
|
||
await JSRuntime.InvokeVoidAsync("velocityChartRenderer.updateAngularVelocityChart",
|
||
AngularVelocityChartCanvas,
|
||
angularData);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogDebug(ex, "Error updating velocity charts");
|
||
}
|
||
}
|
||
|
||
public class InitialPoseInput
|
||
{
|
||
public double X { get; set; }
|
||
public double Y { get; set; }
|
||
public double Yaw { get; set; }
|
||
}
|
||
}
|