An editor UI widget that displays a pinned minimap of a Prism graph canvas. It collects node rectangles and connection endpoints, computes a scaled overview, draws nodes, wires and the viewport frame, and lets the user click or drag the frame to center the main view.
using Editor.Prism.Core;
using Editor.Prism.Ui.Adapters;
using Connection = Editor.NodeEditor.Connection;
namespace Editor.Prism.Ui;
/// <summary>
/// A live overview of the whole graph, pinned to the bottom-right of the canvas.
/// <para>
/// Nothing in s&box has one, and on a graph with two hundred nodes the alternative is zooming out
/// until the cards are unreadable just to find out where you are. Cards draw as rectangles in their
/// category colour, wires as faint lines, and the current viewport as an accent frame. Click anywhere
/// to jump there; drag the frame to pan.
/// </para>
/// <para>
/// It is a child <see cref="Widget"/> of the graph view rather than a <c>GraphicsItem</c> in its
/// scene — an item would pan and zoom along with the nodes it is supposed to be describing.
/// </para>
/// </summary>
public sealed class Minimap : Widget
{
/// <summary>Margin between the minimap and the canvas edges.</summary>
public const float Inset = 12f;
readonly List<Entry> _entries = new();
readonly List<(Vector2 From, Vector2 To)> _wires = new();
Rect _content;
Rect _viewport;
Vector2 _parentSize;
bool _dragging;
bool _hovered;
int _revision = -1;
int _topology = -1;
RealTimeSince _sinceRebuild;
/// <summary>Create a minimap over a canvas.</summary>
public Minimap( PrismGraphView view, Widget parent = null ) : base( parent ?? view )
{
View = view;
FixedWidth = PrismTheme.MinimapWidth;
FixedHeight = PrismTheme.MinimapHeight;
MouseTracking = true;
Cursor = CursorShape.Finger;
ToolTip = "Minimap — click to jump, drag to pan";
}
/// <summary>The canvas this minimap describes.</summary>
public PrismGraphView View { get; set; }
/// <summary>
/// Force a rebuild on the next frame. Needed for the handful of things that move cards without the
/// pointer being anywhere near the canvas and without changing the graph's shape — align, distribute
/// and auto-layout, all of which are driven from a menu.
/// </summary>
public void Invalidate() => _topology = -1;
// ---------------------------------------------------------------- layout ----
/// <summary>
/// Keep the minimap pinned to the bottom-right corner. The canvas is not ours to subclass for an
/// <c>OnResize</c> override, and a per-frame comparison against the parent size costs nothing.
/// </summary>
[EditorEvent.Frame]
public void OnPrismFrame()
{
if ( !this.IsValid() || !Visible ) return;
var parent = Parent;
if ( !parent.IsValid() ) return;
if ( parent.Size != _parentSize )
{
_parentSize = parent.Size;
Position = new Vector2(
MathF.Max( Inset, parent.Size.x - Width - Inset ),
MathF.Max( Inset, parent.Size.y - Height - Inset ) );
}
// Rebuilding the model every frame on a large graph is wasteful; ten times a second is well
// under what the eye notices while panning.
if ( _sinceRebuild < 0.1f ) return;
// And ten times a second is still nine too many when nothing can have moved. The map only
// changes when the user is on the canvas — dragging a card, panning, zooming — or when the
// graph's shape changed underneath us, which is what a paste, an undo or an auto-layout run
// from a menu looks like.
var topology = View.IsValid() && View.Adapter is not null ? View.Adapter.TopologyVersion : 0;
var viewport = VisibleScene();
if ( topology == _topology && viewport == _viewport && View.IsValid() && !View.IsUnderMouse ) return;
_topology = topology;
_sinceRebuild = 0f;
Rebuild();
}
void Rebuild()
{
if ( !View.IsValid() ) return;
_entries.Clear();
_wires.Clear();
var bounds = new Rect();
var any = false;
foreach ( var item in View.Items )
{
if ( item is not NodeUI card ) continue;
var rect = card.SceneRect;
var color = card.Node is PrismNodeAdapter adapter
? adapter.CategoryColor
: PrismTheme.CategoryUtility;
_entries.Add( new Entry( rect, color, card.Selected ) );
if ( !any ) bounds = rect;
else bounds.Add( rect );
any = true;
}
foreach ( var item in View.Items )
{
if ( item is not Connection connection ) continue;
if ( !connection.Output.IsValid() || !connection.Input.IsValid() ) continue;
_wires.Add( (connection.OutputPosition, connection.InputPosition) );
}
_viewport = VisibleScene();
if ( any )
{
// Always include the viewport, or panning away from every node leaves the frame off the map.
bounds.Add( _viewport );
_content = bounds.Grow( 64f );
}
else
{
_content = _viewport;
}
var hash = HashCode.Combine( _entries.Count, _wires.Count,
HashCode.Combine( _content.Left, _content.Top, _content.Width, _content.Height ),
HashCode.Combine( _viewport.Left, _viewport.Top, _viewport.Width, _viewport.Height ) );
if ( hash == _revision ) return;
_revision = hash;
Update();
}
Rect VisibleScene()
{
if ( !View.IsValid() ) return new Rect();
var scale = MathF.Max( 0.001f, View.Scale.x );
var size = View.Size / scale;
return new Rect( View.Center - size * 0.5f, size );
}
// ---------------------------------------------------------------- painting ----
/// <inheritdoc/>
protected override void OnPaint()
{
var rect = LocalRect;
Paint.Antialiasing = true;
PrismPaint.DropShadow( rect, PrismTheme.RadiusPanel, PrismTheme.Shadow );
Paint.SetBrushAndPen( PrismTheme.Panel.WithAlpha( 0.94f ),
_hovered ? PrismTheme.BorderStrong : PrismTheme.BorderSubtle );
Paint.DrawRect( rect, PrismTheme.RadiusPanel );
if ( _entries.Count == 0 )
{
PrismPaint.Text( rect, "Empty graph", PrismTheme.TextDisabled,
PrismTheme.PanelHeaderSize, PrismTheme.PortLabelWeight, TextFlag.Center );
return;
}
var inner = rect.Shrink( 6f );
var scale = ScaleFor( inner );
var offset = OffsetFor( inner, scale );
Paint.SetPen( PrismTheme.BorderSubtle.WithAlpha( 0.55f ), 1f );
foreach ( var wire in _wires )
{
Paint.DrawLine( wire.From * scale + offset, wire.To * scale + offset );
}
foreach ( var entry in _entries )
{
var mapped = new Rect( entry.Rect.Position * scale + offset, entry.Rect.Size * scale );
// Below about two pixels a rectangle stops reading as a shape, so clamp to a dot.
mapped.Width = MathF.Max( 2f, mapped.Width );
mapped.Height = MathF.Max( 2f, mapped.Height );
Paint.SetBrushAndPen( entry.Color.WithAlpha( entry.Selected ? 0.95f : 0.6f ) );
Paint.DrawRect( mapped, 1f );
if ( !entry.Selected ) continue;
Paint.SetBrush( Color.Transparent );
Paint.SetPen( PrismTheme.Accent, 1f );
Paint.DrawRect( mapped.Grow( 1f ), 1f );
}
var frame = new Rect( _viewport.Position * scale + offset, _viewport.Size * scale );
Paint.SetBrushAndPen( PrismTheme.AccentSoft, PrismTheme.Accent, 1.25f );
Paint.DrawRect( frame, 2f );
}
float ScaleFor( Rect inner )
{
if ( _content.Width <= 0f || _content.Height <= 0f ) return 1f;
return MathF.Min( inner.Width / _content.Width, inner.Height / _content.Height );
}
Vector2 OffsetFor( Rect inner, float scale ) =>
inner.Position + ( inner.Size - _content.Size * scale ) * 0.5f - _content.Position * scale;
// ---------------------------------------------------------------- interaction ----
/// <inheritdoc/>
protected override void OnMouseEnter()
{
base.OnMouseEnter();
_hovered = true;
Update();
}
/// <inheritdoc/>
protected override void OnMouseLeave()
{
base.OnMouseLeave();
_hovered = false;
Update();
}
/// <inheritdoc/>
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( !e.LeftMouseButton ) return;
_dragging = true;
JumpTo( e.LocalPosition );
e.Accepted = true;
}
/// <inheritdoc/>
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
if ( !_dragging ) return;
JumpTo( e.LocalPosition );
e.Accepted = true;
}
/// <inheritdoc/>
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
_dragging = false;
}
/// <inheritdoc/>
protected override void OnMouseWheel( WheelEvent e )
{
// Swallow it. Scrolling over the minimap zooming the canvas underneath is disorienting, and
// letting it fall through to the view would do exactly that.
e.Accept();
}
void JumpTo( Vector2 local )
{
if ( !View.IsValid() ) return;
var inner = LocalRect.Shrink( 6f );
var scale = ScaleFor( inner );
if ( scale <= 0f ) return;
var offset = OffsetFor( inner, scale );
var scene = ( local - offset ) / scale;
PrismLog.Guard( "Jump from the minimap", () => View.CenterOn( scene ) );
_sinceRebuild = 1f;
}
readonly record struct Entry( Rect Rect, Color Color, bool Selected );
}