I would like if it is possible to display gantt chart in your WPF data chart.
I was thinking of a few possible ways. One would be to uses stacked (step) area with the bottom area transparent but this would not be very efficient as it would require to have a lot more data per displayed bar than a predefined gantt (range bar) chart.
Another way might be to use stacked Y axis and uses one Y axis for each series and then uses step area for that serie.
It might also be possible to uses range area but it would require to add twice as much points to the series.
I want that the solution be as memory efficient as possible and typically a zoom on time axis (X) would be done to show one day (or less) data but we might have a few month of data. I might have about 10 series (tasks) that are repeatable and might occurs up to a few hundred times per day.
I might dynamically update series as the user scroll if it is required to have a good performance (be responsive when the user scroll) as I was doing with WinForms In fragistics gantt chart. When the user scroll, I do not want to have noticeable latency on a typical recent computer.
I am still evaluating a few chart components. Infragistics xamDataChart seems to be more complete that most packages but miss Gantt series (or range bars) and have a few other issues.
It may be possible to emulate one with a range area series, I'll get back to you.
Here's a start of how you might emulate a gantt chart over a scatter chart. There are various things that you would extend here to get better performance, they have been left as an excercise to the reader. This sample may also not encompass some of the functionality you would expect from a gantt chart, obviously, but I hope you find it useful:
The Xaml:
<Window.Resources> <local:TestGanttData1 x:Key="data1" /> <local:TestGanttData2 x:Key="data2" /> <local:TestGanttData3 x:Key="data3" /> </Window.Resources> <Grid> <local:LiteGanttChart x:Name="gantt" MinimumValue="1/1/2010" MaximumValue="1/12/2010" Interval="1.00:00:00"> <local:LiteGanttChart.GanttToolTip> <Border Background="White" BorderBrush="Gray" BorderThickness="1" IsHitTestVisible="False" > <StackPanel> <StackPanel Orientation="Horizontal"> <TextBlock Text="{Binding Item.StartTime, StringFormat=MM/dd}" /> <TextBlock Text=" - " /> <TextBlock Text="{Binding Item.EndTime, StringFormat=MM/dd}" /> </StackPanel> <TextBlock Text="{Binding Item.Description}" /> </StackPanel> </Border> </local:LiteGanttChart.GanttToolTip> <local:LiteGanttChart.Series> <local:GanttSeries TaskName="Task1" StartTimeMemberPath="StartTime" EndTimeMemberPath="EndTime" ItemsSource="{StaticResource data1}" /> <local:GanttSeries TaskName="Task2" StartTimeMemberPath="StartTime" EndTimeMemberPath="EndTime" ItemsSource="{StaticResource data2}" /> <local:GanttSeries TaskName="Task3" StartTimeMemberPath="StartTime" EndTimeMemberPath="EndTime" ItemsSource="{StaticResource data3}" /> </local:LiteGanttChart.Series> </local:LiteGanttChart> </Grid>
And the code behind:
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } } public class GanttSeries : FrameworkElement { public GanttSeries() { Data = new ObservableCollection<LiteGanttData>(); } public static readonly DependencyProperty TaskNameProperty = DependencyProperty.Register("TaskName", typeof(string), typeof(GanttSeries), new PropertyMetadata(null, (o, e) => (o as GanttSeries).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); public string TaskName { get { return (string)GetValue(TaskNameProperty); } set { SetValue(TaskNameProperty, value); } } public static readonly DependencyProperty TaskValueProperty = DependencyProperty.Register("TaskValue", typeof(double), typeof(GanttSeries), new PropertyMetadata(-1.0, (o, e) => (o as GanttSeries).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); public double TaskValue { get { return (double)GetValue(TaskValueProperty); } set { SetValue(TaskValueProperty, value); } } public static readonly DependencyProperty BrushProperty = DependencyProperty.Register("Brush", typeof(Brush), typeof(GanttSeries), new PropertyMetadata(null, (o, e) => (o as GanttSeries).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); public Brush Brush { get { return (Brush)GetValue(BrushProperty); } set { SetValue(BrushProperty, value); } } public static readonly DependencyProperty StartTimeMemberPathProperty = DependencyProperty.Register("StartTimeMemberPath", typeof(string), typeof(GanttSeries), new PropertyMetadata(null, (o, e) => (o as GanttSeries).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); public string StartTimeMemberPath { get { return (string)GetValue(StartTimeMemberPathProperty); } set { SetValue(StartTimeMemberPathProperty, value); } } public static readonly DependencyProperty EndTimeMemberPathProperty = DependencyProperty.Register("EndTimeMemberPath", typeof(string), typeof(GanttSeries), new PropertyMetadata(null, (o, e) => (o as GanttSeries).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); public string EndTimeMemberPath { get { return (string)GetValue(EndTimeMemberPathProperty); } set { SetValue(EndTimeMemberPathProperty, value); } } public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register("ItemsSource", typeof(IEnumerable), typeof(GanttSeries), new PropertyMetadata(null, (o, e) => (o as GanttSeries).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); public IEnumerable ItemsSource { get { return (IEnumerable)GetValue(ItemsSourceProperty); } set { SetValue(ItemsSourceProperty, value); } } private FastItemsSource _fastItemsSource; protected FastItemsSource FastItemsSource { get { return _fastItemsSource; } set { FastItemsSource old = _fastItemsSource; _fastItemsSource = value; OnPropertyChanged("FastItemsSource", old, _fastItemsSource); } } protected internal IFastItemColumn<DateTime> StartTimeColumn { get { return _startTimeColumn; } private set { if (_startTimeColumn != value) { IFastItemColumn<DateTime> oldStartTimeColumn = _startTimeColumn; _startTimeColumn = value; OnPropertyChanged("StartTimeColumn", oldStartTimeColumn, _startTimeColumn); } } } private IFastItemColumn<DateTime> _startTimeColumn; protected internal IFastItemColumn<DateTime> EndTimeColumn { get { return _endTimeColumn; } private set { if (_endTimeColumn != value) { IFastItemColumn<DateTime> oldEndTimeColumn = _endTimeColumn; _endTimeColumn = value; OnPropertyChanged("EndTimeColumn", oldEndTimeColumn, _endTimeColumn); } } } private IFastItemColumn<DateTime> _endTimeColumn; public ObservableCollection<LiteGanttData> Data { get; private set; } private void OnPropertyChanged(string propertyName, object oldValue, object newValue) { switch (propertyName) { case "StartTimeMemberPath": if (oldValue != null && FastItemsSource != null) { FastItemsSource.DeregisterColumn(StartTimeColumn); StartTimeColumn = null; } if (newValue != null && FastItemsSource != null) { StartTimeColumn = FastItemsSource.RegisterColumnDateTime( StartTimeMemberPath); } ResetData(); break; case "EndTimeMemeberPath": if (oldValue != null && FastItemsSource != null) { FastItemsSource.DeregisterColumn(EndTimeColumn); EndTimeColumn = null; } if (newValue != null && FastItemsSource != null) { EndTimeColumn = FastItemsSource.RegisterColumnDateTime( EndTimeMemberPath); } ResetData(); break; case "TaskName": OnNameChanged(); ResetData(); break; case "TaskValue": ResetData(); break; case "ItemsSource": if (newValue != null) { FastItemsSource source = new FastItemsSource(); source.ItemsSource = (IEnumerable)newValue; FastItemsSource = source; } else { FastItemsSource = null; } break; case "FastItemsSource": if (oldValue != null) { (oldValue as FastItemsSource).Event -= source_Event; if (StartTimeColumn != null) { (oldValue as FastItemsSource) .DeregisterColumn(StartTimeColumn); StartTimeColumn = null; } if (EndTimeColumn != null) { (oldValue as FastItemsSource) .DeregisterColumn(EndTimeColumn); EndTimeColumn = null; } } if (newValue != null) { (newValue as FastItemsSource).Event += source_Event; if (StartTimeMemberPath != null) { StartTimeColumn = (newValue as FastItemsSource) .RegisterColumnDateTime(StartTimeMemberPath); } if (EndTimeMemberPath != null) { EndTimeColumn = (newValue as FastItemsSource) .RegisterColumnDateTime(EndTimeMemberPath); } } ResetData(); break; } } public LiteGanttChart Owner { get; set; } private void OnNameChanged() { if (Owner != null) { Owner.ResetSeries(); } } void source_Event(object sender, FastItemsSourceEventArgs e) { //excersize to the reader, do something more //efficient here. ResetData(); } private void ResetData() { Data.Clear(); if (FastItemsSource != null && StartTimeColumn != null && EndTimeColumn != null && StartTimeColumn.Count == EndTimeColumn.Count && EndTimeColumn.Count == FastItemsSource.Count && !String.IsNullOrEmpty(TaskName) && TaskValue >= 0) { for (int i = 0; i < FastItemsSource.Count; i++) { foreach (var item in GetLiteGanttData( StartTimeColumn[i], EndTimeColumn[i])) { Data.Add(item); } } } } private IEnumerable<LiteGanttData> GetLiteGanttData( DateTime startTime, DateTime endTime) { LiteGanttData preceding = new LiteGanttData() { Task = TaskName, StartTime = startTime, EndTime = endTime, Time = startTime.AddMilliseconds(-1), Value = double.NaN }; yield return preceding; LiteGanttData start = new LiteGanttData() { Task = TaskName, StartTime = startTime, EndTime = endTime, Time = startTime, Value = TaskValue }; yield return start; LiteGanttData end = new LiteGanttData() { Task = TaskName, StartTime = startTime, EndTime = endTime, Time = endTime, Value = TaskValue }; yield return end; LiteGanttData succeeding = new LiteGanttData() { Task = TaskName, StartTime = startTime, EndTime = endTime, Time = endTime.AddMilliseconds(1), Value = double.NaN }; yield return succeeding; } } public class LiteGanttData : INotifyPropertyChanged { private string _task; public string Task { get { return _task; } set { _task = value; RaisePropertyChanged("Task"); } } private DateTime _startTime; public DateTime StartTime { get { return _startTime; } set { _startTime = value; RaisePropertyChanged("StartTime"); } } private DateTime _endTime; public DateTime EndTime { get { return _endTime; } set { _endTime = value; RaisePropertyChanged("EndTime"); } } private DateTime _time; public DateTime Time { get { return _time; } set { _time = value; RaisePropertyChanged("Time"); } } private double _value; public double Value { get { return _value; } set { _value = value; RaisePropertyChanged("Value"); } } private void RaisePropertyChanged(string propertyName) { if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs( propertyName)); } } public event PropertyChangedEventHandler PropertyChanged; } public class TaskNameConverter : IValueConverter { public object Convert( object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { if (parameter == null || !(parameter is string)) { return ""; } string[] names = ((string)parameter).Split(','); if (!(value is double)) { return ""; } double val = (double)value; int index = (int)val; index--; if (targetType == typeof(string) && index >= 0 && index < names.Length) { return names[index]; } return ""; } public object ConvertBack( object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { throw new NotImplementedException(); } } public class TicksToDateTimeConverter : IValueConverter { #region IValueConverter Members object IValueConverter.Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { return new DateTime(Convert.ToInt64(value)).ToString("MM/dd"); } object IValueConverter.ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { return ((DateTime)value).Ticks; } #endregion } public class LiteGanttChart : ContentControl { private Grid _grid = new Grid(); private XamDataChart _chart = new XamDataChart(); public static readonly DependencyProperty MinimumValueProperty = DependencyProperty.Register("MinimumValue", typeof(DateTime), typeof(LiteGanttChart), new PropertyMetadata(DateTime.MinValue, (o, e) => (o as LiteGanttChart).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); private void OnPropertyChanged(string propertyName, object oldValue, object newValue) { switch (propertyName) { case "MaximumValue": ResetSeries(); break; case "MinimumValue": ResetSeries(); break; case "Interval": ResetSeries(); break; } } public DateTime MinimumValue { get { return (DateTime)GetValue(MinimumValueProperty); } set { SetValue(MinimumValueProperty, value); } } public static readonly DependencyProperty MaximumValueProperty = DependencyProperty.Register("MaximumValue", typeof(DateTime), typeof(LiteGanttChart), new PropertyMetadata(DateTime.MaxValue, (o, e) => (o as LiteGanttChart).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); public DateTime MaximumValue { get { return (DateTime)GetValue(MaximumValueProperty); } set { SetValue(MaximumValueProperty, value); } } public static readonly DependencyProperty IntervalProperty = DependencyProperty.Register("Interval", typeof(TimeSpan), typeof(LiteGanttChart), new PropertyMetadata(TimeSpan.MaxValue, (o, e) => (o as LiteGanttChart).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); public TimeSpan Interval { get { return (TimeSpan)GetValue(IntervalProperty); } set { SetValue(IntervalProperty, value); } } public static readonly DependencyProperty GanttToolTipProperty = DependencyProperty.Register("GanttToolTip", typeof(UIElement), typeof(LiteGanttChart), new PropertyMetadata(null, (o, e) => (o as LiteGanttChart).OnPropertyChanged( e.Property.Name, e.OldValue, e.NewValue))); public UIElement GanttToolTip { get { return (UIElement)GetValue(GanttToolTipProperty); } set { SetValue(GanttToolTipProperty, value); } } public LiteGanttChart() { _series = new GanttSeriesCollection(); Series.CollectionChanged += Series_CollectionChanged; _chart = CreateChart(); HorizontalContentAlignment = HorizontalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch; Content = _grid; _grid.Children.Add(_chart); _chart.SeriesMouseEnter += _chart_SeriesMouseEnter; _chart.SeriesMouseMove += _chart_SeriesMouseMove; _chart.SeriesMouseLeave += _chart_SeriesMouseLeave; } private Popup _popup = null; void _chart_SeriesMouseLeave(object sender, ChartMouseEventArgs e) { HidePopup(); } private void HidePopup() { if (_popup == null) { return; } _popup.IsOpen = false; } void _chart_SeriesMouseMove(object sender, ChartMouseEventArgs e) { GanttDataContext context = GetGanttDataContext(e); UpdatePopup(e.GetPosition(this), context); } private GanttDataContext GetGanttDataContext(ChartMouseEventArgs e) { if (e.Series == null) { return null; } var ganttSeries = Series[_chart.Series.IndexOf(e.Series)]; var ganttDataContext = new GanttDataContext(); ganttDataContext.GanttSeries = ganttSeries; ganttDataContext.Series = e.Series; var scatter = e.Series as ScatterLineSeries; if (scatter == null) { return null; } var pos = e.GetPosition(scatter); double xVal = scatter.XAxis.GetUnscaledValue(pos.X, _chart.WindowRect, new Rect(0, 0, scatter.XAxis.ActualWidth, scatter.XAxis.ActualHeight)); List<LiteGanttData> data = new List<LiteGanttData>(ganttSeries.Data); data.Sort((item1, item2) => item1.Time.CompareTo(item2.Time)); int i; for (i = 0; i < data.Count; i++) { if (data[i].Time > new DateTime((long)xVal)) { break; } } if (i > 0) { i--; } int index = ganttSeries.Data.IndexOf(data[i]); index = index / 4; if (ganttSeries.ItemsSource is IList) { ganttDataContext.Item = ((IList)ganttSeries.ItemsSource)[index]; } return ganttDataContext; } private void UpdatePopup( Point point, GanttDataContext dataContext) { if (GanttToolTip == null) { return; } if (dataContext == null) { return; } if (_popup == null) { _popup = new Popup(); _grid.Children.Add(_popup); } _popup.Child = GanttToolTip; _popup.DataContext = dataContext; _popup.PlacementTarget = _grid; _popup.Placement = PlacementMode.Relative; _popup.VerticalOffset = point.Y + 10; _popup.HorizontalOffset = point.X + 10; _popup.IsOpen = true; } void _chart_SeriesMouseEnter(object sender, ChartMouseEventArgs e) { GanttDataContext context = GetGanttDataContext(e); UpdatePopup(e.GetPosition(this), context); } private XamDataChart CreateChart() { XamDataChart chart = new XamDataChart(); var xAxis = new NumericXAxis(); var yAxis = new NumericYAxis(); chart.Axes.Add(xAxis); chart.Axes.Add(yAxis); return chart; } void Series_CollectionChanged( object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { foreach (var series in Series) { series.Owner = this; } ResetSeries(); } internal void ResetSeries() { _chart.Series.Clear(); int i = 1; var xAxis = _chart.Axes.OfType<NumericXAxis>().First(); if (this.ReadLocalValue(MinimumValueProperty) != DependencyProperty.UnsetValue) { xAxis.MinimumValue = MinimumValue.Ticks; } if (this.ReadLocalValue(MinimumValueProperty) != DependencyProperty.UnsetValue) { xAxis.MaximumValue = MaximumValue.Ticks; } if (this.ReadLocalValue(IntervalProperty) != DependencyProperty.UnsetValue) { xAxis.Interval = Interval.Ticks; } string xaml = @"<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:x=""http://schemas.microsoft.com/winfx/2006/xaml"" xmlns:local=""clr-namespace:@@Namespace;assembly=@@Assembly"" > <DataTemplate.Resources> <local:TicksToDateTimeConverter x:Key=""converter"" /> </DataTemplate.Resources> <TextBlock Text=""{Binding Item, Converter={StaticResource converter}}"" /> </DataTemplate>"; xaml = xaml.Replace( "@@Namespace", typeof(TicksToDateTimeConverter).Namespace); xaml = xaml.Replace( "@@Assembly", typeof(TicksToDateTimeConverter).Assembly.FullName); DataTemplate template = XamlReader.Parse(xaml) as DataTemplate; if (template == null) { return; } xAxis.Label = template; var yAxis = _chart.Axes.OfType<NumericYAxis>().First(); yAxis.LabelSettings = new AxisLabelSettings() { Extent = 50, HorizontalAlignment = System.Windows.HorizontalAlignment.Right }; yAxis.MinimumValue = 0; yAxis.MaximumValue = Series.Count + 1; yAxis.Interval = 1; var names = from series in Series select series.TaskName; StringBuilder taskNames = new StringBuilder(); bool first = true; foreach (var name in names) { if (first) { first = false; } else { taskNames.Append(","); } taskNames.Append(name); } xaml = @"<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:x=""http://schemas.microsoft.com/winfx/2006/xaml"" xmlns:local=""clr-namespace:@@Namespace;assembly=@@Assembly"" > <DataTemplate.Resources> <local:TaskNameConverter x:Key=""converter"" /> </DataTemplate.Resources> <TextBlock Text=""{Binding Item, Converter={StaticResource converter}, ConverterParameter='@@Names'}"" /> </DataTemplate>"; xaml = xaml.Replace( "@@Namespace", typeof(TaskNameConverter).Namespace); xaml = xaml.Replace( "@@Assembly", typeof(TaskNameConverter).Assembly.FullName); xaml = xaml.Replace( "@@Names", taskNames.ToString()); template = XamlReader.Parse(xaml) as DataTemplate; if (template == null) { return; } yAxis.Label = template; foreach (var ganttSeries in Series) { var series = CreateSeries(ganttSeries, i); _chart.Series.Add(series); i++; } } private ScatterLineSeries CreateSeries(GanttSeries ganttSeries, int index) { ganttSeries.TaskValue = index; var series = new ScatterLineSeries(); series.XAxis = _chart.Axes.OfType<NumericXAxis>().First(); series.YAxis = _chart.Axes.OfType<NumericYAxis>().First(); series.XMemberPath = "Time"; series.YMemberPath = "Value"; series.ItemsSource = ganttSeries.Data; series.MarkerType = MarkerType.None; series.Thickness = 20; series.SetBinding(ScatterLineSeries.TitleProperty, new Binding("TaskName") { Source = ganttSeries, Mode = BindingMode.OneWay }); series.SetBinding(ScatterLineSeries.BrushProperty, new Binding("Brush") { Source = ganttSeries }); return series; } private GanttSeriesCollection _series; public GanttSeriesCollection Series { get { return _series; } } } public class GanttDataContext { public GanttSeries GanttSeries { get; set; } public Series Series { get; set; } public object Item { get; set; } } public class GanttSeriesCollection : ObservableCollection<GanttSeries> { } public class TestGanttDataItem { public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } public string Description { get; set; } } public class TestGanttData1 : ObservableCollection<TestGanttDataItem> { public TestGanttData1() { Add(new TestGanttDataItem() { StartTime = new DateTime(2010, 1, 2), EndTime = new DateTime(2010, 1, 3), Description = "test1" }); Add(new TestGanttDataItem() { StartTime = new DateTime(2010, 1, 4), EndTime = new DateTime(2010, 1, 6), Description = "test2" }); } } public class TestGanttData2 : ObservableCollection<TestGanttDataItem> { public TestGanttData2() { Add(new TestGanttDataItem() { StartTime = new DateTime(2010, 1, 2), EndTime = new DateTime(2010, 1, 4), Description = "test3" }); Add(new TestGanttDataItem() { StartTime = new DateTime(2010, 1, 5), EndTime = new DateTime(2010, 1, 7), Description = "test4" }); } } public class TestGanttData3 : ObservableCollection<TestGanttDataItem> { public TestGanttData3() { Add(new TestGanttDataItem() { StartTime = new DateTime(2010, 1, 3), EndTime = new DateTime(2010, 1, 4), Description = "test5" }); Add(new TestGanttDataItem() { StartTime = new DateTime(2010, 1, 8), EndTime = new DateTime(2010, 1, 11), Description = "test6" }); } }
Hope this helps!
-Graham
I’ve implemented a dynamic version of this chart but would like to know if the solution can be modified so that additional data can be displayed on the chart. In particular I’d like to display text that will be tied to each segment in a series that can be displayed above it (or within the line segment; the text will most likely be a number as well as its units).