dataframe-viz-1.0.0.0: src/DataFrame/Display/Terminal/Plot.hs
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoFieldSelectors #-}
{- |
A plotly-express-style one-shot plotting API for terminal output.
Each chart kind has a small record spec (with sensible defaults) and a
single render function. Customise plots by record-update on the spec:
@
bar ((mkBar "Sex") { y = Just "Survived" }) titanic
bar ((mkBar "ocean_proximity")
{ y = Just "median_house_value", agg = Mean }) housing
@
-}
module DataFrame.Display.Terminal.Plot (
-- * Aggregation
Agg (..),
-- * 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
heatmap,
correlationMatrix,
-- * Deprecated legacy entry points
{- |
These shims delegate to the parametrized API. They are kept for
one release to ease migration; prefer the spec-based functions
above for new code.
-}
plotHistogram,
plotScatter,
plotBars,
plotLines,
plotPie,
plotBoxPlots,
plotStackedBars,
plotHeatmap,
extractNumericColumn,
extractStringColumn,
) where
import Control.Monad (forM)
import qualified Data.List as L
import qualified Data.Text as T
import qualified Data.Text.IO as T
import GHC.Stack (HasCallStack)
import DataFrame.Display.Internal.Common (
Agg (..),
aggLabel,
aggregateByGroup,
extractNumericColumn,
extractStringColumn,
groupWithOther,
groupWithOtherForPie,
isNumericColumn,
)
import DataFrame.Internal.DataFrame (DataFrame (..), columnNames)
import Granite (Plot, defPlot)
import qualified Granite
-- ---------------------------------------------------------------------------
-- Bar
-- ---------------------------------------------------------------------------
{- | Bar-chart specification.
@x@ is required; everything else has a default. When @y@ is @Nothing@ the
chart counts rows per group; otherwise @y@ is aggregated using @agg@.
-}
data Bar = Bar
{ x :: T.Text
-- ^ Grouping (categorical) column.
, y :: Maybe T.Text
-- ^ Value column; @Nothing@ counts rows.
, agg :: Agg
-- ^ Aggregation applied to @y@. Defaults to 'Sum'.
, topN :: Maybe Int
-- ^ Optional cap on the number of bars (rest folded into "Other").
, title :: Maybe T.Text
-- ^ Chart title; auto-generated if @Nothing@.
, plot :: Plot
-- ^ Granite plot configuration (size, palette, ticks, etc.).
}
-- | Smart constructor: spec with sensible defaults, given the @x@ column.
mkBar :: T.Text -> Bar
mkBar c =
Bar
{ x = c
, y = Nothing
, agg = Sum
, topN = Nothing
, title = Nothing
, plot = defPlot
}
-- | Render a 'Bar' spec to stdout.
bar :: (HasCallStack) => Bar -> DataFrame -> IO ()
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
t = case spec.title of
Just s -> s
Nothing -> autoTitle effectiveAgg spec.y spec.x
cfg = spec.plot{Granite.plotTitle = t}
T.putStrLn $ Granite.bars rows' cfg
-- ---------------------------------------------------------------------------
-- Histogram
-- ---------------------------------------------------------------------------
-- | Histogram specification for a numeric column.
data Histogram = Histogram
{ x :: T.Text
-- ^ Numeric column to bin.
, bins :: Int
-- ^ Number of bins (default 30).
, title :: Maybe T.Text
, plot :: Plot
}
mkHistogram :: T.Text -> Histogram
mkHistogram c =
Histogram
{ x = c
, bins = 30
, title = Nothing
, plot = defPlot
}
histogram :: (HasCallStack) => Histogram -> DataFrame -> IO ()
histogram spec df = do
let values = extractNumericColumn spec.x df
(lo, hi) = if null values then (0, 1) else (minimum values, maximum values)
t = case spec.title of
Just s -> s
Nothing -> "histogram of " <> spec.x
cfg = spec.plot{Granite.plotTitle = t}
T.putStrLn $ Granite.histogram (Granite.bins spec.bins lo hi) values cfg
-- ---------------------------------------------------------------------------
-- Scatter
-- ---------------------------------------------------------------------------
-- | Scatter-plot specification.
data Scatter = Scatter
{ x :: T.Text
, y :: T.Text
, color :: Maybe T.Text
-- ^ Optional grouping column; produces a separate series per value.
, title :: Maybe T.Text
, plot :: Plot
}
mkScatter :: T.Text -> T.Text -> Scatter
mkScatter xc yc =
Scatter
{ x = xc
, y = yc
, color = Nothing
, title = Nothing
, plot = defPlot
}
scatter :: (HasCallStack) => Scatter -> DataFrame -> IO ()
scatter spec df = do
let t = case spec.title of
Just s -> s
Nothing -> spec.x <> " vs " <> spec.y
cfg = spec.plot{Granite.plotTitle = t}
xVals = extractNumericColumn spec.x df
yVals = extractNumericColumn spec.y df
case spec.color of
Nothing ->
T.putStrLn $ Granite.scatter [(spec.x <> " vs " <> spec.y, zip xVals yVals)] cfg
Just grp -> do
let groupVals = extractStringColumn grp df
triples = zip3 groupVals xVals yVals
uniqGroups = L.nub groupVals
series =
[ (g, [(xv, yv) | (gv, xv, yv) <- triples, gv == g])
| g <- uniqGroups
]
T.putStrLn $ Granite.scatter series cfg
-- ---------------------------------------------------------------------------
-- Line
-- ---------------------------------------------------------------------------
-- | Line-chart specification: one x column, one or more y series.
data Line = Line
{ x :: T.Text
, y :: [T.Text]
, title :: Maybe T.Text
, plot :: Plot
}
mkLine :: T.Text -> [T.Text] -> Line
mkLine xc ys =
Line
{ x = xc
, y = ys
, title = Nothing
, plot = defPlot
}
line :: (HasCallStack) => Line -> DataFrame -> IO ()
line spec df = do
let t = case spec.title of
Just s -> s
Nothing -> case spec.y of
[single] -> single <> " over " <> spec.x
_ -> T.intercalate ", " spec.y <> " over " <> spec.x
cfg = spec.plot{Granite.plotTitle = t}
xVals = extractNumericColumn spec.x df
seriesData <- forM spec.y $ \col -> do
let values = extractNumericColumn col df
return (col, zip xVals values)
T.putStrLn $ Granite.lineGraph seriesData cfg
-- ---------------------------------------------------------------------------
-- Pie
-- ---------------------------------------------------------------------------
-- | Pie-chart specification.
data Pie = Pie
{ values :: T.Text
{- ^ Slice-defining column. For 'Count', categorical; for the
numeric aggs, the value column.
-}
, names :: Maybe T.Text
{- ^ Optional label column. If @Nothing@, slices are labelled by
the value column directly (for 'Count') or by row index.
-}
, agg :: Agg
, topN :: Maybe Int
, title :: Maybe T.Text
, plot :: Plot
}
mkPie :: T.Text -> Pie
mkPie c =
Pie
{ values = c
, names = Nothing
, agg = Count
, topN = Just 8
, title = Nothing
, plot = defPlot
}
pie :: (HasCallStack) => Pie -> DataFrame -> IO ()
pie spec df = do
let vCol = spec.values
mNames = spec.names
a = spec.agg
t = case spec.title of
Just s -> s
Nothing -> case mNames of
Just n -> aggLabel a <> "(" <> vCol <> ") by " <> n
Nothing -> aggLabel a <> " of " <> vCol
cfg = spec.plot{Granite.plotTitle = t}
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
T.putStrLn $ Granite.pie rows' cfg
-- ---------------------------------------------------------------------------
-- Box
-- ---------------------------------------------------------------------------
-- | Box-plot specification across one or more numeric columns.
data Box = Box
{ y :: [T.Text]
, title :: Maybe T.Text
, plot :: Plot
}
mkBox :: [T.Text] -> Box
mkBox ys =
Box
{ y = ys
, title = Nothing
, plot = defPlot
}
box :: (HasCallStack) => Box -> DataFrame -> IO ()
box spec df = do
let t = case spec.title of
Just s -> s
Nothing -> "box plot of " <> T.intercalate ", " spec.y
cfg = spec.plot{Granite.plotTitle = t}
boxData <- forM spec.y $ \col -> return (col, extractNumericColumn col df)
T.putStrLn $ Granite.boxPlot boxData cfg
-- ---------------------------------------------------------------------------
-- Whole-frame plots
-- ---------------------------------------------------------------------------
{- | Heatmap of all numeric columns in the dataframe; each numeric column
becomes a column of the matrix.
-}
heatmap :: (HasCallStack) => DataFrame -> IO ()
heatmap df = do
let numericCols = filter (isNumericColumn df) (columnNames df)
matrix = map (`extractNumericColumn` df) numericCols
T.putStrLn $ Granite.heatmap matrix defPlot
-- | Pearson correlation heatmap across all numeric columns.
correlationMatrix :: (HasCallStack) => DataFrame -> IO ()
correlationMatrix df = do
let numericCols = filter (isNumericColumn df) (columnNames df)
cols = map (`extractNumericColumn` df) numericCols
correlations =
[ [corr xs ys | ys <- cols]
| xs <- cols
]
print (zip [(0 :: Int) ..] numericCols)
T.putStrLn $
Granite.heatmap correlations (defPlot{Granite.plotTitle = "correlation matrix"})
where
corr xs ys =
let n = fromIntegral $ length xs
meanX = sum xs / n
meanY = sum ys / n
covXY = sum [(a - meanX) * (b - meanY) | (a, b) <- zip xs ys] / n
stdX = sqrt $ sum [(a - meanX) ^ (2 :: Int) | a <- xs] / n
stdY = sqrt $ sum [(b - meanY) ^ (2 :: Int) | b <- ys] / n
in covXY / (stdX * stdY)
-- ---------------------------------------------------------------------------
-- 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
-- ---------------------------------------------------------------------------
-- | Use 'histogram' with 'mkHistogram' instead.
plotHistogram :: (HasCallStack) => T.Text -> DataFrame -> IO ()
plotHistogram c = histogram (mkHistogram c)
{-# DEPRECATED plotHistogram "use 'histogram (mkHistogram col)' instead" #-}
-- | Use 'scatter' with 'mkScatter' instead.
plotScatter :: (HasCallStack) => T.Text -> T.Text -> DataFrame -> IO ()
plotScatter xc yc = scatter (mkScatter xc yc)
{-# DEPRECATED plotScatter "use 'scatter (mkScatter xCol yCol)' instead" #-}
-- | Use 'bar' with 'mkBar' instead.
plotBars :: (HasCallStack) => T.Text -> DataFrame -> IO ()
plotBars c = bar (mkBar c)
{-# DEPRECATED plotBars "use 'bar (mkBar col)' instead" #-}
-- | Use 'line' with 'mkLine' instead.
plotLines :: (HasCallStack) => T.Text -> [T.Text] -> DataFrame -> IO ()
plotLines xc ys = line (mkLine xc ys)
{-# DEPRECATED plotLines "use 'line (mkLine xCol yCols)' instead" #-}
-- | Use 'pie' with 'mkPie' instead.
plotPie :: (HasCallStack) => T.Text -> Maybe T.Text -> DataFrame -> IO ()
plotPie c mLabel =
let s = mkPie c
in pie s{names = mLabel}
{-# DEPRECATED plotPie "use 'pie (mkPie col)' instead" #-}
-- | Use 'box' with 'mkBox' instead.
plotBoxPlots :: (HasCallStack) => [T.Text] -> DataFrame -> IO ()
plotBoxPlots ys = box (mkBox ys)
{-# DEPRECATED plotBoxPlots "use 'box (mkBox cols)' instead" #-}
{- | Stacked-bar plot: one bar per category, segments from the listed value
columns. Kept as a direct passthrough to granite — has no spec record
because no aggregation is involved (segments are summed per category).
-}
plotStackedBars :: (HasCallStack) => T.Text -> [T.Text] -> DataFrame -> IO ()
plotStackedBars categoryCol valueColumns df = do
let categories = extractStringColumn categoryCol df
uniqueCategories = L.nub categories
stackData <- forM uniqueCategories $ \cat -> do
let indices = [i | (i, c) <- zip [0 ..] categories, c == cat]
seriesData <- forM valueColumns $ \col -> do
let allValues = extractNumericColumn col df
vs = [allValues !! i | i <- indices, i < length allValues]
return (col, sum vs)
return (cat, seriesData)
T.putStrLn $ Granite.stackedBars stackData defPlot
-- | Use 'heatmap' instead.
plotHeatmap :: (HasCallStack) => DataFrame -> IO ()
plotHeatmap = heatmap
{-# DEPRECATED plotHeatmap "use 'heatmap' instead" #-}