diff --git a/Westermo.GraphX.Controls.Avalonia.Tests/GeometryCachingTests.cs b/Westermo.GraphX.Controls.Avalonia.Tests/GeometryCachingTests.cs index 46899c4..dea77ca 100644 --- a/Westermo.GraphX.Controls.Avalonia.Tests/GeometryCachingTests.cs +++ b/Westermo.GraphX.Controls.Avalonia.Tests/GeometryCachingTests.cs @@ -7,6 +7,10 @@ using Westermo.GraphX.Common.Enums; using Westermo.GraphX.Common.Models; using Westermo.GraphX.Controls.Controls; +using Westermo.GraphX.Controls.Controls.EdgeLabels; +using Westermo.GraphX.Controls.Controls.EdgePointers; +using Westermo.GraphX.Controls.Controls.VertexLabels; +using Westermo.GraphX.Controls.Models.Interfaces; using Westermo.GraphX.Logic.Models; namespace Westermo.GraphX.Controls.Avalonia.Tests; @@ -27,6 +31,21 @@ private class TEdge(TVertex s, TVertex t) : EdgeBase(s, t) public override Westermo.GraphX.Measure.Point[]? RoutingPoints { get; set; } = null; } + private sealed class PlainEdgeLabel : EdgeLabelControl { } + + private sealed class PlainLabelFactory : ILabelFactory + where TLabel : Control, new() + { + public IEnumerable CreateLabel(TCtrl control) => [new TLabel()]; + } + + private static void MeasureAndArrange(EdgeControl edge) + { + edge.InvalidateMeasure(); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + } + private static void EnsureVertexTemplate(VertexControl vc) { if (vc.Template == null) @@ -72,6 +91,41 @@ private static void EnsureEdgeTemplate(EdgeControl ec) ec.ApplyTemplate(); } + private static void EnsureEdgeTemplateWithTargetPointer(EdgeControl ec, double pointerWidth = 10, double pointerHeight = 10) + { + var content = new Grid(); + var path = new global::Avalonia.Controls.Shapes.Path + { + Name = "PART_edgePath", + Stroke = Brushes.Black, + StrokeThickness = 1 + }; + var pointer = new DefaultEdgePointer + { + Name = "PART_EdgePointerForTarget", + Width = pointerWidth, + Height = pointerHeight, + Content = new global::Avalonia.Controls.Shapes.Polygon + { + Points = [new Point(0, 0), new Point(pointerWidth, pointerHeight / 2), new Point(0, pointerHeight)], + Fill = Brushes.Black + } + }; + content.Children.Add(path); + content.Children.Add(pointer); + var ns = new NameScope(); + ns.Register("PART_edgePath", path); + ns.Register("PART_EdgePointerForTarget", pointer); + var functor = new Func(_ => new TemplateResult(content, ns)); + ec.Template = new ControlTemplate + { + TargetType = typeof(EdgeControl), + Content = functor + }; + + ec.ApplyTemplate(); + } + private (GraphArea> area, VertexControl sourceVc, VertexControl targetVc, EdgeControl edge) CreateSimpleGraph() { @@ -120,6 +174,46 @@ private static void EnsureEdgeTemplate(EdgeControl ec) return (area, sourceVc, targetVc, edge); } + private (GraphArea> area, + VertexControl sourceVc, EdgeControl edge) CreateSelfLoopGraph() + { + var graph = new BidirectionalGraph(); + var v1 = new TVertex("A") { ID = 1 }; + graph.AddVertex(v1); + var e = new TEdge(v1, v1); + graph.AddEdge(e); + + var lc = new GXLogicCore> + { + Graph = graph, + EnableParallelEdges = false + }; + + var area = new GraphArea> + { + LogicCore = lc, + Width = 500, + Height = 400 + }; + + var positions = new Dictionary + { + [v1] = new Point(50, 100) + }; + + area.PreloadGraph(positions, showObjectsIfPosSpecified: true); + + var sourceVc = area.VertexList[v1]; + sourceVc.Width = 40; + sourceVc.Height = 30; + EnsureVertexTemplate(sourceVc); + + var edge = (EdgeControl)area.EdgesList[e]; + EnsureEdgeTemplate(edge); + + return (area, sourceVc, edge); + } + [Test] public async Task GeometryBounds_ReturnsNullBeforeFirstUpdate() { @@ -176,4 +270,188 @@ public async Task Geometry_IsReused_WhenMeasureIsInvalidatedWithoutInputChanges( await Assert.That(edge.GetLineGeometry()).IsSameReferenceAs(initialGeometry); } -} \ No newline at end of file + + [Test] + public async Task GeometryCacheBenchmarkSwitch_RebuildsWithoutCacheAndResumesReuseWhenEnabled() + { + var (_, _, _, edge) = CreateSimpleGraph(); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + + edge.UseGeometryCache = false; + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + var firstUncachedGeometry = edge.GetLineGeometry(); + + edge.InvalidateMeasure(); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + await Assert.That(edge.GetLineGeometry()).IsNotSameReferenceAs(firstUncachedGeometry); + + edge.UseGeometryCache = true; + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + var firstCachedGeometry = edge.GetLineGeometry(); + + edge.InvalidateMeasure(); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + await Assert.That(edge.GetLineGeometry()).IsSameReferenceAs(firstCachedGeometry); + } + + [Test] + public async Task Geometry_IsRebuilt_WhenPointerDesiredSizeChangesBeforeEdgeMeasure() + { + var (_, _, _, edge) = CreateSimpleGraph(); + EnsureEdgeTemplateWithTargetPointer(edge); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + + var initialGeometry = edge.GetLineGeometry(); + var targetPointer = edge.GetEdgePointerForTarget() as Control; + await Assert.That(targetPointer).IsNotNull(); + await Assert.That(targetPointer!.DesiredSize.Width).IsEqualTo(10); + + // Changing the pointer size invalidates its own measure first. The edge cache check must + // observe the fresh DesiredSize during the same pass so the endpoint offsets are rebuilt immediately. + targetPointer.Width = 20; + edge.InvalidateMeasure(); + + await Assert.That(targetPointer.DesiredSize.Width).IsEqualTo(10); + + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + + await Assert.That(targetPointer.DesiredSize.Width).IsEqualTo(20); + await Assert.That(edge.GetLineGeometry()).IsNotSameReferenceAs(initialGeometry); + } + + [Test] + public async Task Geometry_IsRebuilt_WhenReversePathChanges() + { + var (_, _, _, edge) = CreateSimpleGraph(); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + var initialGeometry = edge.GetLineGeometry(); + + // ReversePath only affects the traversal order used when the final StreamGeometry is built + // (it doesn't change the underlying point values), so the cache must treat it as an explicit + // input rather than relying on the point-signature comparison to notice the change. + ((TEdge)edge.Edge!).ReversePath = true; + edge.InvalidateMeasure(); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + + await Assert.That(edge.GetLineGeometry()).IsNotSameReferenceAs(initialGeometry); + } + + [Test] + public async Task Geometry_IsCleared_WhenShowSelfLoopIndicatorChanges() + { + var (_, _, edge) = CreateSelfLoopGraph(); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + + await Assert.That(edge.GetLineGeometry()).IsNotNull(); + + edge.ShowSelfLoopIndicator = false; + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + + await Assert.That(edge.GetLineGeometry()).IsNull(); + } + + [Test] + public async Task Geometry_UpdatesEndpoint_WhenVertexShapeChanges() + { + var (_, source, target, edge) = CreateSimpleGraph(); + target.SetPosition(250, 180); + GraphAreaBase.SetFinalY(target, 180); + source.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + source.VertexShape = VertexShape.Circle; + MeasureAndArrange(edge); + var oldEndpoint = edge.SourceEndpoint; + + source.VertexShape = VertexShape.Rectangle; + MeasureAndArrange(edge); + + await Assert.That(edge.SourceEndpoint).IsNotEqualTo(oldEndpoint); + } + + [Test] + public async Task Geometry_UpdatesEndpoints_WhenParallelEdgesAreEnabled() + { + var (area, source, target, edge) = CreateSimpleGraph(); + source.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.IsParallel = true; + edge.ParallelEdgeOffset = 25; + MeasureAndArrange(edge); + var oldEndpoint = edge.SourceEndpoint; + + area.LogicCore!.EnableParallelEdges = true; + MeasureAndArrange(edge); + + await Assert.That(edge.SourceEndpoint).IsNotEqualTo(oldEndpoint); + } + + [Test] + public async Task PointerOrientation_IsCalculatedOnlyForPointersThatNeedIt() + { + var (_, _, _, edge) = CreateSimpleGraph(); + MeasureAndArrange(edge); + await Assert.That(edge.TargetPointerComputedAngle).IsEqualTo(0); + + EnsureEdgeTemplateWithTargetPointer(edge); + var pointer = (DefaultEdgePointer)edge.GetEdgePointerForTarget()!; + pointer.NeedRotation = false; + MeasureAndArrange(edge); + await Assert.That(edge.TargetPointerComputedAngle).IsEqualTo(0); + + pointer.NeedRotation = true; + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width + 1, edge.DesiredSize.Height)); + await Assert.That(Math.Abs(edge.TargetPointerComputedAngle)).IsGreaterThan(0); + } + + [Test] + public async Task GetRelatedEdgeControls_ReturnsSelfLoopOnlyOnce() + { + var (area, source, edge) = CreateSelfLoopGraph(); + var vertex = (TVertex)source.Vertex!; + var secondLoop = new TEdge(vertex, vertex); + area.LogicCore!.Graph.AddEdge(secondLoop); + var secondControl = area.ControlFactory.CreateEdgeControl(source, source, secondLoop); + area.AddEdge(secondLoop, secondControl); + + var related = area.GetRelatedEdgeControls(source, EdgesType.All); + + await Assert.That(related.Count).IsEqualTo(2); + await Assert.That(related.Count(control => ReferenceEquals(control, edge))).IsEqualTo(1); + await Assert.That(related.Count(control => ReferenceEquals(control, secondControl))).IsEqualTo(1); + } + + [Test] + public async Task ClearLayout_ReleasesGeneratedLabelsThatAreNotAttached() + { + var (area, _, _, _) = CreateSimpleGraph(); + area.VertexLabelFactory = new PlainLabelFactory(); + area.EdgeLabelFactory = new PlainLabelFactory(); + area.PreloadGraph(new Dictionary + { + [area.LogicCore!.Graph.Vertices.First()] = new Point(50, 100), + [area.LogicCore.Graph.Vertices.Last()] = new Point(250, 100) + }); + await Assert.That(area.GeneratedVertexLabelCount).IsEqualTo(2); + await Assert.That(area.GeneratedEdgeLabelCount).IsEqualTo(1); + + area.ClearLayout(removeCustomObjects: false); + await Assert.That(area.GeneratedVertexLabelCount).IsEqualTo(2); + await Assert.That(area.GeneratedEdgeLabelCount).IsEqualTo(1); + + area.ClearLayout(); + + await Assert.That(area.GeneratedVertexLabelCount).IsEqualTo(0); + await Assert.That(area.GeneratedEdgeLabelCount).IsEqualTo(0); + } +} diff --git a/Westermo.GraphX.Controls.Avalonia.Tests/VcpEdgeGeometryTests.cs b/Westermo.GraphX.Controls.Avalonia.Tests/VcpEdgeGeometryTests.cs index 93be324..8d111d2 100644 --- a/Westermo.GraphX.Controls.Avalonia.Tests/VcpEdgeGeometryTests.cs +++ b/Westermo.GraphX.Controls.Avalonia.Tests/VcpEdgeGeometryTests.cs @@ -211,4 +211,40 @@ public async Task EdgeGeometry_UsesConnectionPointCenterWhenShapeNone() await Assert.That(actualTarget.X).IsBetween(targetCenter.X - eps, targetCenter.X + eps); await Assert.That(actualTarget.Y).IsBetween(targetCenter.Y - eps, targetCenter.Y + eps); } + + [Test] + public async Task EdgeGeometry_UpdatesEndpoint_WhenConnectionPointShapeChanges() + { + var (_, _, _, cp, _, _, edge) = + CreateAreaWithVcp(VertexShape.Circle, VertexShape.Circle, bothEndpoints: true); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + var oldEndpoint = edge.SourceEndpoint; + + cp.Shape = VertexShape.None; + edge.InvalidateMeasure(); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + + await Assert.That(edge.SourceEndpoint).IsNotEqualTo(oldEndpoint); + await Assert.That(edge.SourceEndpoint).IsEqualTo(cp.RectangularSize.Center()); + } + + [Test] + public async Task EdgeGeometry_UpdatesEndpoint_WhenConnectionPointSizeChanges() + { + var (_, _, _, cp, _, _, edge) = + CreateAreaWithVcp(VertexShape.Circle, VertexShape.Circle, bothEndpoints: true); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + var oldEndpoint = edge.SourceEndpoint; + + cp.Width = 30; + cp.Update(); + edge.InvalidateMeasure(); + edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height)); + + await Assert.That(edge.SourceEndpoint).IsNotEqualTo(oldEndpoint); + } } \ No newline at end of file diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/EdgeControlBase.cs b/Westermo.GraphX.Controls.Avalonia/Controls/EdgeControlBase.cs index df2efb6..c01c3af 100644 --- a/Westermo.GraphX.Controls.Avalonia/Controls/EdgeControlBase.cs +++ b/Westermo.GraphX.Controls.Avalonia/Controls/EdgeControlBase.cs @@ -10,8 +10,10 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Shapes; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.Media; using Westermo.GraphX.Common; +using Westermo.GraphX.Common.Enums; using Westermo.GraphX.Common.Exceptions; using Westermo.GraphX.Common.Interfaces; using Westermo.GraphX.Controls.Controls.Misc; @@ -94,6 +96,22 @@ private struct EdgePointerLayoutInfo /// Indicates that valid data has been stored. public bool HasData; + + /// + /// Whether and coincide. + /// Precomputed alongside / in MeasureOverride + /// so ArrangeEdgePointer doesn't repeat the point-direction/angle math on every arrange pass. + /// + public bool IsCoincident; + + /// Precomputed direction vector from towards (zero when coincident). + public Vector Direction; + + /// Precomputed rotation angle (degrees) for a pointer that requires rotation. + public double Angle; + + /// Tracks whether the angle has been computed for this pointer layout. + public bool AngleComputed; } /// Cached source pointer layout info, set during geometry rebuild. @@ -102,6 +120,8 @@ private struct EdgePointerLayoutInfo /// Cached target pointer layout info, set during geometry rebuild. private EdgePointerLayoutInfo _targetPointerLayout; + internal double TargetPointerComputedAngle => _targetPointerLayout.Angle; + /// /// Calculates the padding needed to prevent edge pointers from being clipped. /// @@ -162,15 +182,16 @@ public Measure.Point GetPointerPosition(IEdgePointer pointer) /// Computes direction, rotation angle, and arranges the pointer within the edge's local space. /// Always runs after base.ArrangeOverride(), whether geometry was rebuilt or not. /// - private static Measure.Point ArrangeEdgePointer(EdgePointerLayoutInfo data, IEdgePointer pointer, + private static Measure.Point ArrangeEdgePointer(ref EdgePointerLayoutInfo data, IEdgePointer pointer, bool hideEdgePointerOnVertexOverlap) { var from = data.Position; - var to = data.DirectionTarget; var allowUnsuppress = data.AllowUnsuppress; - var dir = from.DirectionTo(to); - if (from == to) + // Direction/angle are precomputed once in MeasureOverride; only the + // suppress/unsuppress decision (which depends on the live toggle) is done here. + var dir = data.Direction; + if (data.IsCoincident) { if (hideEdgePointerOnVertexOverlap) pointer.Suppress(); else dir = new Vector(0, 0); @@ -186,9 +207,17 @@ private static Measure.Point ArrangeEdgePointer(EdgePointerLayoutInfo data, IEdg // Convert to local coordinates using the now-known offset var position = new Measure.Point(from.X, from.Y); - var angle = pointer.NeedRotation - ? -MathHelper.GetAngleBetweenPoints(from.ToGraphX(), to.ToGraphX()).ToDegrees() - : 0; + var angle = 0.0; + if (pointer.NeedRotation) + { + if (!data.AngleComputed) + { + data.Angle = -MathHelper.GetAngleBetweenPoints(from.ToGraphX(), + data.DirectionTarget.ToGraphX()).ToDegrees(); + data.AngleComputed = true; + } + angle = data.Angle; + } var vecMove = new Measure.Vector((.5 + dir.X * .5) * width, (.5 + dir.Y * .5) * height); position = new Measure.Point(position.X - vecMove.X, position.Y - vecMove.Y); @@ -200,6 +229,26 @@ private static Measure.Point ArrangeEdgePointer(EdgePointerLayoutInfo data, IEdg return position; } + /// + /// Precomputes the direction vector, coincidence flag, and rotation angle for a pointer's + /// layout data. Called once per MeasureOverride so + /// doesn't repeat this trigonometry on every arrange pass. + /// + private static void ComputePointerDirectionAndAngle(ref EdgePointerLayoutInfo data, IEdgePointer? pointer) + { + if (!data.HasData || pointer is null) return; + var from = data.Position; + var to = data.DirectionTarget; + data.IsCoincident = from == to; + if (!data.IsCoincident) + data.Direction = from.DirectionTo(to); + if (pointer.NeedRotation) + { + data.Angle = -MathHelper.GetAngleBetweenPoints(from.ToGraphX(), to.ToGraphX()).ToDegrees(); + data.AngleComputed = true; + } + } + private static (double, double) GetWidthAndHeight(Size size, Control ctrl) { var width = size.Width; @@ -520,12 +569,13 @@ public void AttachLabel(IEdgeLabelControl ctrl) /// public void DetachLabels(IEdgeLabelControl? ctrl = null) { - EdgeLabelControls.OfType>() - .ForEach(label => - { - label.Detach(); - RootArea?.Children.Remove((Control)label); - }); + foreach (var label in EdgeLabelControls) + { + if (label is not IAttachableControl attachable) continue; + attachable.Detach(); + RootArea?.Children.Remove((Control)label); + } + EdgeLabelControls.Clear(); RootArea?.NotifyBatchedEdgeChanged(this); } @@ -535,7 +585,10 @@ public void DetachLabels(IEdgeLabelControl? ctrl = null) /// public void UpdateLabel() { - _edgeLabelControls.Where(l => l.ShowLabel).ForEach(l => { l.Show(); }); + foreach (var l in _edgeLabelControls) + { + if (l.ShowLabel) l.Show(); + } } @@ -656,15 +709,16 @@ internal void SetBatchedPathSuppressed(bool suppressed) internal bool IsBatchedPathSuppressed => _batchedPathOpacity.HasValue; - // Re-added after edit: measure template child once with unlimited size so DesiredSize is initialized - protected void MeasureChild(Control? child) + protected static void MeasureChild(Control? child) { - if (child == null) return; - - // Ensure the child's template is applied so content is available for measuring - if (child is TemplatedControl templated) + switch (child) { - templated.ApplyTemplate(); + case null: + return; + case TemplatedControl templated: + // Ensure the child's template is applied so content is available for measuring + templated.ApplyTemplate(); + break; } child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); @@ -683,7 +737,24 @@ private static Size Union(params ReadOnlySpan sizes) return new Size(width, height); } - private int _oldSignature; + private EdgeControlCache? _cache; + private bool _useGeometryCache = true; + + // Benchmark-only comparison against Avalonia's own measure invalidation. + internal bool UseGeometryCache + { + get => _useGeometryCache; + set + { + if (_useGeometryCache == value) return; + _useGeometryCache = value; + _cache = null; + InvalidateMeasure(); + } + } + + // Null during timed benchmarks and normal use; probes observe actual MeasureOverride calls. + internal static Action? GeometryReuseObserver { get; set; } // Provide a desired size for layout based on current geometry bounds so edge is not collapsed to 0x0. protected override Size MeasureOverride(Size availableSize) @@ -697,12 +768,6 @@ protected override Size MeasureOverride(Size availableSize) return default; } - - var selfLoopSize = IsSelfLooped - ? new Size(SelfLoopIndicatorRadius * 2 + SelfLoopIndicatorOffset.X, - SelfLoopIndicatorRadius * 2 + SelfLoopIndicatorOffset.Y) - : new Size(); - var spanningRect = sourceRect.Union(targetRect); var infiniteSize = new Size(double.PositiveInfinity, double.PositiveInfinity); if (SelfLoopIndicator is { } selfLoopIndicator) selfLoopIndicator.Measure(infiniteSize); @@ -711,82 +776,118 @@ protected override Size MeasureOverride(Size availableSize) //get the route informations var routeInformation = routedEdge.RoutingPoints; - var gEdge = Edge as IGraphXCommonEdge; - UpdateConnectionPoints(gEdge, routeInformation, sourceRect, targetRect); - // If the logic above is working correctly, both the source and target connection points will exist. - if (!SourceConnectionPoint.HasValue || !TargetConnectionPoint.HasValue) - throw new GX_GeneralException("One or both connection points was not found due to an internal error."); - - var p1 = SourceConnectionPoint.Value; - var p2 = TargetConnectionPoint.Value; - UpdatePoints(p1, p2, routedEdge.RoutingPoints); - - // Cache layout info for pointer arrangement after base.ArrangeOverride - _sourcePointerLayout = new EdgePointerLayoutInfo - { - Position = _points[0], - DirectionTarget = _points[1], - HasData = EdgePointerForSource != null, - AllowUnsuppress = true - }; - _targetPointerLayout = new EdgePointerLayoutInfo - { - Position = _points[^1], - DirectionTarget = _points[^2], - HasData = EdgePointerForTarget != null, - AllowUnsuppress = true - }; if (EdgePointerForSource is Control pointerForSource) { if (ShowArrows) EdgePointerForSource.Show(); pointerForSource.Measure(infiniteSize); - var sourceOffset = ComputeEdgePointerOffset(EdgePointerForSource, _points[0], _points[1]); - _points[0] = _points[0].Subtract(sourceOffset); } if (EdgePointerForTarget is Control pointerForTarget) { if (ShowArrows) EdgePointerForTarget.Show(); pointerForTarget.Measure(infiniteSize); - var targetOffset = ComputeEdgePointerOffset(EdgePointerForTarget, _points[^1], _points[^2]); - _points[^1] = _points[^1].Subtract(targetOffset); } - //Measure bounds after edge pointers have been measured. + var canReuseGeometry = _useGeometryCache + && (_cache ??= new EdgeControlCache(this)) + .CheckGeometryReusability(sourceRect, targetRect, routeInformation); + GeometryReuseObserver?.Invoke(canReuseGeometry); - // Calculate padding needed for edge pointers to prevent clipping - var pointerPadding = GetEdgePointerPadding(); - _pathBounds = CollectionsMarshal.AsSpan(_points).GetBounds(pointerPadding); + var selfLoopSize = IsSelfLooped + ? new Size(SelfLoopIndicatorRadius * 2 + SelfLoopIndicatorOffset.X, + SelfLoopIndicatorRadius * 2 + SelfLoopIndicatorOffset.Y) + : new Size(); + var spanningRect = sourceRect.Union(targetRect); - // For self-looped edges the source and target connection points collapse onto the same - // location, so the raw _pathBounds is degenerate (zero size). Replace it with the rectangle - // that will house the self-loop indicator (or the built-in ellipse) anchored relative to the - // source vertex's top-left, matching the WPF positioning in PrepareSelfLoopedEdge. - if (IsSelfLooped) + if (!canReuseGeometry) { - var hasTemplate = SelfLoopIndicator is not null; - var indicatorSize = hasTemplate - ? SelfLoopIndicator!.DesiredSize - : new Size(SelfLoopIndicatorRadius * 2, SelfLoopIndicatorRadius * 2); - // Match PrepareSelfLoopedEdge's anchor offset: subtract DesiredSize for a template, - // or one radius for the built-in ellipse. - var anchorOffsetX = hasTemplate ? SelfLoopIndicator!.DesiredSize.Width : SelfLoopIndicatorRadius; - var anchorOffsetY = hasTemplate ? SelfLoopIndicator!.DesiredSize.Height : SelfLoopIndicatorRadius; - var indicatorTopLeft = new Point( - sourceRect.X + SelfLoopIndicatorOffset.X - anchorOffsetX, - sourceRect.Y + SelfLoopIndicatorOffset.Y - anchorOffsetY); - _pathBounds = new Rect(indicatorTopLeft, indicatorSize); - } + var gEdge = Edge as IGraphXCommonEdge; + UpdateConnectionPoints(gEdge, routeInformation, sourceRect, targetRect); - // Shift points into local space - for (var i = 0; i < _points.Count; i++) - _points[i] = _points[i].Subtract(_pathBounds.TopLeft); - _sourcePointerLayout.Position = _sourcePointerLayout.Position.Subtract(_pathBounds.TopLeft); - _sourcePointerLayout.DirectionTarget = _sourcePointerLayout.DirectionTarget.Subtract(_pathBounds.TopLeft); - _targetPointerLayout.Position = _targetPointerLayout.Position.Subtract(_pathBounds.TopLeft); - _targetPointerLayout.DirectionTarget = _targetPointerLayout.DirectionTarget.Subtract(_pathBounds.TopLeft); + // If the logic above is working correctly, both the source and target connection points will exist. + if (!SourceConnectionPoint.HasValue || !TargetConnectionPoint.HasValue) + throw new GX_GeneralException("One or both connection points was not found due to an internal error."); + var p1 = SourceConnectionPoint.Value; + var p2 = TargetConnectionPoint.Value; + UpdatePoints(p1, p2, routedEdge.RoutingPoints); + + // Cache layout info for pointer arrangement after base.ArrangeOverride + _sourcePointerLayout = new EdgePointerLayoutInfo + { + Position = _points[0], + DirectionTarget = _points[1], + HasData = EdgePointerForSource != null, + AllowUnsuppress = true + }; + _targetPointerLayout = new EdgePointerLayoutInfo + { + Position = _points[^1], + DirectionTarget = _points[^2], + HasData = EdgePointerForTarget != null, + AllowUnsuppress = true + }; + if (EdgePointerForSource is not null) + { + var sourceOffset = ComputeEdgePointerOffset(EdgePointerForSource, _points[0], _points[1]); + _points[0] = _points[0].Subtract(sourceOffset); + } + + if (EdgePointerForTarget is not null) + { + var targetOffset = ComputeEdgePointerOffset(EdgePointerForTarget, _points[^1], _points[^2]); + _points[^1] = _points[^1].Subtract(targetOffset); + } + + //Measure bounds after edge pointers have been measured. + + // Calculate padding needed for edge pointers to prevent clipping + var pointerPadding = GetEdgePointerPadding(); + _pathBounds = CollectionsMarshal.AsSpan(_points).GetBounds(pointerPadding); + + // For self-looped edges the source and target connection points collapse onto the same + // location, so the raw _pathBounds is degenerate (zero size). Replace it with the rectangle + // that will house the self-loop indicator (or the built-in ellipse) anchored relative to the + // source vertex's top-left, matching the WPF positioning in PrepareSelfLoopedEdge. + if (IsSelfLooped) + { + var hasTemplate = SelfLoopIndicator is not null; + var indicatorSize = hasTemplate + ? SelfLoopIndicator!.DesiredSize + : new Size(SelfLoopIndicatorRadius * 2, SelfLoopIndicatorRadius * 2); + // Match PrepareSelfLoopedEdge's anchor offset: subtract DesiredSize for a template, + // or one radius for the built-in ellipse. + var anchorOffsetX = hasTemplate ? SelfLoopIndicator!.DesiredSize.Width : SelfLoopIndicatorRadius; + var anchorOffsetY = hasTemplate ? SelfLoopIndicator!.DesiredSize.Height : SelfLoopIndicatorRadius; + var indicatorTopLeft = new Point( + sourceRect.X + SelfLoopIndicatorOffset.X - anchorOffsetX, + sourceRect.Y + SelfLoopIndicatorOffset.Y - anchorOffsetY); + _pathBounds = new Rect(indicatorTopLeft, indicatorSize); + } + + // Shift points into local space + for (var i = 0; i < _points.Count; i++) + _points[i] = _points[i].Subtract(_pathBounds.TopLeft); + _sourcePointerLayout.Position = _sourcePointerLayout.Position.Subtract(_pathBounds.TopLeft); + _sourcePointerLayout.DirectionTarget = _sourcePointerLayout.DirectionTarget.Subtract(_pathBounds.TopLeft); + _targetPointerLayout.Position = _targetPointerLayout.Position.Subtract(_pathBounds.TopLeft); + _targetPointerLayout.DirectionTarget = _targetPointerLayout.DirectionTarget.Subtract(_pathBounds.TopLeft); + + // Precompute direction/angle once here (translation-invariant) instead of recomputing + // them on every ArrangeEdgePointer call, which happens on every arrange pass even + // when the geometry hasn't changed since the last measure. + ComputePointerDirectionAndAngle(ref _sourcePointerLayout, EdgePointerForSource); + ComputePointerDirectionAndAngle(ref _targetPointerLayout, EdgePointerForTarget); + + if (_useGeometryCache) + _cache!.UpdateCacheInfo(sourceRect, targetRect, routeInformation); + else + _isGeometryDirty = true; + } + + // Labels can change content independently of the edge's own geometry (e.g. text update), + // so they're always re-measured and folded into the final size even on the reuse path. foreach (var label in EdgeLabelControls) { if (IsSelfLooped && !label.DisplayForSelfLoopedEdges) continue; @@ -795,10 +896,6 @@ protected override Size MeasureOverride(Size availableSize) selfLoopSize = Union(selfLoopSize, ctrl.DesiredSize); } - var pointSignature = GetSignature(_points); - var changed = _oldSignature != pointSignature; - _isGeometryDirty = LineGeometry is null || changed; - _oldSignature = pointSignature; return IsSelfLooped ? _pathBounds.Size : Union(spanningRect.Size, selfLoopSize, _pathBounds.Size); @@ -830,10 +927,10 @@ protected override Size ArrangeOverride(Size finalSize) // Position edge pointers if (_sourcePointerLayout.HasData && EdgePointerForSource != null) SourcePointerPosition = - ArrangeEdgePointer(_sourcePointerLayout, EdgePointerForSource, HideEdgePointerOnVertexOverlap); + ArrangeEdgePointer(ref _sourcePointerLayout, EdgePointerForSource, HideEdgePointerOnVertexOverlap); if (_targetPointerLayout.HasData && EdgePointerForTarget != null) TargetPointerPosition = - ArrangeEdgePointer(_targetPointerLayout, EdgePointerForTarget, HideEdgePointerOnVertexOverlap); + ArrangeEdgePointer(ref _targetPointerLayout, EdgePointerForTarget, HideEdgePointerOnVertexOverlap); // Position labels at edge midpoint var midPoint = GetMidpoint(out var angle, out var flipAxis, out var vector); @@ -919,7 +1016,7 @@ protected virtual Point GetParallelOffset(Point sourceCenter, Point targetCenter private readonly List _points = []; - private Rect _pathBounds; + private Rect _pathBounds; private bool _isGeometryDirty = true; internal bool CanRenderInBatchedLayer => @@ -994,10 +1091,12 @@ protected virtual void UpdateSelfLoopedEdgeData() //return if we don't need to show edge loops if (!ShowSelfLoopIndicator) return; - //pregenerate built-in indicator geometry if template PART is absent - if (!HasSelfLoopedEdgeTemplate) - LineGeometry = new EllipseGeometry(); - else SelfLoopIndicator?.IsVisible = true; + // Note: no geometry needs to be pregenerated here even when the built-in indicator + // (no template) is used - PrepareEdgeLayout() calls UpdateSelfLoopedEdgeData() and then + // immediately overwrites LineGeometry with PrepareSelfLoopedEdge()'s actual ellipse, so + // allocating one here would be immediately discarded. + if (HasSelfLoopedEdgeTemplate) + SelfLoopIndicator?.IsVisible = true; } else { @@ -1092,18 +1191,6 @@ private void UpdatePoints(Point p1, Point p2, Measure.Point[]? routeInformation) } } - private static int GetSignature(List points) - { - var hash = new HashCode(); - foreach (var p in points) - { - hash.Add(p.X); - hash.Add(p.Y); - } - - return hash.ToHashCode(); - } - /// /// Creates a for the edge path as an optimized alternative to /// CreateFigure. Using is more performant than @@ -1250,13 +1337,13 @@ private bool NeedParallelCalc(bool hasRouteInfo) private IVertexConnectionPoint GetTargetCpOrThrow(int id) { - return Target?.GetConnectionPointById(id, true) ?? throw new GX_ObjectNotFoundException(string.Format( + return Target?.GetConnectionPointById(id) ?? throw new GX_ObjectNotFoundException(string.Format( "Can't find target vertex VCP by edge target connection point Id({1}) : {0}", Target, id)); } private IVertexConnectionPoint GetSourceCpOrThrow(int id) { - return Source!.GetConnectionPointById(id, true) ?? throw new GX_ObjectNotFoundException(string.Format( + return Source!.GetConnectionPointById(id) ?? throw new GX_ObjectNotFoundException(string.Format( "Can't find source vertex VCP by edge source connection point Id({1}) : {0}", Source, id)); } @@ -1286,4 +1373,205 @@ public IList GetLabelControls() { return [.. EdgeLabelControls]; } + + #region Caching + + private class EdgeControlCache(EdgeControlBase edge) + { + // Cached inputs from the last full geometry computation, used by the change-detection guard + // in MeasureOverride to skip UpdateConnectionPoints/UpdatePoints/signature recomputation when + // nothing relevant changed (Canvas panels re-measure every child on every parent measure pass, + // regardless of whether that specific child's own layout inputs changed). + private bool _hasMeasuredGeometryOnce; + private Rect _lastSourceRect; + private Rect _lastTargetRect; + private Measure.Point[]? _lastRouteInformation; + private IEdgePointer? _lastEdgePointerForSource; + private IEdgePointer? _lastEdgePointerForTarget; + private Size _lastSourcePointerDesiredSize; + private Size _lastTargetPointerDesiredSize; + private bool _lastShowArrows; + private bool _lastShowSelfLoopIndicator; + private bool _lastIsSelfLooped; + private double _lastSelfLoopIndicatorRadius; + private Point _lastSelfLoopIndicatorOffset; + private Size _lastSelfLoopIndicatorDesiredSize; + private bool _lastIsParallel; + private int _lastParallelEdgeOffset; + private Point? _lastOverrideEndpoint; + private int? _lastSourceConnectionPointId; + private int? _lastTargetConnectionPointId; + private bool _lastReversePath; + private bool _lastIsEdgeRoutingEnabled; + private bool _lastEnableParallelEdges; + private double _lastEdgeCurvingTolerance; + private VertexShape _lastSourceVertexShape; + private VertexShape _lastTargetVertexShape; + private ConnectionPointState _lastSourceConnectionPoint; + private ConnectionPointState _lastTargetConnectionPoint; + private ConnectionPointState _currentSourceConnectionPoint; + private ConnectionPointState _currentTargetConnectionPoint; + + private readonly record struct ConnectionPointState(IVertexConnectionPoint? Control, Rect Bounds, + VertexShape Shape); + + private static ConnectionPointState GetConnectionPointState(VertexControl? vertex, int? id) + { + if (vertex is null || id is null) return default; + // Connection-point geometry can change without the vertex bounds changing. + // Refresh only edges with a selected connection point before checking reuse. + var point = vertex.GetConnectionPointById(id.Value, true); + return point is null ? default : new ConnectionPointState(point, point.RectangularSize, point.Shape); + } + + internal bool CheckGeometryReusability(Rect sourceRect, Rect targetRect, Measure.Point[]? routeInformation) + { + _currentSourceConnectionPoint = GetConnectionPointState(edge.Source, SourceConnectionPointId(edge)); + _currentTargetConnectionPoint = GetConnectionPointState(edge.Target, TargetConnectionPointId(edge)); + return _hasMeasuredGeometryOnce + && sourceRect == _lastSourceRect + && targetRect == _lastTargetRect + && RoutePointsUnchanged(routeInformation, _lastRouteInformation) + && edge.IsSelfLooped == _lastIsSelfLooped + && ReferenceEquals(edge.EdgePointerForSource, _lastEdgePointerForSource) + && PointerDesiredSize(edge.EdgePointerForSource) == _lastSourcePointerDesiredSize + && ReferenceEquals(edge.EdgePointerForTarget, _lastEdgePointerForTarget) + && PointerDesiredSize(edge.EdgePointerForTarget) == _lastTargetPointerDesiredSize + && edge.ShowArrows == _lastShowArrows + && edge.ShowSelfLoopIndicator == _lastShowSelfLoopIndicator + && edge.SelfLoopIndicatorRadius.Equals(_lastSelfLoopIndicatorRadius) + && edge.SelfLoopIndicatorOffset == _lastSelfLoopIndicatorOffset + && SelfLoopIndicatorDesiredSize(edge.SelfLoopIndicator) == _lastSelfLoopIndicatorDesiredSize + && edge.IsParallel == _lastIsParallel + && edge.ParallelEdgeOffset == _lastParallelEdgeOffset + // Belt-and-suspenders: OverrideEndpoint is already folded into targetRect by + // TryGetTargetPoints, but comparing it directly documents the dependency explicitly. + && edge.OverrideEndpoint == _lastOverrideEndpoint + && SourceConnectionPointId(edge) == _lastSourceConnectionPointId + && TargetConnectionPointId(edge) == _lastTargetConnectionPointId + && edge.Source?.VertexShape == _lastSourceVertexShape + && edge.Target?.VertexShape == _lastTargetVertexShape + && _currentSourceConnectionPoint == _lastSourceConnectionPoint + && _currentTargetConnectionPoint == _lastTargetConnectionPoint + && ReversePath(edge) == _lastReversePath + && (edge.RootArea?.IsEdgeRoutingEnabled ?? false) == _lastIsEdgeRoutingEnabled + && (edge.RootArea?.EnableParallelEdges ?? false) == _lastEnableParallelEdges + && (edge.RootArea?.EdgeCurvingTolerance ?? 0) == _lastEdgeCurvingTolerance; + } + + private static int? SourceConnectionPointId(EdgeControlBase edge) + { + return (edge.Edge as IGraphXCommonEdge)?.SourceConnectionPointId; + } + + private static int? TargetConnectionPointId(EdgeControlBase edge) + { + return (edge.Edge as IGraphXCommonEdge)?.TargetConnectionPointId; + } + + private static bool ReversePath(EdgeControlBase edge) + { + return (edge.Edge as IGraphXCommonEdge)?.ReversePath ?? false; + } + + private static Size PointerDesiredSize(IEdgePointer? edgeEdgePointerForSource) + { + if (edgeEdgePointerForSource is Layoutable ctrl) + { + return ctrl.DesiredSize; + } + + return default; + } + + private static Size SelfLoopIndicatorDesiredSize(Control? indicator) + { + return indicator?.DesiredSize ?? default; + } + + + /// + /// Value-based comparison for routing point arrays. + /// + private static bool RoutePointsUnchanged(Measure.Point[]? current, Measure.Point[]? cached) + { + if (current is null || cached is null) + return current is null && cached is null; + if (current.Length != cached.Length) + return false; + for (var i = 0; i < current.Length; i++) + if (current[i] != cached[i]) + return false; + return true; + } + + internal void UpdateCacheInfo(Rect sourceRect, Rect targetRect, Measure.Point[]? routeInformation) + { + var changed = PointsChangedSincePreviousGeometry(edge._points); + var selfLoopVisualChanged = edge.IsSelfLooped != _lastIsSelfLooped + || (edge.IsSelfLooped + && (edge.ShowSelfLoopIndicator != _lastShowSelfLoopIndicator + || edge.SelfLoopIndicatorRadius != _lastSelfLoopIndicatorRadius + || edge.SelfLoopIndicatorOffset != _lastSelfLoopIndicatorOffset + || SelfLoopIndicatorDesiredSize(edge.SelfLoopIndicator) != + _lastSelfLoopIndicatorDesiredSize)); + // ReversePath only flips the point traversal order when the final StreamGeometry is built + // (CreateEdgeGeometry); it never changes the _points values themselves, so the point-signature + // comparison above can't detect it. Track it explicitly so toggling it still rebuilds LineGeometry. + var reversePathChanged = ReversePath(edge) != _lastReversePath; + edge._isGeometryDirty = edge.LineGeometry is null || changed || selfLoopVisualChanged || reversePathChanged; + if (changed) + { + _lastPointsForSignature.Clear(); + _lastPointsForSignature.AddRange(edge._points); + } + + _lastSourceRect = sourceRect; + _lastTargetRect = targetRect; + // Clone rather than store the live reference + _lastRouteInformation = routeInformation?.ToArray(); + _lastIsSelfLooped = edge.IsSelfLooped; + _lastEdgePointerForSource = edge.EdgePointerForSource; + _lastSourcePointerDesiredSize = PointerDesiredSize(edge.EdgePointerForSource); + _lastEdgePointerForTarget = edge.EdgePointerForTarget; + _lastTargetPointerDesiredSize = PointerDesiredSize(edge.EdgePointerForTarget); + _lastShowArrows = edge.ShowArrows; + _lastShowSelfLoopIndicator = edge.ShowSelfLoopIndicator; + _lastSelfLoopIndicatorRadius = edge.SelfLoopIndicatorRadius; + _lastSelfLoopIndicatorOffset = edge.SelfLoopIndicatorOffset; + _lastSelfLoopIndicatorDesiredSize = SelfLoopIndicatorDesiredSize(edge.SelfLoopIndicator); + _lastIsParallel = edge.IsParallel; + _lastParallelEdgeOffset = edge.ParallelEdgeOffset; + _lastOverrideEndpoint = edge.OverrideEndpoint; + _lastSourceConnectionPointId = SourceConnectionPointId(edge); + _lastTargetConnectionPointId = TargetConnectionPointId(edge); + _lastSourceVertexShape = edge.Source?.VertexShape ?? default; + _lastTargetVertexShape = edge.Target?.VertexShape ?? default; + _lastSourceConnectionPoint = _currentSourceConnectionPoint; + _lastTargetConnectionPoint = _currentTargetConnectionPoint; + _lastReversePath = ReversePath(edge); + _lastIsEdgeRoutingEnabled = edge.RootArea?.IsEdgeRoutingEnabled ?? false; + _lastEnableParallelEdges = edge.RootArea?.EnableParallelEdges ?? false; + _lastEdgeCurvingTolerance = edge.RootArea?.EdgeCurvingTolerance ?? 0; + _hasMeasuredGeometryOnce = true; + } + + // Cached point list from the last geometry computation, compared directly (element-wise) + // instead of via full-point HashCode hashing to detect whether the path actually moved and + // LineGeometry needs to be rebuilt. Direct comparison is cheaper than hashing (it short-circuits + // on the first mismatch) and avoids HashCode.Combine overhead on every recompute. + private readonly List _lastPointsForSignature = []; + + private bool PointsChangedSincePreviousGeometry(List points) + { + if (points.Count != _lastPointsForSignature.Count) + return true; + for (var i = 0; i < points.Count; i++) + if (points[i] != _lastPointsForSignature[i]) + return true; + return false; + } + } + + #endregion } \ No newline at end of file diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/GraphArea.cs b/Westermo.GraphX.Controls.Avalonia/Controls/GraphArea.cs index 0bdae50..e0419b5 100644 --- a/Westermo.GraphX.Controls.Avalonia/Controls/GraphArea.cs +++ b/Westermo.GraphX.Controls.Avalonia/Controls/GraphArea.cs @@ -281,6 +281,15 @@ public StateStorage StateStorage private readonly Dictionary _edgesList = []; private readonly Dictionary _vertexList = []; + // Tracked label controls added via GenerateVertexLabel()/GenerateEdgeLabel(), so that + // GenerateVertexLabels()/GenerateEdgeLabels() don't need to scan the whole Children + // collection (vertices + edges + labels) just to find previously generated labels to remove. + private readonly HashSet _generatedVertexLabelControls = []; + private readonly HashSet _generatedEdgeLabelControls = []; + + internal int GeneratedVertexLabelCount => _generatedVertexLabelControls.Count; + internal int GeneratedEdgeLabelCount => _generatedEdgeLabelControls.Count; + /// /// Gets edge controls read only collection. To modify collection use AddEdge() RemoveEdge() methods. /// @@ -348,12 +357,15 @@ public GraphArea() { Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); - return VertexList.Values.FirstOrDefault(a => + foreach (var a in _vertexList.Values) { var pos = a.GetPosition(); var rect = new Rect(pos.X, pos.Y, a.Bounds.Width, a.Bounds.Height); - return rect.Contains(position); - }); + if (rect.Contains(position)) + return a; + } + + return null; } @@ -459,7 +471,9 @@ private void RemoveVertexInternal(VertexControl ctrl, bool removeVertexFromDataG if (ctrl.VertexLabelControl != null) { - Children.Remove((Control)ctrl.VertexLabelControl); + var label = (Control)ctrl.VertexLabelControl; + Children.Remove(label); + _generatedVertexLabelControls.Remove(label); ctrl.DetachLabel(); } @@ -493,6 +507,11 @@ private void RemoveEdgeInternal(TEdge? edgeData, bool removeFromList, bool remov private void RemoveEdgeInternal(EdgeControlBase ctrl, bool removeEdgeFromDataGraph = false) { + foreach (var label in ctrl.EdgeLabelControls) + { + if (label is Control control) + _generatedEdgeLabelControls.Remove(control); + } ctrl.DetachLabels(); UnregisterBatchedEdge(ctrl); @@ -700,26 +719,13 @@ protected virtual int GetNextUniqueId(bool isVertex) protected virtual void GenerateVertexLabels() { if (VertexLabelFactory == null) return; - // OPTIMIZATION: Collect items to remove first to avoid modifying collection during iteration - var toRemove = ListPool.Rent(); - try + foreach (var item in _generatedVertexLabelControls) { - foreach (var child in Children) - { - if (child is IVertexLabelControl) - toRemove.Add(child); - } - - foreach (var item in toRemove) - { - Children.Remove(item); - } - } - finally - { - ListPool.Return(toRemove); + Children.Remove(item); } + _generatedVertexLabelControls.Clear(); + foreach (var kvp in VertexList) { GenerateVertexLabel(kvp.Value); @@ -743,34 +749,22 @@ protected virtual void GenerateVertexLabel(VertexControl vertexControl) if (_svVertexLabelShow == false || !IsVisible) l.IsVisible = false; AddCustomChildControl(l); + _generatedVertexLabelControls.Add(l); l.Measure(new Size(double.MaxValue, double.MaxValue)); ((IVertexLabelControl)l).UpdatePosition(); } } - protected virtual void GenerateEdgeLabels() + protected virtual void GenerateEdgeLabels() { if (EdgeLabelFactory == null) return; - // OPTIMIZATION: Collect items to remove first to avoid modifying collection during iteration - var toRemove = ListPool.Rent(); - try + foreach (var item in _generatedEdgeLabelControls) { - foreach (var child in Children) - { - if (child is IEdgeLabelControl) - toRemove.Add(child); - } - - foreach (var item in toRemove) - { - Children.Remove(item); - } - } - finally - { - ListPool.Return(toRemove); + Children.Remove(item); } + _generatedEdgeLabelControls.Clear(); + foreach (var kvp in EdgesList) { GenerateEdgeLabel(kvp.Value); @@ -781,12 +775,12 @@ protected virtual void GenerateEdgeLabel(EdgeControl edgeControl) { var labels = EdgeLabelFactory!.CreateLabel(edgeControl); var uiElements = labels as Control[] ?? [.. labels]; - // OPTIMIZATION: Check interface implementation without LINQ foreach (var a in uiElements) { if (a is not IEdgeLabelControl) throw new GX_InvalidDataException("Generated edge label should implement IEdgeLabelControl interface"); AddCustomChildControl(a); + _generatedEdgeLabelControls.Add(a); } } @@ -801,12 +795,11 @@ public Dictionary GetVertexSizes() { //measure if needed and get all vertex sizes Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); - var vertexSizes = new Dictionary(_vertexList.Count(a => - ((IGraphXVertex)a.Value.Vertex!).SkipProcessing != ProcessingOptionEnum.Exclude)); - //go through the vertex presenters and get the actual layoutpositions - foreach (var vc in VertexList.Where(vc => - ((IGraphXVertex)vc.Value.Vertex!).SkipProcessing != ProcessingOptionEnum.Exclude)) + //single pass: capacity is an upper bound (excluded vertices are simply not added) + var vertexSizes = new Dictionary(_vertexList.Count); + foreach (var vc in _vertexList) { + if (((IGraphXVertex)vc.Value.Vertex!).SkipProcessing == ProcessingOptionEnum.Exclude) continue; vertexSizes[vc.Key] = new Size(vc.Value.DesiredSize.Width, vc.Value.DesiredSize.Height); } @@ -817,19 +810,17 @@ public Dictionary GetVertexSizes() public Dictionary GetVertexSizesAndPositions( out IDictionary vertexPositions) { - //measure if needed and get all vertex sizes - var count = _vertexList.Count(a => - ((IGraphXVertex)a.Value.Vertex!).SkipProcessing != ProcessingOptionEnum.Exclude); - var vertexSizes = new Dictionary(count); - vertexPositions = new Dictionary(count); - //go through the vertex presenters and get the actual layoutpositions - foreach (var vc in VertexList.Where(vc => - ((IGraphXVertex)vc.Value.Vertex!).SkipProcessing != ProcessingOptionEnum.Exclude)) + //single pass over vertices (capacity is an upper bound; excluded vertices are simply not added) + var vertexSizes = new Dictionary(_vertexList.Count); + var positions = new Dictionary(_vertexList.Count); + foreach (var vc in _vertexList) { + if (((IGraphXVertex)vc.Value.Vertex!).SkipProcessing == ProcessingOptionEnum.Exclude) continue; vertexSizes[vc.Key] = new Size(vc.Value.DesiredSize.Width, vc.Value.DesiredSize.Height); - vertexPositions[vc.Key] = vc.Value.GetPosition(); + positions[vc.Key] = vc.Value.GetPosition(); } + vertexPositions = positions; return vertexSizes; } @@ -838,9 +829,14 @@ public Dictionary GetVertexSizesAndPositions( /// public Dictionary GetVertexPositions() { - return VertexList - .Where(a => ((IGraphXVertex)a.Value.Vertex!).SkipProcessing != ProcessingOptionEnum.Exclude) - .ToDictionary(vertex => vertex.Key, vertex => vertex.Value.GetPosition()); + var positions = new Dictionary(_vertexList.Count); + foreach (var vc in _vertexList) + { + if (((IGraphXVertex)vc.Value.Vertex!).SkipProcessing == ProcessingOptionEnum.Exclude) continue; + positions[vc.Key] = vc.Value.GetPosition(); + } + + return positions; } #endregion @@ -982,31 +978,35 @@ void PreUpdate() //add missing visuals and remove old ones if graph is filtered to reflect filtering if (EnableVisualsRenewOnFiltering && (LogicCore.IsFiltered || LogicCore.IsFilterRemoved)) { + // Build membership sets once so removal/addition checks below are O(1) + // instead of repeatedly scanning LogicCore.Graph.Edges/Vertices (which may not be index-backed). + var dataEdges = new HashSet(LogicCore.Graph.Edges); + var dataVertices = new HashSet(LogicCore.Graph.Vertices); + //remove edge if it has been removed from data graph - _edgesList.Keys.ToList() - .ForEach(a => - { - if (!LogicCore.Graph.Edges.Contains(a)) RemoveEdge(a); - }); + foreach (var a in _edgesList.Keys.ToList()) + { + if (!dataEdges.Contains(a)) RemoveEdge(a); + } + //remove vertex if it has been removed from data graph - _vertexList.Keys.ToList() - .ForEach(a => - { - if (!LogicCore.Graph.Vertices.Contains(a)) RemoveVertex(a); - }); + foreach (var a in _vertexList.Keys.ToList()) + { + if (!dataVertices.Contains(a)) RemoveVertex(a); + } - LogicCore.Graph.Vertices.ForEach(v => + foreach (var v in dataVertices) { if (!_vertexList.ContainsKey(v)) AddVertex(v, ControlFactory.CreateVertexControl(v)); - }); + } - LogicCore.Graph.Edges.ForEach(e => + foreach (var e in dataEdges) { - if (_edgesList.ContainsKey(e)) return; + if (_edgesList.ContainsKey(e)) continue; var source = _vertexList[e.Source]; var target = _vertexList[e.Target]; AddEdge(e, ControlFactory.CreateEdgeControl(source, target, e)); - }); + } } Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); @@ -1587,7 +1587,6 @@ public override void GenerateEdgesForVertex(VertexControl vc, EdgesType edgeType _svShowEdgeArrows ?? true, isVisibleByDefault); InsertEdge(item, ctrl); - ctrl.InvalidateMeasure(); if (item.Source == item.Target) gotSelfLoop = true; } @@ -1599,10 +1598,12 @@ public override void GenerateEdgesForVertex(VertexControl vc, EdgesType edgeType _svShowEdgeArrows ?? true, isVisibleByDefault); InsertEdge(item, ctrl); - ctrl.InvalidateMeasure(); if (item.Source == item.Target) gotSelfLoop = true; } + // Invalidate measure once for the whole GraphArea instead of per-created edge control. + InvalidateMeasure(); + if (LogicCore.EnableParallelEdges) UpdateParallelEdgesData(); } @@ -1710,7 +1711,13 @@ public override List GetRelatedVertexControls(IGraphControl ctrl, break; } - list.AddRange(VertexList.Where(a => vList.Contains(a.Key)).Select(a => a.Value)); + // Iterate the (typically small) neighbour list directly and look each up, + // instead of scanning the whole VertexList for membership in vList. + foreach (var neighbour in vList) + { + if (_vertexList.TryGetValue(neighbour, out var neighbourCtrl)) + list.Add(neighbourCtrl); + } } if (ctrl is not EdgeControl ec) return list; @@ -1762,7 +1769,19 @@ public override List GetRelatedEdgeControls(IGraphControl ctrl, break; } - list.AddRange(EdgesList.Where(a => eList.Contains(a.Key)).Select(a => a.Value)); + // Iterate the (typically small) edge list directly and look each up, + // instead of scanning the whole EdgesList for membership in eList. + HashSet? seenSelfLoops = null; + foreach (var e in eList) + { + if (!_edgesList.TryGetValue(e, out var edgeCtrl)) continue; + // GetAllEdges includes a self-loop in both its incoming and outgoing lists. + if (edgesType == EdgesType.All && EqualityComparer.Default.Equals(e.Source, e.Target) + && !(seenSelfLoops ??= []).Add(e)) + continue; + list.Add(edgeCtrl); + } + break; } } @@ -1809,25 +1828,25 @@ public override List GetRelatedControls(IGraphControl ctrl, if (resultType is GraphControlType.Edge or GraphControlType.VertexAndEdge) { if (edgesInList != null) - list.AddRange(from item in edgesInList - where _edgesList.ContainsKey(item) - select _edgesList[item]); + foreach (var item in edgesInList) + if (_edgesList.TryGetValue(item, out var inEdgeCtrl)) + list.Add(inEdgeCtrl); if (edgesOutList != null) - list.AddRange(from item in edgesOutList - where _edgesList.ContainsKey(item) - select _edgesList[item]); + foreach (var item in edgesOutList) + if (_edgesList.TryGetValue(item, out var outEdgeCtrl)) + list.Add(outEdgeCtrl); } if (resultType != GraphControlType.Vertex && resultType != GraphControlType.VertexAndEdge) return list; if (edgesInList != null) - list.AddRange(from item in edgesInList - where _vertexList.ContainsKey(item.Source) - select _vertexList[item.Source]); + foreach (var item in edgesInList) + if (_vertexList.TryGetValue(item.Source, out var sourceCtrl)) + list.Add(sourceCtrl); if (edgesOutList != null) - list.AddRange(from item in edgesOutList - where _vertexList.ContainsKey(item.Target) - select _vertexList[item.Target]); + foreach (var item in edgesOutList) + if (_vertexList.TryGetValue(item.Target, out var targetCtrl)) + list.Add(targetCtrl); return list; } @@ -1921,14 +1940,19 @@ public virtual void RebuildFromSerializationData(IEnumerable a.Data is TEdge); + // Build an ID -> vertex lookup once, instead of a per-edge O(vertices) FirstOrDefault scan. + var vertexById = new Dictionary(_vertexList.Count); + foreach (var v in _vertexList.Keys) + vertexById[v.ID] = v; + foreach (var item in edgeList) { var edgeData = (TEdge)item.Data; if (edgeData == null) continue; var sourceId = edgeData.Source.ID; var targetId = edgeData.Target.ID; - var dataSource = _vertexList.Keys.FirstOrDefault(a => a.ID == sourceId); - var dataTarget = _vertexList.Keys.FirstOrDefault(a => a.ID == targetId); + vertexById.TryGetValue(sourceId, out var dataSource); + vertexById.TryGetValue(targetId, out var dataTarget); edgeData.Source = dataSource!; edgeData.Target = dataTarget!; @@ -2003,6 +2027,8 @@ public virtual void ClearLayout(bool removeCustomObjects = true, bool clearState if (removeCustomObjects) { Children.Clear(); + _generatedVertexLabelControls.Clear(); + _generatedEdgeLabelControls.Clear(); RecreateBatchedEdgeLayerAfterChildrenClear(); } @@ -2058,8 +2084,9 @@ public virtual void MoveToBack(T control, bool moveLabels = true) protected virtual void MoveTo(bool toFront, T control, bool moveLabels = true) where T : class { - var result = (Control?)(object?)Children.OfType().FirstOrDefault(a => a == control); - if (result == null) return; + // Avoid enumerating Children via OfType().FirstOrDefault(closure) - + // the control reference itself is what we need to check/move. + if (control is not Control result) return; if (!Children.Contains(result)) return; Children.Remove(result); if (toFront) Children.Add(result); @@ -2101,11 +2128,7 @@ public void SyncVertexSelectionState() if (SelectedVertices is null) { // No selection tracking - clear all selection states - foreach (var kvp in _vertexList) - { - kvp.Value.IsSelected = false; - } - + ClearSelectionState(); return; } diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/VertexControl.cs b/Westermo.GraphX.Controls.Avalonia/Controls/VertexControl.cs index c442004..7fff1e5 100644 --- a/Westermo.GraphX.Controls.Avalonia/Controls/VertexControl.cs +++ b/Westermo.GraphX.Controls.Avalonia/Controls/VertexControl.cs @@ -209,6 +209,7 @@ public T GetDataVertex() where T : IGraphXVertex /// and processing on the second (which has both X and Y updated). /// private bool _positionUpdatePending; + private Action? _positionUpdateTick; public void XYChanged(AvaloniaPropertyChangedEventArgs args) { @@ -224,16 +225,20 @@ public void XYChanged(AvaloniaPropertyChangedEventArgs args) // First property change — defer to coalesce with a potential second change. _positionUpdatePending = true; - Dispatcher.UIThread.Post(() => - { - // Only fires if no second property change occurred synchronously - // (e.g., when only X or only Y was changed individually). - if (!_positionUpdatePending) return; - _positionUpdatePending = false; - if (ShowLabel) - VertexLabelControl?.UpdatePosition(); - OnPositionChanged(new Point(), GetPosition()); - }, DispatcherPriority.Render); + // Cache the tick delegate as a field (assigned once, lazily) instead of allocating a + // fresh closure on every un-coalesced X/Y change - this fires per vertex per drag frame. + Dispatcher.UIThread.Post(_positionUpdateTick ??= OnPositionUpdateTick, DispatcherPriority.Render); + } + + private void OnPositionUpdateTick() + { + // Only fires if no second property change occurred synchronously + // (e.g., when only X or only Y was changed individually). + if (!_positionUpdatePending) return; + _positionUpdatePending = false; + if (ShowLabel) + VertexLabelControl?.UpdatePosition(); + OnPositionChanged(new Point(), GetPosition()); } private Point? m_dragOrigin; diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/VertexControlBase.cs b/Westermo.GraphX.Controls.Avalonia/Controls/VertexControlBase.cs index 9f9dd62..e871886 100644 --- a/Westermo.GraphX.Controls.Avalonia/Controls/VertexControlBase.cs +++ b/Westermo.GraphX.Controls.Avalonia/Controls/VertexControlBase.cs @@ -261,6 +261,9 @@ public Point GetCenterPosition(bool final = false) public IVertexConnectionPoint? GetConnectionPointAt(Point position) { + // No extra "is layout dirty" guard needed here: Avalonia's own Layoutable.Measure + // already short-circuits when IsMeasureValid is true and the requested constraint + // matches the last one used, so this call is cheap on the (common) already-measured path. Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); return VertexConnectionPointsList.Values.FirstOrDefault(a => diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ViewFinderDisplay.cs b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ViewFinderDisplay.cs deleted file mode 100644 index 78e81bc..0000000 --- a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ViewFinderDisplay.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Media; -using Westermo.GraphX.Controls.Controls.ZoomControl.Helpers; - -namespace Westermo.GraphX.Controls.Controls.ZoomControl; - -public class ViewFinderDisplay : Control -{ - public ViewFinderDisplay() - { - HookOnInitialize(); - } - - protected virtual void HookOnInitialize() - { - } - - public static readonly StyledProperty BackgroundProperty = - AvaloniaProperty.Register(nameof(Background), - new SolidColorBrush(Color.FromArgb(0xC0, 0xFF, 0xFF, 0xFF))); - - public IBrush? Background - { - get => GetValue(BackgroundProperty); - set => SetValue(BackgroundProperty, value); - } - - public static readonly StyledProperty ContentBoundsProperty = - AvaloniaProperty.Register(nameof(ContentBounds), default); - - internal Rect ContentBounds - { - get => GetValue(ContentBoundsProperty); - set => SetValue(ContentBoundsProperty, value); - } - - public static readonly StyledProperty ShadowBrushProperty = - AvaloniaProperty.Register(nameof(ShadowBrush), - new SolidColorBrush(Color.FromArgb(0x80, 0xFF, 0xFF, 0xFF))); - - public IBrush? ShadowBrush - { - get => GetValue(ShadowBrushProperty); - set => SetValue(ShadowBrushProperty, value); - } - - public static readonly StyledProperty ViewportBrushProperty = - AvaloniaProperty.Register(nameof(ViewportBrush), Brushes.Transparent); - - public IBrush? ViewportBrush - { - get => GetValue(ViewportBrushProperty); - set => SetValue(ViewportBrushProperty, value); - } - - public static readonly StyledProperty ViewportPenProperty = - AvaloniaProperty.Register(nameof(ViewportPen), - new Pen(new SolidColorBrush(Color.FromArgb(0x80, 0x00, 0x00, 0x00)), 1d)); - - public Pen? ViewportPen - { - get => GetValue(ViewportPenProperty); - set => SetValue(ViewportPenProperty, value); - } - - public static readonly StyledProperty ViewportRectProperty = - AvaloniaProperty.Register(nameof(ViewportRect), default); - - public Rect ViewportRect - { - get => GetValue(ViewportRectProperty); - set => SetValue(ViewportRectProperty, value); - } - - public static readonly StyledProperty VisualBrushProperty = - AvaloniaProperty.Register(nameof(VisualBrush)); - - internal VisualBrush? VisualBrush - { - get => GetValue(VisualBrushProperty); - set => SetValue(VisualBrushProperty, value); - } - - internal Size AvailableSize { get; private set; } = default; - internal double Scale { get; set; } = 1d; - - protected override Size ArrangeOverride(Size finalSize) => DesiredSize; - - protected override Size MeasureOverride(Size availableSize) - { - AvailableSize = availableSize; - var width = DoubleHelper.IsNaN(ContentBounds.Width) ? 0 : Math.Max(0, ContentBounds.Width); - var height = DoubleHelper.IsNaN(ContentBounds.Height) ? 0 : Math.Max(0, ContentBounds.Height); - var displayPanelSize = new Size(width, height); - if (displayPanelSize.Width > availableSize.Width || displayPanelSize.Height > availableSize.Height) - { - var aspectX = availableSize.Width / displayPanelSize.Width; - var aspectY = availableSize.Height / displayPanelSize.Height; - var scale = aspectX < aspectY ? aspectX : aspectY; - displayPanelSize = new Size(Math.Max(0, displayPanelSize.Width * scale), - Math.Max(0, displayPanelSize.Height * scale)); - } - - return displayPanelSize; - } - - public override void Render(DrawingContext dc) - { - base.Render(dc); - if (Background != null) dc.DrawRectangle(Background, null, ContentBounds); - if (VisualBrush != null) dc.DrawRectangle(VisualBrush, null, ContentBounds); - var boundsRect = new Rect(Bounds.Size); - bool intersects = !(ViewportRect.Right <= boundsRect.X || ViewportRect.X >= boundsRect.Right || - ViewportRect.Bottom <= boundsRect.Y || ViewportRect.Y >= boundsRect.Bottom); - if (intersects) - { - var r1 = new Rect(new Point(0, 0), new Size(Bounds.Width, Math.Max(0, ViewportRect.Top))); - var r2 = new Rect(new Point(0, ViewportRect.Top), - new Size(Math.Max(0, ViewportRect.Left), ViewportRect.Height)); - var r3 = new Rect(new Point(ViewportRect.Right, ViewportRect.Top), - new Size(Math.Max(0, Bounds.Width - ViewportRect.Right), ViewportRect.Height)); - var r4 = new Rect(new Point(0, ViewportRect.Bottom), - new Size(Bounds.Width, Math.Max(0, Bounds.Height - ViewportRect.Bottom))); - if (ShadowBrush != null) - { - dc.DrawRectangle(ShadowBrush, null, r1); - dc.DrawRectangle(ShadowBrush, null, r2); - dc.DrawRectangle(ShadowBrush, null, r3); - dc.DrawRectangle(ShadowBrush, null, r4); - } - - dc.DrawRectangle(ViewportBrush, ViewportPen, ViewportRect); - } - else if (ShadowBrush != null) - { - dc.DrawRectangle(ShadowBrush, null, new Rect(Bounds.Size)); - } - } -} \ No newline at end of file diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/Wayfinder.cs b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/Wayfinder.cs index aa90cf3..f329f3f 100644 --- a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/Wayfinder.cs +++ b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/Wayfinder.cs @@ -245,11 +245,7 @@ private void RefreshSourceVisualSubscriptions() private void SourceVisualPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { - // Visual controls do not expose a general render-invalidated event in - // Avalonia 12. Observe their rendering-affecting property changes - // instead, including child controls such as edge and vertex labels. - // ZoomControl's translate/zoom properties are on its parent and never - // reach this subscription, so viewport-only changes keep the cache. + if (_contentCacheDirty) return; InvalidateContentCache(); InvalidateVisual(); } @@ -783,14 +779,16 @@ private void RasterizeGraphAreaChildren( context, graphArea, graphArea.Children, - rect => new Rect( - (rect.X - _contentRect.X) * Scale, - (rect.Y - _contentRect.Y) * Scale, - rect.Width * Scale, - rect.Height * Scale), + _contentToCacheRectMapper ??= MapContentRectToCacheRect, renderCachedRasterLayerAsBitmap: true, renderBatchedEdgeLayerDirectly: false); } + private Func? _contentToCacheRectMapper; + private Rect MapContentRectToCacheRect(Rect rect) => new( + (rect.X - _contentRect.X) * Scale, + (rect.Y - _contentRect.Y) * Scale, + rect.Width * Scale, + rect.Height * Scale); private void InvalidateContentCache() { diff --git a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs index 6789ac4..f5b7f84 100644 --- a/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs +++ b/Westermo.GraphX.Controls.Avalonia/Controls/ZoomControl/ZoomControl.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; using Avalonia; using Avalonia.Controls; @@ -40,11 +40,8 @@ public sealed class ZoomControl : ContentControl, IZoomControl, INotifyPropertyC /// private bool _viewportUpdatePending; - // Hook placeholders - private void HookBeforeZoomChanging() - { - } - + // Hook placeholder: fires after Zoom_PropertyChanged commits, to schedule + // the coalesced viewport update. private void HookAfterZoomChanging() { ScheduleViewportUpdate(); @@ -59,12 +56,17 @@ private void ScheduleViewportUpdate() { if (_viewportUpdatePending) return; _viewportUpdatePending = true; - Dispatcher.UIThread.Post(() => - { - _viewportUpdatePending = false; - NotifyGraphAreaViewportChanged(); - NotifyGraphAreaZoomChanged(); - }, DispatcherPriority.Render); + Dispatcher.UIThread.Post(_viewportUpdateTick ??= OnViewportUpdateTick, DispatcherPriority.Render); + } + + private Action? _viewportUpdateTick; + + private void OnViewportUpdateTick() + { + _viewportUpdatePending = false; + NotifyGraphAreaViewportChanged(); + NotifyGraphAreaZoomChanged(); + _cachedVisibleContentRect = null; } /// @@ -122,7 +124,11 @@ private void NotifyGraphAreaZoomChanged() /// /// Gets the currently visible content rectangle in content coordinates. /// - public Rect GetVisibleContentRect() + public Rect GetVisibleContentRect() => _cachedVisibleContentRect ??= ComputeVisibleContentRect(); + + private Rect? _cachedVisibleContentRect; + + private Rect ComputeVisibleContentRect() { if (_presenter == null || ContentVisual == null) return default; @@ -191,7 +197,7 @@ public void CenterContent() { if (_presenter == null) return; var initialTranslate = GetTrackableTranslate(); - DoZoomAnimation(Zoom, initialTranslate.X * Zoom, initialTranslate.Y * Zoom, false); + SetZoomTransformImmediate(Zoom, initialTranslate.X * Zoom, initialTranslate.Y * Zoom, false); } /// @@ -252,14 +258,6 @@ private Vector GetInitialTranslate(double contentWidth, double contentHeight, do public static RoutedEvent Refocus = RoutedEvent.Register("Refocus", RoutingStrategies.Bubble); - private void CanRefocusView(object sender, RoutedEventArgs e) - { - } - - private void RefocusView(object sender, RoutedEventArgs e) - { - } - #endregion @@ -271,6 +269,11 @@ private void RefocusView(object sender, RoutedEventArgs e) /// /// Gets or sets whether zoom and pan animations are enabled. Default is true. /// + /// + /// Reserved for a future real (interpolated) zoom/pan animation. Currently + /// unused: all zoom/pan actions apply their target transform immediately + /// (see ) regardless of this flag. + /// public bool IsAnimationEnabled { get => GetValue(IsAnimationEnabledProperty); @@ -346,20 +349,24 @@ private static double TranslateY_Coerce(AvaloniaObject o, double d) => private static void TranslateX_PropertyChanged(ZoomControl zc, AvaloniaPropertyChangedEventArgs e) { + zc._cachedVisibleContentRect = null; if (zc._translateTransform == null) return; zc._translateTransform.X = (double)e.NewValue!; if (!zc._isZooming) zc.Mode = ZoomControlModes.Custom; zc.OnPropertyChanged(nameof(Presenter)); + if (zc._isBatchingPresenterInvalidation) return; zc.InvalidatePresenterTransform(!zc._isZooming); zc.ScheduleViewportUpdate(); } private static void TranslateY_PropertyChanged(ZoomControl zc, AvaloniaPropertyChangedEventArgs e) { + zc._cachedVisibleContentRect = null; if (zc._translateTransform == null) return; zc._translateTransform.Y = (double)e.NewValue!; if (!zc._isZooming) zc.Mode = ZoomControlModes.Custom; zc.OnPropertyChanged(nameof(Presenter)); + if (zc._isBatchingPresenterInvalidation) return; zc.InvalidatePresenterTransform(!zc._isZooming); zc.ScheduleViewportUpdate(); } @@ -385,9 +392,11 @@ private static void TranslateY_PropertyChanged(ZoomControl zc, AvaloniaPropertyC public static readonly StyledProperty ZoomProperty = AvaloniaProperty.Register(nameof(Zoom), 1.0); + private bool _isBatchingPresenterInvalidation; + private static void Zoom_PropertyChanged(ZoomControl zc, AvaloniaPropertyChangedEventArgs e) { - zc.HookBeforeZoomChanging(); + zc._cachedVisibleContentRect = null; if (zc._scaleTransform == null) return; var zoom = (double)e.NewValue!; zc._scaleTransform.ScaleX = zoom; @@ -395,13 +404,23 @@ private static void Zoom_PropertyChanged(ZoomControl zc, AvaloniaPropertyChanged if (!zc._isZooming) { var delta = (double)e.NewValue / (double)e.OldValue!; - zc.TranslateX *= delta; - zc.TranslateY *= delta; + var wasBatching = zc._isBatchingPresenterInvalidation; + zc._isBatchingPresenterInvalidation = true; + try + { + zc.TranslateX *= delta; + zc.TranslateY *= delta; + } + finally + { + zc._isBatchingPresenterInvalidation = wasBatching; + } + zc.Mode = ZoomControlModes.Custom; } zc.OnPropertyChanged(nameof(Presenter)); - zc.InvalidatePresenterTransform(); + zc.InvalidatePresenterTransform(!zc._isZooming); zc.OnPropertyChanged(nameof(Zoom)); zc.HookAfterZoomChanging(); } @@ -607,6 +626,7 @@ static ZoomControl() private void HandleContentChanged(object? oldContent, object? newContent) { if (Design.IsDesignMode) return; + _cachedVisibleContentRect = null; if (oldContent is ITrackableContent oldTrackable) oldTrackable.ContentSizeChanged -= Content_ContentSizeChanged; @@ -645,6 +665,7 @@ public ZoomControl() private void ZoomControl_SizeChanged(object? sender, SizeChangedEventArgs e) { + _cachedVisibleContentRect = null; if (Mode == ZoomControlModes.Fill || _pendingFillOnBoundsAvailable) DoZoomToFill(); } @@ -828,8 +849,7 @@ public event EventHandler? Click remove => RemoveHandler(ClickEvent, value); } - // Simplified animation: direct property set - private void DoZoomAnimation(double targetZoom, double transformX, double transformY, bool isZooming = true) + private void SetZoomTransformImmediate(double targetZoom, double transformX, double transformY, bool isZooming = true) { _isZooming = isZooming; SetCurrentValue(TranslateXProperty, transformX); @@ -867,7 +887,7 @@ public void ZoomToContent(Rect rectangle, bool usingContentCoordinates = true) new Point((ActualWidth / 2 - center.X) * newRelativeScale, (ActualHeight / 2 - center.Y) * newRelativeScale); - DoZoomAnimation(newRelativeScale, newRelativePosition.X, newRelativePosition.Y); + SetZoomTransformImmediate(newRelativeScale, newRelativePosition.X, newRelativePosition.Y); } public event EventHandler? ZoomAnimationCompleted; @@ -877,7 +897,7 @@ private void DoZoomToOriginal() { if (_presenter == null) return; var initialTranslate = GetTrackableTranslate(); - DoZoomAnimation(1.0, initialTranslate.X, initialTranslate.Y); + SetZoomTransformImmediate(1.0, initialTranslate.X, initialTranslate.Y); } private void DoZoomToFill() @@ -917,7 +937,7 @@ private void DoZoomToFill() var deltaZoom = Math.Clamp(Math.Min(ActualWidth / c.Width, ActualHeight / c.Height), MinZoom, MaxZoom); var initialTranslate = IsContentTrackable ? GetTrackableTranslate() : GetInitialTranslate(c.Width, c.Height); - DoZoomAnimation(deltaZoom, initialTranslate.X * deltaZoom, initialTranslate.Y * deltaZoom); + SetZoomTransformImmediate(deltaZoom, initialTranslate.X * deltaZoom, initialTranslate.Y * deltaZoom); } private void ZoomToInternal(Rect rect, bool setDelta = false) @@ -965,7 +985,7 @@ private void DoZoom(double deltaZoom, int mod, Point origoPosition, Point startH transformY = GetCoercedTranslateY(TranslateY + endTranslate.Y, currentZoom); } - DoZoomAnimation(currentZoom, transformX, transformY); + SetZoomTransformImmediate(currentZoom, transformX, transformY); Mode = ZoomControlModes.Custom; } @@ -992,49 +1012,16 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) private void Presenter_ContentSizeChanged(object sender, Size newSize) { + _cachedVisibleContentRect = null; if (Mode == ZoomControlModes.Fill || _pendingFillOnBoundsAvailable) DoZoomToFill(); } private void Presenter_SizeChanged(object? sender, SizeChangedEventArgs e) { + _cachedVisibleContentRect = null; if (Mode == ZoomControlModes.Fill || _pendingFillOnBoundsAvailable) DoZoomToFill(); } public new event PropertyChangedEventHandler? PropertyChanged; public void OnPropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } - -#region ResizeEdge Nested Type - -public enum ResizeEdge -{ - None, - TopLeft, - TopRight, - BottomLeft, - BottomRight, - Left, - Top, - Right, - Bottom -} - -#endregion - -#region CacheBits Nested Type - -public enum CacheBits -{ - IsUpdatingView = 0x1, - IsUpdatingViewport = 0x2, - IsDraggingViewport = 0x4, - IsResizingViewport = 0x8, - IsMonitoringInput = 0x10, - IsContentWrapped = 0x20, - HasArrangedContentPresenter = 0x40, - HasRenderedFirstView = 0x80, - RefocusViewOnFirstRender = 0x100, - HasUiPermission = 0x200 -} - -#endregion \ No newline at end of file diff --git a/Westermo.GraphX.Controls.Avalonia/Properties/AssemblyInfo.cs b/Westermo.GraphX.Controls.Avalonia/Properties/AssemblyInfo.cs index a9478c1..8096161 100644 --- a/Westermo.GraphX.Controls.Avalonia/Properties/AssemblyInfo.cs +++ b/Westermo.GraphX.Controls.Avalonia/Properties/AssemblyInfo.cs @@ -5,6 +5,7 @@ [assembly: ComVisible(false)] [assembly: Guid("1236aebd-1b59-4374-9c12-b377bb1973fc")] [assembly: InternalsVisibleTo("Westermo.GraphX.Controls.Avalonia.Tests")] +[assembly: InternalsVisibleTo("RenderingBenchmarks")] [assembly: XmlnsPrefix("https://westermo.com/graphx/", "graphx")] [assembly: XmlnsDefinition("https://westermo.com/graphx/", "Westermo.GraphX.Controls")]