@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 Logger @inject IJSRuntime JSRuntime @inject IDialogService DialogService @inject XlocIntegrationService XlocService @implements IAsyncDisposable XLOC Map Visualization
XLOC Map visualization
Feature Information View @if (IsLoadingGridMap) { Loading... } else { Load Grid Map } + - Reset
@if (_gridMapData != null) { Size @_gridMapData.Width × @_gridMapData.Height · @_gridMapData.Resolution m/cell } @if (_robotPose != null) { Robot X @_robotPose.X.ToString("F2") m · Y @_robotPose.Y.ToString("F2") m · Yaw @(_robotPose.Yaw * 180.0 / Math.PI).ToString("F1")° }
@if (ShowFeaturePanel) {
Mapping Control
@if (IsMappingControlExpanded) {
Start Mapping Stop & Save
@if (IsMappingActive) { Mapping in progress... Drive the robot to create map. @if (string.IsNullOrEmpty(NewMapName)) {
⚠️ Please enter map name before stopping }
} else if (_diagnosticsData != null) { XLOC State: @_diagnosticsData.StateString
To start mapping, click "Start Mapping" button
} }
Select & Activate Map
@if (IsMapSelectionExpanded) {
@if (AvailableMaps.Count > 0) { @foreach (var map in AvailableMaps) { @map } @foreach (var map in AvailableMaps) { @map } } else { No maps found. Loading from: @XlocPaths.GetMapsDirectory() Refresh Maps }
Activate & Manage
@if (IsLoadingMap) { Loading... } else { Load Map (View) } @if (IsActivatingMap) { } else { } Activate
}
Localization Control
@if (IsLocalizationControlExpanded) {
Start Stop
@if (IsLocalizationActive) { Localization active - Robot is tracking position on map. } }
Update Map Control
@if (IsUpdateMapControlExpanded) {
Start Update Stop & Save
@if (IsUpdateMapActive) { Map update in progress... Online map is being overlaid on the active map. } else if (!IsLocalizationActive) { Note: Update map can only be started when localization is active. } }
Set Initial Pose
@if (IsInitialPoseExpanded) {
Drag Arrow Mode: @(IsInitialPoseMode ? "ON" : "OFF")
@if (IsInitialPoseMode) { ✓ Click and drag on map to set pose } else { Enter coordinates below or turn on drag mode }
Apply Initial Pose @if (!string.IsNullOrEmpty(InitialPoseMessage)) { @InitialPoseMessage } }
Reset Error
@if (IsResetErrorExpanded) { Reset SLAM error state to clear previous trajectory state. This can help resolve mapping or localization issues. Reset SLAM Error @if (!string.IsNullOrEmpty(ResetErrorMessage)) { @ResetErrorMessage } }
Change Map Origin
@if (IsChangeOriginExpanded) { Re-anchors the map coordinate system. Enter the new origin as a pose (x, y, z + yaw) in the current map frame.
@if (IsApplyingChangeOrigin) { Applying... } else { Apply New Origin } @if (!string.IsNullOrEmpty(ChangeOriginMessage)) { @ChangeOriginMessage } }
@if (!string.IsNullOrEmpty(UploadMessage)) { @UploadMessage }
}
@if (ShowViewPanel) {
Robot Icon
Robot Footprint
Global Path Source: PlannerDataOutput.plan
Point count: @GlobalPathPointCount
Status: @(string.IsNullOrEmpty(GlobalPathStatus) ? "Idle" : GlobalPathStatus)
@if (IsGlobalPathLoading) { Refreshing... } else { Refresh Path }
Cost Map Source: PlannerDataOutput.costmap
Resolution: @(CostMapResolution > 0 ? $"{CostMapResolution:F3} m/cell" : "N/A")
Size: @CostMapSize
Status: @(string.IsNullOrEmpty(CostMapStatus) ? "Idle" : CostMapStatus)
@if (IsGlobalMapLoading) { Refreshing... } else { Refresh Cost Map }
LiDAR Display
full minimal
full minimal
full minimal
}
@if (ShowInformationPanel) {
Map Info
@if (IsMapInfoExpanded) { @if (_gridMapData != null) { Resolution: @_gridMapData.Resolution m/cell
Size: @_gridMapData.Width x @_gridMapData.Height cells
Origin: (@_gridMapData.Origin.X.ToString("F2"), @_gridMapData.Origin.Y.ToString("F2"), @_gridMapData.Origin.Z.ToString("F2"))
} else { No map data available } }
@if (_robotPose != null) {
Robot Pose
@if (IsRobotPoseExpanded) { X: @_robotPose.X.ToString("F3")m
Y: @_robotPose.Y.ToString("F3")m
Yaw: @((_robotPose.Yaw * 180.0 / Math.PI).ToString("F1"))°
}
}
Velocity
@if (IsVelocityExpanded) { OdomVel:
  Linear: @((_velocityData?.OdomLinearVel ?? 0.0).ToString("F3")) m/s
  Angular: @((_velocityData?.OdomAngularVel ?? 0.0).ToString("F3")) rad/s
CmdVel:
  Linear: @((_velocityData?.CmdLinearVel ?? 0.0).ToString("F3")) m/s
  Angular: @((_velocityData?.CmdAngularVel ?? 0.0).ToString("F3")) rad/s
OdomVel
CmdVel
Linear Velocity (m/s)
Angular Velocity (rad/s)
}
@if (_diagnosticsData != null) {
XLOC Diagnostics
@if (IsXlocDiagnosticsExpanded) { State: @_diagnosticsData.StateString
Active Map: @(_diagnosticsData.CurrentActiveMap ?? "(none)")
Reliability: @(_diagnosticsData.Reliability.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))%
Matching Score: @(_diagnosticsData.MatchingScore.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))%
Header Seq: @_diagnosticsData.HeaderSeq
Frame ID: @(_diagnosticsData.HeaderFrameId ?? "(empty)")
Timestamp: @_diagnosticsData.HeaderStampSec.@_diagnosticsData.HeaderStampNsec.ToString("D9")s
}
}
}
@code { private List 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 _velocityHistory = new(); private const int MaxHistoryPoints = 100; private DateTime _chartStartTime = DateTime.UtcNow; private System.Threading.Timer? _poseUpdateTimer; private System.Threading.Timer? _onlineMapUpdateTimer; private int _poseUpdateInProgress = 0; private int _onlineMapUpdateInProgress = 0; private bool _isDisposed = false; private DateTime _lastUiRefreshAt = DateTime.MinValue; private DateTime _lastPathCostMapUpdateAt = DateTime.MinValue; private const int UiRefreshIntervalMs = 400; private const int PathCostMapUpdateIntervalMs = 1500; private bool ShowFeaturePanel = false; private bool ShowInformationPanel = false; private bool ShowViewPanel = false; private DotNetObjectReference? _dotNetRef; /// /// Builds the URL for server-side calls into this same app. /// Uses 127.0.0.1 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. /// private Uri ClientApiUri(string pathAndQuery) { var p = pathAndQuery.StartsWith('/') ? pathAndQuery : "/" + pathAndQuery; var absolute = Navigation.ToAbsoluteUri(p); return new UriBuilder(absolute) { Host = "127.0.0.1" }.Uri; } // View toggles and data private bool ShowRobotIconLayer = true; private bool ShowRobotFootprintLayer = true; private bool ShowGlobalPathLayer = false; private bool ShowLocalPathLayer = true; private bool IsGlobalPathLoading = false; private string GlobalPathStatus = string.Empty; private int GlobalPathPointCount = 0; private DateTime _lastGlobalPathFetchAt = DateTime.MinValue; private int _globalPathFetchInProgress = 0; private bool IsLocalPathLoading = false; private string LocalPathStatus = string.Empty; private int LocalPathPointCount = 0; private DateTime _lastLocalPathFetchAt = DateTime.MinValue; private int _localPathFetchInProgress = 0; // Cost map related properties private bool ShowCostMapLayer = false; private bool IsGlobalMapLoading = false; private string CostMapStatus = string.Empty; private double CostMapResolution = 0; private string CostMapSize = "0x0"; private int _costMapFetchInProgress = 0; // LiDAR display options private const string LidarDisplayModeFull = "full"; private const string LidarDisplayModeMinimal = "minimal"; private bool ShowLidar1Layer = true; private bool ShowLidar2Layer = true; private bool ShowLidar3Layer = true; private string Lidar1DisplayMode = LidarDisplayModeMinimal; private string Lidar2DisplayMode = LidarDisplayModeMinimal; private string Lidar3DisplayMode = LidarDisplayModeMinimal; // Expand/Collapse states for feature panel sections private bool IsMappingControlExpanded = true; private bool IsMapSelectionExpanded = true; private bool IsLocalizationControlExpanded = true; private bool IsUpdateMapControlExpanded = true; private bool IsInitialPoseExpanded = true; private bool IsResetErrorExpanded = true; private bool IsChangeOriginExpanded = false; // Expand/Collapse states for information panel sections private bool IsMapInfoExpanded = true; private bool IsRobotPoseExpanded = true; private bool IsVelocityExpanded = true; private bool IsXlocDiagnosticsExpanded = true; // New fields for SLAM control private string NewMapName = ""; private bool IsMappingActive = false; private bool IsLocalizationActive = false; private bool IsUpdateMapActive = false; private bool IsActivatingMap = false; private bool IsDownloadingMapZip = false; private bool IsImportingMap = false; private string CurrentActiveMap = ""; private bool _isStoppingLocalization = false; // Flag to prevent state override when stopping // Helper properties for state-based button enabling/disabling // XlocState: 0=MAPPING, 1=LOCALIZATION, 2=PROCESSING, 3=READY, 4=ERROR private bool IsProcessing => _diagnosticsData != null && _diagnosticsData.XlocState == 2; private bool IsReady => _diagnosticsData != null && _diagnosticsData.XlocState == 3; private bool IsMappingState => _diagnosticsData != null && _diagnosticsData.XlocState == 0; private bool IsLocalizationState => _diagnosticsData != null && _diagnosticsData.XlocState == 1; // Button enable/disable conditions // Note: If _diagnosticsData is null, buttons are disabled (waiting for state) private bool CanStartMapping => _diagnosticsData != null && IsReady && !IsProcessing && !IsMappingActive; private bool CanStopMapping => _diagnosticsData != null && IsMappingState && IsMappingActive; private bool CanActivateMap => _diagnosticsData != null && IsReady && !IsProcessing && !string.IsNullOrEmpty(SelectedMapName) && !IsActivatingMap; private bool CanStartLocalization => _diagnosticsData != null && IsReady && !IsProcessing && !IsLocalizationActive && !string.IsNullOrEmpty(CurrentActiveMap); private bool CanStopLocalization => _diagnosticsData != null && IsLocalizationState && IsLocalizationActive; // Update map can only be started when localization is active private bool CanStartUpdateMap => _diagnosticsData != null && IsLocalizationState && IsLocalizationActive && !IsUpdateMapActive; private bool CanStopUpdateMap => IsUpdateMapActive; // Initial pose control private bool IsInitialPoseMode = false; private double InitialPoseX = 0.0; private double InitialPoseY = 0.0; private double InitialPoseZ = 0.0; private double InitialPoseRoll = 0.0; // degrees private double InitialPosePitch = 0.0; // degrees private double InitialPoseYaw = 0.0; // degrees private string InitialPoseMessage = ""; private bool InitialPoseSuccess = false; // Reset Error control private string ResetErrorMessage = ""; private bool ResetErrorSuccess = false; // Change Map Origin control private double ChangeOriginX = 0.0; private double ChangeOriginY = 0.0; private double ChangeOriginZ = 0.0; private double ChangeOriginYawDeg = 0.0; private string ChangeOriginMessage = ""; private bool ChangeOriginSuccess = false; private bool IsApplyingChangeOrigin = false; // Helper methods for styling private string GetInitialPoseModeBackgroundStyle() { var bgColor = IsInitialPoseMode ? "rgba(76, 175, 80, 0.1)" : "rgba(244, 67, 54, 0.1)"; return $"display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(15, 23, 42, 0.08); background: {bgColor}; margin-bottom: 12px;"; } private string GetInitialPoseModeTextStyle() { var color = IsInitialPoseMode ? "#4caf50" : "#f44336"; return $"font-weight: 500; color: {color} !important;"; } private async Task ToggleFeaturePanel() { ShowFeaturePanel = !ShowFeaturePanel; StateHasChanged(); await Task.Delay(100); // Wait for DOM update await ResizeCanvas(); } private async Task ToggleInformationPanel() { ShowInformationPanel = !ShowInformationPanel; StateHasChanged(); await Task.Delay(100); // Wait for DOM update await ResizeCanvas(); } private async Task ToggleViewPanel() { ShowViewPanel = !ShowViewPanel; StateHasChanged(); await Task.Delay(100); // Wait for DOM update await ResizeCanvas(); } private async Task OnGlobalPathVisibilityChanged(bool visible) { ShowGlobalPathLayer = visible; Logger.LogInformation("Global Path visibility changed to: {Visible}", visible); try { if (ShowGlobalPathLayer) { // First fetch path data, THEN enable visibility Logger.LogInformation("Fetching path data before enabling visibility..."); await UpdateGlobalPath(force: true); // Enable visibility after data is loaded await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setGlobalPathVisible", true); Logger.LogInformation("Path visibility enabled in renderer"); } else { // Disable visibility immediately await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setGlobalPathVisible", false); GlobalPathStatus = "Hidden"; Logger.LogInformation("Path visibility disabled"); await InvokeAsync(StateHasChanged); } } catch (Exception ex) { Logger.LogWarning(ex, "Error toggling global path visibility"); GlobalPathStatus = "Error toggling layer"; await InvokeAsync(StateHasChanged); } } private async Task OnRobotIconVisibilityChanged(bool visible) { ShowRobotIconLayer = visible; Logger.LogInformation("Robot icon visibility changed to: {Visible}", visible); try { await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotPoseVisible", visible); } catch (Exception ex) { Logger.LogWarning(ex, "Error toggling robot icon visibility"); } } private async Task OnRobotFootprintVisibilityChanged(bool visible) { ShowRobotFootprintLayer = visible; Logger.LogInformation("Robot footprint visibility changed to: {Visible}", visible); try { await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotFootprintVisible", visible); } catch (Exception ex) { Logger.LogWarning(ex, "Error toggling robot footprint visibility"); } } private static string NormalizeLidarDisplayMode(string? mode) { if (string.Equals(mode, LidarDisplayModeFull, StringComparison.OrdinalIgnoreCase)) { return LidarDisplayModeFull; } return LidarDisplayModeMinimal; } private async Task ApplyLidarDisplayOptionsAsync() { try { var options = new { lidar1 = new { visible = ShowLidar1Layer, mode = Lidar1DisplayMode }, lidar2 = new { visible = ShowLidar2Layer, mode = Lidar2DisplayMode }, lidar3 = new { visible = ShowLidar3Layer, mode = Lidar3DisplayMode } }; await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setLidarDisplayOptions", options); } catch (Exception ex) { Logger.LogWarning(ex, "Error applying LiDAR display options"); } } private async Task OnLidar1VisibilityChanged(bool visible) { ShowLidar1Layer = visible; await ApplyLidarDisplayOptionsAsync(); } private async Task OnLidar2VisibilityChanged(bool visible) { ShowLidar2Layer = visible; await ApplyLidarDisplayOptionsAsync(); } private async Task OnLidar3VisibilityChanged(bool visible) { ShowLidar3Layer = visible; await ApplyLidarDisplayOptionsAsync(); } private async Task OnLidar1DisplayModeChanged(string mode) { Lidar1DisplayMode = NormalizeLidarDisplayMode(mode); await ApplyLidarDisplayOptionsAsync(); } private async Task OnLidar2DisplayModeChanged(string mode) { Lidar2DisplayMode = NormalizeLidarDisplayMode(mode); await ApplyLidarDisplayOptionsAsync(); } private async Task OnLidar3DisplayModeChanged(string mode) { Lidar3DisplayMode = NormalizeLidarDisplayMode(mode); await ApplyLidarDisplayOptionsAsync(); } private async Task UpdateGlobalPath(bool force = false) { if (!ShowGlobalPathLayer) { Logger.LogDebug("UpdateGlobalPath: Skipped - ShowGlobalPathLayer is disabled"); return; } if (!force && DateTime.UtcNow - _lastGlobalPathFetchAt < TimeSpan.FromMilliseconds(600)) { return; } if (System.Threading.Interlocked.CompareExchange(ref _globalPathFetchInProgress, 1, 0) != 0) { Logger.LogDebug("UpdateGlobalPath: Skipped - Already in progress"); return; } IsGlobalPathLoading = true; @* Logger.LogInformation("UpdateGlobalPath: Fetching path data from /api/navigation/global_data/path"); *@ try { var response = await Http.GetAsync(ClientApiUri("/api/navigation/global_data/path")); if (!response.IsSuccessStatusCode) { GlobalPathStatus = $"HTTP {(int)response.StatusCode}"; Logger.LogWarning("UpdateGlobalPath: HTTP error {StatusCode}", response.StatusCode); return; } var json = await response.Content.ReadAsStringAsync(); var result = JsonSerializer.Deserialize(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(); 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(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(); 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(json); if (!result.TryGetProperty("status", out var status)) { CostMapStatus = "Invalid response"; return; } var statusStr = status.GetString(); if (statusStr == "no_data") { // Navigation data not available yet - this is normal, don't log warning CostMapStatus = "Waiting for data..."; return; } if (statusStr != "success") { CostMapStatus = "No cost map data"; return; } if (!result.TryGetProperty("costmap", out var costmapElement)) { CostMapStatus = "No costmap payload"; @* Logger.LogWarning("UpdateCostMap: No costmap payload in response"); *@ return; } var hasFullMap = result.TryGetProperty("hasFullMap", out var hasFullMapProp) && hasFullMapProp.GetBoolean(); var isCostmapUpdated = result.TryGetProperty("isCostmapUpdated", out var isCostmapUpdatedProp) && isCostmapUpdatedProp.GetBoolean(); // Extract cost map metadata var frameId = costmapElement.TryGetProperty("frameId", out var frameProp) ? frameProp.GetString() : "map"; var width = costmapElement.TryGetProperty("width", out var widthProp) ? widthProp.GetInt32() : 0; var height = costmapElement.TryGetProperty("height", out var heightProp) ? heightProp.GetInt32() : 0; var resolution = costmapElement.TryGetProperty("resolution", out var resProp) ? resProp.GetDouble() : 0; var dataSize = costmapElement.TryGetProperty("dataSize", out var dataSizeProp) ? dataSizeProp.GetInt32() : 0; var dataBase64 = costmapElement.TryGetProperty("data", out var dataProp) ? dataProp.GetString() : string.Empty; // Extract origin (position of cost map in map frame) double originX = 0, originY = 0, originTheta = 0; if (costmapElement.TryGetProperty("origin", out var originElement)) { originX = originElement.TryGetProperty("x", out var oxProp) ? oxProp.GetDouble() : 0; originY = originElement.TryGetProperty("y", out var oyProp) ? oyProp.GetDouble() : 0; originTheta = originElement.TryGetProperty("theta", out var otProp) ? otProp.GetDouble() : 0; } // Extract cost map update patch string updateFrameId = frameId ?? "map"; int updateX = 0, updateY = 0, updateWidth = 0, updateHeight = 0, updateDataSize = 0; string? updateDataBase64 = null; if (result.TryGetProperty("costmapUpdate", out var costmapUpdateElement)) { updateFrameId = costmapUpdateElement.TryGetProperty("frameId", out var updateFrameProp) ? updateFrameProp.GetString() ?? updateFrameId : updateFrameId; updateX = costmapUpdateElement.TryGetProperty("x", out var updateXProp) ? updateXProp.GetInt32() : 0; updateY = costmapUpdateElement.TryGetProperty("y", out var updateYProp) ? updateYProp.GetInt32() : 0; updateWidth = costmapUpdateElement.TryGetProperty("width", out var updateWidthProp) ? updateWidthProp.GetInt32() : 0; updateHeight = costmapUpdateElement.TryGetProperty("height", out var updateHeightProp) ? updateHeightProp.GetInt32() : 0; updateDataSize = costmapUpdateElement.TryGetProperty("dataSize", out var updateDataSizeProp) ? updateDataSizeProp.GetInt32() : 0; updateDataBase64 = costmapUpdateElement.TryGetProperty("data", out var updateDataProp) ? updateDataProp.GetString() : null; } // Extract odometry context (used to move local costmap window) double odomX = 0, odomY = 0, odomYaw = 0; if (result.TryGetProperty("odometry", out var odometryElement)) { odomX = odometryElement.TryGetProperty("x", out var odomXProp) ? odomXProp.GetDouble() : 0; odomY = odometryElement.TryGetProperty("y", out var odomYProp) ? odomYProp.GetDouble() : 0; odomYaw = odometryElement.TryGetProperty("yaw", out var odomYawProp) ? odomYawProp.GetDouble() : 0; } CostMapResolution = resolution; CostMapSize = $"{width}x{height}"; CostMapStatus = hasFullMap ? "Full map" : (isCostmapUpdated ? "Patched" : "Empty costmap"); @* Logger.LogInformation("UpdateCostMap: Full={HasFullMap}, Updated={IsCostmapUpdated}, Width={Width}, Height={Height}, Resolution={Resolution:F3}, DataSize={DataSize}, UpdateDataSize={UpdateDataSize}", hasFullMap, isCostmapUpdated, width, height, resolution, dataSize, updateDataSize); *@ // Send cost map data to JavaScript renderer if ((hasFullMap && !string.IsNullOrEmpty(dataBase64) && width > 0 && height > 0) || (isCostmapUpdated && !string.IsNullOrEmpty(updateDataBase64) && updateWidth > 0 && updateHeight > 0)) { try { await JSRuntime.InvokeVoidAsync( "xlocMapRenderer.setCostMapData", new { frameId = frameId, width = width, height = height, resolution = resolution, originX = originX, originY = originY, originTheta = originTheta, dataBase64 = hasFullMap ? dataBase64 : null, hasFullMap = hasFullMap, isCostmapUpdated = isCostmapUpdated, localizationActive = IsLocalizationActive, matchingScore = _diagnosticsData?.MatchingScore ?? -1.0, xlocPose = _robotPose == null ? null : new { x = _robotPose.X, y = _robotPose.Y, yaw = _robotPose.Yaw }, odometry = new { x = odomX, y = odomY, yaw = odomYaw }, costmapUpdate = new { frameId = updateFrameId, x = updateX, y = updateY, width = updateWidth, height = updateHeight, dataBase64 = isCostmapUpdated ? updateDataBase64 : null } }); @* Logger.LogInformation("UpdateCostMap: Cost map data sent to JavaScript renderer"); *@ } catch (Exception jsEx) { Logger.LogError(jsEx, "UpdateCostMap: Error sending cost map data to JavaScript"); CostMapStatus = "JS error"; throw; } } } catch (HttpRequestException ex) { CostMapStatus = "Connection error"; Logger.LogError(ex, "UpdateCostMap: HTTP connection error"); } catch (Exception ex) { Logger.LogWarning(ex, "Error updating cost map"); CostMapStatus = "Update error"; } finally { IsGlobalMapLoading = false; System.Threading.Interlocked.Exchange(ref _costMapFetchInProgress, 0); await InvokeAsync(StateHasChanged); } } private async Task ResizeCanvas() { try { await JSRuntime.InvokeVoidAsync("xlocMapRenderer.resize"); } catch (Microsoft.JSInterop.JSDisconnectedException) { // Circuit disconnected, ignore } catch (Exception ex) { Logger.LogDebug(ex, "Error resizing canvas"); } } private async Task OnInitialPoseModeChanged() { // Note: IsInitialPoseMode is already updated by @bind-Value try { Logger.LogInformation("Initial pose mode changed to: {Mode}", IsInitialPoseMode ? "ON" : "OFF"); if (IsInitialPoseMode) { // Initialize from current robot pose if available, otherwise use origin if (_robotPose != null) { InitialPoseX = _robotPose.X; InitialPoseY = _robotPose.Y; InitialPoseYaw = _robotPose.Yaw * 180.0 / Math.PI; // Convert rad to deg Logger.LogInformation("Initial pose mode ON - Using current robot pose: ({X}, {Y}, {Yaw}°)", InitialPoseX, InitialPoseY, InitialPoseYaw); } else { // Default to map origin (0, 0) if no robot pose InitialPoseX = 0.0; InitialPoseY = 0.0; InitialPoseYaw = 0.0; Logger.LogInformation("Initial pose mode ON - Using default pose at origin: ({X}, {Y}, {Yaw}°)", InitialPoseX, InitialPoseY, InitialPoseYaw); } // Set initial pose in JavaScript renderer so arrow is visible await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setInitialPose", new { x = InitialPoseX, y = InitialPoseY, yaw = InitialPoseYaw * Math.PI / 180.0 // Convert deg to rad for JS }); Logger.LogInformation("✓ Drag Arrow Mode enabled - Click and drag on map to set robot pose"); } else { Logger.LogInformation("Initial pose mode OFF - Drag Arrow Mode disabled"); } // Enable/disable drag mode in JavaScript await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setInitialPoseMode", IsInitialPoseMode); await InvokeAsync(StateHasChanged); } catch (Exception ex) { Logger.LogError(ex, "Error toggling initial pose mode: {Message}", ex.Message); InitialPoseMessage = $"Error: {ex.Message}"; InitialPoseSuccess = false; // Revert the toggle if there was an error IsInitialPoseMode = !IsInitialPoseMode; await InvokeAsync(StateHasChanged); } } [JSInvokable] public async Task OnInitialPoseSelected(InitialPoseInput pose) { try { Logger.LogInformation("Initial pose selected from drag: X={X:F3}m, Y={Y:F3}m, Yaw={Yaw:F3}rad ({YawDeg:F1}°)", pose.X, pose.Y, pose.Yaw, pose.Yaw * 180.0 / Math.PI); // Update UI fields with the dragged pose InitialPoseX = pose.X; InitialPoseY = pose.Y; InitialPoseYaw = pose.Yaw * 180.0 / Math.PI; // Convert rad to deg // Keep Z, Roll, Pitch unchanged (use current values or 0) // For 2D localization, these are typically 0 await InvokeAsync(StateHasChanged); // Automatically apply the pose to the robot await ApplyInitialPose(); } catch (Exception ex) { Logger.LogError(ex, "Error handling initial pose selection from drag"); InitialPoseMessage = $"Error: {ex.Message}"; InitialPoseSuccess = false; await InvokeAsync(StateHasChanged); } } private async Task ApplyInitialPose() { // Validate state: Cannot perform when PROCESSING if (IsProcessing) { var currentState = _diagnosticsData?.StateString ?? "Unknown"; InitialPoseMessage = $"Cannot apply initial pose: XLOC is in PROCESSING state. Current state: {currentState}"; InitialPoseSuccess = false; StateHasChanged(); Logger.LogWarning("Cannot apply initial pose: XLOC is PROCESSING. Current: {State}", currentState); return; } InitialPoseMessage = ""; InitialPoseSuccess = false; // CRITICAL: Set flag to prevent map reload during initial pose application _isApplyingInitialPose = true; try { // ==================================================================== // CRITICAL: Setting initial pose ONLY changes ROBOT position in map frame // Map frame origin (0,0,0) is ABSOLUTELY FIXED and NEVER changes! // ==================================================================== // Save current state BEFORE applying initial pose var lockedOriginBefore = _lockedMapOrigin; var gridMapOriginBefore = _gridMapData?.Origin; // CRITICAL: Get current robot pose from XLOC BEFORE setting initial pose var currentPoseBefore = await Http.GetAsync(ClientApiUri("/api/xloc/pose/current2d")); (double x, double y, double yaw)? poseBefore = null; if (currentPoseBefore.IsSuccessStatusCode) { var json = await currentPoseBefore.Content.ReadAsStringAsync(); var result = JsonSerializer.Deserialize(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("xlocMapRenderer.getViewState"); var viewStateBefore = JsonSerializer.Deserialize(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(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("xlocMapRenderer.getViewState"); var viewStateAfter = JsonSerializer.Deserialize(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(result); if (response.IsSuccessStatusCode && json.TryGetProperty("status", out var status) && status.GetString() == "success") { ResetErrorMessage = "✅ SLAM error reset successfully! Previous trajectory state has been cleared."; ResetErrorSuccess = true; Logger.LogInformation("SLAM error reset successfully"); } else { var message = json.TryGetProperty("message", out var msg) ? msg.GetString() : "Unknown error"; ResetErrorMessage = $"❌ Failed to reset SLAM error: {message}"; ResetErrorSuccess = false; Logger.LogWarning("Failed to reset SLAM error: {Message}", message); } } catch (Exception ex) { ResetErrorMessage = $"❌ Error resetting SLAM error: {ex.Message}"; ResetErrorSuccess = false; Logger.LogError(ex, "Error resetting SLAM error"); } StateHasChanged(); } private async Task ApplyChangeMapOrigin() { if (IsProcessing) { var currentState = _diagnosticsData?.StateString ?? "Unknown"; ChangeOriginMessage = $"Cannot change map origin: XLOC is in PROCESSING state. Current state: {currentState}"; ChangeOriginSuccess = false; StateHasChanged(); return; } IsApplyingChangeOrigin = true; ChangeOriginMessage = "Applying new map origin..."; ChangeOriginSuccess = false; StateHasChanged(); try { var yawRad = ChangeOriginYawDeg * Math.PI / 180.0; // Call the native XLOC re-anchor directly via the integration service. var ok = XlocService.ChangeMapOrigin( ChangeOriginX, ChangeOriginY, ChangeOriginZ, roll: 0.0, pitch: 0.0, yaw: yawRad); if (!ok) { ChangeOriginMessage = "❌ Failed to change map origin (see server log)."; ChangeOriginSuccess = false; Logger.LogWarning("ChangeMapOrigin returned false"); return; } // Drop the JS origin lock so LoadGridMap can pick up the new origin that native // XLOC has now written into the map's YAML (via XlocIntegrationService). try { await JSRuntime.InvokeVoidAsync("xlocMapRenderer.clearMapOriginLock"); } catch (Exception jsEx) { Logger.LogWarning(jsEx, "Failed to clear JS origin lock after ChangeMapOrigin"); } _lockedMapOrigin = null; await LoadGridMap(); ChangeOriginMessage = $"✅ Map origin updated to ({ChangeOriginX:F3}, {ChangeOriginY:F3}, {ChangeOriginZ:F3}) yaw={ChangeOriginYawDeg:F1}°"; ChangeOriginSuccess = true; Logger.LogInformation("Map origin changed successfully to ({X}, {Y}, {Z}) yaw={YawDeg}°", ChangeOriginX, ChangeOriginY, ChangeOriginZ, ChangeOriginYawDeg); } catch (Exception ex) { ChangeOriginMessage = $"❌ Error changing map origin: {ex.Message}"; ChangeOriginSuccess = false; Logger.LogError(ex, "Error changing map origin"); } finally { IsApplyingChangeOrigin = false; StateHasChanged(); } } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { // Initialize map renderer try { await JSRuntime.InvokeVoidAsync("xlocMapRenderer.init", MapCanvas); _dotNetRef = DotNetObjectReference.Create(this); await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setDotNetHelper", _dotNetRef); await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotPoseVisible", ShowRobotIconLayer); await ApplyLidarDisplayOptionsAsync(); await TryLoadRobotFootprintAsync(); Logger.LogInformation("Map renderer initialized"); // Initialize velocity charts - wait a bit for canvas to be ready await Task.Delay(100); try { await JSRuntime.InvokeVoidAsync("velocityChartRenderer.initLinearVelocityChart", LinearVelocityChartCanvas); await JSRuntime.InvokeVoidAsync("velocityChartRenderer.initAngularVelocityChart", AngularVelocityChartCanvas); } catch (Exception ex) { Logger.LogWarning(ex, "Failed to initialize velocity charts, will retry on first update"); } // Restore mapping and localization state from localStorage await RestoreMappingState(); await RestoreLocalizationState(); // Load available maps await LoadAvailableMaps(); // Restore selected map from localStorage var restoredActiveMap = await RestoreActiveMap(); if (!string.IsNullOrEmpty(restoredActiveMap) && AvailableMaps.Contains(restoredActiveMap)) { // Use the restored active map as both selected and active SelectedMapName = restoredActiveMap; CurrentActiveMap = restoredActiveMap; // Save to ensure consistency await SaveSelectedMap(); // Load the previously active map await LoadSelectedMap(); } else { // Fallback: Try to restore selected map if active map not available await RestoreSelectedMap(); if (!string.IsNullOrEmpty(SelectedMapName) && AvailableMaps.Contains(SelectedMapName)) { await LoadSelectedMap(); } else { // Final fallback: Auto-load grid map on startup await LoadGridMap(); } } } catch (Exception ex) { Logger.LogError(ex, "Failed to initialize map renderer"); } // Start timer to update robot pose and laser scan // 100ms = 10Hz update rate for smooth real-time visualization _poseUpdateTimer = new System.Threading.Timer(async _ => await UpdateRobotPose(), null, TimeSpan.Zero, TimeSpan.FromMilliseconds(100)); // Auto-start localization when READY: see XlocAutoLocalizationHostedService (backend), not here. } } private async Task TryLoadRobotFootprintAsync() { try { var loaded = await LoadRobotFootprintAsync(Http, ClientApiUri("/api/navigation/robot_footprint")); if (loaded) { return; } Logger.LogDebug("Robot footprint endpoint returned no usable footprint payload"); } catch (HttpRequestException ex) when (IsUntrustedRootSslError(ex) && ShouldAllowInsecureSslFallback()) { Logger.LogWarning("Robot footprint request failed due to untrusted certificate chain. Retrying with local-network insecure SSL fallback."); try { using var insecureHandler = new HttpClientHandler { ServerCertificateCustomValidationCallback = static (_, _, _, _) => true }; using var insecureClient = new HttpClient(insecureHandler) { BaseAddress = new UriBuilder(Navigation.ToAbsoluteUri("/")) { Host = "127.0.0.1" }.Uri }; var loaded = await LoadRobotFootprintAsync(insecureClient, ClientApiUri("/api/navigation/robot_footprint")); if (loaded) { Logger.LogInformation("Robot footprint loaded using insecure SSL fallback for local/private endpoint"); return; } Logger.LogWarning("Robot footprint fallback request completed but payload did not contain footprint data"); } catch (Exception fallbackEx) { Logger.LogWarning(fallbackEx, "Failed to load robot footprint using insecure SSL fallback"); } } catch (Exception ex) { Logger.LogWarning(ex, "Failed to load robot footprint"); } } private async Task LoadRobotFootprintAsync(HttpClient client, Uri requestUri) { var footprintResponse = await client.GetAsync(requestUri); if (!footprintResponse.IsSuccessStatusCode) { return false; } var footprintJson = await footprintResponse.Content.ReadAsStringAsync(); using var footprintDoc = JsonDocument.Parse(footprintJson); if (!footprintDoc.RootElement.TryGetProperty("footprint", out var footprint)) { return false; } await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotFootprint", footprint.GetRawText()); await JSRuntime.InvokeVoidAsync("xlocMapRenderer.setRobotFootprintVisible", ShowRobotFootprintLayer); Logger.LogInformation("Robot footprint loaded"); return true; } private static bool IsUntrustedRootSslError(HttpRequestException ex) { return ex.InnerException is AuthenticationException authEx && authEx.Message.Contains("UntrustedRoot", StringComparison.OrdinalIgnoreCase); } private bool ShouldAllowInsecureSslFallback() { var baseUri = Navigation.ToAbsoluteUri("/"); if (!string.Equals(baseUri.Scheme, "https", StringComparison.OrdinalIgnoreCase)) { return false; } // Robotics / MOXA / self-signed dev certs: allow retry path for any HTTPS host (loopback, LAN, NAT, public IP). return true; } private async Task LoadAvailableMaps() { try { // Try API first try { var response = await Http.GetAsync(ClientApiUri("/api/xloc/maps/list")); if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(); var result = JsonSerializer.Deserialize(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(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(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(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(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(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(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(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(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(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 { { "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(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(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(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(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(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(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 }; } 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(" 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); } } /// /// Render map with overlay: static map as base, online map as overlay (for update map mode) /// 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); } } /// /// 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 /// 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); } } /// /// Start timer to update online map during mapping or update map mode (throttled to reduce load). /// 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)"); } } /// /// Stop the online map update timer /// private void StopOnlineMapUpdateTimer() { if (_onlineMapUpdateTimer != null) { _onlineMapUpdateTimer.Dispose(); _onlineMapUpdateTimer = null; Logger.LogInformation("Online map update timer stopped"); } } /// /// 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() /// 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(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("robotnet.localStorageHelper.getItem", "xloc_mapping_active"); var mapName = await JSRuntime.InvokeAsync("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("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("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 RestoreActiveMap() { try { var savedActiveMap = await JSRuntime.InvokeAsync("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(); } 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(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; } } }