diff --git a/.copilot-tracking/changes/20260219-sketch-style-editing-changes.md b/.copilot-tracking/changes/20260219-sketch-style-editing-changes.md
new file mode 100644
index 0000000..b9a7b4d
--- /dev/null
+++ b/.copilot-tracking/changes/20260219-sketch-style-editing-changes.md
@@ -0,0 +1,62 @@
+
+# Release Changes: Phase 4.0 Sketch-Style Curve Editing
+
+**Related Plan**: .github/planning/phase-4-plan.md (Section 0)
+**Implementation Date**: 2026-02-19
+
+## Summary
+
+Implements sketch-style curve editing on the chart, allowing users to click and drag to draw torque values directly onto a curve series. The mouse position is snapped to the nearest speed data point (X axis) and rounded to a configurable torque increment (Y axis, default 0.2).
+
+## Changes
+
+### Added
+
+- src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs - Added `SketchEditSeriesName`, `IsSketchEditActive`, `TorqueSnapIncrement` properties and `SetSketchEditSeries`, `ClearSketchEditSeries`, `ApplySketchPoint`, `FindNearestSpeedIndex`, `SnapTorque` methods for sketch-edit mode
+- src/MotorEditor.Avalonia/Views/ChartView.axaml.cs - Added `_isSketchDragging` state, `TryApplySketchAtPixel` helper, and sketch-edit handling in pointer event handlers
+- tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs - Added 26 unit tests covering sketch-edit activation/deactivation, torque snapping, nearest speed index, and ApplySketchPoint behavior
+
+### Modified
+
+- src/MotorEditor.Avalonia/Models/UserPreferences.cs - Added `TorqueSnapIncrement` property (default 0.2) with clone support
+- src/MotorEditor.Avalonia/Views/ChartView.axaml - Added sketch-edit mode indicator overlay showing active series name
+- src/MotorEditor.Avalonia/Views/ChartView.axaml.cs - Extended pointer pressed/moved/released/capture-lost handlers to support sketch-edit drag interactions
+- src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml - Added per-series ✏ sketch-edit toggle button in the column header
+- src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml.cs - Added `OnSketchEditToggleClick` and `RefreshSketchEditToggles` handlers for sketch-edit toggle
+
+### Removed
+
+(none)
+
+## Release Summary
+
+**Total Files Affected**: 6
+
+### Files Created (0)
+
+(none)
+
+### Files Modified (6)
+
+- src/MotorEditor.Avalonia/Models/UserPreferences.cs - Added TorqueSnapIncrement preference
+- src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs - Added sketch-edit state management and point application logic
+- src/MotorEditor.Avalonia/Views/ChartView.axaml - Added sketch-edit mode indicator overlay
+- src/MotorEditor.Avalonia/Views/ChartView.axaml.cs - Added sketch-edit mouse interaction handling
+- src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml - Added per-series sketch-edit toggle button in column header
+- src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml.cs - Added sketch-edit toggle click handler and sibling refresh logic
+- tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs - Added 26 unit tests for sketch-edit functionality
+
+### Files Removed (0)
+
+(none)
+
+### Dependencies & Infrastructure
+
+- **New Dependencies**: None
+- **Updated Dependencies**: None
+- **Infrastructure Changes**: None
+- **Configuration Updates**: None
+
+### Deployment Notes
+
+No special deployment considerations. The sketch-edit feature is purely additive and does not change existing behavior.
diff --git a/.github/planning/phase-4-plan.md b/.github/planning/phase-4-plan.md
index 9f2a98a..3dfabec 100644
--- a/.github/planning/phase-4-plan.md
+++ b/.github/planning/phase-4-plan.md
@@ -3,12 +3,12 @@
### 0. Sketch-Style Curve Editing
- **Goal**: Enable simple, straightforward editing of a specified torque curve by clicking and dragging the mouse in the graph area.
- **Steps**:
- - [ ] Enable "Sketch edit" mode for a curve series. Only one curve series can be in sketch edit mode at a time.
- - [ ] Implement a manner to track mouse position over the chart area in near-real time, in terms of x (speed), and y (torque).
- - [ ] The speed component of the mouse position shall be rounded to the nearest speed data point in the chart field.
- - [ ] The torque component of the mouse position shall be rounded to the nearest 0.2; this value shall be set-able in preferences.
- - [ ] When the mouse is clicked while in the graph area, the curve with an active sketch-edit mode, will record the position of the mouse in the curve data, at the position of the x (speed) value in the dataset. E.g. if the mouse is nearest to 5% speed, and is nearest to 10.2 Nm and the user clicks, the curve series data should update such that at 5% speed that curve torque value is equal to 10.2 Nm.
- - [ ] The user shall be able to click and drag the mouse over the graph, with the positions of the active sketch-edit curve being written to the curve data series as the mouse moves. Hence, the user could "sketch" over an image motor curve, cleaning up slight data errors very easily.
+ - [X] Enable "Sketch edit" mode for a curve series. Only one curve series can be in sketch edit mode at a time.
+ - [X] Implement a manner to track mouse position over the chart area in near-real time, in terms of x (speed), and y (torque).
+ - [X] The speed component of the mouse position shall be rounded to the nearest speed data point in the chart field.
+ - [X] The torque component of the mouse position shall be rounded to the nearest 0.2; this value shall be set-able in preferences.
+ - [X] When the mouse is clicked while in the graph area, the curve with an active sketch-edit mode, will record the position of the mouse in the curve data, at the position of the x (speed) value in the dataset. E.g. if the mouse is nearest to 5% speed, and is nearest to 10.2 Nm and the user clicks, the curve series data should update such that at 5% speed that curve torque value is equal to 10.2 Nm.
+ - [X] The user shall be able to click and drag the mouse over the graph, with the positions of the active sketch-edit curve being written to the curve data series as the mouse moves. Hence, the user could "sketch" over an image motor curve, cleaning up slight data errors very easily.
### 1. EQ-Style Curve Editing
- **Goal**: Enable rich, EQ-style editing of torque curves directly on the chart while keeping the data grid and underlying models in sync.
diff --git a/src/MotorEditor.Avalonia/Models/UserPreferences.cs b/src/MotorEditor.Avalonia/Models/UserPreferences.cs
index 6c0fc04..4108f4b 100644
--- a/src/MotorEditor.Avalonia/Models/UserPreferences.cs
+++ b/src/MotorEditor.Avalonia/Models/UserPreferences.cs
@@ -43,6 +43,20 @@ public sealed class UserPreferences
///
public bool ShowVoltageMaxSpeedLine { get; set; } = true;
+ ///
+ /// Torque snap increment used during sketch editing. The torque value
+ /// recorded when the user sketches on the chart is rounded to the
+ /// nearest multiple of this value. Defaults to 0.2.
+ ///
+ public decimal TorqueSnapIncrement { get; set; } = 0.2m;
+
+ ///
+ /// Drag sketch band width as a percent of nearest point spacing.
+ /// Lower values require the pointer to stay closer to each point while
+ /// click-hold-drag sketching. Defaults to 5%.
+ ///
+ public decimal SketchDragBandPercent { get; set; } = 5m;
+
///
/// Creates a copy of the current preferences.
///
@@ -54,7 +68,9 @@ public UserPreferences Clone()
Theme = Theme,
CurveColors = new List(CurveColors),
ShowMotorRatedSpeedLine = ShowMotorRatedSpeedLine,
- ShowVoltageMaxSpeedLine = ShowVoltageMaxSpeedLine
+ ShowVoltageMaxSpeedLine = ShowVoltageMaxSpeedLine,
+ TorqueSnapIncrement = TorqueSnapIncrement,
+ SketchDragBandPercent = SketchDragBandPercent
};
}
}
diff --git a/src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs b/src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs
index 6afc511..6e1a90d 100644
--- a/src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs
+++ b/src/MotorEditor.Avalonia/ViewModels/ChartViewModel.cs
@@ -98,6 +98,17 @@ public partial class ChartViewModel : ViewModelBase
private string? _underlayImagePath;
private double _underlayAnchorX;
private double _underlayAnchorY;
+ private string? _sketchEditSeriesName;
+ private decimal _torqueSnapIncrement = 0.2m;
+ private double _dragSketchBandFraction = 0.05;
+ private bool _showTooltips = true;
+ private bool _tooltipStateBeforeSketch = true;
+ private double _zoomLevel = 1.0;
+ private bool _baseLimitsCaptured;
+ private double _baseXMin;
+ private double _baseXMax;
+ private double _baseYMin;
+ private double _baseYMax;
[ObservableProperty]
private ObservableCollection _series = [];
@@ -468,6 +479,38 @@ partial void OnShowVoltageMaxSpeedLineChanged(bool value)
///
public static bool EnableZoomPan => false;
+ ///
+ /// Gets or sets whether the data tooltip popup is shown when hovering
+ /// over data points on the chart. When disabled the tooltip is hidden.
+ /// This is automatically turned off when sketch-edit mode activates
+ /// and restored when sketch-edit mode deactivates.
+ ///
+ public bool ShowTooltips
+ {
+ get => _showTooltips;
+ set
+ {
+ if (_showTooltips == value)
+ {
+ return;
+ }
+
+ _showTooltips = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ChartTooltipPosition));
+ }
+ }
+
+ ///
+ /// Gets the effective
+ /// to bind to the chart control. Returns Hidden when tooltips are
+ /// disabled; otherwise returns Top.
+ ///
+ public LiveChartsCore.Measure.TooltipPosition ChartTooltipPosition =>
+ _showTooltips
+ ? LiveChartsCore.Measure.TooltipPosition.Top
+ : LiveChartsCore.Measure.TooltipPosition.Hidden;
+
///
/// Event raised when any series data point changes.
///
@@ -1693,4 +1736,622 @@ private double RoundToNiceValue(double value, bool roundUp, bool isPowerValue =
return result;
}
+
+ ///
+ /// Gets the name of the curve series currently in sketch-edit mode,
+ /// or null if no series is being sketch-edited.
+ /// Only one series can be in sketch-edit mode at a time.
+ ///
+ public string? SketchEditSeriesName
+ {
+ get => _sketchEditSeriesName;
+ private set
+ {
+ if (string.Equals(_sketchEditSeriesName, value, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ _sketchEditSeriesName = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsSketchEditActive));
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether sketch-edit mode is currently active
+ /// for any curve series.
+ ///
+ public bool IsSketchEditActive => !string.IsNullOrEmpty(_sketchEditSeriesName);
+
+ ///
+ /// Gets or sets the torque snap increment used during sketch editing.
+ /// The torque value is rounded to the nearest multiple of this value.
+ /// Must be greater than zero. Defaults to 0.2.
+ ///
+ public decimal TorqueSnapIncrement
+ {
+ get => _torqueSnapIncrement;
+ set
+ {
+ if (value <= 0)
+ {
+ return;
+ }
+
+ if (_torqueSnapIncrement == value)
+ {
+ return;
+ }
+
+ _torqueSnapIncrement = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the drag sketch activation band as a fraction of the
+ /// nearest point spacing. Used only during click-hold-drag sketching.
+ /// For example, 0.05 means 5% of spacing.
+ ///
+ public double DragSketchBandFraction
+ {
+ get => _dragSketchBandFraction;
+ set
+ {
+ if (value <= 0 || value > 1)
+ {
+ return;
+ }
+
+ if (Math.Abs(_dragSketchBandFraction - value) < 0.0001)
+ {
+ return;
+ }
+
+ _dragSketchBandFraction = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Activates sketch-edit mode for the specified curve series.
+ /// Any previously active sketch-edit series is deactivated first.
+ ///
+ /// The name of the curve series to edit.
+ /// Must match a series in the current voltage configuration.
+ public void SetSketchEditSeries(string seriesName)
+ {
+ if (string.IsNullOrWhiteSpace(seriesName))
+ {
+ return;
+ }
+
+ if (_currentVoltage is null)
+ {
+ return;
+ }
+
+ var curve = _currentVoltage.Curves.FirstOrDefault(c => c.Name == seriesName);
+ if (curve is null)
+ {
+ return;
+ }
+
+ // Locked curves cannot be sketch-edited.
+ if (curve.Locked)
+ {
+ return;
+ }
+
+ // Save the current tooltip state so we can restore it later,
+ // then hide tooltips while sketching.
+ if (!IsSketchEditActive)
+ {
+ _tooltipStateBeforeSketch = _showTooltips;
+ ShowTooltips = false;
+
+ // Only capture base limits if not already zoomed in; otherwise
+ // we would overwrite the real base with the zoomed-in limits,
+ // preventing the user from zooming back out.
+ if (Math.Abs(_zoomLevel - 1.0) < 0.001)
+ {
+ CaptureBaseAxisLimits();
+ }
+ }
+
+ SketchEditSeriesName = seriesName;
+ }
+
+ ///
+ /// Deactivates sketch-edit mode. Restores the tooltip visibility
+ /// to the state it had before sketch mode was activated.
+ /// Zoom state is preserved across sketch-mode toggling.
+ ///
+ public void ClearSketchEditSeries()
+ {
+ if (IsSketchEditActive)
+ {
+ ShowTooltips = _tooltipStateBeforeSketch;
+ }
+
+ SketchEditSeriesName = null;
+ }
+
+ ///
+ /// Applies a sketch point to the active sketch-edit curve series.
+ /// The value is snapped to the nearest
+ /// speed (RPM) data point in the series, and
+ /// is rounded to the nearest .
+ ///
+ /// The X coordinate in chart data space (RPM).
+ /// The Y coordinate in chart data space (torque).
+ /// true if a data point was modified; otherwise false.
+ public bool ApplySketchPoint(double chartX, double chartY)
+ {
+ return ApplySketchPoint(chartX, chartY, isDragStroke: false);
+ }
+
+ ///
+ /// Applies a sketch point to the active sketch-edit curve series.
+ /// The value is snapped to the nearest
+ /// speed (RPM) data point in the series, and
+ /// is rounded to the nearest .
+ /// When is true, sketch edits use a
+ /// narrower horizontal activation band so freehand dragging traces
+ /// curves more smoothly instead of snapping across wide point regions.
+ ///
+ /// The X coordinate in chart data space (RPM).
+ /// The Y coordinate in chart data space (torque).
+ /// True when the point is from a continuous drag operation.
+ /// true if a data point was modified; otherwise false.
+ public bool ApplySketchPoint(double chartX, double chartY, bool isDragStroke)
+ {
+ if (!IsSketchEditActive || _currentVoltage is null)
+ {
+ return false;
+ }
+
+ var curve = _currentVoltage.Curves.FirstOrDefault(c => c.Name == _sketchEditSeriesName);
+ if (curve is null || curve.Data.Count == 0)
+ {
+ return false;
+ }
+
+ if (curve.Locked)
+ {
+ ClearSketchEditSeries();
+ return false;
+ }
+
+ // Snap X to the nearest speed data point.
+ var nearestIndex = FindNearestSpeedIndex(curve, chartX);
+ if (nearestIndex < 0)
+ {
+ return false;
+ }
+
+ if (isDragStroke && !IsWithinDragSketchBand(curve, nearestIndex, chartX, _dragSketchBandFraction))
+ {
+ return false;
+ }
+
+ // Snap Y to the nearest torque increment.
+ var snappedTorque = SnapTorque((decimal)chartY, _torqueSnapIncrement);
+
+ var point = curve.Data[nearestIndex];
+
+ // No change needed if the torque is already at the snapped value.
+ if (point.Torque == snappedTorque)
+ {
+ return false;
+ }
+
+ if (_undoStack is not null)
+ {
+ var command = new EditPointCommand(curve, nearestIndex, point.Rpm, snappedTorque);
+ _undoStack.PushAndExecute(command);
+ }
+ else
+ {
+ point.Torque = snappedTorque;
+ }
+
+ // Update the cached observable point so the chart line updates
+ // without a full rebuild.
+ if (_seriesDataCache.TryGetValue(curve.Name, out var cachedPoints)
+ && nearestIndex < cachedPoints.Count)
+ {
+ cachedPoints[nearestIndex].Y = (double)snappedTorque;
+ }
+
+ DataChanged?.Invoke(this, EventArgs.Empty);
+ return true;
+ }
+
+ internal static bool IsWithinDragSketchBand(Curve curve, int index, double rpm, double bandFraction)
+ {
+ if (index < 0 || index >= curve.Data.Count)
+ {
+ return false;
+ }
+
+ if (bandFraction <= 0)
+ {
+ return false;
+ }
+
+ var targetRpm = (double)curve.Data[index].Rpm;
+ var nearestNeighborSpacing = GetNearestNeighborSpacing(curve, index);
+ if (nearestNeighborSpacing <= 0)
+ {
+ return true;
+ }
+
+ var maxDistance = nearestNeighborSpacing * bandFraction;
+ return Math.Abs(rpm - targetRpm) <= maxDistance;
+ }
+
+ private static double GetNearestNeighborSpacing(Curve curve, int index)
+ {
+ var hasLeft = index > 0;
+ var hasRight = index < curve.Data.Count - 1;
+
+ if (!hasLeft && !hasRight)
+ {
+ return 0;
+ }
+
+ var leftSpacing = hasLeft
+ ? Math.Abs((double)(curve.Data[index].Rpm - curve.Data[index - 1].Rpm))
+ : double.MaxValue;
+ var rightSpacing = hasRight
+ ? Math.Abs((double)(curve.Data[index + 1].Rpm - curve.Data[index].Rpm))
+ : double.MaxValue;
+
+ var spacing = Math.Min(leftSpacing, rightSpacing);
+ return spacing == double.MaxValue ? 0 : spacing;
+ }
+
+ ///
+ /// Finds the index of the data point in
+ /// whose RPM is closest to .
+ ///
+ internal static int FindNearestSpeedIndex(Curve curve, double rpm)
+ {
+ if (curve.Data.Count == 0)
+ {
+ return -1;
+ }
+
+ var bestIndex = 0;
+ var bestDist = Math.Abs((double)curve.Data[0].Rpm - rpm);
+
+ for (var i = 1; i < curve.Data.Count; i++)
+ {
+ var dist = Math.Abs((double)curve.Data[i].Rpm - rpm);
+ if (dist < bestDist)
+ {
+ bestDist = dist;
+ bestIndex = i;
+ }
+ }
+
+ return bestIndex;
+ }
+
+ ///
+ /// Rounds to the nearest multiple of
+ /// .
+ ///
+ internal static decimal SnapTorque(decimal torque, decimal increment)
+ {
+ if (increment <= 0)
+ {
+ return torque;
+ }
+
+ return Math.Round(torque / increment, MidpointRounding.AwayFromZero) * increment;
+ }
+
+ ///
+ /// Current chart zoom magnification level.
+ /// 1.0 means no zoom; values greater than 1.0 mean we are zoomed in.
+ ///
+ public double ZoomLevel => _zoomLevel;
+
+ ///
+ /// The base (unzoomed) X-axis minimum limit, captured before the
+ /// first zoom action. Used by the view to position the underlay
+ /// image correctly during zoom.
+ ///
+ public double BaseXMin => _baseXMin;
+
+ ///
+ /// The base (unzoomed) X-axis maximum limit.
+ ///
+ public double BaseXMax => _baseXMax;
+
+ ///
+ /// The base (unzoomed) Y-axis minimum limit.
+ ///
+ public double BaseYMin => _baseYMin;
+
+ ///
+ /// The base (unzoomed) Y-axis maximum limit.
+ ///
+ public double BaseYMax => _baseYMax;
+
+ ///
+ /// Whether the base axis limits have been captured for zoom calculations.
+ /// Used by the view to decide whether to apply zoom-based underlay scaling.
+ ///
+ public bool BaseLimitsCaptured => _baseLimitsCaptured;
+
+ ///
+ /// Zoom level expressed as a percentage for display (e.g. 100 = no zoom,
+ /// 200 = 2× magnification). Rounded to the nearest integer.
+ ///
+ public int ZoomPercentage => (int)Math.Round(_zoomLevel * 100.0);
+
+ ///
+ /// Zoom level as a slider-friendly value. Setting this adjusts the
+ /// zoom around the current viewport centre.
+ ///
+ public double ZoomSliderValue
+ {
+ get => _zoomLevel;
+ set
+ {
+ var clamped = Math.Clamp(value, 1.0, 20.0);
+ if (Math.Abs(_zoomLevel - clamped) < 0.001)
+ {
+ return;
+ }
+
+ if (XAxes.Length == 0 || YAxes.Length == 0)
+ {
+ return;
+ }
+
+ if (!_baseLimitsCaptured)
+ {
+ CaptureBaseAxisLimits();
+ }
+
+ _zoomLevel = clamped;
+
+ // Zoom around the centre of the current viewport.
+ var centreX = XAxes.Length > 0
+ ? ((XAxes[0].MinLimit ?? _baseXMin) + (XAxes[0].MaxLimit ?? _baseXMax)) / 2.0
+ : (_baseXMin + _baseXMax) / 2.0;
+ var centreY = YAxes.Length > 0
+ ? ((YAxes[0].MinLimit ?? _baseYMin) + (YAxes[0].MaxLimit ?? _baseYMax)) / 2.0
+ : (_baseYMin + _baseYMax) / 2.0;
+
+ ApplyZoomAroundPoint(centreX, centreY);
+ OnPropertyChanged(nameof(ZoomLevel));
+ OnPropertyChanged(nameof(ZoomPercentage));
+ OnPropertyChanged(nameof(ZoomSliderValue));
+ }
+ }
+
+ ///
+ /// Applies a zoom step relative to the given data-space focus point.
+ /// A positive zooms in; negative zooms out.
+ /// Works at any time when axes are present.
+ ///
+ /// Focus X in data space (RPM).
+ /// Focus Y in data space (Torque).
+ /// Zoom delta; positive zooms in, negative zooms out.
+ public void ApplyZoom(double focusX, double focusY, double delta)
+ {
+ if (XAxes.Length == 0 || YAxes.Length == 0)
+ {
+ return;
+ }
+
+ // Capture the base (unzoomed) axis limits on the first zoom action.
+ if (!_baseLimitsCaptured)
+ {
+ CaptureBaseAxisLimits();
+ }
+
+ // Compute the new zoom level with a clamped range [1, 20].
+ const double zoomFactor = 0.15;
+ var multiplier = delta > 0 ? (1.0 - zoomFactor) : (1.0 + zoomFactor);
+ _zoomLevel = Math.Clamp(_zoomLevel / multiplier, 1.0, 20.0);
+
+ ApplyZoomAroundPoint(focusX, focusY);
+ OnPropertyChanged(nameof(ZoomLevel));
+ OnPropertyChanged(nameof(ZoomPercentage));
+ OnPropertyChanged(nameof(ZoomSliderValue));
+ }
+
+ ///
+ /// Resets the chart zoom to the full (unzoomed) view.
+ /// Can be triggered by the user via = key or middle-double-click.
+ ///
+ public void ResetZoom()
+ {
+ if (Math.Abs(_zoomLevel - 1.0) < 0.001)
+ {
+ return;
+ }
+
+ _zoomLevel = 1.0;
+ _baseLimitsCaptured = false;
+
+ if (XAxes.Length > 0)
+ {
+ XAxes[0].MinLimit = _baseXMin;
+ XAxes[0].MaxLimit = _baseXMax;
+ }
+
+ if (YAxes.Length > 0)
+ {
+ YAxes[0].MinLimit = _baseYMin;
+ YAxes[0].MaxLimit = _baseYMax;
+ }
+
+ OnPropertyChanged(nameof(ZoomLevel));
+ OnPropertyChanged(nameof(ZoomPercentage));
+ OnPropertyChanged(nameof(ZoomSliderValue));
+ OnPropertyChanged(nameof(XAxes));
+ OnPropertyChanged(nameof(YAxes));
+ }
+
+ ///
+ /// Pans the zoomed viewport by the given data-space deltas.
+ /// Only effective when zoomed in (ZoomLevel > 1). The viewport
+ /// is clamped to the base axis limits.
+ ///
+ /// Pan amount in data-space X units (positive = pan right).
+ /// Pan amount in data-space Y units (positive = pan up).
+ public void PanBy(double deltaX, double deltaY)
+ {
+ if (Math.Abs(_zoomLevel - 1.0) < 0.001)
+ {
+ return;
+ }
+
+ if (XAxes.Length == 0 || YAxes.Length == 0)
+ {
+ return;
+ }
+
+ var curXMin = XAxes[0].MinLimit ?? _baseXMin;
+ var curXMax = XAxes[0].MaxLimit ?? _baseXMax;
+ var curYMin = YAxes[0].MinLimit ?? _baseYMin;
+ var curYMax = YAxes[0].MaxLimit ?? _baseYMax;
+
+ var newXMin = curXMin - deltaX;
+ var newXMax = curXMax - deltaX;
+ var newYMin = curYMin - deltaY;
+ var newYMax = curYMax - deltaY;
+
+ // Clamp to base limits.
+ if (newXMin < _baseXMin)
+ {
+ var shift = _baseXMin - newXMin;
+ newXMin += shift;
+ newXMax += shift;
+ }
+
+ if (newXMax > _baseXMax)
+ {
+ var shift = newXMax - _baseXMax;
+ newXMin -= shift;
+ newXMax -= shift;
+ }
+
+ if (newYMin < _baseYMin)
+ {
+ var shift = _baseYMin - newYMin;
+ newYMin += shift;
+ newYMax += shift;
+ }
+
+ if (newYMax > _baseYMax)
+ {
+ var shift = newYMax - _baseYMax;
+ newYMin -= shift;
+ newYMax -= shift;
+ }
+
+ XAxes[0].MinLimit = newXMin;
+ XAxes[0].MaxLimit = newXMax;
+ YAxes[0].MinLimit = newYMin;
+ YAxes[0].MaxLimit = newYMax;
+
+ OnPropertyChanged(nameof(XAxes));
+ OnPropertyChanged(nameof(YAxes));
+ }
+
+ ///
+ /// Captures the current axis limits as the baseline for zoom calculations.
+ ///
+ private void CaptureBaseAxisLimits()
+ {
+ if (XAxes.Length > 0)
+ {
+ _baseXMin = XAxes[0].MinLimit ?? 0;
+ _baseXMax = XAxes[0].MaxLimit ?? 6000;
+ }
+
+ if (YAxes.Length > 0)
+ {
+ _baseYMin = YAxes[0].MinLimit ?? 0;
+ _baseYMax = YAxes[0].MaxLimit ?? 100;
+ }
+
+ _baseLimitsCaptured = true;
+ }
+
+ ///
+ /// Recomputes the X and Y axis limits for the current zoom level
+ /// centred on the given data-space focus point.
+ ///
+ private void ApplyZoomAroundPoint(double focusX, double focusY)
+ {
+ var fullWidth = _baseXMax - _baseXMin;
+ var fullHeight = _baseYMax - _baseYMin;
+
+ var newWidth = fullWidth / _zoomLevel;
+ var newHeight = fullHeight / _zoomLevel;
+
+ // Fraction of the full range where the focus point sits.
+ var fx = fullWidth > 0 ? (focusX - _baseXMin) / fullWidth : 0.5;
+ var fy = fullHeight > 0 ? (focusY - _baseYMin) / fullHeight : 0.5;
+
+ fx = Math.Clamp(fx, 0, 1);
+ fy = Math.Clamp(fy, 0, 1);
+
+ var newXMin = focusX - fx * newWidth;
+ var newXMax = focusX + (1 - fx) * newWidth;
+ var newYMin = focusY - fy * newHeight;
+ var newYMax = focusY + (1 - fy) * newHeight;
+
+ // Clamp to base limits so the viewport never extends beyond
+ // the original data range.
+ if (newXMin < _baseXMin)
+ {
+ newXMin = _baseXMin;
+ newXMax = _baseXMin + newWidth;
+ }
+
+ if (newXMax > _baseXMax)
+ {
+ newXMax = _baseXMax;
+ newXMin = _baseXMax - newWidth;
+ }
+
+ if (newYMin < _baseYMin)
+ {
+ newYMin = _baseYMin;
+ newYMax = _baseYMin + newHeight;
+ }
+
+ if (newYMax > _baseYMax)
+ {
+ newYMax = _baseYMax;
+ newYMin = _baseYMax - newHeight;
+ }
+
+ if (XAxes.Length > 0)
+ {
+ XAxes[0].MinLimit = newXMin;
+ XAxes[0].MaxLimit = newXMax;
+ }
+
+ if (YAxes.Length > 0)
+ {
+ YAxes[0].MinLimit = newYMin;
+ YAxes[0].MaxLimit = newYMax;
+ }
+
+ // Notify so the chart and underlay update in sync.
+ OnPropertyChanged(nameof(XAxes));
+ OnPropertyChanged(nameof(YAxes));
+ }
}
diff --git a/src/MotorEditor.Avalonia/ViewModels/MainWindowViewModel.cs b/src/MotorEditor.Avalonia/ViewModels/MainWindowViewModel.cs
index 9b49dbe..be6c4c5 100644
--- a/src/MotorEditor.Avalonia/ViewModels/MainWindowViewModel.cs
+++ b/src/MotorEditor.Avalonia/ViewModels/MainWindowViewModel.cs
@@ -957,9 +957,11 @@ public MainWindowViewModel()
WireEditingCoordinator();
WireUndoInfrastructure();
WireDirectoryBrowserIntegration();
+ _userPreferencesService.PreferencesChanged += OnUserPreferencesChanged;
// Load saved power curves preference
chartViewModel.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false);
+ ApplyUserPreferencesToChart(chartViewModel);
}
///
@@ -1010,9 +1012,11 @@ public MainWindowViewModel(IUserPreferencesService userPreferencesService)
WireEditingCoordinator();
WireUndoInfrastructure();
WireDirectoryBrowserIntegration();
+ _userPreferencesService.PreferencesChanged += OnUserPreferencesChanged;
// Load saved power curves preference
chartViewModel.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false);
+ ApplyUserPreferencesToChart(chartViewModel);
}
public MainWindowViewModel(IFileService fileService, ICurveGeneratorService curveGeneratorService)
@@ -1053,9 +1057,11 @@ public MainWindowViewModel(IFileService fileService, ICurveGeneratorService curv
WireEditingCoordinator();
WireUndoInfrastructure();
WireDirectoryBrowserIntegration();
+ _userPreferencesService.PreferencesChanged += OnUserPreferencesChanged;
// Load saved power curves preference
chartViewModel.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false);
+ ApplyUserPreferencesToChart(chartViewModel);
}
///
@@ -1107,11 +1113,13 @@ public MainWindowViewModel(
WireEditingCoordinator();
WireUndoInfrastructure();
WireDirectoryBrowserIntegration();
+ _userPreferencesService.PreferencesChanged += OnUserPreferencesChanged;
// Load saved preferences
ChartViewModel!.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false);
ChartViewModel!.ShowMotorRatedSpeedLine = _settingsStore.LoadBool("ShowMotorRatedSpeedLine", true);
ChartViewModel!.ShowVoltageMaxSpeedLine = _settingsStore.LoadBool("ShowVoltageMaxSpeedLine", true);
+ ApplyUserPreferencesToChart(ChartViewModel);
}
private void WireDirectoryBrowserIntegration()
@@ -1172,6 +1180,7 @@ private DocumentTab CreateNewTab()
tab.ChartViewModel.ShowPowerCurves = _settingsStore.LoadBool("ShowPowerCurves", false);
tab.ChartViewModel.ShowMotorRatedSpeedLine = _settingsStore.LoadBool("ShowMotorRatedSpeedLine", true);
tab.ChartViewModel.ShowVoltageMaxSpeedLine = _settingsStore.LoadBool("ShowVoltageMaxSpeedLine", true);
+ ApplyUserPreferencesToChart(tab.ChartViewModel);
tab.ChartViewModel.DataChanged += (s, e) => tab.MarkDirty();
tab.CurveDataTableViewModel.DataChanged += (s, e) =>
@@ -3903,6 +3912,13 @@ private void ToggleSeriesLock(Curve? series)
ActiveTab?.UndoStack.PushAndExecute(command);
UpdateDirtyFromUndoDepth();
+ if (newLocked &&
+ ChartViewModel is not null &&
+ string.Equals(ChartViewModel.SketchEditSeriesName, series.Name, StringComparison.Ordinal))
+ {
+ ChartViewModel.ClearSketchEditSeries();
+ }
+
// Refresh the curve data table so that the DataGrid columns
// are rebuilt with the correct read-only state for the
// affected series. This keeps the editor behavior and
@@ -4009,6 +4025,37 @@ private async Task ShowPreferencesAsync()
}
}
+ private void OnUserPreferencesChanged(object? sender, EventArgs e)
+ {
+ foreach (var tab in Tabs)
+ {
+ if (tab.ChartViewModel is not null)
+ {
+ ApplyUserPreferencesToChart(tab.ChartViewModel);
+ }
+ }
+ }
+
+ private void ApplyUserPreferencesToChart(ChartViewModel? chartViewModel)
+ {
+ if (chartViewModel is null)
+ {
+ return;
+ }
+
+ var snapIncrement = _userPreferencesService.Preferences.TorqueSnapIncrement;
+ if (snapIncrement > 0)
+ {
+ chartViewModel.TorqueSnapIncrement = snapIncrement;
+ }
+
+ var dragBandPercent = _userPreferencesService.Preferences.SketchDragBandPercent;
+ if (dragBandPercent > 0)
+ {
+ chartViewModel.DragSketchBandFraction = (double)(dragBandPercent / 100m);
+ }
+ }
+
///
/// Called when any property on the motor changes.
///
diff --git a/src/MotorEditor.Avalonia/ViewModels/PreferencesViewModel.cs b/src/MotorEditor.Avalonia/ViewModels/PreferencesViewModel.cs
index f59ccb9..467a1ed 100644
--- a/src/MotorEditor.Avalonia/ViewModels/PreferencesViewModel.cs
+++ b/src/MotorEditor.Avalonia/ViewModels/PreferencesViewModel.cs
@@ -26,6 +26,12 @@ public partial class PreferencesViewModel : ViewModelBase
[ObservableProperty]
private string? _selectedCurveColor;
+ [ObservableProperty]
+ private decimal _torqueSnapIncrement;
+
+ [ObservableProperty]
+ private decimal _sketchDragBandPercent;
+
///
/// Creates a new PreferencesViewModel.
///
@@ -38,6 +44,8 @@ public PreferencesViewModel(IUserPreferencesService preferencesService)
_decimalPrecision = _preferencesService.Preferences.DecimalPrecision;
_theme = _preferencesService.Preferences.Theme;
_curveColors = new ObservableCollection(_preferencesService.Preferences.CurveColors);
+ _torqueSnapIncrement = _preferencesService.Preferences.TorqueSnapIncrement;
+ _sketchDragBandPercent = _preferencesService.Preferences.SketchDragBandPercent;
}
///
@@ -54,6 +62,8 @@ private void Save()
_preferencesService.Preferences.CurveColors.Add(color);
}
+ _preferencesService.Preferences.TorqueSnapIncrement = TorqueSnapIncrement;
+ _preferencesService.Preferences.SketchDragBandPercent = SketchDragBandPercent;
_preferencesService.SavePreferences();
}
diff --git a/src/MotorEditor.Avalonia/Views/ChartView.axaml b/src/MotorEditor.Avalonia/Views/ChartView.axaml
index a2fab72..a9c174f 100644
--- a/src/MotorEditor.Avalonia/Views/ChartView.axaml
+++ b/src/MotorEditor.Avalonia/Views/ChartView.axaml
@@ -32,7 +32,7 @@
Series="{Binding Series}"
XAxes="{Binding XAxes}"
YAxes="{Binding YAxes}"
- TooltipPosition="Top"
+ TooltipPosition="{Binding ChartTooltipPosition}"
ZoomMode="None"
AnimationsSpeed="00:00:00.000">
@@ -74,6 +74,44 @@
VerticalAlignment="Top"
Margin="0,20,0,0"
IsHitTestVisible="False"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MotorEditor.Avalonia/Views/ChartView.axaml.cs b/src/MotorEditor.Avalonia/Views/ChartView.axaml.cs
index 360c989..e70ca50 100644
--- a/src/MotorEditor.Avalonia/Views/ChartView.axaml.cs
+++ b/src/MotorEditor.Avalonia/Views/ChartView.axaml.cs
@@ -23,12 +23,22 @@ public partial class ChartView : UserControl
{
private ChartViewModel? _chartViewModel;
private bool _isUnderlayDragging;
+ private bool _isSketchDragging;
+ private bool _isPanning;
+ private Point _panStart;
private Point _dragStart;
private double _startOffsetX;
private double _startOffsetY;
private Rect _underlayBounds = new();
private bool _underlayLayoutQueued;
+ ///
+ /// Tracks the last known pointer position over the chart in
+ /// data-space coordinates, used as the focus point for keyboard
+ /// zoom (+/- keys).
+ ///
+ private Point _lastPointerDataPosition;
+
///
/// Creates a new ChartView instance.
///
@@ -43,11 +53,25 @@ public ChartView()
TorqueChart.LayoutUpdated += (_, _) => QueueUnderlayLayout();
TorqueChart.UpdateFinished += _ => QueueUnderlayLayout();
+ // Use tunneling (handledEventsToo: true) for wheel events so we
+ // intercept them before LiveCharts processes them internally.
+ TorqueChart.AddHandler(
+ PointerWheelChangedEvent,
+ OnChartPointerWheelChanged,
+ Avalonia.Interactivity.RoutingStrategies.Tunnel,
+ handledEventsToo: true);
+
// Handle mouse clicks on the chart to support basic point
// selection. This wiring keeps the interaction logic in the
// view while delegating selection state to the EditingCoordinator
// via the ChartViewModel.
TorqueChart.PointerPressed += OnChartPointerPressed;
+
+ // Wire keyboard zoom to the chart control itself and ensure
+ // it can receive focus when the pointer enters the chart area.
+ TorqueChart.Focusable = true;
+ TorqueChart.KeyDown += OnChartKeyDown;
+ TorqueChart.PointerEntered += OnChartPointerEntered;
}
protected override void OnDataContextChanged(EventArgs e)
@@ -67,6 +91,15 @@ protected override void OnDataContextChanged(EventArgs e)
QueueUnderlayLayout();
}
+ ///
+ /// Gives the chart control keyboard focus when the pointer enters,
+ /// so that +/- zoom keys work without requiring a click first.
+ ///
+ private void OnChartPointerEntered(object? sender, PointerEventArgs e)
+ {
+ TorqueChart.Focus();
+ }
+
private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (TryBeginUnderlayDrag(e))
@@ -81,11 +114,45 @@ private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e)
}
var pointerPoint = e.GetCurrentPoint(TorqueChart);
+
+ // Middle-button double-click resets zoom to unzoomed view.
+ if (pointerPoint.Properties.IsMiddleButtonPressed && e.ClickCount == 2)
+ {
+ vm.ResetZoom();
+ QueueUnderlayLayout();
+ e.Handled = true;
+ return;
+ }
+
+ // Middle-button single press begins a pan drag.
+ if (pointerPoint.Properties.IsMiddleButtonPressed && e.ClickCount <= 1)
+ {
+ _isPanning = true;
+ _panStart = e.GetPosition(TorqueChart);
+ e.Pointer.Capture(TorqueChart);
+ e.Handled = true;
+ return;
+ }
+
if (!pointerPoint.Properties.IsLeftButtonPressed)
{
return;
}
+ // When sketch-edit mode is active, apply the sketch point
+ // and begin tracking the drag.
+ if (vm.IsSketchEditActive)
+ {
+ if (TryApplySketchAtPixel(vm, e.GetPosition(TorqueChart)))
+ {
+ _isSketchDragging = true;
+ e.Pointer.Capture(TorqueChart);
+ e.Handled = true;
+ }
+
+ return;
+ }
+
// Use the underlying chart to find the nearest point under the
// cursor within a reasonable radius.
var chart = TorqueChart.CoreChart;
@@ -143,7 +210,44 @@ private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e)
private void OnChartPointerMoved(object? sender, PointerEventArgs e)
{
- if (!_isUnderlayDragging || _chartViewModel is null)
+ if (_chartViewModel is null)
+ {
+ return;
+ }
+
+ // Track the data-space position for keyboard zoom focus.
+ var pixelPos = e.GetPosition(TorqueChart);
+ if (TryPixelToDataSpace(pixelPos, out var dataX, out var dataY))
+ {
+ _lastPointerDataPosition = new Point(dataX, dataY);
+ }
+
+ // Middle-mouse pan: convert pixel delta to data-space delta.
+ if (_isPanning)
+ {
+ var currentPos = e.GetPosition(TorqueChart);
+ var pixelDelta = currentPos - _panStart;
+ _panStart = currentPos;
+
+ if (TryPixelDeltaToDataDelta(pixelDelta, out var dataDeltaX, out var dataDeltaY))
+ {
+ _chartViewModel.PanBy(dataDeltaX, dataDeltaY);
+ QueueUnderlayLayout();
+ }
+
+ e.Handled = true;
+ return;
+ }
+
+ // Continue sketch-edit drag: apply sketch point as the mouse moves.
+ if (_isSketchDragging && _chartViewModel.IsSketchEditActive)
+ {
+ TryApplySketchAtPixel(_chartViewModel, pixelPos, isDragStroke: true);
+ e.Handled = true;
+ return;
+ }
+
+ if (!_isUnderlayDragging)
{
return;
}
@@ -175,6 +279,22 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
private void OnChartPointerReleased(object? sender, PointerEventArgs e)
{
+ if (_isPanning)
+ {
+ _isPanning = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ return;
+ }
+
+ if (_isSketchDragging)
+ {
+ _isSketchDragging = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ return;
+ }
+
if (!_isUnderlayDragging)
{
return;
@@ -188,6 +308,8 @@ private void OnChartPointerReleased(object? sender, PointerEventArgs e)
private void OnChartPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
{
_isUnderlayDragging = false;
+ _isSketchDragging = false;
+ _isPanning = false;
}
private void OnChartViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -245,6 +367,232 @@ _chartViewModel.UnderlayImage is null ||
return true;
}
+ ///
+ /// Converts a pixel position on the chart to data-space coordinates
+ /// and forwards it to the view model's sketch-edit logic.
+ ///
+ private bool TryApplySketchAtPixel(ChartViewModel vm, Point pixelPosition, bool isDragStroke = false)
+ {
+ var chart = TorqueChart.CoreChart;
+ if (chart is null)
+ {
+ return false;
+ }
+
+ var drawLocation = chart.DrawMarginLocation;
+ var drawSize = chart.DrawMarginSize;
+ if (drawSize.Width <= 0 || drawSize.Height <= 0)
+ {
+ return false;
+ }
+
+ // Only apply when the pointer is within the draw margin area.
+ if (pixelPosition.X < drawLocation.X ||
+ pixelPosition.X > drawLocation.X + drawSize.Width ||
+ pixelPosition.Y < drawLocation.Y ||
+ pixelPosition.Y > drawLocation.Y + drawSize.Height)
+ {
+ return false;
+ }
+
+ // Use the primary X and Y axes to convert pixel to data coordinates.
+ var xAxes = vm.XAxes;
+ var yAxes = vm.YAxes;
+ if (xAxes.Length == 0 || yAxes.Length == 0)
+ {
+ return false;
+ }
+
+ var xAxis = xAxes[0];
+ var yAxis = yAxes[0];
+
+ var xMin = xAxis.MinLimit ?? 0;
+ var xMax = xAxis.MaxLimit ?? 1;
+ var yMin = yAxis.MinLimit ?? 0;
+ var yMax = yAxis.MaxLimit ?? 1;
+
+ if (xMax - xMin <= 0 || yMax - yMin <= 0)
+ {
+ return false;
+ }
+
+ // Linear interpolation from pixel space to data space.
+ var chartX = xMin + (pixelPosition.X - drawLocation.X) / drawSize.Width * (xMax - xMin);
+ var chartY = yMax - (pixelPosition.Y - drawLocation.Y) / drawSize.Height * (yMax - yMin);
+
+ return vm.ApplySketchPoint(chartX, chartY, isDragStroke);
+ }
+
+ ///
+ /// Converts a pixel position on the chart to data-space coordinates
+ /// using the current axis limits.
+ ///
+ private bool TryPixelToDataSpace(Point pixelPosition, out double dataX, out double dataY)
+ {
+ dataX = 0;
+ dataY = 0;
+
+ if (_chartViewModel is null)
+ {
+ return false;
+ }
+
+ var chart = TorqueChart.CoreChart;
+ if (chart is null)
+ {
+ return false;
+ }
+
+ var drawLocation = chart.DrawMarginLocation;
+ var drawSize = chart.DrawMarginSize;
+ if (drawSize.Width <= 0 || drawSize.Height <= 0)
+ {
+ return false;
+ }
+
+ var xAxes = _chartViewModel.XAxes;
+ var yAxes = _chartViewModel.YAxes;
+ if (xAxes.Length == 0 || yAxes.Length == 0)
+ {
+ return false;
+ }
+
+ var xMin = xAxes[0].MinLimit ?? 0;
+ var xMax = xAxes[0].MaxLimit ?? 1;
+ var yMin = yAxes[0].MinLimit ?? 0;
+ var yMax = yAxes[0].MaxLimit ?? 1;
+
+ if (xMax - xMin <= 0 || yMax - yMin <= 0)
+ {
+ return false;
+ }
+
+ dataX = xMin + (pixelPosition.X - drawLocation.X) / drawSize.Width * (xMax - xMin);
+ dataY = yMax - (pixelPosition.Y - drawLocation.Y) / drawSize.Height * (yMax - yMin);
+ return true;
+ }
+
+ ///
+ /// Converts a pixel delta (movement) to data-space delta for panning.
+ ///
+ private bool TryPixelDeltaToDataDelta(Point pixelDelta, out double deltaX, out double deltaY)
+ {
+ deltaX = 0;
+ deltaY = 0;
+
+ if (_chartViewModel is null)
+ {
+ return false;
+ }
+
+ var chart = TorqueChart.CoreChart;
+ if (chart is null)
+ {
+ return false;
+ }
+
+ var drawSize = chart.DrawMarginSize;
+ if (drawSize.Width <= 0 || drawSize.Height <= 0)
+ {
+ return false;
+ }
+
+ var xAxes = _chartViewModel.XAxes;
+ var yAxes = _chartViewModel.YAxes;
+ if (xAxes.Length == 0 || yAxes.Length == 0)
+ {
+ return false;
+ }
+
+ var xRange = (xAxes[0].MaxLimit ?? 1) - (xAxes[0].MinLimit ?? 0);
+ var yRange = (yAxes[0].MaxLimit ?? 1) - (yAxes[0].MinLimit ?? 0);
+
+ if (xRange <= 0 || yRange <= 0)
+ {
+ return false;
+ }
+
+ deltaX = pixelDelta.X / drawSize.Width * xRange;
+ // Y axis is inverted: positive pixel delta = downward = negative data Y.
+ deltaY = -(pixelDelta.Y / drawSize.Height * yRange);
+ return true;
+ }
+
+ ///
+ /// Handles Ctrl+mouse wheel / Ctrl+touchpad scroll to zoom the chart.
+ /// Also handles Ctrl+touchpad pinch-to-zoom (reported as wheel events
+ /// with Ctrl modifier on most platforms).
+ ///
+ private void OnChartPointerWheelChanged(object? sender, PointerWheelEventArgs e)
+ {
+ if (_chartViewModel is null)
+ {
+ return;
+ }
+
+ // Only zoom when the Ctrl key is held.
+ if (!e.KeyModifiers.HasFlag(KeyModifiers.Control))
+ {
+ return;
+ }
+
+ var pixelPos = e.GetPosition(TorqueChart);
+ if (!TryPixelToDataSpace(pixelPos, out var focusX, out var focusY))
+ {
+ return;
+ }
+
+ // Delta.Y is positive for scroll-up (zoom in) and negative for scroll-down (zoom out).
+ _chartViewModel.ApplyZoom(focusX, focusY, e.Delta.Y);
+ QueueUnderlayLayout();
+ e.Handled = true;
+ }
+
+ ///
+ /// Handles keyboard zoom: + to zoom in, - to zoom out,
+ /// = to reset zoom to the unzoomed view.
+ ///
+ private void OnChartKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (_chartViewModel is null)
+ {
+ return;
+ }
+
+ // + key (Shift+= on most keyboards) or numpad + — zoom in.
+ if ((e.Key is Key.OemPlus && e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+ || e.Key is Key.Add)
+ {
+ _chartViewModel.ApplyZoom(
+ _lastPointerDataPosition.X,
+ _lastPointerDataPosition.Y,
+ 1.0);
+ QueueUnderlayLayout();
+ e.Handled = true;
+ return;
+ }
+
+ // - key or numpad - — zoom out.
+ if (e.Key is Key.OemMinus or Key.Subtract)
+ {
+ _chartViewModel.ApplyZoom(
+ _lastPointerDataPosition.X,
+ _lastPointerDataPosition.Y,
+ -1.0);
+ QueueUnderlayLayout();
+ e.Handled = true;
+ return;
+ }
+
+ // = key (OemPlus without Shift) — reset zoom.
+ if (e.Key is Key.OemPlus && !e.KeyModifiers.HasFlag(KeyModifiers.Shift))
+ {
+ _chartViewModel.ResetZoom();
+ QueueUnderlayLayout();
+ e.Handled = true;
+ }
+ }
+
private void QueueUnderlayLayout()
{
if (_underlayLayoutQueued)
@@ -291,11 +639,79 @@ private void UpdateUnderlayLayoutCore()
return;
}
- var imageWidth = drawSize.Width * _chartViewModel.UnderlayXScale;
- var imageHeight = drawSize.Height * _chartViewModel.UnderlayYScale;
-
- var x = drawLocation.X + (_chartViewModel.UnderlayOffsetX * drawSize.Width);
- var y = drawLocation.Y + drawSize.Height - imageHeight - (_chartViewModel.UnderlayOffsetY * drawSize.Height);
+ // Base image size and position in the unzoomed coordinate system.
+ var baseImageWidth = drawSize.Width * _chartViewModel.UnderlayXScale;
+ var baseImageHeight = drawSize.Height * _chartViewModel.UnderlayYScale;
+
+ var baseX = drawLocation.X + (_chartViewModel.UnderlayOffsetX * drawSize.Width);
+ var baseY = drawLocation.Y + drawSize.Height - baseImageHeight
+ - (_chartViewModel.UnderlayOffsetY * drawSize.Height);
+
+ double imageWidth;
+ double imageHeight;
+ double x;
+ double y;
+
+ // When zoom is active and base limits have been captured, scale
+ // the underlay image to keep it aligned with the chart data.
+ // The draw-margin pixel area stays the same but represents a
+ // narrower data range, so the image must grow proportionally
+ // and shift so that the visible slice matches.
+ if (_chartViewModel.BaseLimitsCaptured && _chartViewModel.ZoomLevel > 1.001)
+ {
+ var xAxes = _chartViewModel.XAxes;
+ var yAxes = _chartViewModel.YAxes;
+ if (xAxes.Length > 0 && yAxes.Length > 0)
+ {
+ var baseXRange = _chartViewModel.BaseXMax - _chartViewModel.BaseXMin;
+ var baseYRange = _chartViewModel.BaseYMax - _chartViewModel.BaseYMin;
+ var curXMin = xAxes[0].MinLimit ?? _chartViewModel.BaseXMin;
+ var curXMax = xAxes[0].MaxLimit ?? _chartViewModel.BaseXMax;
+ var curYMin = yAxes[0].MinLimit ?? _chartViewModel.BaseYMin;
+ var curYMax = yAxes[0].MaxLimit ?? _chartViewModel.BaseYMax;
+ var curXRange = curXMax - curXMin;
+ var curYRange = curYMax - curYMin;
+
+ if (curXRange > 0 && curYRange > 0 && baseXRange > 0 && baseYRange > 0)
+ {
+ var xZoom = baseXRange / curXRange;
+ var yZoom = baseYRange / curYRange;
+
+ imageWidth = baseImageWidth * xZoom;
+ imageHeight = baseImageHeight * yZoom;
+
+ // Shift so the image data alignment is preserved.
+ // The base image left edge corresponds to a data-space X
+ // offset. We need to map that into the zoomed pixel space.
+ x = drawLocation.X + (baseX - drawLocation.X) * xZoom
+ - (curXMin - _chartViewModel.BaseXMin) / baseXRange * drawSize.Width * xZoom;
+ // Y axis is inverted (top of draw area = max data value).
+ y = drawLocation.Y + (baseY - drawLocation.Y) * yZoom
+ - (_chartViewModel.BaseYMax - curYMax) / baseYRange * drawSize.Height * yZoom;
+ }
+ else
+ {
+ imageWidth = baseImageWidth;
+ imageHeight = baseImageHeight;
+ x = baseX;
+ y = baseY;
+ }
+ }
+ else
+ {
+ imageWidth = baseImageWidth;
+ imageHeight = baseImageHeight;
+ x = baseX;
+ y = baseY;
+ }
+ }
+ else
+ {
+ imageWidth = baseImageWidth;
+ imageHeight = baseImageHeight;
+ x = baseX;
+ y = baseY;
+ }
Canvas.SetLeft(UnderlayImage, x);
Canvas.SetTop(UnderlayImage, y);
diff --git a/src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml b/src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml
index 8c2134a..ea20dcd 100644
--- a/src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml
+++ b/src/MotorEditor.Avalonia/Views/CurveDataPanel.axaml
@@ -122,7 +122,7 @@
HorizontalAlignment="Center"
Cursor="Hand"
DoubleTapped="OnSeriesNameDoubleTapped"/>
-
+
@@ -143,7 +143,20 @@
-
+
+
+
+
+
+
+
+
+ private void OnSketchEditToggleClick(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not ToggleButton toggleButton || toggleButton.DataContext is not Curve series)
+ {
+ return;
+ }
+
+ if (DataContext is not MainWindowViewModel viewModel || viewModel.ChartViewModel is null)
+ {
+ return;
+ }
+
+ // Locked curves cannot be sketch-edited.
+ if (series.Locked)
+ {
+ toggleButton.IsChecked = false;
+ return;
+ }
+
+ var chart = viewModel.ChartViewModel;
+
+ if (string.Equals(chart.SketchEditSeriesName, series.Name, StringComparison.Ordinal))
+ {
+ // Already the active sketch series — deactivate.
+ chart.ClearSketchEditSeries();
+ toggleButton.IsChecked = false;
+ }
+ else
+ {
+ // Activate for this series (replaces any previous).
+ chart.SetSketchEditSeries(series.Name);
+ toggleButton.IsChecked = true;
+ }
+
+ // Uncheck sibling sketch-edit toggle buttons so only one is active.
+ RefreshSketchEditToggles(chart.SketchEditSeriesName);
+ }
+
+ ///
+ /// Walks the series header to ensure only the toggle button for
+ /// is checked.
+ ///
+ private void RefreshSketchEditToggles(string? activeSeriesName)
+ {
+ var headerScrollViewer = this.FindControl("HeaderScrollViewer");
+ if (headerScrollViewer is null)
+ {
+ return;
+ }
+
+ foreach (var toggle in headerScrollViewer.GetVisualDescendants().OfType())
+ {
+ if (toggle.DataContext is not Curve curve)
+ {
+ continue;
+ }
+
+ // Identify sketch-edit toggles by tooltip text to avoid
+ // interfering with other toggle buttons in the template.
+ if (toggle.GetValue(ToolTip.TipProperty) is not string tip ||
+ !string.Equals(tip, "Toggle Sketch Edit", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ toggle.IsChecked = string.Equals(curve.Name, activeSeriesName, StringComparison.Ordinal);
+ }
+ }
+
///
/// Handles delete series button click.
///
diff --git a/src/MotorEditor.Avalonia/Views/MainWindow.axaml b/src/MotorEditor.Avalonia/Views/MainWindow.axaml
index d0dbeaf..b1ffe7e 100644
--- a/src/MotorEditor.Avalonia/Views/MainWindow.axaml
+++ b/src/MotorEditor.Avalonia/Views/MainWindow.axaml
@@ -308,6 +308,9 @@
IsChecked="{Binding ChartViewModel.ShowVoltageMaxSpeedLine, Mode=TwoWay}"
Command="{Binding ToggleShowVoltageMaxSpeedLineCommand}"
CommandParameter="{Binding IsChecked, RelativeSource={RelativeSource Self}}"/>
+
diff --git a/src/MotorEditor.Avalonia/Views/PreferencesWindow.axaml b/src/MotorEditor.Avalonia/Views/PreferencesWindow.axaml
index 62bec4e..5787ffe 100644
--- a/src/MotorEditor.Avalonia/Views/PreferencesWindow.axaml
+++ b/src/MotorEditor.Avalonia/Views/PreferencesWindow.axaml
@@ -77,6 +77,38 @@
MinWidth="100"/>
+
+
+
+
+
+
+
+
+
+
+
+
{ "#FF0000", "#00FF00" }
+ CurveColors = new List { "#FF0000", "#00FF00" },
+ SketchDragBandPercent = 5m
};
var clone = original.Clone();
@@ -162,11 +163,13 @@ public void UserPreferences_Clone_CreatesIndependentCopy()
clone.DecimalPrecision = 5;
clone.Theme = "Light";
clone.CurveColors.Add("#0000FF");
+ clone.SketchDragBandPercent = 12m;
// Original should remain unchanged
Assert.Equal(3, original.DecimalPrecision);
Assert.Equal("Dark", original.Theme);
Assert.Equal(2, original.CurveColors.Count);
+ Assert.Equal(5m, original.SketchDragBandPercent);
}
[Fact]
diff --git a/tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs b/tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs
index 60ebe8f..60f4fde 100644
--- a/tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs
+++ b/tests/CurveEditor.Tests/ViewModels/ChartViewModelTests.cs
@@ -996,4 +996,608 @@ public void TorqueAxisLabels_UseWholeNumbersForLargeValues(string torqueUnit)
Assert.DoesNotContain(".", label100);
Assert.DoesNotContain(".", label50);
}
+
+ // --- Sketch-edit tests ---
+
+ [Fact]
+ public void SetSketchEditSeries_WithValidName_ActivatesSketchEdit()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ viewModel.SetSketchEditSeries("Peak");
+
+ Assert.True(viewModel.IsSketchEditActive);
+ Assert.Equal("Peak", viewModel.SketchEditSeriesName);
+ }
+
+ [Fact]
+ public void SetSketchEditSeries_WithInvalidName_DoesNotActivate()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ viewModel.SetSketchEditSeries("NonExistent");
+
+ Assert.False(viewModel.IsSketchEditActive);
+ Assert.Null(viewModel.SketchEditSeriesName);
+ }
+
+ [Fact]
+ public void SetSketchEditSeries_WithNullVoltage_DoesNotActivate()
+ {
+ var viewModel = new ChartViewModel();
+
+ viewModel.SetSketchEditSeries("Peak");
+
+ Assert.False(viewModel.IsSketchEditActive);
+ }
+
+ [Fact]
+ public void ClearSketchEditSeries_DeactivatesSketchEdit()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ viewModel.SetSketchEditSeries("Peak");
+
+ viewModel.ClearSketchEditSeries();
+
+ Assert.False(viewModel.IsSketchEditActive);
+ Assert.Null(viewModel.SketchEditSeriesName);
+ }
+
+ [Fact]
+ public void SetSketchEditSeries_ReplacesExisting()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ viewModel.SetSketchEditSeries("Peak");
+
+ viewModel.SetSketchEditSeries("Continuous");
+
+ Assert.Equal("Continuous", viewModel.SketchEditSeriesName);
+ }
+
+ [Theory]
+ [InlineData(10.0, 0.2, 10.0)]
+ [InlineData(10.09, 0.2, 10.0)]
+ [InlineData(10.15, 0.2, 10.2)]
+ [InlineData(10.3, 0.2, 10.4)]
+ [InlineData(10.5, 0.5, 10.5)]
+ [InlineData(10.6, 0.5, 10.5)]
+ [InlineData(10.75, 0.5, 11.0)]
+ [InlineData(10.0, 1.0, 10.0)]
+ [InlineData(10.4, 1.0, 10.0)]
+ [InlineData(10.5, 1.0, 11.0)]
+ public void SnapTorque_RoundsToNearestIncrement(double torque, double increment, double expected)
+ {
+ var result = ChartViewModel.SnapTorque((decimal)torque, (decimal)increment);
+
+ Assert.Equal((decimal)expected, result);
+ }
+
+ [Fact]
+ public void SnapTorque_WithZeroIncrement_ReturnsTorqueUnchanged()
+ {
+ var result = ChartViewModel.SnapTorque(10.3m, 0m);
+
+ Assert.Equal(10.3m, result);
+ }
+
+ [Fact]
+ public void FindNearestSpeedIndex_ReturnsClosestIndex()
+ {
+ var curve = new Curve("Test");
+ curve.InitializeData(1000, 50);
+ // Data points at 0%, 1%, 2%… of 1000 RPM → 0, 10, 20, 30…
+
+ var index = ChartViewModel.FindNearestSpeedIndex(curve, 25);
+
+ // Closest data point to 25 RPM is at index 3 (30 RPM) or index 2 (20 RPM).
+ // 25 is equidistant between 20 and 30, but the algorithm picks the first found with lower distance.
+ // At 20 RPM dist=5, at 30 RPM dist=5. Since loop iterates forward, the first match (index 2) stays.
+ Assert.Equal(2, index); // 20 RPM (closest or tied)
+ }
+
+ [Fact]
+ public void FindNearestSpeedIndex_WithEmptyCurve_ReturnsNegativeOne()
+ {
+ var curve = new Curve("Empty");
+
+ var index = ChartViewModel.FindNearestSpeedIndex(curve, 100);
+
+ Assert.Equal(-1, index);
+ }
+
+ [Fact]
+ public void ApplySketchPoint_WhenNotActive_ReturnsFalse()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ var result = viewModel.ApplySketchPoint(100, 50);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void ApplySketchPoint_WhenActive_UpdatesTorqueAndReturnsTrue()
+ {
+ var viewModel = new ChartViewModel();
+ var voltage = CreateTestVoltage();
+ viewModel.CurrentVoltage = voltage;
+ viewModel.SetSketchEditSeries("Peak");
+
+ // Pick an RPM that is close to a known data point.
+ var peakCurve = voltage.Curves.First(c => c.Name == "Peak");
+ var targetRpm = (double)peakCurve.Data[5].Rpm; // 5% of 5000 = 250 RPM
+ var newTorque = 42.3; // Should snap to 42.4 (nearest 0.2)
+
+ var result = viewModel.ApplySketchPoint(targetRpm, newTorque);
+
+ Assert.True(result);
+ Assert.Equal(42.4m, peakCurve.Data[5].Torque);
+ }
+
+ [Fact]
+ public void ApplySketchPoint_WithCustomSnapIncrement_SnapsCorrectly()
+ {
+ var viewModel = new ChartViewModel();
+ var voltage = CreateTestVoltage();
+ viewModel.CurrentVoltage = voltage;
+ viewModel.TorqueSnapIncrement = 0.5m;
+ viewModel.SetSketchEditSeries("Peak");
+
+ var peakCurve = voltage.Curves.First(c => c.Name == "Peak");
+ var targetRpm = (double)peakCurve.Data[10].Rpm;
+ var newTorque = 42.3;
+
+ viewModel.ApplySketchPoint(targetRpm, newTorque);
+
+ Assert.Equal(42.5m, peakCurve.Data[10].Torque);
+ }
+
+ [Fact]
+ public void ApplySketchPoint_WhenActiveSeriesLocked_ReturnsFalseAndClearsSketchMode()
+ {
+ var viewModel = new ChartViewModel();
+ var voltage = CreateTestVoltage();
+ viewModel.CurrentVoltage = voltage;
+ viewModel.SetSketchEditSeries("Peak");
+
+ var peakCurve = voltage.Curves.First(c => c.Name == "Peak");
+ peakCurve.Locked = true;
+
+ var result = viewModel.ApplySketchPoint((double)peakCurve.Data[5].Rpm, 42.3);
+
+ Assert.False(result);
+ Assert.False(viewModel.IsSketchEditActive);
+ Assert.Null(viewModel.SketchEditSeriesName);
+ }
+
+ [Fact]
+ public void ApplySketchPoint_DragStrokeOutsideNarrowBand_DoesNotUpdateTorque()
+ {
+ var viewModel = new ChartViewModel();
+ var voltage = CreateTestVoltage();
+ viewModel.CurrentVoltage = voltage;
+ viewModel.TorqueSnapIncrement = 0.1m;
+ viewModel.SetSketchEditSeries("Peak");
+
+ var peakCurve = voltage.Curves.First(c => c.Name == "Peak");
+ var index = 10;
+ var originalTorque = peakCurve.Data[index].Torque;
+ var outsideBandRpm = (double)peakCurve.Data[index].Rpm + 3d;
+
+ var result = viewModel.ApplySketchPoint(outsideBandRpm, 11.7, isDragStroke: true);
+
+ Assert.False(result);
+ Assert.Equal(originalTorque, peakCurve.Data[index].Torque);
+ }
+
+ [Fact]
+ public void ApplySketchPoint_DragStrokeInsideNarrowBand_UpdatesTorque()
+ {
+ var viewModel = new ChartViewModel();
+ var voltage = CreateTestVoltage();
+ viewModel.CurrentVoltage = voltage;
+ viewModel.TorqueSnapIncrement = 0.1m;
+ viewModel.SetSketchEditSeries("Peak");
+
+ var peakCurve = voltage.Curves.First(c => c.Name == "Peak");
+ var index = 10;
+ var insideBandRpm = (double)peakCurve.Data[index].Rpm + 2d;
+
+ var result = viewModel.ApplySketchPoint(insideBandRpm, 11.7, isDragStroke: true);
+
+ Assert.True(result);
+ Assert.Equal(11.7m, peakCurve.Data[index].Torque);
+ }
+
+ [Fact]
+ public void DragSketchBandFraction_DefaultsToFivePercent()
+ {
+ var viewModel = new ChartViewModel();
+
+ Assert.Equal(0.05, viewModel.DragSketchBandFraction, 3);
+ }
+
+ [Fact]
+ public void DragSketchBandFraction_RejectsInvalidValues()
+ {
+ var viewModel = new ChartViewModel();
+
+ viewModel.DragSketchBandFraction = 0;
+ Assert.Equal(0.05, viewModel.DragSketchBandFraction, 3);
+
+ viewModel.DragSketchBandFraction = 1.5;
+ Assert.Equal(0.05, viewModel.DragSketchBandFraction, 3);
+ }
+
+ [Fact]
+ public void ApplySketchPoint_RaisesDataChanged()
+ {
+ var viewModel = new ChartViewModel();
+ var voltage = CreateTestVoltage();
+ viewModel.CurrentVoltage = voltage;
+ viewModel.SetSketchEditSeries("Peak");
+ var eventRaised = false;
+ viewModel.DataChanged += (_, _) => eventRaised = true;
+
+ var peakCurve = voltage.Curves.First(c => c.Name == "Peak");
+ var targetRpm = (double)peakCurve.Data[5].Rpm;
+ viewModel.ApplySketchPoint(targetRpm, 42.3);
+
+ Assert.True(eventRaised);
+ }
+
+ [Fact]
+ public void ApplySketchPoint_WhenTorqueAlreadyAtSnappedValue_ReturnsFalse()
+ {
+ var viewModel = new ChartViewModel();
+ var voltage = CreateTestVoltage();
+ viewModel.CurrentVoltage = voltage;
+ viewModel.SetSketchEditSeries("Peak");
+
+ var peakCurve = voltage.Curves.First(c => c.Name == "Peak");
+ // Set the target torque to the current snapped value.
+ var currentTorque = peakCurve.Data[5].Torque;
+ var targetRpm = (double)peakCurve.Data[5].Rpm;
+
+ var result = viewModel.ApplySketchPoint(targetRpm, (double)currentTorque);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void TorqueSnapIncrement_DefaultsToPointTwo()
+ {
+ var viewModel = new ChartViewModel();
+
+ Assert.Equal(0.2m, viewModel.TorqueSnapIncrement);
+ }
+
+ [Fact]
+ public void TorqueSnapIncrement_RejectsZeroOrNegative()
+ {
+ var viewModel = new ChartViewModel();
+
+ viewModel.TorqueSnapIncrement = 0m;
+ Assert.Equal(0.2m, viewModel.TorqueSnapIncrement);
+
+ viewModel.TorqueSnapIncrement = -1m;
+ Assert.Equal(0.2m, viewModel.TorqueSnapIncrement);
+ }
+
+ [Fact]
+ public void SetSketchEditSeries_WithWhitespaceName_DoesNotActivate()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ viewModel.SetSketchEditSeries(" ");
+
+ Assert.False(viewModel.IsSketchEditActive);
+ }
+
+ // --- Tooltip toggle tests ---
+
+ [Fact]
+ public void ShowTooltips_DefaultsToTrue()
+ {
+ var viewModel = new ChartViewModel();
+
+ Assert.True(viewModel.ShowTooltips);
+ }
+
+ [Fact]
+ public void ChartTooltipPosition_ReturnsTopWhenEnabled()
+ {
+ var viewModel = new ChartViewModel();
+
+ Assert.Equal(LiveChartsCore.Measure.TooltipPosition.Top, viewModel.ChartTooltipPosition);
+ }
+
+ [Fact]
+ public void ChartTooltipPosition_ReturnsHiddenWhenDisabled()
+ {
+ var viewModel = new ChartViewModel();
+
+ viewModel.ShowTooltips = false;
+
+ Assert.Equal(LiveChartsCore.Measure.TooltipPosition.Hidden, viewModel.ChartTooltipPosition);
+ }
+
+ [Fact]
+ public void SetSketchEditSeries_HidesTooltipsAutomatically()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ Assert.True(viewModel.ShowTooltips);
+
+ viewModel.SetSketchEditSeries("Peak");
+
+ Assert.False(viewModel.ShowTooltips);
+ }
+
+ [Fact]
+ public void ClearSketchEditSeries_RestoresTooltipState()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ viewModel.SetSketchEditSeries("Peak");
+ Assert.False(viewModel.ShowTooltips);
+
+ viewModel.ClearSketchEditSeries();
+
+ Assert.True(viewModel.ShowTooltips);
+ }
+
+ [Fact]
+ public void ClearSketchEditSeries_RestoresTooltipToFalseIfWasDisabledBeforeSketch()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ viewModel.ShowTooltips = false;
+ viewModel.SetSketchEditSeries("Peak");
+
+ viewModel.ClearSketchEditSeries();
+
+ Assert.False(viewModel.ShowTooltips);
+ }
+
+ // --- Sketch zoom tests ---
+
+ [Fact]
+ public void ZoomLevel_DefaultsToOne()
+ {
+ var viewModel = new ChartViewModel();
+
+ Assert.Equal(1.0, viewModel.ZoomLevel);
+ }
+
+ [Fact]
+ public void ApplyZoom_WithoutSketchMode_StillZooms()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ viewModel.ApplyZoom(100, 50, 1.0);
+
+ Assert.True(viewModel.ZoomLevel > 1.0);
+ }
+
+ [Fact]
+ public void ApplyZoom_WhenActive_IncreasesZoomLevel()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ viewModel.SetSketchEditSeries("Peak");
+
+ viewModel.ApplyZoom(2500, 27, 1.0);
+
+ Assert.True(viewModel.ZoomLevel > 1.0);
+ }
+
+ [Fact]
+ public void ApplyZoom_ZoomOutDoesNotGoBelowOne()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ viewModel.ApplyZoom(2500, 27, -1.0);
+
+ Assert.Equal(1.0, viewModel.ZoomLevel);
+ }
+
+ [Fact]
+ public void ResetZoom_RestoresZoomLevelToOne()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ viewModel.ApplyZoom(2500, 27, 1.0);
+ Assert.True(viewModel.ZoomLevel > 1.0);
+
+ viewModel.ResetZoom();
+
+ Assert.Equal(1.0, viewModel.ZoomLevel);
+ }
+
+ [Fact]
+ public void ClearSketchEditSeries_PreservesZoom()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ viewModel.SetSketchEditSeries("Peak");
+ viewModel.ApplyZoom(2500, 27, 1.0);
+ Assert.True(viewModel.ZoomLevel > 1.0);
+
+ viewModel.ClearSketchEditSeries();
+
+ Assert.True(viewModel.ZoomLevel > 1.0);
+ }
+
+ [Fact]
+ public void ApplyZoom_NarrowsAxisLimits()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ var originalXMax = viewModel.XAxes[0].MaxLimit;
+ var originalYMax = viewModel.YAxes[0].MaxLimit;
+
+ viewModel.ApplyZoom(2500, 27, 1.0);
+
+ // After zooming in, the visible range should be narrower.
+ var newXRange = (viewModel.XAxes[0].MaxLimit ?? 0) - (viewModel.XAxes[0].MinLimit ?? 0);
+ var originalXRange = (originalXMax ?? 0) - 0;
+ Assert.True(newXRange < originalXRange);
+ }
+
+ [Fact]
+ public void SetSketchEditSeries_WithLockedCurve_DoesNotActivate()
+ {
+ var viewModel = new ChartViewModel();
+ var voltage = CreateTestVoltage();
+ var peak = voltage.Curves.First(c => c.Name == "Peak");
+ peak.Locked = true;
+ viewModel.CurrentVoltage = voltage;
+
+ viewModel.SetSketchEditSeries("Peak");
+
+ Assert.False(viewModel.IsSketchEditActive);
+ Assert.Null(viewModel.SketchEditSeriesName);
+ }
+
+ [Fact]
+ public void SetSketchEditSeries_WithUnlockedCurve_Activates()
+ {
+ var viewModel = new ChartViewModel();
+ var voltage = CreateTestVoltage();
+ viewModel.CurrentVoltage = voltage;
+
+ viewModel.SetSketchEditSeries("Peak");
+
+ Assert.True(viewModel.IsSketchEditActive);
+ Assert.Equal("Peak", viewModel.SketchEditSeriesName);
+ }
+
+ [Fact]
+ public void ZoomPercentage_DefaultsTo100()
+ {
+ var viewModel = new ChartViewModel();
+
+ Assert.Equal(100, viewModel.ZoomPercentage);
+ }
+
+ [Fact]
+ public void ZoomPercentage_IncreasesAfterZoomIn()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ viewModel.ApplyZoom(2500, 27, 1.0);
+
+ Assert.True(viewModel.ZoomPercentage > 100);
+ }
+
+ [Fact]
+ public void ZoomSliderValue_ControlsZoomLevel()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ viewModel.ZoomSliderValue = 5.0;
+
+ Assert.Equal(5.0, viewModel.ZoomLevel, 2);
+ Assert.Equal(500, viewModel.ZoomPercentage);
+ }
+
+ [Fact]
+ public void ZoomSliderValue_ClampedToRange()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ viewModel.ZoomSliderValue = 25.0;
+
+ Assert.Equal(20.0, viewModel.ZoomLevel, 2);
+ }
+
+ [Fact]
+ public void PanBy_WhenNotZoomed_DoesNothing()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ var originalXMin = viewModel.XAxes[0].MinLimit;
+
+ viewModel.PanBy(100, 100);
+
+ Assert.Equal(originalXMin, viewModel.XAxes[0].MinLimit);
+ }
+
+ [Fact]
+ public void PanBy_WhenZoomed_ShiftsAxisLimits()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ viewModel.ApplyZoom(2500, 27, 1.0);
+ var xMinBefore = viewModel.XAxes[0].MinLimit ?? 0;
+
+ viewModel.PanBy(100, 0);
+
+ var xMinAfter = viewModel.XAxes[0].MinLimit ?? 0;
+ Assert.True(xMinAfter < xMinBefore);
+ }
+
+ [Fact]
+ public void SetSketchEditSeries_WhileZoomed_DoesNotOverwriteBaseLimits()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ var originalXMax = viewModel.XAxes[0].MaxLimit;
+
+ // Zoom in first.
+ viewModel.ApplyZoom(2500, 27, 1.0);
+ Assert.True(viewModel.ZoomLevel > 1.0);
+
+ // Activate sketch mode while zoomed — should not corrupt base limits.
+ viewModel.SetSketchEditSeries("Peak");
+
+ // Should still be able to reset zoom to the original full view.
+ viewModel.ResetZoom();
+ Assert.Equal(1.0, viewModel.ZoomLevel);
+ Assert.Equal(originalXMax, viewModel.XAxes[0].MaxLimit);
+ }
+
+ [Fact]
+ public void BaseLimitsCaptured_FalseByDefault()
+ {
+ var viewModel = new ChartViewModel();
+
+ Assert.False(viewModel.BaseLimitsCaptured);
+ }
+
+ [Fact]
+ public void BaseLimitsCaptured_TrueAfterZoom()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+
+ viewModel.ApplyZoom(2500, 27, 1.0);
+
+ Assert.True(viewModel.BaseLimitsCaptured);
+ }
+
+ [Fact]
+ public void BaseLimitsCaptured_FalseAfterReset()
+ {
+ var viewModel = new ChartViewModel();
+ viewModel.CurrentVoltage = CreateTestVoltage();
+ viewModel.ApplyZoom(2500, 27, 1.0);
+
+ viewModel.ResetZoom();
+
+ Assert.False(viewModel.BaseLimitsCaptured);
+ }
}
diff --git a/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelTests.cs
index cdc99be..397d273 100644
--- a/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelTests.cs
+++ b/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelTests.cs
@@ -1,7 +1,9 @@
using CurveEditor.Services;
using CurveEditor.ViewModels;
using JordanRobot.MotorDefinition.Model;
+using MotorEditor.Avalonia.Models;
using Moq;
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Xunit;
@@ -130,4 +132,46 @@ public void OnSelectedDriveChanged_ClearsSelectedVoltageWhenDriveIsNull()
Assert.Empty(vm.AvailableVoltages);
}
+ [Fact]
+ public void Constructor_WithPreferencesService_AppliesTorqueSnapIncrementToChart()
+ {
+ var preferences = new UserPreferences
+ {
+ TorqueSnapIncrement = 0.03m,
+ SketchDragBandPercent = 5m
+ };
+
+ var preferencesServiceMock = new Mock();
+ preferencesServiceMock.SetupGet(s => s.Preferences).Returns(preferences);
+ preferencesServiceMock.SetupGet(s => s.State).Returns(new ApplicationState());
+
+ var vm = new MainWindowViewModel(preferencesServiceMock.Object);
+
+ Assert.Equal(0.03m, vm.ChartViewModel!.TorqueSnapIncrement);
+ Assert.Equal(0.05, vm.ChartViewModel.DragSketchBandFraction, 3);
+ }
+
+ [Fact]
+ public void PreferencesChanged_WhenTorqueSnapIncrementUpdated_PropagatesToChart()
+ {
+ var preferences = new UserPreferences
+ {
+ TorqueSnapIncrement = 0.03m,
+ SketchDragBandPercent = 5m
+ };
+
+ var preferencesServiceMock = new Mock();
+ preferencesServiceMock.SetupGet(s => s.Preferences).Returns(preferences);
+ preferencesServiceMock.SetupGet(s => s.State).Returns(new ApplicationState());
+
+ var vm = new MainWindowViewModel(preferencesServiceMock.Object);
+
+ preferences.TorqueSnapIncrement = 0.07m;
+ preferences.SketchDragBandPercent = 8m;
+ preferencesServiceMock.Raise(s => s.PreferencesChanged += null, EventArgs.Empty);
+
+ Assert.Equal(0.07m, vm.ChartViewModel!.TorqueSnapIncrement);
+ Assert.Equal(0.08, vm.ChartViewModel.DragSketchBandFraction, 3);
+ }
+
}
diff --git a/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelUndoRedoIntegrationTests.cs b/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelUndoRedoIntegrationTests.cs
index 62d2e90..bae37c2 100644
--- a/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelUndoRedoIntegrationTests.cs
+++ b/tests/CurveEditor.Tests/ViewModels/MainWindowViewModelUndoRedoIntegrationTests.cs
@@ -232,4 +232,57 @@ public void ToggleSeriesLock_ThenUndoRedo_RevertsLockedState()
Assert.True(series.Locked);
}
+
+ [Fact]
+ public void ToggleSeriesLock_WhenSketchEditActive_ClearsSketchEditMode()
+ {
+ var fileServiceMock = new Mock();
+ var curveGeneratorMock = new Mock();
+
+ fileServiceMock.SetupGet(f => f.IsDirty).Returns(false);
+
+ var vm = new MainWindowViewModel(fileServiceMock.Object, curveGeneratorMock.Object);
+
+ var motor = new ServoMotor
+ {
+ MotorName = "Test Motor",
+ Drives = new List
+ {
+ new()
+ {
+ Name = "Drive A",
+ Voltages = new List
+ {
+ new()
+ {
+ Value = 208,
+ Curves = new List
+ {
+ new()
+ {
+ Name = "Peak",
+ Locked = false
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ vm.CurrentMotor = motor;
+ vm.SelectedDrive = motor.Drives[0];
+ vm.SelectedVoltage = motor.Drives[0].Voltages[0];
+
+ var series = motor.Drives[0].Voltages[0].Curves[0];
+ vm.ChartViewModel.SetSketchEditSeries(series.Name);
+
+ Assert.True(vm.ChartViewModel.IsSketchEditActive);
+
+ vm.ToggleSeriesLockCommand.Execute(series);
+
+ Assert.True(series.Locked);
+ Assert.False(vm.ChartViewModel.IsSketchEditActive);
+ Assert.Null(vm.ChartViewModel.SketchEditSeriesName);
+ }
}
diff --git a/tests/CurveEditor.Tests/ViewModels/PreferencesViewModelTests.cs b/tests/CurveEditor.Tests/ViewModels/PreferencesViewModelTests.cs
index fd38168..bd39905 100644
--- a/tests/CurveEditor.Tests/ViewModels/PreferencesViewModelTests.cs
+++ b/tests/CurveEditor.Tests/ViewModels/PreferencesViewModelTests.cs
@@ -51,11 +51,24 @@ public void SaveCommand_UpdatesPreferences()
var viewModel = new PreferencesViewModel(_preferencesService);
viewModel.DecimalPrecision = 4;
viewModel.Theme = "Dark";
+ viewModel.SketchDragBandPercent = 7m;
viewModel.SaveCommand.Execute(null);
Assert.Equal(4, _preferencesService.Preferences.DecimalPrecision);
Assert.Equal("Dark", _preferencesService.Preferences.Theme);
+ Assert.Equal(7m, _preferencesService.Preferences.SketchDragBandPercent);
+ }
+
+ [Fact]
+ public void Constructor_LoadsSketchDragBandPercent()
+ {
+ _preferencesService.Preferences.SketchDragBandPercent = 6.5m;
+ _preferencesService.SavePreferences();
+
+ var viewModel = new PreferencesViewModel(_preferencesService);
+
+ Assert.Equal(6.5m, viewModel.SketchDragBandPercent);
}
[Fact]