OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / NodeEditor.cs

819 lines · csharp ref:
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));
    }
}