Files
I150/docs/MapEditor/V2-DangNV/LAYOUTMANAGER_TECHNICAL.md
2026-07-03 16:37:12 +07:00

25 KiB
Raw Blame History

LayoutManager - Technical Documentation

Version: 1.0
Last Updated: 2024-12-02
Target Audience: Developers


📋 Table of Contents

  1. Architecture Overview
  2. Component Structure
  3. State Management
  4. API Integration
  5. Implementation Details
  6. Extension Guide

Architecture Overview

Tech Stack

┌─────────────────────────────────────────────────┐
│              Frontend (Blazor WASM)             │
├─────────────────────────────────────────────────┤
│  RobotNet10.RobotApp.Client (Host)             │
│    └─ RobotNet10.MapEditor (Component Library) │
│       ├─ Pages/                                 │
│       │  └─ LayoutManager.razor (Route)        │
│       ├─ Components/                            │
│       │  ├─ LayoutManagerComponent.razor       │
│       │  ├─ LayoutTreePanel.razor              │
│       │  ├─ LayoutPreviewPanel.razor           │
│       │  ├─ SvgPreviewCanvas.razor             │
│       │  └─ Dialogs/                            │
│       ├─ Services/                              │
│       │  ├─ State/                              │
│       │  │  └─ LayoutManagerState.cs           │
│       │  └─ API/                                │
│       │     └─ MapManagerApiService.cs         │
│       └─ Models/                                │
│          └─ TreeItemModel.cs                   │
└─────────────────────────────────────────────────┘
                     ↕ HTTP/REST
┌─────────────────────────────────────────────────┐
│           Backend (ASP.NET Core API)            │
├─────────────────────────────────────────────────┤
│  RobotNet10.MapManager                         │
│    ├─ Controllers/                              │
│    │  ├─ LayoutManagerController.cs            │
│    │  ├─ LayoutDataController.cs               │
│    │  └─ ImagesController.cs                   │
│    ├─ Services/                                 │
│    │  ├─ LayoutService.cs                      │
│    │  ├─ IImageStorageService.cs               │
│    │  └─ FileSystemImageStorageService.cs      │
│    └─ Data/                                     │
│       └─ MapManagerDbContext.cs                │
└─────────────────────────────────────────────────┘
                     ↕
┌─────────────────────────────────────────────────┐
│              Database (SQLite)                  │
│  - Layouts, LayoutVersions, LayoutLevels       │
│  - Nodes, Edges, Stations                      │
│  - LayoutLevelEditorSettings                   │
└─────────────────────────────────────────────────┘

Design Patterns

  1. Component Pattern: Separation of Page vs Component

    • LayoutManager.razor (Page): Routing, render mode, providers
    • LayoutManagerComponent.razor (Component): Business logic, UI
  2. State Management: Centralized state with event notification

    • LayoutManagerState: Single source of truth
    • OnStateChanged event for reactive updates
  3. API Service: HTTP client wrapper

    • MapManagerApiService: Encapsulates all API calls
    • Typed DTOs for request/response
  4. Repository Pattern: Backend data access

    • ILayoutService: Business logic interface
    • EF Core for data persistence

Component Structure

File Organization

RobotNet10.MapEditor/
├─ Pages/
│  └─ LayoutManager.razor              ← Route entry point
│
├─ Components/
│  └─ LayoutManager/
│     ├─ LayoutManagerComponent.razor  ← Main component
│     ├─ LayoutTreePanel.razor         ← Hierarchical tree
│     ├─ LayoutPreviewPanel.razor      ← Preview + actions
│     └─ Dialogs/
│        ├─ CreateLayoutDialog.razor
│        ├─ CreateVersionDialog.razor
│        ├─ CreateLevelDialog.razor
│        ├─ EditLevelDialog.razor
│        ├─ ImportLayoutDialog.razor
│        └─ ExportLayoutDialog.razor
│
├─ Components/Shared/
│  └─ SvgPreviewCanvas.razor           ← SVG rendering
│
├─ Services/
│  ├─ State/
│  │  └─ LayoutManagerState.cs         ← State management
│  └─ API/
│     └─ MapManagerApiService.cs       ← HTTP client
│
└─ Models/
   └─ TreeItemModel.cs                 ← Tree node model

Component Hierarchy

LayoutManager.razor (Page)
  └─ LayoutManagerComponent.razor
      ├─ LayoutTreePanel.razor
      │   ├─ CreateLayoutDialog (MudDialog)
      │   ├─ CreateVersionDialog (MudDialog)
      │   ├─ CreateLevelDialog (MudDialog)
      │   └─ EditLevelDialog (MudDialog)
      │
      └─ LayoutPreviewPanel.razor
          ├─ SvgPreviewCanvas.razor
          ├─ ImportLayoutDialog (MudDialog)
          └─ ExportLayoutDialog (MudDialog)

State Management

LayoutManagerState.cs

Responsibilities:

  • Hold current UI state (selected layout/version/level)
  • Load data from API
  • Cache preview data and images
  • Notify components of changes via events

Key Properties:

public class LayoutManagerState
{
    // Data
    public List<LayoutDto> Layouts { get; private set; }
    public LayoutDto? SelectedLayout { get; private set; }
    public LayoutVersionDto? SelectedVersion { get; private set; }
    public LayoutLevelDto? SelectedLevel { get; private set; }
    
    // Preview Data
    public LayoutDataDto? PreviewData { get; private set; }
    public byte[]? PreviewImage { get; private set; }
    
    // Loading States
    public bool IsLoading { get; private set; }
    public bool IsLoadingPreview { get; private set; }
    
    // Event for reactive updates
    public event Action? OnStateChanged;
}

Key Methods:

// Load all layouts from API
public async Task LoadLayoutsAsync(string? search = null)

// Select a level and load its preview
public async Task SelectLevelAsync(LayoutLevelDto level)

// CRUD operations
public async Task CreateLayoutAsync(CreateLayoutRequest request)
public async Task CreateVersionAsync(Guid layoutId, CreateLayoutVersionRequest request)
public async Task CreateLevelAsync(Guid versionId, CreateLayoutLevelRequest request)
public async Task DeleteLayoutAsync(Guid layoutId)
public async Task DeleteVersionAsync(Guid versionId)
public async Task DeleteLevelAsync(Guid levelId)

Usage Pattern:

@inject LayoutManagerState State
@implements IDisposable

protected override async Task OnInitializedAsync()
{
    // Subscribe to state changes
    State.OnStateChanged += StateHasChanged;
    
    // Load initial data
    await State.LoadLayoutsAsync();
}

public void Dispose()
{
    // Unsubscribe to prevent memory leaks
    State.OnStateChanged -= StateHasChanged;
}

API Integration

MapManagerApiService.cs

Base Configuration:

// Program.cs
builder.Services.AddHttpClient<MapManagerApiService>(client =>
{
    var baseUrl = builder.Configuration["MapManagerApi:BaseUrl"] ?? "https://localhost:5001";
    client.BaseAddress = new Uri(baseUrl);
});

builder.Services.AddScoped<LayoutManagerState>();

API Methods:

public class MapManagerApiService
{
    // Layouts
    Task<List<LayoutDto>> SearchLayoutsAsync(string? search = null)
    Task<LayoutDto> CreateLayoutAsync(CreateLayoutRequest request)
    Task<LayoutDto> UpdateLayoutAsync(Guid layoutId, UpdateLayoutRequest request)
    Task DeleteLayoutAsync(Guid layoutId)
    Task<LayoutDto> ActivateLayoutAsync(Guid layoutId)
    Task<LayoutDto> DeactivateLayoutAsync(Guid layoutId)
    
    // Versions
    Task<LayoutVersionDto> CreateVersionAsync(Guid layoutId, CreateLayoutVersionRequest request)
    Task<List<LayoutVersionDto>> GetVersionsAsync(Guid layoutId)
    Task DeleteVersionAsync(Guid versionId)
    
    // Levels
    Task<LayoutLevelDto> CreateLevelAsync(Guid versionId, CreateLayoutLevelRequest request)
    Task<LayoutLevelDto> CreateLevelWithImageAsync(
        Guid versionId, string layoutLevelId, int levelOrder,
        double resolution, double originX, double originY,
        Stream imageStream, string fileName)
    Task<LayoutLevelDto> UpdateLevelAsync(Guid levelId, UpdateLayoutLevelRequest request)
    Task DeleteLevelAsync(Guid levelId)
    
    // Layout Data
    Task<LayoutDataDto> GetLayoutDataAsync(Guid layoutLevelId)
    
    // Images
    Task<byte[]?> GetLayoutImageAsync(Guid layoutLevelId)
    Task UploadLayoutImageAsync(Guid layoutLevelId, Stream imageStream, string fileName)
    Task DeleteLayoutImageAsync(Guid layoutLevelId)
}

HTTP Request Flow

Example: Create Level with Image

1. Frontend: User fills CreateLevelDialog
   ↓
2. Frontend: Call CreateLevelWithImageAsync()
   ↓
3. HTTP: POST /api/layouts/versions/{versionId}/levels/with-image
   Content-Type: multipart/form-data
   Body:
     - layoutLevelId: "floor_1"
     - levelOrder: 0
     - resolution: 0.05
     - originX: 0
     - originY: 0
     - file: [PNG binary]
   ↓
4. Backend: LayoutManagerController.CreateLevelWithImage()
   a. Extract image dimensions (ImageSharp)
   b. Create LayoutLevel entity
   c. Create LayoutLevelEditorSettings entity
   d. Save to database
   e. Upload image to storage
   f. (Rollback if image upload fails)
   ↓
5. Backend: Return 201 Created with LayoutLevelDto
   ↓
6. Frontend: Update state, refresh UI
   ↓
7. Frontend: Show success notification

Implementation Details

1. Tree View Implementation

Challenge: MudBlazor MudTreeView has complex data binding.

Solution: Custom tree rendering with nested MudPaper + MudStack

@foreach (var layout in State.Layouts)
{
    <MudPaper>
        <MudStack Row="true">
            <MudIconButton Icon="..." OnClick="() => ToggleLayout(layout.Id)" />
            <MudIcon Icon="@Icons.Material.Filled.Map" />
            <MudText>@layout.LayoutName</MudText>
            <MudMenu>...</MudMenu>
        </MudStack>
    </MudPaper>
    
    @if (expandedLayouts.Contains(layout.Id))
    {
        <MudStack Class="ml-6">
            @foreach (var version in layout.Versions)
            {
                <!-- Nested version rendering -->
            }
        </MudStack>
    }
}

State:

private HashSet<Guid> expandedLayouts = new();
private HashSet<Guid> expandedVersions = new();

private void ToggleLayout(Guid layoutId)
{
    if (expandedLayouts.Contains(layoutId))
        expandedLayouts.Remove(layoutId);
    else
        expandedLayouts.Add(layoutId);
}

2. SVG Preview Canvas

Challenge: Responsive SVG that fits container without overflow.

Solution: Dynamic viewBox calculation

<svg width="100%" height="100%" viewBox="@ViewBoxString" ...>
    <image href="@GetImageDataUrl()" 
           x="0" y="0"
           width="@GetImageWidth()" 
           height="@GetImageHeight()" />
    
    <!-- Nodes, Edges, Stations -->
</svg>

ViewBox Logic:

private string ViewBoxString
{
    get
    {
        // If image exists, use physical size (meters)
        if (EditorSettings?.ImageWidth.HasValue == true)
        {
            var width = EditorSettings.ImageWidth.Value * EditorSettings.Resolution;
            var height = EditorSettings.ImageHeight.Value * EditorSettings.Resolution;
            return $"0 0 {width:F2} {height:F2}";
        }
        
        // Otherwise, calculate from nodes
        if (LayoutData?.Nodes.Count > 0)
        {
            var minX = LayoutData.Nodes.Min(n => n.X);
            var maxX = LayoutData.Nodes.Max(n => n.X);
            var minY = LayoutData.Nodes.Min(n => n.Y);
            var maxY = LayoutData.Nodes.Max(n => n.Y);
            var padding = Math.Max((maxX - minX), (maxY - minY)) * 0.1;
            return $"{minX - padding:F2} {minY - padding:F2} ...";
        }
        
        // Default
        return "0 0 100 50";
    }
}

Key Points:

  • width="100%" height="100%" → SVG scales to container
  • viewBox defines coordinate system (meters, not pixels)
  • Image dimensions in meters: PixelSize × Resolution
  • preserveAspectRatio="none" → Image stretches to fill viewBox

3. Image Upload with Dimension Extraction

Frontend (Client-Side):

private async Task OnImageSelected(InputFileChangeEventArgs e)
{
    var file = e.File;
    
    // Read file as byte array
    using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
    using var ms = new MemoryStream();
    await stream.CopyToAsync(ms);
    var bytes = ms.ToArray();
    
    // Extract dimensions from PNG header (bytes 16-23)
    if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
    {
        imageWidth = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
        imageHeight = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
    }
}

Backend (Server-Side with ImageSharp):

// In FileSystemImageStorageService.cs
public async Task<(int width, int height)> GetImageDimensionsAsync(Stream imageStream)
{
    if (imageStream.CanSeek)
        imageStream.Position = 0;
    
    using var image = await Image.LoadAsync(imageStream);
    return (image.Width, image.Height);
}

// In LayoutManagerController.cs
[HttpPost("versions/{versionId:guid}/levels/with-image")]
public async Task<ActionResult<LayoutLevelDto>> CreateLevelWithImage(
    Guid versionId, [FromForm] string layoutLevelId, ... , IFormFile file)
{
    // Step 1: Extract dimensions
    int imageWidth, imageHeight;
    using (var stream = file.OpenReadStream())
    {
        (imageWidth, imageHeight) = await _imageStorageService.GetImageDimensionsAsync(stream);
    }
    
    // Step 2: Create level with dimensions
    var request = new CreateLayoutLevelRequest
    {
        LayoutLevelId = layoutLevelId,
        CoordinateSystem = new CoordinateSystemInfo
        {
            Resolution = resolution,
            OriginX = originX,
            OriginY = originY,
            ImageWidth = imageWidth,
            ImageHeight = imageHeight,
            BoundsMinX = 0,
            BoundsMaxX = imageWidth * resolution,
            BoundsMinY = 0,
            BoundsMaxY = imageHeight * resolution
        }
    };
    
    var level = await _layoutService.CreateLevelAsync(versionId, request);
    
    // Step 3: Upload image
    try
    {
        using (var stream = file.OpenReadStream())
        {
            await _imageStorageService.SaveImageAsync(level.Id, stream);
        }
    }
    catch
    {
        // Rollback: Delete created level
        await _layoutService.DeleteLevelAsync(level.Id);
        throw;
    }
    
    return CreatedAtAction(nameof(GetLevel), new { levelId = level.Id }, dto);
}

4. File Download (JavaScript Interop)

Challenge: Download byte[] as file from Blazor WASM.

Solution: Generate data URL and trigger download via JS

private async Task DownloadImage()
{
    if (State.PreviewImage == null) return;
    
    var base64 = Convert.ToBase64String(State.PreviewImage);
    var fileName = $"{State.SelectedLevel.LayoutLevelId}_background.png";
    
    await JS.InvokeVoidAsync("eval", 
        $@"
        const link = document.createElement('a');
        link.href = 'data:image/png;base64,{base64}';
        link.download = '{fileName}';
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        ");
}

Alternative (Cleaner):

Create wwwroot/download.js:

window.downloadFile = (fileName, base64Data) => {
    const link = document.createElement('a');
    link.href = `data:image/png;base64,${base64Data}`;
    link.download = fileName;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
};

Then in Blazor:

await JS.InvokeVoidAsync("downloadFile", fileName, base64);

Extension Guide

Adding New Dialog

  1. Create Dialog Component:
@* NewFeatureDialog.razor *@
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar

<MudDialog>
    <DialogContent>
        <!-- Form fields -->
    </DialogContent>
    <DialogActions>
        <MudButton OnClick="Cancel">Cancel</MudButton>
        <MudButton Color="Color.Primary" OnClick="Submit">Submit</MudButton>
    </DialogActions>
</MudDialog>

@code {
    [CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
    [Parameter] public SomeDto Data { get; set; }
    
    private void Cancel() => MudDialog?.Cancel();
    
    private async Task Submit()
    {
        // Call API
        // Close dialog
        MudDialog?.Close(DialogResult.Ok(result));
    }
}
  1. Register in Parent Component:
private async Task OpenNewFeatureDialog()
{
    var dialog = await DialogService.ShowAsync<NewFeatureDialog>(
        "Title",
        new DialogParameters { ["Data"] = someData });
    
    var result = await dialog.Result;
    
    if (result != null && !result.Canceled)
    {
        Snackbar.Add("Success!", Severity.Success);
        await RefreshData();
    }
}

Adding New API Endpoint

  1. Backend Controller:
[HttpPost("custom-action")]
public async Task<IActionResult> CustomAction([FromBody] CustomRequest request)
{
    var result = await _service.DoSomethingAsync(request);
    return Ok(result);
}
  1. Frontend API Service:
public async Task<CustomResponse> CustomActionAsync(CustomRequest request)
{
    var response = await _httpClient.PostAsJsonAsync(
        $"{_baseUrl}api/layouts/custom-action", request);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadFromJsonAsync<CustomResponse>();
}
  1. Use in State:
public async Task PerformCustomActionAsync(CustomRequest request)
{
    IsLoading = true;
    NotifyStateChanged();
    
    try
    {
        var result = await _apiService.CustomActionAsync(request);
        // Update state
    }
    finally
    {
        IsLoading = false;
        NotifyStateChanged();
    }
}

Performance Optimization

1. Debounce Search:

private Timer? searchTimer;

private void OnSearchKeyUp(KeyboardEventArgs e)
{
    searchTimer?.Dispose();
    searchTimer = new Timer(async _ =>
    {
        await InvokeAsync(async () =>
        {
            await State.LoadLayoutsAsync(searchText);
        });
    }, null, 500, Timeout.Infinite); // 500ms debounce
}

2. Lazy Load Images:

// Only load image when level selected
public async Task SelectLevelAsync(LayoutLevelDto level)
{
    SelectedLevel = level;
    IsLoadingPreview = true;
    NotifyStateChanged();
    
    // Load preview data
    PreviewData = await _apiService.GetLayoutDataAsync(level.Id);
    
    // Load image separately (can be large)
    PreviewImage = await _apiService.GetLayoutImageAsync(level.Id);
    
    IsLoadingPreview = false;
    NotifyStateChanged();
}

3. Cache API Responses:

private Dictionary<Guid, LayoutDataDto> _previewCache = new();

public async Task<LayoutDataDto> GetLayoutDataCachedAsync(Guid levelId)
{
    if (_previewCache.TryGetValue(levelId, out var cached))
        return cached;
    
    var data = await _apiService.GetLayoutDataAsync(levelId);
    _previewCache[levelId] = data;
    return data;
}

Testing

Unit Tests

[Fact]
public async Task CreateLevel_WithImage_ShouldExtractDimensions()
{
    // Arrange
    var service = new FileSystemImageStorageService(logger);
    using var stream = File.OpenRead("test_1024x768.png");
    
    // Act
    var (width, height) = await service.GetImageDimensionsAsync(stream);
    
    // Assert
    Assert.Equal(1024, width);
    Assert.Equal(768, height);
}

Integration Tests

[Fact]
public async Task E2E_CreateLayoutWithLevel()
{
    // Create layout
    var layout = await apiService.CreateLayoutAsync(new CreateLayoutRequest
    {
        LayoutId = "test",
        LayoutName = "Test"
    });
    
    // Create version
    var version = await apiService.CreateVersionAsync(layout.Id, new CreateLayoutVersionRequest
    {
        Version = "1.0"
    });
    
    // Create level with image
    using var imageStream = File.OpenRead("test.png");
    var level = await apiService.CreateLevelWithImageAsync(
        version.Id, "floor_1", 0, 0.05, 0, 0, imageStream, "test.png");
    
    // Assert
    Assert.NotNull(level);
    Assert.Equal("floor_1", level.LayoutLevelId);
    Assert.NotNull(level.EditorSettings);
    Assert.True(level.EditorSettings.ImageWidth > 0);
}

Troubleshooting

Common Issues

1. CORS Errors:

Access to XMLHttpRequest at 'https://localhost:5001/api/layouts' from origin 'https://localhost:5002' 
has been blocked by CORS policy

Fix: Configure CORS in backend Program.cs:

builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.WithOrigins("https://localhost:5002")
              .AllowAnyMethod()
              .AllowAnyHeader();
    });
});

app.UseCors();

2. Image Upload 413 Payload Too Large:

Fix: Increase max request size:

// Program.cs
builder.Services.Configure<FormOptions>(options =>
{
    options.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10 MB
});

// Also in web.config for IIS
<system.webServer>
  <security>
    <requestFiltering>
      <requestLimits maxAllowedContentLength="10485760" />
    </requestFiltering>
  </security>
</system.webServer>

3. State Not Updating:

Check:

  • Subscribed to OnStateChanged event?
  • Calling StateHasChanged() in event handler?
  • Disposed subscription to prevent memory leaks?
protected override async Task OnInitializedAsync()
{
    State.OnStateChanged += StateHasChanged; // ✅
    await State.LoadLayoutsAsync();
}

public void Dispose()
{
    State.OnStateChanged -= StateHasChanged; // ✅ Important!
}

Best Practices

  1. Always validate input before API calls
  2. Handle exceptions and show user-friendly messages
  3. Use loading indicators for async operations
  4. Dispose subscriptions and timers
  5. Keep components small and focused
  6. Extract reusable logic into services
  7. Use typed DTOs, avoid magic strings
  8. Log errors for debugging
  9. Test with real data (large images, many nodes)
  10. Profile performance for bottlenecks

Future Improvements

  • Batch operations (delete multiple levels)
  • Undo/Redo for state changes
  • Keyboard shortcuts
  • Drag & drop image upload
  • Image cropping/editing in browser
  • Multi-select in tree (Ctrl+Click)
  • Export selected layouts to ZIP
  • Real-time collaboration (SignalR)
  • Offline support (IndexedDB cache)
  • Mobile-responsive layout

References


Questions? Contact: dev-team@phenikaa.com