SciChart WPF 2D Charts > Tutorials > MVVM > Tutorial 10b - Synchronizing Axis Ranges MVVM
Tutorial 10b - Synchronizing Axis Ranges MVVM
Source code for this tutorial can be found at our SciChart.WPF.Examples Github Repository under Tutorials section.

This tutorial builds on Tutorial 09b (Linking Multiple Charts with MVVM) and demonstrates how to synchronize axis VisibleRanges across multiple SciChartSurface controls using the MVVM API.

You will learn how to:

  • Sync axes that share the same unit by assigning a common RangeSyncGroupId.
  • Sync axes with different units by providing an IRangeSyncTransform that converts between the axis’s own range and the canonical group range.
  • Control ZoomExtents behavior with RangeSyncSourceOnZoomExtents so that one axis acts as the authoritative source for the group when ZoomExtents fires.

The example displays two charts stacked vertically. Both show the same temperature signal over time — one in Celsius, the other in Fahrenheit. The X axes (time) are synced directly. The Y axes are synced through a Celsius to Fahrenheit transform.

Key Properties

All three properties are defined on AxisBaseViewModel and are available on every axis ViewModel type (NumericAxisViewModel, DateTimeAxisViewModel, etc.).

RangeSyncGroupId (string) — Assigns this axis to a named synchronization group. All axes with the same non-null group ID automatically synchronize their VisibleRanges. When any axis in the group changes its range (via zoom, pan, or programmatic update), the new range is propagated to every other axis in the same group.

RangeSyncTransform (IRangeSyncTransform) — An optional transform that converts between this axis’s range and the canonical group range. Axes without a transform use identity — their range IS the group range. The interface has two methods: ToGroupRange (axis → group, called when this axis changes) and FromGroupRange (group → axis, called when another axis in the group changes).

RangeSyncSourceOnZoomExtents (bool) — When true, this axis broadcasts its computed extents to the sync group during ZoomExtents. When false (default), ZoomExtents fits this axis to its own local data without propagating to the group — which temporarily breaks synchronization. Typically, you set this to true on one authoritative axis per group.

View (XAML)

The window contains two SciChartSurface controls, each bound to its own ChartViewModel via SeriesBinding and AxesBinding. A CheckBox overlay on each chart toggles RangeSyncSourceOnZoomExtents at runtime.

MainWindow.xaml
Copy Code
<Window x:Class="SciChart.Mvvm.Tutorial.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:s="http://schemas.abtsoftware.co.uk/scichart"
        xmlns:local="clr-namespace:SciChart.Mvvm.Tutorial"
        Title="Tutorial 10b — Synchronizing Axis Ranges with MVVM"
        Height="650" Width="800">
 
    <Window.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/SciChart.Charting;component/Themes/Default.xaml"/>
            </ResourceDictionary.MergedDictionaries>
            <local:MainViewModel x:Key="MainViewModel"/>
        </ResourceDictionary>
    </Window.Resources>
 
    <Grid DataContext="{StaticResource MainViewModel}">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
 
        <!-- Chart 1: Temperature in Celsius -->
        <Grid Grid.Row="0">
            <s:SciChartSurface ChartTitle="{Binding CelsiusChart.ChartTitle}"
                               RenderableSeries="{s:SeriesBinding CelsiusChart.RenderableSeries}"
                               XAxes="{s:AxesBinding CelsiusChart.XAxes}"
                               YAxes="{s:AxesBinding CelsiusChart.YAxes}">
                <s:SciChartSurface.ChartModifier>
                    <s:ModifierGroup>
                        <s:RubberBandXyZoomModifier/>
                        <s:ZoomPanModifier ExecuteOn="MouseRightButton"/>
                        <s:ZoomExtentsModifier/>
                        <s:MouseWheelZoomModifier/>
                        <s:XAxisDragModifier />
                        <s:YAxisDragModifier />
                    </s:ModifierGroup>
                </s:SciChartSurface.ChartModifier>
            </s:SciChartSurface>
 
            <CheckBox Margin="8" VerticalAlignment="Top" HorizontalAlignment="Left"
                      Foreground="#FFF" Content="RangeSyncSourceOnZoomExtents"
                      IsChecked="{Binding CelsiusChart.RangeSyncOnZoomExtents, Mode=TwoWay}"/>
        </Grid>
 
        <!-- Chart 2: Temperature in Fahrenheit -->
        <Grid Grid.Row="1">
            <s:SciChartSurface ChartTitle="{Binding FahrenheitChart.ChartTitle}"
                               RenderableSeries="{s:SeriesBinding FahrenheitChart.RenderableSeries}"
                               XAxes="{s:AxesBinding FahrenheitChart.XAxes}"
                               YAxes="{s:AxesBinding FahrenheitChart.YAxes}">
                <s:SciChartSurface.ChartModifier>
                    <s:ModifierGroup>
                        <s:RubberBandXyZoomModifier/>
                        <s:ZoomPanModifier ExecuteOn="MouseRightButton"/>
                        <s:ZoomExtentsModifier/>
                        <s:MouseWheelZoomModifier/>
                        <s:XAxisDragModifier />
                        <s:YAxisDragModifier />
                    </s:ModifierGroup>
                </s:SciChartSurface.ChartModifier>
            </s:SciChartSurface>
 
            <CheckBox Margin="8" VerticalAlignment="Top" HorizontalAlignment="Left"
                      Foreground="#FFF" Content="RangeSyncSourceOnZoomExtents"
                      IsChecked="{Binding FahrenheitChart.RangeSyncOnZoomExtents, Mode=TwoWay}"/>
        </Grid>
    </Grid>
</Window>

ChartViewModel

A lightweight ViewModel that holds the axes, series, and title for a single chart. The RangeSyncOnZoomExtents property propagates to every axis in the chart, making it easy to toggle from a single CheckBox binding.

ChartViewModel.cs
Copy Code
public class ChartViewModel : BindableObject
{
    private string _chartTitle;
    private bool _rangeSyncOnZoomExtents;
 
    public ObservableCollection<IAxisViewModel> XAxes { get; }
        = new ObservableCollection<IAxisViewModel>();
    public ObservableCollection<IAxisViewModel> YAxes { get; }
        = new ObservableCollection<IAxisViewModel>();
    public ObservableCollection<IRenderableSeriesViewModel> RenderableSeries { get; }
        = new ObservableCollection<IRenderableSeriesViewModel>();
 
    public string ChartTitle
    {
        get => _chartTitle;
        set { _chartTitle = value; OnPropertyChanged("ChartTitle"); }
    }
 
    public bool RangeSyncOnZoomExtents
    {
        get => _rangeSyncOnZoomExtents;
        set
        {
            if (_rangeSyncOnZoomExtents != value)
            {
                _rangeSyncOnZoomExtents = value;
                OnPropertyChanged("RangeSyncOnZoomExtents");
 
                foreach (var axis in XAxes)
                    if (axis is AxisBaseViewModel vm)
                        vm.RangeSyncSourceOnZoomExtents = value;
 
                foreach (var axis in YAxes)
                    if (axis is AxisBaseViewModel vm)
                        vm.RangeSyncSourceOnZoomExtents = value;
            }
        }
    }
}

MainViewModel

The MainViewModel creates two chart models. The Celsius chart’s axes define the canonical group ranges. The Fahrenheit chart’s Y axis carries a CelsiusFahrenheitTransform to convert between units.

MainViewModel.cs
Copy Code
public class MainViewModel : BindableObject
{
    public ChartViewModel CelsiusChart { get; }
    public ChartViewModel FahrenheitChart { get; }
 
    public MainViewModel()
    {
        CelsiusChart = BuildCelsiusChart();
        FahrenheitChart = BuildFahrenheitChart();
 
        CelsiusChart.RenderableSeries.ZoomExtentsWhenReady();
        FahrenheitChart.RenderableSeries.ZoomExtentsWhenReady();
    }
 
    private ChartViewModel BuildCelsiusChart()
    {
        var chart = new ChartViewModel { ChartTitle = "Temperature (\u00b0C)" };
 
        // X axis \u2014 synced via "TimeGroup" (no transform, same time unit)
        chart.XAxes.Add(new NumericAxisViewModel
        {
            AxisTitle = "Time (s)",
            AutoRange = AutoRange.Once,
            RangeSyncGroupId = "TimeGroup"
        });
 
        // Y axis \u2014 synced via "TempGroup" (no transform = identity,
        // this axis's range IS the canonical group range)
        chart.YAxes.Add(new NumericAxisViewModel
        {
            AxisTitle = "\u00b0C",
            AutoRange = AutoRange.Once,
            RangeSyncGroupId = "TempGroup"
        });
 
        // Make this chart the authoritative source on ZoomExtents
        chart.RangeSyncOnZoomExtents = true;
 
        // Sample data: temperature oscillating around 20 \u00b0C
        var ds = new XyDataSeries<double, double> { SeriesName = "Sensor (\u00b0C)" };
        for (int i = 0; i < 500; i++)
        {
            double x = i * 0.1;
            ds.Append(x, 20 + 10 * Math.Sin(x * 0.5) + 5 * Math.Sin(x * 1.3));
        }
 
        chart.RenderableSeries.Add(new LineRenderableSeriesViewModel
        {
            DataSeries = ds,
            Stroke = Color.FromRgb(0x50, 0xC7, 0xE0),
            StrokeThickness = 2
        });
 
        return chart;
    }
 
    private ChartViewModel BuildFahrenheitChart()
    {
        var chart = new ChartViewModel { ChartTitle = "Temperature (\u00b0F)" };
 
        // X axis \u2014 same sync group, no transform
        chart.XAxes.Add(new NumericAxisViewModel
        {
            AxisTitle = "Time (s)",
            AutoRange = AutoRange.Once,
            RangeSyncGroupId = "TimeGroup"
        });
 
        // Y axis \u2014 same sync group, with a transform.
        // CelsiusFahrenheitTransform converts:
        //   ToGroupRange:   F \u2192 C  (broadcast to group when this axis changes)
        //   FromGroupRange: C \u2192 F  (update this axis when the group changes)
        chart.YAxes.Add(new NumericAxisViewModel
        {
            AxisTitle = "\u00b0F",
            AutoRange = AutoRange.Once,
            RangeSyncGroupId = "TempGroup",
            RangeSyncTransform = new CelsiusFahrenheitTransform()
        });
 
        // Same signal in Fahrenheit: F = C \u00d7 9/5 + 32
        var ds = new XyDataSeries<double, double> { SeriesName = "Sensor (\u00b0F)" };
        for (int i = 0; i < 500; i++)
        {
            double x = i * 0.1;
            double celsius = 20 + 10 * Math.Sin(x * 0.5) + 5 * Math.Sin(x * 1.3);
            ds.Append(x, celsius * 9.0 / 5.0 + 32);
        }
 
        chart.RenderableSeries.Add(new LineRenderableSeriesViewModel
        {
            DataSeries = ds,
            Stroke = Color.FromRgb(0xF4, 0x84, 0x20),
            StrokeThickness = 2
        });
 
        return chart;
    }
}

IRangeSyncTransform

The IRangeSyncTransform interface has two methods. ToGroupRange converts from the axis’s own range to the canonical group range (called when this axis is the source of a change). FromGroupRange converts in the opposite direction (called when this axis must update to match a change from another axis in the group).

An axis without a transform uses identity — its VisibleRange IS the group range. In this example the Celsius axis has no transform (it defines the canonical unit), and only the Fahrenheit axis needs the conversion.

CelsiusFahrenheitTransform.cs
Copy Code
public class CelsiusFahrenheitTransform : IRangeSyncTransform
{
    /// Convert axis range (Fahrenheit) to group range (Celsius).
    public IRange ToGroupRange(IRange axisRange)
    {
        if (axisRange is DoubleRange r)
        {
            return new DoubleRange(
                (r.Min - 32) * 5.0 / 9.0,
                (r.Max - 32) * 5.0 / 9.0);
        }
        return axisRange;
    }
 
    /// Convert group range (Celsius) to axis range (Fahrenheit).
    public IRange FromGroupRange(IRange groupRange)
    {
        if (groupRange is DoubleRange r)
        {
            return new DoubleRange(
                r.Min * 9.0 / 5.0 + 32,
                r.Max * 9.0 / 5.0 + 32);
        }
        return groupRange;
    }
}

How It Works

  • Basic sync (same unit). The X axes of both charts share RangeSyncGroupId = "TimeGroup" with no transform. Zooming or panning the time axis on either chart immediately updates the other to the exact same range.
  • Transform sync (different units). The Y axes share RangeSyncGroupId = "TempGroup". The Celsius axis has no transform (its range IS the group range). The Fahrenheit axis has a CelsiusFahrenheitTransform. When the user zooms the Celsius Y axis to [10, 30], the Fahrenheit axis automatically computes [50, 86] via FromGroupRange. Conversely, zooming the Fahrenheit axis to [50, 86] sends [10, 30] to the group via ToGroupRange, and the Celsius axis picks it up unchanged.
  • ZoomExtents and RangeSyncSourceOnZoomExtents. During ZoomExtents, normal range synchronization is suppressed so that each axis can independently fit to its own data. RangeSyncSourceOnZoomExtents overrides this: an axis with the flag set to true computes its own extents and then broadcasts them to the sync group. Other axes in the group receive the converted range via FromGroupRange, keeping everything in sync. Try it: zoom into a region, uncheck RangeSyncSourceOnZoomExtents on both charts, then double-click to ZoomExtents — the charts desync. Re-check the Celsius chart and double-click again — sync is restored.