Initial commit
This commit is contained in:
@@ -0,0 +1,874 @@
|
||||
using RobotNet10.FleetManager.Client;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.Robot;
|
||||
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
|
||||
using RobotNet10.MapEditor.Services.API;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Edge;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Layout;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
|
||||
namespace RobotNet10.FleetManager.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Viewport state for SVG canvas
|
||||
/// </summary>
|
||||
public class ViewportState
|
||||
{
|
||||
public double ViewBoxX { get; set; }
|
||||
public double ViewBoxY { get; set; }
|
||||
public double ViewBoxWidth { get; set; }
|
||||
public double ViewBoxHeight { get; set; }
|
||||
public double ZoomLevel { get; set; } = 1.0;
|
||||
|
||||
public string ToViewBoxString() =>
|
||||
$"{ViewBoxX:F2} {ViewBoxY:F2} {ViewBoxWidth:F2} {ViewBoxHeight:F2}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Robot data for monitoring
|
||||
/// </summary>
|
||||
public class RobotMonitorData
|
||||
{
|
||||
public string RobotId { get; set; } = string.Empty;
|
||||
public Guid? ModelId { get; set; }
|
||||
public RobotModelDto? Model { get; set; } // Robot model info (Length, Width, etc.)
|
||||
public string? ModelImageBase64 { get; set; }
|
||||
public DateTime LastUpdateTime { get; set; }
|
||||
public RobotMonitorBoardcastData? Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State management for RobotMonitor page
|
||||
/// </summary>
|
||||
public class RobotMonitorState(
|
||||
MapManagerApiService mapApiService,
|
||||
RobotApiService robotApiService,
|
||||
RobotModelApiService robotModelApiService,
|
||||
RobotStateHubClient? hubClient = null)
|
||||
{
|
||||
// ===== DATA =====
|
||||
public List<LayoutDto> Layouts { get; private set; } = [];
|
||||
public List<LayoutVersionDto> AvailableVersions { get; private set; } = [];
|
||||
public List<LayoutLevelDto> AvailableLevels { get; private set; } = [];
|
||||
public List<RobotDto> AvailableRobots { get; private set; } = [];
|
||||
|
||||
public Guid? SelectedLayoutId { get; set; }
|
||||
public Guid? SelectedVersionId { get; set; }
|
||||
public Guid? SelectedLevelId { get; set; }
|
||||
public LayoutLevelDto? Level { get; private set; }
|
||||
public List<NodeDto> Nodes { get; private set; } = [];
|
||||
public List<EdgeDto> Edges { get; private set; } = [];
|
||||
public byte[]? BackgroundImage { get; private set; }
|
||||
|
||||
// ===== ROBOTS =====
|
||||
public Dictionary<string, RobotMonitorData> Robots { get; private set; } = [];
|
||||
public string? SelectedRobotId { get; set; }
|
||||
|
||||
// ===== DISPLAY OPTIONS =====
|
||||
public bool ShowGrid { get; set; } = false;
|
||||
public bool ShowBackgroundImage { get; set; } = true;
|
||||
public bool ShowPath { get; set; } = false;
|
||||
public bool ShowName { get; set; } = true;
|
||||
public bool FollowRobot { get; set; } = false;
|
||||
public bool RobotInfoPanelExpanded { get; set; } = false;
|
||||
|
||||
// ===== VIEWPORT =====
|
||||
public ViewportState Viewport { get; } = new();
|
||||
|
||||
// ===== UI STATE =====
|
||||
public bool IsLoading { get; private set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public bool IsMonitorDeactivated { get; private set; } = false;
|
||||
|
||||
// ===== EVENTS =====
|
||||
public event Action? OnDataChanged;
|
||||
public event Action? OnStateChanged;
|
||||
|
||||
// ===== DEPENDENCIES =====
|
||||
private readonly MapManagerApiService _mapApiService = mapApiService;
|
||||
private readonly RobotApiService _robotApiService = robotApiService;
|
||||
private readonly RobotModelApiService _robotModelApiService = robotModelApiService;
|
||||
private readonly RobotStateHubClient? _hubClient = hubClient;
|
||||
|
||||
// ===== ROBOT MODEL CACHE =====
|
||||
private readonly Dictionary<Guid, string> _robotModelImageCache = [];
|
||||
private readonly Dictionary<Guid, RobotModelDto> _robotModelCache = []; // modelId -> RobotModelDto
|
||||
private readonly Dictionary<string, Guid> _robotModelIdCache = []; // robotId -> modelId
|
||||
|
||||
// ===== TIMEOUT CONFIGURATION =====
|
||||
private const int RobotTimeoutSeconds = 10; // Remove robots after 10 seconds of no updates
|
||||
|
||||
/// <summary>
|
||||
/// Initialize monitor state
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Load layouts
|
||||
await LoadLayoutsAsync();
|
||||
|
||||
// Load robots and their models
|
||||
await LoadRobotsAsync();
|
||||
|
||||
// Connect SignalR if available
|
||||
if (_hubClient != null)
|
||||
{
|
||||
await InitializeSignalRAsync();
|
||||
|
||||
// Register OnMonitorDeactivated event handler
|
||||
_hubClient.OnMonitorDeactivated += HandleMonitorDeactivated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Failed to initialize: {ex.Message}";
|
||||
// Log error for debugging
|
||||
System.Diagnostics.Debug.WriteLine($"RobotMonitor initialization error: {ex}");
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load all layouts
|
||||
/// </summary>
|
||||
public async Task LoadLayoutsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var allLayouts = await _mapApiService.SearchLayoutsAsync();
|
||||
// Only get active layouts
|
||||
Layouts = [.. allLayouts.Where(l => l.IsActive)];
|
||||
ErrorMessage = null; // Clear any previous errors
|
||||
|
||||
// Auto-select first layout, last version, last level
|
||||
if (Layouts.Count > 0)
|
||||
{
|
||||
var firstLayout = Layouts[0];
|
||||
SelectedLayoutId = firstLayout.Id;
|
||||
|
||||
// Load versions for first layout
|
||||
AvailableVersions = await _mapApiService.GetVersionsAsync(firstLayout.Id);
|
||||
|
||||
if (AvailableVersions.Count > 0)
|
||||
{
|
||||
var lastVersion = AvailableVersions.Last();
|
||||
SelectedVersionId = lastVersion.Id;
|
||||
|
||||
// Load levels for last version
|
||||
AvailableLevels = await _mapApiService.GetLevelsAsync(lastVersion.Id);
|
||||
|
||||
if (AvailableLevels.Count > 0)
|
||||
{
|
||||
var lastLevel = AvailableLevels.Last();
|
||||
SelectedLevelId = lastLevel.Id;
|
||||
|
||||
// Load layout data for last level
|
||||
await LoadLayoutDataAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Build detailed error message
|
||||
var errorDetails = new System.Text.StringBuilder();
|
||||
errorDetails.AppendLine($"Failed to load layouts: {ex.Message}");
|
||||
|
||||
// Add inner exception details if available
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
errorDetails.AppendLine($"Inner exception: {ex.InnerException.Message}");
|
||||
}
|
||||
|
||||
// Add stack trace for debugging (first few lines only)
|
||||
if (ex.StackTrace != null)
|
||||
{
|
||||
var stackLines = ex.StackTrace.Split('\n').Take(3);
|
||||
errorDetails.AppendLine($"Stack trace: {string.Join(" ", stackLines)}");
|
||||
}
|
||||
|
||||
ErrorMessage = errorDetails.ToString().Trim();
|
||||
Layouts = [];
|
||||
}
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle layout selection change
|
||||
/// </summary>
|
||||
public async Task OnLayoutSelectedAsync(Guid? layoutId)
|
||||
{
|
||||
SelectedLayoutId = layoutId;
|
||||
SelectedVersionId = null;
|
||||
SelectedLevelId = null;
|
||||
AvailableVersions = [];
|
||||
AvailableLevels = [];
|
||||
|
||||
if (layoutId.HasValue)
|
||||
{
|
||||
// Load versions for selected layout
|
||||
try
|
||||
{
|
||||
AvailableVersions = await _mapApiService.GetVersionsAsync(layoutId.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "load versions");
|
||||
ErrorMessage = errorDetails;
|
||||
AvailableVersions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clear layout data
|
||||
await LoadLayoutDataAsync();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle version selection change
|
||||
/// </summary>
|
||||
public async Task OnVersionSelectedAsync(Guid? versionId)
|
||||
{
|
||||
SelectedVersionId = versionId;
|
||||
SelectedLevelId = null;
|
||||
AvailableLevels = [];
|
||||
|
||||
if (versionId.HasValue)
|
||||
{
|
||||
// Load levels for selected version
|
||||
try
|
||||
{
|
||||
AvailableLevels = await _mapApiService.GetLevelsAsync(versionId.Value);
|
||||
AvailableLevels = [.. AvailableLevels.OrderBy(l => l.LevelOrder)];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "load levels");
|
||||
ErrorMessage = errorDetails;
|
||||
AvailableLevels = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clear layout data
|
||||
await LoadLayoutDataAsync();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle level selection change
|
||||
/// </summary>
|
||||
public async Task OnLevelSelectedAsync(Guid? levelId)
|
||||
{
|
||||
// Unsubscribe from old level if exists
|
||||
if (SelectedLevelId.HasValue && _hubClient != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hubClient.UnsubscribeFromLevelForMonitorAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unsubscribe errors
|
||||
}
|
||||
}
|
||||
|
||||
SelectedLevelId = levelId;
|
||||
await LoadLayoutDataAsync();
|
||||
|
||||
// Subscribe to new level
|
||||
if (SelectedLevelId.HasValue && _hubClient != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hubClient.SubscribeToLevelForMonitorAsync(SelectedLevelId.Value);
|
||||
IsMonitorDeactivated = false; // Reset deactivated state when subscribing
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore subscription errors
|
||||
}
|
||||
}
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle robot selection change (from dropdown)
|
||||
/// </summary>
|
||||
public void OnRobotSelected(string? robotId)
|
||||
{
|
||||
SelectRobot(robotId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load layout data for selected level
|
||||
/// </summary>
|
||||
public async Task LoadLayoutDataAsync()
|
||||
{
|
||||
if (!SelectedLevelId.HasValue)
|
||||
{
|
||||
Level = null;
|
||||
Nodes = [];
|
||||
Edges = [];
|
||||
BackgroundImage = null;
|
||||
NotifyStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Load level info
|
||||
Level = await _mapApiService.GetLevelAsync(SelectedLevelId.Value);
|
||||
|
||||
// Load layout data (nodes, edges)
|
||||
var layoutData = await _mapApiService.GetLayoutDataAsync(SelectedLevelId.Value);
|
||||
Nodes = layoutData.Nodes?.ToList() ?? [];
|
||||
Edges = layoutData.Edges?.ToList() ?? [];
|
||||
|
||||
// Load background image
|
||||
try
|
||||
{
|
||||
BackgroundImage = await _mapApiService.GetLayoutImageAsync(SelectedLevelId.Value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
BackgroundImage = null;
|
||||
}
|
||||
|
||||
// Initialize viewport
|
||||
InitializeViewport();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "load layout data");
|
||||
ErrorMessage = errorDetails;
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load robots from API
|
||||
/// </summary>
|
||||
private async Task LoadRobotsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
AvailableRobots = await _robotApiService.GetAllAsync();
|
||||
|
||||
// Cache robot model IDs
|
||||
foreach (var robot in AvailableRobots)
|
||||
{
|
||||
if (robot.ModelId != Guid.Empty)
|
||||
{
|
||||
_robotModelIdCache[robot.RobotId] = robot.ModelId;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "load robots");
|
||||
ErrorMessage = errorDetails;
|
||||
AvailableRobots = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize SignalR connection
|
||||
/// </summary>
|
||||
private async Task InitializeSignalRAsync()
|
||||
{
|
||||
if (_hubClient == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Register event handlers
|
||||
_hubClient.OnMonitorBoardcastUpdate += HandleStateUpdate;
|
||||
//_hubClient.OnVisualizationUpdate += HandleVisualizationUpdate;
|
||||
_hubClient.OnConnectionError += HandleConnectionError;
|
||||
// Connect
|
||||
await _hubClient.ConnectAsync();
|
||||
|
||||
// Subscribe to all robots
|
||||
// Subscribe to level will be done in OnLevelSelectedAsync when level is selected
|
||||
if (SelectedLevelId.HasValue && _hubClient != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hubClient.SubscribeToLevelForMonitorAsync(SelectedLevelId.Value);
|
||||
IsMonitorDeactivated = false; // Reset deactivated state when subscribing
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore subscription errors
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorDetails = GetDetailedErrorMessage(ex, "connect SignalR");
|
||||
ErrorMessage = errorDetails;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle monitor deactivated event (when connection is evicted due to max connections)
|
||||
/// </summary>
|
||||
private void HandleMonitorDeactivated()
|
||||
{
|
||||
IsMonitorDeactivated = true;
|
||||
ErrorMessage = "Monitor connection deactivated: Maximum 5 connections per level reached. Another connection has taken your place.";
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle SignalR connection error
|
||||
/// </summary>
|
||||
private void HandleConnectionError(string error)
|
||||
{
|
||||
ErrorMessage = $"SignalR connection error: {error}";
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize viewport to fit the image bounds
|
||||
/// </summary>
|
||||
private void InitializeViewport()
|
||||
{
|
||||
if (Level?.EditorSettings != null)
|
||||
{
|
||||
var settings = Level.EditorSettings;
|
||||
|
||||
// Calculate physical dimensions
|
||||
double physicalWidth = (settings.ImageWidth ?? 1000) * settings.Resolution;
|
||||
double physicalHeight = (settings.ImageHeight ?? 500) * settings.Resolution;
|
||||
|
||||
// Set viewport to fit image
|
||||
Viewport.ViewBoxX = 0;
|
||||
Viewport.ViewBoxY = 0;
|
||||
Viewport.ViewBoxWidth = physicalWidth;
|
||||
Viewport.ViewBoxHeight = physicalHeight;
|
||||
Viewport.ZoomLevel = 1.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default viewport
|
||||
Viewport.ViewBoxX = 0;
|
||||
Viewport.ViewBoxY = 0;
|
||||
Viewport.ViewBoxWidth = 50;
|
||||
Viewport.ViewBoxHeight = 30;
|
||||
Viewport.ZoomLevel = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SIGNALR HANDLERS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Handle StateMsg update from SignalR
|
||||
/// </summary>
|
||||
private void HandleStateUpdate(RobotMonitorBoardcastData state)
|
||||
{
|
||||
var robotId = state.RobotId;
|
||||
|
||||
// Get or create robot data
|
||||
if (!Robots.TryGetValue(robotId, out var robotData))
|
||||
{
|
||||
robotData = new RobotMonitorData
|
||||
{
|
||||
RobotId = robotId,
|
||||
ModelId = _robotModelIdCache.TryGetValue(robotId, out var modelId) ? modelId : null
|
||||
};
|
||||
Robots[robotId] = robotData;
|
||||
|
||||
// Load robot model image asynchronously
|
||||
_ = LoadRobotModelImageAsync(robotData);
|
||||
}
|
||||
|
||||
// Update position and state
|
||||
robotData.Data = state;
|
||||
robotData.LastUpdateTime = DateTime.UtcNow;
|
||||
|
||||
// If FollowRobot and this is selected robot, update viewport
|
||||
if (FollowRobot && SelectedRobotId == robotId)
|
||||
{
|
||||
FocusOnRobot(robotId);
|
||||
}
|
||||
|
||||
// Remove inactive robots periodically (check every update)
|
||||
RemoveInactiveRobots();
|
||||
|
||||
OnDataChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load robot model image and info
|
||||
/// </summary>
|
||||
private async Task LoadRobotModelImageAsync(RobotMonitorData robotData)
|
||||
{
|
||||
if (!robotData.ModelId.HasValue) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Load model info if not cached
|
||||
if (!_robotModelCache.TryGetValue(robotData.ModelId.Value, out var model))
|
||||
{
|
||||
model = await _robotModelApiService.GetByIdAsync(robotData.ModelId.Value);
|
||||
if (model != null)
|
||||
{
|
||||
_robotModelCache[robotData.ModelId.Value] = model;
|
||||
}
|
||||
}
|
||||
|
||||
if (model != null)
|
||||
{
|
||||
robotData.Model = model;
|
||||
}
|
||||
|
||||
// Check image cache first
|
||||
if (_robotModelImageCache.TryGetValue(robotData.ModelId.Value, out var cachedImage))
|
||||
{
|
||||
robotData.ModelImageBase64 = cachedImage;
|
||||
NotifyStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load image from API
|
||||
var imageBase64 = await _robotModelApiService.GetImageAsync(robotData.ModelId.Value);
|
||||
if (imageBase64 != null)
|
||||
{
|
||||
_robotModelImageCache[robotData.ModelId.Value] = imageBase64;
|
||||
robotData.ModelImageBase64 = imageBase64;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors loading images
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ROBOT SELECTION
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Select a robot
|
||||
/// </summary>
|
||||
public void SelectRobot(string? robotId)
|
||||
{
|
||||
SelectedRobotId = robotId;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VIEWPORT OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Pan the viewport by delta (in SVG units)
|
||||
/// </summary>
|
||||
public void Pan(double deltaX, double deltaY)
|
||||
{
|
||||
Viewport.ViewBoxX += deltaX;
|
||||
Viewport.ViewBoxY += deltaY;
|
||||
NotifyStateChanged(); // Use immediate for smooth panning
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zoom the viewport around a point (in SVG/world coordinates)
|
||||
/// </summary>
|
||||
public void Zoom(double factor, double svgCenterX, double svgCenterY)
|
||||
{
|
||||
// Limit zoom level
|
||||
var newZoom = Viewport.ZoomLevel * factor;
|
||||
if (newZoom < 0.1 || newZoom > 10) return;
|
||||
|
||||
// Calculate the ratio of the cursor position within the current viewBox
|
||||
var ratioX = (svgCenterX - Viewport.ViewBoxX) / Viewport.ViewBoxWidth;
|
||||
var ratioY = (svgCenterY - Viewport.ViewBoxY) / Viewport.ViewBoxHeight;
|
||||
|
||||
// New dimensions after zoom
|
||||
var newWidth = Viewport.ViewBoxWidth / factor;
|
||||
var newHeight = Viewport.ViewBoxHeight / factor;
|
||||
|
||||
// Adjust ViewBox position so the cursor point stays at the same world position
|
||||
Viewport.ViewBoxX = svgCenterX - ratioX * newWidth;
|
||||
Viewport.ViewBoxY = svgCenterY - ratioY * newHeight;
|
||||
Viewport.ViewBoxWidth = newWidth;
|
||||
Viewport.ViewBoxHeight = newHeight;
|
||||
Viewport.ZoomLevel = newZoom;
|
||||
|
||||
NotifyStateChanged(); // Use immediate for smooth zooming
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zoom in/out centered at the viewport center
|
||||
/// </summary>
|
||||
public void ZoomAtCenter(double factor)
|
||||
{
|
||||
// Limit zoom level
|
||||
var newZoom = Viewport.ZoomLevel * factor;
|
||||
if (newZoom < 0.1 || newZoom > 10) return;
|
||||
|
||||
// Calculate center of current viewport
|
||||
var centerX = Viewport.ViewBoxX + Viewport.ViewBoxWidth / 2;
|
||||
var centerY = Viewport.ViewBoxY + Viewport.ViewBoxHeight / 2;
|
||||
|
||||
// New dimensions after zoom
|
||||
var newWidth = Viewport.ViewBoxWidth / factor;
|
||||
var newHeight = Viewport.ViewBoxHeight / factor;
|
||||
|
||||
// Adjust viewBox to keep the same center point
|
||||
Viewport.ViewBoxX = centerX - newWidth / 2;
|
||||
Viewport.ViewBoxY = centerY - newHeight / 2;
|
||||
Viewport.ViewBoxWidth = newWidth;
|
||||
Viewport.ViewBoxHeight = newHeight;
|
||||
Viewport.ZoomLevel = newZoom;
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fit viewport to image bounds
|
||||
/// </summary>
|
||||
public void FitToScreen()
|
||||
{
|
||||
InitializeViewport();
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Focus viewport on selected robot (with optional zoom)
|
||||
/// </summary>
|
||||
public void FocusOnRobot(string robotId, bool zoomToFit = false)
|
||||
{
|
||||
if (!Robots.TryGetValue(robotId, out var robot) || robot is null || robot.Data is null)
|
||||
return;
|
||||
|
||||
// Center viewport on robot
|
||||
var (X, Y) = WorldToSvg(robot.Data.AgvPosition.X, robot.Data.AgvPosition.Y);
|
||||
|
||||
if (zoomToFit && robot.Model != null)
|
||||
{
|
||||
// Zoom to fit robot with some padding
|
||||
var padding = 2.0; // meters padding around robot
|
||||
var robotSize = Math.Max(robot.Model.Length, robot.Model.Width);
|
||||
var viewSize = robotSize + padding * 2;
|
||||
|
||||
Viewport.ViewBoxWidth = viewSize;
|
||||
Viewport.ViewBoxHeight = viewSize;
|
||||
Viewport.ZoomLevel = GetPhysicalDimensions().Width / viewSize;
|
||||
}
|
||||
|
||||
Viewport.ViewBoxX = X - Viewport.ViewBoxWidth / 2;
|
||||
Viewport.ViewBoxY = Y - Viewport.ViewBoxHeight / 2;
|
||||
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// COORDINATE TRANSFORM
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Get physical dimensions of the layout
|
||||
/// </summary>
|
||||
public (double Width, double Height) GetPhysicalDimensions()
|
||||
{
|
||||
if (Level?.EditorSettings == null)
|
||||
return (50, 30);
|
||||
|
||||
var settings = Level.EditorSettings;
|
||||
return (
|
||||
(settings.ImageWidth ?? 1000) * settings.Resolution,
|
||||
(settings.ImageHeight ?? 500) * settings.Resolution
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transform world coordinates (layout) to SVG coordinates
|
||||
/// </summary>
|
||||
public (double X, double Y) WorldToSvg(double worldX, double worldY)
|
||||
{
|
||||
var (_, physicalHeight) = GetPhysicalDimensions();
|
||||
|
||||
var originX = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginX : 0;
|
||||
var originY = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginY : 0;
|
||||
return (worldX - originX, physicalHeight - (worldY - originY));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transform SVG coordinates to world coordinates (layout)
|
||||
/// </summary>
|
||||
public (double X, double Y) SvgToWorld(double svgX, double svgY)
|
||||
{
|
||||
var (_, physicalHeight) = GetPhysicalDimensions();
|
||||
|
||||
var originX = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginX : 0;
|
||||
var originY = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginY : 0;
|
||||
return (svgX + originX, physicalHeight - svgY + originY);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DISPLAY OPTIONS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Toggle follow robot mode
|
||||
/// </summary>
|
||||
public void ToggleFollowRobot()
|
||||
{
|
||||
FollowRobot = !FollowRobot;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle robot info panel visibility
|
||||
/// </summary>
|
||||
public void ToggleRobotInfoPanel()
|
||||
{
|
||||
RobotInfoPanelExpanded = !RobotInfoPanelExpanded;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// UTILITIES
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Remove robots that haven't been updated for a while
|
||||
/// </summary>
|
||||
private void RemoveInactiveRobots()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var timeout = TimeSpan.FromSeconds(RobotTimeoutSeconds);
|
||||
var robotsToRemove = new List<string>();
|
||||
|
||||
foreach (var kvp in Robots)
|
||||
{
|
||||
if (now - kvp.Value.LastUpdateTime > timeout)
|
||||
{
|
||||
robotsToRemove.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var robotId in robotsToRemove)
|
||||
{
|
||||
Robots.Remove(robotId);
|
||||
|
||||
// Clear selection if removed robot was selected
|
||||
if (SelectedRobotId == robotId)
|
||||
{
|
||||
SelectedRobotId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup resources (unsubscribe from SignalR, etc.)
|
||||
/// </summary>
|
||||
public async Task CleanupAsync()
|
||||
{
|
||||
if (_hubClient != null)
|
||||
{
|
||||
// Unregister event handlers
|
||||
_hubClient.OnMonitorBoardcastUpdate -= HandleStateUpdate;
|
||||
//_hubClient.OnVisualizationUpdate -= HandleVisualizationUpdate;
|
||||
_hubClient.OnConnectionError -= HandleConnectionError;
|
||||
_hubClient.OnMonitorDeactivated -= HandleMonitorDeactivated;
|
||||
|
||||
// Unsubscribe from level before disconnecting
|
||||
try
|
||||
{
|
||||
await _hubClient.UnsubscribeFromLevelForMonitorAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unsubscribe errors
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
try
|
||||
{
|
||||
await _hubClient.DisconnectAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get detailed error message from exception
|
||||
/// </summary>
|
||||
private static string GetDetailedErrorMessage(Exception ex, string operation)
|
||||
{
|
||||
var errorDetails = new System.Text.StringBuilder();
|
||||
errorDetails.AppendLine($"Failed to {operation}: {ex.Message}");
|
||||
|
||||
// Add inner exception details if available
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
errorDetails.AppendLine($"Inner exception: {ex.InnerException.Message}");
|
||||
}
|
||||
|
||||
// For HttpRequestException, try to extract more details
|
||||
if (ex is System.Net.Http.HttpRequestException httpEx)
|
||||
{
|
||||
var message = httpEx.Message;
|
||||
|
||||
// Extract status code if present
|
||||
if (message.Contains("404"))
|
||||
{
|
||||
errorDetails.AppendLine("Status: 404 Not Found - The requested resource was not found on the server.");
|
||||
}
|
||||
else if (message.Contains("401"))
|
||||
{
|
||||
errorDetails.AppendLine("Status: 401 Unauthorized - Authentication required.");
|
||||
}
|
||||
else if (message.Contains("403"))
|
||||
{
|
||||
errorDetails.AppendLine("Status: 403 Forbidden - Access denied.");
|
||||
}
|
||||
else if (message.Contains("500"))
|
||||
{
|
||||
errorDetails.AppendLine("Status: 500 Internal Server Error - Server encountered an error.");
|
||||
}
|
||||
|
||||
// Add URL if present in message
|
||||
if (message.Contains("URL:"))
|
||||
{
|
||||
var urlStart = message.IndexOf("URL:");
|
||||
if (urlStart >= 0)
|
||||
{
|
||||
var urlPart = message[urlStart..];
|
||||
errorDetails.AppendLine(urlPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorDetails.ToString().Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify state changed immediately (for critical updates)
|
||||
/// </summary>
|
||||
public void NotifyStateChanged()
|
||||
{
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user