using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using IFSEngine.Model;
using IFSRenderer.Shared;
namespace IFSRenderer.AvaloniaShell;
/// <summary>
/// Node graph editor for an <see cref="IFS"/>'s iterators and their xaos weights.
/// </summary>
/// <remarks>
/// <para>
/// The engine models the iterator graph as a COMPLETE directed graph:
/// <c>Iterator.WeightTo[target]</c> exists for every pair, and a weight of 0 simply
/// means "no connection". So adding/removing an edge is only ever a weight change -
/// there is no separate topology to maintain.
/// </para>
/// <para>
/// Navigation mirrors the WPF app, which wrapped its NodeMap in an Xceed Zoombox
/// with <c>DragModifiers="None"</c> and <c>RelativeZoomModifiers="None"</c>: plain
/// left-drag on empty space pans, and the wheel zooms - no modifier keys. Weight
/// editing therefore CANNOT live on the wheel; as in the original, clicking an arrow
/// selects the connection and the weight is edited in the properties panel.
/// </para>
/// <para>
/// Node positions are NOT part of the engine model (the WPF app keeps them in its own
/// IfsNodes serialization layer), so they are laid out on a circle and kept here.
/// </para>
/// </remarks>
public sealed class NodeEditor : Control
{
private const double MinZoom = 0.25, MaxZoom = 4.0;
/// <summary>Bow of the connection curves, matching ConnectionArrow's ArrowCurve.</summary>
private const double ArrowCurve = Math.PI / 4.0;
/// <summary>ConnectionArrow: control points are pushed out 30, the head sits 22 past the midpoint.</summary>
private const double CurveBow = 30, HeadBow = 22;
/// <summary>ConnectionArrow.ArrowHeadSize default.</summary>
private const double ArrowHeadSize = 20;
/// <summary>
/// IteratorViewModel.NodeSize — the node is a circle whose diameter grows with base weight,
/// from 100 at weight 0 to 200 at weight 5 and above.
/// </summary>
private static double NodeSize(Iterator it) =>
100 + Math.Clamp(10 * it.BaseWeight, 0.0, 50.0) * 2;
private static double NodeRadius(Iterator it) => NodeSize(it) / 2;
private readonly Dictionary<Iterator, Point> _positions = [];
private IFS _fractal;
// view transform: screen = world * zoom + pan
private double _zoom = 1.0;
private Point _pan;
private Iterator _selected;
private Iterator _dragNode;
private Point _dragOffset;
/// <summary>Node pressed but not yet dragged clear of - a pending connection gesture.</summary>
private Iterator _pressNode;
private Iterator _linkSource;
private Point _linkCursor;
private bool _panning;
private Point _panStart;
private Point _panOrigin;
/// <summary>True once the user has positioned a node, which freezes auto-layout.</summary>
private bool _userArranged;
/// <summary>Fit the view once the control has a real size (Bounds is 0 during SetFractal).</summary>
private bool _needsFit;
private Iterator _hoverNode;
private NodeAction? _hoverButton;
private (Iterator From, Iterator To)? _hoverEdge;
private (Iterator From, Iterator To)? _selectedEdge;
/// <summary>The action buttons Node.xaml reveals when a node is hovered or selected.</summary>
private enum NodeAction { Drag, FlipOpacity, FlipWeight, Duplicate, Split, ConnectSelf }
/// <summary>A hover control: where it sits, how big, and what it shows.</summary>
private readonly record struct NodeButton(NodeAction Action, Point Centre, double Radius,
string Glyph, double FontSize);
public event Action<Iterator> IteratorSelected;
/// <summary>Raised by the node's 👁️ button.</summary>
public event Action<Iterator> FlipOpacityRequested;
/// <summary>Raised by the node's ⚖ button.</summary>
public event Action<Iterator> FlipWeightRequested;
/// <summary>Raised by the node's duplicate button.</summary>
public event Action<Iterator> DuplicateRequested;
/// <summary>Raised by the node's split button.</summary>
public event Action<Iterator> SplitRequested;
/// <summary>Raised when a connection is clicked, so the panel can edit its weight.</summary>
public event Action<Iterator, Iterator> ConnectionSelected;
/// <summary>Raised when a weight changed and the GPU buffers need invalidating.</summary>
public event Action GraphChanged;
/// <summary>
/// Asks the host to record an undo state, BEFORE the graph is mutated.
/// </summary>
/// <remarks>
/// Connecting, disconnecting and moving a node all change persisted document state — node
/// positions round-trip through IfsNodesSerializer — but none of it was recorded, so the
/// whole node graph sat outside undo. The canonical snapshots on the equivalent paths
/// (IFSViewModel.cs:185, :277).
/// </remarks>
public Action SnapshotRequested { get; set; }
// NodeMap.xaml: <SolidColorBrush x:Key="bgBrush" Color="Gray"/>
private static readonly IBrush BackgroundBrush = new SolidColorBrush(Colors.Gray);
/// <summary>Node.xaml draws its label in the system control-text colour, i.e. near-black.</summary>
private static readonly IBrush NodeText = new SolidColorBrush(Color.Parse("#1a1a1a"));
// ConnectionArrow.xaml: both body and head are White at StrokeThickness 2.
private static readonly IPen EdgePen = new Pen(Brushes.White, 2);
private static readonly IPen EdgePenHot = new Pen(Brushes.White, 3);
private static readonly IPen EdgePenSelected = new Pen(Brushes.White, 3);
private static readonly IPen LinkPen = new Pen(Brushes.White, 2, DashStyle.Dash);
/// <summary>
/// The node's ring colour: Node.xaml strokes the ellipse with the iterator's palette colour
/// (IteratorViewModel.ColorRGB = Palette.GetColorLerp(ColorIndex)).
/// </summary>
private IBrush RingBrush(Iterator it)
{
if (_fractal?.Palette is not { KeyColors.Count: > 0 })
return Brushes.LightGray;
System.Numerics.Vector4 c = _fractal.Palette.SampleGradient(Math.Clamp(it.ColorIndex, 0, 1));
return new SolidColorBrush(Color.FromRgb(Channel(c.X), Channel(c.Y), Channel(c.Z)));
}
// MaterialDesignFloatingActionButton on the Grey primary: #9e9e9e with dark content.
private static readonly IBrush FabFill = new SolidColorBrush(Color.Parse("#9e9e9e"));
private static readonly IBrush FabFillHot = new SolidColorBrush(Color.Parse("#c4c4c4"));
private static readonly IBrush FabGlyph = new SolidColorBrush(Color.Parse("#1a1a1a"));
private static readonly IBrush FabShadow = new SolidColorBrush(Color.FromArgb(80, 0, 0, 0));
private static readonly IBrush HandleFill = new SolidColorBrush(Colors.LightGray);
/// <summary>
/// The six controls Node.xaml overlays on a hovered or selected node. Positions are the
/// WPF alignments resolved against the node's bounding box: the four 30x30 buttons sit in
/// the corners at Margin 5, the 35x35 connect-self button hangs off the bottom edge at
/// Margin -15, and the 30x25 drag handle sits at top centre.
/// </summary>
private static IEnumerable<NodeButton> NodeButtons(Iterator it, Point c)
{
double r = NodeRadius(it);
double left = c.X - r, right = c.X + r, top = c.Y - r, bottom = c.Y + r;
yield return new NodeButton(NodeAction.Drag, new Point(c.X, top + 12.5), 13, "══", 18);
yield return new NodeButton(NodeAction.FlipOpacity, new Point(left + 20, top + 20), 15, "👁", 16);
yield return new NodeButton(NodeAction.FlipWeight, new Point(right - 20, top + 20), 15, "⚖", 16);
yield return new NodeButton(NodeAction.Duplicate, new Point(left + 20, bottom - 20), 15, "🗎🗎", 12);
yield return new NodeButton(NodeAction.Split, new Point(right - 20, bottom - 20), 15, "🗎|🗎", 10);
yield return new NodeButton(NodeAction.ConnectSelf, new Point(c.X, bottom - 2.5), 17.5, "Ꝍ", 20);
}
/// <summary>True while this node should show its action buttons.</summary>
private bool ShowsButtons(Iterator it) => it == _hoverNode || it == _selected;
private NodeButton? HitButton(Point world)
{
if (_fractal is null)
return null;
foreach (Iterator it in _fractal.Iterators)
{
if (!ShowsButtons(it) || !_positions.TryGetValue(it, out Point c))
continue;
foreach (NodeButton b in NodeButtons(it, c))
if (Distance(b.Centre, world) <= b.Radius)
return b;
}
return null;
}
/// <summary>
/// Node.xaml fills the ellipse with a radial gradient from OpacityColor to LightGray at the
/// very rim (offset 0.98 -> 1.0). OpacityColor is greyscale: 100 + Opacity * 255 * 0.6.
/// </summary>
private static IBrush NodeFillBrush(Iterator it)
{
byte o = (byte)(100 + Math.Clamp(it.Opacity, 0, 1) * 255 * 0.6);
return new RadialGradientBrush
{
GradientStops =
{
new GradientStop(Color.FromRgb(o, o, o), 0.98),
new GradientStop(Colors.LightGray, 1.0),
},
};
}
public NodeEditor()
{
ClipToBounds = true;
Focusable = true;
}
public void SetFractal(IFS fractal)
{
_fractal = fractal;
_positions.Clear();
_selected = null;
_selectedEdge = null;
_zoom = 1.0;
_pan = default;
// Restore the layout the file carried, and lay out only what it didn't.
// Saved coordinates are kept verbatim so a save round-trips exactly; the
// view is fitted to them instead of the coordinates being rescaled.
foreach (Iterator it in fractal.Iterators)
{
NodePosition? saved = it.GetPosition();
if (saved is not null)
_positions[it] = new Point(saved.Value.X, saved.Value.Y);
}
_userArranged = _positions.Count > 0;
if (_positions.Count < fractal.Iterators.Count)
Layout();
_needsFit = true;
FitView();
InvalidateVisual();
}
/// <summary>
/// Connects or DISCONNECTS a pair — the canonical is a toggle, not a set.
/// </summary>
/// <remarks>
/// <c>IFSViewModel.Iterator_ConnectingEnded</c> (IFSViewModel.cs:186-189) flips the weight:
/// non-zero becomes 0, zero becomes 1. Always assigning 1.0, as this did, meant an existing
/// connection could never be removed by re-dragging it or by pressing Ꝍ again — the gesture
/// silently did nothing the second time.
/// </remarks>
private void ToggleConnection(Iterator from, Iterator to)
{
SnapshotRequested?.Invoke();
bool connected = from.WeightTo.TryGetValue(to, out double w) && w > 0.0;
from.WeightTo[to] = connected ? 0.0 : 1.0;
GraphChanged?.Invoke();
}
/// <summary>Selects a node from outside (keeps the list and the graph in sync).</summary>
public void Select(Iterator it)
{
_selected = it;
_selectedEdge = null;
InvalidateVisual();
}
/// <summary>Re-runs auto-layout and re-fits the view (the "auto layout" command).</summary>
public void ResetView()
{
_positions.Clear();
_userArranged = false;
Layout();
FitView();
InvalidateVisual();
}
/// <summary>Pans and zooms so every node is on screen.</summary>
private void FitView()
{
if (_positions.Count == 0 || Bounds.Width < 10 || Bounds.Height < 10)
return;
double rad = _fractal is null ? 60
: _fractal.Iterators.Select(NodeRadius).DefaultIfEmpty(60).Max();
double minX = _positions.Values.Min(p => p.X) - rad;
double maxX = _positions.Values.Max(p => p.X) + rad;
double minY = _positions.Values.Min(p => p.Y) - rad;
double maxY = _positions.Values.Max(p => p.Y) + rad;
const double margin = 28;
double contentW = Math.Max(maxX - minX, 1) + margin * 2;
double contentH = Math.Max(maxY - minY, 1) + margin * 2;
_zoom = Math.Clamp(Math.Min(Bounds.Width / contentW, Bounds.Height / contentH), MinZoom, MaxZoom);
double cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
_pan = new Point(Bounds.Width / 2 - cx * _zoom, Bounds.Height / 2 - cy * _zoom);
_needsFit = false;
}
private void Layout()
{
if (_fractal is null)
return;
var iterators = _fractal.Iterators.ToList();
if (iterators.Count == 0)
return;
double w = Math.Max(Bounds.Width, 400), h = Math.Max(Bounds.Height, 240);
var centre = new Point(w / 2, h / 2);
// The graph panel is wide and short, so lay the nodes out on an ELLIPSE
// matched to the panel's aspect. A circle would be radius-limited by the
// short axis and the nodes would overlap horizontally.
double rx = Math.Max(w / 2 - 90, 130);
double ry = Math.Max(h / 2 - 70, 90);
if (iterators.Count == 1)
{
if (!_positions.ContainsKey(iterators[0]))
Place(iterators[0], centre);
return;
}
for (int i = 0; i < iterators.Count; i++)
{
if (_positions.ContainsKey(iterators[i]))
continue; // keep a position the file supplied or the user set
double angle = -Math.PI / 2 + i * 2 * Math.PI / iterators.Count;
Place(iterators[i], new Point(
centre.X + rx * Math.Cos(angle),
centre.Y + ry * Math.Sin(angle)));
}
}
/// <summary>Moves a node, keeping the saveable side-table in step with the view.</summary>
private void Place(Iterator iterator, Point position)
{
_positions[iterator] = position;
iterator.SetPosition(new NodePosition(position.X, position.Y));
}
protected override void OnSizeChanged(SizeChangedEventArgs e)
{
base.OnSizeChanged(e);
if (_needsFit)
FitView();
// The control's first real size arrives well after SetFractal, so keep
// re-laying-out until the user takes over by dragging a node.
if (_fractal is not null && !_userArranged)
{
Layout();
InvalidateVisual();
}
}
// --------------------------------------------------------- view transform
private Point ToWorld(Point screen) =>
new((screen.X - _pan.X) / _zoom, (screen.Y - _pan.Y) / _zoom);
// ------------------------------------------------------------------ drawing
public override void Render(DrawingContext ctx)
{
ctx.FillRectangle(BackgroundBrush, new Rect(Bounds.Size));
if (_fractal is null)
return;
if (_positions.Count == 0)
Layout();
using (ctx.PushTransform(
Matrix.CreateScale(_zoom, _zoom) * Matrix.CreateTranslation(_pan.X, _pan.Y)))
{
var iterators = _fractal.Iterators.ToList();
foreach (Iterator from in iterators)
{
foreach (Iterator to in iterators)
{
if (!from.WeightTo.TryGetValue(to, out double weight) || weight <= 0.0001)
continue;
if (!_positions.ContainsKey(from) || !_positions.ContainsKey(to))
continue;
bool selected = _selectedEdge is { } se && se.From == from && se.To == to;
bool hot = _hoverEdge is { } he && he.From == from && he.To == to;
IPen pen = selected ? EdgePenSelected : hot ? EdgePenHot : EdgePen;
if (from == to)
DrawSelfLoop(ctx, from, pen);
else
DrawEdge(ctx, _positions[from], _positions[to], pen);
}
}
if (_linkSource is not null && _positions.TryGetValue(_linkSource, out Point src))
ctx.DrawLine(LinkPen, src, _linkCursor);
foreach (Iterator it in iterators)
{
if (!_positions.TryGetValue(it, out Point p))
continue;
double r = NodeRadius(it);
bool sel = it == _selected;
bool hot = it == _hoverNode;
// Node.xaml: StrokeThickness 4, growing to 8 while hovered or selected.
var ring = new Pen(RingBrush(it), sel || hot ? 8 : 4);
// The WPF node casts a DropShadowEffect when selected (depth 5, blur 20,
// pointing down). DrawingContext has no effect pipeline, so it is approximated
// by a soft offset disc behind the node.
if (sel)
ctx.DrawEllipse(new SolidColorBrush(Color.FromArgb(90, 0, 0, 0)), null,
new Point(p.X, p.Y + 5), r + 4, r + 4);
ctx.DrawEllipse(NodeFillBrush(it), ring, p, r, r);
string title = string.IsNullOrWhiteSpace(it.Name) ? it.Transform?.Name ?? "iterator" : it.Name;
FormattedText label = Text(Trim(title, 18), 13, NodeText);
ctx.DrawText(label, new Point(p.X - label.Width / 2, p.Y - label.Height / 2));
if (ShowsButtons(it))
DrawNodeButtons(ctx, it, p);
}
}
}
private void DrawNodeButtons(DrawingContext ctx, Iterator it, Point centre)
{
foreach (NodeButton b in NodeButtons(it, centre))
{
bool hot = _hoverButton == b.Action && _hoverNode == it;
if (b.Action == NodeAction.Drag)
{
// Not a button in the original either - a bare glyph with a drop shadow.
FormattedText handle = Text(b.Glyph, b.FontSize, HandleFill);
ctx.DrawText(Text(b.Glyph, b.FontSize, FabShadow),
new Point(b.Centre.X - handle.Width / 2 + 1, b.Centre.Y - handle.Height / 2 + 1));
ctx.DrawText(handle,
new Point(b.Centre.X - handle.Width / 2, b.Centre.Y - handle.Height / 2));
continue;
}
ctx.DrawEllipse(FabShadow, null,
new Point(b.Centre.X, b.Centre.Y + 1.5), b.Radius, b.Radius);
ctx.DrawEllipse(hot ? FabFillHot : FabFill, null, b.Centre, b.Radius, b.Radius);
FormattedText glyph = Text(b.Glyph, b.FontSize, FabGlyph);
ctx.DrawText(glyph, new Point(b.Centre.X - glyph.Width / 2, b.Centre.Y - glyph.Height / 2));
}
}
/// <summary>
/// Draws a connection exactly as <c>ConnectionArrow.CalcGeometryByPoints</c> does: a cubic
/// bezier from node CENTRE to node CENTRE (not edge to edge), control points at 1/3 and 2/3
/// pushed out by 30 at (heading + ArrowCurve), and an open V arrowhead placed 22 past the
/// midpoint — not at the end, which is where this port previously drew it.
/// </summary>
private void DrawEdge(DrawingContext ctx, Point p1, Point p2, IPen pen)
{
double angle = Math.Atan2(p2.Y - p1.Y, p2.X - p1.X) + ArrowCurve;
double cosa = Math.Cos(angle), sina = Math.Sin(angle);
Point c1 = new((p1.X * 2 + p2.X) / 3 + CurveBow * cosa, (p1.Y * 2 + p2.Y) / 3 + CurveBow * sina);
Point c2 = new((p1.X + p2.X * 2) / 3 + CurveBow * cosa, (p1.Y + p2.Y * 2) / 3 + CurveBow * sina);
var path = new StreamGeometry();
using (var g = path.Open())
{
g.BeginFigure(p1, false);
g.CubicBezierTo(c1, c2, p2);
g.EndFigure(false);
}
ctx.DrawGeometry(null, pen, path);
// The head uses the straight-line heading, as the original does.
Point mid = new((p1.X + p2.X) / 2 + HeadBow * cosa, (p1.Y + p2.Y) / 2 + HeadBow * sina);
double heading = Math.Atan2(p2.Y - p1.Y, p2.X - p1.X);
ctx.DrawLine(pen, mid, new Point(
mid.X - Math.Cos(heading + 0.5) * ArrowHeadSize,
mid.Y - Math.Sin(heading + 0.5) * ArrowHeadSize));
ctx.DrawLine(pen, mid, new Point(
mid.X - Math.Cos(heading - 0.5) * ArrowHeadSize,
mid.Y - Math.Sin(heading - 0.5) * ArrowHeadSize));
}
/// <summary>
/// Self-connection, as <c>ConnectionArrow.CalcGeometryToSelf</c>: a circle of radius
/// NodeSize*0.4 sitting beside the node, pushed away from its neighbours.
/// </summary>
private void DrawSelfLoop(DrawingContext ctx, Iterator it, IPen pen)
{
Point p = _positions[it];
double r = NodeSize(it) * 0.4;
double a = LoopbackAngle(it);
double cosa = Math.Cos(a), sina = Math.Sin(a);
var centre = new Point(p.X + r * cosa, p.Y + r * sina);
ctx.DrawEllipse(null, pen, centre, r, r);
Point mid = new(p.X + 2 * r * cosa, p.Y + 2 * r * sina);
double h = a - Math.PI / 4.0;
double ch = Math.Cos(h), sh = Math.Sin(h);
ctx.DrawLine(pen, mid, new Point(mid.X - ArrowHeadSize * ch, mid.Y - ArrowHeadSize * sh));
ctx.DrawLine(pen, mid, new Point(mid.X - ArrowHeadSize * sh, mid.Y + ArrowHeadSize * ch));
}
/// <summary>
/// NodeMap.CalculateLoopbackAngle: point the loop away from the average direction of every
/// connection touching this node, so it does not sit on top of them.
/// </summary>
private double LoopbackAngle(Iterator it)
{
if (!_positions.TryGetValue(it, out Point p) || _fractal is null)
return 0;
double dx = 0, dy = 0;
foreach (Iterator other in _fractal.Iterators)
{
if (other == it || !_positions.TryGetValue(other, out Point q))
continue;
bool linked = (it.WeightTo.TryGetValue(other, out double w1) && w1 > 0.0001)
|| (other.WeightTo.TryGetValue(it, out double w2) && w2 > 0.0001);
if (!linked)
continue;
dx += (p.X + q.X) / 2 - p.X;
dy += (p.Y + q.Y) / 2 - p.Y;
}
return Math.Atan2(-dy, -dx);
}
private static FormattedText Text(string s, double size, IBrush brush) =>
new(s, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, size, brush);
private static string Trim(string s, int n) => s.Length <= n ? s : s[..(n - 1)] + "…";
private static byte Channel(float v) => (byte)Math.Clamp(v * 255f, 0f, 255f);
private static Point Normalize(Point p)
{
double len = Math.Sqrt(p.X * p.X + p.Y * p.Y);
return len < 0.0001 ? new Point(1, 0) : new Point(p.X / len, p.Y / len);
}
// ------------------------------------------------------------------- input
private Iterator HitNode(Point world) =>
_fractal?.Iterators.FirstOrDefault(it =>
_positions.TryGetValue(it, out Point c) && Distance(c, world) <= NodeRadius(it));
private (Iterator From, Iterator To)? HitEdge(Point world)
{
if (_fractal is null)
return null;
var iterators = _fractal.Iterators.ToList();
foreach (Iterator from in iterators)
{
foreach (Iterator to in iterators)
{
if (!from.WeightTo.TryGetValue(to, out double w) || w <= 0.0001)
continue;
if (!_positions.TryGetValue(from, out Point a) || !_positions.TryGetValue(to, out Point b))
continue;
if (from == to)
{
double rr = NodeSize(from) * 0.4;
double ang = LoopbackAngle(from);
var centre = new Point(a.X + rr * Math.Cos(ang), a.Y + rr * Math.Sin(ang));
// ConnectionArrow gives the click area a 20px stroke.
if (Math.Abs(Distance(world, centre) - rr) < 10)
return (from, to);
continue;
}
if (DistanceToCurve(world, a, b) < 10)
return (from, to);
}
}
return null;
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
Point screen = e.GetPosition(this);
Point world = ToWorld(screen);
var props = e.GetCurrentPoint(this).Properties;
// The hover controls sit on top of the node, so they are tested first.
if (props.IsLeftButtonPressed && HitButton(world) is { } button)
{
Iterator target = _hoverNode ?? _selected;
if (target is not null)
{
InvokeNodeAction(button.Action, target, world, e);
return;
}
}
Iterator node = HitNode(world);
if (node is not null)
{
_selected = node;
_selectedEdge = null;
IteratorSelected?.Invoke(node);
if (props.IsLeftButtonPressed)
{
// Node.xaml starts a CONNECTION when the pointer leaves the node with the
// button held; moving the node is the ══ handle's job. So hold the press
// and decide in OnPointerMoved.
_pressNode = node;
e.Pointer.Capture(this);
}
InvalidateVisual();
return;
}
var edge = HitEdge(world);
if (edge is { } ed)
{
if (props.IsRightButtonPressed)
{
SnapshotRequested?.Invoke();
ed.From.WeightTo[ed.To] = 0.0;
_selectedEdge = null;
GraphChanged?.Invoke();
}
else
{
_selectedEdge = ed;
_selected = null;
ConnectionSelected?.Invoke(ed.From, ed.To);
}
InvalidateVisual();
return;
}
// Empty space + left button = pan, matching the WPF Zoombox (DragModifiers=None).
if (props.IsLeftButtonPressed)
{
_panning = true;
_panStart = screen;
_panOrigin = _pan;
e.Pointer.Capture(this);
}
}
/// <summary>Runs a hover control. Drag starts a node move; the rest raise for the host.</summary>
private void InvokeNodeAction(NodeAction action, Iterator it, Point world, PointerPressedEventArgs e)
{
_selected = it;
_selectedEdge = null;
IteratorSelected?.Invoke(it);
switch (action)
{
case NodeAction.Drag:
// Node positions are persisted document state, so moving one is undoable.
SnapshotRequested?.Invoke();
_dragNode = it;
_dragOffset = world - _positions[it];
e.Pointer.Capture(this);
break;
case NodeAction.FlipOpacity:
FlipOpacityRequested?.Invoke(it);
break;
case NodeAction.FlipWeight:
FlipWeightRequested?.Invoke(it);
break;
case NodeAction.Duplicate:
DuplicateRequested?.Invoke(it);
break;
case NodeAction.Split:
SplitRequested?.Invoke(it);
break;
case NodeAction.ConnectSelf:
ToggleConnection(it, it);
break;
}
InvalidateVisual();
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
Point screen = e.GetPosition(this);
Point world = ToWorld(screen);
if (_panning)
{
_pan = new Point(_panOrigin.X + (screen.X - _panStart.X), _panOrigin.Y + (screen.Y - _panStart.Y));
InvalidateVisual();
return;
}
// Dragging clear of the pressed node begins a connection, mirroring OnMouseLeave.
if (_pressNode is not null && _positions.TryGetValue(_pressNode, out Point origin)
&& Distance(world, origin) > NodeRadius(_pressNode))
{
_linkSource = _pressNode;
_pressNode = null;
}
if (_linkSource is not null)
{
_linkCursor = world;
InvalidateVisual();
return;
}
if (_dragNode is not null)
{
Place(_dragNode, world - _dragOffset);
_userArranged = true;
InvalidateVisual();
return;
}
NodeButton? overButton = HitButton(world);
// Keep the current node "hovered" while the pointer is on one of its buttons, which
// may hang outside the circle (connect-self sits on the bottom edge).
Iterator hoverNode = overButton is not null ? _hoverNode : HitNode(world);
var edge = hoverNode is null ? HitEdge(world) : null;
if (edge?.From != _hoverEdge?.From || edge?.To != _hoverEdge?.To
|| hoverNode != _hoverNode || overButton?.Action != _hoverButton)
{
_hoverEdge = edge;
_hoverNode = hoverNode;
_hoverButton = overButton?.Action;
InvalidateVisual();
}
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (_linkSource is not null)
{
Iterator target = HitNode(ToWorld(e.GetPosition(this)));
if (target is not null)
ToggleConnection(_linkSource, target);
_linkSource = null;
InvalidateVisual();
}
_pressNode = null;
_dragNode = null;
_panning = false;
e.Pointer.Capture(null);
}
/// <summary>Wheel zooms about the cursor, as the WPF Zoombox did (RelativeZoomModifiers=None).</summary>
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
base.OnPointerWheelChanged(e);
Point cursor = e.GetPosition(this);
double old = _zoom;
_zoom = Math.Clamp(_zoom * (1 + e.Delta.Y * 0.12), MinZoom, MaxZoom);
if (Math.Abs(_zoom - old) < 1e-9)
return;
// keep the world point under the cursor fixed
double k = _zoom / old;
_pan = new Point(cursor.X - (cursor.X - _pan.X) * k, cursor.Y - (cursor.Y - _pan.Y) * k);
InvalidateVisual();
e.Handled = true;
}
private static double Distance(Point a, Point b) =>
Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
/// <summary>
/// Distance to the drawn bezier, sampled. Cheap and accurate enough for a
/// click target, and it keeps hit-testing honest about the curve we render.
/// </summary>
private static double DistanceToCurve(Point p, Point a, Point b)
{
Point start = a, end = b;
double angle = Math.Atan2(end.Y - start.Y, end.X - start.X) + ArrowCurve;
double cosa = Math.Cos(angle), sina = Math.Sin(angle);
Point c1 = new((start.X * 2 + end.X) / 3 + CurveBow * cosa, (start.Y * 2 + end.Y) / 3 + CurveBow * sina);
Point c2 = new((start.X + end.X * 2) / 3 + CurveBow * cosa, (start.Y + end.Y * 2) / 3 + CurveBow * sina);
const int samples = 16;
double best = double.MaxValue;
Point previous = start;
for (int i = 1; i <= samples; i++)
{
double t = i / (double)samples;
double u = 1 - t;
Point point = new(
u * u * u * start.X + 3 * u * u * t * c1.X + 3 * u * t * t * c2.X + t * t * t * end.X,
u * u * u * start.Y + 3 * u * u * t * c1.Y + 3 * u * t * t * c2.Y + t * t * t * end.Y);
best = Math.Min(best, DistanceToSegment(p, previous, point));
previous = point;
}
return best;
}
private static double DistanceToSegment(Point p, Point a, Point b)
{
double dx = b.X - a.X, dy = b.Y - a.Y;
double lenSq = dx * dx + dy * dy;
if (lenSq < 0.0001)
return Distance(p, a);
double t = Math.Clamp(((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq, 0, 1);
return Distance(p, new Point(a.X + t * dx, a.Y + t * dy));
}
}