Search code examples
c#plotchartsvector-graphicsgraphing

Zoomable, printable, scrollable train movement graphs in c#


I need to build a graphic train schedule visualisation tool in C#. Actually I have to rebuild this perfect tool in C#. Marey's Trains The graphs have to be zoomable, scrollable and printable/exportable to PDF with vector graphical elements.

Could you give me some tips? How should I start it? What sort of libraries should I use?

Is it worth to try using graphing libraries like OxyPlot? Maybe it's not the best because of the special axes and irregular grids - as I think. What's your opinion?

example


Solution

  • No matter which chart tool you use, once you need special types of display you will always have to add some extra coding.

    Here is an example of using the MSChart control.

    Do note that it has a limitation wrt to exporting vector formats:

    It can export to various formats, including 3 EMF types; however only some application can actually use those. Not sure about the PDF libary you use..!

    If you can't use the Emf formats you can get nice results by making the Chart control really big, exporting to Png and then making the Dpi resolution much larger the the default screen resolution it has after saving.. Setting it to 600 or 1200dpi should do for most pdf uses..

    Now lets look at an example:

    enter image description here

    A few notes:

    • I have made my life easier in a number of ways. I have only coded for one direction and I have not reversed the rooster, so it goes only bottom to top.

    • I have not used real data but made them up.

    • I have not created one or more classes to hold the station data; instead I use a very simple Tuple.

    • I have not created a DataTable to hold the train data. Instead I make them up and add them to the chart on the fly..

    • I didn't test, but zooming and scrolling should work as well..

    Here is the List<Tuple> that holds my station data:

    // station name, distance, type: 0=terminal, 1=normal, 2=main station
    List<Tuple<string, double, int>> TrainStops = null;
    

    Here is how I set up the chart:

    Setup24HoursAxis(chart1, DateTime.Today);
    TrainStops = SetupTrainStops(17);
    SetupTrainStopAxis(chart1);
    
    for (int i = 0; i < 23 * 3; i++)
    {
        AddTrainStopSeries(chart1, DateTime.Today.Date.AddMinutes(i * 20),
                           17 - rnd.Next(4), i% 5 == 0 ? 1 : 0);
    }
    // this exports the image above:
    chart1.SaveImage("D:\\trains.png", ChartImageFormat.Png);
    

    This creates one train every 20 minutes with 14-17 stops and every 5th train a fast one.

    Here are the routines I call:

    Setting up the x-axis for hold one day's worth of data is straightforward.

    public static void Setup24HoursAxis(Chart chart, DateTime dt)
    {
        chart.Legends[0].Enabled = false;
    
        Axis ax = chart.ChartAreas[0].AxisX;
        ax.IntervalType = DateTimeIntervalType.Hours;
        ax.Interval = 1;
        ax.Minimum =  dt.ToOADate();
        ax.Maximum = (dt.AddHours(24)).ToOADate();
        ax.LabelStyle.Format = "H:mm";
    }
    

    Creating a List of stations with random distances is also very simple. I made the 1st and last ones terminals and every 5th a main station.

    public List<Tuple<string, double, int>> SetupTrainStops(int count)
    {
        var stops = new List<Tuple<string, double, int>>();
        Random rnd = new Random(count);
        for (int i = 0; i < count; i++)
        {
            string n = (char)(i+(byte)'A') + "-Street";
            double d = 1 + rnd.Next(3) + rnd.Next(4) + rnd.Next(5) / 10d;
            if (d < 3) d = 3;  // a minimum distance so the label won't touch
            int t = (i == 0 | i == count-1) ? 0 : rnd.Next(5)==0 ? 2 : 1;
            var ts = new Tuple<string, double, int>(n, d, t);
            stops.Add(ts);
        }
        return stops;
    }
    

    Now that we have the train stops we can set up the y-axis:

    public void SetupTrainStopAxis(Chart chart)
    {
        Axis ay = chart.ChartAreas[0].AxisY;
        ay.LabelStyle.Font = new Font("Consolas", 8f);
        double totalDist = 0;
        for (int i = 0; i < TrainStops.Count; i++)
        {
            CustomLabel cl = new CustomLabel();
            cl.Text = TrainStops[i].Item1;
            cl.FromPosition = totalDist - 0.1d;
            cl.ToPosition = totalDist + 0.1d;
            totalDist += TrainStops[i].Item2;
            cl.ForeColor = TrainStops[i].Item3 == 1 ? Color.DimGray : Color.Black;
            ay.CustomLabels.Add(cl);
        }
        ay.Minimum = 0;
        ay.Maximum = totalDist;
        ay.MajorGrid.Enabled = false;
        ay.MajorTickMark.Enabled = false;
    }
    

    A few notes are called for here:

    • As the values are quite dynamic we can't use normal Labels which would come with the fixed Interval spacing.
    • So we create CustomLabels instead.
    • For these we need two values to determine the space into which they shall be centered. So we create a small span by adding/subtracting 0.1d.
    • We have calculated the total distance and use it to set up the Maximum of the y-axis. Again: To mimick the schedule you show you will have to do some reversing here and there..
    • By adding CustomLabels the normal ones are turned off automatically. As we need MajorGridlines at the irregular intervals we also turn the normal ones off. Hence we must draw them ourselves. Not really hard as you can see..:

    For this we code one of the xxxPaint events:

    private void chart1_PostPaint(object sender, ChartPaintEventArgs e)
    {
        Axis ay = chart1.ChartAreas[0].AxisY;
        Axis ax = chart1.ChartAreas[0].AxisX;
    
        int x0 = (int) ax.ValueToPixelPosition(ax.Minimum);
        int x1 = (int) ax.ValueToPixelPosition(ax.Maximum);
    
        double totalDist = 0;
        foreach (var ts in TrainStops)
        {
            int y = (int)ay.ValueToPixelPosition(totalDist);
            totalDist += ts.Item2;
            using (Pen p = new Pen(ts.Item3 == 1 ? Color.DarkGray : Color.Black, 
                                   ts.Item3 == 1 ? 0.5f : 1f))
                e.ChartGraphics.Graphics.DrawLine(p, x0 + 1, y, x1, y);
        }
        // ** Insert marker drawing code (from update below) here !
    }
    

    Note the use of the ValueToPixelPosition conversion functions of the axes!

    Now for the last part: How to add a Series of train data..:

    public void AddTrainStopSeries(Chart chart, DateTime start, int count, int speed)
    {
        Series s = chart.Series.Add(start.ToShortTimeString());
        s.ChartType = SeriesChartType.Line;
        s.Color = speed == 0 ? Color.Black : Color.Brown;
        s.MarkerStyle = MarkerStyle.Circle;
        s.MarkerSize = 4;
    
        double totalDist = 0;
        DateTime ct = start;
        for (int i = 0; i < count; i++)
        {
            var ts = TrainStops[i];
            ct = ct.AddMinutes(ts.Item2 * (speed == 0 ? 1 : 1.1d));
            DataPoint dp = new DataPoint( ct.ToOADate(), totalDist );
            totalDist += TrainStops[i].Item2;
            s.Points.Add(dp);
        }
    }
    

    Note that since my data don't contain real arrival/departure times I calculated them from the distance and some speed factor. You, of course, would use your data!

    Also note that I have used a Line chart with extra Marker circles.

    Also note that each train series can easily be disabled/hidden or brought back again.

    Let's show only the fast trains:

    private void cbx_ShowOnlyFastTrains_CheckedChanged(object sender, EventArgs e)
    {
        foreach (Series s in chart1.Series)
            s.Enabled = !cbx_ShowOnlyFastTrains.Checked || s.Color == Color.Brown;
    }
    

    Of course for a robust application you will not rely ona magic color ;-) Instead you could add a Tag object to the Series to hold all sorts of train info.

    Update: As you noticed the drawn GridLines cover the Markers. You can insert this piece of code here (**); it will owner-draw the Markers at the end of the xxxPaint event.

    int w = chart1.Series[0].MarkerSize;
    foreach(Series s in chart1.Series)
    foreach(DataPoint dp in s.Points)
    {
        int x = (int) ax.ValueToPixelPosition(dp.XValue) - w / 2;
        int y = (int) ay.ValueToPixelPosition(dp.YValues[0])- w / 2;
            using (SolidBrush b = new SolidBrush(dp.Color))
                e.ChartGraphics.Graphics.FillEllipse(b, x, y, w, w);
    }
    

    Close-up:

    enter image description here