Sparklines in text
▁▂▃▄▅▆▇█ Sparklines! █▇▆▅▄▃▂▁
Years ago Edward Tufte came out with his books on visualizing information, and I learned about sparklines!
Sparklines are such a cool idea, but I rarely use them because most of my code talks to databases or consoles, not graphical displays.
Just now, I almost missed my flight but it was delayed two hours! So maybe I can build a small thing?
Ok, I’m not the first person to have this idea, there’s even a rosetta code entry for this!
So I’ll hack up something quick in Haskell, and if I get stuck, read the existing answers. The rosetta code entry has unit tests I can steal, that’s handy. The eight unicode characters they suggest are in the heading above.
First thought on how to solve this is, for any set of numbers, find the max and min, then calculate the half, quarter, and eighth values, to be able to put all input numbers in a region. Then output one of the eight values.
First, calculate the eight regions, then map a function over the numbers that replaces each one with the character that matches this region.
module Main where
=
tests 0, 999, 4000, 4999, 7000, 7999] == "▁▁▅▅██"
[sparkline [0, 1, 19, 20] == "▁▁██"
, sparkline [1,2,3,4,5,6,7,8] == "▁▂▃▄▅▆▇█"
, sparkline [8,7,6,5,4,3,2,1] == "█▇▆▅▄▃▂▁"
, sparkline [
]
lines = "▁▂▃▄▅▆▇█" -- this code assumes lines is ascending order values
= last lines -- fails on empty list
maxChar = head lines -- fails on empty list
minChar = lines !! (length lines `div` 2) -- still works if lines changes length
halfChar
= "" -- no elements? no values!
sparkline [] :[]) = "▅" -- only one element? you get a value! does this "single element list" syntax even work?
sparkline (x@(x:y:zs) = map convertHere all -- at least two, we can do stuff now
sparkline allwhere inc = findIncrement (minN,maxN)
= min all
minN = max all
maxN = numToOffset minN inc -- partial application, yay!
convertHere
findIncrement :: Num n => (n,n) -> n
min,max) = increment = (max - min) / (length lines) -- allows lines to change length
findIncrement (
= lines !! offset
numToOffset minN increment num where addIncs i = i * increment + minN
= until (\v -> addIncs v > num) (+ 1) 0 offset
After getting partway into the code above I realized a sparkline calculation requires a minimum of two values to have a max and min and then calculate the values.
The code above is what I wrote without consulting the typechecker.
The Haskell 2010 Report - Standard Classes shows the hierarchy of number types.
Floating point numbers are not your friend
Once I reached the point where I expected the code to work, I ran across a conflict between div
and /
in Haskell.
In the interest of getting the code done quickly, I decided to divide the difference between the max and min by the number of sparkline characters, eight in this case.
Then I could add that increment a certain number of times and use that number to index into the list of sparkline characters.
The index operator !!
requires an integer, but all the other division operations really need to be floating point (or maybe ratio?).
I hacked around on the code above, when the type system was happy, it required the input number type to be both a whole number and a fractional number!
Since that’s not a real thing, I had to find a better way to do this. I ended ugly hacking around the problem by relying on rounding converting a floating point number that’s less than zero but more than -1 into zero, ugh!
The initial version was two hours before and during a flight to Atlanta, then an hour fighting with floating point math on the flight back to NYC. If already you have Haskell installed, you can copy the the version below into a file, set it executable, and it works as a shell script if you feed it numbers.
% ./Sparkline.hs 1 2 3 4 5 6 7 8 9
▁▁▂▃▄▅▆▇█
$! runhaskell
module Main where
import System.Environment (getArgs)
=
tests 0, 999, 4000, 4999, 7000, 7999] == "▁▁▅▅██"
[sparkline [0, 1, 19, 20] == "▁▁██"
, sparkline [1,2,3,4,5,6,7,8] == "▁▂▃▄▅▆▇█"
, sparkline [8,7,6,5,4,3,2,1] == "█▇▆▅▄▃▂▁"
, sparkline [
]
= "▁▂▃▄▅▆▇█" -- this code assumes lineChars is ascending order values
lineChars
= last lineChars -- fails on empty list
maxChar = head lineChars -- fails on empty list
minChar = lineChars !! (length lineChars `div` 2) -- still works if lineChars changes length
halfChar
sparkline :: (RealFrac a) => [a] -> [Char]
= [] -- no elements? no values!
sparkline [] = [] -- only one element? you get a value!
sparkline [x] @(x:y:zs) = convertHere <$> all -- at least two, we can do stuff now
sparkline allwhere inc = findIncrement (minN,maxN)
= minimum all
minN = maximum all
maxN = numToChar minN inc -- partial application, yay!
convertHere
findIncrement :: (Fractional a) => (a,a) -> a
= (maxnum - minnum) / (realToFrac $ length lineChars) -- allows lineChars to change length
findIncrement (minnum,maxnum)
numToOffset :: (Ord a, Num a) => a -> a -> a -> a
= until (\v -> addIncs v >= num) (+ 1) 0
numToOffset minN increment num where addIncs i = i * increment + minN
numToChar :: RealFrac a => a -> a -> a -> Char
= lineChars !! if off < 0 then 0 else off
numToChar minN increment num where off = (truncate $ (numToOffset minN increment num) - 1)
main :: IO ()
= do
main <- getArgs
args putStrLn . sparkline $ read <$> args
Final thoughts
The Recurse Center manual says “understand why your code works the way it does”. When I’m tired, it’s too easy for me to fix the code to make the type checker happy without using my brain to consider the actual problem. As I said in the two week retro, if I’m tired I write code that I have to fix later.
After my version worked, I looked at the Haskell solutions and golly that second solution is compact.
If you assume each input number is a separate command line argument, there’s an even more compact version below.
#! runhaskell
import Control.Arrow ((&&&))
import Data.List (findIndex)
import Data.Maybe (maybe)
import System.Environment (getArgs)
=
sparkLine xs let (mn, mx) = (minimum &&& maximum) xs
= (mx - mn) / 8
w = (mn +) . (w *) <$> [1 .. 7]
lbounds in fmap (maybe '█' ("▁▂▃▄▅▆▇" !!) . flip findIndex lbounds . flip (>)) xs
= do
main <- getArgs
args putStrLn . sparkLine $ read <$> args
Still doesn’t get close to the APL version though!