Editor/Prism/Ui/Minimap.cs

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.

Native Interop
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&amp;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 );
}