granite-0.5.0.0: src/Granite/Format.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE Strict #-}
{- |
Module : Granite.Format
Copyright : (c) 2025
License : MIT
Maintainer : mschavinda@gmail.com
Apply a declarative 'Formatter' to a 'Double'. Replaces the
function-typed @LabelFormatter@ used by the legacy 'Granite.Plot'
record, so the entire chart spec can stay JSON-shaped.
-}
module Granite.Format (
Formatter (..),
runFormatter,
) where
import Data.Text (Text)
import Data.Text qualified as Text
import Numeric (showEFloat, showFFloat)
{- | A declarative formatter applied to numeric tick / label values.
The /Default/ behaves like the legacy formatter: scientific for
very small or very large magnitudes, fixed-point with one decimal
otherwise. The other constructors give the caller explicit control
without needing to embed a Haskell function in the spec.
-}
data Formatter
= FormatDefault
| -- | fixed point with the given decimals
FormatPrecision Int
| -- | scientific notation with the given decimals
FormatScientific Int
| -- | percentage (multiplies by 100), with given decimals
FormatPercent Int
| -- | integer with comma grouping (1,234,567)
FormatComma
| {- | epoch-millisecond value formatted with the given strftime-style
pattern; unsupported in this phase, returns the raw value.
-}
FormatDateTime !Text
| {- | a printf-like template with a single \"{}" placeholder.
This phase only supports \"{}" itself.
-}
FormatTemplate !Text
| -- | SI suffixes (1.2k, 3.4M)
FormatSI
deriving (Eq, Show, Read)
runFormatter :: Formatter -> Double -> Text
runFormatter f v = case f of
FormatDefault -> defaultFmt v
FormatPrecision n -> Text.pack (showFFloat (Just n) v "")
FormatScientific n -> Text.pack (showEFloat (Just n) v "")
FormatPercent n -> Text.pack (showFFloat (Just n) (v * 100) "") <> "%"
FormatComma -> commaGroup (round v :: Integer)
FormatDateTime _ -> Text.pack (show v)
FormatTemplate t -> Text.replace "{}" (defaultFmt v) t
FormatSI -> siSuffix v
defaultFmt :: Double -> Text
defaultFmt v
| abs v >= 10000 || abs v < 0.01 && v /= 0 =
Text.pack (showEFloat (Just 1) v "")
| otherwise = Text.pack (showFFloat (Just 1) v "")
commaGroup :: Integer -> Text
commaGroup n =
let neg = n < 0
digits = reverse (show (abs n))
chunks = chunkN 3 digits
grouped = reverse (intercalateRev "," chunks)
in (if neg then "-" else "") <> Text.pack grouped
chunkN :: Int -> [a] -> [[a]]
chunkN _ [] = []
chunkN n xs = take n xs : chunkN n (drop n xs)
intercalateRev :: [a] -> [[a]] -> [a]
intercalateRev sep = go
where
go [] = []
go [x] = x
go (x : xs) = x ++ sep ++ go xs
siSuffix :: Double -> Text
siSuffix v
| a >= 1e12 = fmt (v / 1e12) "T"
| a >= 1e9 = fmt (v / 1e9) "G"
| a >= 1e6 = fmt (v / 1e6) "M"
| a >= 1e3 = fmt (v / 1e3) "k"
| a >= 1 = Text.pack (showFFloat (Just 1) v "")
| a >= 1e-3 = fmt (v * 1e3) "m"
| a >= 1e-6 = fmt (v * 1e6) "\x00b5"
| otherwise = Text.pack (showEFloat (Just 1) v "")
where
a = abs v
fmt x suf = Text.pack (showFFloat (Just 1) x "") <> suf