Skip to content

Feature/minimap#276

Merged
erikdarlingdata merged 9 commits intoerikdarlingdata:devfrom
rferraton:feature/minimap
Apr 25, 2026
Merged

Feature/minimap#276
erikdarlingdata merged 9 commits intoerikdarlingdata:devfrom
rferraton:feature/minimap

Conversation

@rferraton
Copy link
Copy Markdown
Contributor

@rferraton rferraton commented Apr 25, 2026

What does this PR do?

Minimap for Plan Viewer

Adds an interactive minimap overlay to the plan viewer, providing a bird's-eye view of the entire execution plan for easier navigation in large plans. Each plan tab maintains its own independent minimap state.

Features

Toggle & Positioning

• Small "minimap" button always visible at the top-left corner of the plan canvas (4px margin)
• Click to open/close; minimap appears directly below the button as a floating overlay (ZIndex 10)
• Close button (✕) in the minimap header bar
• Minimap automatically closes when the plan is cleared

Rendering

• Entire plan tree rendered in miniature: nodes, elbow connector edges, and operator icons
• Nodes show their operator icon scaled to fit (up to 16px, hidden below 6px)
• Expensive nodes highlighted with red-tinted background and OrangeRed border
• Edge thickness is proportional to row counts using the same logarithmic formula as the main plan, scaled down to minimap coordinates
• Branch visualization: each child subtree of the root node gets a transparent colored background area (8 cycling colors) making it easy to distinguish plan branches at a glance

Viewport Indicator

• Semi-transparent accent-colored rectangle shows the currently visible portion of the plan
• Updates in real-time on scroll, pan (mouse drag / middle-click), and zoom (Ctrl+wheel, buttons, fit-to-view)
Navigation Interactions
• Click & drag on empty area: moves the plan viewer viewport to follow the pointer — the main plan scrolls in real-time
• Single click on a node: centers the plan viewer on that node
• Double click on a node: zooms to ~1/3 viewport size, centers on the node, and selects it (shows properties panel)

Selected Node Highlight

• When a node is selected in the main plan viewer (click or via properties), the corresponding minimap node is highlighted with the selection brush (blue, 2px border)
• Highlight persists through minimap re-renders and is correctly cleared on statement switch or plan clear

Resizing

• Diagonal grip lines in the bottom-right corner indicate resizability
• Drag the grip to resize between 200×200 (min) and 500×500 (max)
• Default size: 400×400
• Last used dimensions are preserved in memory (static fields) across plan tabs within the same session, but not persisted to disk

Theming

• All colors derived from theme resources (BackgroundBrush, BorderBrush, ForegroundBrush, AccentBrush, BackgroundLightBrush) — no hardcoded hex values except the decorative branch overlay palette
• Minimap panel border, header, and canvas background all follow the dark theme
• Viewport box fill/border derived from AccentBrush with alpha transparency
• Node border brush derived from ForegroundBrush at 50% opacity, cached per render cycle

Technical Notes

• The minimap wrapper Grid was inserted between the 5-column layout Grid and the PlanScrollViewer, changing the parent traversal path for column definition resolution (Parent instead of Parent)
• Canvas pointer handlers (PointerPressed/Moved/Released) are wired once in the constructor, not per render
• RenderMinimap() guards against deferred execution after panel close
• _selectedNode is properly cleared in both Clear() and RenderStatement() to prevent stale references
• Branch colors extracted to a static readonly Color[] to avoid per-render allocation
• Non-expensive node border brush is computed once per render cycle and cached in _minimapNodeBorderBrushCache

Which component(s) does this affect?

  • Desktop App (PlanViewer.App)
  • Core Library (PlanViewer.Core)
  • CLI Tool (PlanViewer.Cli)
  • SSMS Extension (PlanViewer.Ssms)
  • Tests
  • Documentation

How was this tested?

2026-04-25_23h20_28 2026-04-25_23h22_53

Describe the testing you've done. Include:

  • Plan files tested : estimated, actual, qs
  • Platforms tested : Windows Only

Checklist

  • I have read the contributing guide
  • My code builds with zero warnings (dotnet build -c Debug)
  • All tests pass (dotnet test)
  • I have not introduced any hardcoded credentials or server names

• A "minimap" toggle button (top-left, always visible over the plan canvas, very small)
• A minimap panel (300×300 default, with close button "✕", header bar, and a Canvas for rendering)
PlanViewerControl.axaml.cs — Added:
1. State fields: Static _minimapWidth/_minimapHeight (preserved in memory across plans, not on disk), drag/resize state, node mapping for minimap interaction
2. Toggle/Open/Close: MinimapToggle_Click(object?, RoutedEventArgs), OpenMinimapPanel(), CloseMinimapPanel()
3. Rendering (RenderMinimap()):
• Branch areas: Each child subtree of the root gets a transparent colored rectangle (8 distinct colors cycling) behind its nodes
• Edges: Scaled-down elbow connectors
• Nodes: Small colored rectangles (red tint for expensive nodes)
• Viewport box: Semi-transparent blue rectangle showing the visible portion of the plan
4. Interaction:
• Click & drag on minimap to pan the plan viewer
• Single click on a node centers the plan viewer on that node
• Double click on a node zooms to ~1/3 viewport size and selects the node
• Resize grip (bottom-right corner) allows resizing between 200×200 and 500×500
5. Live updates: Viewport box updates on scroll, zoom, pan, and mouse wheel zoom. Minimap re-renders on statement change.
6. Per-plan: Each PlanViewerControl instance has its own minimap state, so multiple open plans each have their own minimap.
2. Resize grip: Moved from the canvas (where it got destroyed on every re-render) to a permanent AXAML element — a Border with 3 diagonal lines in the bottom-right corner indicating resizability, wired to the existing resize handlers in the constructor
3. Node borders: Changed from EdgeBrush (dark grey #6B7280) to a new MinimapNodeBorderBrush (#A0A4AB — light grey)
4. Node content: Each minimap node now shows its operator icon (scaled to fit, ~70% of the node size, max 16px)
5. Default size: Changed from 300×300 to 400×400
…1D23, border #3A3D45, 5px thickness, same for header and canvas background

2. Selected node highlighting: When a node is selected in the plan viewer, the corresponding minimap node gets a blue selection border (SelectionBrush, 2px). The highlight persists through minimap re-renders and resets when a different node is selected.
3. Proportional edge thickness: Minimap edges now use the same logarithmic row-count formula as the plan viewer (Math.Log(rows) capped at 2–12), scaled down by the minimap scale factor, with a minimum of 0.5px.
Fixes:
1 _selectedNode cleared Added _selectedNode = null in both Clear() and RenderStatement()
2 Dead field removed Removed _minimapDragStart field and its assignment
3 Handlers wired once Moved MinimapCanvas.PointerPressed/Moved/Released subscriptions to constructor, removed per-render -=/+= cycle
4 Unused constant removed Removed MinimapDefaultSize
5 Guard added RenderMinimap() now returns early with if (!MinimapPanel.IsVisible) return; before doing any work
6 Correct brush restored Deselect now uses _minimapNodeBorderBrushCache (matches creation brush) instead of FindBrushResource("BorderBrush")
7 Branch colors extracted Moved to static readonly Color[] MinimapBranchColors field — no allocation per render
8 Node border brush cached Computed once per render cycle in RenderMinimap() and stored in _minimapNodeBorderBrushCache, reused for all non-expensive nodes
@erikdarlingdata
Copy link
Copy Markdown
Owner

Code Review — Feature/minimap

Overview

Adds an interactive minimap overlay to the plan viewer: floating panel toggled from a top-left button, renders a scaled-down view of the plan tree with branch coloring, viewport indicator, click-to-center / double-click-to-zoom, and drag-resize. State is per-instance for selection but _minimapWidth/_minimapHeight are static so size carries between tabs in a session. Big additive UI feature, mostly in one file.

The implementation is well-organized into a #region Minimap block, theme-aware, and the description matches what the diff does.

Issues

1. Unrelated change to MainWindow.axaml.cs (should not be in this PR)

-        dialog.ShowDialog(this);
+        if (IsVisible)
+            dialog.ShowDialog(this);
+        else
+            dialog.Show();

This has nothing to do with the minimap. It looks like a real bug fix (showing a modal dialog before the main window is visible throws), but it should be split into its own PR with its own description and test plan. Mixing it in here makes the history hard to read and bisect.

2. Fragile parent traversal change

-        var planGrid = (Grid)PlanScrollViewer.Parent!;
+        var planGrid = (Grid)PlanScrollViewer.Parent!.Parent!;

The 5-column Grid was the direct parent; now it's the grandparent because of the new wrapper Grid. The double-bang is unchecked — any future reorganisation of the AXAML silently breaks at runtime with a NullReferenceException or InvalidCastException. A couple of options to make this less brittle:

  • Give the outer Grid x:Name="PlanGrid" and reference it directly via the generated field. That's the convention used elsewhere in this file.
  • Or walk parents in a small loop until is Grid g && g.ColumnDefinitions.Count == 5.

This is the highest-impact correctness change in the diff, and it's currently held together by an assumption that's invisible from the C# side.

3. Render performance on large plans

RenderMinimap() walks the tree three times (branches, edges, nodes), creates a Border per node, a Path per edge, plus icon Images. For 500-node plans (which exist), that's 1000+ Controls on a separate canvas, re-allocated on every resize move (MinimapResizeGrip_PointerMoved posts RenderMinimap per move). The DispatcherPriority.Background post helps coalesce, but only somewhat — Avalonia will still execute each one in order.

Cheaper options:

  • Throttle resize re-renders (only re-render on PointerReleased, and just rescale during drag — or skip the re-render entirely during drag and let layout transform handle it).
  • Coalesce edges into a single Path with multiple PathFigures instead of one Path per edge.

Not a blocker, but worth a look on a >300-node plan.

4. Static minimap dimensions are process-global, not per-window

private static double _minimapWidth = 400;
private static double _minimapHeight = 400;

The PR text says this is intentional ("preserved across plan tabs within the same session"). That works for one window. But PlanViewerControl is instantiated in multiple windows / tabs, and now resizing the minimap in one window changes the opening size in another. If that's intended, fine — but it should probably be persisted to settings rather than left as static state, because users will expect it to survive a restart anyway.

5. Click vs double-click ordering

if (e.ClickCount == 2) { ZoomToNode(...); return; }
if (e.ClickCount == 1) { CenterOnNode(...); return; }

Avalonia delivers a ClickCount==1 press, then a separate ClickCount==2 press for a double-click — meaning a double-click on a node first re-centers, then re-centers + zooms + selects. The user just sees the final state so it usually looks fine, but PlanScrollViewer.Offset is set twice in rapid succession via Dispatcher.UIThread.Post. The result-of-record is correct; just noting in case you see a flicker.

6. Allocation per render

RenderMinimap caches _minimapNodeBorderBrushCache (good), but RenderMinimapNodes still creates a fresh SolidColorBrush(Color.FromArgb(0x60, 0xE5, 0x73, 0x73)) per expensive node. Hoisting that into a static readonly keeps the hot path allocation-free. UpdateMinimapViewportBox correctly reuses the existing border, so the scroll path is fine.

7. Minor

  • RenderMinimapBranches has an empty line after the opening brace — small style nit.
  • _minimapNodeBorderBrushCache = Brushes.Gray initial value is dead — RenderMinimap always reassigns before use. Could just be IBrush? and asserted. Cosmetic.
  • Parent!.Parent! in the constructor is run inside InitializeComponent's aftermath; if AXAML parsing ever fails partially, this throws an obscure NRE. A defensive cast with a clear message would help debugging.
  • No tests, but this is a UI-only feature so unit tests are not strongly indicated.

Risk

Medium. The minimap itself is fully optional and doesn't change anything when it's closed (default). The two real risk surfaces are:

  1. The grandparent traversal change touches the existing column-definition resolution that drives the non-minimap statements/properties layout. If that line ever returns the wrong Grid, every layout-dependent feature breaks.
  2. The unrelated MainWindow.axaml.cs dialog change.

Recommendation

Request changes:

  1. Split the MainWindow.axaml.cs dialog fix into a separate PR.
  2. Replace the Parent!.Parent! pattern with an x:Name-resolved field on the outer Grid.
  3. Decide: persist minimap size to settings (recommended) or keep it static + document why.
  4. Test on a large plan (~300+ nodes) and consider throttling resize re-renders if the live drag feels janky.

The rest are nits and follow-ups. The feature itself is well-scoped and the code is readable.

@rferraton
Copy link
Copy Markdown
Contributor Author

@erikdarlingdata : I don't have any plans with over 300 nodes on hand. Do you?
I propose to postpone the performance optimization and come back if the issue is raised by someone

@erikdarlingdata
Copy link
Copy Markdown
Owner

Follow-up after the latest commits — most points are addressed:

  • Parent!.Parent! replaced with x:Name="PlanGrid" and a clean direct reference. Nice.
  • ✅ Expensive-node background brush hoisted to static readonly MinimapExpensiveNodeBgBrush.
  • _selectedNode cleared in both Clear() and RenderStatement(), handlers wired once in the ctor, branch colors extracted to a static array, brush cache reused on deselect.

One small thing on the MainWindow.axaml.cs revert (commit 02f6f96) — it's not a true revert. The behavior is back to the original dialog.ShowDialog(this);, but the file now has:

        };
-        dialog.ShowDialog(this);
+
+            dialog.ShowDialog(this);
     }

A stray blank line plus the call indented 12 spaces instead of the original 8. Could you re-revert this hunk so it matches main exactly? That keeps the PR diff scoped to the minimap.

For the >300-node performance question: postponing is fine with me. Worst case the resize-drag re-render gets noticeable on a big plan and we throttle later. Not a blocker.

@erikdarlingdata
Copy link
Copy Markdown
Owner

The latest "align to main" commit (53e0a43) didn't fully revert the MainWindow.axaml.cs hunk — the indentation got swapped from 4 spaces to a tab character on those 3 lines, which now mismatches the rest of the file (which uses 4-space indentation):

```diff

  •    dialog.ShowDialog(this);
    
  • }
  •   dialog.ShowDialog(this);
    
  • }
  • private async Task CheckForUpdatesOnStartupAsync()
  • private async Task CheckForUpdatesOnStartupAsync()
    ```

Could you do a strict revert of those 3 lines to their state on dev? After that this PR's diff should be 0 lines for MainWindow.axaml.cs. Apart from that, looks ready.

@erikdarlingdata erikdarlingdata merged commit a8aa163 into erikdarlingdata:dev Apr 25, 2026
2 checks passed
erikdarlingdata added a commit that referenced this pull request Apr 25, 2026
Cleanup of cosmetic regression from #276: tabs were left on 3 lines around
ShowError that don't match the surrounding 4-space-indented file.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rferraton
Copy link
Copy Markdown
Contributor Author

Sorry, the git revert one file was done too late.

@erikdarlingdata erikdarlingdata mentioned this pull request Apr 27, 2026
5 tasks
erikdarlingdata added a commit that referenced this pull request Apr 27, 2026
Highlights since v1.8.0:
- Minimap for plan navigation (#276)
- Colored links by accuracy ratio divergence (#289)
- Distinct parallelism subtype icons (#285, #288)
- Query Store filter ordering fix (#287)
- xunit v2 -> v3 migration (#278)
- SqlClient 6 -> 7, ScriptDom 170 -> 180 (#279)
- System.CommandLine GA (#280)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants