커스텀 그리기

차트는 여러 가지 객체로 그래프는 물론이고 설명, 꾸미기 등의 다양한 출력을 지원하지만 그럼에도 불구하고 추가 정보를 차트 위에 더 그릴 필요가 있다. 최종적으로 원하는 모양을 마음대로 그리려면 이벤트를 처리해야 한다.

차트는 그리기 전에 PrePaint 이벤트를 호출하고 그린 후에 PostPaint 이벤트를 호출한다. 이 이벤트에 대한 핸들러를 설치하면 차트 위에 무엇이든지 그릴 수 있다.

 

private void Form1_Load(object sender, EventArgs e)

{

       Random R = new Random(100);

       double value = 30;

       for (int i = 0; i < 30; i++)

       {

                  value += R.Next(-9, 9);

                  chart1.Series[0].Points.AddXY(i, value);

       }

       chart1.Series[0].ChartType = SeriesChartType.Line;

       chart1.Series[0].Color = Color.Blue;

       chart1.Series[0].BorderWidth = 2;

 

       chart1.PostPaint += PostPaintHandler;

}

 

void PostPaintHandler(object sender, ChartPaintEventArgs e)

{

       Debug.WriteLine(e.ChartElement + " => " + e.Position);

       e.ChartGraphics.Graphics.DrawEllipse(Pens.Red, 60, 90, 200, 100);

}

 

라인 그래프를 하나 배치하고 PostPaint 이벤트에 대한 핸들러를 등록하면 차트를 다 그린 후 이 이벤트를 호출한다. 인수로 ChartPaintEventArgs 객체를 전달하여 그리기에 필요한 정보를 알려 준다.

 

속성

설명

Chart

그리기 대상 차트이다.

Chart​Element

차트의 어떤 부분을 그리고 있는지 알려 준다.

ChartGraphics

그리기에 필요한 정보를 가지는 객체이다. 이 객체 안의 Graphics 속성이 GDI+의 그리기 객체이며 그 외에 좌표 변환을 처리하는 메서드가 포함되어 있다.

Position

그리는 요소의 위치 정보가 저장되어 있다.

 

코드에서는 이벤트 인수의 주요 정보를 디버깅 창에 출력하고 그래픽 객체를 사용하여 적당한 크기의 타원을 그렸다. 출력된 디버깅 정보는 다음과 같다.

 

Series-Series1=>10.35352, 6.15, 64.91257, 81.1875

ChartArea-ChartArea1=>10.35352, 6.15, 64.91257, 81.1875

LegendCell-LegendCell1=>80.5383, 4.516129, 5.175983, 4.516129

LegendCell-LegendCell2=>85.71429, 4.516129, 9.730849, 4.516129

Legend-Legend1=>Auto

System.Windows.Forms.DataVisualization.Charting.Chart=>0, 0, 100, 100

 

시리즈, 에리어, 범례, 차트를 그릴 때마다 이 이벤트가 호출되며 각 요소가 어디쯤에 있는지 알려 준다. 차트를 다 그린 후 그 위에 타원을 그렸다.

과연 타원이 잘 그려지기는 했다. 그러나 이 타원의 좌표는 픽셀 단위여서 차트의 크기와는 상관없이 항상 일정하다. 폼을 작게 축소해도 타원은 차트와는 완전히 따로 논다.

차트는 크기와 상관없이 일정한 모양을 유지하기 위해 상대 좌표계를 사용한다. 좌상단이 (0,0)이고 우하단은 (100, 100)이며 중간의 좌표는 상대적인 비율에 따라 결정된다. 차트 정중앙은 (50, 50)이다.

디버깅 창에 출력한 차트 요소의 위치를 보면 다 0 ~ 100 사이의 좌표임을 알 수 있다. 차트의 모든 요소는 우하단을 100%로 한 백분율 좌표이다. 따라서 직접 그릴 때는 원하는 상대 좌표를 픽셀 좌표로 바꾸어 사용해야 한다.

좌표간의 변환은 ChartGraphics 객체가 제공하며 PointF, SizeF, RectangleF를 상호 변환한다. 백분율 좌표이다 보니 모든 좌표는 실수이다. 제일 간단한 좌표 변환 메서드부터 보자.

 

PointF Get​Absolute​Point(PointF)

PointF Get​Relative​Point(PointF)

 

아주 직관적이다. 상대 좌표를 주면 절대 좌표로 바꿔 주고 절대 좌표를 주면 상대 좌표로 바꿔 준다. SizeFRectangleF로 마찬가지이다.

 

SizeF GetAbsoluteSize (SizeF size);

SizeF GetRelativeSize (SizeF size);

RectangleF GetAbsoluteRectangle (RectangleF rectangle);

RectangleF GetRelativeRectangle (RectangleF rectangle);

 

타원은 사각 영역이므로 GetAbsoluteRectangle 메서드로 픽셀 좌표를 구하면 된다.

 

void PostPaintHandler(object sender, ChartPaintEventArgs e)

{

       RectangleF relRect = new RectangleF(30, 30, 40, 40);

       RectangleF absRect = e.ChartGraphics.GetAbsoluteRectangle(relRect);

       e.ChartGraphics.Graphics.DrawEllipse(Pens.Red, absRect);

}

 

(30, 30) 상대 좌표에서 폭과 높이가 각각 40 크기인 사각형을 정의했다. 폭이나 높이나 30% ~ 70%까지에 걸치는 중앙에 위치한 타원 좌표이다. 이 상대 좌표를 GetAbsoluteRectangle 메서드로 픽셀 좌표를 구하고 이 좌표에 타원을 그렸다.

차트의 중앙(에리어의 중앙이 아니고)에 타원이 그려지며 차트의 크기가 바뀌어도 항상 중앙에 그려진다.

 

이 변환 메서드를 사용하면 상대 좌표와 절대 좌표를 자유롭게 변환하여 차트의 원하는 곳에 원하는 도형을 자유롭게 그릴 수 있다. 다음 예제는 차트의 임의 영역에 마우스를 갖다 대면 현재 위치와 값을 보여준다.

 

void PostPaintHandler(object sender, ChartPaintEventArgs e)

{

       if (e.ChartElement != chart1.ChartAreas[0]) return;

       ChartArea area = chart1.ChartAreas[0];

       Graphics g = e.ChartGraphics.Graphics;

 

       // X축의 범위를 구함

       double xstart = area.AxisX.ValueToPixelPosition(area.AxisX.Minimum);

       double xend = area.AxisX.ValueToPixelPosition(area.AxisX.Maximum);

 

       // 범위 바깥이면 리턴

       Point pt = PointToClient(MousePosition);

       if (pt.X < xstart || pt.X > xend) return;

 

       // 수직선 그림

       Pen vertPen = new Pen(Color.Gray, 2);

       vertPen.DashStyle = DashStyle.Dash;

       RectangleF rtArea = e.ChartGraphics.GetAbsoluteRectangle(e.Position.ToRectangleF());

       g.DrawLine(vertPen, pt.X, rtArea.Y, pt.X, rtArea.Bottom);

 

       // 사각형 그림

       Rectangle rt = new Rectangle(pt.X + 10, pt.Y, 100, 80);

       if (rt.Right > xend) rt.X = pt.X - 110;

       if (rt.Bottom > rtArea.Bottom) rt.Y = pt.Y - 80;

       g.FillRectangle(new SolidBrush(Color.FromArgb(128, 255, 255, 0)), rt);

       g.DrawRectangle(Pens.Gray, rt);

 

       // 커서 위치의 X, Y값을 찾는다.

       double x = Math.Round(area.AxisX.PixelPositionToValue(pt.X), 2);

       double y = 0;

       foreach (DataPoint p in chart1.Series[0].Points)

       {

                  if (p.XValue > x)

                  {

                            y = p.YValues[0];

                            break;

                  }

       }

       String content = String.Format("pt.X = {0}\nX={1}\nY={2}", pt.X, x, y);

       g.DrawString(content, Font, Brushes.Black, rt.X + 10, rt.Y + 10);

}

 

private void chart1_MouseMove(object sender, MouseEventArgs e)

{

       chart1.Invalidate();

}

 

 마우스가 움직일 때마다 차트를 다시 그려야 하므로 MouseMove 이벤트 핸들러에서 Invalidate를 호출한다. 차트를 완전히 다시 그리고 PostPaint 메서드도 호출된다.

그리기 이벤트가 들어오면 먼저 그리기 대상이 에리어인지 점검한다. 앞에서 이벤트 인수를 덤프해 봤는데 PostPoint는 에리어뿐만 아니라 시리즈, 범례 등에 대해서도 발생한다. 이때는 굳이 반복적인 그리기를 할 필요가 없다.

다음은 마우스가 차트 안에 있는지 점검한다. 범례나 차트 왼쪽에 있으면 굳이 그릴 필요가 없다. 범위 점검을 위해 X축의 좌표를 구한다. 이때는 다음 메서드를 사용한다.

 

double ValueToPosition (double axisValue);

double ValueToPixelPosition (double axisValue);

 

X축의 값을 주면 각 값이 차트의 어디쯤에 있는지 좌표를 리턴하되 각각 상대 좌표와 픽셀 좌표이다. AxisXMinimumMaximum에 대해 상대 좌표를 조사하면 대략 10 ~ 75 범위가 된다. 차트폭의 10%에서 X축이 시작해서 75%까지 차지하는데 범례를 빼면 오른쪽 범위가 더 늘어난다.

마우스 커서와 비교하려면 아예 픽셀 좌표로 구하는 것이 편리하다. 상대 좌표로부터 픽셀 좌표를 구하는 것도 사실 그리 어렵지는 않다. 어차피 상대좌표라는 것이 폭에 대한 백분율일 뿐이므로 차트 폭에 대해 상대 좌표를 곱한 후 100으로 나누면 된다.

 

double xstart = chart1.Width * area.AxisX.ValueToPosition(area.AxisX.Minimum) / 100;

double xend = chart1.Width * area.AxisX.ValueToPosition(area.AxisX.Maximum) / 100;

 

X축의 범위를 구한 후 마우스 커서 좌표가 이 범위안에 있는지 조사한다. 커서 좌표는 폼의 MousePosition으로 구하되 이 값은 화면 좌표여서 PointToClient로 작업 영역 좌표로 바꾸어야 한다.

커서의 수평 좌표가 X축 범위 내에 있으면 그리기를 하되 그렇지 않으면 리턴해 버린다. 차트 바깥에 있을 때는 굳이 그릴 필요가 없고 또 축값을 조사할 때 범위 바깥은 예외가 발생하는 문제도 있다. Y축의 범위도 점검할 수 있지만 아래위로 벗어나는 것은 허용하기로 했다.

X축 대신 에리어의 InnerPlotPosition 속성으로 플롯 영역을 대신 사용해도 될 것 같지만 플롯은 범례까지 포함하기 때문에 안된다. 범위 안에 들어 왔으면 커서 위치에 수직선을 그린다. 에리어의 픽셀 범위를 rtArea에 구하고 이 영역의 수직 영역에 대해 선을 긋는다.

다음은 수직선 옆에 반투명한 색으로 사각형을 그린다. 커서와 너무 밀착하면 답답해 보이므로 10픽셀만큼 거리를 띄우고 사각형이 오른쪽이나 아래로 벗어나면 차트 영역 안으로 좌표를 조정했다. 다음 메서드는 각각 상대 좌표와 픽셀 좌표의 현재 X축값을 조사한다.

 

double PositionToValue (double position);

double PixelPositionToValue (double position);

 

마우스 위치의 X축 픽셀 좌표를 x에 구하고 이 위치의 y값은 시리즈에서 순차 검색하여 찾는다. 데이터 포인트 전체를 순회하며 x값보다 최초로 더 큰 XValue를 찾고 그 지점의 y값을 구하면 된다. 이해하기는 쉬운 코드이다.

그러나 데이터가 작을 때는 이런 간단한 방법도 쓸만하지만 X축 값이 수천개 정도 된다면 순차 검색은 너무 느리다. X값이 정렬되어 있으니 이럴 때는 당연히 이분 검색을 사용해야 한다. 직접 이분 검색을 할 필요는 없고 포인트 컬렉션의 FindByValue 메서드를 사용하면 된다.

 

DataPoint dp = chart1.Series[0].Points.FindByValue(Math.Round(x), "X");

if (dp != null) y = dp.YValues[0];

 

이 메서드는 주로 Y값을 검색할 때 쓰지만 두 번째 인수로 "X""Y2" 등을 지정하면 다른 값도 찾을 수 있다. 내부적으로 가장 효율적인 검색 방법을 취할 것이다. 정확한 X값만 검색하기 때문에 시리즈에 저장한 값과 같은 포맷으로 맞춰 주어야 한다.

PixelPositionToValue는 소수점 이하 자리까지 계산해 주지만 시리즈에 X는 정수로 들어가 있으니 버림한 후 검색해야 정확하다. 또한 X가 차트 범위를 벗어나면 null이 리턴될 수도 있으므로 에러 처리도 꼭 필요하다.

사실 이 코드는 나도 아주 나중에 알게 되었는데 덕분에 차트의 속도가 굉장히 빨라졌다. 이래서 항상 관심을 가지고 레퍼런스를 틈틈이 뒤져 봐야 한다. 무식한 코드 만들지 말고 잘 만들어 놓은 코드를 적극 활용하자. 이렇게 구한 세 값을 사각 영역안에 출력한다.

커서만 갖다 대면 이 값이 X축의 어디쯤인지, Y값은 얼마인지 축으로 시선을 이동하지 않고도 정확한 값을 알 수 있어 편리하다. 차트를 그릴 때마다 PostPaint 이벤트가 날라오고 원하는 모든 정보를 다 구할 수 있어 얼마든지 예쁜 모양으로 정보를 보여줄 수 있다.