AstrolBoard<TBox, TRelation> renders a Board<TBox, TRelation>: a list of boxes (Box<TBox>, with content of type TBox) and of relations between them (Relation<TRelation>). You decide how each box looks with BoxRender; the board changes position and size on the same object and raises OnChangeBoard so you can save it.
Move a box.
@using Astrol.Component.Board@using Astrol.Entities.Basic.UI.Board @* Drag the boxes, resize them from the corners, Ctrl+click to select several, arrows to move them, Ctrl+Z / Ctrl+Y, Ctrl+F to search and Ctrl+wheel to zoom. *@<div style="height:380px"> <AstrolBoard TBox="WorkItem" TRelation="object" Board="board" BackgroundType="BackgroundType.Points" MinWidth="80" MinHeight="60" OnChangeBoard="OnChange"> <BoxRender> <div style="height:100%; padding:8px; border-radius:8px; border:1px solid var(--color-gray-a); background:var(--layout-menu-bg); overflow:hidden"> <b>@context.BOX_NAME</b> <div style="font-size:.8em; opacity:.8">@context.Content.Owner</div> </div> </BoxRender> </AstrolBoard></div><p aria-live="polite">@message</p> @code { private string message = "Move a box."; private readonly Board<WorkItem, object> board = Create(); private void OnChange(Board<WorkItem, object> changed) { var first = changed.Boxes[0]; message = $"{first.BOX_NAME}: X {first.X:0}, Y {first.Y:0}, {first.Width:0} x {first.Height:0}"; } private static Board<WorkItem, object> Create() { var b = new Board<WorkItem, object>(); b.Boxes.Add(new Box<WorkItem> { X = 30, Y = 40, Width = 160, Height = 90, BOX_NAME = "Design", Content = new WorkItem { Owner = "Ana" } }); b.Boxes.Add(new Box<WorkItem> { X = 290, Y = 40, Width = 160, Height = 90, BOX_NAME = "Build", Content = new WorkItem { Owner = "Luis" } }); b.Boxes.Add(new Box<WorkItem> { X = 290, Y = 220, Width = 160, Height = 90, BOX_NAME = "Test", Content = new WorkItem { Owner = "Eva" } }); b.Relations.Add(new Relation<object> { BoxIdA = b.Boxes[0].Id, BoxIdB = b.Boxes[1].Id }); b.Relations.Add(new Relation<object> { BoxIdA = b.Boxes[1].Id, BoxIdB = b.Boxes[2].Id }); return b; } public class WorkItem { public string Owner { get; set; } = string.Empty; }}<AstrolBoard TBox="Task" TRelation="object" Board="board" ShowBoardContextMenu="true" ShowBoxContextMenu="true" OnOpenBoardContextMenu="p => { position = p; }" OnOpenBoxContextMenu="e => { (boxId, position) = e; }"> <BoardContextMenu> <MenuOpion NeedAuthorize="false" Icon="astrol-plus" Text="New box here" OnClick="AddBox" /> </BoardContextMenu> <BoxContextMenu> <MenuOpion NeedAuthorize="false" Icon="astrol-clone" Text="Duplicate" OnClick="Duplicate" HasSeparator="true" /> <MenuOpion NeedAuthorize="false" Icon="astrol-trash" IconColor="var(--aui-danger)" Text="Delete" OnClick="Delete" /> </BoxContextMenu> ...</AstrolBoard>private Position position = new();private string? boxId; // The position is in board pixels: divide by Scale to get model unitsprivate void AddBox() => board.Boxes.Add(new Box<Task> { X = position.X / board.Scale, Y = position.Y / board.Scale, BOX_NAME = "New" });private void Delete() => board.Boxes.RemoveAll(b => b.Id == boxId);<AstrolBoard TBox="Task" TRelation="object" Board="board" RelationAnchor="RelationAnchor.Border" LineStyle="RelationLineStyle.Curved" EditAnchors="true"> ... </AstrolBoard>// From the middle of the right side of A to the top quarter of the left side of Bboard.Relations.Add(new Relation<object>{ BoxIdA = a.Id, BoxIdB = b.Id, AnchorA = BoxAnchor.Right, // (1, 0.5) AnchorB = new BoxAnchor(0, 0.25F), // x and y from 0 to 1, relative to the box});<AstrolBoard TBox="Task" TRelation="object" Board="board" @ref="boardRef" RelationAnchor="RelationAnchor.Border" RelationArrow="RelationArrow.End" AllowConnect="true" OnConnect="Connect" BackgroundDrag="BoardBackgroundDrag.Select" SnapToGrid="20" SnapToBoxes="true" OnBoxesAdded="Added" OnBoxesRemoved="Removed"> <RelationRender>@context.Label</RelationRender> ...</AstrolBoard>// The relation is not on the board yet: complete it or cancel itprivate void Connect(BoardConnectEventArgs<object> e){ e.Relation.Label = "next"; e.Cancel = board.Relations.Any(r => r.BoxIdA == e.Relation.BoxIdA && r.BoxIdB == e.Relation.BoxIdB);} board.Relations.Add(new Relation<object> { BoxIdA = a.Id, BoxIdB = b.Id, Label = "Yes", Arrow = RelationArrow.Both });<button @onclick="Arrange">Arrange</button><AstrolBoard TBox="Task" TRelation="object" Board="board" @ref="boardRef"> ... </AstrolBoard>private AstrolBoard<Task, object>? boardRef; private Task Arrange() => boardRef!.ArrangeAsync(new BoardLayoutOptions{ Direction = BoardLayoutDirection.LeftToRight, LayerSpacing = 60,}); // Without the component (e.g. before showing the board):BoardLayout.Arrange(board);With ShowGuides="true", while dragging or resizing the selected box, dashed guide lines appear from its corners (outwards) and from the middle of its sides (fainter), up to the edge of the board, to align it with other boxes. They are off by default. Each box can force them with Box.ShowGuides (true / false; null follows the board). The look is changed with CSS variables:
<AstrolBoard TBox="Task" TRelation="object" Board="board" ShowGuides="true"> ... </AstrolBoard>// Per box: null = follow the board, true / false = force it for this boxboard.Boxes.Add(new Box<Task> { BOX_NAME = "Title", ShowGuides = false });/* Optional: line style, separation and opacity of the side lines */:root { --border-line: 1px dashed var(--color-red-transparent); --pbse-offset: 2px; --pbse-side-opacity: 0.45;}With @ref (private AstrolBoard<Task, object>? boardRef;):