update move. scale mapping

This commit is contained in:
Đăng Nguyễn 2025-10-02 09:49:16 +07:00
parent 2640fb92c1
commit 93097412b0
10 changed files with 560 additions and 93 deletions

View File

@ -1,72 +1,55 @@
@inject HttpClient Http
<style>
.selected {
background-color: #3399ff !important;
}
.selected > td {
color: white !important;
}
.selected > td .mud-input {
color: white !important;
}
</style>
<div class="d-flex flex-row w-100 h-100 overflow-hidden">
<MudTable @ref="Table" Items="@MapsShow" T="MapDto" Dense Hover ReadOnly FixedHeader RowClass="cursor-pointer" Striped Elevation="10"
ServerData="ReloadData" Loading=@IsLoading RowClassFunc="@SelectedRowClassFunc" OnRowClick="RowClickEvent" Height="90vh" HorizontalScrollbar>
<ToolBarContent>
<MudText Typo="Typo.h6">Maps</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>Nr</MudTh>
<MudTh>Name</MudTh>
<MudTh>Width (m)</MudTh>
<MudTh>Height (m)</MudTh>
<MudTh>Resolution (m/px)</MudTh>
<MudTh>OriginX</MudTh>
<MudTh>OriginY</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Nr">
@(Table?.CurrentPage * Table?.RowsPerPage + MapsShow.IndexOf(context) + 1)
</MudTd>
<MudTd DataLabel="Name">
@context.Name
</MudTd>
<MudTd DataLabel="Width">
@context.Width
</MudTd>
<MudTd DataLabel="Height">
@context.Height
</MudTd>
<MudTd DataLabel="Resolution">
@context.Resolution
</MudTd>
<MudTd DataLabel="OriginX">
@context.OriginX
</MudTd>
<MudTd DataLabel="OriginY">
@context.OriginY
</MudTd>
<MudTd>
<MudMenuItem Icon="@Icons.Material.Filled.Edit" IconColor="Color.Info">Active</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error">Delete</MudMenuItem>
</MudTd>
</RowTemplate>
<PagerContent>
<div class="d-flex w-100 flex-row-reverse">
<MudTablePager Style="width: 100%;" PageSizeOptions="new[] { 25, 100, 200 }" />
</div>
</PagerContent>
</MudTable>
</div>
<MudTable Class="h-100 w-100" @ref="Table" Items="@MapsShow" T="MapDto" Dense Hover ReadOnly FixedHeader RowClass="cursor-pointer" Striped Elevation="10"
ServerData="ReloadData" Loading=@IsLoading Height="88%" HorizontalScrollbar=true>
<ToolBarContent >
<MudText Typo="Typo.h6">Maps</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>Nr</MudTh>
<MudTh>Name</MudTh>
<MudTh>Width (m)</MudTh>
<MudTh>Height (m)</MudTh>
<MudTh>Resolution (m/px)</MudTh>
<MudTh>OriginX</MudTh>
<MudTh>OriginY</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Nr">
@(Table?.CurrentPage * Table?.RowsPerPage + MapsShow.IndexOf(context) + 1)
</MudTd>
<MudTd DataLabel="Name">
@context.Name
</MudTd>
<MudTd DataLabel="Width">
@context.Width
</MudTd>
<MudTd DataLabel="Height">
@context.Height
</MudTd>
<MudTd DataLabel="Resolution">
@context.Resolution
</MudTd>
<MudTd DataLabel="OriginX">
@context.OriginX
</MudTd>
<MudTd DataLabel="OriginY">
@context.OriginY
</MudTd>
<MudTd>
<MudMenuItem Icon="@Icons.Material.Filled.Edit" IconColor="Color.Info">Active</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error">Delete</MudMenuItem>
</MudTd>
</RowTemplate>
<PagerContent>
<div class="d-flex w-100 flex-row-reverse">
<MudTablePager Style="width: 100%;" PageSizeOptions="new[] { 25, 100, 200 }" />
</div>
</PagerContent>
</MudTable>
@code {
private string txtSearch = "";
private bool IsLoading = false;
@ -137,29 +120,4 @@
MapsShow = tasks.Skip(state.Page * state.PageSize).Take(state.PageSize).ToList();
return Task.FromResult(new TableData<MapDto>() { TotalItems = tasks.Count, Items = MapsShow });
}
private void RowClickEvent(TableRowClickEventArgs<MapDto> tableRowClickEventArgs) { }
private string SelectedRowClassFunc(MapDto element, int rowNumber)
{
if (selectedRowNumber == rowNumber && Table?.SelectedItem != null && !Table.SelectedItem.Equals(element))
{
return string.Empty;
}
else if (selectedRowNumber == rowNumber && Table?.SelectedItem != null && Table.SelectedItem.Equals(element))
{
return "selected";
}
else if (Table?.SelectedItem != null && Table.SelectedItem.Equals(element))
{
selectedRowNumber = rowNumber;
MapSelected = element;
// MapPreviewRef?.SetMapPreview(MapSelected);
return "selected";
}
else
{
return string.Empty;
}
}
}

View File

@ -1,5 +1,317 @@
<h3>MapView</h3>
@inject IJSRuntime JS
@using Excubo.Blazor.Canvas.Contexts
<div class="view">
<div class="toolbar">
<MudTooltip Text="Start Localization" role="button" Placement="Placement.Bottom" Color="Color.Info">
<button type="button" class="btn btn-secondary action-button" disabled="@(false)">
<i class="mdi mdi-play icon-button"></i>
</button>
</MudTooltip>
<MudTooltip Text="Stop Localization" role="button" Placement="Placement.Bottom" Color="Color.Info">
<button type="button" class="btn btn-secondary action-button" disabled="@(true)">
<i class="mdi mdi-pause icon-button"></i>
</button>
</MudTooltip>
<MudTooltip Text="Reset View" role="button" Placement="Placement.Bottom" Color="Color.Info">
<button type="button" class="btn btn-secondary action-button" @onclick="ResetView">
<i class="mdi mdi-restore icon-button"></i>
</button>
</MudTooltip>
<div class="ms-auto d-flex align-items-center">
<div class="zoom-info-container">
<small class="zoom-info">
<span class="info-item">
<i class="mdi mdi-magnify"></i>
Zoom: @($"{ZoomScale:F2}x")
</span>
<span class="info-separator">|</span>
<span class="info-item">
<i class="mdi mdi-crosshairs-gps"></i>
Mouse: (@($"{MouseX:F0}"), @($"{MouseY:F0}"))
</span>
<span class="info-separator">|</span>
<span class="info-item">
<i class="mdi mdi-map-marker"></i>
World: (@($"{WorldMouseX:F2}m"), @($"{WorldMouseY:F2}m"))
</span>
<span class="info-separator">|</span>
<span class="info-item">
<i class="mdi mdi-map-marker"></i>
Translate: (@($"{CanvasTranslateX:F2}"), @($"{CanvasTranslateY:F2}"))
</span>
</small>
</div>
</div>
</div>
<div @ref="ViewContainerRef" class="d-flex position-relative w-100 flex-grow-1 overflow-hidden">
<canvas @ref="CanvasRef"
@onwheel="HandleWheel"
@onwheel:preventDefault="true"
@onmousemove="HandleMouseMove"
@onmouseleave="HandleMouseLeave"
style="display: block; cursor: crosshair; transform: scale(1, -1)"></canvas>
</div>
</div>
@code {
private ElementReference CanvasRef;
private ElementReference ViewContainerRef;
private double ZoomScale = 1.0;
private const double MIN_ZOOM = 0.1;
private const double MAX_ZOOM = 5.0;
private const double BASE_PIXELS_PER_METER = 50.0;
private bool IsMouseInCanvas = false;
private double MouseX;
private double MouseY;
private double OriginX = 0;
private double OriginY = 0;
private double WorldMouseX;
private double WorldMouseY;
private double CanvasWidth;
private double CanvasHeight;
private double CanvasTranslateX = 0;
private double CanvasTranslateY = 0;
protected override async Task OnAfterRenderAsync(bool first_render)
{
await base.OnAfterRenderAsync(first_render);
if (!first_render) return;
var parentSize = await JS.InvokeAsync<DomRect>("getElementSize", ViewContainerRef);
CanvasWidth = parentSize.Width;
CanvasHeight = parentSize.Height;
await JS.InvokeVoidAsync("setCanvasSize", CanvasRef, CanvasWidth, CanvasHeight);
CanvasTranslateX = CanvasWidth / 2;
CanvasTranslateY = CanvasHeight / 2;
await DrawCanvas();
}
private async Task DrawCanvas()
{
await using var ctx = await JS.GetContext2DAsync(CanvasRef);
await ctx.ClearRectAsync(0, 0, CanvasWidth, CanvasHeight);
await ctx.SaveAsync();
await ctx.TranslateAsync(CanvasTranslateX, CanvasTranslateY);
await ctx.ScaleAsync(ZoomScale, ZoomScale);
await DrawGrid(ctx);
await DrawAxes(ctx);
await ctx.RestoreAsync();
}
private async Task DrawAxes(Context2D ctx)
{
double originCanvasX = OriginX * BASE_PIXELS_PER_METER;
double originCanvasY = OriginY * BASE_PIXELS_PER_METER;
await ctx.FillStyleAsync("red");
await ctx.BeginPathAsync();
await ctx.ArcAsync(originCanvasX, originCanvasY, 10 / ZoomScale, 0, Math.PI * 2);
await ctx.FillAsync(FillRule.NonZero);
await ctx.LineWidthAsync(2 / ZoomScale);
await ctx.StrokeStyleAsync("blue");
await ctx.BeginPathAsync();
await ctx.MoveToAsync(-CanvasWidth / ZoomScale, originCanvasY);
await ctx.LineToAsync(CanvasWidth / ZoomScale, originCanvasY);
await ctx.StrokeAsync();
await ctx.StrokeStyleAsync("red");
await ctx.BeginPathAsync();
await ctx.MoveToAsync(originCanvasX, -CanvasHeight / ZoomScale);
await ctx.LineToAsync(originCanvasX, CanvasHeight / ZoomScale);
await ctx.StrokeAsync();
double gridSpacingMeters = GetGridSpacingMeters();
double arrowLength = gridSpacingMeters * BASE_PIXELS_PER_METER;
double arrowHeadSize = 16 / ZoomScale;
await ctx.FillStyleAsync("blue");
await ctx.StrokeStyleAsync("blue");
await ctx.LineWidthAsync(4 / ZoomScale);
await ctx.BeginPathAsync();
await ctx.MoveToAsync(originCanvasX, originCanvasY);
await ctx.LineToAsync(originCanvasX + arrowLength, originCanvasY);
await ctx.StrokeAsync();
await ctx.BeginPathAsync();
await ctx.MoveToAsync(originCanvasX + arrowLength, originCanvasY);
await ctx.LineToAsync(originCanvasX + arrowLength - arrowHeadSize, originCanvasY - arrowHeadSize / 2);
await ctx.LineToAsync(originCanvasX + arrowLength - arrowHeadSize, originCanvasY + arrowHeadSize / 2);
await ctx.ClosePathAsync();
await ctx.FillAsync(FillRule.NonZero);
await ctx.FillStyleAsync("red");
await ctx.StrokeStyleAsync("red");
await ctx.LineWidthAsync(4 / ZoomScale);
await ctx.BeginPathAsync();
await ctx.MoveToAsync(originCanvasX, originCanvasY);
await ctx.LineToAsync(originCanvasX, originCanvasY + arrowLength);
await ctx.StrokeAsync();
await ctx.BeginPathAsync();
await ctx.MoveToAsync(originCanvasX, originCanvasY + arrowLength);
await ctx.LineToAsync(originCanvasX - arrowHeadSize / 2, originCanvasY + arrowLength - arrowHeadSize);
await ctx.LineToAsync(originCanvasX + arrowHeadSize / 2, originCanvasY + arrowLength - arrowHeadSize);
await ctx.ClosePathAsync();
await ctx.FillAsync(FillRule.NonZero);
}
private async Task DrawGrid(Context2D ctx)
{
await ctx.StrokeStyleAsync("rgba(200, 200, 200, 0.4)");
await ctx.LineWidthAsync(1 / ZoomScale);
await ctx.SetLineDashAsync(new double[] { 5 / ZoomScale, 5 / ZoomScale });
double gridSpacingMeters = GetGridSpacingMeters();
double gridSpacingPixels = gridSpacingMeters * BASE_PIXELS_PER_METER;
double visibleLeft = -CanvasTranslateX / ZoomScale;
double visibleRight = (CanvasWidth - CanvasTranslateX) / ZoomScale;
double visibleTop = -CanvasTranslateY / ZoomScale;
double visibleBottom = (CanvasHeight - CanvasTranslateY) / ZoomScale;
double startX = Math.Floor(visibleLeft / gridSpacingPixels) * gridSpacingPixels;
double startY = Math.Floor(visibleTop / gridSpacingPixels) * gridSpacingPixels;
for (double x = startX; x <= visibleRight; x += gridSpacingPixels)
{
await ctx.BeginPathAsync();
await ctx.MoveToAsync(x, visibleTop);
await ctx.LineToAsync(x, visibleBottom);
await ctx.StrokeAsync();
}
for (double y = startY; y <= visibleBottom; y += gridSpacingPixels)
{
await ctx.BeginPathAsync();
await ctx.MoveToAsync(visibleLeft, y);
await ctx.LineToAsync(visibleRight, y);
await ctx.StrokeAsync();
}
await ctx.SetLineDashAsync(new double[] { });
}
private double GetGridSpacingMeters()
{
if (BASE_PIXELS_PER_METER * ZoomScale >= 300) return 0.2;
else if (BASE_PIXELS_PER_METER * ZoomScale >= 150) return 0.5;
else if (BASE_PIXELS_PER_METER * ZoomScale >= 75) return 1.0;
else if (BASE_PIXELS_PER_METER * ZoomScale >= 40) return 2.0;
else if (BASE_PIXELS_PER_METER * ZoomScale >= 20) return 5.0;
else if (BASE_PIXELS_PER_METER * ZoomScale >= 10) return 10.0;
else return 20.0;
}
private double CanvasToWorldX(double canvasX)
{
return (canvasX - CanvasTranslateX) / ZoomScale / BASE_PIXELS_PER_METER - OriginX;
}
private double CanvasToWorldY(double canvasY)
{
return (canvasY - CanvasTranslateY) / ZoomScale / BASE_PIXELS_PER_METER - OriginY;
}
private double WorldToCanvasX(double worldX)
{
return (worldX + OriginX) * BASE_PIXELS_PER_METER * ZoomScale + CanvasTranslateX;
}
private double WorldToCanvasY(double worldY)
{
return (worldY + OriginY) * BASE_PIXELS_PER_METER * ZoomScale + CanvasTranslateY;
}
private async Task HandleMouseMove(MouseEventArgs e)
{
MouseX = e.OffsetX;
MouseY = e.OffsetY;
IsMouseInCanvas = true;
WorldMouseX = CanvasToWorldX(MouseX);
WorldMouseY = CanvasToWorldY(MouseY);
StateHasChanged();
if (e.Buttons == 4)
{
CanvasTranslateX += e.MovementX;
CanvasTranslateY -= e.MovementY;
await DrawCanvas();
}
}
private async Task HandleMouseLeave(MouseEventArgs e)
{
IsMouseInCanvas = false;
MouseX = 0;
MouseY = 0;
StateHasChanged();
await DrawCanvas();
}
private async Task HandleWheel(WheelEventArgs e)
{
if (e.Buttons == 4) return;
const double zoomFactor = 0.1;
double oldZoom = ZoomScale;
if (e.DeltaY < 0) ZoomScale = Math.Min(MAX_ZOOM, ZoomScale * (1 + zoomFactor));
else ZoomScale = Math.Max(MIN_ZOOM, ZoomScale * (1 - zoomFactor));
if (Math.Abs(ZoomScale - oldZoom) < 0.001) return;
MouseX = e.OffsetX;
MouseY = e.OffsetY;
WorldMouseX = CanvasToWorldX(MouseX);
WorldMouseY = CanvasToWorldY(MouseY);
double zoomPointWorldX = (MouseX - CanvasTranslateX) / oldZoom / BASE_PIXELS_PER_METER - OriginX;
double zoomPointWorldY = (MouseY - CanvasTranslateY) / oldZoom / BASE_PIXELS_PER_METER - OriginY;
double newZoomPointCanvasX = (zoomPointWorldX + OriginX) * BASE_PIXELS_PER_METER * ZoomScale;
double newZoomPointCanvasY = (zoomPointWorldY + OriginY) * BASE_PIXELS_PER_METER * ZoomScale;
CanvasTranslateX = MouseX - newZoomPointCanvasX;
CanvasTranslateY = MouseY - newZoomPointCanvasY;
StateHasChanged();
await DrawCanvas();
}
private async Task ResetView()
{
CanvasTranslateX = CanvasWidth / 2;
CanvasTranslateY = CanvasHeight / 2;
ZoomScale = 1.0;
StateHasChanged();
await DrawCanvas();
}
public class DomRect
{
public double Width { get; set; }
public double Height { get; set; }
}
}

View File

@ -0,0 +1,173 @@
.view {
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
background-color: var(--mud-palette-surface);
border-radius: var(--mud-default-borderradius);
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
box-shadow: var(--mud-elevation-10);
}
.view .toolbar {
width: 100%;
height: 3rem;
flex: 0 0 auto;
display: flex;
align-items: center;
padding: 0.5rem;
border-bottom: 1px solid var(--mud-palette-divider);
background-color: var(--mud-palette-background);
}
.view .toolbar .action-button {
padding: 0rem 0.4rem;
margin-right: 0.5rem;
border-radius: var(--mud-default-borderradius);
transition: all 0.3s ease;
background-color: var(--bs-gray-200);
}
.view .toolbar .action-button:hover {
background-color: var(--mud-palette-action-hover);
transform: translateY(-1px);
}
.view .toolbar .action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.view .toolbar .action-button:disabled:hover {
transform: none;
background-color: transparent;
}
.view .toolbar .icon-button {
font-size: 1.2rem;
color: var(--mud-palette-primary);
}
.view canvas {
transition: cursor 0.2s ease;
}
.view canvas:hover {
cursor: crosshair;
}
/* Enhanced zoom and coordinate info styling */
.zoom-info-container {
display: flex;
align-items: center;
background-color: var(--mud-palette-background);
border: 1px solid var(--mud-palette-divider);
border-radius: 6px;
padding: 0.5rem 0.75rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.zoom-info {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 0.8rem;
color: var(--mud-palette-text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.info-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
}
.info-item i {
font-size: 0.9rem;
color: var(--mud-palette-primary);
}
.info-separator {
color: var(--mud-palette-divider);
font-weight: 300;
margin: 0 0.25rem;
}
/* Responsive design for info container */
@media (max-width: 768px) {
.zoom-info-container {
padding: 0.25rem 0.5rem;
}
.zoom-info {
font-size: 0.7rem;
gap: 0.25rem;
}
.info-item {
gap: 0.15rem;
}
.info-item i {
font-size: 0.8rem;
}
}
/* Very small screens - stack vertically */
@media (max-width: 480px) {
.zoom-info {
flex-direction: column;
gap: 0.15rem;
text-align: center;
}
.info-separator {
display: none;
}
.zoom-info-container {
padding: 0.35rem;
}
}
/* Hover effects for info container */
.zoom-info-container:hover {
background-color: var(--mud-palette-action-hover);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Animation for coordinate updates */
.info-item {
transition: color 0.2s ease;
}
.info-item:hover {
color: var(--mud-palette-primary);
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.zoom-info-container {
border-width: 2px;
background-color: var(--mud-palette-surface);
}
.info-item {
font-weight: 600;
}
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.zoom-info-container {
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.zoom-info-container:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.4);
}
}

View File

@ -7,7 +7,7 @@
<PageTitle>Map Manager</PageTitle>
<div class="d-flex w-100 h-100 p-2 overflow-hidden flex-row">
<div style="height: 100%; width: 40%">
<div class="me-4" style="height: 100%; width: 40%">
<RobotApp.Client.Pages.Components.Mapping.MapTable />
</div>
<div class="flex-grow-1 h-100" style="width: 60%">

View File

@ -14,3 +14,5 @@
@using MudBlazor
@using RobotApp.Common.Shares
@using RobotApp.Common.Shares.Dtos
@using Excubo.Blazor.Canvas
@using Excubo.Blazor.Canvas.Contexts

View File

@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Excubo.Blazor.Canvas" Version="3.2.91" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.1" />
<PackageReference Include="MudBlazor" Version="8.12.0" />
@ -20,7 +21,6 @@
<ItemGroup>
<Folder Include="Models\" />
<Folder Include="wwwroot\js\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,11 @@
window.getElementSize = (element) => {
return {
width: element.clientWidth,
height: element.clientHeight,
};
}
window.setCanvasSize = (canvas, width, height) => {
canvas.width = width;
canvas.height = height;
}

View File

@ -40,6 +40,7 @@
<script src="@Assets["lib/bootstrap/js/bootstrap.bundle.min.js"]"></script>
<script src="@Assets["lib/bootstrap/js/bootstrap.min.js"]"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["js/canvas.js"]"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc;
namespace RobotApp.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ImagesController : ControllerBase
{
}
}

Binary file not shown.