25 KiB
LayoutManager - Technical Documentation
Version: 1.0
Last Updated: 2024-12-02
Target Audience: Developers
📋 Table of Contents
- Architecture Overview
- Component Structure
- State Management
- API Integration
- Implementation Details
- 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
-
Component Pattern: Separation of Page vs Component
LayoutManager.razor(Page): Routing, render mode, providersLayoutManagerComponent.razor(Component): Business logic, UI
-
State Management: Centralized state with event notification
LayoutManagerState: Single source of truthOnStateChangedevent for reactive updates
-
API Service: HTTP client wrapper
MapManagerApiService: Encapsulates all API calls- Typed DTOs for request/response
-
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 containerviewBoxdefines 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
- 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));
}
}
- 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
- Backend Controller:
[HttpPost("custom-action")]
public async Task<IActionResult> CustomAction([FromBody] CustomRequest request)
{
var result = await _service.DoSomethingAsync(request);
return Ok(result);
}
- 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>();
}
- 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
OnStateChangedevent? - 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
- Always validate input before API calls
- Handle exceptions and show user-friendly messages
- Use loading indicators for async operations
- Dispose subscriptions and timers
- Keep components small and focused
- Extract reusable logic into services
- Use typed DTOs, avoid magic strings
- Log errors for debugging
- Test with real data (large images, many nodes)
- 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
- MudBlazor Documentation
- Blazor WebAssembly Guide
- ImageSharp Documentation
- SVG Specification
- VDMA LIF Standard
Questions? Contact: dev-team@phenikaa.com