From 888650aedc6fc7e75222422e9ee856cbe29a4de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beto=20Rodr=C3=ADguez?= Date: Sun, 26 Apr 2026 11:38:24 -0600 Subject: [PATCH 1/6] fix(zoom): split ZoomAndPanMode into per-gesture flags Adds PanX, ZoomX, PanY, ZoomY so users can enable zoom while disabling pan (or vice versa). The existing X, Y and Both names are preserved as composites (X = PanX | ZoomX, Y = PanY | ZoomY), so code or XAML that references them by name keeps the same pan+zoom semantics. The CartesianChartEngine now checks the granular flag for each gesture: Zoom and zoom-by-section consult ZoomX/ZoomY, Pan and the pointer-up bounce-back consult PanX/PanY, and the post-zoom fit consults ZoomX/ZoomY. Closes #2175 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/LiveChartsCore/CartesianChartEngine.cs | 38 ++++++++-------- src/LiveChartsCore/Measure/ZoomAndPanMode.cs | 48 +++++++++++++++----- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/src/LiveChartsCore/CartesianChartEngine.cs b/src/LiveChartsCore/CartesianChartEngine.cs index 0d9474602..3d010e225 100644 --- a/src/LiveChartsCore/CartesianChartEngine.cs +++ b/src/LiveChartsCore/CartesianChartEngine.cs @@ -144,7 +144,7 @@ public double[] ScaleUIPoint(LvcPoint point, int xAxisIndex = 0, int yAxisIndex /// Zooms at the specified pivot. /// /// - /// The flags, for example ZoomAndPanMode.X | ZoomAndPanMode.NoFit, will zoom only in the x axis + /// The flags, for example ZoomAndPanMode.ZoomX | ZoomAndPanMode.NoFit, will zoom only in the x axis /// and will ignore the fit to bounds feature. /// /// The pivot, is the reference point, the center where the zoom operation is calculated. @@ -164,11 +164,11 @@ public void Zoom(ZoomAndPanMode flags, LvcPoint pivot, ZoomDirection direction, $"When the scale factor is defined, the zoom direction must be {nameof(ZoomDirection.DefinedByScaleFactor)}... " + $"it just makes sense."); - if (flags.HasFlag(ZoomAndPanMode.X)) + if (flags.HasFlag(ZoomAndPanMode.ZoomX)) foreach (var axis in XAxes) ZoomAxis(axis, flags, pivot.X, direction, scaleFactor); - if (flags.HasFlag(ZoomAndPanMode.Y)) + if (flags.HasFlag(ZoomAndPanMode.ZoomY)) foreach (var axis in YAxes) ZoomAxis(axis, flags, pivot.Y, direction, scaleFactor); @@ -179,17 +179,17 @@ public void Zoom(ZoomAndPanMode flags, LvcPoint pivot, ZoomDirection direction, /// Pans with the specified delta. /// /// - /// The flags, for example ZoomAndPanMode.X | ZoomAndPanMode.NoFit, will pan only in the x axis + /// The flags, for example ZoomAndPanMode.PanX | ZoomAndPanMode.NoFit, will pan only in the x axis /// and will ignore the fit to bounds feature. /// /// The delta. public void Pan(ZoomAndPanMode flags, LvcPoint delta) { - if (flags.HasFlag(ZoomAndPanMode.X)) + if (flags.HasFlag(ZoomAndPanMode.PanX)) foreach (var axis in XAxes) PanAxis(axis, flags, delta.X, true); - if (flags.HasFlag(ZoomAndPanMode.Y)) + if (flags.HasFlag(ZoomAndPanMode.PanY)) foreach (var axis in YAxes) PanAxis(axis, flags, delta.Y, true); } @@ -198,14 +198,14 @@ public void Pan(ZoomAndPanMode flags, LvcPoint delta) /// Starts a zooming section operation at the specified point. /// /// - /// The flags, for example ZoomAndPanMode.X | ZoomAndPanMode.NoFit, will zoom only in the x axis + /// The flags, for example ZoomAndPanMode.ZoomX | ZoomAndPanMode.NoFit, will zoom only in the x axis /// and will ignore the fit to bounds feature. /// /// The point to start the panning operation. public void StartZoomingSection(ZoomAndPanMode flags, LvcPoint point) { - var xMode = (flags & ZoomAndPanMode.X) == ZoomAndPanMode.X; - var yMode = (flags & ZoomAndPanMode.Y) == ZoomAndPanMode.Y; + var xMode = flags.HasFlag(ZoomAndPanMode.ZoomX); + var yMode = flags.HasFlag(ZoomAndPanMode.ZoomY); if (flags.HasFlag(ZoomAndPanMode.NoZoomBySection) || (!xMode && !yMode)) return; @@ -249,7 +249,7 @@ public void StartZoomingSection(ZoomAndPanMode flags, LvcPoint point) /// in the UI, it does not apply the zoom yet. /// /// - /// The flags, for example ZoomAndPanMode.X | ZoomAndPanMode.NoFit, will zoom only in the x axis + /// The flags, for example ZoomAndPanMode.ZoomX | ZoomAndPanMode.NoFit, will zoom only in the x axis /// and will ignore the fit to bounds feature. /// /// The point. @@ -257,8 +257,8 @@ public void GrowZoomingSection(ZoomAndPanMode flags, LvcPoint point) { if (_zoomingSection is null || _sectionZoomingStart is null) return; - var xMode = (flags & ZoomAndPanMode.X) == ZoomAndPanMode.X; - var yMode = (flags & ZoomAndPanMode.Y) == ZoomAndPanMode.Y; + var xMode = flags.HasFlag(ZoomAndPanMode.ZoomX); + var yMode = flags.HasFlag(ZoomAndPanMode.ZoomY); var x = point.X; var y = point.Y; @@ -279,7 +279,7 @@ public void GrowZoomingSection(ZoomAndPanMode flags, LvcPoint point) /// End the zooming section operation at the specified point, and applies the zoom. /// /// - /// The flags, for example ZoomAndPanMode.X | ZoomAndPanMode.NoFit, will zoom only in the x axis + /// The flags, for example ZoomAndPanMode.ZoomX | ZoomAndPanMode.NoFit, will zoom only in the x axis /// and will ignore the fit to bounds feature. /// /// The point. @@ -301,11 +301,11 @@ public void EndZoomingSection(ZoomAndPanMode flags, LvcPoint point) return; } - if ((flags & ZoomAndPanMode.X) == ZoomAndPanMode.X) + if (flags.HasFlag(ZoomAndPanMode.ZoomX)) foreach (var axis in XAxes) ZoomAxisBySection(axis, point.X); - if ((flags & ZoomAndPanMode.Y) == ZoomAndPanMode.Y) + if (flags.HasFlag(ZoomAndPanMode.ZoomY)) foreach (var axis in YAxes) ZoomAxisBySection(axis, point.Y); @@ -944,10 +944,10 @@ protected internal override void InvokePointerUp(LvcPoint point, bool isSecondar var fits = !flags.HasFlag(ZoomAndPanMode.NoFit); if (fits) { - if (flags.HasFlag(ZoomAndPanMode.X)) + if (flags.HasFlag(ZoomAndPanMode.PanX)) foreach (var axis in XAxes) PanAxis(axis, flags, 0, false); - if (flags.HasFlag(ZoomAndPanMode.Y)) + if (flags.HasFlag(ZoomAndPanMode.PanY)) foreach (var axis in YAxes) PanAxis(axis, flags, 0, false); } @@ -1009,11 +1009,11 @@ void Fit(ICartesianAxis axis) axis.SetLimits(min, max); } - if (flags.HasFlag(ZoomAndPanMode.X)) + if (flags.HasFlag(ZoomAndPanMode.ZoomX)) foreach (var axis in XAxes) Fit(axis); - if (flags.HasFlag(ZoomAndPanMode.Y)) + if (flags.HasFlag(ZoomAndPanMode.ZoomY)) foreach (var axis in YAxes) Fit(axis); } diff --git a/src/LiveChartsCore/Measure/ZoomAndPanMode.cs b/src/LiveChartsCore/Measure/ZoomAndPanMode.cs index dbf04d01f..1539958b7 100644 --- a/src/LiveChartsCore/Measure/ZoomAndPanMode.cs +++ b/src/LiveChartsCore/Measure/ZoomAndPanMode.cs @@ -1,4 +1,4 @@ -// The MIT License(MIT) +// The MIT License(MIT) // // Copyright(c) 2021 Alberto Rodriguez Orozco & LiveCharts Contributors // @@ -36,33 +36,59 @@ public enum ZoomAndPanMode None = 0, /// - /// Enables zooming and panning on the X axis and enables fitting to bounds. + /// Enables panning on the X axis. /// - X = 1 << 0, + PanX = 1 << 0, /// - /// Enables zooming and panning on the Y axis and enables fitting to bounds. + /// Enables zooming on the X axis (wheel, pinch and zoom by section). + /// + ZoomX = 1 << 1, + + /// + /// Enables panning on the Y axis. + /// + PanY = 1 << 2, + + /// + /// Enables zooming on the Y axis (wheel, pinch and zoom by section). /// - Y = 1 << 1, + ZoomY = 1 << 3, /// /// Disables data bounds fitting when zooming or panning, this flag must be used in conjunction with - /// , , or to have an effect. + /// any of the pan/zoom flags (, , , , + /// , , or ) to have an effect. /// - NoFit = 1 << 2, + NoFit = 1 << 4, /// /// Disables the "Zoom by section" feature, which allows zooming in on a specific section of the chart. /// - NoZoomBySection = 1 << 3, + NoZoomBySection = 1 << 5, /// /// When this flag is present the panning will be triggered using the right click on desktop devices and the touch-and-hold gesture on touch devices. /// The "Zoom by section" feature will be triggered to the left click on desktop devices and the touch-and-hold gesture on touch devices, - /// this flag must be used in conjunction with - /// , , or to have an effect. + /// this flag must be used in conjunction with any of the pan/zoom flags + /// (, , , , + /// , , or ) to have an effect. + /// + InvertPanningPointerTrigger = 1 << 6, + + /// + /// Enables zooming and panning on the X axis and enables fitting to bounds. + /// Equivalent to | . To enable only one + /// gesture on the X axis use or directly. + /// + X = PanX | ZoomX, + + /// + /// Enables zooming and panning on the Y axis and enables fitting to bounds. + /// Equivalent to | . To enable only one + /// gesture on the Y axis use or directly. /// - InvertPanningPointerTrigger = 1 << 4, + Y = PanY | ZoomY, /// /// Enables zooming and panning on both axes and enables fitting to bounds. From d9570366861ad54983e04ada8bdb4cfdb3156733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beto=20Rodr=C3=ADguez?= Date: Sun, 26 Apr 2026 11:38:30 -0600 Subject: [PATCH 2/6] test(zoom): cover granular ZoomAndPanMode flag isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that ZoomX/PanX (and Y counterparts) only act on their gesture, that mismatched calls are no-ops (Pan with ZoomX-only does nothing, Zoom with PanX-only does nothing), and that the composite X flag still pans and zooms — covering the back-compat guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OtherTests/ChartInteractiveApiTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tests/CoreTests/OtherTests/ChartInteractiveApiTests.cs b/tests/CoreTests/OtherTests/ChartInteractiveApiTests.cs index ef99ba59c..944bb8e87 100644 --- a/tests/CoreTests/OtherTests/ChartInteractiveApiTests.cs +++ b/tests/CoreTests/OtherTests/ChartInteractiveApiTests.cs @@ -131,6 +131,134 @@ public void CartesianZoomingSection_NoZoomBySectionFlagIsNoOp() new LvcPoint(100, 100)); } + private static (SKCartesianChart chart, Axis xAxis, Axis yAxis, CartesianChartEngine core) CreatePinnedChart() + { + // MinLimit/MaxLimit are pinned so Zoom/Pan operations leave a measurable change + // on the axis limits without the auto-fit logic snapping things back. + var xAxis = new Axis { MinLimit = 0, MaxLimit = 10 }; + var yAxis = new Axis { MinLimit = 0, MaxLimit = 10 }; + + var chart = new SKCartesianChart + { + Width = 400, + Height = 400, + XAxes = [xAxis], + YAxes = [yAxis], + Series = [ new LineSeries { Values = [1d, 2d, 3d, 4d, 5d, 6d, 7d, 8d, 9d, 10d] } ] + }; + + _ = chart.GetImage(); + var core = (CartesianChartEngine)chart.CoreChart; + return (chart, xAxis, yAxis, core); + } + + [TestMethod] + public void Zoom_WithZoomXOnly_AffectsOnlyXAxis() + { + var (_, xAxis, yAxis, core) = CreatePinnedChart(); + + var xRangeBefore = xAxis.MaxLimit!.Value - xAxis.MinLimit!.Value; + var yRangeBefore = yAxis.MaxLimit!.Value - yAxis.MinLimit!.Value; + + core.Zoom(ZoomAndPanMode.ZoomX | ZoomAndPanMode.NoFit, new LvcPoint(200, 200), ZoomDirection.ZoomIn); + + var xRangeAfter = xAxis.MaxLimit!.Value - xAxis.MinLimit!.Value; + var yRangeAfter = yAxis.MaxLimit!.Value - yAxis.MinLimit!.Value; + + Assert.IsTrue(xRangeAfter < xRangeBefore, "ZoomX should shrink the X axis range."); + Assert.AreEqual(yRangeBefore, yRangeAfter, 1e-9, "ZoomX must leave the Y axis untouched."); + } + + [TestMethod] + public void Zoom_WithPanXOnly_DoesNotZoomXAxis() + { + // PanX alone enables panning but NOT zooming; a Zoom call should be a no-op. + var (_, xAxis, yAxis, core) = CreatePinnedChart(); + + var xRangeBefore = xAxis.MaxLimit!.Value - xAxis.MinLimit!.Value; + var yRangeBefore = yAxis.MaxLimit!.Value - yAxis.MinLimit!.Value; + + core.Zoom(ZoomAndPanMode.PanX | ZoomAndPanMode.NoFit, new LvcPoint(200, 200), ZoomDirection.ZoomIn); + + Assert.AreEqual(xRangeBefore, xAxis.MaxLimit!.Value - xAxis.MinLimit!.Value, 1e-9); + Assert.AreEqual(yRangeBefore, yAxis.MaxLimit!.Value - yAxis.MinLimit!.Value, 1e-9); + } + + [TestMethod] + public void Pan_WithPanXOnly_AffectsOnlyXAxis() + { + var (_, xAxis, yAxis, core) = CreatePinnedChart(); + + var xMinBefore = xAxis.MinLimit!.Value; + var yMinBefore = yAxis.MinLimit!.Value; + + core.Pan(ZoomAndPanMode.PanX | ZoomAndPanMode.NoFit, new LvcPoint(20, 20)); + + Assert.AreNotEqual(xMinBefore, xAxis.MinLimit!.Value, "PanX should shift the X axis."); + Assert.AreEqual(yMinBefore, yAxis.MinLimit!.Value, 1e-9, "PanX must leave the Y axis untouched."); + } + + [TestMethod] + public void Pan_WithZoomXOnly_DoesNotPanXAxis() + { + // ZoomX alone enables zoom but NOT pan; a Pan call should be a no-op. + var (_, xAxis, yAxis, core) = CreatePinnedChart(); + + var xMinBefore = xAxis.MinLimit!.Value; + var yMinBefore = yAxis.MinLimit!.Value; + + core.Pan(ZoomAndPanMode.ZoomX | ZoomAndPanMode.NoFit, new LvcPoint(20, 20)); + + Assert.AreEqual(xMinBefore, xAxis.MinLimit!.Value, 1e-9); + Assert.AreEqual(yMinBefore, yAxis.MinLimit!.Value, 1e-9); + } + + [TestMethod] + public void Zoom_WithZoomYOnly_AffectsOnlyYAxis() + { + var (_, xAxis, yAxis, core) = CreatePinnedChart(); + + var xRangeBefore = xAxis.MaxLimit!.Value - xAxis.MinLimit!.Value; + var yRangeBefore = yAxis.MaxLimit!.Value - yAxis.MinLimit!.Value; + + core.Zoom(ZoomAndPanMode.ZoomY | ZoomAndPanMode.NoFit, new LvcPoint(200, 200), ZoomDirection.ZoomIn); + + Assert.AreEqual(xRangeBefore, xAxis.MaxLimit!.Value - xAxis.MinLimit!.Value, 1e-9); + Assert.IsTrue(yAxis.MaxLimit!.Value - yAxis.MinLimit!.Value < yRangeBefore); + } + + [TestMethod] + public void Pan_WithPanYOnly_AffectsOnlyYAxis() + { + var (_, xAxis, yAxis, core) = CreatePinnedChart(); + + var xMinBefore = xAxis.MinLimit!.Value; + var yMinBefore = yAxis.MinLimit!.Value; + + core.Pan(ZoomAndPanMode.PanY | ZoomAndPanMode.NoFit, new LvcPoint(20, 20)); + + Assert.AreEqual(xMinBefore, xAxis.MinLimit!.Value, 1e-9); + Assert.AreNotEqual(yMinBefore, yAxis.MinLimit!.Value); + } + + [TestMethod] + public void CompositeXFlag_StillEnablesBothPanAndZoomOnX() + { + // Backward-compat guarantee: ZoomAndPanMode.X must keep pan+zoom semantics. + var (_, xAxis, yAxis, core) = CreatePinnedChart(); + + var xRangeBefore = xAxis.MaxLimit!.Value - xAxis.MinLimit!.Value; + var yRangeBefore = yAxis.MaxLimit!.Value - yAxis.MinLimit!.Value; + var xMinBefore = xAxis.MinLimit!.Value; + + core.Zoom(ZoomAndPanMode.X | ZoomAndPanMode.NoFit, new LvcPoint(200, 200), ZoomDirection.ZoomIn); + core.Pan(ZoomAndPanMode.X | ZoomAndPanMode.NoFit, new LvcPoint(20, 0)); + + Assert.IsTrue(xAxis.MaxLimit!.Value - xAxis.MinLimit!.Value < xRangeBefore); + Assert.AreNotEqual(xMinBefore, xAxis.MinLimit!.Value); + Assert.AreEqual(yRangeBefore, yAxis.MaxLimit!.Value - yAxis.MinLimit!.Value, 1e-9); + } + private static SKGeoMap CreateGeoMap(MapProjection projection = MapProjection.Mercator) => new() { From 3c8c2ae9aca4ac67ca40358394a11d1783df10c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beto=20Rodr=C3=ADguez?= Date: Sun, 26 Apr 2026 11:42:12 -0600 Subject: [PATCH 3/6] docs(zoom): document granular PanX/ZoomX/PanY/ZoomY flags Updates the axes guide, the lines/zoom sample template and llms-full.txt to list the new per-gesture flags alongside the existing X/Y/Both composites, and shows the "zoom on X, no pan" combination from the discussion that motivated #2175. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cartesianChart/axes.md | 10 ++++++++-- docs/samples/lines/zoom/template.md | 10 ++++++++-- llms-full.txt | 10 ++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/cartesianChart/axes.md b/docs/cartesianChart/axes.md index 8dfc40ea4..04d7693d7 100644 --- a/docs/cartesianChart/axes.md +++ b/docs/cartesianChart/axes.md @@ -22,13 +22,19 @@ Zooming and panning is disabled by default, you can enable it by setting the `Zo [ZoomAndPanMode](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Measure.ZoomAndPanMode) and the options are: - `None`: Disables zooming and panning. -- `X`: Enables zooming and panning on the X axis. -- `Y`: Enables zooming and panning on the Y axis. +- `PanX`: Enables panning on the X axis. +- `ZoomX`: Enables zooming on the X axis (wheel, pinch and zoom by section). +- `PanY`: Enables panning on the Y axis. +- `ZoomY`: Enables zooming on the Y axis (wheel, pinch and zoom by section). +- `X`: Enables zooming and panning on the X axis. Equivalent to `PanX | ZoomX`. +- `Y`: Enables zooming and panning on the Y axis. Equivalent to `PanY | ZoomY`. - `Both`: Enables zooming and panning on both axes. - `NoFit`: Disables the "Fit to Bounds" feature that forces the chart to bounce back to the data bounds when zooming and panning finishes. - `NoZoomBySection`: Disables the "Zoom by Section" feature, this feature selects an area and zooms to this area, normally by right clicking or double tapping and then dragging to the end of the section. - `InvertPanningPointerTrigger`: Inverts the panning and zoom by section pointer triggers, when the flag is present, panning is triggered by right clicking the chart, and zoom by section by left clicking (or inverts single/double taps on mobile). +Because each gesture has its own flag you can mix and match — for example `ZoomMode = ZoomAndPanMode.ZoomX | ZoomAndPanMode.PanY` enables zoom on the X axis and panning on the Y axis only, and `ZoomMode = ZoomAndPanMode.ZoomX` enables zoom on the X axis with no panning at all (useful on mobile where pan gestures can hide tooltips). + The [ZoomAndPanMode](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Measure.ZoomAndPanMode) type is a flag enum, so you can combine the options, for example, if you want to enable zooming on the `X` axis and disable "Fit to Bounds" and "Zoom by Section" you can set the `ZoomMode` property to: diff --git a/docs/samples/lines/zoom/template.md b/docs/samples/lines/zoom/template.md index 8260a2cab..878189d37 100644 --- a/docs/samples/lines/zoom/template.md +++ b/docs/samples/lines/zoom/template.md @@ -48,13 +48,19 @@ You can enable zooming and panning by setting the `ZoomMode` property, this prop [ZoomAndPanMode](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Measure.ZoomAndPanMode) and the options are: - `None`: Disables zooming and panning. -- `X`: Enables zooming and panning on the X axis. -- `Y`: Enables zooming and panning on the Y axis. +- `PanX`: Enables panning on the X axis. +- `ZoomX`: Enables zooming on the X axis (wheel, pinch and zoom by section). +- `PanY`: Enables panning on the Y axis. +- `ZoomY`: Enables zooming on the Y axis (wheel, pinch and zoom by section). +- `X`: Enables zooming and panning on the X axis. Equivalent to `PanX | ZoomX`. +- `Y`: Enables zooming and panning on the Y axis. Equivalent to `PanY | ZoomY`. - `Both`: Enables zooming and panning on both axes. - `NoFit`: Disables the "Fit to Bounds" feature that forces the the chart to bounce back to the data bounds when zooming and panning finishes. - `NoZoomBySection`: Disables the "Zoom by Section" feature, this feature selects an area and zooms to this area, normally by right clicking or double tapping and then dragging to the end of the section. - `InvertPanningPointerTrigger`: Inverts the panning and zoom by section pointer triggers, when the flag is present, panning is triggered by right clicking the chart, and zoom by section by left clicking (or inverts single/double taps on mobile). +Because each gesture has its own flag you can mix and match — for example `ZoomMode = ZoomAndPanMode.ZoomX | ZoomAndPanMode.PanY` enables zoom on the X axis and panning on the Y axis only, and `ZoomMode = ZoomAndPanMode.ZoomX` enables zoom on the X axis with no panning at all (useful on mobile where pan gestures can hide tooltips). + The [ZoomAndPanMode](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Measure.ZoomAndPanMode) type is a flag enum, so you can combine the options, for example, if you want to enable zooming on the `Both` axes and disable "Fit top Bounds" you could set the `ZoomMode` property to: diff --git a/llms-full.txt b/llms-full.txt index 35a6588d2..847391da0 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -660,6 +660,16 @@ myChart.ZoomMode = ZoomAndPanMode.Both; myChart.ZoomMode = ZoomAndPanMode.None; ``` +Each gesture also has its own flag (`PanX`, `ZoomX`, `PanY`, `ZoomY`) so zoom and pan can be enabled independently. The composites `X = PanX | ZoomX`, `Y = PanY | ZoomY` and `Both = X | Y` keep their original pan+zoom semantics. + +```csharp +// Allow zooming on the X axis but disable panning (e.g. so pan gestures don't hide tooltips on mobile): +myChart.ZoomMode = ZoomAndPanMode.ZoomX; + +// Zoom on X, pan on Y: +myChart.ZoomMode = ZoomAndPanMode.ZoomX | ZoomAndPanMode.PanY; +``` + `ZoomAndPanMode` is a flags enum — combine values: ```csharp myChart.ZoomMode = ZoomAndPanMode.X | ZoomAndPanMode.NoFit | ZoomAndPanMode.NoZoomBySection; From f4391810fedb9049b63442c1e21c45b89987c41e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beto=20Rodr=C3=ADguez?= Date: Sun, 26 Apr 2026 12:05:08 -0600 Subject: [PATCH 4/6] fix(zoom): honor NoFit argument in manual Zoom() call FitAllOnZoom checked the chart view's ZoomMode for the NoFit flag instead of the flags passed to the public Zoom(...) call. Manual callers that built their own input scheme (view ZoomMode = None) had their NoFit ignored, so the post-zoom debounced fit snapped axis limits back to the data bounds. Now the fit consults the flags argument, matching the other NoFit checks in ZoomAxis / PanAxis / InvokePointerUp. Closes #2119 (zoom decoupling part is in the prior commit; this covers the "manual Zoom must honor NoFit" part). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/LiveChartsCore/CartesianChartEngine.cs | 5 ++- .../OtherTests/ChartInteractiveApiTests.cs | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/LiveChartsCore/CartesianChartEngine.cs b/src/LiveChartsCore/CartesianChartEngine.cs index 3d010e225..f5a82d276 100644 --- a/src/LiveChartsCore/CartesianChartEngine.cs +++ b/src/LiveChartsCore/CartesianChartEngine.cs @@ -986,7 +986,10 @@ internal void SubscribeSharedEvents(HashSet instance) private void FitAllOnZoom(ZoomAndPanMode flags) { - if (_chartView.ZoomMode.HasFlag(ZoomAndPanMode.NoFit)) + // Honor the flags passed to the public Zoom(...) call rather than the view's + // ZoomMode. Manual callers (e.g. a button-driven zoom) need NoFit to take + // effect even when the view's ZoomMode does not include it. + if (flags.HasFlag(ZoomAndPanMode.NoFit)) return; void Fit(ICartesianAxis axis) diff --git a/tests/CoreTests/OtherTests/ChartInteractiveApiTests.cs b/tests/CoreTests/OtherTests/ChartInteractiveApiTests.cs index 944bb8e87..30567b5c2 100644 --- a/tests/CoreTests/OtherTests/ChartInteractiveApiTests.cs +++ b/tests/CoreTests/OtherTests/ChartInteractiveApiTests.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using LiveChartsCore; using LiveChartsCore.Drawing; using LiveChartsCore.Geo; @@ -259,6 +260,40 @@ public void CompositeXFlag_StillEnablesBothPanAndZoomOnX() Assert.AreEqual(yRangeBefore, yAxis.MaxLimit!.Value - yAxis.MinLimit!.Value, 1e-9); } + [TestMethod] + public async Task Zoom_NoFitArgumentIsHonored_EvenWhenViewZoomModeOmitsIt() + { + // Reproduces issue #2119: a manual core.Zoom(... | NoFit, ...) call must keep + // the zoomed range. Previously the post-zoom debounced fit checked the view's + // ZoomMode (None here) instead of the flags argument, snapping limits back. + var xAxis = new Axis { MinLimit = 50, MaxLimit = 60 }; + var yAxis = new Axis { MinLimit = 50, MaxLimit = 60 }; + + var chart = new SKCartesianChart + { + Width = 400, + Height = 400, + ZoomMode = ZoomAndPanMode.None, + XAxes = [xAxis], + YAxes = [yAxis], + Series = [ new LineSeries { Values = [1d, 2d, 3d, 4d, 5d, 6d, 7d, 8d, 9d, 10d] } ] + }; + _ = chart.GetImage(); + + var core = (CartesianChartEngine)chart.CoreChart; + + // Pinned MaxLimit (60) sits above the data bounds (1..10). Without NoFit, the + // post-zoom Fit would clamp MaxLimit down toward the data max (~10). + core.Zoom(ZoomAndPanMode.Both | ZoomAndPanMode.NoFit, new LvcPoint(200, 200), ZoomDirection.ZoomIn); + + // Wait past the 300 ms debounce so FitAllOnZoom has had a chance to fire. + await Task.Delay(450); + _ = chart.GetImage(); + + Assert.IsTrue(xAxis.MaxLimit!.Value > 50, $"NoFit must prevent X MaxLimit snap-to-data; X MaxLimit={xAxis.MaxLimit}."); + Assert.IsTrue(yAxis.MaxLimit!.Value > 50, $"NoFit must prevent Y MaxLimit snap-to-data; Y MaxLimit={yAxis.MaxLimit}."); + } + private static SKGeoMap CreateGeoMap(MapProjection projection = MapProjection.Mercator) => new() { From f67d6e05cc5b3e8eb7bbaddd1bc43d4b94062d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beto=20Rodr=C3=ADguez?= Date: Sun, 26 Apr 2026 12:50:54 -0600 Subject: [PATCH 5/6] fix(zoom): don't swallow wheel for pan-only modes; doc/grammar fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avalonia and Eto cartesian views were marking PointerWheel events as Handled whenever ZoomMode was non-None. With pan-only modes (PanX/PanY) now possible, that swallowed the wheel even though the underlying Zoom() call is a no-op — blocking parent ScrollViewers in the same way issue #1864 originally fixed for ZoomMode = None. The guard now checks for any zoom flag (ZoomX/ZoomY) before suppressing the event. Also fixes two pre-existing doc issues touched by this PR: - StartZoomingSection's `point` param doc said "panning operation" instead of describing the zoom-by-section start point. - InvertPanningPointerTrigger doc said "triggered to the left click" (should read "triggered by the left click"). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/LiveChartsCore/CartesianChartEngine.cs | 2 +- src/LiveChartsCore/Measure/ZoomAndPanMode.cs | 2 +- .../SourceGenCartesianChart.avalonia.cs | 4 +++- .../SourceGenCartesianChart.eto.cs | 4 ++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/LiveChartsCore/CartesianChartEngine.cs b/src/LiveChartsCore/CartesianChartEngine.cs index f5a82d276..42abc9b66 100644 --- a/src/LiveChartsCore/CartesianChartEngine.cs +++ b/src/LiveChartsCore/CartesianChartEngine.cs @@ -201,7 +201,7 @@ public void Pan(ZoomAndPanMode flags, LvcPoint delta) /// The flags, for example ZoomAndPanMode.ZoomX | ZoomAndPanMode.NoFit, will zoom only in the x axis /// and will ignore the fit to bounds feature. /// - /// The point to start the panning operation. + /// The point where the zooming section operation starts. public void StartZoomingSection(ZoomAndPanMode flags, LvcPoint point) { var xMode = flags.HasFlag(ZoomAndPanMode.ZoomX); diff --git a/src/LiveChartsCore/Measure/ZoomAndPanMode.cs b/src/LiveChartsCore/Measure/ZoomAndPanMode.cs index 1539958b7..7f839176c 100644 --- a/src/LiveChartsCore/Measure/ZoomAndPanMode.cs +++ b/src/LiveChartsCore/Measure/ZoomAndPanMode.cs @@ -69,7 +69,7 @@ public enum ZoomAndPanMode /// /// When this flag is present the panning will be triggered using the right click on desktop devices and the touch-and-hold gesture on touch devices. - /// The "Zoom by section" feature will be triggered to the left click on desktop devices and the touch-and-hold gesture on touch devices, + /// The "Zoom by section" feature will be triggered by the left click on desktop devices and the touch-and-hold gesture on touch devices, /// this flag must be used in conjunction with any of the pan/zoom flags /// (, , , , /// , , or ) to have an effect. diff --git a/src/skiasharp/LiveChartsCore.SkiaSharp.Avalonia/SourceGenCartesianChart.avalonia.cs b/src/skiasharp/LiveChartsCore.SkiaSharp.Avalonia/SourceGenCartesianChart.avalonia.cs index 4f5d79afc..48da15f69 100644 --- a/src/skiasharp/LiveChartsCore.SkiaSharp.Avalonia/SourceGenCartesianChart.avalonia.cs +++ b/src/skiasharp/LiveChartsCore.SkiaSharp.Avalonia/SourceGenCartesianChart.avalonia.cs @@ -56,8 +56,10 @@ private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) // Only mark the event as handled when zoom is enabled; otherwise let it bubble // so parent ScrollViewers still scroll and external subscribers still fire // (Avalonia routed events skip subsequent handlers once Handled = true). + // Pan-only modes (PanX/PanY) must not swallow wheel either since the Zoom + // call below is a no-op for them. // See https://github.com/Live-Charts/LiveCharts2/issues/1864. - if (ZoomMode == ZoomAndPanMode.None) return; + if ((ZoomMode & (ZoomAndPanMode.ZoomX | ZoomAndPanMode.ZoomY)) == 0) return; e.Handled = true; var c = (CartesianChartEngine)CoreChart; diff --git a/src/skiasharp/LiveChartsCore.SkiaSharpView.Eto/SourceGenCartesianChart.eto.cs b/src/skiasharp/LiveChartsCore.SkiaSharpView.Eto/SourceGenCartesianChart.eto.cs index 6e1a14f57..43d875eb6 100644 --- a/src/skiasharp/LiveChartsCore.SkiaSharpView.Eto/SourceGenCartesianChart.eto.cs +++ b/src/skiasharp/LiveChartsCore.SkiaSharpView.Eto/SourceGenCartesianChart.eto.cs @@ -45,6 +45,10 @@ public SourceGenCartesianChart() private void OnMouseWheel(object? sender, MouseEventArgs e) { + // Don't swallow the wheel when zoom is not enabled — pan-only modes + // (PanX/PanY) and None must let the event bubble so containers can scroll. + if ((ZoomMode & (ZoomAndPanMode.ZoomX | ZoomAndPanMode.ZoomY)) == 0) return; + var c = (CartesianChartEngine)CoreChart; var p = e.Location; c.Zoom(ZoomMode, new LvcPoint(p.X, p.Y), e.Delta.Height > 0 ? ZoomDirection.ZoomIn : ZoomDirection.ZoomOut); From ec7217ad44f923f4a949c8efcc842af1292c4e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beto=20Rodr=C3=ADguez?= Date: Sun, 26 Apr 2026 13:01:09 -0600 Subject: [PATCH 6/6] docs(zoom): fix typos and align InvertPanningPointerTrigger wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "forces the the chart" → "forces the chart" (lines/zoom template). - "Fit top Bounds" → "Fit to Bounds" (lines/zoom template). - InvertPanningPointerTrigger doc claimed both pan and zoom-by-section were triggered by "touch-and-hold" on touch devices, which can't be true since they're different actions. Aligned with the secondary- action terminology used elsewhere in the codebase: pan via right click / double tap, zoom-by-section via left click / single tap. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/samples/lines/zoom/template.md | 4 ++-- src/LiveChartsCore/Measure/ZoomAndPanMode.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/samples/lines/zoom/template.md b/docs/samples/lines/zoom/template.md index 878189d37..09bd51a50 100644 --- a/docs/samples/lines/zoom/template.md +++ b/docs/samples/lines/zoom/template.md @@ -55,14 +55,14 @@ You can enable zooming and panning by setting the `ZoomMode` property, this prop - `X`: Enables zooming and panning on the X axis. Equivalent to `PanX | ZoomX`. - `Y`: Enables zooming and panning on the Y axis. Equivalent to `PanY | ZoomY`. - `Both`: Enables zooming and panning on both axes. -- `NoFit`: Disables the "Fit to Bounds" feature that forces the the chart to bounce back to the data bounds when zooming and panning finishes. +- `NoFit`: Disables the "Fit to Bounds" feature that forces the chart to bounce back to the data bounds when zooming and panning finishes. - `NoZoomBySection`: Disables the "Zoom by Section" feature, this feature selects an area and zooms to this area, normally by right clicking or double tapping and then dragging to the end of the section. - `InvertPanningPointerTrigger`: Inverts the panning and zoom by section pointer triggers, when the flag is present, panning is triggered by right clicking the chart, and zoom by section by left clicking (or inverts single/double taps on mobile). Because each gesture has its own flag you can mix and match — for example `ZoomMode = ZoomAndPanMode.ZoomX | ZoomAndPanMode.PanY` enables zoom on the X axis and panning on the Y axis only, and `ZoomMode = ZoomAndPanMode.ZoomX` enables zoom on the X axis with no panning at all (useful on mobile where pan gestures can hide tooltips). The [ZoomAndPanMode](https://lvcharts.com/api/{{ version }}/LiveChartsCore.Measure.ZoomAndPanMode) type is a flag enum, -so you can combine the options, for example, if you want to enable zooming on the `Both` axes and disable "Fit top Bounds" +so you can combine the options, for example, if you want to enable zooming on the `Both` axes and disable "Fit to Bounds" you could set the `ZoomMode` property to: diff --git a/src/LiveChartsCore/Measure/ZoomAndPanMode.cs b/src/LiveChartsCore/Measure/ZoomAndPanMode.cs index 7f839176c..462c0223f 100644 --- a/src/LiveChartsCore/Measure/ZoomAndPanMode.cs +++ b/src/LiveChartsCore/Measure/ZoomAndPanMode.cs @@ -68,8 +68,8 @@ public enum ZoomAndPanMode NoZoomBySection = 1 << 5, /// - /// When this flag is present the panning will be triggered using the right click on desktop devices and the touch-and-hold gesture on touch devices. - /// The "Zoom by section" feature will be triggered by the left click on desktop devices and the touch-and-hold gesture on touch devices, + /// When this flag is present the panning will be triggered using the right click on desktop devices and a double tap on touch devices. + /// The "Zoom by section" feature will be triggered by the left click on desktop devices and a single tap on touch devices, /// this flag must be used in conjunction with any of the pan/zoom flags /// (, , , , /// , , or ) to have an effect.