Add animation blueprint graph viewer with node/connection extraction and visualization

Co-authored-by: LoogLong <86428208+LoogLong@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-02-28 10:05:39 +00:00
parent bd366ea12f
commit ba9bb323a3
4 changed files with 643 additions and 0 deletions

View File

@ -0,0 +1,136 @@
using System.Collections.Generic;
using System.Linq;
using CUE4Parse.UE4.Assets;
using CUE4Parse.UE4.Assets.Exports;
using CUE4Parse.UE4.Assets.Exports.EdGraph;
using CUE4Parse.UE4.Objects.Engine.EdGraph;
namespace FModel.ViewModels;
public class AnimGraphNode
{
public string Name { get; set; } = string.Empty;
public string ExportType { get; set; } = string.Empty;
public string NodeComment { get; set; } = string.Empty;
public int NodePosX { get; set; }
public int NodePosY { get; set; }
public List<AnimGraphPin> Pins { get; set; } = [];
public override string ToString() => $"{ExportType} ({Name})";
}
public class AnimGraphPin
{
public string PinName { get; set; } = string.Empty;
public EEdGraphPinDirection Direction { get; set; }
public string PinType { get; set; } = string.Empty;
public string DefaultValue { get; set; } = string.Empty;
public AnimGraphNode OwnerNode { get; set; } = null!;
}
public class AnimGraphConnection
{
public AnimGraphNode SourceNode { get; set; } = null!;
public string SourcePinName { get; set; } = string.Empty;
public AnimGraphNode TargetNode { get; set; } = null!;
public string TargetPinName { get; set; } = string.Empty;
}
public class AnimGraphViewModel
{
public string PackageName { get; set; } = string.Empty;
public List<AnimGraphNode> Nodes { get; } = [];
public List<AnimGraphConnection> Connections { get; } = [];
public static AnimGraphViewModel ExtractFromPackage(IPackage package)
{
var vm = new AnimGraphViewModel { PackageName = package.Name };
var allExports = package.GetExports().ToList();
// Map from UObject to AnimGraphNode for connection resolution
var nodeMap = new Dictionary<UObject, AnimGraphNode>();
// First pass: extract all graph nodes
foreach (var export in allExports)
{
if (export is not UEdGraphNode graphNode) continue;
var node = new AnimGraphNode
{
Name = graphNode.Name,
ExportType = graphNode.ExportType,
NodePosX = graphNode.GetOrDefault("NodePosX", 0),
NodePosY = graphNode.GetOrDefault("NodePosY", 0),
NodeComment = graphNode.GetOrDefault<string>("NodeComment", string.Empty)
};
// Extract pins
foreach (var pinRef in graphNode.Pins)
{
if (pinRef is not UEdGraphPin pin) continue;
var graphPin = new AnimGraphPin
{
PinName = pin.PinName.Text ?? string.Empty,
Direction = pin.Direction,
PinType = pin.PinType?.PinCategory.Text ?? string.Empty,
DefaultValue = pin.DefaultValue ?? string.Empty,
OwnerNode = node
};
node.Pins.Add(graphPin);
}
nodeMap[graphNode] = node;
vm.Nodes.Add(node);
}
// Second pass: resolve connections via LinkedTo references
foreach (var export in allExports)
{
if (export is not UEdGraphNode graphNode) continue;
if (!nodeMap.TryGetValue(graphNode, out var sourceNode)) continue;
foreach (var pinRef in graphNode.Pins)
{
if (pinRef is not UEdGraphPin pin) continue;
foreach (var linkedRef in pin.LinkedTo)
{
if (linkedRef == null) continue;
// Resolve the owning node of the linked pin
var linkedNodeObj = linkedRef.OwningNode.ResolvedObject?.Object?.Value;
if (linkedNodeObj == null || !nodeMap.TryGetValue(linkedNodeObj, out var targetNode)) continue;
// Only add connection from output to input to avoid duplicates
if (pin.Direction != EEdGraphPinDirection.EGPD_Output) continue;
// Try to find the target pin name
var targetPinName = string.Empty;
if (linkedNodeObj is UEdGraphNode linkedGraphNode)
{
foreach (var tp in linkedGraphNode.Pins)
{
if (tp is UEdGraphPin targetPin && targetPin.PinId == linkedRef.PinId)
{
targetPinName = targetPin.PinName.Text ?? string.Empty;
break;
}
}
}
vm.Connections.Add(new AnimGraphConnection
{
SourceNode = sourceNode,
SourcePinName = pin.PinName.Text ?? string.Empty,
TargetNode = targetNode,
TargetPinName = targetPinName
});
}
}
}
return vm;
}
}

View File

@ -49,6 +49,7 @@ using CUE4Parse.UE4.IO;
using CUE4Parse.UE4.Localization;
using CUE4Parse.UE4.Objects.Core.Serialization;
using CUE4Parse.UE4.Objects.Engine;
using CUE4Parse.UE4.Objects.Engine.Animation;
using CUE4Parse.UE4.Objects.UObject;
using CUE4Parse.UE4.Objects.UObject.Editor;
using CUE4Parse.UE4.Oodle.Objects;
@ -1265,6 +1266,21 @@ public class CUE4ParseViewModel : ViewModel
return false;
}
case UAnimBlueprintGeneratedClass when isNone:
{
var graphVm = AnimGraphViewModel.ExtractFromPackage(pkg);
if (graphVm.Nodes.Count > 0)
{
Application.Current.Dispatcher.Invoke(() =>
{
Helper.OpenWindow<AnimGraphViewer>("Animation Blueprint Graph Viewer", () =>
{
new AnimGraphViewer(graphVm).Show();
});
});
}
return true;
}
case UWorld when isNone && UserSettings.Default.PreviewWorlds:
case UBlueprintGeneratedClass when isNone && UserSettings.Default.PreviewWorlds && TabControl.SelectedTab.ParentExportType switch
{

View File

@ -0,0 +1,60 @@
<adonisControls:AdonisWindow x:Class="FModel.Views.AnimGraphViewer"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:FModel.Views.Resources.Converters"
xmlns:adonisUi="clr-namespace:AdonisUI;assembly=AdonisUI"
xmlns:adonisControls="clr-namespace:AdonisUI.Controls;assembly=AdonisUI"
xmlns:adonisExtensions="clr-namespace:AdonisUI.Extensions;assembly=AdonisUI"
WindowStartupLocation="CenterScreen" IconVisibility="Collapsed"
Height="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenHeight}, Converter={converters:RatioConverter}, ConverterParameter='0.75'}"
Width="{Binding Source={x:Static SystemParameters.MaximizedPrimaryScreenWidth}, Converter={converters:RatioConverter}, ConverterParameter='0.75'}">
<adonisControls:AdonisWindow.Style>
<Style TargetType="adonisControls:AdonisWindow" BasedOn="{StaticResource {x:Type adonisControls:AdonisWindow}}">
<Setter Property="Title" Value="Animation Blueprint Graph Viewer"/>
</Style>
</adonisControls:AdonisWindow.Style>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Toolbar -->
<Border Grid.Row="0" Padding="8 4"
Background="{DynamicResource {x:Static adonisUi:Brushes.Layer1BackgroundBrush}}"
adonisExtensions:LayerExtension.IncreaseLayer="True">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="PackageNameText" VerticalAlignment="Center" FontWeight="SemiBold" Margin="0 0 16 0"/>
<TextBlock x:Name="NodeCountText" VerticalAlignment="Center" Margin="0 0 16 0"/>
<TextBlock x:Name="ConnectionCountText" VerticalAlignment="Center" Margin="0 0 16 0"/>
<Button Content="Fit to View" MinWidth="78" Margin="4 0" Click="OnFitToView"/>
<Button Content="Reset Zoom" MinWidth="78" Margin="4 0" Click="OnResetZoom"/>
</StackPanel>
</Border>
<!-- Graph Canvas -->
<Border Grid.Row="1" ClipToBounds="True" Background="#1E1E2E"
MouseWheel="OnMouseWheel" MouseLeftButtonDown="OnCanvasMouseDown"
MouseLeftButtonUp="OnCanvasMouseUp" MouseMove="OnCanvasMouseMove">
<Canvas x:Name="GraphCanvas" RenderTransformOrigin="0,0">
<Canvas.RenderTransform>
<TransformGroup>
<ScaleTransform x:Name="ScaleTransform" ScaleX="1" ScaleY="1"/>
<TranslateTransform x:Name="TranslateTransform" X="0" Y="0"/>
</TransformGroup>
</Canvas.RenderTransform>
</Canvas>
</Border>
<!-- Status bar -->
<Border Grid.Row="2" Padding="8 4"
Background="{DynamicResource {x:Static adonisUi:Brushes.Layer1BackgroundBrush}}"
adonisExtensions:LayerExtension.IncreaseLayer="True">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="ZoomText" Text="Zoom: 100%" VerticalAlignment="Center" Margin="0 0 16 0"/>
<TextBlock x:Name="SelectedNodeText" VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</adonisControls:AdonisWindow>

View File

@ -0,0 +1,431 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
using CUE4Parse.UE4.Objects.Engine.EdGraph;
using FModel.ViewModels;
namespace FModel.Views;
public partial class AnimGraphViewer
{
private const double NodeWidth = 200;
private const double NodeHeaderHeight = 28;
private const double PinRowHeight = 22;
private const double PinRadius = 6;
private const double NodeCornerRadius = 4;
private const double ScaleFactor = 0.6;
private readonly AnimGraphViewModel _viewModel;
private readonly Dictionary<AnimGraphNode, Point> _nodePositions = new();
private readonly Dictionary<AnimGraphNode, (Border border, double width, double height)> _nodeVisuals = new();
private readonly Dictionary<(AnimGraphNode node, string pinName, EEdGraphPinDirection dir), Point> _pinPositions = new();
private bool _isPanning;
private Point _lastMousePos;
public AnimGraphViewer(AnimGraphViewModel viewModel)
{
_viewModel = viewModel;
InitializeComponent();
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
PackageNameText.Text = _viewModel.PackageName;
NodeCountText.Text = $"Nodes: {_viewModel.Nodes.Count}";
ConnectionCountText.Text = $"Connections: {_viewModel.Connections.Count}";
DrawGraph();
FitToView();
}
private void DrawGraph()
{
GraphCanvas.Children.Clear();
_nodePositions.Clear();
_nodeVisuals.Clear();
_pinPositions.Clear();
// Calculate positions from UE node coordinates
foreach (var node in _viewModel.Nodes)
{
_nodePositions[node] = new Point(node.NodePosX * ScaleFactor, node.NodePosY * ScaleFactor);
}
// Auto-layout nodes that have 0,0 positions
AutoLayoutZeroPositionNodes();
// Draw connections first (behind nodes)
foreach (var conn in _viewModel.Connections)
{
DrawConnection(conn);
}
// Draw nodes on top
foreach (var node in _viewModel.Nodes)
{
DrawNode(node);
}
// Update connection lines with actual pin positions
GraphCanvas.Children.OfType<Path>().ToList().ForEach(p => GraphCanvas.Children.Remove(p));
foreach (var conn in _viewModel.Connections)
{
DrawConnectionLine(conn);
}
}
private void AutoLayoutZeroPositionNodes()
{
var zeroNodes = _viewModel.Nodes.Where(n => n.NodePosX == 0 && n.NodePosY == 0).ToList();
if (zeroNodes.Count <= 1) return;
// If most nodes have zero positions, do a simple grid layout
var nonZeroCount = _viewModel.Nodes.Count - zeroNodes.Count;
if (nonZeroCount > zeroNodes.Count) return; // Only a few are zero, leave them
var cols = (int)Math.Ceiling(Math.Sqrt(zeroNodes.Count));
for (var i = 0; i < zeroNodes.Count; i++)
{
var col = i % cols;
var row = i / cols;
_nodePositions[zeroNodes[i]] = new Point(col * (NodeWidth + 80), row * 200);
}
}
private void DrawNode(AnimGraphNode node)
{
var pos = _nodePositions[node];
var inputPins = node.Pins.Where(p => p.Direction == EEdGraphPinDirection.EGPD_Input).ToList();
var outputPins = node.Pins.Where(p => p.Direction == EEdGraphPinDirection.EGPD_Output).ToList();
var maxPins = Math.Max(inputPins.Count, outputPins.Count);
var nodeHeight = NodeHeaderHeight + Math.Max(maxPins, 1) * PinRowHeight + 8;
// Node background
var border = new Border
{
Width = NodeWidth,
Height = nodeHeight,
CornerRadius = new CornerRadius(NodeCornerRadius),
Background = new SolidColorBrush(Color.FromRgb(45, 45, 65)),
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 110)),
BorderThickness = new Thickness(1),
SnapsToDevicePixels = true
};
var grid = new Grid();
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(NodeHeaderHeight) });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
// Header
var headerBorder = new Border
{
Background = GetNodeHeaderBrush(node.ExportType),
CornerRadius = new CornerRadius(NodeCornerRadius, NodeCornerRadius, 0, 0)
};
var headerText = new TextBlock
{
Text = GetNodeDisplayName(node),
Foreground = Brushes.White,
FontSize = 11,
FontWeight = FontWeights.SemiBold,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(4, 0, 4, 0)
};
headerBorder.Child = headerText;
Grid.SetRow(headerBorder, 0);
grid.Children.Add(headerBorder);
// Pins area
var pinsGrid = new Grid();
pinsGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
pinsGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
// Input pins
var inputPanel = new StackPanel { Margin = new Thickness(4, 4, 0, 0) };
foreach (var pin in inputPins)
{
inputPanel.Children.Add(CreatePinLabel(pin, HorizontalAlignment.Left));
}
Grid.SetColumn(inputPanel, 0);
pinsGrid.Children.Add(inputPanel);
// Output pins
var outputPanel = new StackPanel { Margin = new Thickness(0, 4, 4, 0) };
foreach (var pin in outputPins)
{
outputPanel.Children.Add(CreatePinLabel(pin, HorizontalAlignment.Right));
}
Grid.SetColumn(outputPanel, 1);
pinsGrid.Children.Add(outputPanel);
Grid.SetRow(pinsGrid, 1);
grid.Children.Add(pinsGrid);
border.Child = grid;
Canvas.SetLeft(border, pos.X);
Canvas.SetTop(border, pos.Y);
Panel.SetZIndex(border, 1);
GraphCanvas.Children.Add(border);
_nodeVisuals[node] = (border, NodeWidth, nodeHeight);
// Calculate pin positions for connections
for (var i = 0; i < inputPins.Count; i++)
{
var pinPos = new Point(pos.X, pos.Y + NodeHeaderHeight + 4 + i * PinRowHeight + PinRowHeight / 2);
_pinPositions[(node, inputPins[i].PinName, EEdGraphPinDirection.EGPD_Input)] = pinPos;
}
for (var i = 0; i < outputPins.Count; i++)
{
var pinPos = new Point(pos.X + NodeWidth, pos.Y + NodeHeaderHeight + 4 + i * PinRowHeight + PinRowHeight / 2);
_pinPositions[(node, outputPins[i].PinName, EEdGraphPinDirection.EGPD_Output)] = pinPos;
}
// Add tooltip
border.ToolTip = BuildNodeTooltip(node);
// Click to select
border.MouseLeftButtonDown += (s, e) =>
{
SelectedNodeText.Text = $"Selected: {node.ExportType} - {node.Name}";
e.Handled = true;
};
}
private static TextBlock CreatePinLabel(AnimGraphPin pin, HorizontalAlignment alignment)
{
var displayName = string.IsNullOrEmpty(pin.PinName) ? "(unnamed)" : pin.PinName;
var pinColor = GetPinColor(pin.PinType);
return new TextBlock
{
Text = alignment == HorizontalAlignment.Left ? $"● {displayName}" : $"{displayName} ●",
Foreground = new SolidColorBrush(pinColor),
FontSize = 10,
Height = PinRowHeight,
HorizontalAlignment = alignment,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
Padding = new Thickness(2, 2, 2, 0)
};
}
private void DrawConnection(AnimGraphConnection conn)
{
// Placeholder - actual lines drawn after node layout
}
private void DrawConnectionLine(AnimGraphConnection conn)
{
var sourceKey = (conn.SourceNode, conn.SourcePinName, EEdGraphPinDirection.EGPD_Output);
var targetKey = (conn.TargetNode, conn.TargetPinName, EEdGraphPinDirection.EGPD_Input);
if (!_pinPositions.TryGetValue(sourceKey, out var startPos))
{
// Fallback: use node center-right
if (_nodePositions.TryGetValue(conn.SourceNode, out var srcNodePos))
startPos = new Point(srcNodePos.X + NodeWidth, srcNodePos.Y + NodeHeaderHeight + 10);
else
return;
}
if (!_pinPositions.TryGetValue(targetKey, out var endPos))
{
// Fallback: use node center-left
if (_nodePositions.TryGetValue(conn.TargetNode, out var tgtNodePos))
endPos = new Point(tgtNodePos.X, tgtNodePos.Y + NodeHeaderHeight + 10);
else
return;
}
var dx = Math.Abs(endPos.X - startPos.X) * 0.5;
var pathFigure = new PathFigure { StartPoint = startPos };
pathFigure.Segments.Add(new BezierSegment(
new Point(startPos.X + dx, startPos.Y),
new Point(endPos.X - dx, endPos.Y),
endPos, true));
var pathGeometry = new PathGeometry();
pathGeometry.Figures.Add(pathFigure);
var path = new Path
{
Data = pathGeometry,
Stroke = new SolidColorBrush(Color.FromRgb(180, 180, 200)),
StrokeThickness = 1.5,
Opacity = 0.7,
SnapsToDevicePixels = true
};
Panel.SetZIndex(path, 0);
GraphCanvas.Children.Add(path);
}
private static string GetNodeDisplayName(AnimGraphNode node)
{
var type = node.ExportType;
// Clean up common prefixes
if (type.StartsWith("AnimGraphNode_"))
type = type["AnimGraphNode_".Length..];
else if (type.StartsWith("K2Node_"))
type = type["K2Node_".Length..];
if (!string.IsNullOrEmpty(node.NodeComment))
return $"{type}: {node.NodeComment}";
return type;
}
private static Brush GetNodeHeaderBrush(string exportType)
{
return exportType switch
{
_ when exportType.Contains("AnimGraphNode") => new SolidColorBrush(Color.FromRgb(0, 120, 80)),
_ when exportType.Contains("K2Node") => new SolidColorBrush(Color.FromRgb(60, 60, 160)),
_ when exportType.Contains("State") => new SolidColorBrush(Color.FromRgb(140, 60, 20)),
_ when exportType.Contains("Transition") => new SolidColorBrush(Color.FromRgb(140, 120, 0)),
_ when exportType.Contains("Result") => new SolidColorBrush(Color.FromRgb(120, 40, 40)),
_ => new SolidColorBrush(Color.FromRgb(70, 70, 90))
};
}
private static Color GetPinColor(string pinType)
{
return pinType switch
{
"exec" => Color.FromRgb(255, 255, 255),
"bool" => Color.FromRgb(139, 0, 0),
"float" or "real" or "double" => Color.FromRgb(140, 255, 140),
"int" or "int64" => Color.FromRgb(80, 220, 180),
"struct" => Color.FromRgb(0, 120, 215),
"object" => Color.FromRgb(0, 160, 200),
"string" or "text" or "name" => Color.FromRgb(255, 80, 180),
"delegate" => Color.FromRgb(255, 56, 56),
"pose" => Color.FromRgb(0, 160, 100),
_ => Color.FromRgb(180, 180, 200)
};
}
private static string BuildNodeTooltip(AnimGraphNode node)
{
var lines = new List<string>
{
$"Name: {node.Name}",
$"Type: {node.ExportType}",
$"Position: ({node.NodePosX}, {node.NodePosY})"
};
if (!string.IsNullOrEmpty(node.NodeComment))
lines.Add($"Comment: {node.NodeComment}");
lines.Add($"Input Pins: {node.Pins.Count(p => p.Direction == EEdGraphPinDirection.EGPD_Input)}");
lines.Add($"Output Pins: {node.Pins.Count(p => p.Direction == EEdGraphPinDirection.EGPD_Output)}");
foreach (var pin in node.Pins)
{
var dir = pin.Direction == EEdGraphPinDirection.EGPD_Input ? "In" : "Out";
var defaultVal = string.IsNullOrEmpty(pin.DefaultValue) ? "" : $" = {pin.DefaultValue}";
lines.Add($" [{dir}] {pin.PinName} ({pin.PinType}){defaultVal}");
}
return string.Join("\n", lines);
}
// Zoom & Pan
private void OnMouseWheel(object sender, MouseWheelEventArgs e)
{
var factor = e.Delta > 0 ? 1.1 : 1.0 / 1.1;
var pos = e.GetPosition(GraphCanvas);
ScaleTransform.ScaleX *= factor;
ScaleTransform.ScaleY *= factor;
// Zoom toward mouse position
TranslateTransform.X = pos.X * (1 - factor) + TranslateTransform.X * factor;
TranslateTransform.Y = pos.Y * (1 - factor) + TranslateTransform.Y * factor;
// Prevent ScaleX from going through the roof causing WPF layout issues
ScaleTransform.ScaleX = Math.Clamp(ScaleTransform.ScaleX, 0.05, 5.0);
ScaleTransform.ScaleY = Math.Clamp(ScaleTransform.ScaleY, 0.05, 5.0);
ZoomText.Text = $"Zoom: {ScaleTransform.ScaleX * 100:F0}%";
}
private void OnCanvasMouseDown(object sender, MouseButtonEventArgs e)
{
_isPanning = true;
_lastMousePos = e.GetPosition(this);
((UIElement)sender).CaptureMouse();
}
private void OnCanvasMouseUp(object sender, MouseButtonEventArgs e)
{
_isPanning = false;
((UIElement)sender).ReleaseMouseCapture();
}
private void OnCanvasMouseMove(object sender, MouseEventArgs e)
{
if (!_isPanning) return;
var currentPos = e.GetPosition(this);
var delta = currentPos - _lastMousePos;
TranslateTransform.X += delta.X;
TranslateTransform.Y += delta.Y;
_lastMousePos = currentPos;
}
private void OnFitToView(object sender, RoutedEventArgs e)
{
FitToView();
}
private void FitToView()
{
if (_nodePositions.Count == 0) return;
var minX = _nodePositions.Values.Min(p => p.X);
var minY = _nodePositions.Values.Min(p => p.Y);
var maxX = _nodePositions.Values.Max(p => p.X) + NodeWidth;
var maxY = _nodePositions.Values.Max(p => p.Y) + 150;
var graphWidth = maxX - minX;
var graphHeight = maxY - minY;
if (graphWidth < 1 || graphHeight < 1) return;
var viewWidth = ActualWidth > 0 ? ActualWidth : 800;
var viewHeight = ActualHeight > 0 ? ActualHeight - 80 : 600;
var scaleX = viewWidth / graphWidth * 0.9;
var scaleY = viewHeight / graphHeight * 0.9;
var scale = Math.Min(Math.Min(scaleX, scaleY), 2.0);
ScaleTransform.ScaleX = scale;
ScaleTransform.ScaleY = scale;
TranslateTransform.X = -minX * scale + (viewWidth - graphWidth * scale) / 2;
TranslateTransform.Y = -minY * scale + (viewHeight - graphHeight * scale) / 2;
ZoomText.Text = $"Zoom: {scale * 100:F0}%";
}
private void OnResetZoom(object sender, RoutedEventArgs e)
{
ScaleTransform.ScaleX = 1;
ScaleTransform.ScaleY = 1;
TranslateTransform.X = 0;
TranslateTransform.Y = 0;
ZoomText.Text = "Zoom: 100%";
}
}