Haskell data visualization part 2: Hello, slightly different plots

Posted on March 21, 2026
Reading time: 17 minutes

Plotting with Haskell Libraries, Part 2

In part one, I made a few scatter plots with default settings (or examples from docs if “default settings” wasn’t a meaningful category). In part two, I’ll look at how plot configuration works in each of the five libraries I used in part one. Those libraries are:

I’m interested in a few kinds of plot configuration, specifically:

  • Axis control:
    • How do you change an axis’s scale and limits?
  • Labeling:
    • How do you re-title a plot?
    • How do you control axis labels?
  • Appearance:
    • How do you change the color cycle / color map used for symbols on a plot?
    • How do you change the font used in a plot’s text?
    • How do you make your plot bigger or smaller?

In general, these are the kinds of plot features that you want to change when you want to share a plot or collection of plots with someone else, whether that’s through embedding them in a report of some kind or pasting into chats.

All example are available in my goofing-off repo.

dataframe

dataframe part 1

The code example in part 1 used plotScatter, but there’s a collection of different scatter plot methods available: plotScatterWith, plotScatterBy, and plotScatterByWith all take a few extra arguments.

The *With functions take a PlotConfig, which answers the question of re-titling and controlling the plot size. The *By* functions take a reference to a column to choose different colors for points based on what the value of the label column. Together, they let you use the color channel for some information and control some plot aspects.

dataframeScatterConfig :: DT.TypedDataFrame LabeledDfSchema -> IO ()
dataframeScatterConfig typedDf =
  let plotConfig =
        DfPlot.PlotConfig
          { DfPlot.plotWidth = 200,
            DfPlot.plotTitle = "Little plot",
            DfPlot.plotHeight = 200,
            DfPlot.plotFile = Nothing,
            DfPlot.plotType = DfPlot.Scatter
          }
   in DfPlot.plotScatterByWith "x" "y" "tag" plotConfig (DT.thaw typedDf)
        >>= ( \(DfPlot.HtmlPlot plotText) ->
                Text.writeFile "plots/dataframeScatterConfig.html" plotText
            )

There’s not a ton else you can do – you can’t pick different axis limits switch to a log scale, change the axis labels, pick a different color cycle, or change the font.

I also tried out using the TypedDataFrame API here. It didn’t provide any additional safety in the plotting code in this case, since the plotting API specifically expects an untyped dataframe, but it will provide some safety in later examples, and it’s fun to imagine a TypedDataFrame plotting API that prevents you from trying to produce impossible plots before you finish running some data pipeline.

granite

granite part 1

granite’s part 1 code example used all the same tools that this example uses, except in part 1, it used defPlot unmodified. defPlot is the default plot configuration.

Little plot 1.5 0.5 -0.5 -1.0 0.5 2.0 points
graniteSvgScatterConfig :: DT.TypedDataFrame LabeledDfSchema -> IO ()
graniteSvgScatterConfig df =
  let xs = DT.columnAsList @"x" df
      ys = DT.columnAsList @"y" df
      plotConfig =
        G.defPlot
          { G.widthChars = 30,
            G.heightChars = 30,
            G.plotTitle = "Little plot",
            G.xBounds = (Just (-1), Just 2),
            G.yBounds = (Just (-0.5), Just 1.5),
            G.colorPalette = [G.BrightGreen, G.BrightBlack]
          }
      plot = GSvg.scatter [G.series "points" (zip xs ys)] plotConfig
   in Text.writeFile "plots/graniteSvgScatterConfig.html" plot

The PlotConfig value from defPlot can be updated with a different title, a different plot size, replacement axis bounds for the x and y axes, and a different color palette. In the part 1 plot, since I didn’t modify anything, the values were plotted in the sensible range from 0 to 1 on both axes using the default first color from granite’s color palette. In this version, the axis ranges are now very dumb because I hand-picked them to be, the color is BrightGreen instead of BrightBlue, I changed the title, and I changed the size of the output.

The width and height of the plot are specified in “chars”, which get converted into a size for the plot based on constants annotated Pixels per terminal character height and Pixels per terminal character width. For SVG output, the terminal pixel sizes aren’t relevant, and those two constants not being equal means that my plot that looks like it ought to be “30x30” in mystery units isn’t square. That was surprising to me, but not a big deal, and understandable for a plotting library that targeted terminals first.

As with dataframe, there’s no way to switch from a linear to a log scale for either axis, you can’t change the font, and you can’t change the axis labels.

hvega

hvega part 1

hvega’s part 1 code example was more complicated than most of the other examples. Its part 2 example is also more complicated, but in part 2, part of the reason for that is clear. If you want, you can use hvega to take pretty fine-grained control over every aspect of your plot. If you need a long label for your y axis and want to make it bigger for some reason, you can! If you want different fonts for your title, axis labels, and legends, you can! If you want a monochrome/gradated purple scatter plot on a purple background, that’s your prerogative!

hvegaScatterConfig :: DT.TypedDataFrame LabeledDfSchema -> IO ()
hvegaScatterConfig df =
  let vegaColumns =
        [ V.dataColumn "x" (V.Numbers (DT.columnAsList @"x" df)),
          V.dataColumn "y" (V.Numbers (DT.columnAsList @"y" df)),
          V.dataColumn "tag"
            (V.Strings ((Text.pack . pure <$> DT.columnAsList @"tag" df)))
        ]
      vegaData = foldl' (.) (V.dataFromColumns []) vegaColumns
      enc =
        V.encoding
          . V.position
            V.X
            [ V.PName "x",
              V.PmType V.Quantitative,
              -- control axis extent
              V.PScale [V.SDomain (V.DNumbers [0, 1.2])],
              V.PAxis [V.AxTitle "The x values"]
            ]
          . V.position
            V.Y
            [ V.PName "y",
              V.PmType V.Quantitative,
              -- set a log scale on y
              V.PScale [V.SType V.ScLog],
              V.PAxis
                [V.AxTitle "The very important y values",
                 V.AxTitleFontSize 18]
            ]
          -- color the points based on tag using the "purples" scale
          . V.color [V.MName "tag",
                     V.MmType V.Nominal,
                     V.MScale [V.SScheme "purples" [10]]]
      -- set a different title with a different font
      title = V.title
                "Wide purple plot >>="
                [V.TFont "Hasklug Nerd Font", V.TFontStyle "italic"]
   in V.toHtmlFile "plots/vegaScatterConfig.html" $
        V.toVegaLite
          [ vegaData [],
            V.mark V.Point [],
            enc [],
            title,
            -- change the plot dimensions
            V.width 600,
            V.height 200,
            V.background "rgba(20, 0, 50, 0.2)"
          ]

If you also have the Hasklug Nerd Font, you’ll see nice ligatures on the >>= in the plot title.

As a Vega / Vega Lite novice, I didn’t have the easiest time figuring out what values I needed to provide in order to configure the different aspects of the plot, and this example is about three times as many lines of code as the example in part 1. The trade for this complexity is power1 – essentially every aspect of the plot can be configured in some way.

hvega targets version 4 of the Vega specification. The current version of the Vega specification is version 6. One cost of targeting an older version of the specification is that if you click on the three dots next to the plot and choose “Open in Vega Editor,” you’ll get a warning about how the editor wants version 6. If you want to edit the plot using Vega Editor, you can just lie and bump the "$scheme" property to v6.json instead. This might not work forever, but I didn’t have any trouble with it.

chart-svg

chart-svg part 1

In part one I ignored the possibility of setting chart attributes without optics because

experience says if someone makes handy optics available, it’s probably because not using them hurts.

After using the optics here for chart configuration, I think that was the correct instinct.

titled scatter x label y label 0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.2 0.4 0.6 0.8 1.0
chartSvgScatter :: DT.TypedDataFrame LabeledDfSchema -> IO ()
chartSvgScatter df =
  let xs = DT.columnAsList @"x" df
      ys = DT.columnAsList @"y" df
      points = zipWith ChartSVG.Point xs ys
      -- change mark color
      style = ChartSVG.defaultGlyphStyle & #color .~ ChartSVG.palette 123
              & #size .~ 0.015
      chart = ChartSVG.GlyphChart style points
      scatterExample =
        mempty
          -- title a plot
          & set #chartTree (ChartSVG.named "titled-scatter" [chart])
          & #hudOptions
            .~ ( ChartSVG.defaultHudOptions
                   -- title a plot
                   & #titles
                     .~ [ ChartSVG.Priority 0 $
                            ChartSVG.defaultTitleOptions "titled scatter"
                              & #style % #size .~ 0.05,
                          -- add specific labels for x and y
                          ChartSVG.Priority 1 $
                            ChartSVG.defaultTitleOptions "x label"
                              & #place .~ PlaceBottom,
                          ChartSVG.Priority 2 $
                            ChartSVG.defaultTitleOptions "y label"
                              & #place .~ PlaceLeft
                        ]
               )
          -- change font
          & #markupOptions % #cssOptions %
            #fontFamilies .~ "svg { font-family: \"Hasklug Nerd Font\"; }"
          -- resize the plot
          & #markupOptions % #markupHeight .~ Just 200
          & #markupOptions % #chartAspect .~ ChartSVG.FixedAspect 3 ::
          ChartSVG.ChartOptions
   in ChartSVG.writeChartOptions
        "plots/chartSvgScatterConfig.svg"
        scatterExample

The use of OverloadedLabels with optics in the examples really pays off. chart-svg docs note:

Chart, HudOptions and associated chart configuration types are big and sometimes deep syntax trees, and simple optics; getting, setting and modding, makes manipulation more pleasant.

Especially because I have a single module with a bunch of qualified imports, the alternative where I’d have used record update syntax instead would have been really tedious.

Even when they’re easy to set though, some of the styles are raw CSS strings, like many of the properties under cssOptions. To set font for a title, I used

mempty
  -- ...
  & #markupOptions % #cssOptions % #fontFamilies .~ something

That something is a raw bytestring, which it’s on you as the developer to ensure is valid CSS targeting the thing you care about. Some people spend a lot of time writing CSS and are good at it. I am not, so this was a rough API for me.

I did not figure out how to log scale one of the axes or change the axis limits.2

Chart

Chart part 1

chartScatterConfig :: DT.TypedDataFrame LabeledDfSchema -> IO ()
chartScatterConfig df =
  let xs = DT.columnAsList @"x" df
      ys = Chart.LogValue <$> DT.columnAsList @"y" df
   in Cairo.toFile Chart.def "plots/chartScatterConfig.png" $ do
        Chart.setColors [Chart.opaque Chart.dodgerblue]
        Chart.layout_title_style . Chart.font_name .= "Hasklug Nerd Font"
        Chart.layout_title .= "log y scatter >>="
        Chart.layout_x_axis
          . Chart.laxis_override
          .= Chart.axisLabelsOverride [(0.5, "x label")]
        Chart.layout_y_axis . Chart.laxis_override
          .= Chart.axisLabelsOverride [(0.5, "y label")]
        Chart.plot $ Chart.points "points" (zip xs ys)

Lastly, I configured another scatter plot of the same data with Chart.

chart-svg and Chart share an optics-oriented approach to configuration.3 Once you have the modifications lined up, the EC helper type4 provides an imperative-looking API for setting different options, in contrast to the monoidal builder of chart-svg and the JSON Schema translation of hvega. That kind of API might be especially nice for people used to plotting in Python libraries, though that translation may be hampered by a straw-Python-enthusiast’s presumable unfamiliarity with optics and StateT.

A neat piece of the API was the mechanism for converting an axis to a log scale. Instead of modifying the axis, I wrapped the values in a LogValue newtype, which caused the library to choose log scaling for the axis associated with those values.

The Chart gallery makes me think I could have completed all of the configuration tasks I set out here, but I failed to change axis limits.

I’ll update this list as I complete the other posts, but here’s the basic outline:

  • Hello, plots
  • Plot configuration (this post)
  • Low-level plotting (annotating plots, drawing shapes wherever you want)

  1. Not that changing axis labels is the most power anyone can imagine in a charting library, but it’s more power than not changing axis labels.↩︎

  2. I also somehow broke the exported svg by including <$> in the title text for the main plot, so I’ve edited it slightly here so it will embed in this page correctly. The export went fine when saving it off as its own file, so I’m guessing it’s something to do with pandoc’s render of the markdown containing the svg, but I don’t really know. Anyway, I don’t think it was chart-svg’s fault. Computers, man.↩︎

  3. For some reason, I had an easier time with chart-svg than with Chart. It’s possible the reason is that I did chart-svg first, and the first set of optics poisoned my brain for the second.↩︎

  4. It took me way too long to check docs and realize this is just StateT. I really wish I’d done that sooner!↩︎


If you have anything to say about anything in this post, feel free to email the author