OxyPlotの使い方 ViewのPlotを画像保存するビヘイビア2

WPFでChartグラフを表示するためのライブラリーに OxyPlot があります。View に Plot を貼りつけて XAML で作成した Chart を保存するビヘイビアについて書いてあります。ViewModel から自動で View の Plot を保存する為の仕組みも組み込んでいます。前回のものから EventAggregator を使わないものに変更しました。

OxyPlot で Chart を表示するには、PlotModel を ViewModel に作ってコードで全部作りこむ方法と、View に Plot を貼って、XAMLで作りこむ方法の2種類があります。Chart の画像保存を行いたい場合、ViewModel に PlotModel がある場合にはコードから自由に Chart を画像保存できますが、View 側に Plot を貼った時には、Viewmodel 側にはデータしかないので View 側で Chart の保存を行わなくてはなりません。 今回は、ビヘイビアを作成して、それを使用することで、右clickからのクリップボードへのコピー、ファイルへの保存、ViewModelからも画像の自動保存を可能としました。

OxyContextMenuBehavior の仕様

OxyContextMenuBehaviorを保存したい画像の Plot に添付します。

f:id:feynman911:20191023215214j:plain

そうすることで、Plot 上で右クリックから Copy と Save ができるようになります。

f:id:feynman911:20191023215442j:plain

OxyContextMenuBehavior のプロパティーには次のようなものがあります。

  • FileName: ViewModel とバインドして保存ファイル名を指定。空欄の時にはダイアログが開きます。
  • ImageHeight: 保存画像の高さ。0の時には表示サイズ。
  • ImageWidth: 保存画像の幅。0の時には表示サイズ。
  • SaveChart: ViewModel とバインドして+1する時に画像が保存されます。
  • Scale: 表示倍率。

f:id:feynman911:20191128202342j:plain

プロパティの FileNameに ViewModel に作成した FileName をバインドします。プロパティのSaveChartにViewModel のChartSaveをバインドすることで、ChartSaveの変化でChartを保存する事ができるようになります。

f:id:feynman911:20191128202507j:plain

ビヘイビアコード

今回作成した OxyContextMenuBehavior は次のようなものです。

namespace ScatterPointApp.Behaviors
{
    [TypeConstraint(typeof(Plot))]
    public class OxyContextMenuBehavior : Behavior<Plot>
    {
        /// <summary>
        /// 要素にアタッチされた時にイベントハンドラを登録
        /// </summary>
        protected override void OnAttached()
        {
            base.OnAttached();

            menuItemCopy.Header = "Copy";
            menuItemSave.Header = "Save";
            menuListBox.Items.Add(menuItemCopy);
            menuListBox.Items.Add(menuItemSave);

            menuItemCopy.Click += OnCopyClick;
            menuItemSave.Click += OnSaveClick;
            this.AssociatedObject.ContextMenu = menuListBox;
        }

        private void OnSaveClick(object sender, RoutedEventArgs e)
        {
            double width = this.AssociatedObject.ActualWidth;
            double height = this.AssociatedObject.ActualHeight;
            if (ImageWidth > 0) width = ImageWidth;
            if (ImageHeight > 0) height = ImageHeight;
            if (Scale > 0)
            {
                width = width * Scale;
                height = height * Scale;
            }

            var dlg = new SaveFileDialog
            {
                Filter = ".png files|*.png|.pdf files|*.pdf",
                DefaultExt = ".png"
            };
            if (dlg.ShowDialog().Value) FileName = dlg.FileName;

            var ext = Path.GetExtension(FileName).ToLower();
            switch (ext)
            {
                case ".png":

                    PngExporter.Export(this.AssociatedObject.ActualModel, FileName, (int)width, (int)height, OxyColors.White);
                    break;
                case ".pdf":
                    using (var s = File.Create(FileName))
                    {
                        PdfExporter.Export(this.AssociatedObject.ActualModel, s, width, height);
                    }
                    break;
                default:
                    break;
            }
        }

        /// <summary>
        /// 画像保存(右クリック用)
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void OnCopyClick(object sender, RoutedEventArgs e)
        {
            double width = this.AssociatedObject.ActualWidth;
            double height = this.AssociatedObject.ActualHeight;
            if (ImageWidth > 0) width = ImageWidth;
            if (ImageHeight > 0) height = ImageHeight;
            if (Scale > 0)
            {
                width = width * Scale;
                height = height * Scale;
            }

            var pngExporter = new PngExporter
            { Width = (int)width, Height = (int)height, Background = OxyColors.White };
            Application.Current.Dispatcher.Invoke((Action)(() =>
            {
                var bitmap = pngExporter.ExportToBitmap(this.AssociatedObject.ActualModel);
                Clipboard.SetImage(bitmap);
            }));
        }

        /// <summary>
        /// 要素にデタッチされた時にイベントハンドラを解除
        /// </summary>
        protected override void OnDetaching()
        {
            base.OnDetaching();

            menuItemSave.Click -= OnSaveClick;
            menuItemCopy.Click -= OnCopyClick;
            this.AssociatedObject.ContextMenu = null;
        }

        ContextMenu menuListBox = new ContextMenu();
        MenuItem menuItemCopy = new MenuItem();
        MenuItem menuItemSave = new MenuItem();

        #region ******************************* ImageWidth
        public int ImageWidth
        {
            get { return (int)this.GetValue(ImageWidthProperty); }
            set { this.SetValue(ImageWidthProperty, value); }
        }

        public static readonly DependencyProperty ImageWidthProperty =
            DependencyProperty.Register("ImageWidth", typeof(int),
                typeof(OxyContextMenuBehavior),
                new FrameworkPropertyMetadata(0,
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                    ImageWidthChangeFunc,
                    ImageWidthCoerceFunc));

        static void ImageWidthChangeFunc(DependencyObject target,
            DependencyPropertyChangedEventArgs e)
        {
            var of = (int)e.OldValue;
            var nf = (int)e.NewValue;
            var obj = (OxyContextMenuBehavior)target;
        }

        static object ImageWidthCoerceFunc(DependencyObject target, object baseValue)
        {
            var obj = (OxyContextMenuBehavior)target;
            var val = (int)baseValue;
            if (val < 0) val = 0;
            return val;
        }
        #endregion

        #region ******************************* ImageHeight
        public int ImageHeight
        {
            get { return (int)this.GetValue(ImageHeightProperty); }
            set { this.SetValue(ImageHeightProperty, value); }
        }

        public static readonly DependencyProperty ImageHeightProperty =
            DependencyProperty.Register("ImageHeight", typeof(int),
                typeof(OxyContextMenuBehavior),
                new FrameworkPropertyMetadata(0,
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                    ImageHeightChangeFunc,
                    ImageHeightCoerceFunc));

        static void ImageHeightChangeFunc(DependencyObject target,
            DependencyPropertyChangedEventArgs e)
        {
            var of = (int)e.OldValue;
            var nf = (int)e.NewValue;
            var obj = (OxyContextMenuBehavior)target;
        }

        static object ImageHeightCoerceFunc(DependencyObject target, object baseValue)
        {
            var obj = (OxyContextMenuBehavior)target;
            var val = (int)baseValue;
            if (val < 0) val = 0;
            return val;
        }
        #endregion

        #region ******************************* Scale
        public double Scale
        {
            get { return (double)this.GetValue(ScaleProperty); }
            set { this.SetValue(ScaleProperty, value); }
        }

        public static readonly DependencyProperty ScaleProperty =
            DependencyProperty.Register("Scale", typeof(double),
                typeof(OxyContextMenuBehavior),
                new FrameworkPropertyMetadata(1.0,
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                    ScaleChangeFunc,
                    ScaleCoerceFunc));

        static void ScaleChangeFunc(DependencyObject target,
            DependencyPropertyChangedEventArgs e)
        {
            var of = (double)e.OldValue;
            var nf = (double)e.NewValue;
            var obj = (OxyContextMenuBehavior)target;
        }

        static object ScaleCoerceFunc(DependencyObject target, object baseValue)
        {
            var obj = (OxyContextMenuBehavior)target;
            var val = (double)baseValue;
            if (val < 0.5) val = 0.5;
            if (val > 5.0) val = 5.0;
            return val;
        }
        #endregion

        #region ******************************* FileName
        public string FileName
        {
            get { return (string)this.GetValue(FileNameProperty); }
            set { this.SetValue(FileNameProperty, value); }
        }

        public static readonly DependencyProperty FileNameProperty =
            DependencyProperty.Register("FileName", typeof(string),
                typeof(OxyContextMenuBehavior),
                new FrameworkPropertyMetadata("",
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        #endregion

        #region ******************************* SaveChart
        public int SaveChart
        {
            get { return (int)this.GetValue(SaveChartProperty); }
            set { this.SetValue(SaveChartProperty, value); }
        }

        public static readonly DependencyProperty SaveChartProperty =
            DependencyProperty.Register("SaveChart", typeof(int),
                typeof(OxyContextMenuBehavior),
                new FrameworkPropertyMetadata(0,
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                    SaveChartChangeFunc,
                    SaveChartCoerceFunc));

        static void SaveChartChangeFunc(DependencyObject target,
            DependencyPropertyChangedEventArgs e)
        {
            var of = (int)e.OldValue;
            var nf = (int)e.NewValue;
            var obj = (OxyContextMenuBehavior)target;
            obj.Save();
        }

        static object SaveChartCoerceFunc(DependencyObject target, object baseValue)
        {
            var obj = (OxyContextMenuBehavior)target;
            var val = (int)baseValue;
            return val;
        }
        #endregion

        /// <summary>
        /// 画像保存
        /// </summary>
        private void Save()
        {
            string filename = FileName;

            if (filename == "")
            {
                var dlg = new SaveFileDialog
                {
                    Filter = ".png files|*.png",
                    DefaultExt = ".png"
                };
                if (dlg.ShowDialog().Value) filename = dlg.FileName;
            }

            Plot plot = this.AssociatedObject as Plot;

            double width = plot.ActualWidth;
            double height = plot.ActualHeight;
            if (ImageWidth > 0) width = ImageWidth;
            if (ImageHeight > 0) height = ImageHeight;
            if (Scale > 0)
            {
                width = width * Scale;
                height = height * Scale;
            }

            if (filename != "")
            {
                var ext = Path.GetExtension(filename).ToLower();
                if (ext == ".png")
                {
                    PngExporter.Export(plot.ActualModel, filename, (int)width, (int)height, OxyColors.White);
                }
            }

        }
    }
}

ViewModel に次のようなコードを追加します。FileNameを空欄にするとダイアログが開くようになります。

        private string fileName = "";
        public string FileName
        {
            get { return fileName; }
            set { SetProperty(ref fileName, value); }
        }

        private int chartSave = 0;
        public int ChartSave
        {
            get { return chartSave; }
            set { SetProperty(ref chartSave, value); }
        }

        private DelegateCommand commandSave;
        public DelegateCommand CommandSave =>
            commandSave ?? (commandSave = new DelegateCommand(ExecuteCommandSave));

        async void ExecuteCommandSave()
        {
            await Task.Run(() => Save());
        }

        void Save()
        {
            Application.Current.Dispatcher.Invoke(
                new Action(() =>
                {
                    FileName = "Test_.png";
                    ChartSave++;
                }));
        }

Chartが保存されるのは、ChartSaveの値が変化するタイミングなので、ChartSaveは毎回+1するようにしています。

注意点として、UIスレッド以外から使用すると動作が不安定となるので、その時には上記の様に Dispatcher.InvokeでUIスレッドから実行するようにします。

まとめ

Plot に添付して ContextMenu を表示する Behaivior です。
ファイル保存の Save とクリップボードへのコピーの Copy が右クリックで表示されるようになります。
ファイル保存用の FileName プロパティーと、画像サイズを指定できるように ImageHeight と ImageWidth を設定してあります。
Scale プロパティーは表示画像サイズをベースにして指定倍率で画像を扱う時に使用できます。

作成したソースコードの場所

ソースコードは次の場所に置いてあります。

github.com