XamDataChart でマウス ドラッグでデータを選択するような UI を実現する方法の紹介です。

実現イメージ

このアプリケーションは、チャート上でマウス ドラッグをするとその部分が薄い青色でハイライトされ、その範囲のデータが右側に一覧表示されます。そしてクリア ボタンをクリックすると元の選択されていない状態に戻ります。

左側のチャートは XamDataChart の ScatterLineSeries を使用しています。右側の一覧は XamDataGrid を使用しています。

実装方法概要

  1. ValueOverlay を使用して XamDataChart 上の選択範囲を表現します。
  2. XamDataChart のマウス ボタンの Down イベント、Move イベント、Up イベント、Leave イベントのそれぞれにイベント ハンドラーを追加し、ドラッグ開始から終了までを監視し、選択範囲を更新します。
  3. クリア ボタンの Click イベントのイベント ハンドラーで選択範囲をクリアします。
  4. 選択範囲が更新されたら XamDataGrid にバインドしている DataSource も更新します。

実装方法詳細

0. 各コントロールを配置します。

簡略的にレイアウトの骨格だけを示すと以下のようになります。

<!-- MainWindow.xaml -->
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>

    <Button x:Name="button1" Grid.Row="0" Margin="10" Content="クリア" .../>

    <Grid Grid.Row="1">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <ig:XamDataChart x:Name="xamDataChart1" Grid.Column="0" Margin="10" .../>

        <StackPanel Grid.Column="1" Orientation="Vertical">
            <TextBlock Margin="10" Text="選択中のデータ" />
            <Border Margin="10, 0, 10, 0" BorderThickness="1" BorderBrush="Gray">
                <igDP:XamDataGrid x:Name="xamDataGrid1" Grid.Column="1" .../>
            </Border>
        </StackPanel>
    </Grid>
</Grid>
  • 3 ~ 6 行目 : 画面全体を上下に分割しています。
  • 8 行目 : クリア ボタンです。画面上側に配置しています。
  • 11 ~ 14 行目 : 画面下側をさらに左右に分割しています。
  • 16 行目 : 画面左下側に XamDataChart を配置しています。
  • 18 ~ 23 行目: 画面右下側にテキストと XamDataGrid を配置しています。

1. ValueOverlay を使用して XamDataChart 上の選択範囲を表現します。

ValueOverlay の本来の目的は、データ セットの平均値 / 中央値などの重要な値を示すためにチャート上に 1 本の線を引くことにあるのですが、線の色や透明度、太さなどを設定できますので、今回のように「選択範囲」を示す用途にも活用できます。

Value プロパティを設定するとその値の位置に 1 本の線が引かれます。さらに Thickness プロパティを設定すると Value プロパティで指定された値を中心に指定された太さで線が描画され、Brush プロパティを設定すると色や透明度が指定できます。

今回は選択範囲を示したいので、マウスドラッグの開始地点と終了地点を Thickness プロパティにバインドし、その中央地点を Value にバインドします。Brush には ValueOverlay がハイライトに見えるよう、青色で背面の ScatterLienSeries が透けて見える透明度 0.15 で設定します。

Model 側

X と Y のプロパティを持つクラスを用意します。X と Y の値をパラメーターとして渡せばインスタンス化できるように x と y を引数に持つコンストラクターも用意します。

// DataPoint.cs

internal class DataPoint
{
    public double X { get; set; }
    public double Y { get; set; }

    public DataPoint()
    {
    }

    public DataPoint(double x, double y)
    {
        X = x;
        Y = y;
    }
}

ViewModel 側

// MainWindowViewModel.cs

internal class MainWindowViewModel : ObservableObject
{
    private DataPoint? _selectionRangeStart;
    public DataPoint? SelectionRangeStart
    {
        get => _selectionRangeStart;
        set
        {
            _selectionRangeStart = value;
            OnPropertyChanged();
            OnPropertyChanged(nameof(SelectionRangeMiddle));
        }
    }

    private DataPoint? _selectionRangeEnd;
    public DataPoint? SelectionRangeEnd
    {
        get => _selectionRangeEnd;
        set
        {
            _selectionRangeEnd = value;
            OnPropertyChanged();
            OnPropertyChanged(nameof(SelectionRangeMiddle));
        }
    }

    public DataPoint? SelectionRangeMiddle
    {
        get
        {
            if (_selectionRangeStart == null || _selectionRangeEnd == null) return null;
            else
            {
                return new ()
                {
                    X = (_selectionRangeStart.X + _selectionRangeEnd.X) / 2.0,
                    Y = (_selectionRangeStart.Y + _selectionRangeEnd.Y) / 2.0,
                };
            }
        }
    }

    public MainWindowViewModel()
    {
        // ... (中略) ...
    }
}
  • SelectionRangeStart プロパティと SelectionRangeEnd プロパティがそれぞれマウス ドラッグの開始地点と終了地点を示す DataPoint です。
  • SelectionRangeMiddle プロパティがマウス ドラッグ範囲の中間地点を示す DataPoint です。
  • 13 行目、25 行目 : マウス ドラッグの範囲が更新されたら中間地点も更新する必要があるので、中間地点のプロパティも PropertyChanged イベントを発生させています。

View 側

<!-- MainWindow.xaml -->

<Window.Resources>
    <local:GetThickness x:Key="getThickness"/>
</Window.Resources>

<!-- ... (中略) ... -->

<ig:XamDataChart ...>
    <ig:XamDataChart.Series>
        <ig:XamDataChart.Axes>
            <ig:NumericXAxis x:Name="xAxis" MinimumValue="0" MaximumValue="9" />
            <ig:NumericYAxis x:Name="yAxis" MinimumValue="0" MaximumValue="100" />
        </ig:XamDataChart.Axes>

        <!-- 選択されている範囲をハイライトするための ValueOverlay -->
        <ig:ValueOverlay
            x:Name="highlight"
            Axis="{Binding ElementName=xAxis}"
            Value="{Binding SelectionRangeMiddle.X}">
            <ig:ValueOverlay.Thickness>
                <MultiBinding Converter="{StaticResource getThickness}" Mode="OneWay">
                    <Binding Path="SelectionRangeStart" />
                    <Binding Path="SelectionRangeEnd" />
                    <Binding ElementName="xAxis" />
                </MultiBinding>
            </ig:ValueOverlay.Thickness>
            <ig:ValueOverlay.Brush>
                <SolidColorBrush Color="Blue" Opacity="0.15"/>
            </ig:ValueOverlay.Brush>
        </ig:ValueOverlay>

        <!-- ... (中略) ... -->

    </ig:XamDataChart.Series>
</ig:XamDataChart>
  • 20 行目 : ValueOverlay の Value プロパティを SelectionRangeMiddle オブジェクトの X プロパティにバインドしています。
  • 21 ~ 27 行目 : Thickness プロパティをマウス ドラッグ開始地点 SelectionRangeStart と終了地点 SelectionRangeEnd にバインドしています。ただ、SelectionRangeStart と SelectionRangeEnd はチャートの軸空間の値です。一方で Thickness プロパティは WPF の測定単位で指定する必要がありますので、軸空間の値から WPF の測定単位に変換する処理が必要です。そのため、軸空間から測定単位への変換に必須の要素である xAxis もバインドし、Window の Resources に登録してある GetThickness という IMultiValueConverter インターフェースを実装したクラス オブジェクトで変換しています。
  • 28 ~ 30 行目 : ValueOverlay がハイライトのように見えるように色と透明度を調整をしています。色は青。濃すぎると背面の ScatterLineSeries が見えず、薄すぎるとハイライトには見えないので、ちょうどよい塩梅の透明度 0.15 を指定しています。

GetThickness コンバーター

// GetThickness.cs

internal class GetThickness : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        if (values == null || values.Length != 3
            || values[0] == null || values[0].GetType() != typeof(DataPoint)
            || values[1] == null || values[1].GetType() != typeof(DataPoint)
            || values[2] == null || values[2].GetType() != typeof(NumericXAxis))
        {
            return new Thickness(0);
        }

        var start = (DataPoint)values[0];
        var end = (DataPoint)values[1];
        var xAxis = (NumericXAxis)values[2];

        var x0 = xAxis.ScaleValue(start.X);
        var x1 = xAxis.ScaleValue(end.X);
        return Math.Max(Math.Abs(x0 - x1), 1);
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}
  • 19 行目、20 行目 : 軸の ScaleValue メソッドを使って軸空間の値から WPF の測定単位の値に変換しています。
  • 21 行目 : 2 地点間の差分の絶対値を返しています。ただし、クリックした直後はこの値は非常に小さくなってしまうことが容易に想像できるので、最低でも 1 以上の太さで描画されるようにしています。

これで ValueOverlay 側の実装は完了です。次は、各マウス イベントで ValueOverlay にバインドしたプロパティを更新していきます。

3. XamDataChart のマウス ボタンの Down イベント、Move イベント、Up イベント、Leave イベントのそれぞれにイベント ハンドラーを追加し、ドラッグ開始から終了までを監視し、選択範囲を更新します。

View 側

<!-- MainWindow.xaml -->

<ig:XamDataChart
    x:Name="xamDataChart1" ...
    PlotAreaMouseLeftButtonDown="xamDataChart1_PlotAreaMouseLeftButtonDown"
    PreviewMouseMove="xamDataChart1_PreviewMouseMove"
    PlotAreaMouseLeftButtonUp="xamDataChart1_PlotAreaMouseLeftButtonUp"
    PlotAreaMouseLeave="xamDataChart1_PlotAreaMouseLeave">

    <!-- ... (中略) ... -->

</ig:XamDataChart>
  • 各イベント ハンドラーを追加します。

View (コード ビハインド) 側

// MainWindow.xaml.cs

public partial class MainWindow : Window
{
    private bool _isMouseLeftButtonDown = false;    // マウス左ボタンが押されている間だけ true になる変数

    public MainWindow()
    {
        InitializeComponent();
    }

    private void xamDataChart1_PlotAreaMouseLeftButtonDown(object sender, Infragistics.Controls.Charts.PlotAreaMouseButtonEventArgs e)
    {
        var x = xAxis.UnscaleValue(e.PlotAreaPosition.X);
        var y = yAxis.UnscaleValue(e.PlotAreaPosition.Y);
        var vm = (MainWindowViewModel)DataContext;
        vm.SelectionRangeStart = new Model.DataPoint(x, y);
        vm.SelectionRangeEnd = new Model.DataPoint(x, y);

        _isMouseLeftButtonDown = true;
    }

    private void xamDataChart1_PreviewMouseMove(object sender, MouseEventArgs e)
    {
        if (_isMouseLeftButtonDown)
        {
            var crosshairPoint = xamDataChart1.CrosshairPoint;
            var x = (xAxis.ActualMaximumValue - xAxis.ActualMinimumValue) * crosshairPoint.X + xAxis.ActualMinimumValue;
            var vm = (MainWindowViewModel)DataContext;
            if(vm.SelectionRangeEnd != null)
            {
                vm.SelectionRangeEnd = new Model.DataPoint(x, vm.SelectionRangeEnd.Y);
            }
        }
    }

    private void xamDataChart1_PlotAreaMouseLeave(object sender, Infragistics.Controls.Charts.PlotAreaMouseEventArgs e)
    {
        if (_isMouseLeftButtonDown)
        {
            var x = xAxis.UnscaleValue(e.PlotAreaPosition.X);
            var vm = (MainWindowViewModel)DataContext;
            if (vm.SelectionRangeEnd != null)
            {
                vm.SelectionRangeEnd = new Model.DataPoint(x, vm.SelectionRangeEnd.Y);
            }
        }

        _isMouseLeftButtonDown = false;
    }

    private void xamDataChart1_PlotAreaMouseLeftButtonUp(object sender, Infragistics.Controls.Charts.PlotAreaMouseButtonEventArgs e)
    {
        _isMouseLeftButtonDown = false;
    }
}
  • 12 ~ 21 行目 : グラフ本体の描画領域でのマウス左ボタン クリック時のイベント ハンドラーです。軸の UnscaleValue メソッドを使って WPF 測定単位で渡されてくる PlotAreaPosition を軸空間の値に変換しています (参考記事: XamDataChart – クリックされたプロットエリア位置を取得する)。そして左ボタンクリック時、つまりドラッグ開始時ですので、マウス ドラッグ開始地点と終了地点を同じ値で設定しています。また、マウスボタンが押されている状態を示す _isMouseLeftButtonDown を true にします。
  • 23 ~ 35 行目 : XamDataChart 上でマウスが移動したときのイベント ハンドラーです。ドラッグ範囲の更新はドラッグ中のみですので、まず _isMouseLeftButtonDown で判定しています。XamDataChart の CrosshairPoint プロパティの情報を使って現在のマウス カーソルの軸空間の値を取得し (参考記事: XamDataChart – マウスカーソル位置の座標を取得する方法)、ドラッグ終了地点を更新しています。
  • 37 ~ 50 行目 : グラフ本体の描画領域からマウス カーソルが離れたときのイベント ハンドラーです。内容はこれまで出てきた各マウス イベント ハンドラーと同様ですが、マウス ボタンから指を離したとみなして _isMouseLeftButtonDown を false にしています。
  • 52 ~ 55 行目 : グラフ本体の描画領域でマウス ボタンから指を離したときのイベント ハンドラーです。

これで XamDataChart 側のマウス ドラッグによる選択範囲の UI 実装は終わりです。次にクリア ボタンクリック時の選択クリア処理です。

3. クリア ボタンの Click イベントのイベント ハンドラーで選択範囲をクリアします。

View 側

<!-- MainWindow.xaml -->

<Button x:Name="button1" Grid.Row="0" Margin="10" Content="クリア" Click="button1_Click"/>

View (コード ビハインド) 側

// MainWindow.xaml.cs

private void button1_Click(object sender, RoutedEventArgs e)
{
    var vm = (MainWindowViewModel)DataContext;
    vm.SelectionRangeStart = null;
    vm.SelectionRangeEnd = null;
}

以上で XamDataChart の実装が完了です。次は選択されているデータの一覧を表示する XamDataGrid の実装です。

4. 選択範囲が更新されたら XamDataGrid にバインドしている DataSource も更新します。

ViewModel 側に現在選択されている範囲の DataPoint を取得 / 設定するプロパティを追加し、XamDataGrid の DataSource プロパティにバインドします。そして選択範囲が更新されたときにこのプロパティも更新し、PropertyChanged イベントも発生させます。

ViewModel 側

// MainWindowViewModel.cs

internal class MainWindowViewModel : ObservableObject
{
    public List<DataPoint> DataPoints { get; set; } // チャートに表示するデータ本体

    private DataPoint? _selectionRangeStart;
    public DataPoint? SelectionRangeStart
    {
        get => _selectionRangeStart;
        set
        {
            _selectionRangeStart = value;
            OnPropertyChanged();
            OnPropertyChanged(nameof(SelectionRangeMiddle));

            if (_selectionRangeStart == null || _selectionRangeEnd == null)
            {
                _selectedDataPoints = new();
            }
            else
            {
                var x0 = Math.Min(_selectionRangeStart.X, _selectionRangeEnd.X);
                var x1 = Math.Max(_selectionRangeStart.X, _selectionRangeEnd.X);
                _selectedDataPoints = DataPoints.Where(dataPoint => x0 <= dataPoint.X && dataPoint.X <= x1).ToList();
            }
            OnPropertyChanged(nameof(SelectedDataPoints));
        }
    }

    private DataPoint? _selectionRangeEnd;
    public DataPoint? SelectionRangeEnd
    {
        get => _selectionRangeEnd;
        set
        {
            _selectionRangeEnd = value;
            OnPropertyChanged();
            OnPropertyChanged(nameof(SelectionRangeMiddle));

            if (_selectionRangeStart == null || _selectionRangeEnd == null)
            {
                _selectedDataPoints = new();
            }
            else
            {
                var x0 = Math.Min(_selectionRangeStart.X, _selectionRangeEnd.X);
                var x1 = Math.Max(_selectionRangeStart.X, _selectionRangeEnd.X);
                _selectedDataPoints = DataPoints.Where(dataPoint => x0 <= dataPoint.X && dataPoint.X <= x1).ToList();
            }
            OnPropertyChanged(nameof(SelectedDataPoints));
        }
    }

    // ... (中略) ...

    private List<DataPoint> _selectedDataPoints;
    public List<DataPoint> SelectedDataPoints
    {
        get => _selectedDataPoints;
        set
        {
            _selectedDataPoints = value;
            OnPropertyChanged(nameof(SelectedDataPoints));
        }
    }

    public MainWindowViewModel()
    {

        // ... (中略) ...

        _selectedDataPoints = new List<DataPoint>();
    }
}
  • 57 ~ 66 行目 : 現在選択されている範囲の DataPoint を取得 / 設定するプロパティを追加しています。
  • 17 ~ 27 行目、41 ~ 51 行目 : 選択範囲の開始地点および終了地点が更新されたときに選択範囲の DataPoint も更新する処理を追加しています。開始地点もしくは終了地点が設定されていなければ選択範囲の DataPoint も 0 件なので、空の List<DataPoint> を作成しています。両方とも設定されているときは、右側から左側へとマウス ドラッグされている場合など、必ずしも開始地点の方が X の値が小さいとは限らないので、大小を確認してから範囲内にあるデータを取り出しています。そして現在選択されている範囲の DataPoint のプロパティ変更通知を発生させています。
  • 73 行目 : 最初は空の List<DataPoint> を割り当てています。XamDataGrid の DataSource へのバインド処理は、特に初回はヘッダーを作成したりといった処理もあり、ある程度の時間がかかります。XamDataChart でマウス ドラッグを開始した時にこの処理が走ってしまうと、スムーズに範囲指定ができなくなってしまいます。それを避けるために、最初に空のリストを割り当て、XamDataGrid へのバインド処理も一通りさせておきます。

View 側

<!-- MainWindow.xaml -->

<igDP:XamDataGrid ...
    DataSource="{Binding SelectedDataPoints}"
    GroupByAreaLocation="None">
    <igDP:XamDataGrid.FieldSettings>
        <igDP:FieldSettings CellClickAction="SelectCell" AllowEdit="False"/>
    </igDP:XamDataGrid.FieldSettings>
</igDP:XamDataGrid>
  • 4 行目 : DataSource プロパティに ViewModel 側の現在選択されている範囲の DataPoint を取得 / 設定するプロパティをバインドします。
  • その他、今回は一覧を表示するだけなので、それ以外の不要な要素や機能はオフにします。

以上ですべて完了です!

 

 

製品について

Ultimate UI for WPF