ایجاد یک کنترل نمودار خطی ساده با استفاده از WPF

پنجشنبه 7 مرداد 1395

در این مقاله قصد داریم تا با استفاده از تکنولوژی wpf، یک custom control ساده برای رسم نمودارها ایجاد کنیم که استفاده از آن بسیار آسان باشد.

ایجاد یک کنترل نمودار خطی ساده با استفاده از WPF

معرفی

تعداد کمی چارت کنترل در اینترنت و یا حتی در WPF Toolkit وجود دارد. مشکلی که ما با این کنترل ها داریم این است که آنها برای ما کار نمیکنند. ما یک نمودار ساده و آسان و سریع میخواهیم که در این جا اسم آن را BasicChart میگذاریم. این کنترل فقط مقادیری که توسط یک ObservableCollection به ItemsSource این کنترل bound شده است را نمایش می دهد.

چگونه از این کنترل استفاده کنیم؟

در ابتدا ما فرض میکنیم که شما فقط میخواهید بدانید که چگونه باید از این کنترل استفاده کرد، پس ما سریع سراغ ویژگی های اصلی میرویم.

ما فرض خواهیم کرد که داده های سری های شما در یک متغییر از نوع ObservableCollection<LineSeries> ذخیره شده است. کلاس LineSeries باید داده ها را برای ساخت یک نمودار 2 بعدی نگهداری کند. این داده ها میتوانند در یک Observable Collection یا هر شی دیگری که میتواند توسط یک IEnumerable ای که یک متغییر برای جداگانه برای X و یک متغییر برای Y  و یک متغییر اختیاری برای نمایش عنوان برای هر منحنی نمایش داده شود، قرار بگیرد. اگر عنوان را برای یک منحنی تعیین نکنیم خودش به آن نامی مانند "منحنی شماره 1" بر اساس محل قرار گیری آن در ObservableCollection اختصاص میدهد.

پس کلاس Data شبیه کلاس زیر میشود، کلاس NotiferBase فقط یک Wrapper برای PropertyChanged میباشد:

public class Data : NotifierBase
{
    private double m_Frequency = new double();
    public double Frequency
    {
        get { return m_Frequency; }
        set
        {
            SetProperty(ref m_Frequency, value);
        }
    }

    private double m_Value = new double();
    public double Value
    {
        get { return m_Value; }
        set
        {
            SetProperty(ref m_Value, value);
        }
    }
}

و کلاس LineSeries به صورت زیر میشود:

public class LineSeries : NotifierBase
{
    private ObservableCollection<Data> m_MyData = new ObservableCollection<Data>();
    public ObservableCollection<Data> MyData
    {
        get { return m_MyData; }
        set
        {
            SetProperty(ref m_MyData, value);
        }
    }

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

و کد XAML ای که چارت را تشکیل میدهد به شکل زیر است:

<Chart:BasicChart x:Name="MyChart" Height="350" Width="500"
               DataCollectionName="MyData"
               DisplayMemberLabels="Frequency"
               DisplayMemberValues="Value"
               SkipLabels="3"
               StartSkipAt="1"
               ShowGraphPoints="True"
               ChartTitle="Calcualted values" YLabel="Magnitude"
               XLabel="Freqency [Hz]" YMax="60" YMin="0" DoubleToString="N0"
                  XMin="1" XMax="24"/>

 

یک سری تنظیمات پیش فرض معمولی برای کنترل BasicChart در فایل XAML قرار داده شده است.

 

Layout

BasicChart به عنوان یک UserControl ساخته شده است و موثر ترین و بهترین راه برای قرار دادن محتویات نمودار داخل آن استفاده از سطرها و  ستون های Grid است.

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="40"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="50"></RowDefinition>
        <RowDefinition Height="*"/>
        <RowDefinition Height="40"/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>

Grid به ما layout زیر را میدهد:

 

عنوان "Calculated values" در ردیف اول و با ارتفاع 50 قرار دارد و نمودار در ردیف دوم قرار دارد که ما بقی فضای Grid را اشغال میکند. عنوان "ّFrequency" در ردیف سوم و با ارتفاع ثابت 40 قرار دارد. ردیف آخر برای CheckBox ها رزرو شده است که این قابلیت را به ما میدهند تا بتوانیم منحنی های مختلف را مخفی کنیم و بعد دوباره نمایش دهیم. ارتفاع این ردیف Auto قرار داده شده است تا بر اساس محتویات درون آن ردیف محاسبه شود.  تمامی lable ها و عنوان های داخل این کنترل همگی textBlock هایی هستند که تعدادی style روی آنها اعمال شده است. تمامی آنها به Dependency property ها در کد بایند شده اند ، پس بدون معطلی بیشتر:

<TextBlock Grid.Column="0" Grid.Row="0" Grid.ColumnSpan="2"
           FontSize="16" FontWeight="ExtraBold" TextAlignment="Center"
           Text="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:BasicChart}}, Path=ChartTitle}"></TextBlock>

تمام جذابیت و همچنین دشواری کار در قسمت مرکزی نمودار است.همچنین ، پهنا و ارتفاع سلول های Grid به * تنظیم شده است و به این معنی است که تمامی فضای باقی مانده را اشغال خواهند کرد. ما به پهنا و ارتفاع سلول گرید احتیاج داریم پس برای به دست آوردن آن تمامی شی هایی که داخل سلول های گرید هستند داخل یک Boarder  با ویژگی x:Name قرار میگیرند.برای اینکه این عصر را از دید کاربران این کنترل مخفی کنیم،ما آنها را با استفاده از x:FieldModifier ، Private میکنیم.

<Border x:FieldModifier="private" x:Name="PlotAreaBorder"
              SizeChanged="PlotAreaBorder_SizeChanged"
              Grid.Row="1" Grid.Column="1"
              HorizontalAlignment="Stretch" VerticalAlignment="Stretch">


              ...

</Border>

با این روش PlotAreaBorder فقط در Code Behind این User Controlدر دسترس خواهد بود اما در بیرون user Control parent در دسترس نخواهد بود. رویداد SizeChanged نیز برای این Boarder در نظر گرفته شده است.

در داخل این Border فقط یک canvas وجود داردکه تمامی اشیائی که مربوط به نمودار می شود را به عنوان فرزندانش در خود دارد:

<Canvas Background="White" >
    <Canvas.Children>
        <Polyline x:Name="YAxisLine" ...
        <Polyline x:Name="XAxisLine" ...
        <ItemsControl x:Name="PlotArea" ...
        <ItemsControl x:Name="YAxis" ...
        <ItemsControl x:Name="XAxis" ...
    </Canvas.Children>
</Canvas>

دو نمودار اول در تمام فضای موجود رسم خواهند شد، که هر دوی آنها از 40 واحد از سمت راست و 40 واحد از گوشه ی پایین سمت چپ شروع میشوند.فضای خالی سمت چپ محلی برای قرار گیری lable های محور y ها و فضای پایین محل قرار گیری عنوان های محور X ها می باشد.تمامی این فیلدها به همراه x:FieldModefire=”private” تعریف میشوند به دلیل اینکه ما میخواهیم آنها را در هنگام مقدار دهی ItemSource کنترل اصلی تنظیم کنیم.

ItemControl ها خودشان کمی جذاب هستند، آنها ما را قادر میسازند تا تمامی آیتم های درون یک لیست (در برنامه ما یک Observable Collection) را به یک منحنی در نمودار متصل کنیم. (Bind). این یک مزیت است که هنگامی که ما میخواهیم به صورت موقت یک منحنی را غیر فعال کنیم فیلد های X و Y نباید تغییر کنند. به غیر از متغیر هایی که تغییر کرده اند آنها کاملا شبیه به یکدیگر هستند:

<ItemsControl x:FieldModifier="private" x:Name="YAxis" Canvas.Bottom="40" Canvas.Left="0" Width="40" Height="170" ItemsSource="{Binding YItems}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Canvas/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemContainerStyle>
        <Style>
            <Setter Property="Canvas.Bottom" Value="{Binding ElementName=YAxis, Path=YLocation}"/>
            <Setter Property="Canvas.Left" Value="40"/>
        </Style>
    </ItemsControl.ItemContainerStyle>
</ItemsControl>

حتما توجه کنید که شما باید به User Control صفت x:Name را بدهید تا بتوانید آن را به کنترل های فرزندش در لیست متصل کنید. به صورت خلاصه شما برای اینکه بتوانید کنترل های مرتبط را در فایل XAML به صورت واحد تعریف و پیدا کنید به آن نیاز خواهید داشت. همچنین این مورد برای اعمال هر تغییر در User Control و یا اعمال انیمیشن ها و غیره نیز صدق میکند.

ItemsControl PlotArea کمی متفاوت خواهد بود، ما آن را فقط برای رسم نمودار ها به صورت مستقیم و بدون تغییر مقادیر Y نیاز خواهیم داشت. برای تغییر موقعیت آن به سمت راست در هر نمودار از ScaleTransform استفاده خواهیم کرد.

<ItemsControl x:FieldModifier="private" x:Name="PlotArea" Canvas.Bottom="40" Canvas.Left="40"  ClipToBounds="True"  ItemsSource="{Binding}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Canvas >
                <Canvas.LayoutTransform>
                    <ScaleTransform ScaleX="1" ScaleY="-1"></ScaleTransform>
                </Canvas.LayoutTransform>
            </Canvas>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
</ItemsControl>

هر دوتا Lable User Control ، X وY با ClassModefier=”internal” مشخص شده اند. که این مورد در صورتی که بخواهید نمودار را در یک فایل DLL جداگانه کامپایل کنید از دسترس خارج خواهد کرد.

ColorGenerator از این پرسش در StackOverflow به دست آمده است.

: Code Behind

همان گونه که شما از یک UserControl انتظار دارید، این نمودار یک سری خاصیت (Property) دارد که با تنظیم آنها میتوانید تعیین کنید که نمودار چگونه رفتار کند و چطور به نظر برسد.مهم ترین این پراپرتی ها ، پراپرتی ItemsSource میباشد که قرار است تمامی مقادیری که رسم میشوند را در خود نگهداری میکند.

public static readonly DependencyProperty ItemsSourceProperty =
    DependencyProperty.Register("ItemsSource", typeof(IEnumerable), typeof(BasicChart),
        new FrameworkPropertyMetadata(null,
            new PropertyChangedCallback(OnItemsSourceChanged)));

public IEnumerable ItemsSource
{
    get { return (IEnumerable)GetValue(ItemsSourceProperty); }
    set { SetValue(ItemsSourceProperty, value); }
}

این کنترل به همراه تمامی خواصی(Property) که کالکشنی که به آن متصل هستند و یا ظاهر را تغییر میدهند، یک PropertyChangedCallBack را نیز پیاده سازی کرده است، و تمام اعمال مربوط به Binding در آنجا انجام میشود. اولین کاری که در کد انجام می شود تنظیم کردن listener های مربوط به Property Changed  و Collection Changed است. این ها زمانی به کار می آیند که شما آیتمی را به لیست اضافه و یا حذف میکنید و یا میخواهید رسم یک نمودار را فعال و یا غیر فعال کنید. رسم المان ها با فراخوانی SetUpYAxis و SetUpXAxis و SetUpGraph  انجام می شود.

private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var MyBasicChart = (BasicChart)d;

    foreach (var item in MyBasicChart.ItemsSource)
    {
        int i = MyBasicChart.CurveVisibility.Count;
        // Set up a Notification if the IsChecked property is changed
        MyBasicChart.CurveVisibility.Add(new CheckBoxClass() { BackColor = DistinctColorList[i], Name = "Curve nr: " + (i+1).ToString() });

        ((INotifyPropertyChanged)MyBasicChart.CurveVisibility[MyBasicChart.CurveVisibility.Count - 1]).PropertyChanged +=
            (s, ee) => OnCurveVisibilityChanged(MyBasicChart, (IEnumerable)e.NewValue);
    }

    if (e.NewValue != null)
    {
        // Assuming that the curves are binded using an ObservableCollection,
        // it needs to update the Layout if items are added, removed etc.
        if (e.NewValue is INotifyCollectionChanged)
            ((INotifyCollectionChanged)e.NewValue).CollectionChanged += (s, ee) =>
            ItemsSource_CollectionChanged(MyBasicChart, ee, (IEnumerable)e.NewValue);
    }

    if (e.OldValue != null)
    {
        // Unhook the Event
        if (e.OldValue is INotifyCollectionChanged)
            ((INotifyCollectionChanged)e.OldValue).CollectionChanged -=
                (s, ee) => ItemsSource_CollectionChanged(MyBasicChart, ee, (IEnumerable)e.OldValue);

    }

    // Check that the properties to bind to is set
    if (MyBasicChart.DisplayMemberValues != "" && MyBasicChart.DisplayMemberLabels != "" && MyBasicChart.DataCollectionName != "")
    {
        SetUpYAxis(MyBasicChart);
        SetUpXAxis(MyBasicChart);
        SetUpGraph(MyBasicChart, (IEnumerable)e.NewValue);
    }
    else
    {
        MessageBox.Show("Values that indicate the X value and the resulting Y value must be given, as well as the name of the Collection");
    }
}

تمامی کاری که مانده است این است که UserControl را طوری تغییر دهیم که نسبت به تغییرات در لیست و یا تغییر در Property به نام  visibility یکی از نمودارها واکنش نشان دهد. ما با رویداد CollectionChanged شروع میکنیم:

private static void ItemsSource_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e, IEnumerable eNewValue)
{
    var MyClass = (BasicChart)sender;

    if (e.Action == NotifyCollectionChangedAction.Add)
    {
        MyClass.CurveVisibility.Add(new CheckBoxClass() {
            BackColor = DistinctColorList[MyClass.CurveVisibility.Count],
            IsChecked = true,
            Name = "Curve nr: " + (MyClass.CurveVisibility.Count+1).ToString() });
        ((INotifyPropertyChanged)MyClass.CurveVisibility[MyClass.CurveVisibility.Count - 1]).PropertyChanged
            += (s, ee) => OnCurveVisibilityChanged(MyClass, eNewValue);
    }
    else if (e.Action == NotifyCollectionChangedAction.Remove)
    {
        ((INotifyPropertyChanged)MyClass.CurveVisibility[e.OldStartingIndex]).PropertyChanged
            -= (s, ee) => OnCurveVisibilityChanged(MyClass, eNewValue);
        MyClass.CurveVisibility.RemoveAt(e.OldStartingIndex);
    }

    if (MyClass.DisplayMemberValues != "" && MyClass.DisplayMemberLabels != "" && MyClass.DataCollectionName!= "")
    {
        SetUpYAxis(MyClass);
        SetUpXAxis(MyClass);
        SetUpGraph(MyClass, eNewValue);
    }
}

همان طور که میبینید ما فقط پشتیبانی از Add و Remove را اضافه کرده ایم و در واقع تمام کاری که ما انجام داده ایم اضافه و یا حذف کردن handler هایی که در ارتباط با visibility هر یک از action ها هستند می باشد. اگر Collection تغییر کند ما همه چیز را از ابتدا رسم میکنیم ، به دلیل اینکه یک مقدار جدید برای Y یا یک مقدار اضافه برای X باعث ایحاد تغییر در رسم تمامی نمودار ها می شود.( اگرچه در حال حاضر ما از مقادیر متفاوت برای مقدار X هریک از نمودار ها پشتیبانی نمیکنیم، شما باید تمامی مقادیری را که نمیخواهید نمایش دهید را به Double.NaN تغییر دهید.)

زمانی که visibility هر یک از نمودار ها تغییر کرد تنها کافی است که نمودار را دوباره رسم کنیم، حتی با وجود اینکه مقادیر Y تغییر نکردند و یا هیچ آیتم جدیدی به کالکشن اضافه نشده است.

private static void OnCurveVisibilityChanged(BasicChart sender, IEnumerable NewValues)
{
    SetUpGraph(sender, NewValues);
}

رسم نمودارها و محور ها

همان طور که قبلا توضیح داده شد، رسم منحنی و همین طور محور های X و Y با استفاده از یک ItemsControl انجام می شود. این به این معنی است که تمامی این اجزا به پراپرتی ItemsSource از ItemsControl بایند شده اند.

محور های X و Y درواقع یک UserControl با XAML زیر هستند:

<UserControl x:Class="WpfAcousticTransferMatrix.ChartControl.XAxisLabels"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:ClassModifier="internal"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WpfAcousticTransferMatrix.ChartControl"
             x:Name="XAxis"
             mc:Ignorable="d" 
              d:DesignHeight="40" d:DesignWidth="20">
    <UserControl.Resources>
        <BooleanToVisibilityConverter x:Key="BoolConverter"></BooleanToVisibilityConverter>
    </UserControl.Resources>
    <Canvas>
        <Canvas.Children>
            <Polyline x:Name="XLine" Points="0,0 0,5" Stroke="{Binding RelativeSource={RelativeSource 
                            AncestorType=UserControl}, Path=LineColor}"  StrokeThickness="1"/>
            <TextBlock x:Name="MyLabel" Width="50" Margin="-25,0,0,0" TextAlignment="Center" Text="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=XLabel}" Visibility="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=XLabelVisible, Converter={StaticResource BoolConverter}}" Canvas.Top="10">
                <TextBlock.LayoutTransform>
                    <RotateTransform Angle="{Binding RelativeSource={RelativeSource 
                            AncestorType=UserControl}, Path=LabelAngle}"></RotateTransform>
                </TextBlock.LayoutTransform>
            </TextBlock>
        </Canvas.Children>
    </Canvas>
</UserControl>

کد کاملا واضح و مشخص است، به جز قسمتی که مربوط به فیلد text rotation می شود. از لحاظ ظاهری بهتر است که اگر چرخشی نداشت در وسط قرار بگیرد درحالی که عنوانی که در سمت چپ قرار دارد طوری بچرخد که اول متن در ابتدای خط قرار بگیرد.

public static void OnLabelAngleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var test = (XAxisLabels)d;
    double value = (double)e.NewValue;

    if (value == 0)
    {
        test.MyLabel.Margin = new Thickness(-25, 0, 0, 0);
        test.MyLabel.TextAlignment = TextAlignment.Center;
    }
    else
    {
        test.MyLabel.Margin = new Thickness(0, 0, 0, 0);
        test.MyLabel.TextAlignment = TextAlignment.Left;
    }

}

 

نکته دیگری که ما برای زیبایی کنترل انجام داده ایم در مورد طول خط است در مواردی کهlable نداریم:

private static void XLabelChange(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var MyLabelClass = (XAxisLabels)d;
    if (MyLabelClass.XLabel == "")
    {
        MyLabelClass.XLine.Points[1] = new Point(0, 5);
    }
    else
    {
        MyLabelClass.XLine.Points[1] = new Point(0, 10);
    }
}

محور Y مربوط بهUserControl  تقریبا همیشه یکسان است به جز مواردی که امکان rotate کردن فیلد text را نداریم.

فایل های ضمیمه

سجاد باقرزاده

نویسنده 54 مقاله در برنامه نویسان
  • WPF
  • 3k بازدید
  • 3 تشکر

کاربرانی که از نویسنده این مقاله تشکر کرده اند

در صورتی که در رابطه با این مقاله سوالی دارید، در تاپیک های انجمن مطرح کنید