How to create a bar chart from a CSV file with Haskell

— 270 Words — 2 min

Today I wanted to create a bar chart for a new blog post on blog.airsequel.com. It was supposed to show the number of days between each SQLite release. I decided to use Haskell, but I couldn't find any good code examples out there. So, I went ahead and wrote the code from scratch. 😮‍💨 I'm sharing it here in hopes of sparing the next person the time and effort. 😅

This is the final chart:

The data is simply copied from the History Of SQLite Releases page and formated as a CSV file with the following columns:

Date,Version
2023-03-22,3.41.2
2023-03-10,3.41.1

Check out this post's directory at GitHub for the full source files.

I used Cassava to parse the CSV file and Chart to create the SVG chart.

I wanted to use chart-svg originally, since they had just released a quite nice looking new version. However, it's not published on Hackage or Stackage yet. So I used the good old Chart module instead.

Without further ado, here is the code:


#! /usr/bin/env stack
{- stack script
    --ghc-options "-Wall"
    --resolver lts-20.18
    --package bytestring
    --package cassava
    --package Chart
    --package Chart-diagrams
    --package text
    --package time
    --package vector
-}

{-# LANGUAGE OverloadedRecordDot #-}

import Control.Monad (mzero)
import Data.ByteString.Char8 (unpack)
import Data.ByteString.Lazy qualified as BL
import Data.Csv qualified as Csv
import Data.Text qualified as T
import Data.Time.Calendar (diffDays)
import Data.Time.Clock (UTCTime, getCurrentTime, utctDay)
import Data.Time.Format (defaultTimeLocale, parseTimeM)
import Data.Vector qualified as V
import Graphics.Rendering.Chart.Backend.Diagrams (
  FileFormat (SVG),
  FileOptions (FileOptions),
  loadSansSerifFonts,
  toFile,
 )
import Graphics.Rendering.Chart.Easy (
  BarsPlotValue,
  EC,
  PlotBars,
  PlotBarsSpacing (BarsFixGap),
  PlotBarsStyle (BarsClustered),
  PlotValue,
  (&),
  (.=),
  (<&>),
 )
import Graphics.Rendering.Chart.Easy qualified as CE

-- | Define the data type for each row in the CSV
data Release = Release
  { date :: UTCTime
  , version :: T.Text
  }
  deriving (Show)

-- | Parse a date from the CSV
parseDate :: Csv.Field -> Csv.Parser UTCTime
parseDate field =
  parseTimeM True defaultTimeLocale "%Y-%m-%d" (unpack field)

-- | Parse a row from the CSV
instance Csv.FromRecord Release where
  parseRecord v
    | V.length v == 2 =
        Release
          <$> (v Csv..! 0 >>= parseDate)
          <*> (T.pack . unpack <$> v Csv..! 1)
    | otherwise = mzero

-- | Calculate difference between consecutive elements
stepSizes :: V.Vector Int -> V.Vector Int
stepSizes xs =
  V.zipWith (-) (V.tail xs) xs

-- | Use an improved style for the bar chart
createBars ::
  (PlotValue x, BarsPlotValue y) =>
  [(x, [y])] ->
  EC l (PlotBars x y)
createBars vals = CE.liftEC $ do
  CE.plot_bars_titles .= ["Days since last release"]
  CE.plot_bars_values .= vals
  CE.plot_bars_style .= BarsClustered
  CE.plot_bars_spacing .= BarsFixGap 0.2 2
  CE.plot_bars_item_styles .= [(CE.solidFillStyle $ CE.opaque CE.teal, Nothing)]

{- | Load the CSV file, calculate the number of days since each release,
| and write the chart to an SVG file
-}
main :: IO ()
main = do
  csvData <- BL.readFile "release_data.csv"
  case Csv.decode Csv.HasHeader csvData of
    Left err -> putStrLn err
    Right (releases :: V.Vector Release) -> do
      now <- getCurrentTime

      let
        daysSinceLastRelease =
          releases
            & V.map
              ( \release ->
                  fromInteger $
                    diffDays (utctDay now) (utctDay release.date)
              )
            & stepSizes

      toFile
        (FileOptions (800, 450) SVG loadSansSerifFonts)
        "days_since_last_sqlite_release.svg"
        $ do
          CE.layout_title .= "Days Since Last SQLite Release"
          CE.plot $
            CE.plotBars
              <$> createBars
                ( releases
                    `V.zip` daysSinceLastRelease
                    & V.toList
                    <&> \(release :: Release, day) -> (release.date, [day])
                )


The only change I had to make to the SVG afterwards was to remove the width and height attributes from the <svg> tag. This lets it automatically scale to the size of the parent element. I also created a ticket for them on GitHub to support omitting the size: github.com/timbod7/haskell-chart/issues/250..


If you have any comments, thoughts, or other feedback feel free to tweet me @AdrianSieber. Thanks for your help! 😊