10
\$\begingroup\$

One of the things I was recently investigating was the ability to generate "gauge" charts—that is, a chart which shows where a value stands within a range of values.

As an example, if we know that, in general, the minimum air pressure is about 45 kPa, and the max is about 150 kPa, then we can generate a "gauge" that shows how far along that trend our current pressure, say 101.325 kPa, is. We might end up with one of the following (depending on whether we want horizontal or arc).

Arc Gauge Linear Gauge

So basically, we can generate one of two graphs, where the value fills the left portion, and indicates, visually, how it stacks up to the range.

The code for each is below, both are mostly identical but have a few small differences.

ArcGauge.fs:

module EBrown.Graphing.ArcGauge
open System.Drawing
open System.Drawing.Drawing2D

type Configuration =
    { Height : int
      Width : int
      Padding : Rectangle
      GaugeWidth : int
      Font : Font option
      EmptyGaugeColor : Color option
      FillGaugeColor : Color option
      FontColor : Color option
      OutlineColor : Color option
      BackgroundColor : Color option
      OutlineThickness : float32 option }

let generate<'a> configuration (toFloat : 'a -> float32) (formatter : 'a -> string) (max : 'a) (min : 'a) (value : 'a) =
    use defaultFont = new Font("Arial", 12.f, FontStyle.Regular)
    let font = configuration.Font |> Option.defaultValue defaultFont

    let imageWidth = configuration.Width - configuration.Padding.Top - configuration.Padding.Bottom
    let imageHeight = configuration.Height - configuration.Padding.Left - configuration.Padding.Right
    let gaugeSweep = 180.0f
    let angle = gaugeSweep * (((value |> toFloat) - (min |> toFloat)) / ((max |> toFloat) - (min |> toFloat)))
    let gaugeWidth = configuration.GaugeWidth
    let startAngle = 180.0f + (180.0f - gaugeSweep) / 2.0f

    let image = new Bitmap(imageWidth + configuration.Padding.Left + configuration.Padding.Right, imageHeight + configuration.Padding.Top + configuration.Padding.Bottom)
    use g = Graphics.FromImage(image)
    g.SmoothingMode <- SmoothingMode.AntiAlias
    g.InterpolationMode <- InterpolationMode.HighQualityBicubic

    use backgroundBrush = new SolidBrush(configuration.BackgroundColor |> Option.defaultValue Color.White)
    use emptyFillBrush = new SolidBrush(configuration.EmptyGaugeColor |> Option.defaultValue (Color.FromArgb(255, 192, 192, 192)))
    use gaugeFillBrush = new SolidBrush(configuration.FillGaugeColor |> Option.defaultValue (Color.FromArgb(255, 192, 64, 64)))
    use externalPen = new Pen(configuration.OutlineColor |> Option.defaultValue (Color.FromArgb(255, 96, 96, 96)), configuration.OutlineThickness |> Option.defaultValue 1.5f)
    use fontBrush = new SolidBrush(configuration.FontColor |> Option.defaultValue Color.Black)

    g.FillRectangle(backgroundBrush, Rectangle(0, 0, imageWidth + configuration.Padding.Left + configuration.Padding.Right, imageHeight + configuration.Padding.Top + configuration.Padding.Bottom))
    use path = new GraphicsPath()
    path.AddArc(Rectangle(configuration.Padding.Left, configuration.Padding.Top, imageWidth, imageWidth), startAngle, angle)
    path.Reverse()
    path.AddArc(Rectangle(configuration.Padding.Left + gaugeWidth, configuration.Padding.Top + gaugeWidth, imageWidth - gaugeWidth * 2, imageWidth - gaugeWidth * 2), startAngle, angle)
    path.CloseFigure()

    use externalPath = new GraphicsPath()
    externalPath.AddArc(Rectangle(configuration.Padding.Left, configuration.Padding.Top, imageWidth, imageWidth), startAngle, gaugeSweep)
    externalPath.Reverse()
    externalPath.AddArc(Rectangle(configuration.Padding.Left + gaugeWidth, configuration.Padding.Top + gaugeWidth, imageWidth - gaugeWidth * 2, imageWidth - gaugeWidth * 2), startAngle, gaugeSweep)
    externalPath.CloseFigure()

    g.FillPath(emptyFillBrush, externalPath)
    g.FillPath(gaugeFillBrush, path)
    g.DrawPath(externalPen, externalPath)

    let drawLabel = General.drawLabelCentered g (Rectangle(0, 0, image.Width, image.Height)) font fontBrush

    let gaugeLabelOffsetX = (configuration.Padding.Left |> float32) + (gaugeWidth / 2 |> float32)
    let imageMidX = (imageWidth |> float32) * 0.5f + (configuration.Padding.Left |> float32)
    drawLabel (value |> formatter) (PointF(imageMidX, (imageWidth |> float32) * 0.5f + (configuration.Padding.Top |> float32) - 10.f))
    drawLabel (min |> formatter) (PointF(gaugeLabelOffsetX, (imageWidth |> float32) * 0.5f + 5.f + (configuration.Padding.Top |> float32)))
    drawLabel (max |> formatter) (PointF((image.Width |> float32) - gaugeLabelOffsetX, (imageWidth |> float32) * 0.5f + 5.f + (configuration.Padding.Top |> float32)))
    image

LinearGauge.fs:

module EBrown.Graphing.LinearGauge
open System.Drawing
open System.Drawing.Drawing2D

type Configuration =
    { Height : int
      Width : int
      Padding : Rectangle
      GaugeWidth : int
      Font : Font option
      EmptyGaugeColor : Color option
      FillGaugeColor : Color option
      FontColor : Color option
      OutlineColor : Color option
      BackgroundColor : Color option
      OutlineThickness : float32 option }

let generate<'a> configuration (toFloat : 'a -> float32) (formatter : 'a -> string) (max : 'a) (min : 'a) (value : 'a) =
    use defaultFont = new Font("Arial", 12.f, FontStyle.Regular)
    let font = configuration.Font |> Option.defaultValue defaultFont

    let imageWidth = configuration.Width - configuration.Padding.Top - configuration.Padding.Bottom
    let imageHeight = configuration.Height - configuration.Padding.Left - configuration.Padding.Right
    let length = ((imageWidth |> float32) * (((value |> toFloat) - (min |> toFloat)) / ((max |> toFloat) - (min |> toFloat)))) |> int
    let gaugeWidth = configuration.GaugeWidth

    let image = new Bitmap(imageWidth + configuration.Padding.Left + configuration.Padding.Right, imageHeight + configuration.Padding.Top + configuration.Padding.Bottom)
    use g = Graphics.FromImage(image)
    g.SmoothingMode <- SmoothingMode.AntiAlias
    g.InterpolationMode <- InterpolationMode.HighQualityBicubic

    use backgroundBrush = new SolidBrush(configuration.BackgroundColor |> Option.defaultValue Color.White)
    use emptyFillBrush = new SolidBrush(configuration.EmptyGaugeColor |> Option.defaultValue (Color.FromArgb(255, 192, 192, 192)))
    use gaugeFillBrush = new SolidBrush(configuration.FillGaugeColor |> Option.defaultValue (Color.FromArgb(255, 192, 64, 64)))
    use externalPen = new Pen(configuration.OutlineColor |> Option.defaultValue (Color.FromArgb(255, 96, 96, 96)), configuration.OutlineThickness |> Option.defaultValue 1.5f)
    use fontBrush = new SolidBrush(configuration.FontColor |> Option.defaultValue Color.Black)

    g.FillRectangle(backgroundBrush, Rectangle(0, 0, imageWidth + configuration.Padding.Left + configuration.Padding.Right, imageHeight + configuration.Padding.Top + configuration.Padding.Bottom))
    let filledGauge = Rectangle(configuration.Padding.Left, configuration.Padding.Top, length, gaugeWidth)

    let externalGauge = Rectangle(configuration.Padding.Left, configuration.Padding.Top, imageWidth, gaugeWidth)

    g.FillRectangle(emptyFillBrush, externalGauge)
    g.FillRectangle(gaugeFillBrush, filledGauge)
    g.DrawRectangle(externalPen, externalGauge)

    let drawLabel = General.drawLabel g (Rectangle(0, 0, image.Width, image.Height)) font fontBrush
    let drawLabelCentered = General.drawLabelCentered g (Rectangle(0, 0, image.Width, image.Height)) font fontBrush

    let gaugeLabelOffsetX = (configuration.Padding.Left |> float32)
    let imageMidX = (imageWidth |> float32) * 0.5f + (configuration.Padding.Left |> float32)
    drawLabelCentered (value |> formatter) (PointF(imageMidX, (configuration.Padding.Top |> float32) + (gaugeWidth |> float32) + 5.f))
    drawLabel (min |> formatter) (PointF(gaugeLabelOffsetX, (configuration.Padding.Top |> float32) + (gaugeWidth |> float32) + 5.f))
    drawLabel (max |> formatter) (PointF((image.Width |> float32) - gaugeLabelOffsetX - g.MeasureString(max |> formatter, font).Width, (configuration.Padding.Top |> float32) + (gaugeWidth |> float32) + 5.f))
    image

You'll notice that both make references to a General module, which is pretty simple:

General.fs:

module EBrown.Graphing.General
open System.Drawing

let drawLabelCentered (g : Graphics) (bounds : Rectangle) font brush str (ptLoc : PointF) =
    let measurements = g.MeasureString(str, font)
    g.DrawString(
        str,
        font,
        brush,
        PointF(
            min (max (ptLoc.X - measurements.Width * 0.5f) (bounds.Left |> float32)) ((bounds.Right |> float32) - measurements.Width),
            max (min (ptLoc.Y) ((bounds.Bottom |> float32) - measurements.Height)) ((bounds.Top |> float32))))

let drawLabel (g : Graphics) (bounds : Rectangle) font brush str (ptLoc : PointF) =
    let measurements = g.MeasureString(str, font)
    g.DrawString(
        str,
        font,
        brush,
        PointF(
            min (max (ptLoc.X) (bounds.Left |> float32)) ((bounds.Right |> float32) - measurements.Width),
            max (min (ptLoc.Y) ((bounds.Bottom |> float32) - measurements.Height)) (bounds.Top |> float32)))

As a test, I have the following F# script:

// Learn more about F# at http://fsharp.org
// See the 'F# Tutorial' project for more help.

#load "General.fs"
#load "ArcGauge.fs"
#load "LinearGauge.fs"
open EBrown.Graphing

// Define your library scripting code here
open System.Drawing
open System.Drawing.Imaging
open System.Diagnostics

let values = (150., 45., 101.325)
let g1() =
    let savePath = @"C:\Users\ebrown\Desktop\TestGauge.png"
    let padding = Rectangle.FromLTRB(10, 10, 10, 30)
    let config =
        { ArcGauge.Configuration.Height = 200
          ArcGauge.Configuration.Width = 400
          ArcGauge.Configuration.Padding = padding
          ArcGauge.Configuration.Font = None
          ArcGauge.Configuration.GaugeWidth = 64
          ArcGauge.Configuration.EmptyGaugeColor = Color.FromArgb(255, 192, 192, 192) |> Some
          ArcGauge.Configuration.FillGaugeColor = Color.FromArgb(255, 96, 16, 16) |> Some
          ArcGauge.Configuration.FontColor = Color.FromArgb(255, 32, 0, 0) |> Some
          ArcGauge.Configuration.OutlineColor = Color.FromArgb(255, 64, 64, 64) |> Some
          ArcGauge.Configuration.BackgroundColor = Color.White |> Some
          ArcGauge.Configuration.OutlineThickness = 1.25f |> Some }
    let image = values|||> ArcGauge.generate config float32 (fun f -> f.ToString("0 KPa"))
    image.Save(savePath, ImageFormat.Png)
    Process.Start savePath |> ignore

let g2() =
    let savePath = @"C:\Users\ebrown\Desktop\TestGauge2.png"
    let padding = Rectangle.FromLTRB(10, 10, 10, 30)
    let config =
        { LinearGauge.Configuration.Height = 50
          LinearGauge.Configuration.Width = 400
          LinearGauge.Configuration.Padding = padding
          LinearGauge.Configuration.Font = None
          LinearGauge.Configuration.GaugeWidth = 32
          LinearGauge.Configuration.EmptyGaugeColor = Color.FromArgb(255, 192, 192, 192) |> Some
          LinearGauge.Configuration.FillGaugeColor = Color.FromArgb(255, 96, 16, 16) |> Some
          LinearGauge.Configuration.FontColor = Color.FromArgb(255, 32, 0, 0) |> Some
          LinearGauge.Configuration.OutlineColor = Color.FromArgb(255, 64, 64, 64) |> Some
          LinearGauge.Configuration.BackgroundColor = Color.White |> Some
          LinearGauge.Configuration.OutlineThickness = 1.25f |> Some }
    let image = values|||> LinearGauge.generate config float32 (fun f -> f.ToString("0 KPa"))
    image.Save(savePath, ImageFormat.Png)
    Process.Start savePath |> ignore

//() |> g1
//() |> g2

Lastly, you can find this on GitHub.

\$\endgroup\$
  • \$\begingroup\$ In both ArcGauge.fs and LinearGauge.fs, I'm getting the error below. I would have thought an open statement would fix it, but open EBrown.Graphing.General does not compile. The value, namespace, type or module 'General' is not defined. \$\endgroup\$ – Scott Hutchinson Jul 7 '18 at 20:33
3
\$\begingroup\$

In the script, you could change this:

let config =
    { ArcGauge.Configuration.Height = 200
      ArcGauge.Configuration.Width = 400
      ArcGauge.Configuration.Padding = padding
      ArcGauge.Configuration.Font = None
      ArcGauge.Configuration.GaugeWidth = 64
      ArcGauge.Configuration.EmptyGaugeColor = Color.FromArgb(255, 192, 192, 192) |> Some
      ArcGauge.Configuration.FillGaugeColor = Color.FromArgb(255, 96, 16, 16) |> Some
      ArcGauge.Configuration.FontColor = Color.FromArgb(255, 32, 0, 0) |> Some
      ArcGauge.Configuration.OutlineColor = Color.FromArgb(255, 64, 64, 64) |> Some
      ArcGauge.Configuration.BackgroundColor = Color.White |> Some
      ArcGauge.Configuration.OutlineThickness = 1.25f |> Some }

to this (because it has less duplication and would be easier to read):

open ArcGauge

let config =
    { Height = 200
      Width = 400
      Padding = padding
      Font = None
      GaugeWidth = 64
      EmptyGaugeColor = Color.FromArgb(255, 192, 192, 192) |> Some
      FillGaugeColor = Color.FromArgb(255, 96, 16, 16) |> Some
      FontColor = Color.FromArgb(255, 32, 0, 0) |> Some
      OutlineColor = Color.FromArgb(255, 64, 64, 64) |> Some
      BackgroundColor = Color.White |> Some
      OutlineThickness = 1.25f |> Some }

In the code below, the constant 180.0 is repeated three times (magic numbers). Probably it has the same meaning each time, so it could be assigned to a named constant that would help developers understand how it is used.

let gaugeSweep = 180.0f
let angle = gaugeSweep * (((value |> toFloat) - (min |> toFloat)) / ((max |> toFloat) - (min |> toFloat)))
let startAngle = 180.0f + (180.0f - gaugeSweep) / 2.0f
\$\endgroup\$
  • 3
    \$\begingroup\$ Now that I look at it, 180.0f - gaugeSweep will always be 0. \$\endgroup\$ – Scott Hutchinson Jul 8 '18 at 20:20
  • \$\begingroup\$ That was actually meant to allow me to change the gaugeSweep value, haven't decided if I still want that feature or not, and that's a remnant of it. \$\endgroup\$ – Der Kommissar Jul 9 '18 at 16:43
2
\$\begingroup\$

Your two Configuration types are the same. Could they be merged into one type in a shared module? Then some code to get imageWidth, imageHeight could be shared, maybe as a member of the Configuration record.

This line seems redundant:

    let gaugeWidth = conf.GaugeWidth

If you make the generate functions inline you can remove all of the type annotations and use float32 directly within them instead of passing in a toFloat function:

let inline generate configuration formatter max min value =
...
    let length = ((imageWidth |> float32) * (((value |> float32) - (min |> float32)) / ((max |> float32) - (min |> float32)))) |> int

I don't see the need to pipe to a single function, especially when a line is already very long. You could use normal prefixed functions. And then many parentheses can be safely removed too because of operator precedence:

    let length = float32 imageWidth * (float32 value - float32 min) / (float32 max - float32 min) |> int

configuration is used many times. You some very long lines shorter and more readable by making the name something shorter like config or conf.

Be wary of calling parameters max and min because there are already F# core functions with these names.

Some of the longer lines might be easier to understand if you add some intermediate let bindings.

\$\endgroup\$
1
\$\begingroup\$

So, took all the advices and then some:

  • moved Configuration out to it's own module, and created a Default configuration as well as some member functions for helping out with C#/VB.NET;
    • this makes it more reusable between both gauges, since they basically take the same configuration parameters;
    • the member functions make the immutable record appear mutable, even though it's not (they work like the DateTime functions);
  • renamed max and min parameters to upper and lower, respectively;
    • as mentioned, max and min are F# functions, so this helps remove ambiguity and more appropriately describes them, since technically you can provide a value outside the range;
  • removed optional from the majority of configuration items, except Font (since it's a disposable and I don't want Default leaking that);
    • this means one can start with Default config, and not have to worry about the resources in it;
  • added a generateDelegate overload for Arc and Linear gauges, which takes a Func<'a, float32> and Func<'a, string> instead of the 'a -> float32 and 'a -> string, which makes it more friendly for C#/VB.NET;
    • because there's poor compatibility for FSharpFunc in C#/VB.NET (which is what 'a -> float32 is translated to), by accepting a Func<'a, float32> we can use the .Invoke function on it as a parameter to the generate function;
  • in the Script.fsx I boiled it down to a much smaller setup, especially since I have migrated out configurations, and I use the image for IDisposable;
    • I also corrected the GitHub repo, as it was generating to the same file for g1 and g2;
  • made General.drawLabelCentered call General.drawLabel, since they both had identical logic (and drawLabelCentered is a specialized version of drawLabel anyway) except for the coordinates;
    • this also means we could open ourselves up to more specialized versions of drawLabel, such as one that would draw the label by the baseline, instead of starting it at the point and moving down-left;
\$\endgroup\$

Your Answer

By clicking "Post Your Answer", you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.