Files
BQP/srcs/RobotNet10/RobotApp/RobotNet10.RobotApp/Components/Pages/XlocMapView.razor
2026-07-13 09:25:40 +07:00

5547 lines
244 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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-toolbar__btn--active {
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.35) !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-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
cursor: pointer;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(245, 124, 0, 0.14);
}
.xloc-map-section-title-wrap {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.xloc-map-section-icon {
width: 30px;
height: 30px;
border-radius: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(245, 124, 0, 0.22), rgba(255, 152, 0, 0.18));
color: #e65100;
border: 1px solid rgba(245, 124, 0, 0.28);
flex: 0 0 auto;
}
.xloc-map-section-title {
color: #ef6c00 !important;
font-weight: 700;
margin: 0;
letter-spacing: 0.2px;
}
.xloc-map-setup__block {
padding: 13px 13px 11px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 251, 245, 0.74));
border: 1px solid rgba(245, 124, 0, 0.18);
border-radius: 12px;
box-shadow: var(--xloc-shadow-sm);
}
.xloc-map-setup__actions {
padding: 13px 13px 11px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 249, 240, 0.74));
border: 1px solid rgba(245, 124, 0, 0.18);
border-radius: 12px;
box-shadow: var(--xloc-shadow-sm);
}
.xloc-map-setup__select {
margin-bottom: 10px;
border-radius: 10px;
overflow: hidden;
}
.xloc-map-setup__actions-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
gap: 12px;
}
.xloc-map-setup__badge {
width: 36px;
height: 36px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(245, 124, 0, 0.2), rgba(255, 152, 0, 0.14));
border: 1px solid rgba(245, 124, 0, 0.3);
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-action-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 9px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.09);
background: rgba(255, 255, 255, 0.7);
}
.xloc-map-action-group--primary {
border-color: rgba(245, 124, 0, 0.24);
background: linear-gradient(180deg, rgba(255, 252, 245, 0.94), rgba(255, 248, 236, 0.9));
}
.xloc-map-action-group--utility {
border-color: rgba(2, 132, 199, 0.2);
background: linear-gradient(180deg, rgba(248, 252, 255, 0.94), rgba(241, 249, 255, 0.9));
}
.xloc-map-action-group--danger {
border-color: rgba(220, 38, 38, 0.24);
background: linear-gradient(180deg, rgba(255, 249, 249, 0.94), rgba(255, 241, 241, 0.9));
}
.xloc-map-action-group__title {
display: flex;
align-items: center;
gap: 7px;
margin: 0;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.xloc-map-action-group--primary .xloc-map-action-group__title {
color: #c05600;
}
.xloc-map-action-group--utility .xloc-map-action-group__title {
color: #0369a1;
}
.xloc-map-action-group--danger .xloc-map-action-group__title {
color: #b91c1c;
}
.xloc-map-action-btn {
border-radius: 10px !important;
min-height: 42px;
font-weight: 600;
text-transform: none;
letter-spacing: 0.1px;
}
.xloc-map-action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.1);
}
.xloc-map-status {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.7);
border-radius: 8px;
border: 1px solid rgba(245, 124, 0, 0.15);
}
.xloc-map-status__dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.xloc-map-status__dot--active {
background-color: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
}
.xloc-map-status__dot--inactive {
background-color: #94a3b8;
}
.xloc-map-status__label {
font-size: 13px;
color: #374151;
}
.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;
}
.xloc-lidar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.xloc-lidar-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.2px;
border: 1px solid transparent;
}
.xloc-lidar-chip__dot {
width: 10px;
height: 10px;
border-radius: 999px;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.7);
}
.xloc-lidar-chip--1 {
background: rgba(204, 0, 0, 0.08);
color: #8b0000;
border-color: rgba(204, 0, 0, 0.2);
}
.xloc-lidar-chip--1 .xloc-lidar-chip__dot {
background: #cc0000;
}
.xloc-lidar-chip--2 {
background: rgba(21, 128, 61, 0.1);
color: #166534;
border-color: rgba(21, 128, 61, 0.22);
}
.xloc-lidar-chip--2 .xloc-lidar-chip__dot {
background: #16a34a;
}
.xloc-lidar-chip--3 {
background: rgba(21, 101, 192, 0.1);
color: #0f4c9b;
border-color: rgba(21, 101, 192, 0.24);
}
.xloc-lidar-chip--3 .xloc-lidar-chip__dot {
background: #1976d2;
}
.xloc-lidar-switch {
margin-right: 2px;
}
.xloc-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.xloc-toggle-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
border: 1px solid transparent;
flex: 1;
min-width: 0;
}
.xloc-toggle-chip .mud-icon-root {
font-size: 18px;
flex: 0 0 auto;
}
.xloc-toggle-chip--violet {
background: rgba(142, 36, 170, 0.1);
color: #6a1b9a;
border-color: rgba(142, 36, 170, 0.22);
}
.xloc-toggle-chip--cyan {
background: rgba(0, 188, 212, 0.1);
color: #00838f;
border-color: rgba(0, 188, 212, 0.24);
}
.xloc-toggle-chip--purple {
background: rgba(123, 31, 162, 0.1);
color: #6a1b9a;
border-color: rgba(123, 31, 162, 0.22);
}
.xloc-toggle-chip--amber {
background: rgba(245, 124, 0, 0.1);
color: #e65100;
border-color: rgba(245, 124, 0, 0.24);
}
.xloc-toggle-chip--pose {
background: rgba(123, 31, 162, 0.1);
color: #6a1b9a;
border-color: rgba(123, 31, 162, 0.22);
}
.xloc-view-switch {
flex: 0 0 auto;
margin: 0 !important;
align-self: center;
}
.xloc-view-switch .mud-switch-track {
opacity: 1 !important;
background-color: #cbd5e1 !important;
}
.xloc-view-switch .mud-switch-thumb {
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.2) !important;
background-color: #ffffff !important;
}
.xloc-view-switch.mud-checked .mud-switch-track-primary {
background-color: rgba(25, 118, 210, 0.85) !important;
}
.xloc-view-switch.mud-checked .mud-switch-track-secondary {
background-color: rgba(142, 36, 170, 0.85) !important;
}
.xloc-view-switch.mud-checked .mud-switch-track-info {
background-color: rgba(2, 136, 209, 0.85) !important;
}
.xloc-view-switch.mud-checked .mud-switch-track-warning {
background-color: rgba(245, 124, 0, 0.9) !important;
}
.xloc-view-switch.mud-checked .mud-switch-track-error {
background-color: rgba(211, 47, 47, 0.88) !important;
}
.xloc-view-switch.mud-checked .mud-switch-track-success {
background-color: rgba(46, 125, 50, 0.88) !important;
}
</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="@(FollowRobotView ? Variant.Filled : Variant.Outlined)"
Color="Color.Primary"
Size="Size.Small"
Class="@(FollowRobotView ? "xloc-toolbar__btn--active" : null)"
StartIcon="@Icons.Material.Filled.GpsFixed"
OnClick="ToggleFollowRobotView"
Title="@(FollowRobotView ? "Following robot (drag map to pin view)" : "Pin view to map (click to follow robot)")">
Follow Robot
</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 class="xloc-map-section-header" @onclick="() => IsMapSelectionExpanded = !IsMapSelectionExpanded">
<div class="xloc-map-section-title-wrap">
<span class="xloc-map-section-icon">
<MudIcon Icon="@Icons.Material.Filled.Map" />
</span>
<MudText Typo="Typo.h6" Class="xloc-map-section-title">Select & Activate Map</MudText>
</div>
<MudIcon Icon="@(IsMapSelectionExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #f57c00 !important;" />
</div>
@if (IsMapSelectionExpanded)
{
<div class="xloc-map-setup">
@if (AvailableMaps.Count > 0)
{
<div class="xloc-map-action-group xloc-map-action-group--primary">
<p class="xloc-map-action-group__title">
<MudIcon Icon="@Icons.Material.Filled.Map" Size="Size.Small" />
Map Selection
</p>
<MudSelect T="string"
Value="@SelectedMapName"
ValueChanged="@OnSelectedMapNameChanged"
Label="Available Map"
Variant="Variant.Filled"
Class="xloc-map-setup__select"
Style="background-color: rgba(255, 255, 255, 0.95) !important;">
@foreach (var map in AvailableMaps)
{
<MudSelectItem T="string" Value="@map">@map</MudSelectItem>
}
</MudSelect>
<div class="xloc-map-status">
@if (!string.IsNullOrEmpty(CurrentActiveMap))
{
<span class="xloc-map-status__dot xloc-map-status__dot--active"></span>
<span class="xloc-map-status__label">Active: <strong>@CurrentActiveMap</strong></span>
}
else
{
<span class="xloc-map-status__dot xloc-map-status__dot--inactive"></span>
<span class="xloc-map-status__label">No active map</span>
}
</div>
</div>
<div class="xloc-map-action-group xloc-map-action-group--primary">
<p class="xloc-map-action-group__title">
<MudIcon Icon="@Icons.Material.Filled.Bolt" Size="Size.Small" />
Main Action
</p>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Class="xloc-map-action-btn"
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.Visibility" Class="xloc-map-setup__btn-icon" />
<span>Load View</span>
}
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Warning"
Class="xloc-map-action-btn"
FullWidth="true"
OnClick="ActivateSelectedMap"
Disabled="@(!CanActivateMap)">
@if (IsActivatingMap)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="xloc-map-setup__btn-spinner" />
<span>Activating...</span>
}
else
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Class="xloc-map-setup__btn-icon" />
<span>Activate</span>
}
</MudButton>
</div>
<InputFile id="xloc-import-map-zip"
OnChange="OnImportMapZipAsync"
accept=".zip,application/zip"
style="display:none" />
<div class="xloc-map-action-group xloc-map-action-group--utility">
<p class="xloc-map-action-group__title">
<MudIcon Icon="@Icons.Material.Filled.Unarchive" Size="Size.Small" />
Import / Export
</p>
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
Class="xloc-map-action-btn"
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 (.zip)</span>
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Secondary"
Class="xloc-map-action-btn"
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 (.zip)</span>
</MudButton>
</div>
<div class="xloc-map-action-group xloc-map-action-group--danger">
<p class="xloc-map-action-group__title">
<MudIcon Icon="@Icons.Material.Filled.WarningAmber" Size="Size.Small" />
Danger Zone
</p>
<MudSelect T="string"
Value="@MapToDeleteName"
ValueChanged="@OnMapToDeleteNameChanged"
Label="Map to delete"
Variant="Variant.Filled"
Class="xloc-map-setup__select"
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>
<MudButton Variant="Variant.Outlined"
Color="Color.Error"
Class="xloc-map-action-btn"
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>
</div>
}
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>
<!-- 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 class="xloc-toggle-row" style="@GetInitialPoseModeBackgroundStyle(); padding: 10px 12px; border-radius: 10px; margin-bottom: 8px;">
<span class="xloc-toggle-chip xloc-toggle-chip--pose">
<MudIcon Icon="@Icons.Material.Filled.NearMe" />
<span>Drag Arrow Mode @(IsInitialPoseMode ? "ON" : "OFF")</span>
</span>
<MudSwitch T="bool"
Color="@(IsInitialPoseMode ? Color.Success : Color.Error)"
Class="xloc-view-switch"
@bind-Value="IsInitialPoseMode"
@bind-Value:after="OnInitialPoseModeChanged" />
</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: Order Nav -->
<div class="xloc-panel xloc-panel--accent-cyan">
<div style="display: flex; align-items: center; justify-content: space-between; cursor: pointer; margin-bottom: 12px;" @onclick="() => IsOrderNavExpanded = !IsOrderNavExpanded">
<MudText Typo="Typo.h6" Style="color: #00838f !important; font-weight: 600; margin: 0;">
<MudIcon Icon="@Icons.Material.Filled.Navigation" Style="color: #00838f !important; vertical-align: middle; margin-right: 4px;" />
Order Nav
</MudText>
<MudIcon Icon="@(IsOrderNavExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" Style="color: #00838f !important;" />
</div>
@if (IsOrderNavExpanded)
{
@if (!IsOrderNavUnlocked)
{
<MudText Typo="Typo.body2" Class="mb-2" Style="color: #555 !important;">Enter password to enable order navigation.</MudText>
<MudTextField T="string"
@bind-Value="_orderNavPasswordInput"
Label="Password"
InputType="InputType.Password"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Class="mb-2"
Style="background-color: rgba(255, 255, 255, 0.95) !important;" />
@if (!string.IsNullOrEmpty(OrderNavMessage))
{
<MudAlert Severity="Severity.Error" Class="mb-2" Dense="true">@OrderNavMessage</MudAlert>
}
<MudButton Variant="Variant.Filled" Color="Color.Info" FullWidth="true" OnClick="UnlockOrderNav">
<MudIcon Icon="@Icons.Material.Filled.Lock" Class="mr-1" /> Unlock
</MudButton>
}
else
{
<div style="display: flex; justify-content: flex-end; margin-bottom: 8px;">
<MudButton Variant="Variant.Text" Color="Color.Default" Size="Size.Small" OnClick="LockOrderNav">
<MudIcon Icon="@Icons.Material.Filled.LockOpen" Class="mr-1" /> Lock
</MudButton>
</div>
<div class="xloc-toggle-row" style="background: @(IsOrderNavMode ? "rgba(0, 150, 136, 0.1)" : "rgba(244, 67, 54, 0.1)"); border: 1px solid @(IsOrderNavMode ? "rgba(0, 150, 136, 0.28)" : "rgba(244, 67, 54, 0.28)"); padding: 10px 12px; border-radius: 10px; margin-bottom: 8px;">
<span class="xloc-toggle-chip xloc-toggle-chip--pose">
<MudIcon Icon="@Icons.Material.Filled.NearMe" />
<span>Drag Arrow Mode @(IsOrderNavMode ? "ON" : "OFF")</span>
</span>
<MudSwitch T="bool"
Color="@(IsOrderNavMode ? Color.Success : Color.Error)"
Class="xloc-view-switch"
@bind-Value="IsOrderNavMode"
@bind-Value:after="OnOrderNavModeChanged" />
</div>
<MudText Typo="Typo.caption" Class="mb-2" Style="color: #666 !important; display: block;">
@if (IsOrderNavMode)
{
<span style="color: #009688 !important;">✓ Click and drag on map to set navigation goal</span>
}
else
{
<span>Turn on drag mode or enter coordinates below</span>
}
</MudText>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 10px;">
<MudNumericField T="double" @bind-Value="OrderNavX" 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="OrderNavY" 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="OrderNavYaw" 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.Info" FullWidth="true"
OnClick="SendOrderNav" Disabled="@IsSendingOrderNav">
@if (IsSendingOrderNav)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>Sending...</span>
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Send" Class="mr-1" />
<span>Send Order Nav</span>
}
</MudButton>
@if (!string.IsNullOrEmpty(OrderNavMessage))
{
<MudAlert Severity="@(OrderNavSuccess ? Severity.Success : Severity.Error)" Class="mt-2" Dense="true">
@OrderNavMessage
</MudAlert>
}
}
}
</div>
<!-- Section 6: 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=&quot;http://www.w3.org/2000/svg&quot; width=&quot;24&quot; height=&quot;24&quot; viewBox=&quot;0 0 24 24&quot;><line x1=&quot;0&quot; y1=&quot;12&quot; x2=&quot;24&quot; y2=&quot;12&quot; stroke=&quot;%231e3a5f&quot; stroke-width=&quot;2&quot;/><line x1=&quot;12&quot; y1=&quot;0&quot; x2=&quot;12&quot; y2=&quot;24&quot; stroke=&quot;%231e3a5f&quot; stroke-width=&quot;2&quot;/></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>
<div class="xloc-toggle-row">
<span class="xloc-toggle-chip xloc-toggle-chip--violet">
<MudIcon Icon="@Icons.Material.Filled.SmartToy" />
<span>Show robot icon</span>
</span>
<MudSwitch T="bool"
Value="@ShowRobotIconLayer"
ValueChanged="OnRobotIconVisibilityChanged"
Color="Color.Secondary"
Class="xloc-view-switch" />
</div>
</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>
<div class="xloc-toggle-row">
<span class="xloc-toggle-chip xloc-toggle-chip--cyan">
<MudIcon Icon="@Icons.Material.Filled.CropFree" />
<span>Show robot base footprint</span>
</span>
<MudSwitch T="bool"
Value="@ShowRobotFootprintLayer"
ValueChanged="OnRobotFootprintVisibilityChanged"
Color="Color.Info"
Class="xloc-view-switch" />
</div>
</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>
<div class="xloc-toggle-row">
<span class="xloc-toggle-chip xloc-toggle-chip--purple">
<MudIcon Icon="@Icons.Material.Filled.Timeline" />
<span>Show global path</span>
</span>
<MudSwitch T="bool"
Value="@ShowGlobalPathLayer"
ValueChanged="OnGlobalPathVisibilityChanged"
Color="Color.Primary"
Class="xloc-view-switch" />
</div>
<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>
<div class="xloc-toggle-row">
<span class="xloc-toggle-chip xloc-toggle-chip--amber">
<MudIcon Icon="@Icons.Material.Filled.GridOn" />
<span>Show cost map</span>
</span>
<MudSwitch T="bool"
Value="@ShowCostMapLayer"
ValueChanged="OnCostMapVisibilityChanged"
Color="Color.Warning"
Class="xloc-view-switch" />
</div>
<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">
<div class="xloc-lidar-row">
<span class="xloc-lidar-chip xloc-lidar-chip--1">
<span class="xloc-lidar-chip__dot"></span>
<span>LiDAR 1</span>
</span>
<MudSwitch T="bool"
Value="@ShowLidar1Layer"
ValueChanged="OnLidar1VisibilityChanged"
Color="Color.Error"
Class="xloc-view-switch xloc-lidar-switch" />
</div>
<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">
<div class="xloc-lidar-row">
<span class="xloc-lidar-chip xloc-lidar-chip--2">
<span class="xloc-lidar-chip__dot"></span>
<span>LiDAR 2</span>
</span>
<MudSwitch T="bool"
Value="@ShowLidar2Layer"
ValueChanged="OnLidar2VisibilityChanged"
Color="Color.Success"
Class="xloc-view-switch xloc-lidar-switch" />
</div>
<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;">
<div class="xloc-lidar-row">
<span class="xloc-lidar-chip xloc-lidar-chip--3">
<span class="xloc-lidar-chip__dot"></span>
<span>LiDAR 3</span>
</span>
<MudSwitch T="bool"
Value="@ShowLidar3Layer"
ValueChanged="OnLidar3VisibilityChanged"
Color="Color.Primary"
Class="xloc-view-switch xloc-lidar-switch" />
</div>
<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 />
&nbsp;&nbsp;Linear: @((_velocityData?.OdomLinearVel ?? 0.0).ToString("F3")) m/s<br />
&nbsp;&nbsp;Angular: @((_velocityData?.OdomAngularVel ?? 0.0).ToString("F3")) rad/s<br />
<strong>CmdVel:</strong><br />
&nbsp;&nbsp;Linear: @((_velocityData?.CmdLinearVel ?? 0.0).ToString("F3")) m/s<br />
&nbsp;&nbsp;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 bool FollowRobotView = 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;
// Order Nav state
private bool IsOrderNavExpanded = false;
private bool IsOrderNavUnlocked = false;
private string _orderNavPasswordInput = "";
private bool IsOrderNavMode = false;
private double OrderNavX = 0.0;
private double OrderNavY = 0.0;
private double OrderNavYaw = 0.0;
private string OrderNavMessage = "";
private bool OrderNavSuccess = false;
private bool IsSendingOrderNav = 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)";
var borderColor = IsInitialPoseMode ? "rgba(76, 175, 80, 0.28)" : "rgba(244, 67, 54, 0.28)";
return $"background: {bgColor}; border: 1px solid {borderColor};";
}
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 ToggleFollowRobotView()
{
FollowRobotView = !FollowRobotView;
try
{
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setFollowRobot", FollowRobotView);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to toggle follow-robot view");
FollowRobotView = !FollowRobotView;
}
StateHasChanged();
}
[JSInvokable]
public Task OnFollowRobotViewDisabled()
{
FollowRobotView = false;
return InvokeAsync(StateHasChanged);
}
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);
}
}
private void UnlockOrderNav()
{
if (_orderNavPasswordInput == "robotics_nav")
{
IsOrderNavUnlocked = true;
OrderNavMessage = "";
}
else
{
OrderNavMessage = "Incorrect password.";
OrderNavSuccess = false;
}
_orderNavPasswordInput = "";
}
private async Task LockOrderNav()
{
IsOrderNavUnlocked = false;
IsOrderNavMode = false;
OrderNavMessage = "";
try { await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setOrderNavMode", false); } catch { }
}
private async Task OnOrderNavModeChanged()
{
try
{
if (IsOrderNavMode && _robotPose != null)
{
OrderNavX = _robotPose.X;
OrderNavY = _robotPose.Y;
OrderNavYaw = _robotPose.Yaw * 180.0 / Math.PI;
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setOrderNavPose", new
{
x = OrderNavX,
y = OrderNavY,
yaw = OrderNavYaw * Math.PI / 180.0
});
}
else if (!IsOrderNavMode)
{
OrderNavX = 0.0;
OrderNavY = 0.0;
OrderNavYaw = 0.0;
}
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setOrderNavMode", IsOrderNavMode);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error toggling order nav mode");
IsOrderNavMode = !IsOrderNavMode;
}
}
[JSInvokable]
public async Task OnOrderNavSelected(InitialPoseInput pose)
{
try
{
Logger.LogInformation("Order nav pose selected: X={X:F3}m, Y={Y:F3}m, Yaw={Yaw:F3}rad", pose.X, pose.Y, pose.Yaw);
OrderNavX = pose.X;
OrderNavY = pose.Y;
OrderNavYaw = pose.Yaw * 180.0 / Math.PI;
await InvokeAsync(StateHasChanged);
await SendOrderNav();
}
catch (Exception ex)
{
Logger.LogError(ex, "Error handling order nav selection");
OrderNavMessage = $"Error: {ex.Message}";
OrderNavSuccess = false;
await InvokeAsync(StateHasChanged);
}
}
private async Task SendOrderNav()
{
IsSendingOrderNav = true;
OrderNavMessage = "";
StateHasChanged();
try
{
var goalYawRad = OrderNavYaw * Math.PI / 180.0;
var startX = _robotPose?.X ?? 0.0;
var startY = _robotPose?.Y ?? 0.0;
var startYaw = _robotPose?.Yaw ?? 0.0; // already radians
// Build a minimal VDA5050 order (current pos → goal pos).
// The custom planner requires an Order; makePlan() without one is not implemented.
var orderId = Guid.NewGuid().ToString("N")[..12];
var order = new
{
orderId,
orderUpdateId = 0,
nodes = new object[]
{
new
{
nodeId = "start",
sequenceId = 0,
released = true,
nodePosition = new
{
x = startX, y = startY,
theta = (double?)startYaw,
mapId = "map",
allowedDeviationXY = 0.5,
allowedDeviationTheta = Math.PI
},
actions = Array.Empty<object>()
},
new
{
nodeId = "goal",
sequenceId = 2,
released = true,
nodePosition = new
{
x = OrderNavX, y = OrderNavY,
theta = (double?)goalYawRad,
mapId = "map",
allowedDeviationXY = 0.2,
allowedDeviationTheta = 0.3
},
actions = Array.Empty<object>()
}
},
edges = new object[]
{
new
{
edgeId = "edge0",
sequenceId = 1,
startNodeId = "start",
endNodeId = "goal",
released = true,
actions = Array.Empty<object>(),
// Degree-1 linear NURBS: knotVector size = controlPoints.Length + degree + 1 = 2+1+1 = 4
trajectory = new
{
degree = 1,
knotVector = new[] { 0.0, 0.0, 1.0, 1.0 },
controlPoints = new object[]
{
new { x = startX, y = startY, weight = 1.0 },
new { x = OrderNavX, y = OrderNavY, weight = 1.0 }
}
}
}
}
};
var response = await Http.PostAsJsonAsync(ClientApiUri("/api/navigation/move_to_order_data"), new
{
x = OrderNavX,
y = OrderNavY,
yaw = goalYawRad,
frame_id = "map",
xy_tolerance = 0.1,
yaw_tolerance = 0.2,
order
});
if (response.IsSuccessStatusCode)
{
OrderNavMessage = $"Sent: ({OrderNavX:F2}, {OrderNavY:F2}) Yaw: {OrderNavYaw:F1}°";
OrderNavSuccess = true;
}
else
{
var body = await response.Content.ReadAsStringAsync();
OrderNavMessage = $"Failed ({(int)response.StatusCode}): {body}";
OrderNavSuccess = false;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error sending order nav");
OrderNavMessage = $"Error: {ex.Message}";
OrderNavSuccess = false;
}
finally
{
IsSendingOrderNav = false;
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 JSRuntime.InvokeVoidAsync("xlocMapRenderer.setFollowRobot", FollowRobotView);
await ApplyLidarDisplayOptionsAsync();
await TryLoadRobotFootprintAsync();
await TryLoadLidarTransformsAsync();
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 async Task TryLoadLidarTransformsAsync()
{
try
{
var loaded = await LoadLidarTransformsAsync(Http, ClientApiUri("/api/tf3/lidar_transforms"));
if (loaded)
{
return;
}
Logger.LogDebug("Lidar transforms endpoint returned no usable payload");
}
catch (HttpRequestException ex) when (IsUntrustedRootSslError(ex) && ShouldAllowInsecureSslFallback())
{
Logger.LogWarning("Lidar transforms 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 LoadLidarTransformsAsync(insecureClient, ClientApiUri("/api/tf3/lidar_transforms"));
if (loaded)
{
Logger.LogInformation("Lidar transforms loaded using insecure SSL fallback for local/private endpoint");
return;
}
Logger.LogWarning("Lidar transforms fallback request completed but payload did not contain transform data");
}
catch (Exception fallbackEx)
{
Logger.LogWarning(fallbackEx, "Failed to load lidar transforms using insecure SSL fallback");
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load lidar transforms");
}
}
private async Task<bool> LoadLidarTransformsAsync(HttpClient client, Uri requestUri)
{
var transformsResponse = await client.GetAsync(requestUri);
if (!transformsResponse.IsSuccessStatusCode)
{
return false;
}
var transformsJson = await transformsResponse.Content.ReadAsStringAsync();
using var transformsDoc = JsonDocument.Parse(transformsJson);
if (!transformsDoc.RootElement.TryGetProperty("transforms", out var transforms))
{
return false;
}
await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setLidarTransforms", transforms.GetRawText());
Logger.LogInformation("Lidar transforms 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; }
}
}