5

I am trying to use a colored spectrum strip as an axis for a chart. The idea is to match the color on the image with its associated wavelength along the x-axis at the bottom. The strip needs to change in size to match changes of the chart area and expand and contract sections to match scroll-zooming in the chart area.

I have tried using image annotations but as the chart area changes, the annotation dimensions remain fixed. Also, the scroll zooming that focuses in on mouse position obviously has no effect on the annotation.

The approach that came closest was using the image as a background for the chart area. This automatically scaled the image as the chart area changed but scroll-zooming has no effect on the background image. Also, it would be ideal to have the background clear so as to avoid obscuring data plot points. I can edit the image to have a large transparent section and only a colored strip at the bottom but even then, that strip could obscure lower intensity data points.

Spectrum as annotation and background: enter image description here

Annotation not scaling, background scales well: enter image description here

Both annotation and background not scaling with zooming: enter image description here

TaW
  • 53,122
  • 8
  • 69
  • 111
Alex Hong
  • 53
  • 4

2 Answers2

5

This is a nice idea.

The simplest way is to draw the image in a Paint event of the Chart, maybe PrePaint.

enter image description here

Let's go to work.. We will use the DrawImage overload that allows us zooming as well as cropping. For this we need two rectangles.

The first challenge is to always get the correct target rectangle.

For this we need to convert the InnerPlotPosition from relative positions to absolute pixels.

These two functions will help:

RectangleF ChartAreaClientRectangle(Chart chart, ChartArea CA)
{
    RectangleF CAR = CA.Position.ToRectangleF();
    float pw = chart.ClientSize.Width / 100f;
    float ph = chart.ClientSize.Height / 100f;
    return new RectangleF(pw * CAR.X, ph * CAR.Y, pw * CAR.Width, ph * CAR.Height);
}

RectangleF InnerPlotPositionClientRectangle(Chart chart, ChartArea CA)
{
    RectangleF IPP = CA.InnerPlotPosition.ToRectangleF();
    RectangleF CArp = ChartAreaClientRectangle(chart, CA);

    float pw = CArp.Width / 100f;
    float ph = CArp.Height / 100f;

    return new RectangleF(CArp.X + pw * IPP.X, CArp.Y + ph * IPP.Y, 
                            pw * IPP.Width, ph * IPP.Height);
}

With these numbers setting the destination rectangle is as simple as:

Rectangle tgtR = Rectangle.Round(new RectangleF(ipr.Left, ipr.Bottom - 15, ipr.Width, 15));

You can chose a height as you like..

The next challenge is the source rectangle.

Without zooming it would simply be:

Rectangle srcR = new Rectangle( 0, 0, bmp.Width, bmp.Height);

But for zooming and panning we need to scale it; for this we can use the x-axis and the ScaleView's Minimum and Maximum values.

We calculate factors for the first and last spot on the axis:

double f1 = ax.ScaleView.ViewMinimum / (ax.Maximum - ax.Minimum);
double f2 = ax.ScaleView.ViewMaximum / (ax.Maximum - ax.Minimum);

now we get the source rectangle maybe like this:

int x  = (int)(bmp.Width * f1);
int xx = (int)(bmp.Width * f2);
Rectangle srcR = new Rectangle( x, 0, xx - x, bmp.Height);

Let's put it together:

private void chart_PrePaint(object sender, ChartPaintEventArgs e)
{
    // a few short names
    Graphics g = e.ChartGraphics.Graphics;  
    ChartArea ca = chart.ChartAreas[0];
    Axis ax = ca.AxisX;

    // pixels of plot area
    RectangleF ipr = InnerPlotPositionClientRectangle(chart, ca);

    // scaled first and last position
    double f1 = ax.ScaleView.ViewMinimum / (ax.Maximum - ax.Minimum);
    double f2 = ax.ScaleView.ViewMaximum / (ax.Maximum - ax.Minimum);

    // actual drawing with the zooming overload
    using (Bitmap bmp = (Bitmap)Bitmap.FromFile(imagePath))
    {
        int x  = (int)(bmp.Width * f1);
        int xx = (int)(bmp.Width * f2);
        Rectangle srcR = new Rectangle( x, 0, xx - x, bmp.Height);
        Rectangle tgtR = Rectangle.Round(
                         new RectangleF(ipr.Left , ipr.Bottom - 15, ipr.Width, 15));
        g.DrawImage(bmp, tgtR, srcR, GraphicsUnit.Pixel);
    }
}

A few notes:

  • Of course I would recomend to use an Image resource instead of always loading from disk!

  • The Drawing will always overlay the data points and also the grids. You can either..

  • choose a different minimum to make room

  • make the image smaller

  • move it below the x-axis labels

  • make the image semi-transparent

  • make the x-axis so fat that it can hold the image strip : ax.LineWidth = 10

For the latter solution you would want to offset the y-position depending on the zoom state. Quick and dirty: int yoff = (ax.ScaleView.IsZoomed ? 12 : 5);. To avoid black stripes also make the axis Transparent or chart.BackColor..

Update:

You can also revert to using a StripLine. It can scale its BackgroundImage and you would have to create a suitable image whenever changing the scaleview, i.e. when zooming or panning. For this much of the above code would be used to create the new images. See this post for examples of adding and replacing varying NamedImage to a Chart! (The relevant portion is close to the end about the marker images!)

In fact I found that way to be the best solution and have added a second answer.

TaW
  • 53,122
  • 8
  • 69
  • 111
5

Alternative and recommended solution:

I dabbled with the last option I mentioned in my other answer and found it to be rather nice; it is similarily extensive, so I decided to post a second answer.

The idea is to use a StripLine with just the right BackgroundImage.

The advantage is that is will display nicely under all chart elements and never draw over the axis, grid, datapoints or conflict with the zoom tools.

enter image description here

Since the StripLine must be updated repeatedly I put it in a function:

Here is the function; it makes use of the same two helper functions to calculate pixel positions as the other answer does..:

void updateStripLine(Chart chart, ChartArea ca, string name)
{
     // find our stripline; one could pass in a class level variable as well
    StripLine sl = ca.AxisY.StripLines.Cast<StripLine>()
                           .Where(s => s.Tag.ToString() == name).FirstOrDefault();

    if (sl != null)  // either clean-up the resources..
    {
        var oldni = chart.Images.FindByName(name);
        if (oldni != null)
        {
            oldni.Image.Dispose();
            chart.Images.Remove(oldni);
            oldni.Dispose();
        }
    }
    else  // or, create the line
    {
        sl = new StripLine();
        sl.Tag = name;
        ca.AxisY.StripLines.Add(sl);
    }

    ca.RecalculateAxesScale();
    RectangleF ipr = InnerPlotPositionClientRectangle(chart, ca);

    Axis ax = ca.AxisX;
    Axis ay = ca.AxisY;
    double f1 = ax.ScaleView.ViewMinimum / (ax.Maximum - ax.Minimum);
    double f2 = ax.ScaleView.ViewMaximum / (ax.Maximum - ax.Minimum);

    Bitmap b0 = (Bitmap)chart.Images["spectrum"].Image;
    int x  = (int)(b0.Width * f1);
    int xx = (int)(b0.Width * f2);

    Rectangle srcR = new Rectangle( x, 0, xx - x, b0.Height);
    Rectangle tgtR = Rectangle.Round(new RectangleF(0,0, ipr.Width , 10)); 

    // create bitmap and namedImage:
    Bitmap bmp = new Bitmap( tgtR.Width, tgtR.Height);
    using (Graphics g = Graphics.FromImage(bmp))
         {  g.DrawImage(b0, tgtR, srcR, GraphicsUnit.Pixel);  }
    NamedImage ni = new NamedImage(name, bmp);
    chart.Images.Add(ni);
    sl.BackImageWrapMode = ChartImageWrapMode.Scaled;
    sl.StripWidth =  ay.PixelPositionToValue(0) - ay.PixelPositionToValue(12);
    sl.Interval = 100;  // make large enough to avoid another sLine showing up
    sl.IntervalOffset = 0;
    sl.BackImage = name;
}

Much of the comments and links apply, especially wrt to the NamedImage we use for the StripLine.

A few more notes:

  • I use one of the (four) axis conversion functions, PixelPositionToValue to calculate a pixel height of 12px; the StripLine takes values, so I use two pixel values to get the right difference value.

  • To identify the StripLine I use the Tag property. Of course the Name property would be much more natural, but it is read-only. No idea why?!

  • The function is called from the AxisViewChanged, the Resize event and also the the PrePaint event; this makes sure it will always be called when needed. To avoid invalid calls from the PrePaint there I do it like this: if (ay.StripLines.Count == 0) updateStripLine(chart, ca, "sl"); Of course you should adapt if you use other StripLines on this axis..

  • The code makes use of the same image as before; but I have put it into a first NamedImage called spectrum. This would be an option in the 1st answer as well.

    NamedImage spectrum = new NamedImage("spectrum", Bitmap.FromFile(imagePath); chart.Images.Add(spectrum);

  • It also makes sure to dispose of the old images properly, I hope..

TaW
  • 53,122
  • 8
  • 69
  • 111