packages feed

dataframe-viz 1.0.1.1 → 1.0.2.0

raw patch · 7 files changed

+1367/−242 lines, 7 filesdep +aesonPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: aeson

API changes (from Hackage documentation)

- DataFrame.Display.Web.Plot: HtmlPlot :: Text -> HtmlPlot
- DataFrame.Display.Web.Plot: instance GHC.Show.Show DataFrame.Display.Web.Plot.HtmlPlot
- DataFrame.Display.Web.Plot: newtype HtmlPlot
+ DataFrame.Display.Web.Chart: Arc :: Mark
+ DataFrame.Display.Web.Chart: Area :: Mark
+ DataFrame.Display.Web.Chart: Bar :: Mark
+ DataFrame.Display.Web.Chart: Boxplot :: Mark
+ DataFrame.Display.Web.Chart: Color :: Channel
+ DataFrame.Display.Web.Chart: Column :: Channel
+ DataFrame.Display.Web.Chart: Count :: Agg
+ DataFrame.Display.Web.Chart: Line :: Mark
+ DataFrame.Display.Web.Chart: Max :: Agg
+ DataFrame.Display.Web.Chart: Mean :: Agg
+ DataFrame.Display.Web.Chart: Median :: Agg
+ DataFrame.Display.Web.Chart: Min :: Agg
+ DataFrame.Display.Web.Chart: Nominal :: FieldType
+ DataFrame.Display.Web.Chart: Opacity :: Channel
+ DataFrame.Display.Web.Chart: Order :: Channel
+ DataFrame.Display.Web.Chart: Ordinal :: FieldType
+ DataFrame.Display.Web.Chart: Point :: Mark
+ DataFrame.Display.Web.Chart: Quantitative :: FieldType
+ DataFrame.Display.Web.Chart: Row :: Channel
+ DataFrame.Display.Web.Chart: Rule :: Mark
+ DataFrame.Display.Web.Chart: Shape :: Channel
+ DataFrame.Display.Web.Chart: Size :: Channel
+ DataFrame.Display.Web.Chart: Sum :: Agg
+ DataFrame.Display.Web.Chart: Temporal :: FieldType
+ DataFrame.Display.Web.Chart: Theta :: Channel
+ DataFrame.Display.Web.Chart: Tick :: Mark
+ DataFrame.Display.Web.Chart: Tooltip :: Channel
+ DataFrame.Display.Web.Chart: X :: Channel
+ DataFrame.Display.Web.Chart: Y :: Channel
+ DataFrame.Display.Web.Chart: aggregateOn :: Channel -> Agg -> Chart -> Chart
+ DataFrame.Display.Web.Chart: bar :: Columnable a => Expr a -> DataFrame -> IO ()
+ DataFrame.Display.Web.Chart: binX :: Chart -> Chart
+ DataFrame.Display.Web.Chart: binY :: Chart -> Chart
+ DataFrame.Display.Web.Chart: box :: Columnable a => Expr a -> DataFrame -> IO ()
+ DataFrame.Display.Web.Chart: chart :: DataFrame -> Chart
+ DataFrame.Display.Web.Chart: column :: Columnable a => Expr a -> Chart -> Chart
+ DataFrame.Display.Web.Chart: data Agg
+ DataFrame.Display.Web.Chart: data Channel
+ DataFrame.Display.Web.Chart: data Chart
+ DataFrame.Display.Web.Chart: data FieldType
+ DataFrame.Display.Web.Chart: data Mark
+ DataFrame.Display.Web.Chart: density :: Columnable a => Expr a -> Chart -> Chart
+ DataFrame.Display.Web.Chart: enc :: Columnable a => Channel -> Expr a -> Chart -> Chart
+ DataFrame.Display.Web.Chart: encAs :: Columnable a => Channel -> Expr a -> FieldType -> Chart -> Chart
+ DataFrame.Display.Web.Chart: facet :: Columnable a => Expr a -> Chart -> Chart
+ DataFrame.Display.Web.Chart: histogram :: Columnable a => Expr a -> DataFrame -> IO ()
+ DataFrame.Display.Web.Chart: layer :: [Chart] -> Chart
+ DataFrame.Display.Web.Chart: line :: (Columnable a, Columnable b) => Expr a -> Expr b -> DataFrame -> IO ()
+ DataFrame.Display.Web.Chart: logScale :: Channel -> Chart -> Chart
+ DataFrame.Display.Web.Chart: mark :: Mark -> Chart -> Chart
+ DataFrame.Display.Web.Chart: pie :: Columnable a => Expr a -> DataFrame -> IO ()
+ DataFrame.Display.Web.Chart: regression :: (Columnable a, Columnable b) => Expr a -> Expr b -> Chart -> Chart
+ DataFrame.Display.Web.Chart: row :: Columnable a => Expr a -> Chart -> Chart
+ DataFrame.Display.Web.Chart: scatter :: (Columnable a, Columnable b) => Expr a -> Expr b -> DataFrame -> IO ()
+ DataFrame.Display.Web.Chart: showChart :: Chart -> IO ()
+ DataFrame.Display.Web.Chart: size :: Int -> Int -> Chart -> Chart
+ DataFrame.Display.Web.Chart: title :: Text -> Chart -> Chart
+ DataFrame.Display.Web.Chart: toHtml :: Chart -> String
+ DataFrame.Display.Web.Chart: toVegaSpec :: Chart -> Value
+ DataFrame.Display.Web.Chart.Typed: Arc :: Mark
+ DataFrame.Display.Web.Chart.Typed: Area :: Mark
+ DataFrame.Display.Web.Chart.Typed: Bar :: Mark
+ DataFrame.Display.Web.Chart.Typed: Boxplot :: Mark
+ DataFrame.Display.Web.Chart.Typed: Color :: Channel
+ DataFrame.Display.Web.Chart.Typed: Column :: Channel
+ DataFrame.Display.Web.Chart.Typed: Count :: Agg
+ DataFrame.Display.Web.Chart.Typed: Line :: Mark
+ DataFrame.Display.Web.Chart.Typed: Max :: Agg
+ DataFrame.Display.Web.Chart.Typed: Mean :: Agg
+ DataFrame.Display.Web.Chart.Typed: Median :: Agg
+ DataFrame.Display.Web.Chart.Typed: Min :: Agg
+ DataFrame.Display.Web.Chart.Typed: Nominal :: FieldType
+ DataFrame.Display.Web.Chart.Typed: Opacity :: Channel
+ DataFrame.Display.Web.Chart.Typed: Order :: Channel
+ DataFrame.Display.Web.Chart.Typed: Ordinal :: FieldType
+ DataFrame.Display.Web.Chart.Typed: Point :: Mark
+ DataFrame.Display.Web.Chart.Typed: Quantitative :: FieldType
+ DataFrame.Display.Web.Chart.Typed: Row :: Channel
+ DataFrame.Display.Web.Chart.Typed: Rule :: Mark
+ DataFrame.Display.Web.Chart.Typed: Shape :: Channel
+ DataFrame.Display.Web.Chart.Typed: Size :: Channel
+ DataFrame.Display.Web.Chart.Typed: Sum :: Agg
+ DataFrame.Display.Web.Chart.Typed: Temporal :: FieldType
+ DataFrame.Display.Web.Chart.Typed: Theta :: Channel
+ DataFrame.Display.Web.Chart.Typed: Tick :: Mark
+ DataFrame.Display.Web.Chart.Typed: Tooltip :: Channel
+ DataFrame.Display.Web.Chart.Typed: X :: Channel
+ DataFrame.Display.Web.Chart.Typed: Y :: Channel
+ DataFrame.Display.Web.Chart.Typed: aggregateOn :: forall (cols :: [Type]). Channel -> Agg -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: bar :: forall a (cols :: [Type]). Columnable a => TExpr cols a -> TypedDataFrame cols -> IO ()
+ DataFrame.Display.Web.Chart.Typed: binX :: forall (cols :: [Type]). Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: binY :: forall (cols :: [Type]). Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: box :: forall a (cols :: [Type]). Columnable a => TExpr cols a -> TypedDataFrame cols -> IO ()
+ DataFrame.Display.Web.Chart.Typed: chart :: forall (cols :: [Type]). TypedDataFrame cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: column :: forall a (cols :: [Type]). Columnable a => TExpr cols a -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: data Agg
+ DataFrame.Display.Web.Chart.Typed: data Channel
+ DataFrame.Display.Web.Chart.Typed: data Chart (cols :: [Type])
+ DataFrame.Display.Web.Chart.Typed: data FieldType
+ DataFrame.Display.Web.Chart.Typed: data Mark
+ DataFrame.Display.Web.Chart.Typed: density :: forall a (cols :: [Type]). Columnable a => TExpr cols a -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: enc :: forall a (cols :: [Type]). Columnable a => Channel -> TExpr cols a -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: encAs :: forall a (cols :: [Type]). Columnable a => Channel -> TExpr cols a -> FieldType -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: facet :: forall a (cols :: [Type]). Columnable a => TExpr cols a -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: histogram :: forall a (cols :: [Type]). Columnable a => TExpr cols a -> TypedDataFrame cols -> IO ()
+ DataFrame.Display.Web.Chart.Typed: layer :: forall (cols :: [Type]). [Chart cols] -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: line :: forall a b (cols :: [Type]). (Columnable a, Columnable b) => TExpr cols a -> TExpr cols b -> TypedDataFrame cols -> IO ()
+ DataFrame.Display.Web.Chart.Typed: logScale :: forall (cols :: [Type]). Channel -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: mark :: forall (cols :: [Type]). Mark -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: pie :: forall a (cols :: [Type]). Columnable a => TExpr cols a -> TypedDataFrame cols -> IO ()
+ DataFrame.Display.Web.Chart.Typed: regression :: forall a b (cols :: [Type]). (Columnable a, Columnable b) => TExpr cols a -> TExpr cols b -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: row :: forall a (cols :: [Type]). Columnable a => TExpr cols a -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: scatter :: forall a b (cols :: [Type]). (Columnable a, Columnable b) => TExpr cols a -> TExpr cols b -> TypedDataFrame cols -> IO ()
+ DataFrame.Display.Web.Chart.Typed: showChart :: forall (cols :: [Type]). Chart cols -> IO ()
+ DataFrame.Display.Web.Chart.Typed: size :: forall (cols :: [Type]). Int -> Int -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: title :: forall (cols :: [Type]). Text -> Chart cols -> Chart cols
+ DataFrame.Display.Web.Chart.Typed: toHtml :: forall (cols :: [Type]). Chart cols -> String
+ DataFrame.Display.Web.Chart.Typed: toVegaSpec :: forall (cols :: [Type]). Chart cols -> Value
- DataFrame.Display.Web.Plot: allHistograms :: HasCallStack => DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: allHistograms :: HasCallStack => DataFrame -> IO String
- DataFrame.Display.Web.Plot: bar :: HasCallStack => Bar -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: bar :: HasCallStack => Bar -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: box :: HasCallStack => Box -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: box :: HasCallStack => Box -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: histogram :: HasCallStack => Histogram -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: histogram :: HasCallStack => Histogram -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: line :: HasCallStack => Line -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: line :: HasCallStack => Line -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: pie :: HasCallStack => Pie -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: pie :: HasCallStack => Pie -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: plotBars :: HasCallStack => Text -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: plotBars :: HasCallStack => Text -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: plotBoxPlots :: HasCallStack => [Text] -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: plotBoxPlots :: HasCallStack => [Text] -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: plotHistogram :: HasCallStack => Text -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: plotHistogram :: HasCallStack => Text -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: plotLines :: HasCallStack => Text -> [Text] -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: plotLines :: HasCallStack => Text -> [Text] -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: plotPie :: HasCallStack => Text -> Maybe Text -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: plotPie :: HasCallStack => Text -> Maybe Text -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: plotScatter :: HasCallStack => Text -> Text -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: plotScatter :: HasCallStack => Text -> Text -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: scatter :: HasCallStack => Scatter -> DataFrame -> IO HtmlPlot
+ DataFrame.Display.Web.Plot: scatter :: HasCallStack => Scatter -> DataFrame -> IO String
- DataFrame.Display.Web.Plot: showInDefaultBrowser :: HtmlPlot -> IO ()
+ DataFrame.Display.Web.Plot: showInDefaultBrowser :: String -> IO ()

Files

+ README.md view
@@ -0,0 +1,330 @@+<!--+  This file is the runnable scripths source for dataframe-viz's README.+  Every ```haskell block executes in order in one shared session against a+  small in-memory DataFrame. Regenerate the rendered README with:++      scripths docs/base_scripts/base_readme.md -o README.md++  Run it from THIS directory (dataframe-viz/). The `-- cabal: packages:`+  directive in the first block points at the sibling packages in this monorepo+  so the notebook builds against the local working tree (not Hackage). Blocks+  tagged ```text are illustrative (browser-opening or terminal output) and are+  NOT run.+-->++# dataframe-viz++Plotting for the [`dataframe`](https://hackage.haskell.org/package/dataframe) ecosystem. Two+backends share one API shape:++- **Terminal** (`DataFrame.Display.Terminal.Plot`) draws straight to the console (built on+  [`granite`](https://hackage.haskell.org/package/granite)).+- **Web** emits an interactive **Vega-Lite v5** spec rendered in the browser via `vega-embed` —+  a composable grammar of graphics (facet, layer, regression, density, colour/size encodings)+  driven by **expressions**, untyped or typed.++> **This README is a runnable [scripths](https://github.com/DataHaskell/scripths) notebook.**+> Every Haskell block runs top-to-bottom in one shared session. Reproduce every output below with+> `scripths docs/base_scripts/base_readme.md -o README.md` run from `dataframe-viz/`.++## Setup++Charts emit a Vega-Lite spec; in the REPL `showInDefaultBrowser` / `showChart` write it to a temp+file and open it. To keep the output here small we print the spec *without* its inlined data via a+tiny `grammar` helper, against this in-memory frame. The `packages:` directive builds against the+local `dataframe-core` / `dataframe-operations` / `dataframe-viz` working trees:++```haskell+-- cabal: build-depends: text, aeson+-- cabal: packages: ../../../dataframe-core, ../../../dataframe-parsing+-- cabal: packages: ../../../dataframe-operations, ../../../dataframe-viz+-- cabal: default-extensions: OverloadedStrings, TypeApplications, OverloadedLabels+-- cabal: default-extensions: DataKinds, TypeOperators, FlexibleContexts+import DataFrame.Internal.DataFrame (DataFrame, fromNamedColumns)+import DataFrame.Internal.Column (fromList)+import DataFrame.Operators ((|>))+import qualified DataFrame.Functions as F+import DataFrame.Typed.Types (Column, TypedDataFrame)+import DataFrame.Typed.Freeze (freeze)+import qualified DataFrame.Typed.Expr as TE+import Data.Text (Text)++import qualified DataFrame.Display.Web.Plot as Plot+import qualified DataFrame.Display.Web.Chart as Chart+import qualified DataFrame.Display.Web.Chart.Typed as TPlot++import Data.Aeson (Value (Object))+import Data.Aeson.Text (encodeToLazyText)+import qualified Data.Aeson.KeyMap as KM+import qualified Data.Text.Lazy as TL++df = fromNamedColumns+    [ ("income", fromList [1.5, 2.0, 3.1, 4.2, 5.0, 2.2, 3.3, 1.1 :: Double])+    , ("value",  fromList [100, 150, 200, 250, 300, 180, 220, 90 :: Double])+    , ("region", fromList (["INLAND","NEAR BAY","INLAND","NEAR OCEAN","ISLAND","INLAND","NEAR BAY","INLAND"] :: [Text]))+    ]++income = F.col @Double "income"+value  = F.col @Double "value"+region = F.col @Text   "region"++-- show a Vega-Lite spec without its (verbose) inlined data.+-- Returns String so scripths prints it raw rather than show-escaped.+grammar v = case v of+    Object o -> TL.unpack (encodeToLazyText (Object (KM.delete "data" o)))+    _        -> TL.unpack (encodeToLazyText v)+```++> <!-- scripths:mime text/plain -->++## Terminal plots++Terminal plots render to stdout, so they're shown here rather than run:++```text+import qualified DataFrame.Display.Terminal.Plot as T+T.scatter (T.mkScatter "income" "value") df+T.histogram (T.mkHistogram "income") df+```++## Web plots++Three tiers, all compiling to the same Vega-Lite spec:++| Module | Keyed by | Use |+|--------|----------|-----|+| `DataFrame.Display.Web.Plot` | string column names | quick one-liners; returns an HTML `String` |+| `DataFrame.Display.Web.Chart` | untyped `Expr` | composable grammar |+| `DataFrame.Display.Web.Chart.Typed` | typed `TExpr` / `TypedDataFrame` | same grammar, `#column` checked against the schema at compile time |++Vocabulary (re-exported from all three):++- **Marks** — `Bar Line Point Area Boxplot Arc Rule Tick`.+- **Channels** — `X Y Color Size Shape Opacity Theta Column Row Tooltip Order`.+- **Field types** — `Quantitative Nominal Ordinal Temporal`, inferred from the expression's element type:++| Haskell type | field type |+|--------------|-----------|+| `Int`, `Double`, `Float`, `Word`, … | `Quantitative` |+| `Text`, `String`, `Bool`, `Char` | `Nominal` |+| `Day`, `UTCTime`, `LocalTime`, … | `Temporal` |+| `Maybe a` | as `a` |++Override the inferred type with `encAs`. Aggregations (`aggregateOn`): `Count Sum Mean Median Min Max`.++In the REPL or a notebook you render straight to the browser (these aren't run here):++```text+Plot.scatter (Plot.mkScatter "income" "value") df >>= Plot.showInDefaultBrowser+Chart.showChart (Chart.chart df |> Chart.mark Chart.Point+                                |> Chart.enc Chart.X income+                                |> Chart.enc Chart.Y value)+```++### Untyped grammar (`Expr`)++Build a chart by piping combinators onto `chart df`. A scatter with a categorical colour encoding —+`income :: Expr Double` becomes `quantitative`, `region :: Expr Text` becomes `nominal`:++```haskell+grammar (Chart.toVegaSpec+    (Chart.chart df+        |> Chart.mark Chart.Point+        |> Chart.enc Chart.X income+        |> Chart.enc Chart.Y value+        |> Chart.enc Chart.Color region))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","encoding":{"color":{"field":"region","type":"nominal"},"x":{"field":"income","type":"quantitative"},"y":{"field":"value","type":"quantitative"}},"height":400,"mark":{"tooltip":true,"type":"point"},"width":600}++Map more columns onto `Size` / `Opacity` / `Shape` / `Tooltip`:++```haskell+grammar (Chart.toVegaSpec+    (Chart.chart df+        |> Chart.mark Chart.Point+        |> Chart.enc Chart.X income+        |> Chart.enc Chart.Y value+        |> Chart.enc Chart.Size value+        |> Chart.enc Chart.Opacity income))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","encoding":{"opacity":{"field":"income","type":"quantitative"},"size":{"field":"value","type":"quantitative"},"x":{"field":"income","type":"quantitative"},"y":{"field":"value","type":"quantitative"}},"height":400,"mark":{"tooltip":true,"type":"point"},"width":600}++`aggregateOn` applies a Vega-Lite aggregate to a channel. Sum `value` by `region`, coloured by region:++```haskell+grammar (Chart.toVegaSpec+    (Chart.chart df+        |> Chart.mark Chart.Bar+        |> Chart.enc Chart.X region+        |> Chart.enc Chart.Y value+        |> Chart.aggregateOn Chart.Y Chart.Sum+        |> Chart.enc Chart.Color region))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","encoding":{"color":{"field":"region","type":"nominal"},"x":{"field":"region","type":"nominal"},"y":{"aggregate":"sum","field":"value","type":"quantitative"}},"height":400,"mark":{"tooltip":true,"type":"bar"},"width":600}++A histogram is a binned `X` with a counted `Y` — binning and counting are Vega-Lite transforms:++```haskell+grammar (Chart.toVegaSpec+    (Chart.chart df+        |> Chart.mark Chart.Bar+        |> Chart.enc Chart.X income+        |> Chart.binX+        |> Chart.aggregateOn Chart.Y Chart.Count))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","encoding":{"x":{"bin":true,"field":"income","type":"quantitative"},"y":{"aggregate":"count","type":"quantitative"}},"height":400,"mark":{"tooltip":true,"type":"bar"},"width":600}++A line:++```haskell+grammar (Chart.toVegaSpec+    (Chart.chart df+        |> Chart.mark Chart.Line+        |> Chart.enc Chart.X income+        |> Chart.enc Chart.Y value))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","encoding":{"x":{"field":"income","type":"quantitative"},"y":{"field":"value","type":"quantitative"}},"height":400,"mark":{"tooltip":true,"type":"line"},"width":600}++`encAs` forces a field type; `logScale` puts a channel on a log scale:++```haskell+grammar (Chart.toVegaSpec+    (Chart.chart df+        |> Chart.mark Chart.Point+        |> Chart.encAs Chart.X income Chart.Ordinal+        |> Chart.enc Chart.Y value+        |> Chart.logScale Chart.Y))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","encoding":{"x":{"field":"income","type":"ordinal"},"y":{"field":"value","scale":{"type":"log"},"type":"quantitative"}},"height":400,"mark":{"tooltip":true,"type":"point"},"width":600}++The medium is expressions, not just column names. A non-column expression is evaluated and inlined+under the channel's name (here `y`):++```haskell+grammar (Chart.toVegaSpec+    (Chart.chart df+        |> Chart.mark Chart.Point+        |> Chart.enc Chart.X income+        |> Chart.enc Chart.Y (value + income)))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","encoding":{"x":{"field":"income","type":"quantitative"},"y":{"field":"y","type":"quantitative"}},"height":400,"mark":{"tooltip":true,"type":"point"},"width":600}++`regression` overlays a least-squares line (a second layer) and `facet` splits into small multiples:++```haskell+grammar (Chart.toVegaSpec+    (Chart.regression income value+        (Chart.chart df+            |> Chart.mark Chart.Point+            |> Chart.enc Chart.X income+            |> Chart.enc Chart.Y value+            |> Chart.facet region)))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","height":400,"layer":[{"encoding":{"column":{"field":"region","type":"nominal"},"x":{"field":"income","type":"quantitative"},"y":{"field":"value","type":"quantitative"}},"mark":{"tooltip":true,"type":"point"}},{"encoding":{"x":{"field":"income","type":"quantitative"},"y":{"field":"value","type":"quantitative"}},"mark":{"tooltip":true,"type":"line"},"transform":[{"on":"income","regression":"value"}]}],"width":600}++`density` draws a kernel-density estimate as an area:++```haskell+grammar (Chart.toVegaSpec+    (Chart.density income (Chart.chart df |> Chart.mark Chart.Area)))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","encoding":{"x":{"field":"value","type":"quantitative"},"y":{"field":"density","type":"quantitative"}},"height":400,"mark":{"tooltip":true,"type":"area"},"transform":[{"density":"income"}],"width":600}++`layer` overlays charts that share data:++```haskell+grammar (Chart.toVegaSpec+    (Chart.layer+        [ Chart.chart df |> Chart.mark Chart.Point |> Chart.enc Chart.X income |> Chart.enc Chart.Y value+        , Chart.chart df |> Chart.mark Chart.Line  |> Chart.enc Chart.X income |> Chart.enc Chart.Y value+        ]))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","height":400,"layer":[{"encoding":{"x":{"field":"income","type":"quantitative"},"y":{"field":"value","type":"quantitative"}},"mark":{"tooltip":true,"type":"point"}},{"encoding":{"x":{"field":"income","type":"quantitative"},"y":{"field":"value","type":"quantitative"}},"mark":{"tooltip":true,"type":"line"}}],"width":600}++`title` and `size` set the chart title and pixel dimensions.++### Typed grammar (`TExpr`)++`DataFrame.Display.Web.Chart.Typed` mirrors every combinator above, over a `TypedDataFrame`, so+`#region` / `#value` are checked against the schema at compile time. `box` draws a+box-and-whisker (quartiles, 1.5×IQR whiskers, outliers):++```haskell+type Cols = '[ Column "income" Double, Column "value" Double, Column "region" Text ]++case freeze @Cols df of+    Nothing  -> "schema mismatch"+    Just tdf -> grammar (TPlot.toVegaSpec+        (TPlot.chart tdf+            |> TPlot.mark TPlot.Boxplot+            |> TPlot.enc TPlot.X #region+            |> TPlot.enc TPlot.Y #value))+```++> <!-- scripths:mime text/plain -->+> {"$schema":"https://vega.github.io/schema/vega-lite/v5.json","encoding":{"x":{"field":"region","type":"nominal"},"y":{"field":"value","type":"quantitative"}},"height":400,"mark":{"tooltip":true,"type":"boxplot"},"width":600}++A typed one-liner mirrors the string tier, but the labels must exist in the schema (not run here):++```text+TPlot.scatter #income #value tdf+```++### Rendering++Every tier produces the same outputs:++- `toVegaSpec :: Chart -> Value` — the Vega-Lite spec as an aeson `Value`. Escape hatch for advanced+  use, or hand-off to [`hvega`](https://hackage.haskell.org/package/hvega), which speaks the same spec.+- `toHtml :: Chart -> String` — a self-contained HTML snippet (CDN `vega-embed`, data inlined, so it+  renders from a `file://` URL).+- `showChart :: Chart -> IO ()` — write the HTML to a temp file and open the browser.+- `showInDefaultBrowser :: String -> IO ()` — open an HTML `String` (the string tier returns these).++Frames over ~5,000 rows print a stderr warning, since the data is inlined into the spec.++### String tier (one-shots)++`DataFrame.Display.Web.Plot` is the quick path; each call returns an HTML `String` (not run here):++```text+Plot.bar       (Plot.mkBar "region")              df   -- count rows per region+Plot.histogram (Plot.mkHistogram "income")        df+Plot.scatter   (Plot.mkScatter "income" "value")  df+Plot.line      (Plot.mkLine "income" ["value"])   df+Plot.pie       (Plot.mkPie "region")              df+Plot.box       (Plot.mkBox ["income", "value"])   df+```++Override defaults with record syntax on the spec: `Bar` has `y`, `agg`, `topN`, `title`, `size`;+`Histogram` has `bins`; `Scatter` has `color`; `Pie` has `names`, `agg`, `topN`; `Box` / `Line` take+a list of columns. E.g. `bar (mkBar "region") { y = Just "value", agg = Sum, topN = Just 5 } df`.++## Install++```+build-depends: dataframe-viz+```++The plotting modules are also re-exported from the umbrella `dataframe` package+(`DataFrame.Display.Web.Plot`, `DataFrame.Display.Web.Chart`, `DataFrame.Display.Web.Chart.Typed`).
dataframe-viz.cabal view
@@ -1,6 +1,6 @@ cabal-version:      2.4 name:               dataframe-viz-version:            1.0.1.1+version:            1.0.2.0  synopsis:           Visualisation/plotting helpers for the dataframe ecosystem. description:@@ -12,8 +12,9 @@ license-file:       LICENSE author:             Michael Chavinda maintainer:         mschavinda@gmail.com-copyright:          (c) 2024-2025 Michael Chavinda+copyright:          (c) 2024-2026 Michael Chavinda category:           Data+extra-doc-files:    README.md tested-with:        GHC ==9.4.8 || ==9.6.7 || ==9.8.4 || ==9.10.3 || ==9.12.2  common warnings@@ -30,8 +31,12 @@                         DataFrame.Display                         DataFrame.Display.Terminal.Plot                         DataFrame.Display.Web.Plot+                        DataFrame.Display.Web.Chart+                        DataFrame.Display.Web.Chart.Typed     other-modules:      DataFrame.Display.Internal.Common+                        DataFrame.Display.Internal.VegaLite     build-depends:      base >= 4 && < 5,+                        aeson >= 0.11.0.0 && < 3,                         containers >= 0.6.7 && < 0.9,                         dataframe-core ^>= 1.0,                         directory >= 1.3.0.0 && < 2,
src/DataFrame/Display/Internal/Common.hs view
@@ -20,6 +20,8 @@     -- * Column extraction     extractStringColumn,     extractNumericColumn,+    columnToStrings,+    columnToDoubles,      -- * Type guards     isNumericColumn,@@ -145,25 +147,29 @@ extractStringColumn colName df =     case M.lookup colName (columnIndices df) of         Nothing -> error $ "Column " ++ T.unpack colName ++ " not found"-        Just idx ->-            let col = columns df V.! idx-             in case col of-                    BoxedColumn _ (vec :: V.Vector a) ->-                        case testEquality (typeRep @a) (typeRep @T.Text) of-                            Just Refl -> V.toList vec-                            Nothing -> V.toList $ V.map (T.pack . show) vec-                    UnboxedColumn _ vec ->-                        V.toList $ VG.map (T.pack . show) (VG.convert vec)+        Just idx -> columnToStrings (columns df V.! idx)  extractNumericColumn :: (HasCallStack) => T.Text -> DataFrame -> [Double] extractNumericColumn colName df =     case M.lookup colName (columnIndices df) of         Nothing -> error $ "Column " ++ T.unpack colName ++ " not found"-        Just idx ->-            let col = columns df V.! idx-             in case col of-                    BoxedColumn _ vec -> vectorToDoubles vec-                    UnboxedColumn _ vec -> unboxedVectorToDoubles vec+        Just idx -> columnToDoubles (columns df V.! idx)++-- | Render a column's values as strings (identity for @Text@, @show@ otherwise).+columnToStrings :: (HasCallStack) => Column -> [T.Text]+columnToStrings col = case col of+    BoxedColumn _ (vec :: V.Vector a) ->+        case testEquality (typeRep @a) (typeRep @T.Text) of+            Just Refl -> V.toList vec+            Nothing -> V.toList $ V.map (T.pack . show) vec+    UnboxedColumn _ vec ->+        V.toList $ VG.map (T.pack . show) (VG.convert vec)++-- | Coerce a numeric column to @[Double]@; errors if the element type is not numeric.+columnToDoubles :: (HasCallStack) => Column -> [Double]+columnToDoubles col = case col of+    BoxedColumn _ vec -> vectorToDoubles vec+    UnboxedColumn _ vec -> unboxedVectorToDoubles vec  vectorToDoubles :: forall a. (Columnable a, Show a) => V.Vector a -> [Double] vectorToDoubles vec =
+ src/DataFrame/Display/Internal/VegaLite.hs view
@@ -0,0 +1,352 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE PatternSynonyms #-}+{-# LANGUAGE PolyKinds #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++{- |+Internal Vega-Lite spec model plus aeson encoding, shared by the web plot+backends. Not part of the public API.++The encoding medium is the untyped 'Expr'. A channel encoding resolves to a+'ResolvedField' carrying the field name, the Vega-Lite field type (derived from+the expression's Haskell element type) and the column values to inline.+-}+module DataFrame.Display.Internal.VegaLite (+    -- * Spec model+    Mark (..),+    Channel (..),+    FieldType (..),+    ChannelEnc (..),+    Transform (..),+    VLSpec (..),+    emptySpec,+    chanEnc,+    channelName,++    -- * Resolving expressions to fields+    ResolvedField (..),+    fieldTypeOf,+    resolveField,+    textField,+    numField,++    -- * Encoding to JSON / HTML+    specToValue,+    inlineRows,+    specHtml,+    rowCountWarning,+) where++import qualified Control.Monad+import Data.Aeson (ToJSON (toJSON), Value (Null), object, (.=))+import qualified Data.Aeson.Key as K+import Data.Aeson.Text (encodeToLazyText)+import qualified Data.List as L+import Data.Maybe (catMaybes, fromMaybe)+import qualified Data.Text as T+import qualified Data.Text.Lazy as TL+import qualified Data.Vector as V+import System.IO (hPutStrLn, stderr)+import Type.Reflection (TypeRep, tyConName, typeRep, typeRepTyCon, pattern App)++import DataFrame.Internal.Column (Column, Columnable, unwrapTypedColumn)+import DataFrame.Internal.DataFrame (DataFrame, getColumn)+import DataFrame.Internal.Expression (Expr (Col))+import DataFrame.Internal.Interpreter (interpret)++import DataFrame.Display.Internal.Common (columnToDoubles, columnToStrings)++-- ---------------------------------------------------------------------------+-- Spec model+-- ---------------------------------------------------------------------------++data Mark = Bar | Line | Point | Area | Boxplot | Arc | Rule | Tick+    deriving (Eq, Show)++markName :: Mark -> T.Text+markName m = case m of+    Bar -> "bar"+    Line -> "line"+    Point -> "point"+    Area -> "area"+    Boxplot -> "boxplot"+    Arc -> "arc"+    Rule -> "rule"+    Tick -> "tick"++data Channel+    = X+    | Y+    | Color+    | Size+    | Shape+    | Column+    | Row+    | Opacity+    | Theta+    | Tooltip+    | Order+    deriving (Eq, Show)++channelName :: Channel -> T.Text+channelName c = case c of+    X -> "x"+    Y -> "y"+    Color -> "color"+    Size -> "size"+    Shape -> "shape"+    Column -> "column"+    Row -> "row"+    Opacity -> "opacity"+    Theta -> "theta"+    Tooltip -> "tooltip"+    Order -> "order"++data FieldType = Quantitative | Nominal | Ordinal | Temporal+    deriving (Eq, Show)++fieldTypeName :: FieldType -> T.Text+fieldTypeName t = case t of+    Quantitative -> "quantitative"+    Nominal -> "nominal"+    Ordinal -> "ordinal"+    Temporal -> "temporal"++{- | A single channel encoding. An empty 'ceField' with a 'ceAggregate' of+@count@ produces a fieldless count aggregation, as Vega-Lite expects.+-}+data ChannelEnc = ChannelEnc+    { ceChannel :: Channel+    , ceField :: T.Text+    , ceType :: FieldType+    , ceAggregate :: Maybe T.Text+    , ceBin :: Bool+    , ceLogScale :: Bool+    }++-- | A bare channel encoding with no aggregation, binning, or log scale.+chanEnc :: Channel -> T.Text -> FieldType -> ChannelEnc+chanEnc ch fld ft = ChannelEnc ch fld ft Nothing False False++data Transform+    = -- | Fit @regression(yField) on xField@ (used inside a layer).+      RegressionT T.Text T.Text+    | -- | Kernel-density estimate of a field.+      DensityT T.Text+    deriving (Eq, Show)++{- | A Vega-Lite spec. When 'vlLayers' is non-empty the spec is a layered+container: the top level carries the shared data and each layer carries its own+mark/encoding/transform.+-}+data VLSpec = VLSpec+    { vlMark :: Mark+    , vlEncodings :: [ChannelEnc]+    , vlTransforms :: [Transform]+    , vlTitle :: Maybe T.Text+    , vlWidth :: Int+    , vlHeight :: Int+    , vlLayers :: [VLSpec]+    }++emptySpec :: Mark -> VLSpec+emptySpec m = VLSpec m [] [] Nothing 600 400 []++-- ---------------------------------------------------------------------------+-- Field-type inference from the expression's element type+-- ---------------------------------------------------------------------------++{- | Derive the Vega-Lite field type from a Haskell type: numeric →+'Quantitative', date/time → 'Temporal', everything else → 'Nominal'. @Maybe a@+classifies as its inner type.+-}+fieldTypeOf :: forall a. (Columnable a) => FieldType+fieldTypeOf = classify (typeRep @a)++classify :: forall k (x :: k). TypeRep x -> FieldType+classify tr+    | nm `elem` quantNames = Quantitative+    | nm `elem` temporalNames = Temporal+    | nm == "Maybe" = case tr of+        App _ arg -> classify arg+        _ -> Nominal+    | otherwise = Nominal+  where+    nm = tyConName (typeRepTyCon tr)+    quantNames =+        [ "Int"+        , "Int8"+        , "Int16"+        , "Int32"+        , "Int64"+        , "Word"+        , "Word8"+        , "Word16"+        , "Word32"+        , "Word64"+        , "Integer"+        , "Natural"+        , "Double"+        , "Float"+        , "Scientific"+        ]+    temporalNames =+        ["Day", "UTCTime", "LocalTime", "ZonedTime", "TimeOfDay"]++-- ---------------------------------------------------------------------------+-- Resolving an expression to a field + values+-- ---------------------------------------------------------------------------++data ResolvedField = ResolvedField+    { rfName :: T.Text+    , rfType :: FieldType+    , rfValues :: [Value]+    }++{- | Resolve an expression against a frame. A bare @Col name@ reuses the named+column; any other expression is materialised with the core interpreter and+stored under the given fallback name.+-}+resolveField ::+    forall a. (Columnable a) => DataFrame -> T.Text -> Expr a -> ResolvedField+resolveField df fallbackName expr =+    let ft = fieldTypeOf @a+        (name, col) = case expr of+            Col cname -> (cname, lookupCol cname)+            _ -> (fallbackName, materialiseExpr df expr)+     in ResolvedField name ft (columnToValues ft col)+  where+    lookupCol cname = case getColumn cname df of+        Just c -> c+        Nothing ->+            error $ "DataFrame.Display.Web: column not found: " <> T.unpack cname++materialiseExpr :: (Columnable a) => DataFrame -> Expr a -> Column+materialiseExpr df expr = case interpret df expr of+    Right tc -> unwrapTypedColumn tc+    Left err ->+        error $ "DataFrame.Display.Web: could not evaluate expression: " <> show err++columnToValues :: FieldType -> Column -> [Value]+columnToValues Quantitative col = map toJSON (columnToDoubles col)+columnToValues _ col = map (toJSON :: T.Text -> Value) (columnToStrings col)++-- | A nominal field built directly from text values (for pre-computed data).+textField :: T.Text -> [T.Text] -> ResolvedField+textField name vals = ResolvedField name Nominal (map (toJSON :: T.Text -> Value) vals)++-- | A quantitative field built directly from numeric values (for pre-computed data).+numField :: T.Text -> [Double] -> ResolvedField+numField name vals = ResolvedField name Quantitative (map toJSON vals)++-- ---------------------------------------------------------------------------+-- Encoding to JSON+-- ---------------------------------------------------------------------------++schemaUrl :: T.Text+schemaUrl = "https://vega.github.io/schema/vega-lite/v5.json"++specToValue :: [ResolvedField] -> VLSpec -> Value+specToValue fields spec+    | null (vlLayers spec) =+        object $+            commonPairs ++ unitPairs spec+    | otherwise =+        object $+            commonPairs ++ [("layer", toJSON (map (object . unitPairs) (vlLayers spec)))]+  where+    commonPairs =+        catMaybes+            [ Just ("$schema" .= schemaUrl)+            , fmap ("title" .=) (vlTitle spec)+            , Just ("width" .= vlWidth spec)+            , Just ("height" .= vlHeight spec)+            , Just ("data" .= object ["values" .= inlineRows fields])+            ]++-- | The mark/encoding/transform pairs of a unit spec (no data — that is shared).+unitPairs :: VLSpec -> [(K.Key, Value)]+unitPairs spec =+    catMaybes+        [ if null (vlTransforms spec)+            then Nothing+            else Just ("transform" .= map transformValue (vlTransforms spec))+        , Just ("mark" .= markValue (vlMark spec))+        , Just ("encoding" .= encodingValue (vlEncodings spec))+        ]++markValue :: Mark -> Value+markValue m = object ["type" .= markName m, "tooltip" .= True]++encodingValue :: [ChannelEnc] -> Value+encodingValue encs =+    object [K.fromText (channelName (ceChannel e)) .= channelValue e | e <- encs]++channelValue :: ChannelEnc -> Value+channelValue e =+    object $+        catMaybes+            [ if T.null (ceField e) then Nothing else Just ("field" .= ceField e)+            , Just ("type" .= fieldTypeName (ceType e))+            , fmap ("aggregate" .=) (ceAggregate e)+            , if ceBin e then Just ("bin" .= True) else Nothing+            , if ceLogScale e+                then Just ("scale" .= object ["type" .= ("log" :: T.Text)])+                else Nothing+            ]++transformValue :: Transform -> Value+transformValue (RegressionT yField xField) =+    object ["regression" .= yField, "on" .= xField]+transformValue (DensityT field) =+    object ["density" .= field]++-- | Build the @data.values@ array of row objects, deduplicating fields by name.+inlineRows :: [ResolvedField] -> Value+inlineRows fields =+    let uniq = L.nubBy (\a b -> rfName a == rfName b) fields+        vecs = [(rfName f, V.fromList (rfValues f)) | f <- uniq]+        n = maximum (0 : map (V.length . snd) vecs)+        row i = object [K.fromText nm .= fromMaybe Null (vs V.!? i) | (nm, vs) <- vecs]+     in toJSON [row i | i <- [0 .. n - 1]]++-- ---------------------------------------------------------------------------+-- HTML embedding (vega-embed via CDN)+-- ---------------------------------------------------------------------------++{- | Render a spec to a self-contained HTML snippet that loads vega/vega-lite/+vega-embed from a CDN and embeds the chart. Data is inlined, so the snippet+renders correctly even from a @file://@ URL.+-}+specHtml :: T.Text -> [ResolvedField] -> VLSpec -> T.Text+specHtml chartId fields spec =+    let specJson = TL.toStrict (encodeToLazyText (specToValue fields spec))+     in T.concat+            [ "<div id=\""+            , chartId+            , "\"></div>\n"+            , "<script src=\"https://cdn.jsdelivr.net/npm/vega@5\"></script>\n"+            , "<script src=\"https://cdn.jsdelivr.net/npm/vega-lite@5\"></script>\n"+            , "<script src=\"https://cdn.jsdelivr.net/npm/vega-embed@6\"></script>\n"+            , "<script>vegaEmbed('#"+            , chartId+            , "', "+            , specJson+            , ");</script>\n"+            ]++{- | Warn on stderr when a large number of rows is being inlined, which bloats+the spec and can slow the browser.+-}+rowCountWarning :: [ResolvedField] -> IO ()+rowCountWarning fields = do+    let n = maximum (0 : map (length . rfValues) fields)+    Control.Monad.when (n > 5000) $+        hPutStrLn stderr $+            "DataFrame.Display.Web: inlining "+                ++ show n+                ++ " rows into the plot spec; consider filtering or aggregating first."
+ src/DataFrame/Display/Web/Chart.hs view
@@ -0,0 +1,319 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}++{- |+A composable, expression-based grammar of graphics over the untyped 'Expr'.+Charts are built by piping combinators and rendered to interactive Vega-Lite.++@+import qualified DataFrame.Display.Web.Chart as Plt+import DataFrame.Functions (col)++Plt.showChart+  ( Plt.chart df+      |> Plt.mark Plt.Point+      |> Plt.enc Plt.X (col \@Double "age")+      |> Plt.enc Plt.Y (col \@Double "fare")+      |> Plt.enc Plt.Color (col \@Text "class")+  )+@++The element type of each encoded expression determines the Vega-Lite field+type (numeric → quantitative, date/time → temporal, otherwise nominal). A bare+column expression encodes that column; any other expression is materialised+with the core interpreter and inlined under the channel's name.++For the schema-checked typed variant see "DataFrame.Display.Web.Chart.Typed".+-}+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,+    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 (..),+    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)++-- ---------------------------------------------------------------------------+-- Chart builder+-- ---------------------------------------------------------------------------++{- | An in-progress chart: the source frame, a mark, channel encodings, the+data fields to inline, transforms, and (for layered charts) sub-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. The field type is inferred from the+expression's element type; use 'encAs' to override.+-}+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 declarative aggregation to a channel (computed by Vega-Lite). For+'Count' the field is dropped, matching Vega-Lite's fieldless count.+-}+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.+logScale :: Channel -> Chart -> Chart+logScale ch = withChannel ch (\e -> e{ceLogScale = True})++-- | 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 a single layered chart. The+title and size of the first layer are used for the container.+-}+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 regression (least-squares) line over the chart, fitting @y@ on @x@.+Produces a layered chart: the original marks plus a fitted line.+-}+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. Replaces the+chart's mark and encodings with the density curve (Vega-Lite @density@).+-}+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+                ]+            }++-- ---------------------------------------------------------------------------+-- Rendering+-- ---------------------------------------------------------------------------++-- | The Vega-Lite spec as an aeson 'Value' (escape hatch for advanced use / 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)++-- ---------------------------------------------------------------------------+-- One-shot convenience plots (the simple single-line path)+-- ---------------------------------------------------------------------------++-- | Scatter plot of two expressions.+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)++-- | 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@.+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)++-- | 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)++-- ---------------------------------------------------------------------------+-- Internals+-- ---------------------------------------------------------------------------++-- | 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)]}++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+            }
+ src/DataFrame/Display/Web/Chart/Typed.hs view
@@ -0,0 +1,196 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE KindSignatures #-}++{- |+The schema-checked, typed-expression analogue of "DataFrame.Display.Web.Chart".+Charts are built over a 'TypedDataFrame' using typed expressions ('TExpr',+@#col@), so column references are checked against the schema at compile time+and the field type follows from the column's Haskell type.++@+{\-\# LANGUAGE OverloadedLabels \#-\}+import qualified DataFrame.Display.Web.Chart.Typed as Plt++-- one-liner+Plt.scatter #age #fare tdf++-- composable grammar+Plt.showChart+  ( Plt.chart tdf+      |> Plt.mark Plt.Boxplot+      |> Plt.enc Plt.X #region+      |> Plt.enc Plt.Y #value+      |> Plt.enc Plt.Color #grp+      |> Plt.facet #grp+  )+@++Every combinator delegates to "DataFrame.Display.Web.Chart" by unwrapping the+typed expression ('unTExpr') and frame ('unTDF'); the @cols@ phantom is erased+at the boundary.+-}+module DataFrame.Display.Web.Chart.Typed (+    -- * 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,+    title,+    size,++    -- * Rendering+    toVegaSpec,+    toHtml,+    showChart,++    -- * One-shot convenience plots+    scatter,+    bar,+    histogram,+    line,+    pie,+    box,+) where++import Data.Aeson (Value)+import Data.Kind (Type)+import qualified Data.Text as T++import DataFrame.Display.Internal.Common (Agg (..))+import DataFrame.Display.Internal.VegaLite (+    Channel (..),+    FieldType (..),+    Mark (..),+ )+import qualified DataFrame.Display.Web.Chart as C+import DataFrame.Internal.Column (Columnable)+import DataFrame.Typed.Types (TExpr (..), TypedDataFrame (..))++-- | A typed chart: a phantom-@cols@ wrapper over the untyped builder.+newtype Chart (cols :: [Type]) = Chart C.Chart++-- | Start a typed chart from a typed frame.+chart :: TypedDataFrame cols -> Chart cols+chart tdf = Chart (C.chart (unTDF tdf))++-- | Set the mark type.+mark :: Mark -> Chart cols -> Chart cols+mark m (Chart c) = Chart (C.mark m c)++-- | Encode a typed expression on a channel (field type inferred from its type).+enc :: (Columnable a) => Channel -> TExpr cols a -> Chart cols -> Chart cols+enc ch te (Chart c) = Chart (C.enc ch (unTExpr te) c)++-- | Like 'enc' but force the Vega-Lite field type.+encAs ::+    (Columnable a) =>+    Channel -> TExpr cols a -> FieldType -> Chart cols -> Chart cols+encAs ch te ft (Chart c) = Chart (C.encAs ch (unTExpr te) ft c)++-- | Apply a declarative aggregation to a channel.+aggregateOn :: Channel -> Agg -> Chart cols -> Chart cols+aggregateOn ch a (Chart c) = Chart (C.aggregateOn ch a c)++-- | Bin the X (resp. Y) channel.+binX, binY :: Chart cols -> Chart cols+binX (Chart c) = Chart (C.binX c)+binY (Chart c) = Chart (C.binY c)++-- | Put a channel on a log scale.+logScale :: Channel -> Chart cols -> Chart cols+logScale ch (Chart c) = Chart (C.logScale ch c)++-- | Facet into small multiples by a column (alias for 'column').+facet :: (Columnable a) => TExpr cols a -> Chart cols -> Chart cols+facet = column++-- | Facet across columns / down rows.+column, row :: (Columnable a) => TExpr cols a -> Chart cols -> Chart cols+column te (Chart c) = Chart (C.column (unTExpr te) c)+row te (Chart c) = Chart (C.row (unTExpr te) c)++-- | Set the chart title.+title :: T.Text -> Chart cols -> Chart cols+title t (Chart c) = Chart (C.title t c)++-- | Set the chart size in pixels.+size :: Int -> Int -> Chart cols -> Chart cols+size w h (Chart c) = Chart (C.size w h c)++-- | Overlay several charts sharing data into a single layered chart.+layer :: [Chart cols] -> Chart cols+layer cs = Chart (C.layer [c | Chart c <- cs])++-- | Add a least-squares regression line fitting @y@ on @x@.+regression ::+    (Columnable a, Columnable b) =>+    TExpr cols a -> TExpr cols b -> Chart cols -> Chart cols+regression xE yE (Chart c) = Chart (C.regression (unTExpr xE) (unTExpr yE) c)++-- | Kernel-density estimate of an expression, drawn as an area.+density :: (Columnable a) => TExpr cols a -> Chart cols -> Chart cols+density e (Chart c) = Chart (C.density (unTExpr e) c)++-- | The Vega-Lite spec as an aeson 'Value' (escape hatch for advanced use / hvega).+toVegaSpec :: Chart cols -> Value+toVegaSpec (Chart c) = C.toVegaSpec c++-- | A self-contained HTML snippet embedding the chart.+toHtml :: Chart cols -> String+toHtml (Chart c) = C.toHtml c++-- | Render the chart to a temp file and open it in the default browser.+showChart :: Chart cols -> IO ()+showChart (Chart c) = C.showChart c++-- ---------------------------------------------------------------------------+-- One-shot convenience plots+-- ---------------------------------------------------------------------------++-- | Scatter plot of two typed expressions.+scatter ::+    (Columnable a, Columnable b) =>+    TExpr cols a -> TExpr cols b -> TypedDataFrame cols -> IO ()+scatter xE yE tdf = C.scatter (unTExpr xE) (unTExpr yE) (unTDF tdf)++-- | Count of rows per category, as bars.+bar :: (Columnable a) => TExpr cols a -> TypedDataFrame cols -> IO ()+bar xE tdf = C.bar (unTExpr xE) (unTDF tdf)++-- | Histogram of a numeric expression.+histogram :: (Columnable a) => TExpr cols a -> TypedDataFrame cols -> IO ()+histogram xE tdf = C.histogram (unTExpr xE) (unTDF tdf)++-- | Line chart of @y@ over @x@.+line ::+    (Columnable a, Columnable b) =>+    TExpr cols a -> TExpr cols b -> TypedDataFrame cols -> IO ()+line xE yE tdf = C.line (unTExpr xE) (unTExpr yE) (unTDF tdf)++-- | Pie chart counting rows per category.+pie :: (Columnable a) => TExpr cols a -> TypedDataFrame cols -> IO ()+pie cE tdf = C.pie (unTExpr cE) (unTDF tdf)++-- | Box-and-whisker plot of a numeric expression.+box :: (Columnable a) => TExpr cols a -> TypedDataFrame cols -> IO ()+box yE tdf = C.box (unTExpr yE) (unTDF tdf)
src/DataFrame/Display/Web/Plot.hs view
@@ -5,14 +5,18 @@  {- | A plotly-express-style one-shot plotting API for HTML output. Mirrors-'DataFrame.Display.Terminal.Plot' but emits embeddable Chart.js HTML.+'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-    HtmlPlot (..),     showInDefaultBrowser,      -- * Layout@@ -64,6 +68,7 @@ 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)@@ -91,16 +96,25 @@     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)  -- ------------------------------------------------------------------------------ Output container + layout+-- Layout -- --------------------------------------------------------------------------- --- | A snippet of HTML containing a Chart.js plot.-newtype HtmlPlot = HtmlPlot T.Text deriving (Show)---- | Display dimensions for the HTML canvas, in pixels.+-- | Display dimensions for the chart, in pixels. data Size = Size {width :: Int, height :: Int}  defaultSize :: Size@@ -115,35 +129,12 @@                 (take 64 (randomRs (49, 126) gen :: [Int]))     return $ "chart_" <> T.pack (map chr randomWords) -wrapInHTML :: T.Text -> T.Text -> Size -> T.Text-wrapInHTML chartId content sz =-    T.concat-        [ "<canvas id=\""-        , chartId-        , "\" style=\"width:100%;max-width:"-        , T.pack (show sz.width)-        , "px;height:"-        , T.pack (show sz.height)-        , "px\"></canvas>\n"-        , "<script src=\"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.min.js\"></script>\n"-        , "<script>\n"-        , content-        , "\n</script>\n"-        ]--palette :: [T.Text]-palette =-    [ "rgb(255, 99, 132)"-    , "rgb(54, 162, 235)"-    , "rgb(255, 206, 86)"-    , "rgb(75, 192, 192)"-    , "rgb(153, 102, 255)"-    , "rgb(255, 159, 64)"-    , "rgb(201, 203, 207)"-    , "rgb(255, 99, 71)"-    , "rgb(60, 179, 113)"-    , "rgb(238, 130, 238)"-    ]+-- | 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@@ -161,9 +152,8 @@ mkBar :: T.Text -> Bar mkBar c = Bar c Nothing Sum Nothing Nothing defaultSize -bar :: (HasCallStack) => Bar -> DataFrame -> IO HtmlPlot+bar :: (HasCallStack) => Bar -> DataFrame -> IO String bar spec df = do-    chartId <- generateChartId     let effectiveAgg = case spec.y of             Nothing -> Count             Just _ -> spec.agg@@ -172,27 +162,23 @@         chartTitle = case spec.title of             Just s -> s             Nothing -> autoTitle effectiveAgg spec.y spec.x-        labels = T.intercalate "," ["\"" <> label <> "\"" | (label, _) <- rows']-        dataPoints = T.intercalate "," [T.pack (show v) | (_, v) <- rows']         legendLabel = case spec.y of             Nothing -> "count"             Just yCol -> aggLabel effectiveAgg <> "(" <> yCol <> ")"-        jsCode =-            T.concat-                [ "setTimeout(function() { new Chart(\""-                , chartId-                , "\", {\n  type: \"bar\",\n  data: {\n    labels: ["-                , labels-                , "],\n    datasets: [{\n      label: \""-                , legendLabel-                , "\",\n      data: ["-                , dataPoints-                , "],\n      backgroundColor: \"rgba(54, 162, 235, 0.6)\",\n      borderColor: \"rgba(54, 162, 235, 1)\",\n      borderWidth: 1\n    }]\n  },\n"-                , "  options: {\n    title: { display: true, text: \""-                , chartTitle-                , "\" },\n    scales: { yAxes: [{ ticks: { beginAtZero: true } }] }\n  }\n})}, 100);"-                ]-    return $ HtmlPlot $ wrapInHTML chartId jsCode spec.size+        (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@@ -208,42 +194,33 @@ mkHistogram :: T.Text -> Histogram mkHistogram c = Histogram c 30 Nothing defaultSize -histogram :: (HasCallStack) => Histogram -> DataFrame -> IO HtmlPlot+histogram :: (HasCallStack) => Histogram -> DataFrame -> IO String histogram spec df = do-    chartId <- generateChartId     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 countBin binStarts+        counts = map (fromIntegral . countBin) binStarts         precision = max 0 $ ceiling (negate $ logBase 10 (max 1e-12 binWidth))-        labels =-            T.intercalate-                ","-                [ "\"" <> T.pack (showFFloat (Just precision) b "") <> "\""-                | b <- binStarts-                ]-        dataPoints = T.intercalate "," [T.pack (show c) | c <- counts]+        binLabels =+            [T.pack (showFFloat (Just precision) b "") | b <- binStarts]         chartTitle = case spec.title of             Just s -> s             Nothing -> "histogram of " <> spec.x-        jsCode =-            T.concat-                [ "setTimeout(function() { new Chart(\""-                , chartId-                , "\", {\n  type: \"bar\",\n  data: {\n    labels: ["-                , labels-                , "],\n    datasets: [{\n      label: \""-                , spec.x-                , "\",\n      data: ["-                , dataPoints-                , "],\n      backgroundColor: \"rgba(75, 192, 192, 0.6)\",\n      borderColor: \"rgba(75, 192, 192, 1)\",\n      borderWidth: 1\n    }]\n  },\n"-                , "  options: {\n    title: { display: true, text: \""-                , chartTitle-                , "\" },\n    scales: { yAxes: [{ ticks: { beginAtZero: true } }] }\n  }\n})}, 100);"-                ]-    return $ HtmlPlot $ wrapInHTML chartId jsCode spec.size+        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@@ -260,62 +237,27 @@ mkScatter :: T.Text -> T.Text -> Scatter mkScatter xc yc = Scatter xc yc Nothing Nothing defaultSize -scatter :: (HasCallStack) => Scatter -> DataFrame -> IO HtmlPlot+scatter :: (HasCallStack) => Scatter -> DataFrame -> IO String scatter spec df = do-    chartId <- generateChartId     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-    datasets <- case spec.color of-        Nothing ->-            return $-                T.singleton '\n'-                    <> renderSeries chartTitle (head palette) (zip xVals yVals)-        Just grp -> do-            let groupVals = extractStringColumn grp df-                triples = zip3 groupVals xVals yVals-                uniq = L.nub groupVals-                ds =-                    [ renderSeries g col [(xv, yv) | (gv, xv, yv) <- triples, gv == g]-                    | (g, col) <- zip uniq (cycle palette)-                    ]-            return $ T.intercalate ",\n" ds-    let jsCode =-            T.concat-                [ "setTimeout(function() { new Chart(\""-                , chartId-                , "\", {\n  type: \"scatter\",\n  data: {\n    datasets: ["-                , datasets-                , "\n    ]\n  },\n  options: {\n"-                , "    title: { display: true, text: \""-                , chartTitle-                , "\" },\n"-                , "    scales: {\n"-                , "      xAxes: [{ scaleLabel: { display: true, labelString: \""-                , spec.x-                , "\" } }],\n      yAxes: [{ scaleLabel: { display: true, labelString: \""-                , spec.y-                , "\" } }]\n    }\n  }\n})}, 100);"-                ]-    return $ HtmlPlot $ wrapInHTML chartId jsCode spec.size-  where-    renderSeries label col pts =-        let body =-                T.intercalate-                    ","-                    [ "{x:" <> T.pack (show xv) <> ", y:" <> T.pack (show yv) <> "}" | (xv, yv) <- pts-                    ]-         in T.concat-                [ "    {\n      label: \""-                , label-                , "\",\n      data: ["-                , body-                , "],\n      pointRadius: 4,\n      pointBackgroundColor: \""-                , col-                , "\"\n    }"-                ]+        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@@ -331,47 +273,37 @@ mkLine :: T.Text -> [T.Text] -> Line mkLine xc ys = Line xc ys Nothing defaultSize -line :: (HasCallStack) => Line -> DataFrame -> IO HtmlPlot+line :: (HasCallStack) => Line -> DataFrame -> IO String line spec df = do-    chartId <- generateChartId     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-        xValues = extractNumericColumn spec.x df-        xLabels = T.intercalate "," [T.pack (show v) | v <- xValues]-    datasets <- forM (zip spec.y (cycle palette)) $ \(col, c) -> do-        let values = extractNumericColumn col df-            body = T.intercalate "," [T.pack (show v) | v <- values]-        return $-            T.concat-                [ "    {\n      label: \""-                , col-                , "\",\n      data: ["-                , body-                , "],\n      fill: false,\n      borderColor: \""-                , c-                , "\",\n      tension: 0.1\n    }"-                ]-    let datasetsStr = T.intercalate ",\n" datasets-        jsCode =-            T.concat-                [ "setTimeout(function() { new Chart(\""-                , chartId-                , "\", {\n  type: \"line\",\n  data: {\n    labels: ["-                , xLabels-                , "],\n    datasets: [\n"-                , datasetsStr-                , "\n    ]\n  },\n  options: {\n"-                , "    title: { display: true, text: \""-                , chartTitle-                , "\" },\n"-                , "    scales: { xAxes: [{ scaleLabel: { display: true, labelString: \""-                , spec.x-                , "\" } }] }\n  }\n})}, 100);"-                ]-    return $ HtmlPlot $ wrapInHTML chartId jsCode spec.size+        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@@ -389,9 +321,8 @@ mkPie :: T.Text -> Pie mkPie c = Pie c Nothing Count (Just 8) Nothing defaultSize -pie :: (HasCallStack) => Pie -> DataFrame -> IO HtmlPlot+pie :: (HasCallStack) => Pie -> DataFrame -> IO String pie spec df = do-    chartId <- generateChartId     let vCol = spec.values         mNames = spec.names         a = spec.agg@@ -408,27 +339,21 @@                  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 = T.intercalate "," ["\"" <> label <> "\"" | (label, _) <- rows']-        dataPoints = T.intercalate "," [T.pack (show v) | (_, v) <- rows']-        colors =-            T.intercalate-                ","-                ["\"" <> c <> "\"" | c <- take (length rows') palette]-        jsCode =-            T.concat-                [ "setTimeout(function() { new Chart(\""-                , chartId-                , "\", {\n  type: \"pie\",\n  data: {\n    labels: ["-                , labels-                , "],\n    datasets: [{\n      data: ["-                , dataPoints-                , "],\n      backgroundColor: ["-                , colors-                , "]\n    }]\n  },\n  options: { title: { display: true, text: \""-                , chartTitle-                , "\" } }\n})}, 100);"-                ]-    return $ HtmlPlot $ wrapInHTML chartId jsCode spec.size+        (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@@ -443,34 +368,27 @@ mkBox :: [T.Text] -> Box mkBox ys = Box ys Nothing defaultSize -box :: (HasCallStack) => Box -> DataFrame -> IO HtmlPlot+box :: (HasCallStack) => Box -> DataFrame -> IO String box spec df = do-    chartId <- generateChartId-    boxData <- forM spec.y $ \col -> do-        let vs = extractNumericColumn col df-            sorted = L.sort vs-            n = max 1 (length vs)-            median = sorted !! (n `div` 2)-        return (col, median)-    let labels = T.intercalate "," ["\"" <> col <> "\"" | (col, _) <- boxData]-        medians = T.intercalate "," [T.pack (show med) | (_, med) <- boxData]+    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-        jsCode =-            T.concat-                [ "setTimeout(function() { new Chart(\""-                , chartId-                , "\", {\n  type: \"bar\",\n  data: {\n    labels: ["-                , labels-                , "],\n    datasets: [{\n      label: \"Median\",\n      data: ["-                , medians-                , "],\n      backgroundColor: \"rgba(75, 192, 192, 0.6)\",\n      borderColor: \"rgba(75, 192, 192, 1)\",\n      borderWidth: 1\n    }]\n  },\n"-                , "  options: { title: { display: true, text: \""-                , chartTitle-                , " (showing medians)\" }, scales: { yAxes: [{ ticks: { beginAtZero: true } }] } }\n})}, 100);"-                ]-    return $ HtmlPlot $ wrapInHTML chartId jsCode spec.size+        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@@ -479,12 +397,11 @@ {- | Concatenate a histogram for every numeric column in the frame. Useful as a one-shot exploratory summary. -}-allHistograms :: (HasCallStack) => DataFrame -> IO HtmlPlot+allHistograms :: (HasCallStack) => DataFrame -> IO String allHistograms df = do     let cols = filter (isNumericColumn df) (columnNames df)     xs <- forM cols $ \c -> histogram (mkHistogram c) df-    let allPlots = L.foldl' (\acc (HtmlPlot contents) -> acc <> "\n" <> contents) "" xs-    return (HtmlPlot allPlots)+    return $ L.intercalate "\n" xs  -- --------------------------------------------------------------------------- -- Title helper@@ -499,29 +416,29 @@ -- Deprecated legacy entry points -- --------------------------------------------------------------------------- -plotHistogram :: (HasCallStack) => T.Text -> DataFrame -> IO HtmlPlot+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 HtmlPlot+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 HtmlPlot+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 HtmlPlot+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 HtmlPlot+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 HtmlPlot+plotBoxPlots :: (HasCallStack) => [T.Text] -> DataFrame -> IO String plotBoxPlots ys = box (mkBox ys) {-# DEPRECATED plotBoxPlots "use 'box (mkBox cols)' instead" #-} @@ -529,8 +446,8 @@ -- Browser launcher -- --------------------------------------------------------------------------- -showInDefaultBrowser :: HtmlPlot -> IO ()-showInDefaultBrowser (HtmlPlot p) = do+showInDefaultBrowser :: String -> IO ()+showInDefaultBrowser p = do     plotId <- generateChartId     home <- getHomeDirectory     let path = "plot-" <> T.unpack plotId <> ".html"@@ -540,7 +457,7 @@                 else home <> "/" <> path     putStr "Saving plot to: "     putStrLn fullPath-    T.writeFile fullPath p+    T.writeFile fullPath (T.pack p)     case os of         "mingw32" -> openFileSilently "start" fullPath         "darwin" -> openFileSilently "open" fullPath