Adapting the Microsoft Chart WebControl to MVC
June 1, 2011 6 Comments
The Chart control for ASP.Net that comes baked into .NET 4 or as an add-on to .NET 3 is useful but it is built around the WebForms rendering model which is uncomfortable in MVC applications. There is no pretty way to embed the WebChart control out-of-the-box. Solutions I have seen revolve around either giving up on the MVC separation and embedding the control into a View or creating custom web user controls for each chart and treating these as partial views. The former solution will only work with the WebForm view engine and not Razor or other view engines and the latter is just messy. Either way it feels like walking around with toilet paper stuck to your shoe. A third way is to return the image bytes of from a Chart object created in the controller as a FileResult. This works but you lose the image map tooltips.
The crux of the problem is that Chart is a DataBoundControl which is designed to magically generate its own HTML and to serve an image resource to itself when the rendered HTML is interpreted. It isn’t really meant to have an external controller tell it what to do. However, the Chart control has some nice features:
- It generates cross-browser friendly output
- It generates mouseover tooltips
- It doesn’t require Flash, Silverlight or JavaScipt because it uses MAP and AREA elements to create the tooltips
- With a little coercion, it will generate its image resource for you as a byte array which means you can alternatively embed the chart in an alternate resource like a PDF or other document envelope
Chart the MVC Way
Having decided that the Chart control does something we want, can we bend it to the MVC rendering model while preserving the nice tooltip image maps? The answer is yes we can. I have created a handful of extension methods that turn Chart on its head and allow you to render it out of a Controller class as an ActionResult. I’ll show you how things look in the View and Controller and then walk you through how it works.
View
<% Html.RenderAction( "PerformanceHistoryChart", new { id = Model.Id, shareClass = Model.ShareClass } ); %>
Controller
[OutputCache( Duration = 10, VaryByParam = "id;shareClass" )] public ActionResult PerformanceHistoryChart( Guid id, string shareClass ) { return this.RenderChartMap( "PerformanceHistoryImage", id, shareClass, NewPerformanceHistoryChart ); } [OutputCache( Duration = 10, VaryByParam = "id;shareClass" )] public ActionResult PerformanceHistoryImage( Guid id, string shareClass ) { return this.RenderChartImage( id, shareClass, NewPerformanceHistoryChart ); } private static Chart NewPerformanceHistoryChart( Guid id, string shareClass ) { Chart chart = new Chart(); var series = perfChart.Series.Add( "Default" ); var area = perfChart.ChartAreas.Add( "ChartArea" ); var values = ReportUtility.GetNetReturnOnValuePercentageHistory( id, shareClass ); series.Points.DataBindXY( values.Item1, values.Item2 ); series.ToolTip = "Fiscal Year: #VALX, Growth: #VALY{P}"; area.AxisY.Title = "%"; area.AxisY.LabelStyle.Format = "{P2}"; return chart; }
What’s going on in the View is that we are rendering the result of calling PerformanceHistoryChart on the controller. PerformanceHistoryChart renders HTML which includes an IMG tag reference to the PerformanceHistoryImage action which streams back the chart image as a PNG.PerformanceHistoryChart and PerformanceHistoryImage make use of my Chart to MVC adapter extension methods to do their work. There are two sets of extension method overloads:
- this.RenderChartMap
- this.RenderChartImage
In order to render any chart to a browser you need to provide two action methods. One of them calls RenderChartMap which generates HTML and the other calls RenderChartImage which returns the image as a FileResult for the IMG tag generated by RenderChartMap. Both extension methods accept a factory Func delegate which they use to create the Chart object that they manipulate internally. I’ve created overloads which accept 1 to 5 arguments.
MvcChart Extension Method Adapter Class
I’m sure this could be made more elegant but my first cut is working well. One limitation of my implementation is that you need a pair of extension method overloads that has enough arguments for your Chart factory Func to work. Another issue is that you need to have an explicit route defined for the Actions which return the chart image FileContentResult. The reason is that the Chart control always appends a ?<some-guid> which is part of the caching mechanism it uses for storing images when it is rendering itself as a WebForms control. Therefore, I need to have an explicit route without any query string and allow the query string the Chart control generates to dangle off the end. My assumption is that the arguments will be passed in order from left to right in the URL as they appear in the method call. Also, since we are taking over rendering the Chart control via our Controller we give up the baked-in image caching. This is probably not much of an issue but it makes sense to use OutputCaching attributes to reduce the hits on the Chart factory Funcs and therefore your database.
Here’s my MvcChart class which contains the adapter extension methods. Happy coding.
using System.IO; using System.Web.UI; using System.Web.UI.DataVisualization.Charting; namespace System.Web.Mvc { /// <summary> /// <para>This class adapts the System.Web.UI.DataVisualization.Charting.Chart API to something /// more friendly to ASP.Net MVC by attaching extension methods to the Controller type.Requires /// an explicit route mapping for all arguments. The arguments will be inserted into the Image /// generating URL in order.</para> /// <para>In each controller we need a pair of ActionMethods. One generates the IMG, MAP and AREA tags. /// The other streams the chart PNG. The URL to the chart PNG method is referenced by the imageActionName /// parameter in the RenderChartMap() method call. We also need a factory method which generates the Chart /// object instance. The chart factory is passed as a delegate to these adapter extension methods.</para> /// <remarks>In order to render each chart in a browser, the factory method must be executed twice. /// Consider using the OutputCache attribute to reduce hits on the data backend.</remarks> /// </summary> public static class MvcChart { /// <summary> /// Render img tag and associated image map for a chart. /// </summary> /// <typeparam name="T">Type of the factory argument.</typeparam> /// <param name="imageActionName">Method name in this controller that streams the chart image.</param> /// <param name="arg">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns>ContentResult that generates HTML for the chart.</returns> public static ContentResult RenderChartMap<T>( this Controller controller, string imageActionName, T arg, Func<T, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg ); //FWIW: ImageLocation URL will have ?{some-guid} appended which is part of its classic WebForms ASP.Net //web control rendering model chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/", GetApplicationPath( controller ), GetControllerName( controller ), imageActionName, arg ); return RenderChartMap( chart ); } /// <summary> /// Render img tag and associated image map for a chart. /// </summary> /// <typeparam name="T1">Type of the first factory argument.</typeparam> /// <typeparam name="T2">Type of the second factory argument.</typeparam> /// <param name="controller">Controller instance.</param> /// <param name="imageActionName">Method name in this controller that streams the chart image.</param> /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="arg2">Second parameter used by the chart factory.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns>ContentResult that generates HTML for the chart.</returns> public static ContentResult RenderChartMap<T1,T2>( this Controller controller, string imageActionName, T1 arg1, T2 arg2, Func<T1, T2, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg1, arg2 ); chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/{4}/", GetApplicationPath( controller ), GetControllerName( controller ), imageActionName, arg1, arg2 ); return RenderChartMap( chart ); } /// <summary> /// Render img tag and associated image map for a chart. /// </summary> /// <typeparam name="T1">Type of the first factory argument.</typeparam> /// <typeparam name="T2">Type of the second factory argument.</typeparam> /// <typeparam name="T3">Type of the third factory argument.</typeparam> /// <param name="controller">Controller instance.</param> /// <param name="imageActionName">Method name in this controller that streams the chart image.</param> /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="arg2">Second parameter used by the chart factory.</param> /// <param name="arg3">Third parameter used by the chart factory.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns>ContentResult that generates HTML for the chart.</returns> public static ContentResult RenderChartMap<T1, T2, T3>( this Controller controller, string imageActionName, T1 arg1, T2 arg2, T3 arg3, Func<T1, T2, T3, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg1, arg2, arg3 ); chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/{4}/{5}/", GetApplicationPath( controller ), GetControllerName( controller ), imageActionName, arg1, arg2, arg3 ); return RenderChartMap( chart ); } /// <summary> /// Render img tag and associated image map for a chart. /// </summary> /// <typeparam name="T1">Type of the first factory argument.</typeparam> /// <typeparam name="T2">Type of the second factory argument.</typeparam> /// <typeparam name="T3">Type of the third factory argument.</typeparam> /// <typeparam name="T4">Type of the fourth factory argument.</typeparam> /// <param name="controller">Controller instance.</param> /// <param name="imageActionName">Method name in this controller that streams the chart image.</param> /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="arg2">Second parameter used by the chart factory.</param> /// <param name="arg3">Third parameter used by the chart factory.</param> /// <param name="arg4">Fourth parameter used by the chart factory.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns>ContentResult that generates HTML for the chart.</returns> public static ContentResult RenderChartMap<T1, T2, T3, T4>( this Controller controller, string imageActionName, T1 arg1, T2 arg2, T3 arg3, T4 arg4, Func<T1, T2, T3, T4, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg1, arg2, arg3, arg4 ); chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/{4}/{5}/{6}/", GetApplicationPath( controller ), GetControllerName( controller ), imageActionName, arg1, arg2, arg3, arg4); return RenderChartMap( chart ); } /// <summary> /// Render img tag and associated image map for a chart. /// </summary> /// <typeparam name="T1">Type of the first factory argument.</typeparam> /// <typeparam name="T2">Type of the second factory argument.</typeparam> /// <typeparam name="T3">Type of the third factory argument.</typeparam> /// <typeparam name="T4">Type of the fourth factory argument.</typeparam> /// <typeparam name="T5">Type of the fifth factory argument.</typeparam> /// <param name="imageActionName">Method name in this controller that streams the chart image.</param> /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="arg2">Second parameter used by the chart factory.</param> /// <param name="arg3">Third parameter used by the chart factory.</param> /// <param name="arg4">Fourth parameter used by the chart factory.</param> /// <param name="arg5">Fifth parameter used by the chart factory.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns>ContentResult that generates HTML for the chart.</returns> public static ContentResult RenderChartMap<T1, T2, T3, T4, T5>( this Controller controller, string imageActionName, T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, Func<T1, T2, T3, T4, T5, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg1, arg2, arg3, arg4, arg5 ); chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/{4}/{5}/{6}/{7}/", GetApplicationPath( controller ), GetControllerName( controller ), imageActionName, arg1, arg2, arg3, arg4, arg5 ); return RenderChartMap( chart ); } /// <summary> /// Render chart image byte stream as a PNG file. /// </summary> /// <typeparam name="T">Type of the id.</typeparam> /// <param name="controller">Controller instance.</param> /// <param name="arg">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns></returns> public static FileContentResult RenderChartImage<T>( this Controller controller, T arg, Func<T, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg ); return RenderChartImage( chart ); } /// <summary> /// Render chart image byte stream as a PNG file. /// </summary> /// <typeparam name="T1">Type of the first factory parameter.</typeparam> /// <typeparam name="T2">Type of the second factory parameter.</typeparam> /// <param name="controller">Controller instance.</param> /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="arg2">Second parameter used by the chart factory.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns></returns> public static FileContentResult RenderChartImage<T1, T2>( this Controller controller, T1 arg1, T2 arg2, Func<T1, T2, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg1, arg2 ); return RenderChartImage( chart ); } /// <summary> /// Render chart image byte stream as a PNG file. /// </summary> /// <typeparam name="T1">Type of the first factory parameter.</typeparam> /// <typeparam name="T2">Type of the second factory parameter.</typeparam> /// <typeparam name="T3">Type of the third factory argument.</typeparam> /// <param name="controller">Controller instance.</param> /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="arg2">Second parameter used by the chart factory.</param> /// <param name="arg3">Third parameter used by the chart factory.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns></returns> public static FileContentResult RenderChartImage<T1, T2, T3>( this Controller controller, T1 arg1, T2 arg2, T3 arg3, Func<T1, T2, T3, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg1, arg2, arg3 ); return RenderChartImage( chart ); } /// <summary> /// Render chart image byte stream as a PNG file. /// </summary> /// <typeparam name="T1">Type of the first factory parameter.</typeparam> /// <typeparam name="T2">Type of the second factory parameter.</typeparam> /// <typeparam name="T3">Type of the third factory argument.</typeparam> /// <typeparam name="T4">Type of the fourth factory argument.</typeparam> /// <param name="controller">Controller instance.</param> /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="arg2">Second parameter used by the chart factory.</param> /// <param name="arg3">Third parameter used by the chart factory.</param> /// <param name="arg4">Fourth parameter used by the chart factory.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns></returns> public static FileContentResult RenderChartImage<T1, T2, T3, T4>( this Controller controller, T1 arg1, T2 arg2, T3 arg3, T4 arg4, Func<T1, T2, T3, T4, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg1, arg2, arg3, arg4 ); return RenderChartImage( chart ); } /// <summary> /// Render chart image byte stream as a PNG file. /// </summary> /// <typeparam name="T1">Type of the first factory parameter.</typeparam> /// <typeparam name="T2">Type of the second factory parameter.</typeparam> /// <typeparam name="T3">Type of the third factory argument.</typeparam> /// <typeparam name="T4">Type of the fourth factory argument.</typeparam> /// <typeparam name="T5">Type of the fifth factory argument.</typeparam> /// <param name="controller">Controller instance.</param> /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param> /// <param name="arg2">Second parameter used by the chart factory.</param> /// <param name="arg3">Third parameter used by the chart factory.</param> /// <param name="arg4">Fourth parameter used by the chart factory.</param> /// <param name="arg5">Fourth parameter used by the chart factory.</param> /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param> /// <returns></returns> public static FileContentResult RenderChartImage<T1, T2, T3, T4, T5>( this Controller controller, T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, Func<T1, T2, T3, T4, T5, Chart> chartFactory ) { Chart chart = chartFactory.Invoke( arg1, arg2, arg3, arg4, arg5 ); return RenderChartImage( chart ); } private static ContentResult RenderChartMap( Chart chart ) { chart.RenderType = RenderType.ImageMap; //Make sure that each chart rendered into the page has a unique ID in HTML by //assigning a Guid to the ID property. Otherwise, they will all have the same //ID and the image map will only work for the first chart at best. chart.ID = Guid.NewGuid().ToString( "N" ); using( var writer = new StringWriter() ) using( var htmlTextWriter = new HtmlTextWriter( writer ) ) { chart.RenderControl( htmlTextWriter ); return new ContentResult() { Content = writer.ToString() }; } } private static FileContentResult RenderChartImage( Chart chart ) { using( MemoryStream chartStream = new MemoryStream() ) { chart.SaveImage( chartStream, ChartImageFormat.Png ); return new FileContentResult( chartStream.ToArray(), "image/png" ) { FileDownloadName = "chart.png" }; } } private static string GetApplicationPath( Controller controller ) { var applicationPath = controller.HttpContext.Request.ApplicationPath; applicationPath = applicationPath.EndsWith( "/" ) ? applicationPath : applicationPath + "/"; return applicationPath; } private static string GetControllerName( Controller controller ) { var controllerName = controller.GetType().Name; if( !controllerName.EndsWith( "Controller" ) ) throw new InvalidOperationException( "Controller names must end with \"Controller\"" ); controllerName = controllerName.Substring( 0, controllerName.Length - 10 ); ; return controllerName; } } }
Thanks for the isnihgt. It brings light into the dark!
Youre the one with the brains here. Im wacthing for your posts.
What do you mean by “Another issue is that you need to have an explicit route defined for the Actions which return the chart image FileContentResult”?
I’ve put the code in but the result is a broken img in the page that displays.
You need to have a route that handles ~/Controller/Action/arg1/arg2/arg3/arg4. There is no rewriting to use querystring for each argument not included in a defined route because ?{random-guid} is always appended. So if you have 2 arguments, you would need something like this in your Global.asax.cs:
routes.MapRoute(
"Performance",
"Performance/{action}/{id}/{shareClass}",
new { controller="Performance", action="Index" }
);
In my example here, I have “id” and “shareClass” arguments which are passed in that order as arg1 and arg2.
If you load the image resource by itself in a new tab, you’ll probably get an instructive exception.
Hello Brian, First Thank you so much for time you took to extend MSchart In MVC..
One thing I am struggling with is Understanding the ShareClass .. Can you help with that ..
My Email is Stanley.Mwangi@NasdaqOMX.com.. Thank You Brian
ShareClass is just domain data for my particular project: Class A, Class B, etc.