Initial commit
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
@using RobotNet10.FleetManager.Client.Services
|
||||
|
||||
@implements IDisposable
|
||||
|
||||
@if (State.ShowPath)
|
||||
{
|
||||
<defs>
|
||||
<marker id="target" markerWidth="8" markerHeight="8" refX="4" refY="4">
|
||||
<circle r="0.8" cx="4" cy="4" fill="red" />
|
||||
<circle r="3" cx="4" cy="4" stroke="red" stroke-width="0.2" fill="transparent" stroke-dasharray="0.2 0.2" />
|
||||
<line x1="0" y1="4" x2="2" y2="4" stroke="red" stroke-width="0.2" />
|
||||
<line x1="6" y1="4" x2="8" y2="4" stroke="red" stroke-width="0.2" />
|
||||
<line x1="4" y1="0" x2="4" y2="2" stroke="red" stroke-width="0.2" />
|
||||
<line x1="4" y1="6" x2="4" y2="8" stroke="red" stroke-width="0.2" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<g id="robot-paths-layer">
|
||||
@foreach (var robot in State.Robots.Values)
|
||||
{
|
||||
if (robot is null || robot.Data is null || robot.Data.Path is null) continue;
|
||||
var isSelected = State.SelectedRobotId == robot.RobotId;
|
||||
var strokeColor = isSelected ? "#0288D1" : "#0097A7";
|
||||
var opacity = isSelected ? "1" : "0.8";
|
||||
|
||||
@if (robot.Data.Path.RobotPath.Length > 0)
|
||||
{
|
||||
var data = UpdatePath(robot.Data.Path.RobotPath);
|
||||
var strokeWidth = isSelected ? "0.12" : "0.08";
|
||||
|
||||
<path class="robot-path"
|
||||
d="@data"
|
||||
fill="none"
|
||||
stroke="@strokeColor"
|
||||
stroke-width="@strokeWidth"
|
||||
opacity="@opacity"
|
||||
stroke-dasharray="@(isSelected ? "0.2,0.1" : "none")"
|
||||
marker-end="url(#target)" />
|
||||
}
|
||||
|
||||
@if (robot.Data.Path.RobotBasePath.Length > 0)
|
||||
{
|
||||
var data = UpdatePath(robot.Data.Path.RobotBasePath);
|
||||
var strokeWidth = isSelected ? "0.4" : "0.3";
|
||||
|
||||
<path class="robot-path"
|
||||
d="@data"
|
||||
fill="none"
|
||||
stroke="@strokeColor"
|
||||
stroke-width="@strokeWidth"
|
||||
opacity="@opacity"
|
||||
stroke-dasharray="@(isSelected ? "0.2,0.1" : "none")" />
|
||||
}
|
||||
}
|
||||
</g>
|
||||
}
|
||||
|
||||
<g id="robots-layer">
|
||||
@foreach (var robot in State.Robots.Values)
|
||||
{
|
||||
if (robot is null || robot.Data is null) continue;
|
||||
var svgPos = State.WorldToSvg(robot.Data.AgvPosition.X, robot.Data.AgvPosition.Y);
|
||||
var degrees = -robot.Data.AgvPosition.Theta * 180.0 / Math.PI;
|
||||
|
||||
var baseScale = 2 / State.Viewport.ZoomLevel;
|
||||
var minScale = 1;
|
||||
var maxScale = 10.0;
|
||||
baseScale = Math.Max(minScale, Math.Min(maxScale, baseScale));
|
||||
|
||||
@if (robot.Model != null && !string.IsNullOrEmpty(robot.ModelImageBase64))
|
||||
{
|
||||
var imageLength = robot.Model.Length * baseScale;
|
||||
var imageWidth = robot.Model.Width * baseScale;
|
||||
|
||||
@* Navigation point offset: position relative to bottom-left corner of image *@
|
||||
@* Scale navigation point offset with robot size *@
|
||||
var navPointX = robot.Model.NavigationPointX * baseScale;
|
||||
var navPointY = robot.Model.NavigationPointY * baseScale;
|
||||
var imageX = -navPointX;
|
||||
var imageY = navPointY - imageWidth;
|
||||
|
||||
<g transform="translate(@svgPos.X.ToString("F2"), @svgPos.Y.ToString("F2")) rotate(@degrees.ToString("F2"))">
|
||||
<image href="data:image/png;base64,@robot.ModelImageBase64"
|
||||
x="@imageX.ToString("F3")"
|
||||
y="@imageY.ToString("F3")"
|
||||
width="@imageLength.ToString("F3")"
|
||||
height="@imageWidth.ToString("F3")"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
@onclick="() => HandleRobotClick(robot.RobotId)"
|
||||
style="cursor: pointer; pointer-events: all;" />
|
||||
</g>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Placeholder circle until image loads - scale with zoom *@
|
||||
var placeholderRadius = 0.5 * baseScale;
|
||||
<g transform="translate(@svgPos.X.ToString("F2"), @svgPos.Y.ToString("F2")) rotate(@degrees.ToString("F2"))">
|
||||
<circle cx="0"
|
||||
cy="0"
|
||||
r="@placeholderRadius.ToString("F3")"
|
||||
fill="var(--mud-palette-error)"
|
||||
stroke="var(--mud-palette-error-darken)"
|
||||
stroke-width="@(0.05 * baseScale).ToString(" F3")"
|
||||
@onclick="() => HandleRobotClick(robot.RobotId)"
|
||||
style="cursor: pointer;" />
|
||||
</g>
|
||||
}
|
||||
|
||||
@* Selection highlight *@
|
||||
@if (State.SelectedRobotId == robot.RobotId)
|
||||
{
|
||||
@* Calculate highlight radius based on robot size and scale *@
|
||||
var highlightRadius = robot.Model != null
|
||||
? (Math.Max(robot.Model.Length, robot.Model.Width) / 2.0 + 0.2) * baseScale
|
||||
: 0.5 * baseScale;
|
||||
|
||||
<circle class="robot-selection-highlight"
|
||||
cx="@svgPos.X.ToString("F2")"
|
||||
cy="@svgPos.Y.ToString("F2")"
|
||||
r="@highlightRadius.ToString("F2")"
|
||||
fill="none"
|
||||
stroke="#1976d2"
|
||||
stroke-width="@(0.1 * baseScale).ToString(" F3")"
|
||||
stroke-dasharray="0.15,0.1"
|
||||
opacity="0.8" />
|
||||
}
|
||||
|
||||
@* Robot name (if ShowName = true) *@
|
||||
@if (State.ShowName)
|
||||
{
|
||||
@* Get robot name from AvailableRobots *@
|
||||
var robotName = State.AvailableRobots.FirstOrDefault(r => r.RobotId == robot.RobotId)?.Name ?? robot.RobotId;
|
||||
var isSelected = State.SelectedRobotId == robot.RobotId;
|
||||
var fontSize = 0.4 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
|
||||
var textColor = isSelected ? "#9C27B0" : "#3F51B5";
|
||||
var fontWeight = isSelected ? "bold" : "500";
|
||||
|
||||
var offset = 0.6 * baseScale;
|
||||
|
||||
@RenderSvgText(robotName, svgPos.X, svgPos.Y + offset, fontSize, textColor, fontWeight)
|
||||
}
|
||||
}
|
||||
</g>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RobotMonitorState State { get; set; } = default!;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (!firstRender) return;
|
||||
State.OnDataChanged += StateHasChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
State.OnDataChanged -= StateHasChanged;
|
||||
}
|
||||
|
||||
private void HandleRobotClick(string robotId)
|
||||
{
|
||||
State.SelectRobot(robotId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render SVG text element
|
||||
/// </summary>
|
||||
private RenderFragment RenderSvgText(string content, double x, double y, double fontSize, string fillColor, string fontWeight) => builder =>
|
||||
{
|
||||
builder.OpenElement(0, "text");
|
||||
builder.AddAttribute(1, "letter-spacing", "-0.01em");
|
||||
builder.AddAttribute(2, "x", x.ToString("F2"));
|
||||
builder.AddAttribute(3, "y", y.ToString("F2"));
|
||||
builder.AddAttribute(4, "font-size", fontSize.ToString("F3"));
|
||||
builder.AddAttribute(5, "fill", fillColor);
|
||||
builder.AddAttribute(6, "text-anchor", "middle");
|
||||
builder.AddAttribute(7, "font-family", "Segoe UI");
|
||||
builder.AddAttribute(8, "font-weight", fontWeight);
|
||||
builder.AddAttribute(9, "pointer-events", "none");
|
||||
builder.AddContent(10, content);
|
||||
builder.CloseElement();
|
||||
};
|
||||
|
||||
|
||||
public string UpdatePath(Shared.DTOs.Robot.NavigationPathEdge[] path)
|
||||
{
|
||||
if (path.Length > 0)
|
||||
{
|
||||
var startSvg = State.WorldToSvg(path[0].StartX, path[0].StartY);
|
||||
var inPath = $"M {startSvg.X} {startSvg.Y}";
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
var endSvg = State.WorldToSvg(path[i].EndX, path[i].EndY);
|
||||
var cp1Svg = State.WorldToSvg(path[i].ControlPoint1X, path[i].ControlPoint1Y);
|
||||
var cp2Svg = State.WorldToSvg(path[i].ControlPoint2X, path[i].ControlPoint2Y);
|
||||
if (path[i].Degree == 1) inPath = $"{inPath} L {endSvg.X} {endSvg.Y}";
|
||||
else if (path[i].Degree == 2) inPath = $"{inPath} Q {cp1Svg.X} {cp1Svg.Y} {endSvg.X} {endSvg.Y}";
|
||||
else inPath = $"{inPath} C {cp1Svg.X} {cp1Svg.Y} , {cp2Svg.X} {cp2Svg.Y}, {endSvg.X} {endSvg.Y}";
|
||||
}
|
||||
return inPath;
|
||||
}
|
||||
else return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
/* Robot selection highlight animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
stroke-width: 0.08px;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
stroke-width: 0.12px;
|
||||
}
|
||||
}
|
||||
|
||||
.robot-selection-highlight {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
stroke-dasharray: 0.15, 0.1;
|
||||
}
|
||||
|
||||
/* Path visualization */
|
||||
.robot-path {
|
||||
transition: stroke-opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.robot-path:hover {
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
@using MudBlazor
|
||||
@using RobotNet10.FleetManager.Client.Services
|
||||
|
||||
<div class="pa-2 monitor-toolbar">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<!-- Viewport Controls -->
|
||||
<MudTooltip Text="Zoom In">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ZoomIn"
|
||||
Size="Size.Medium"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Outlined"
|
||||
OnClick="HandleZoomIn" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Zoom Out">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ZoomOut"
|
||||
Size="Size.Medium"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Outlined"
|
||||
OnClick="HandleZoomOut" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Fit to Screen">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FitScreen"
|
||||
Size="Size.Medium"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Outlined"
|
||||
OnClick="HandleFitScale" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Focus on Robot">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.CenterFocusStrong"
|
||||
Size="Size.Medium"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Outlined"
|
||||
OnClick="HandleFocus"
|
||||
Disabled="@(State.SelectedRobotId == null)" />
|
||||
</MudTooltip>
|
||||
|
||||
<MudDivider Vertical="true" />
|
||||
|
||||
<!-- Display Options -->
|
||||
<MudCheckBox @bind-Value="State.FollowRobot"
|
||||
Label="Follow Robot"
|
||||
T=bool
|
||||
Dense
|
||||
Size="Size.Small" />
|
||||
<MudCheckBox @bind-Value="State.ShowPath"
|
||||
Label="Path"
|
||||
T=bool
|
||||
Dense
|
||||
Size="Size.Small" />
|
||||
<MudCheckBox @bind-Value="State.ShowName"
|
||||
Label="Name"
|
||||
T=bool
|
||||
Dense
|
||||
Size="Size.Small" />
|
||||
<MudCheckBox @bind-Value="State.ShowGrid"
|
||||
@bind-Value:after="() => State.NotifyStateChanged()"
|
||||
Label="Grid"
|
||||
T=bool
|
||||
Dense
|
||||
Size="Size.Small" />
|
||||
|
||||
<MudDivider Vertical="true" />
|
||||
|
||||
<!-- Layout SelectBox -->
|
||||
<MudSelect Value="@State.SelectedLayoutId"
|
||||
ValueChanged="@HandleLayoutChanged"
|
||||
Label="Layout"
|
||||
Variant="Variant.Outlined"
|
||||
T="Guid ?"
|
||||
Dense="true"
|
||||
Margin="Margin.Dense"
|
||||
Style="min-width: 150px;">
|
||||
<MudSelectItem Value="@((Guid?)null)">-- Select Layout --</MudSelectItem>
|
||||
@foreach (var layout in State.Layouts)
|
||||
{
|
||||
<MudSelectItem Value="@((Guid?)layout.Id)">@(layout.LayoutName)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<!-- Version SelectBox -->
|
||||
<MudSelect Value="@State.SelectedVersionId"
|
||||
ValueChanged="@HandleVersionChanged"
|
||||
Label="Version"
|
||||
Variant="Variant.Outlined"
|
||||
T="Guid ?"
|
||||
Dense="true"
|
||||
Margin="Margin.Dense"
|
||||
Style="min-width: 120px;"
|
||||
Disabled="@(!State.SelectedLayoutId.HasValue)">
|
||||
<MudSelectItem Value="@((Guid?)null)">-- Select Version --</MudSelectItem>
|
||||
@foreach (var version in State.AvailableVersions)
|
||||
{
|
||||
<MudSelectItem Value="@((Guid?)version.Id)">@version.Version</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<!-- Level SelectBox -->
|
||||
<MudSelect Value="@State.SelectedLevelId"
|
||||
ValueChanged="@HandleLevelChanged"
|
||||
Label="Level"
|
||||
Variant="Variant.Outlined"
|
||||
T="Guid ?"
|
||||
Dense="true"
|
||||
Margin="Margin.Dense"
|
||||
Style="min-width: 120px;"
|
||||
Disabled="@(!State.SelectedVersionId.HasValue)">
|
||||
<MudSelectItem Value="@((Guid?)null)">-- Select Level --</MudSelectItem>
|
||||
@foreach (var level in State.AvailableLevels)
|
||||
{
|
||||
<MudSelectItem Value="@((Guid?)level.Id)">@level.LayoutLevelId</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<!-- Robot SelectBox (only online robots) -->
|
||||
<MudSelect Value="@State.SelectedRobotId"
|
||||
ValueChanged="@HandleRobotChanged"
|
||||
Label="Robot"
|
||||
Variant="Variant.Outlined"
|
||||
T="string"
|
||||
Dense="true"
|
||||
Margin="Margin.Dense"
|
||||
Style="min-width: 150px;">
|
||||
<MudSelectItem T="string" Value="@(string.Empty)">-- Select Robot --</MudSelectItem>
|
||||
@foreach (var robot in State.Robots.Values.OrderBy(r => r.RobotId))
|
||||
{
|
||||
@* Get robot name from AvailableRobots if available *@
|
||||
var robotName = State.AvailableRobots.FirstOrDefault(ar => ar.RobotId == robot.RobotId)?.Name ?? robot.RobotId;
|
||||
<MudSelectItem T="string" Value="@robot.RobotId">@robotName (@robot.RobotId)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<!-- Expand/Collapse Panel Button (cạnh phía InfoPanel) -->
|
||||
<MudSpacer />
|
||||
<MudTooltip Text="@(State.RobotInfoPanelExpanded ? "Collapse Panel" : "Expand Panel")">
|
||||
<MudIconButton Icon="@(State.RobotInfoPanelExpanded? Icons.Material.Filled.ChevronRight : Icons.Material.Filled.ChevronLeft)"
|
||||
OnClick="HandleTogglePanel"
|
||||
Color="Color.Success"
|
||||
Variant="Variant.Outlined" />
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RobotMonitorState State { get; set; } = null!;
|
||||
|
||||
private void HandleZoomIn()
|
||||
{
|
||||
State.ZoomAtCenter(1.2);
|
||||
}
|
||||
|
||||
private void HandleZoomOut()
|
||||
{
|
||||
State.ZoomAtCenter(1.0 / 1.2);
|
||||
}
|
||||
|
||||
private void HandleFitScale()
|
||||
{
|
||||
State.FitToScreen();
|
||||
}
|
||||
|
||||
private void HandleFocus()
|
||||
{
|
||||
if (State.SelectedRobotId != null)
|
||||
{
|
||||
State.FocusOnRobot(State.SelectedRobotId);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTogglePanel()
|
||||
{
|
||||
State.ToggleRobotInfoPanel();
|
||||
}
|
||||
|
||||
private async Task HandleLayoutChanged(Guid? layoutId)
|
||||
{
|
||||
State.SelectedLayoutId = layoutId;
|
||||
await State.OnLayoutSelectedAsync(layoutId);
|
||||
}
|
||||
|
||||
private async Task HandleVersionChanged(Guid? versionId)
|
||||
{
|
||||
State.SelectedVersionId = versionId;
|
||||
await State.OnVersionSelectedAsync(versionId);
|
||||
}
|
||||
|
||||
private async Task HandleLevelChanged(Guid? levelId)
|
||||
{
|
||||
State.SelectedLevelId = levelId;
|
||||
await State.OnLevelSelectedAsync(levelId);
|
||||
}
|
||||
|
||||
private void HandleRobotChanged(string? robotId)
|
||||
{
|
||||
State.SelectRobot(robotId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* Monitor Toolbar Styles */
|
||||
|
||||
.monitor-toolbar {
|
||||
border-bottom: 1px solid var(--mud-palette-lines-default);
|
||||
background-color: var(--mud-palette-surface);
|
||||
flex-shrink: 0; /* Prevent toolbar from shrinking */
|
||||
width: 100%; /* Full width */
|
||||
overflow-x: auto; /* Allow horizontal scroll if needed */
|
||||
overflow-y: hidden;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<div class="mouse-position-display">
|
||||
<span class="coord-label">X:</span>
|
||||
<span class="coord-value">@X.ToString("F2") m</span>
|
||||
<span class="coord-label">Y:</span>
|
||||
<span class="coord-value">@Y.ToString("F2") m</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private double X { get; set; }
|
||||
private double Y { get; set; }
|
||||
|
||||
public void Update(double x, double y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.mouse-position-display {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 50;
|
||||
background-color: rgba(33, 33, 33, 0.85);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.coord-label {
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.coord-value {
|
||||
color: #4fc3f7;
|
||||
font-weight: bold;
|
||||
min-width: 70px;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@using MudBlazor
|
||||
@using RobotNet10.FleetManager.Client.Services
|
||||
|
||||
<div class="pa-4 robot-info-panel">
|
||||
<MudStack Spacing="3">
|
||||
<!-- Header -->
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Robot Information</MudText>
|
||||
|
||||
@if (State.SelectedRobotId != null && State.Robots.TryGetValue(State.SelectedRobotId, out var robot))
|
||||
{
|
||||
<SelectedRobotInfo RobotData="robot" State="State" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Class="mt-4">
|
||||
<MudText Typo="Typo.body2">
|
||||
No robot selected. Click on a robot on the map to view its information.
|
||||
</MudText>
|
||||
</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RobotMonitorState State { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* Robot Info Panel Styles */
|
||||
|
||||
.robot-info-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
transition: width 0.3s ease-in-out;
|
||||
background-color: var(--mud-palette-surface);
|
||||
border-left: 1px solid var(--mud-palette-lines-default);
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Info panel scrollbar styling */
|
||||
.robot-info-panel::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.robot-info-panel::-webkit-scrollbar-track {
|
||||
background: var(--mud-palette-background-grey);
|
||||
}
|
||||
|
||||
.robot-info-panel::-webkit-scrollbar-thumb {
|
||||
background: var(--mud-palette-text-disabled);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.robot-info-panel::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 960px) {
|
||||
.robot-info-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
z-index: 100;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
@using MudBlazor
|
||||
@using RobotNet10.FleetManager.Client.Services
|
||||
@inject RobotMonitorState State
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="robot-monitor-container">
|
||||
@if (State.IsLoading)
|
||||
{
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Typo="Typo.body1">Loading robot monitor...</MudText>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(State.ErrorMessage))
|
||||
{
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudStack Spacing="3">
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
|
||||
<MudText Typo="Typo.h6">Error</MudText>
|
||||
<MudText Typo="Typo.body2">@State.ErrorMessage</MudText>
|
||||
</MudAlert>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="HandleReload">
|
||||
Retry
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Top: Toolbar (full width) -->
|
||||
<MonitorToolbar State="@State" />
|
||||
|
||||
<!-- Bottom: Canvas and Info Panel -->
|
||||
<div class="monitor-main-content">
|
||||
<!-- Left: Canvas -->
|
||||
<SvgMonitorCanvas State="@State" />
|
||||
|
||||
<!-- Right: Robot Info Panel -->
|
||||
@if (State.RobotInfoPanelExpanded)
|
||||
{
|
||||
<RobotInfoPanel State="@State" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Overlay for deactivated monitor -->
|
||||
@if (State.IsMonitorDeactivated)
|
||||
{
|
||||
<div class="monitor-deactivated-overlay">
|
||||
<MudPaper Class="pa-6" Elevation="10">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Block" Size="Size.Large" Color="Color.Error" />
|
||||
<MudText Typo="Typo.h5">Monitor Deactivated</MudText>
|
||||
<MudText Typo="Typo.body1" Align="Align.Center">
|
||||
Maximum 5 connections per level reached.<br />
|
||||
Another connection has taken your place.
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="HandleReload">
|
||||
Reconnect
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
State.OnStateChanged += HandleStateChanged;
|
||||
await State.InitializeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
State.OnStateChanged -= HandleStateChanged;
|
||||
await State.CleanupAsync();
|
||||
}
|
||||
|
||||
private void HandleStateChanged()
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleReload()
|
||||
{
|
||||
await State.InitializeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* Robot Monitor Component Styles */
|
||||
|
||||
.robot-monitor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--mud-palette-background);
|
||||
}
|
||||
|
||||
.monitor-main-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for flex child overflow */
|
||||
}
|
||||
|
||||
.monitor-deactivated-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
pointer-events: all;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
@using MudBlazor
|
||||
@using RobotNet.VDA5050.State
|
||||
@using RobotNet.VDA5050.Type
|
||||
@using RobotNet10.FleetManager.Client.Components.RobotDetail
|
||||
@using RobotNet10.FleetManager.Client.Components.RobotMonitor
|
||||
@using RobotNet10.FleetManager.Client.Services
|
||||
|
||||
@implements IDisposable
|
||||
|
||||
<MudStack Spacing="3">
|
||||
<!-- Robot Header Info -->
|
||||
<MudPaper Class="pa-3" Elevation="1" Style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.h6" Style="color: white;">
|
||||
@GetRobotName()
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Style="color: rgba(255, 255, 255, 0.8);">
|
||||
ID: @RobotData.RobotId
|
||||
</MudText>
|
||||
@if (RobotData.Model != null)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Style="background: rgba(255, 255, 255, 0.2); color: white;">
|
||||
@RobotData.Model.ModelName
|
||||
</MudChip>
|
||||
}
|
||||
@if (RobotData.LastUpdateTime != default)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Style="color: rgba(255, 255, 255, 0.7);">
|
||||
Last update: @RobotData.LastUpdateTime.ToLocalTime().ToString("HH:mm:ss")
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<!-- Position Info (Quick View) -->
|
||||
@if (RobotData.Data != null)
|
||||
{
|
||||
<MudPaper Class="pa-3" Elevation="1">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Visualization</MudText>
|
||||
<MudSimpleTable Dense="true" Elevation="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>X:</strong></td>
|
||||
<td>@RobotData.Data.AgvPosition.X.ToString("F2") m</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Y:</strong></td>
|
||||
<td>@RobotData.Data.AgvPosition.Y.ToString("F2") m</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Θ:</strong></td>
|
||||
<td>@((RobotData.Data.AgvPosition.Theta * 180.0 / Math.PI).ToString("F1"))°</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Vx:</strong></td>
|
||||
<td>@RobotData.Data.AgvVelocity.Vx.ToString("F2") m/s</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Vy:</strong></td>
|
||||
<td>@RobotData.Data.AgvVelocity.Vy.ToString("F2") m/s</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Omega:</strong></td>
|
||||
<td>@RobotData.Data.AgvVelocity.Omega.ToString("F2") rad/s</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Position Initialized:</strong></td>
|
||||
<td>
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Color="@(RobotData.Data.AgvPosition.PositionInitialized ? Color.Success : Color.Warning)">
|
||||
@(RobotData.Data.AgvPosition.PositionInitialized ? "Yes" : "No")
|
||||
</MudChip>
|
||||
</td>
|
||||
</tr>
|
||||
@if (RobotData.Data.AgvPosition.LocalizationScore >= 0)
|
||||
{
|
||||
<tr>
|
||||
<td><strong>Localization Score:</strong></td>
|
||||
<td>@RobotData.Data.AgvPosition.LocalizationScore.ToString("F2")</td>
|
||||
</tr>
|
||||
}
|
||||
@if (RobotData.Data.AgvPosition.DeviationRange >= 0)
|
||||
{
|
||||
<tr>
|
||||
<td><strong>Deviation Range:</strong></td>
|
||||
<td>@RobotData.Data.AgvPosition.DeviationRange.ToString("F2") m</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
<!-- Expansion Panels for Detailed Info -->
|
||||
<MudExpansionPanels Elevation="0" MultiExpansion="true" Gutters="false">
|
||||
<!-- Battery State Panel -->
|
||||
<MudExpansionPanel Icon="@Icons.Material.Filled.BatteryChargingFull"
|
||||
Expanded="true">
|
||||
<TitleContent>
|
||||
<MudText>Battery State</MudText>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
<BatteryCard @ref="BatteryCardRef" ShowNameCard="false"/>
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
|
||||
<!-- Errors Panel -->
|
||||
<MudExpansionPanel Icon="@Icons.Material.Filled.Error"
|
||||
Expanded="false">
|
||||
<TitleContent>
|
||||
<div class="d-flex">
|
||||
<MudText>Errors</MudText>
|
||||
<MudBadge Content="Errors.Length" Color="Color.Info" Overlap="true" Class="d-flex ml-auto">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Info" Color="Color.Secondary" />
|
||||
</MudBadge>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
@foreach (var error in Errors)
|
||||
{
|
||||
<MudTooltip Text="@error.ErrorDescription" Placement="Placement.Top" Color="Color.Info">
|
||||
<MudButton Class="m-2" Color="@(error.ErrorLevel == ErrorLevel.FATAL ? Color.Error : Color.Warning)" Variant="Variant.Filled" Size="Size.Small" Style="text-transform:none">@error.ErrorType</MudButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudPaper>
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
|
||||
<!-- Information Panel -->
|
||||
<MudExpansionPanel Icon="@Icons.Material.Filled.Info"
|
||||
Expanded="false">
|
||||
<TitleContent>
|
||||
<div class="d-flex">
|
||||
<MudText>Notification</MudText>
|
||||
<MudBadge Content="Information.Length" Color="Color.Info" Overlap="true" Class="d-flex ml-auto">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Notifications" Color="Color.Warning" />
|
||||
</MudBadge>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<div class="d-flex flex-column">
|
||||
@foreach (var info in Information)
|
||||
{
|
||||
<MudTooltip Text="@info.InfoDescription" Placement="Placement.Top" Color="Color.Info">
|
||||
<MudButton Class="m-2" Color="@(info.InfoLevel == InfoLevel.INFO ? Color.Info : Color.Default)" Variant="Variant.Filled" Size="Size.Small" Style="text-transform:none">@info.InfoType</MudButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RobotMonitorData RobotData { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public RobotMonitorState State { get; set; } = null!;
|
||||
|
||||
private BatteryCard BatteryCardRef = default!;
|
||||
|
||||
private Error[] Errors = [];
|
||||
private Information[] Information = [];
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (!firstRender) return;
|
||||
State.OnDataChanged += OnDataChanged;
|
||||
}
|
||||
|
||||
private void OnDataChanged()
|
||||
{
|
||||
if (State.SelectedRobotId != null && State.Robots.TryGetValue(State.SelectedRobotId, out var robot))
|
||||
{
|
||||
BatteryCardRef.Update(robot.Data?.Battery);
|
||||
Errors = robot.Data?.Errors ?? [];
|
||||
Information = robot.Data?.Infomations ?? [];
|
||||
RobotData = robot;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
State.OnDataChanged -= OnDataChanged;
|
||||
}
|
||||
|
||||
private string GetRobotName()
|
||||
{
|
||||
// Try to get robot name from AvailableRobots
|
||||
var robot = State.AvailableRobots.FirstOrDefault(r => r.RobotId == RobotData.RobotId);
|
||||
return robot?.Name ?? RobotData.RobotId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using RobotNet10.FleetManager.Client.Services
|
||||
@using RobotNet10.MapEditor.Shared.DTOs.Edge
|
||||
@using RobotNet10.MapEditor.Shared.DTOs.Node
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="svg-monitor-container" @ref="containerRef">
|
||||
<!-- Mouse Position Display -->
|
||||
<MousePositionDisplay @ref="MousePositionDisplayRef" />
|
||||
<svg @ref="svgRef"
|
||||
id="monitor-svg"
|
||||
class="monitor-svg"
|
||||
viewBox="@State.Viewport.ToViewBoxString()"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<!-- SVG Markers -->
|
||||
<marker id="arrowhead" markerWidth="5" markerHeight="5" refX="0" refY="1.5" orient="auto-start-reverse">
|
||||
<polygon class="edge-arrow"
|
||||
points="0 0, 3 1.5, 0 3"
|
||||
fill="#4caf50" />
|
||||
</marker>
|
||||
<marker id="originvector" markerWidth="2.4" markerHeight="2.4" refX="0.4" refY="2">
|
||||
<line x1="0" y1="2" x2="2" y2="2" stroke="red" stroke-width="0.15" />
|
||||
<path d="M 2 2.2 L 2.4 2 L 2 1.8 Z" fill="red" stroke-width="0" />
|
||||
<line x1="0.4" y1="2.4" x2="0.4" y2="0.4" stroke="blue" stroke-width="0.15" />
|
||||
<path d="M 0.6 0.4 L 0.4 0 L 0.2 0.4 Z" fill="blue" stroke-width="0" />
|
||||
</marker>
|
||||
|
||||
<!-- Layer 1: Background Image -->
|
||||
@if (State.ShowBackgroundImage && State.BackgroundImage != null && State.Level?.EditorSettings != null)
|
||||
{
|
||||
var (physicalWidth, physicalHeight) = State.GetPhysicalDimensions();
|
||||
var imageDataUrl = $"data:image/png;base64,{Convert.ToBase64String(State.BackgroundImage)}";
|
||||
|
||||
<image href="@imageDataUrl"
|
||||
x="0"
|
||||
y="0"
|
||||
width="@physicalWidth.ToString("F2")"
|
||||
height="@physicalHeight.ToString("F2")"
|
||||
preserveAspectRatio="none"
|
||||
style="image-rendering: pixelated"/>
|
||||
}
|
||||
|
||||
<!-- Layer 2: Grid -->
|
||||
@if (State.ShowGrid && State.Level?.EditorSettings != null)
|
||||
{
|
||||
var (physicalWidth, physicalHeight) = State.GetPhysicalDimensions();
|
||||
var settings = State.Level.EditorSettings;
|
||||
var originX = settings.OriginX;
|
||||
var originY = settings.OriginY;
|
||||
var gridSpacing = 1.0; // Default grid spacing
|
||||
|
||||
<g id="grid-layer" stroke="#808080" stroke-width="0.04" opacity="0.7" stroke-dasharray="0.1,0.1">
|
||||
@* Vertical lines *@
|
||||
@{
|
||||
var worldMinX = originX;
|
||||
var worldMaxX = originX + physicalWidth;
|
||||
var firstGridXWorld = Math.Floor(worldMinX / gridSpacing) * gridSpacing;
|
||||
var lastGridXWorld = Math.Ceiling(worldMaxX / gridSpacing) * gridSpacing;
|
||||
|
||||
for (double worldX = firstGridXWorld; worldX <= lastGridXWorld; worldX += gridSpacing)
|
||||
{
|
||||
var svgX = State.WorldToSvg(worldX, 0).X;
|
||||
if (svgX >= 0 && svgX <= physicalWidth)
|
||||
{
|
||||
<line x1="@svgX.ToString("F2")" y1="0" x2="@svgX.ToString("F2")" y2="@physicalHeight.ToString("F2")" />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@* Horizontal lines *@
|
||||
@{
|
||||
var worldMinY = originY;
|
||||
var worldMaxY = originY + physicalHeight;
|
||||
var firstGridYWorld = Math.Floor(worldMinY / gridSpacing) * gridSpacing;
|
||||
var lastGridYWorld = Math.Ceiling(worldMaxY / gridSpacing) * gridSpacing;
|
||||
|
||||
for (double worldY = firstGridYWorld; worldY <= lastGridYWorld; worldY += gridSpacing)
|
||||
{
|
||||
var svgY = State.WorldToSvg(0, worldY).Y;
|
||||
if (svgY >= 0 && svgY <= physicalHeight)
|
||||
{
|
||||
<line x1="0" y1="@svgY.ToString("F2")" x2="@physicalWidth.ToString("F2")" y2="@svgY.ToString("F2")" />
|
||||
}
|
||||
}
|
||||
}
|
||||
</g>
|
||||
}
|
||||
|
||||
<!-- Origin Vector (after grid, before edges) -->
|
||||
@if (State.Level is not null && State.Level.EditorSettings != null)
|
||||
{
|
||||
var (_, physicalHeight) = State.GetPhysicalDimensions();
|
||||
var svgOriginY = physicalHeight + State.Level.EditorSettings.OriginY;
|
||||
var width = 1.0 / State.Viewport.ZoomLevel;
|
||||
<line x1="@(-State.Level.EditorSettings.OriginX)" y1="@(svgOriginY)" x2="@(-State.Level.EditorSettings.OriginX)" y2="@(svgOriginY)" fill="none" marker-end="url(#originvector)" stroke-width="@width" />
|
||||
}
|
||||
|
||||
<!-- Layer 3: Edges -->
|
||||
<g id="edges-layer">
|
||||
@foreach (var edge in State.Edges)
|
||||
{
|
||||
var startNode = State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
|
||||
var endNode = State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
|
||||
|
||||
if (startNode != null && endNode != null)
|
||||
{
|
||||
var startSvg = State.WorldToSvg(startNode.X, startNode.Y);
|
||||
var endSvg = State.WorldToSvg(endNode.X, endNode.Y);
|
||||
|
||||
<line x1="@startSvg.X.ToString("F2")"
|
||||
y1="@startSvg.Y.ToString("F2")"
|
||||
x2="@endSvg.X.ToString("F2")"
|
||||
y2="@endSvg.Y.ToString("F2")"
|
||||
stroke="#4caf50"
|
||||
stroke-width="0.07"
|
||||
fill="none" />
|
||||
}
|
||||
}
|
||||
</g>
|
||||
|
||||
<!-- Layer 4: Nodes -->
|
||||
<g id="nodes-layer">
|
||||
@foreach (var node in State.Nodes)
|
||||
{
|
||||
var svgPos = State.WorldToSvg(node.X, node.Y);
|
||||
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
|
||||
|
||||
<circle cx="@svgPos.X.ToString("F2")"
|
||||
cy="@svgPos.Y.ToString("F2")"
|
||||
r="@nodeRadius.ToString("F3")"
|
||||
fill="#2196f3"
|
||||
stroke="#fff"
|
||||
stroke-width="0.03" />
|
||||
}
|
||||
</g>
|
||||
|
||||
<RobotNet10.FleetManager.Client.Components.RobotMonitor.Element.RobotView State="State" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RobotMonitorState State { get; set; } = null!;
|
||||
|
||||
private ElementReference containerRef;
|
||||
private ElementReference svgRef;
|
||||
private IJSObjectReference? jsModule;
|
||||
private DotNetObjectReference<SvgMonitorCanvas>? dotNetRef;
|
||||
|
||||
// Pan state
|
||||
private bool isPanning;
|
||||
private (double X, double Y)? panLastScreen; // Last screen coordinates (for incremental delta calculation)
|
||||
|
||||
private MousePositionDisplay MousePositionDisplayRef = default!;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
|
||||
try
|
||||
{
|
||||
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./js/svgMonitor.js");
|
||||
|
||||
await jsModule.InvokeVoidAsync("initMonitor", svgRef, dotNetRef);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to initialize JS module: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (jsModule != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await jsModule.InvokeVoidAsync("disposeMonitor");
|
||||
await jsModule.DisposeAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
dotNetRef?.Dispose();
|
||||
}
|
||||
|
||||
// Called from JavaScript
|
||||
[JSInvokable]
|
||||
public async Task OnMouseMove(double svgX, double svgY, double screenX = 0, double screenY = 0)
|
||||
{
|
||||
// Update mouse position (world coordinates)
|
||||
var (worldX, worldY) = State.SvgToWorld(svgX, svgY);
|
||||
MousePositionDisplayRef.Update(worldX, worldY);
|
||||
|
||||
// Update pan - use incremental delta to avoid accumulation issues
|
||||
// Key insight: When panning, ViewBox changes after each pan, which changes SVG coordinates
|
||||
// of the same screen point. If we calculate delta from the start point each time,
|
||||
// we get cumulative error because the start point's SVG coordinates change.
|
||||
// Solution: Calculate delta incrementally from the last mouse position, not from start
|
||||
if (isPanning && panLastScreen.HasValue)
|
||||
{
|
||||
// Calculate incremental delta: from last position to current position
|
||||
// This avoids accumulation because we're always calculating relative to the last move
|
||||
if (jsModule != null)
|
||||
{
|
||||
await PanIncrementalAsync(panLastScreen.Value.X, panLastScreen.Value.Y, screenX, screenY);
|
||||
}
|
||||
|
||||
// Update last position for next move
|
||||
panLastScreen = (screenX, screenY);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PanIncrementalAsync(double lastScreenX, double lastScreenY, double currentScreenX, double currentScreenY)
|
||||
{
|
||||
if (jsModule == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Convert last and current screen positions to SVG coordinates
|
||||
// using the CURRENT viewBox (before this pan)
|
||||
var lastSvg = await jsModule.InvokeAsync<double[]>("screenToSvgArray", lastScreenX, lastScreenY);
|
||||
var currentSvg = await jsModule.InvokeAsync<double[]>("screenToSvgArray", currentScreenX, currentScreenY);
|
||||
|
||||
if (lastSvg.Length >= 2 && currentSvg.Length >= 2)
|
||||
{
|
||||
// Calculate incremental delta: how much the mouse moved since last position
|
||||
// Pan moves viewBox in opposite direction of mouse movement
|
||||
var dx = lastSvg[0] - currentSvg[0];
|
||||
var dy = lastSvg[1] - currentSvg[1];
|
||||
|
||||
State.Pan(dx, dy);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error in PanIncrementalAsync: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnMouseDown(double svgX, double svgY, int button, double screenX = 0, double screenY = 0)
|
||||
{
|
||||
// Middle mouse button (button 1) - start pan
|
||||
if (button == 1)
|
||||
{
|
||||
isPanning = true;
|
||||
panLastScreen = (screenX, screenY);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnMouseUp(double svgX, double svgY, int button)
|
||||
{
|
||||
// End pan
|
||||
if (button == 1)
|
||||
{
|
||||
isPanning = false;
|
||||
panLastScreen = null;
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnWheel(double svgX, double svgY, double deltaY)
|
||||
{
|
||||
// Use same zoom factor as LayoutEditor for consistency
|
||||
var factor = deltaY > 0 ? 0.9 : 1.1;
|
||||
State.Zoom(factor, svgX, svgY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* SVG Monitor Canvas Styles */
|
||||
|
||||
.svg-monitor-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #808080;
|
||||
border: 1px solid var(--mud-palette-lines-default);
|
||||
min-width: 0; /* Important for flex child overflow */
|
||||
min-height: 0; /* Important for flex child overflow */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.monitor-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.monitor-svg:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
Reference in New Issue
Block a user