packages feed

dataframe-viz-1.3.2.1: src/DataFrame/Display/Web/Chart.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Composable expression-based grammar of graphics rendered to Vega-Lite.
module DataFrame.Display.Web.Chart (
    -- * Chart
    Chart,
    chart,

    -- * Re-exported spec vocabulary
    Mark (..),
    Channel (..),
    FieldType (..),
    Agg (..),

    -- * Building blocks
    mark,
    enc,
    encAs,
    aggregateOn,
    binX,
    binY,
    facet,
    row,
    column,
    layer,
    regression,
    density,
    logScale,
    includeZero,
    title,
    size,

    -- * Rendering
    toVegaSpec,
    toHtml,
    showChart,

    -- * One-shot convenience plots
    scatter,
    bar,
    histogram,
    line,
    pie,
    box,
) where

import Data.Aeson (Value)
import Data.Function ((&))
import qualified Data.Text as T

import DataFrame.Display.Internal.Common (Agg (..))
import DataFrame.Display.Internal.VegaLite (
    Channel (..),
    ChannelEnc (..),
    FieldType (..),
    Mark (..),
    ResolvedField (..),
    ScaleSpec (..),
    ScaleType (..),
    Transform (DensityT, RegressionT),
    VLSpec (..),
    chanEnc,
    channelName,
    emptySpec,
    resolveField,
    rowCountWarning,
    specHtml,
    specToValue,
 )
import DataFrame.Display.Web.Plot (showInDefaultBrowser)
import DataFrame.Internal.Column (Columnable)
import DataFrame.Internal.DataFrame (DataFrame)
import DataFrame.Internal.Expression (Expr)

-- | An in-progress chart: frame, mark, encodings, transforms, and layers.
data Chart = Chart
    { chDf :: DataFrame
    , chMark :: Mark
    , chEncs :: [ChannelEnc]
    , chFields :: [ResolvedField]
    , chTransforms :: [Transform]
    , chTitle :: Maybe T.Text
    , chW :: Int
    , chH :: Int
    , chLayers :: [Chart]
    }

-- | Start a chart from a frame. Defaults to a point mark at 600x400.
chart :: DataFrame -> Chart
chart df = Chart df Point [] [] [] Nothing 600 400 []

-- | Set the mark type.
mark :: Mark -> Chart -> Chart
mark m c = c{chMark = m}

-- | Encode an expression on a channel, inferring the field type.
enc :: (Columnable a) => Channel -> Expr a -> Chart -> Chart
enc ch e c =
    let rf = resolveField (chDf c) (channelName ch) e
        ce = chanEnc ch (rfName rf) (rfType rf)
     in c{chEncs = setChannel ce (chEncs c), chFields = chFields c ++ [rf]}

-- | Like 'enc' but force the Vega-Lite field type (e.g. 'Temporal', 'Ordinal').
encAs :: (Columnable a) => Channel -> Expr a -> FieldType -> Chart -> Chart
encAs ch e ft c =
    let rf = resolveField (chDf c) (channelName ch) e
        ce = chanEnc ch (rfName rf) ft
     in c{chEncs = setChannel ce (chEncs c), chFields = chFields c ++ [rf]}

-- | Apply a Vega-Lite aggregation to a channel.
aggregateOn :: Channel -> Agg -> Chart -> Chart
aggregateOn ch a =
    withChannel
        ch
        ( \e ->
            e
                { ceAggregate = Just (aggVegaName a)
                , ceField = if a == Count then "" else ceField e
                }
        )

-- | Bin the X (resp. Y) channel (Vega-Lite @bin@ transform).
binX, binY :: Chart -> Chart
binX = withChannel X (\e -> e{ceBin = True})
binY = withChannel Y (\e -> e{ceBin = True})

-- | Put a channel on a log scale; apply it after 'enc'.
logScale :: Channel -> Chart -> Chart
logScale ch = withScale ch (\s -> s{scaleType = Just LogS})

-- | Anchor or release a channel's scale at zero; apply it after 'enc'.
includeZero :: Channel -> Bool -> Chart -> Chart
includeZero ch b = withScale ch (\s -> s{scaleZero = Just b})

-- | Facet into small multiples by a column (alias for 'column').
facet :: (Columnable a) => Expr a -> Chart -> Chart
facet = column

-- | Facet across columns / down rows.
column, row :: (Columnable a) => Expr a -> Chart -> Chart
column = enc Column
row = enc Row

-- | Set the chart title.
title :: T.Text -> Chart -> Chart
title t c = c{chTitle = Just t}

-- | Set the chart size in pixels.
size :: Int -> Int -> Chart -> Chart
size w h c = c{chW = w, chH = h}

-- | Overlay several charts that share data into one layered chart.
layer :: [Chart] -> Chart
layer [] = error "DataFrame.Display.Web.Chart.layer: empty layer list"
layer cs@(c0 : _) =
    c0
        { chLayers = map (\c -> c{chLayers = []}) cs
        , chFields = concatMap chFields cs
        }

-- | Add a least-squares regression line fitting @y@ on @x@.
regression :: (Columnable a, Columnable b) => Expr a -> Expr b -> Chart -> Chart
regression xE yE c =
    let rfx = resolveField (chDf c) "x" xE
        rfy = resolveField (chDf c) "y" yE
        withData = c{chFields = chFields c ++ [rfx, rfy]}
        points = withData{chLayers = []}
        lineLayer =
            withData
                { chMark = Line
                , chEncs =
                    [ chanEnc X (rfName rfx) Quantitative
                    , chanEnc Y (rfName rfy) Quantitative
                    ]
                , chTransforms = [RegressionT (rfName rfy) (rfName rfx)]
                , chLayers = []
                }
     in withData{chLayers = [points, lineLayer]}

-- | Kernel-density estimate of an expression, drawn as an area.
density :: (Columnable a) => Expr a -> Chart -> Chart
density e c =
    let rf = resolveField (chDf c) "value" e
     in c
            { chFields = chFields c ++ [rf]
            , chMark = Area
            , chTransforms = chTransforms c ++ [DensityT (rfName rf)]
            , chEncs =
                [ chanEnc X "value" Quantitative
                , chanEnc Y "density" Quantitative
                ]
            }

-- | The Vega-Lite spec as an aeson 'Value' (escape hatch, e.g. for hvega).
toVegaSpec :: Chart -> Value
toVegaSpec c = specToValue (allFields c) (toVLSpec c)

-- | A self-contained HTML snippet embedding the chart.
toHtml :: Chart -> String
toHtml c = T.unpack (specHtml "vis" (allFields c) (toVLSpec c))

-- | Render the chart to a temp file and open it in the default browser.
showChart :: Chart -> IO ()
showChart c = do
    rowCountWarning (allFields c)
    showInDefaultBrowser (toHtml c)

-- | Scatter plot of two expressions. Axes fit the data (no zero anchor).
scatter ::
    (Columnable a, Columnable b) => Expr a -> Expr b -> DataFrame -> IO ()
scatter xE yE df =
    showChart
        ( chart df
            & mark Point
            & enc X xE
            & enc Y yE
            & includeZero X False
            & includeZero Y False
        )

-- | Count of rows per category, as bars.
bar :: (Columnable a) => Expr a -> DataFrame -> IO ()
bar xE df = showChart (chart df & mark Bar & enc X xE & aggregateOn Y Count)

-- | Histogram of a numeric expression (Vega-Lite binning + count).
histogram :: (Columnable a) => Expr a -> DataFrame -> IO ()
histogram xE df =
    showChart (chart df & mark Bar & enc X xE & binX & aggregateOn Y Count)

-- | Line chart of @y@ over @x@. Axes fit the data (no zero anchor).
line :: (Columnable a, Columnable b) => Expr a -> Expr b -> DataFrame -> IO ()
line xE yE df =
    showChart
        ( chart df
            & mark Line
            & enc X xE
            & enc Y yE
            & includeZero X False
            & includeZero Y False
        )

-- | Pie chart counting rows per category.
pie :: (Columnable a) => Expr a -> DataFrame -> IO ()
pie cE df = showChart (chart df & mark Arc & enc Color cE & aggregateOn Theta Count)

-- | Box-and-whisker plot of a numeric expression.
box :: (Columnable a) => Expr a -> DataFrame -> IO ()
box yE df = showChart (chart df & mark Boxplot & enc Y yE)

-- | Replace any existing encoding on the same channel (last write wins).
setChannel :: ChannelEnc -> [ChannelEnc] -> [ChannelEnc]
setChannel ce encs = filter ((/= ceChannel ce) . ceChannel) encs ++ [ce]

-- | Modify the encoding on a channel, creating a fieldless one if absent.
withChannel :: Channel -> (ChannelEnc -> ChannelEnc) -> Chart -> Chart
withChannel ch f c =
    let encs = chEncs c
     in if any ((== ch) . ceChannel) encs
            then c{chEncs = map (\e -> if ceChannel e == ch then f e else e) encs}
            else c{chEncs = encs ++ [f (chanEnc ch "" Quantitative)]}

-- | Modify a channel's scale spec (creating the encoding if absent).
withScale :: Channel -> (ScaleSpec -> ScaleSpec) -> Chart -> Chart
withScale ch f = withChannel ch (\e -> e{ceScale = f (ceScale e)})

aggVegaName :: Agg -> T.Text
aggVegaName a = case a of
    Count -> "count"
    Sum -> "sum"
    Mean -> "mean"
    Median -> "median"
    Min -> "min"
    Max -> "max"

allFields :: Chart -> [ResolvedField]
allFields c = chFields c ++ concatMap allFields (chLayers c)

toVLSpec :: Chart -> VLSpec
toVLSpec c
    | null (chLayers c) =
        (emptySpec (chMark c))
            { vlEncodings = chEncs c
            , vlTransforms = chTransforms c
            , vlTitle = chTitle c
            , vlWidth = chW c
            , vlHeight = chH c
            }
    | otherwise =
        (emptySpec (chMark c))
            { vlLayers = map toVLSpec (chLayers c)
            , vlTitle = chTitle c
            , vlWidth = chW c
            , vlHeight = chH c
            }