dataframe-viz-1.0.2.0: src/DataFrame/Display/Web/Plot.hs
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoFieldSelectors #-}
{- |
A plotly-express-style one-shot plotting API for HTML output. Mirrors
'DataFrame.Display.Terminal.Plot' but emits embeddable, interactive Vega-Lite
charts (rendered in the browser via vega-embed loaded from a CDN).
This module is the string-keyed convenience tier. For an expression-based,
composable grammar of graphics see "DataFrame.Display.Web.Chart" (untyped
'Expr') and "DataFrame.Display.Web.Chart.Typed" (typed 'TExpr').
-}
module DataFrame.Display.Web.Plot (
-- * Aggregation
Agg (..),
-- * Output
showInDefaultBrowser,
-- * Layout
Size (..),
defaultSize,
-- * Bar charts
Bar (..),
mkBar,
bar,
-- * Histograms
Histogram (..),
mkHistogram,
histogram,
-- * Scatter plots
Scatter (..),
mkScatter,
scatter,
-- * Line charts
Line (..),
mkLine,
line,
-- * Pie charts
Pie (..),
mkPie,
pie,
-- * Box plots
Box (..),
mkBox,
box,
-- * Whole-frame plots
allHistograms,
-- * Deprecated legacy entry points
plotHistogram,
plotScatter,
plotBars,
plotLines,
plotPie,
plotBoxPlots,
) where
import Control.Monad (forM, void)
import Data.Char (chr)
import qualified Data.List as L
import qualified Data.Maybe
import qualified Data.Text as T
import qualified Data.Text.IO as T
import GHC.Stack (HasCallStack)
import Numeric (showFFloat)
import System.Directory (getHomeDirectory)
import System.Info (os)
import System.Process (
StdStream (NoStream),
createProcess,
proc,
std_err,
std_in,
std_out,
waitForProcess,
)
import System.Random (newStdGen, randomRs)
import DataFrame.Display.Internal.Common (
Agg (..),
aggLabel,
aggregateByGroup,
extractNumericColumn,
extractStringColumn,
groupWithOther,
groupWithOtherForPie,
isNumericColumn,
)
import DataFrame.Display.Internal.VegaLite (
Channel (Color, Theta, X, Y),
FieldType (..),
ResolvedField,
VLSpec (..),
chanEnc,
emptySpec,
numField,
specHtml,
textField,
)
import qualified DataFrame.Display.Internal.VegaLite as VL
import DataFrame.Internal.DataFrame (DataFrame, columnNames)
-- ---------------------------------------------------------------------------
-- Layout
-- ---------------------------------------------------------------------------
-- | Display dimensions for the chart, in pixels.
data Size = Size {width :: Int, height :: Int}
defaultSize :: Size
defaultSize = Size 600 400
generateChartId :: IO T.Text
generateChartId = do
gen <- newStdGen
let randomWords =
filter
(\c -> c `elem` ([49 .. 57] ++ [65 .. 90] ++ [97 .. 122]))
(take 64 (randomRs (49, 126) gen :: [Int]))
return $ "chart_" <> T.pack (map chr randomWords)
-- | Assemble a chart HTML snippet from resolved data fields and a spec.
renderSpec :: Size -> [ResolvedField] -> VLSpec -> IO String
renderSpec sz fields spec = do
chartId <- generateChartId
let spec' = spec{vlWidth = sz.width, vlHeight = sz.height}
return $ T.unpack $ specHtml chartId fields spec'
-- ---------------------------------------------------------------------------
-- Bar
-- ---------------------------------------------------------------------------
data Bar = Bar
{ x :: T.Text
, y :: Maybe T.Text
, agg :: Agg
, topN :: Maybe Int
, title :: Maybe T.Text
, size :: Size
}
mkBar :: T.Text -> Bar
mkBar c = Bar c Nothing Sum Nothing Nothing defaultSize
bar :: (HasCallStack) => Bar -> DataFrame -> IO String
bar spec df = do
let effectiveAgg = case spec.y of
Nothing -> Count
Just _ -> spec.agg
rows = aggregateByGroup effectiveAgg spec.x spec.y df
rows' = maybe rows (`groupWithOther` rows) spec.topN
chartTitle = case spec.title of
Just s -> s
Nothing -> autoTitle effectiveAgg spec.y spec.x
legendLabel = case spec.y of
Nothing -> "count"
Just yCol -> aggLabel effectiveAgg <> "(" <> yCol <> ")"
(labels, values) = unzip rows'
fields =
[ textField spec.x labels
, numField legendLabel values
]
vlSpec =
(emptySpec VL.Bar)
{ vlEncodings =
[ chanEnc X spec.x Nominal
, chanEnc Y legendLabel Quantitative
]
, vlTitle = Just chartTitle
}
renderSpec spec.size fields vlSpec
-- ---------------------------------------------------------------------------
-- Histogram
-- ---------------------------------------------------------------------------
data Histogram = Histogram
{ x :: T.Text
, bins :: Int
, title :: Maybe T.Text
, size :: Size
}
mkHistogram :: T.Text -> Histogram
mkHistogram c = Histogram c 30 Nothing defaultSize
histogram :: (HasCallStack) => Histogram -> DataFrame -> IO String
histogram spec df = do
let values = extractNumericColumn spec.x df
(lo, hi) = if null values then (0, 1) else (minimum values, maximum values)
binWidth = (hi - lo) / fromIntegral spec.bins
binStarts = [lo + fromIntegral i * binWidth | i <- [0 .. spec.bins - 1]]
countBin b = length [v | v <- values, v >= b && v < b + binWidth]
counts = map (fromIntegral . countBin) binStarts
precision = max 0 $ ceiling (negate $ logBase 10 (max 1e-12 binWidth))
binLabels =
[T.pack (showFFloat (Just precision) b "") | b <- binStarts]
chartTitle = case spec.title of
Just s -> s
Nothing -> "histogram of " <> spec.x
fields =
[ textField spec.x binLabels
, numField "count" counts
]
vlSpec =
(emptySpec VL.Bar)
{ vlEncodings =
[ chanEnc X spec.x Ordinal
, chanEnc Y "count" Quantitative
]
, vlTitle = Just chartTitle
}
renderSpec spec.size fields vlSpec
-- ---------------------------------------------------------------------------
-- Scatter
-- ---------------------------------------------------------------------------
data Scatter = Scatter
{ x :: T.Text
, y :: T.Text
, color :: Maybe T.Text
, title :: Maybe T.Text
, size :: Size
}
mkScatter :: T.Text -> T.Text -> Scatter
mkScatter xc yc = Scatter xc yc Nothing Nothing defaultSize
scatter :: (HasCallStack) => Scatter -> DataFrame -> IO String
scatter spec df = do
let chartTitle = case spec.title of
Just s -> s
Nothing -> spec.x <> " vs " <> spec.y
xVals = extractNumericColumn spec.x df
yVals = extractNumericColumn spec.y df
baseFields = [numField spec.x xVals, numField spec.y yVals]
baseEncs = [chanEnc X spec.x Quantitative, chanEnc Y spec.y Quantitative]
(fields, encs) = case spec.color of
Nothing -> (baseFields, baseEncs)
Just grp ->
( baseFields ++ [textField grp (extractStringColumn grp df)]
, baseEncs ++ [chanEnc Color grp Nominal]
)
vlSpec =
(emptySpec VL.Point)
{ vlEncodings = encs
, vlTitle = Just chartTitle
}
renderSpec spec.size fields vlSpec
-- ---------------------------------------------------------------------------
-- Line
-- ---------------------------------------------------------------------------
data Line = Line
{ x :: T.Text
, y :: [T.Text]
, title :: Maybe T.Text
, size :: Size
}
mkLine :: T.Text -> [T.Text] -> Line
mkLine xc ys = Line xc ys Nothing defaultSize
line :: (HasCallStack) => Line -> DataFrame -> IO String
line spec df = do
let chartTitle = case spec.title of
Just s -> s
Nothing -> case spec.y of
[single] -> single <> " over " <> spec.x
_ -> T.intercalate ", " spec.y <> " over " <> spec.x
xVals = extractNumericColumn spec.x df
-- Long-form melt: (x, value, series) so each y column becomes a line.
perSeries =
[ (col, zip xVals (extractNumericColumn col df))
| col <- spec.y
]
xsLong = concat [[xv | (xv, _) <- pts] | (_, pts) <- perSeries]
valsLong = concat [[v | (_, v) <- pts] | (_, pts) <- perSeries]
seriesLong = concat [replicate (length pts) col | (col, pts) <- perSeries]
fields =
[ numField spec.x xsLong
, numField "value" valsLong
, textField "series" seriesLong
]
vlSpec =
(emptySpec VL.Line)
{ vlEncodings =
[ chanEnc X spec.x Quantitative
, chanEnc Y "value" Quantitative
, chanEnc Color "series" Nominal
]
, vlTitle = Just chartTitle
}
renderSpec spec.size fields vlSpec
-- ---------------------------------------------------------------------------
-- Pie
-- ---------------------------------------------------------------------------
data Pie = Pie
{ values :: T.Text
, names :: Maybe T.Text
, agg :: Agg
, topN :: Maybe Int
, title :: Maybe T.Text
, size :: Size
}
mkPie :: T.Text -> Pie
mkPie c = Pie c Nothing Count (Just 8) Nothing defaultSize
pie :: (HasCallStack) => Pie -> DataFrame -> IO String
pie spec df = do
let vCol = spec.values
mNames = spec.names
a = spec.agg
chartTitle = case spec.title of
Just s -> s
Nothing -> case mNames of
Just n -> aggLabel a <> "(" <> vCol <> ") by " <> n
Nothing -> aggLabel a <> " of " <> vCol
rows = case (a, mNames) of
(Count, Nothing) -> aggregateByGroup Count vCol Nothing df
(Count, Just n) -> aggregateByGroup Count n Nothing df
(_, Nothing) ->
let xs = extractNumericColumn vCol df
in zip [T.pack ("Item " ++ show i) | i <- [1 .. length xs :: Int]] xs
(_, Just n) -> aggregateByGroup a n (Just vCol) df
rows' = maybe rows (`groupWithOtherForPie` rows) spec.topN
(labels, values) = unzip rows'
catName = Data.Maybe.fromMaybe "category" mNames
fields =
[ textField catName labels
, numField "value" values
]
vlSpec =
(emptySpec VL.Arc)
{ vlEncodings =
[ chanEnc Theta "value" Quantitative
, chanEnc Color catName Nominal
]
, vlTitle = Just chartTitle
}
renderSpec spec.size fields vlSpec
-- ---------------------------------------------------------------------------
-- Box
-- ---------------------------------------------------------------------------
data Box = Box
{ y :: [T.Text]
, title :: Maybe T.Text
, size :: Size
}
mkBox :: [T.Text] -> Box
mkBox ys = Box ys Nothing defaultSize
box :: (HasCallStack) => Box -> DataFrame -> IO String
box spec df = do
let series = [(col, extractNumericColumn col df) | col <- spec.y]
variableVals = concat [replicate (length vs) col | (col, vs) <- series]
valueVals = concat [vs | (_, vs) <- series]
chartTitle = case spec.title of
Just s -> s
Nothing -> "box plot of " <> T.intercalate ", " spec.y
fields =
[ textField "variable" variableVals
, numField "value" valueVals
]
vlSpec =
(emptySpec VL.Boxplot)
{ vlEncodings =
[ chanEnc X "variable" Nominal
, chanEnc Y "value" Quantitative
]
, vlTitle = Just chartTitle
}
renderSpec spec.size fields vlSpec
-- ---------------------------------------------------------------------------
-- Whole-frame helpers
-- ---------------------------------------------------------------------------
{- | Concatenate a histogram for every numeric column in the frame. Useful
as a one-shot exploratory summary.
-}
allHistograms :: (HasCallStack) => DataFrame -> IO String
allHistograms df = do
let cols = filter (isNumericColumn df) (columnNames df)
xs <- forM cols $ \c -> histogram (mkHistogram c) df
return $ L.intercalate "\n" xs
-- ---------------------------------------------------------------------------
-- Title helper
-- ---------------------------------------------------------------------------
autoTitle :: Agg -> Maybe T.Text -> T.Text -> T.Text
autoTitle Count _ groupCol = "count by " <> groupCol
autoTitle a (Just yCol) groupCol = aggLabel a <> "(" <> yCol <> ") by " <> groupCol
autoTitle a Nothing groupCol = aggLabel a <> " by " <> groupCol
-- ---------------------------------------------------------------------------
-- Deprecated legacy entry points
-- ---------------------------------------------------------------------------
plotHistogram :: (HasCallStack) => T.Text -> DataFrame -> IO String
plotHistogram c = histogram (mkHistogram c)
{-# DEPRECATED plotHistogram "use 'histogram (mkHistogram col)' instead" #-}
plotScatter :: (HasCallStack) => T.Text -> T.Text -> DataFrame -> IO String
plotScatter xc yc = scatter (mkScatter xc yc)
{-# DEPRECATED plotScatter "use 'scatter (mkScatter xCol yCol)' instead" #-}
plotBars :: (HasCallStack) => T.Text -> DataFrame -> IO String
plotBars c = bar (mkBar c)
{-# DEPRECATED plotBars "use 'bar (mkBar col)' instead" #-}
plotLines :: (HasCallStack) => T.Text -> [T.Text] -> DataFrame -> IO String
plotLines xc ys = line (mkLine xc ys)
{-# DEPRECATED plotLines "use 'line (mkLine xCol yCols)' instead" #-}
plotPie :: (HasCallStack) => T.Text -> Maybe T.Text -> DataFrame -> IO String
plotPie c mLabel =
let s = mkPie c
in pie s{names = mLabel}
{-# DEPRECATED plotPie "use 'pie (mkPie col)' instead" #-}
plotBoxPlots :: (HasCallStack) => [T.Text] -> DataFrame -> IO String
plotBoxPlots ys = box (mkBox ys)
{-# DEPRECATED plotBoxPlots "use 'box (mkBox cols)' instead" #-}
-- ---------------------------------------------------------------------------
-- Browser launcher
-- ---------------------------------------------------------------------------
showInDefaultBrowser :: String -> IO ()
showInDefaultBrowser p = do
plotId <- generateChartId
home <- getHomeDirectory
let path = "plot-" <> T.unpack plotId <> ".html"
fullPath =
if os == "mingw32"
then home <> "\\" <> path
else home <> "/" <> path
putStr "Saving plot to: "
putStrLn fullPath
T.writeFile fullPath (T.pack p)
case os of
"mingw32" -> openFileSilently "start" fullPath
"darwin" -> openFileSilently "open" fullPath
_ -> openFileSilently "xdg-open" fullPath
openFileSilently :: FilePath -> FilePath -> IO ()
openFileSilently program path = do
(_, _, _, ph) <-
createProcess
(proc program [path])
{ std_in = NoStream
, std_out = NoStream
, std_err = NoStream
}
void (waitForProcess ph)