From 2f29fdd301e80ec250c98751e6114c45ca0b4794 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sat, 25 Apr 2026 20:16:22 +0200
Subject: [PATCH 1/9] =?UTF-8?q?PlanViewerControl.axaml.cs=20=E2=80=94=20Ad?=
=?UTF-8?q?ded:=20=E2=80=A2=20A=20"minimap"=20toggle=20button=20(top-left,?=
=?UTF-8?q?=20always=20visible=20over=20the=20plan=20canvas,=20very=20smal?=
=?UTF-8?q?l)=20=E2=80=A2=20A=20minimap=20panel=20(300=C3=97300=20default,?=
=?UTF-8?q?=20with=20close=20button=20"=E2=9C=95",=20header=20bar,=20and?=
=?UTF-8?q?=20a=20Canvas=20for=20rendering)=20PlanViewerControl.axaml.cs?=
=?UTF-8?q?=20=E2=80=94=20Added:=201.=20State=20fields:=20Static=20=5Fmini?=
=?UTF-8?q?mapWidth/=5FminimapHeight=20(preserved=20in=20memory=20across?=
=?UTF-8?q?=20plans,=20not=20on=20disk),=20drag/resize=20state,=20node=20m?=
=?UTF-8?q?apping=20for=20minimap=20interaction=202.=20Toggle/Open/Close:?=
=?UTF-8?q?=20MinimapToggle=5FClick(object=3F,=20RoutedEventArgs),=20OpenM?=
=?UTF-8?q?inimapPanel(),=20CloseMinimapPanel()=203.=20Rendering=20(Render?=
=?UTF-8?q?Minimap()):=20=E2=80=A2=20Branch=20areas:=20Each=20child=20subt?=
=?UTF-8?q?ree=20of=20the=20root=20gets=20a=20transparent=20colored=20rect?=
=?UTF-8?q?angle=20(8=20distinct=20colors=20cycling)=20behind=20its=20node?=
=?UTF-8?q?s=20=E2=80=A2=20Edges:=20Scaled-down=20elbow=20connectors=20?=
=?UTF-8?q?=E2=80=A2=20Nodes:=20Small=20colored=20rectangles=20(red=20tint?=
=?UTF-8?q?=20for=20expensive=20nodes)=20=E2=80=A2=20Viewport=20box:=20Sem?=
=?UTF-8?q?i-transparent=20blue=20rectangle=20showing=20the=20visible=20po?=
=?UTF-8?q?rtion=20of=20the=20plan=204.=20Interaction:=20=E2=80=A2=20Click?=
=?UTF-8?q?=20&=20drag=20on=20minimap=20to=20pan=20the=20plan=20viewer=20?=
=?UTF-8?q?=E2=80=A2=20Single=20click=20on=20a=20node=20centers=20the=20pl?=
=?UTF-8?q?an=20viewer=20on=20that=20node=20=E2=80=A2=20Double=20click=20o?=
=?UTF-8?q?n=20a=20node=20zooms=20to=20~1/3=20viewport=20size=20and=20sele?=
=?UTF-8?q?cts=20the=20node=20=E2=80=A2=20Resize=20grip=20(bottom-right=20?=
=?UTF-8?q?corner)=20allows=20resizing=20between=20200=C3=97200=20and=2050?=
=?UTF-8?q?0=C3=97500=205.=20Live=20updates:=20Viewport=20box=20updates=20?=
=?UTF-8?q?on=20scroll,=20zoom,=20pan,=20and=20mouse=20wheel=20zoom.=20Min?=
=?UTF-8?q?imap=20re-renders=20on=20statement=20change.=206.=20Per-plan:?=
=?UTF-8?q?=20Each=20PlanViewerControl=20instance=20has=20its=20own=20mini?=
=?UTF-8?q?map=20state,=20so=20multiple=20open=20plans=20each=20have=20the?=
=?UTF-8?q?ir=20own=20minimap.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controls/PlanViewerControl.axaml | 71 ++-
.../Controls/PlanViewerControl.axaml.cs | 480 +++++++++++++++++-
src/PlanViewer.App/MainWindow.axaml.cs | 6 +-
3 files changed, 539 insertions(+), 18 deletions(-)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index fcd6bd6..26fda51 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -255,21 +255,62 @@
Background="{DynamicResource BorderBrush}"
IsVisible="False"/>
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 1b3dd71..eb7e380 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -118,6 +118,21 @@ public partial class PlanViewerControl : UserControl
private double _panStartOffsetX;
private double _panStartOffsetY;
+ // Minimap state
+ private static double _minimapWidth = 300;
+ private static double _minimapHeight = 300;
+ private const double MinimapDefaultSize = 300;
+ private const double MinimapMinSize = 200;
+ private const double MinimapMaxSize = 500;
+ private bool _minimapDragging;
+ private Point _minimapDragStart;
+ private Border? _minimapViewportBox;
+ private bool _minimapResizing;
+ private Point _minimapResizeStart;
+ private double _minimapResizeStartW;
+ private double _minimapResizeStartH;
+ private readonly Dictionary _minimapNodeMap = new();
+
public PlanViewerControl()
{
InitializeComponent();
@@ -127,11 +142,13 @@ public PlanViewerControl()
PlanScrollViewer.AddHandler(PointerPressedEvent, PlanScrollViewer_PointerPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel);
PlanScrollViewer.AddHandler(PointerMovedEvent, PlanScrollViewer_PointerMoved, Avalonia.Interactivity.RoutingStrategies.Tunnel);
PlanScrollViewer.AddHandler(PointerReleasedEvent, PlanScrollViewer_PointerReleased, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ PlanScrollViewer.ScrollChanged += (_, _) => UpdateMinimapViewportBox();
// Resolve non-control elements by traversal (Avalonia doesn't support x:Name on these types)
- // The Grid in Row 4 has 5 ColumnDefinitions:
+ // The 5-column Grid in Row 2 is now the grandparent of PlanScrollViewer
+ // (PlanScrollViewer sits inside a minimap wrapper Grid at Column 2).
// [0]=Statements(0), [1]=StmtSplitter(0), [2]=Canvas(*), [3]=PropsSplitter(0), [4]=Props(0)
- var planGrid = (Grid)PlanScrollViewer.Parent!;
+ var planGrid = (Grid)PlanScrollViewer.Parent!.Parent!;
_statementsColumn = planGrid.ColumnDefinitions[0];
_statementsSplitterColumn = planGrid.ColumnDefinitions[1];
_splitterColumn = planGrid.ColumnDefinitions[3];
@@ -342,6 +359,7 @@ public void Clear()
StatementsButton.IsVisible = false;
StatementsButtonSeparator.IsVisible = false;
ClosePropertiesPanel();
+ CloseMinimapPanel();
}
private static void CountNodeWarnings(PlanNode node, ref int total, ref int critical)
@@ -390,6 +408,10 @@ private void RenderStatement(PlanStatement statement)
PlanScrollViewer.ContextMenu = BuildCanvasContextMenu();
CostText.Text = "";
+
+ // Update minimap if visible
+ if (MinimapPanel.IsVisible)
+ Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded);
}
#region Node Rendering
@@ -3095,6 +3117,7 @@ private void SetZoom(double level)
_zoomTransform.ScaleX = _zoomLevel;
_zoomTransform.ScaleY = _zoomLevel;
ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%";
+ UpdateMinimapViewportBox();
}
///
@@ -3121,6 +3144,7 @@ private void SetZoomAtPoint(double level, Point viewportAnchor)
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY);
+ UpdateMinimapViewportBox();
});
}
@@ -3172,6 +3196,7 @@ private void PlanScrollViewer_PointerMoved(object? sender, PointerEventArgs e)
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
PlanScrollViewer.Offset = new Vector(newX, newY);
+ UpdateMinimapViewportBox();
});
e.Handled = true;
@@ -3464,6 +3489,457 @@ private void CloseStatementsPanel()
#endregion
+ #region Minimap
+
+ private void MinimapToggle_Click(object? sender, RoutedEventArgs e)
+ {
+ if (MinimapPanel.IsVisible)
+ CloseMinimapPanel();
+ else
+ OpenMinimapPanel();
+ }
+
+ private void MinimapClose_Click(object? sender, RoutedEventArgs e)
+ {
+ CloseMinimapPanel();
+ }
+
+ private void OpenMinimapPanel()
+ {
+ MinimapPanel.Width = _minimapWidth;
+ MinimapPanel.Height = _minimapHeight;
+ MinimapPanel.IsVisible = true;
+ RenderMinimap();
+ }
+
+ private void CloseMinimapPanel()
+ {
+ MinimapPanel.IsVisible = false;
+ _minimapDragging = false;
+ _minimapResizing = false;
+ }
+
+ private void RenderMinimap()
+ {
+ MinimapCanvas.Children.Clear();
+ _minimapNodeMap.Clear();
+ _minimapViewportBox = null;
+
+ if (_currentStatement?.RootNode == null || PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0)
+ return;
+
+ var canvasW = MinimapCanvas.Bounds.Width;
+ var canvasH = MinimapCanvas.Bounds.Height;
+ if (canvasW <= 0 || canvasH <= 0)
+ {
+ // Defer until layout is ready
+ Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded);
+ return;
+ }
+
+ var scaleX = canvasW / PlanCanvas.Width;
+ var scaleY = canvasH / PlanCanvas.Height;
+ var scale = Math.Min(scaleX, scaleY);
+
+ // Render branch areas with transparent colored backgrounds
+ RenderMinimapBranches(_currentStatement.RootNode, scale);
+
+ // Render edges
+ RenderMinimapEdges(_currentStatement.RootNode, scale);
+
+ // Render nodes
+ RenderMinimapNodes(_currentStatement.RootNode, scale);
+
+ // Render viewport indicator
+ RenderMinimapViewportBox(scale);
+
+ // Attach resize via bottom-right 8px drag area
+ var resizeGrip = new Border
+ {
+ Width = 10,
+ Height = 10,
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.BottomRightCorner)
+ };
+ Canvas.SetLeft(resizeGrip, canvasW - 10);
+ Canvas.SetTop(resizeGrip, canvasH - 10);
+ resizeGrip.PointerPressed += MinimapResizeGrip_PointerPressed;
+ resizeGrip.PointerMoved += MinimapResizeGrip_PointerMoved;
+ resizeGrip.PointerReleased += MinimapResizeGrip_PointerReleased;
+ MinimapCanvas.Children.Add(resizeGrip);
+
+ // Attach interaction handlers to the canvas
+ MinimapCanvas.PointerPressed -= MinimapCanvas_PointerPressed;
+ MinimapCanvas.PointerMoved -= MinimapCanvas_PointerMoved;
+ MinimapCanvas.PointerReleased -= MinimapCanvas_PointerReleased;
+ MinimapCanvas.PointerPressed += MinimapCanvas_PointerPressed;
+ MinimapCanvas.PointerMoved += MinimapCanvas_PointerMoved;
+ MinimapCanvas.PointerReleased += MinimapCanvas_PointerReleased;
+ }
+
+ private void RenderMinimapBranches(PlanNode root, double scale)
+ {
+ // Assign unique branch colors to each child subtree of the root
+ var branchColors = new[]
+ {
+ Color.FromArgb(0x30, 0x4F, 0xA3, 0xFF), // blue
+ Color.FromArgb(0x30, 0x7B, 0xCF, 0x7B), // green
+ Color.FromArgb(0x30, 0xFF, 0xB3, 0x47), // orange
+ Color.FromArgb(0x30, 0xE5, 0x73, 0x73), // red
+ Color.FromArgb(0x30, 0xCF, 0x7B, 0xCF), // purple
+ Color.FromArgb(0x30, 0x7B, 0xCF, 0xCF), // teal
+ Color.FromArgb(0x30, 0xFF, 0xE0, 0x4F), // yellow
+ Color.FromArgb(0x30, 0xFF, 0x7B, 0xA5), // pink
+ };
+
+ for (int i = 0; i < root.Children.Count; i++)
+ {
+ var child = root.Children[i];
+ var color = branchColors[i % branchColors.Length];
+
+ // Collect bounds of all nodes in this subtree
+ double minX = double.MaxValue, minY = double.MaxValue;
+ double maxX = double.MinValue, maxY = double.MinValue;
+ CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY);
+
+ var rect = new Avalonia.Controls.Shapes.Rectangle
+ {
+ Width = (maxX - minX + PlanLayoutEngine.NodeWidth) * scale + 4,
+ Height = (maxY - minY + PlanLayoutEngine.GetNodeHeight(child)) * scale + 4,
+ Fill = new SolidColorBrush(color),
+ RadiusX = 2,
+ RadiusY = 2
+ };
+ Canvas.SetLeft(rect, minX * scale - 2);
+ Canvas.SetTop(rect, minY * scale - 2);
+ MinimapCanvas.Children.Add(rect);
+ }
+ }
+
+ private static void CollectSubtreeBounds(PlanNode node, ref double minX, ref double minY, ref double maxX, ref double maxY)
+ {
+ if (node.X < minX) minX = node.X;
+ if (node.Y < minY) minY = node.Y;
+ if (node.X > maxX) maxX = node.X;
+ var bottom = node.Y + PlanLayoutEngine.GetNodeHeight(node);
+ if (bottom > maxY) maxY = bottom;
+
+ foreach (var child in node.Children)
+ CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY);
+ }
+
+ private void RenderMinimapEdges(PlanNode node, double scale)
+ {
+ foreach (var child in node.Children)
+ {
+ var parentRight = (node.X + PlanLayoutEngine.NodeWidth) * scale;
+ var parentCenterY = (node.Y + PlanLayoutEngine.GetNodeHeight(node) / 2) * scale;
+ var childLeft = child.X * scale;
+ var childCenterY = (child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2) * scale;
+ var midX = (parentRight + childLeft) / 2;
+
+ var geometry = new PathGeometry();
+ var figure = new PathFigure { StartPoint = new Point(parentRight, parentCenterY), IsClosed = false };
+ figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) });
+ figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) });
+ figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) });
+ geometry.Figures!.Add(figure);
+
+ var path = new AvaloniaPath
+ {
+ Data = geometry,
+ Stroke = EdgeBrush,
+ StrokeThickness = 1,
+ StrokeJoin = PenLineJoin.Round
+ };
+ MinimapCanvas.Children.Add(path);
+
+ RenderMinimapEdges(child, scale);
+ }
+ }
+
+ private void RenderMinimapNodes(PlanNode node, double scale)
+ {
+ var w = PlanLayoutEngine.NodeWidth * scale;
+ var h = PlanLayoutEngine.GetNodeHeight(node) * scale;
+ var bgBrush = node.IsExpensive
+ ? new SolidColorBrush(Color.FromArgb(0x60, 0xE5, 0x73, 0x73))
+ : new SolidColorBrush(Color.FromArgb(0x80, 0x30, 0x34, 0x3F));
+ var borderBrush = node.IsExpensive ? OrangeRedBrush : EdgeBrush;
+
+ var border = new Border
+ {
+ Width = Math.Max(2, w),
+ Height = Math.Max(2, h),
+ Background = bgBrush,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(0.5),
+ CornerRadius = new CornerRadius(1)
+ };
+ Canvas.SetLeft(border, node.X * scale);
+ Canvas.SetTop(border, node.Y * scale);
+ MinimapCanvas.Children.Add(border);
+
+ _minimapNodeMap[border] = node;
+
+ foreach (var child in node.Children)
+ RenderMinimapNodes(child, scale);
+ }
+
+ private void RenderMinimapViewportBox(double scale)
+ {
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ if (viewW <= 0 || viewH <= 0) return;
+
+ var contentW = PlanCanvas.Width * _zoomLevel;
+ var contentH = PlanCanvas.Height * _zoomLevel;
+
+ var boxW = Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale;
+ var boxH = Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale;
+ var boxX = (PlanScrollViewer.Offset.X / _zoomLevel) * scale;
+ var boxY = (PlanScrollViewer.Offset.Y / _zoomLevel) * scale;
+
+ var themeBrush = new SolidColorBrush(Color.FromArgb(0x40, 0x4F, 0xA3, 0xFF));
+ var borderBrush = new SolidColorBrush(Color.FromArgb(0xB0, 0x4F, 0xA3, 0xFF));
+
+ _minimapViewportBox = new Border
+ {
+ Width = Math.Max(4, boxW),
+ Height = Math.Max(4, boxH),
+ Background = themeBrush,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1.5),
+ CornerRadius = new CornerRadius(1),
+ Cursor = new Cursor(StandardCursorType.SizeAll)
+ };
+ Canvas.SetLeft(_minimapViewportBox, boxX);
+ Canvas.SetTop(_minimapViewportBox, boxY);
+ MinimapCanvas.Children.Add(_minimapViewportBox);
+ }
+
+ private void UpdateMinimapViewportBox()
+ {
+ if (!MinimapPanel.IsVisible || _minimapViewportBox == null || _currentStatement?.RootNode == null)
+ return;
+ if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return;
+
+ var canvasW = MinimapCanvas.Bounds.Width;
+ var canvasH = MinimapCanvas.Bounds.Height;
+ if (canvasW <= 0 || canvasH <= 0) return;
+
+ var scaleX = canvasW / PlanCanvas.Width;
+ var scaleY = canvasH / PlanCanvas.Height;
+ var scale = Math.Min(scaleX, scaleY);
+
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ if (viewW <= 0 || viewH <= 0) return;
+
+ var contentW = PlanCanvas.Width * _zoomLevel;
+ var contentH = PlanCanvas.Height * _zoomLevel;
+
+ _minimapViewportBox.Width = Math.Max(4, Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale);
+ _minimapViewportBox.Height = Math.Max(4, Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale);
+ Canvas.SetLeft(_minimapViewportBox, (PlanScrollViewer.Offset.X / _zoomLevel) * scale);
+ Canvas.SetTop(_minimapViewportBox, (PlanScrollViewer.Offset.Y / _zoomLevel) * scale);
+ }
+
+ private double GetMinimapScale()
+ {
+ if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return 1;
+ var canvasW = MinimapCanvas.Bounds.Width;
+ var canvasH = MinimapCanvas.Bounds.Height;
+ if (canvasW <= 0 || canvasH <= 0) return 1;
+ return Math.Min(canvasW / PlanCanvas.Width, canvasH / PlanCanvas.Height);
+ }
+
+ private void MinimapCanvas_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(MinimapCanvas);
+ if (!point.Properties.IsLeftButtonPressed) return;
+
+ var pos = point.Position;
+ var scale = GetMinimapScale();
+
+ // Check if clicking on a node (single click = center, double click = zoom)
+ if (e.ClickCount == 2)
+ {
+ // Double click: find node under pointer and zoom to it
+ var node = FindMinimapNodeAt(pos);
+ if (node != null)
+ {
+ ZoomToNode(node);
+ e.Handled = true;
+ return;
+ }
+ }
+
+ if (e.ClickCount == 1)
+ {
+ // Check if over a minimap node for single-click centering
+ var node = FindMinimapNodeAt(pos);
+ if (node != null)
+ {
+ CenterOnNode(node);
+ e.Handled = true;
+ return;
+ }
+ }
+
+ // Start viewport box drag
+ _minimapDragging = true;
+ _minimapDragStart = pos;
+
+ // Move viewport center to click position
+ ScrollPlanViewerToMinimapPoint(pos, scale);
+
+ e.Pointer.Capture(MinimapCanvas);
+ e.Handled = true;
+ }
+
+ private void MinimapCanvas_PointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_minimapDragging) return;
+
+ var pos = e.GetPosition(MinimapCanvas);
+ var scale = GetMinimapScale();
+ ScrollPlanViewerToMinimapPoint(pos, scale);
+ e.Handled = true;
+ }
+
+ private void MinimapCanvas_PointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_minimapDragging) return;
+ _minimapDragging = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ }
+
+ private void ScrollPlanViewerToMinimapPoint(Point minimapPoint, double scale)
+ {
+ if (scale <= 0) return;
+ // Convert minimap coords to plan content coords
+ var contentX = minimapPoint.X / scale;
+ var contentY = minimapPoint.Y / scale;
+
+ // Center the viewport on this content point
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ var offsetX = Math.Max(0, contentX * _zoomLevel - viewW / 2);
+ var offsetY = Math.Max(0, contentY * _zoomLevel - viewH / 2);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(offsetX, offsetY);
+ });
+ }
+
+ private PlanNode? FindMinimapNodeAt(Point pos)
+ {
+ foreach (var (border, node) in _minimapNodeMap)
+ {
+ var left = Canvas.GetLeft(border);
+ var top = Canvas.GetTop(border);
+ if (pos.X >= left && pos.X <= left + border.Width &&
+ pos.Y >= top && pos.Y <= top + border.Height)
+ return node;
+ }
+ return null;
+ }
+
+ private void CenterOnNode(PlanNode node)
+ {
+ var nodeW = PlanLayoutEngine.NodeWidth;
+ var nodeH = PlanLayoutEngine.GetNodeHeight(node);
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2;
+ var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2;
+ centerX = Math.Max(0, centerX);
+ centerY = Math.Max(0, centerY);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(centerX, centerY);
+ });
+ }
+
+ private void ZoomToNode(PlanNode node)
+ {
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ if (viewW <= 0 || viewH <= 0) return;
+
+ var nodeW = PlanLayoutEngine.NodeWidth;
+ var nodeH = PlanLayoutEngine.GetNodeHeight(node);
+
+ // Zoom so the node takes about 1/3 of the viewport
+ var fitZoom = Math.Min(viewW / (nodeW * 3), viewH / (nodeH * 3));
+ fitZoom = Math.Max(MinZoom, Math.Min(MaxZoom, fitZoom));
+ SetZoom(fitZoom);
+
+ // Center on the node
+ var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2;
+ var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2;
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(Math.Max(0, centerX), Math.Max(0, centerY));
+ });
+
+ // Also select the node in the plan
+ foreach (var (border, n) in _nodeBorderMap)
+ {
+ if (n == node)
+ {
+ SelectNode(border, node);
+ break;
+ }
+ }
+ }
+
+ private void MinimapResizeGrip_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(MinimapPanel);
+ if (!point.Properties.IsLeftButtonPressed) return;
+ _minimapResizing = true;
+ _minimapResizeStart = point.Position;
+ _minimapResizeStartW = MinimapPanel.Width;
+ _minimapResizeStartH = MinimapPanel.Height;
+ e.Pointer.Capture((Control)sender!);
+ e.Handled = true;
+ }
+
+ private void MinimapResizeGrip_PointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_minimapResizing) return;
+ var current = e.GetPosition(MinimapPanel);
+ var dx = current.X - _minimapResizeStart.X;
+ var dy = current.Y - _minimapResizeStart.Y;
+ var newW = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartW + dx));
+ var newH = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartH + dy));
+ MinimapPanel.Width = newW;
+ MinimapPanel.Height = newH;
+ _minimapWidth = newW;
+ _minimapHeight = newH;
+ e.Handled = true;
+
+ // Re-render after resize
+ Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Background);
+ }
+
+ private void MinimapResizeGrip_PointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_minimapResizing) return;
+ _minimapResizing = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ RenderMinimap();
+ }
+
+ #endregion
+
#region Helpers
private IBrush FindBrushResource(string key)
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 01315ae..1ad9d7b 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -1299,7 +1299,11 @@ private void ShowError(string message)
}
}
};
- dialog.ShowDialog(this);
+
+ if (IsVisible)
+ dialog.ShowDialog(this);
+ else
+ dialog.Show();
}
private async Task CheckForUpdatesOnStartupAsync()
From f5f9a5a29de1fc846bdf814c4ffd1eb568ad5480 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sat, 25 Apr 2026 22:34:25 +0200
Subject: [PATCH 2/9] =?UTF-8?q?1.=20Minimap=20border:=20Increased=20from?=
=?UTF-8?q?=201=20to=202=20px=20thickness=202.=20Resize=20grip:=20Moved=20?=
=?UTF-8?q?from=20the=20canvas=20(where=20it=20got=20destroyed=20on=20ever?=
=?UTF-8?q?y=20re-render)=20to=20a=20permanent=20AXAML=20element=20?=
=?UTF-8?q?=E2=80=94=20a=20Border=20with=203=20diagonal=20lines=20in=20the?=
=?UTF-8?q?=20bottom-right=20corner=20indicating=20resizability,=20wired?=
=?UTF-8?q?=20to=20the=20existing=20resize=20handlers=20in=20the=20constru?=
=?UTF-8?q?ctor=203.=20Node=20borders:=20Changed=20from=20EdgeBrush=20(dar?=
=?UTF-8?q?k=20grey=20#6B7280)=20to=20a=20new=20MinimapNodeBorderBrush=20(?=
=?UTF-8?q?#A0A4AB=20=E2=80=94=20light=20grey)=204.=20Node=20content:=20Ea?=
=?UTF-8?q?ch=20minimap=20node=20now=20shows=20its=20operator=20icon=20(sc?=
=?UTF-8?q?aled=20to=20fit,=20~70%=20of=20the=20node=20size,=20max=2016px)?=
=?UTF-8?q?=205.=20Default=20size:=20Changed=20from=20300=C3=97300=20to=20?=
=?UTF-8?q?400=C3=97400?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controls/PlanViewerControl.axaml | 19 ++++++-
.../Controls/PlanViewerControl.axaml.cs | 53 +++++++++++--------
2 files changed, 49 insertions(+), 23 deletions(-)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index 26fda51..1a58349 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -286,9 +286,9 @@
@@ -308,6 +308,21 @@
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index eb7e380..582a0e4 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -119,9 +119,9 @@ public partial class PlanViewerControl : UserControl
private double _panStartOffsetY;
// Minimap state
- private static double _minimapWidth = 300;
- private static double _minimapHeight = 300;
- private const double MinimapDefaultSize = 300;
+ private static double _minimapWidth = 400;
+ private static double _minimapHeight = 400;
+ private const double MinimapDefaultSize = 400;
private const double MinimapMinSize = 200;
private const double MinimapMaxSize = 500;
private bool _minimapDragging;
@@ -159,6 +159,11 @@ public PlanViewerControl()
_zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;
Helpers.DataGridBehaviors.Attach(StatementsGrid);
+
+ // Wire minimap resize grip (defined in AXAML, not in canvas)
+ MinimapResizeGrip.PointerPressed += MinimapResizeGrip_PointerPressed;
+ MinimapResizeGrip.PointerMoved += MinimapResizeGrip_PointerMoved;
+ MinimapResizeGrip.PointerReleased += MinimapResizeGrip_PointerReleased;
}
///
@@ -3553,21 +3558,6 @@ private void RenderMinimap()
// Render viewport indicator
RenderMinimapViewportBox(scale);
- // Attach resize via bottom-right 8px drag area
- var resizeGrip = new Border
- {
- Width = 10,
- Height = 10,
- Background = Brushes.Transparent,
- Cursor = new Cursor(StandardCursorType.BottomRightCorner)
- };
- Canvas.SetLeft(resizeGrip, canvasW - 10);
- Canvas.SetTop(resizeGrip, canvasH - 10);
- resizeGrip.PointerPressed += MinimapResizeGrip_PointerPressed;
- resizeGrip.PointerMoved += MinimapResizeGrip_PointerMoved;
- resizeGrip.PointerReleased += MinimapResizeGrip_PointerReleased;
- MinimapCanvas.Children.Add(resizeGrip);
-
// Attach interaction handlers to the canvas
MinimapCanvas.PointerPressed -= MinimapCanvas_PointerPressed;
MinimapCanvas.PointerMoved -= MinimapCanvas_PointerMoved;
@@ -3658,6 +3648,8 @@ private void RenderMinimapEdges(PlanNode node, double scale)
}
}
+ private static readonly SolidColorBrush MinimapNodeBorderBrush = new(Color.FromRgb(0xA0, 0xA4, 0xAB));
+
private void RenderMinimapNodes(PlanNode node, double scale)
{
var w = PlanLayoutEngine.NodeWidth * scale;
@@ -3665,17 +3657,36 @@ private void RenderMinimapNodes(PlanNode node, double scale)
var bgBrush = node.IsExpensive
? new SolidColorBrush(Color.FromArgb(0x60, 0xE5, 0x73, 0x73))
: new SolidColorBrush(Color.FromArgb(0x80, 0x30, 0x34, 0x3F));
- var borderBrush = node.IsExpensive ? OrangeRedBrush : EdgeBrush;
+ var borderBrush = node.IsExpensive ? OrangeRedBrush : MinimapNodeBorderBrush;
var border = new Border
{
- Width = Math.Max(2, w),
- Height = Math.Max(2, h),
+ Width = Math.Max(4, w),
+ Height = Math.Max(4, h),
Background = bgBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(0.5),
CornerRadius = new CornerRadius(1)
};
+
+ // Show a small icon inside the node if space allows
+ var iconBitmap = IconHelper.LoadIcon(node.IconName);
+ if (iconBitmap != null)
+ {
+ var iconSize = Math.Min(Math.Min(w * 0.7, h * 0.7), 16);
+ if (iconSize >= 6)
+ {
+ border.Child = new Image
+ {
+ Source = iconBitmap,
+ Width = iconSize,
+ Height = iconSize,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ }
+ }
+
Canvas.SetLeft(border, node.X * scale);
Canvas.SetTop(border, node.Y * scale);
MinimapCanvas.Children.Add(border);
From 84b052d70386b3574c451bc3c02c7c8a3b405dcc Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sat, 25 Apr 2026 22:58:11 +0200
Subject: [PATCH 3/9] =?UTF-8?q?1.=20Minimap=20border=20now=20matches=20the?=
=?UTF-8?q?=20tooltip/popup=20style:=20background=20#1A1D23,=20border=20#3?=
=?UTF-8?q?A3D45,=205px=20thickness,=20same=20for=20header=20and=20canvas?=
=?UTF-8?q?=20background=202.=20Selected=20node=20highlighting:=20When=20a?=
=?UTF-8?q?=20node=20is=20selected=20in=20the=20plan=20viewer,=20the=20cor?=
=?UTF-8?q?responding=20minimap=20node=20gets=20a=20blue=20selection=20bor?=
=?UTF-8?q?der=20(SelectionBrush,=202px).=20The=20highlight=20persists=20t?=
=?UTF-8?q?hrough=20minimap=20re-renders=20and=20resets=20when=20a=20diffe?=
=?UTF-8?q?rent=20node=20is=20selected.=203.=20Proportional=20edge=20thick?=
=?UTF-8?q?ness:=20Minimap=20edges=20now=20use=20the=20same=20logarithmic?=
=?UTF-8?q?=20row-count=20formula=20as=20the=20plan=20viewer=20(Math.Log(r?=
=?UTF-8?q?ows)=20capped=20at=202=E2=80=9312),=20scaled=20down=20by=20the?=
=?UTF-8?q?=20minimap=20scale=20factor,=20with=20a=20minimum=20of=200.5px.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controls/PlanViewerControl.axaml | 8 ++--
.../Controls/PlanViewerControl.axaml.cs | 42 ++++++++++++++++++-
2 files changed, 45 insertions(+), 5 deletions(-)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index 1a58349..1f3f80a 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -287,8 +287,8 @@
HorizontalAlignment="Left" VerticalAlignment="Top"
Margin="4,26,0,0" ZIndex="10"
Width="400" Height="400"
- Background="{DynamicResource BackgroundDarkBrush}"
- BorderBrush="{DynamicResource BorderBrush}" BorderThickness="2"
+ Background="#1A1D23"
+ BorderBrush="#3A3D45" BorderThickness="5"
CornerRadius="4" ClipToBounds="True">
@@ -296,7 +296,7 @@
-
+