packages feed

granite-0.5.0.0: src/Granite.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE Strict #-}

{- |
Module      : Granite
Copyright   : (c) 2025
License     : MIT
Maintainer  : mschavinda@gmail.com
Stability   : experimental
Portability : POSIX

A terminal-based plotting library that renders beautiful charts using Unicode
Braille characters and ANSI colors. Granite provides a variety of chart types
including scatter plots, line graphs, bar charts, pie charts, histograms,
heatmaps, and box plots.

= Basic Usage

Create a simple scatter plot:

@
{\-# LANGUAGE OverloadedStrings #-\}
import Granite
import Data.Text.IO as T

main = do
  let points = [(x, sin x) | x <- [0, 0.1 .. 6.28]]
      chart = scatter [series "sin(x)" points] defPlot
  T.putStrLn chart
@

= Customization

Plots can be customized using record update syntax:

@
let customPlot = defPlot
      { widthChars = 80
      , heightChars = 30
      , plotTitle = "My Chart"
      , legendPos = LegendBottom
      }
@

= Terminal Requirements

This library requires a terminal that supports:

  * Unicode (specifically Braille patterns U+2800-U+28FF)
  * ANSI color codes
  * Monospace font with proper Braille character rendering
-}
module Granite (
    -- * Plot Configuration
    Plot (..),
    defPlot,
    LegendPos (..),

    -- * Formatting
    Color (..),
    LabelFormatter,
    AxisEnv (..),

    -- * Data Preparation
    series,
    bins,
    Bins (..),

    -- * Chart Types
    histogram,
    bars,
    scatter,
    pie,
    stackedBars,
    heatmap,
    lineGraph,
    boxPlot,

    -- * Plotly-Express-style helpers
    area,
    ribbon,
    density,
    errorBars,
    funnel,
    polarLine,
    waterfall,
    distPlot,
) where

import Data.Bits (xor, (.&.))
import Data.List qualified as List
import Data.Maybe
import Data.Text (Text)
import Data.Text qualified as Text
import Numeric (showEFloat, showFFloat)
import Text.Printf

import Granite.Color (Color (..), paint, paletteColors, pieColors)
import Granite.Internal.LegacyChart qualified as LC
import Granite.Internal.Util (
    addAt,
    angleWithin,
    clamp,
    ellipsisize,
    eps,
    gridWidth,
    justifyRight,
    maximum',
    minimum',
    mod',
    normalize,
    quartiles,
    setAt,
    ticks1D,
    updateAt,
    wcswidth,
 )
import Granite.Render.Pipeline (renderChartTerminal)
import Granite.Render.Terminal (
    Canvas (..),
    fillDotsC,
    lineDotsC,
    newCanvas,
    renderCanvas,
    setDotC,
 )

-- | Position of the legend in the plot.
data LegendPos
    = -- | Display legend on the right side of the plot
      LegendRight
    | -- | Display legend below the plot
      LegendBottom
    | -- | Do not display legend.
      LegendNone
    deriving (Eq, Show)

{- | Plot configuration parameters.

Controls the appearance and layout of generated charts.
-}
data Plot = Plot
    { widthChars :: Int
    -- ^ Width of the plot area in terminal characters (default: 60)
    , heightChars :: Int
    -- ^ Height of the plot area in terminal characters (default: 20)
    , leftMargin :: Int
    -- ^ Space reserved for y-axis labels (default: 6)
    , bottomMargin :: Int
    -- ^ Space reserved for x-axis labels (default: 2)
    , titleMargin :: Int
    -- ^ Space above the plot for the title (default: 1)
    , xBounds :: (Maybe Double, Maybe Double)
    {- ^ Optional manual x-axis bounds (min, max).
    'Nothing' uses automatic bounds with 5% padding.
    -}
    , yBounds :: (Maybe Double, Maybe Double)
    {- ^ Optional manual y-axis bounds (min, max).
    'Nothing' uses automatic bounds with 5% padding.
    -}
    , plotTitle :: Text
    -- ^ Title displayed above the plot (default: empty)
    , legendPos :: LegendPos
    -- ^ Position of the legend (default: 'LegendRight')
    , colorPalette :: [Color]
    -- ^ Color palette that'll be used by the plot.
    , xFormatter :: LabelFormatter
    -- ^ Formatter for x-axis labels.
    , yFormatter :: LabelFormatter
    -- ^ Formatter for y-axis labels.
    , xNumTicks :: Int
    -- ^ Number of ticks on the x axis.
    , yNumTicks :: Int
    -- ^ Number of ticks on the y axis.
    }

{- | Default plot configuration.

Creates a 60×20 character plot with reasonable defaults:

@
defPlot = Plot
  { widthChars   = 60
  , heightChars  = 20
  , leftMargin   = 6
  , bottomMargin = 2
  , titleMargin  = 1
  , xBounds      = (Nothing, Nothing)
  , yBounds      = (Nothing, Nothing)
  , plotTitle    = ""
  , legendPos    = LegendRight
  , colorPalette = [ BrightBlue, BrightMagenta, BrightCyan, BrightGreen, BrightYellow, BrightRed, BrightWhite, BrightBlack]
  , xFormatter   = \\ _ _ v -> show v
  , yFormatter   = \\ _ _ v -> show v
  , xNumTicks    = 2
  , yNumTicks    = 2
  }
@
-}
defPlot :: Plot
defPlot =
    Plot
        { widthChars = 60
        , heightChars = 20
        , leftMargin = 6
        , bottomMargin = 2
        , titleMargin = 1
        , xBounds = (Nothing, Nothing)
        , yBounds = (Nothing, Nothing)
        , plotTitle = ""
        , legendPos = LegendRight
        , colorPalette = paletteColors
        , xFormatter = fmt
        , yFormatter = fmt
        , xNumTicks = 3
        , yNumTicks = 3
        }

{- | Axis-aware, width-limited, tick-label formatter.

Given:

   * axis context
   * a per-tick width budget (in terminal cells)
   * and the raw tick value.

returns the label to render.
-}
type LabelFormatter =
    -- | Axis context (domain, tick index/count, etc)
    AxisEnv ->
    -- | Slot width budget in characters for this tick.
    Int ->
    -- | Raw data value for the tick
    Double ->
    -- | Rendered label (if it doesn't fit in the slot it will be truncated)
    Text.Text

-- | What the formatter gets to know about the axis/ticks
data AxisEnv = AxisEnv
    { domain :: (Double, Double)
    -- ^ min/max of the axis in data space
    , tickIndex :: Int
    -- ^ index of THIS tick [0..tickCount-1]
    , tickCount :: Int
    -- ^ total number of ticks
    }

{- | Create a named data series for multi-series plots.

@
let s1 = series "Dataset A" [(1,2), (2,4), (3,6)]
    s2 = series "Dataset B" [(1,3), (2,5), (3,7)]
    chart = scatter [s1, s2] defPlot
@
-}
series ::
    -- | Name of the series (appears in legend)
    Text ->
    -- | List of (x, y) data points
    [(Double, Double)] ->
    (Text, [(Double, Double)])
series = (,)

{- | Create a scatter plot from multiple data series.

Each series is rendered with a different color and pattern.
Points are plotted using Braille characters for sub-character resolution.

==== __Example__

@
let points1 = [(x, x^2) | x <- [-3, -2.5 .. 3]]
    points2 = [(x, 2*x + 1) | x <- [-3, -2.5 .. 3]]
    chart = scatter [series "y = x²" points1,
                     series "y = 2x + 1" points2] defPlot
@
-}
scatter ::
    -- | List of named data series
    [(Text, [(Double, Double)])] ->
    -- | Plot configuration
    Plot ->
    -- | Rendered chart as Text
    Text
scatter sers cfg =
    let wC = widthChars cfg
        hC = heightChars cfg
        plotC = newCanvas wC hC
        (xmin, xmax, ymin, ymax) = boundsXY cfg (concatMap snd sers)
        sx x =
            clamp 0 (wC * 2 - 1) $
                round ((x - xmin) / (xmax - xmin + eps) * fromIntegral (wC * 2 - 1))
        sy y =
            clamp 0 (hC * 4 - 1) $
                round ((ymax - y) / (ymax - ymin + eps) * fromIntegral (hC * 4 - 1))
        pats = cycle palette
        cols = cycle (colorPalette cfg)
        withSty = zipWith3 (\(n, ps) p c -> (n, ps, p, c)) sers pats cols
        drawOne c0 (_name, pts, pat, col) =
            List.foldl'
                ( \c (x, y) ->
                    let xd = sx x; yd = sy y
                     in if ink pat xd yd then setDotC c xd yd (Just col) else c
                )
                c0
                pts
        cDone = List.foldl' drawOne plotC withSty
        ax = axisify cfg cDone (xmin, xmax) (ymin, ymax)
        legend =
            legendBlock
                (legendPos cfg)
                (leftMargin cfg + widthChars cfg)
                [(n, p, col) | (n, _, p, col) <- withSty]
     in drawFrame cfg ax legend

{- | Create a line graph connecting data points.

Similar to 'scatter' but connects consecutive points with lines.
Points are automatically sorted by x-coordinate before connecting.

==== __Example__

@
let sine = [(x, sin x) | x <- [0, 0.1 .. 2*pi]]
    cosine = [(x, cos x) | x <- [0, 0.1 .. 2*pi]]
    chart = lineGraph [series "sin" sine, series "cos" cosine] defPlot
@
-}
lineGraph ::
    -- | List of named data series
    [(Text, [(Double, Double)])] ->
    -- | Plot configuration
    Plot ->
    -- | Rendered chart as Text
    Text
lineGraph sers cfg =
    let wC = widthChars cfg
        hC = heightChars cfg
        plotC = newCanvas wC hC
        (xmin, xmax, ymin, ymax) = boundsXY cfg (concatMap snd sers)
        sx x =
            clamp 0 (wC * 2 - 1) $
                round ((x - xmin) / (xmax - xmin + eps) * fromIntegral (wC * 2 - 1))
        sy y =
            clamp 0 (hC * 4 - 1) $
                round ((ymax - y) / (ymax - ymin + eps) * fromIntegral (hC * 4 - 1))

        cols = cycle (colorPalette cfg)
        withSty = zip sers cols

        drawSeries ((_name, pts), col) c0 =
            let sortedPts = List.sortOn fst pts
                dotPairs = zip sortedPts (drop 1 sortedPts)
             in List.foldl'
                    ( \c ((x1, y1), (x2, y2)) ->
                        lineDotsC (sx x1, sy y1) (sx x2, sy y2) (Just col) c
                    )
                    c0
                    dotPairs

        cDone = List.foldl' (flip drawSeries) plotC withSty
        ax :: Text
        ax = axisify cfg cDone (xmin, xmax) (ymin, ymax)
        legend :: Text
        legend =
            legendBlock
                (legendPos cfg)
                (leftMargin cfg + widthChars cfg)
                [(n, Solid, col) | ((n, _), col) <- withSty]
     in drawFrame cfg ax legend

{- | Create a bar chart from categorical data.

Each bar is colored differently and labeled with its category name.

==== __Example__

@
let fruits = [(\"Apple\", 45.2), (\"Banana\", 38.1), (\"Orange\", 52.7)]
    chart = bars fruits defPlot { plotTitle = "Fruit Sales" }
@
-}
bars ::
    -- | List of (category, value) pairs
    [(Text, Double)] ->
    -- | Plot configuration
    Plot ->
    -- | Rendered chart as Text
    Text
bars kvs cfg =
    let wC = widthChars cfg
        hC = heightChars cfg
        (catNames, vals) = unzip kvs
        vmax = maximum' (map abs vals)

        cats :: [(Text, Double, Color)]
        cats =
            [ (name, abs v / vmax, col)
            | ((name, v), col) <- zip kvs (cycle (colorPalette cfg))
            ]

        nCats = min wC (length cats)

        (base, extra) =
            if nCats == 0 then (0, 0) else (wC `div` nCats, wC - wC `div` nCats * nCats)
        widths = [base + (if i < extra then 1 else 0) | i <- [0 .. nCats - 1]]

        catGroups :: [[(String, Maybe Color)]]
        catGroups =
            [ replicate w (colGlyphs hC f, Just col)
            | ((_, f, col), w) <- zip cats widths
            ]

        gutterCol = (replicate hC ' ', Nothing)
        columns = List.intercalate [gutterCol] catGroups

        grid :: [[(Char, Maybe Color)]]
        grid =
            [ [(glyphs !! y, mc) | (glyphs, mc) <- columns]
            | y <- [0 .. hC - 1]
            ]

        ax =
            axisifyGrid
                cfg
                grid
                (0, fromIntegral (max 1 nCats))
                (0, vmax)
                catNames
                (fmap (+ 1) (listToMaybe widths))
        legendWidth = leftMargin cfg + 1 + gridWidth grid
        legend =
            legendBlock
                (legendPos cfg)
                legendWidth
                [(name, Checker, col) | (name, _, col) <- cats]
     in drawFrame cfg ax legend

{- | Create a stacked bar chart.

Each category can have multiple stacked components.

==== __Example__

@
let sales = [(\"Q1\", [(\"Product A\", 100), (\"Product B\", 150)]),
             (\"Q2\", [(\"Product A\", 120), (\"Product B\", 180)])]
    chart = stackedBars sales defPlot
@
-}
stackedBars ::
    -- | Categories with stacked components
    [(Text, [(Text, Double)])] ->
    -- | Plot configuration
    Plot ->
    -- | Rendered chart as Text
    Text
stackedBars categories cfg =
    let wC = widthChars cfg
        hC = heightChars cfg

        seriesNames = case categories of
            [] -> []
            (c : _) -> map fst (snd c)

        totals = [sum (map snd series') | (_, series') <- categories]
        maxHeight = maximum (1e-12 : totals)

        nCats = length categories
        (base, extra) =
            if nCats == 0
                then (0, 0)
                else (wC `div` nCats, wC - wC `div` nCats * nCats)
        widths = [base + (if i < extra then 1 else 0) | i <- [0 .. nCats - 1]]

        cols = cycle (colorPalette cfg)
        seriesColors = zip seriesNames cols

        makeBar (_, series') width =
            let cumHeights = scanl (+) 0 [v / maxHeight | (_, v) <- series']
                segments = zip3 (map fst series') cumHeights (drop 1 cumHeights)

                makeColumn :: [(Char, Maybe Color)]
                makeColumn =
                    [ let heightFromBottom = fromIntegral (hC - y) / fromIntegral hC
                          findSegment [] = (' ', Nothing)
                          findSegment ((name, bottom, top) : rest) =
                            if heightFromBottom > bottom && heightFromBottom <= top
                                then ('█', lookup name seriesColors)
                                else findSegment rest
                       in findSegment segments
                    | y <- [0 .. hC - 1]
                    ]
             in replicate width makeColumn

        gutterCol = replicate hC (' ', Nothing)
        allBars = zipWith makeBar categories widths
        columns = List.intercalate [gutterCol] allBars

        grid = [[col !! y | col <- columns] | y <- [0 .. hC - 1]]

        ax :: Text
        ax =
            axisifyGrid
                cfg
                grid
                (0, fromIntegral (max 1 nCats))
                (0, maxHeight)
                (map fst categories)
                (fmap (+ 1) (listToMaybe widths))
        legend :: Text
        legend =
            legendBlock
                (legendPos cfg)
                ( leftMargin cfg
                    + 1
                    + gridWidth grid
                )
                [(name, Solid, col) | (name, col) <- seriesColors]
     in drawFrame cfg ax legend

-- | Defines the binning parameters.
data Bins = Bins
    { nBins :: Int
    , lo :: Double
    , hi :: Double
    }
    deriving (Eq, Show)

{- | Create a bin configuration for histograms.

@
bins 10 0 100  -- 10 bins from 0 to 100
bins 20 (-5) 5 -- 20 bins from -5 to 5
@
-}
bins :: Int -> Double -> Double -> Bins
bins n a b = Bins (max 1 n) (min a b) (max a b)

{- | Create a histogram from numerical data.

Data is binned according to the provided 'Bins' configuration.

==== __Example__

@
import System.Random

-- Generate random normal-like distribution
let values = take 1000 $ randomRs (0, 100) gen
    chart = histogram (bins 20 0 100) values defPlot
@
-}
histogram ::
    -- | Binning configuration
    Bins ->
    -- | Raw data values to bin
    [Double] ->
    -- | Plot configuration
    Plot ->
    -- | Rendered chart as Text
    Text
histogram (Bins n a b) xs cfg =
    let step = (b - a) / fromIntegral n
        binIx x = clamp 0 (n - 1) $ floor ((x - a) / step)
        counts =
            List.foldl'
                ( \acc x ->
                    if x < a || x > b
                        then acc
                        else addAt acc (binIx x) 1
                )
                (replicate n 0 :: [Int])
                xs
        maxC = fromIntegral (maximum (1 : counts))
        fracs0 = [fromIntegral c / maxC | c <- counts]

        wData = widthChars cfg
        hC = heightChars cfg
        colsF = resampleToWidth wData fracs0

        dataCols = [(colGlyphs hC f, Just BrightCyan) | f <- colsF]
        grid :: [[(Char, Maybe Color)]]
        grid =
            [ [(fst col !! y, snd col) | col <- dataCols]
            | y <- [0 .. hC - 1]
            ]

        ax =
            axisifyGrid cfg grid (a, b) (0, maxC) [] Nothing
        legendWidth = leftMargin cfg + 1 + gridWidth grid
        legend = legendBlock (legendPos cfg) legendWidth [("count", Solid, BrightCyan)]
     in drawFrame cfg ax legend

{- | Create a pie chart showing proportions.

Values are normalized to sum to 100%. Negative values are treated as zero.

==== __Example__

@
let browsers = [(\"Chrome\", 65), (\"Firefox\", 20), (\"Safari\", 10), (\"Other\", 5)]
    chart = pie browsers defPlot { plotTitle = "Browser Market Share" }
@
-}
pie ::
    -- | List of (category, value) pairs
    [(Text, Double)] ->
    -- | Plot configuration
    Plot ->
    -- | Rendered chart as Text
    Text
pie parts0 cfg =
    let parts = normalize parts0
        wC = widthChars cfg
        hC = heightChars cfg
        plotC = newCanvas wC hC
        wDots = wC * 2
        hDots = hC * 4
        r = min (wDots `div` 2 - 2) (hDots `div` 2 - 2)
        cx = wDots `div` 2
        cy = hDots `div` 2
        toAng p = p * 2 * pi
        wedges = scanl (\a (_, p) -> a + toAng p) 0 parts
        angles = zip wedges (drop 1 wedges)
        names = map fst parts
        cols = cycle pieColors
        withP :: [(Text, (Double, Double), Color)]
        withP = zip3 names angles cols

        drawOne (_name, (a0, a1), col) c0 =
            let inside x y =
                    let dx = fromIntegral (x - cx)
                        dy = fromIntegral (cy - y)
                        rr2 = dx * dx + dy * dy
                        r2 = fromIntegral (r * r)
                        ang = atan2 dy dx `mod'` (2 * pi)
                     in rr2 <= r2 && angleWithin ang a0 a1
             in fillDotsC (cx - r, cy - r) (cx + r, cy + r) inside (Just col) c0

        cDone = List.foldl' (flip drawOne) plotC withP
        ax = axisify cfg cDone (0, 1) (0, 1)
        legend =
            legendBlock
                (legendPos cfg)
                (leftMargin cfg + widthChars cfg)
                [(n, Solid, col) | (n, _, col) <- withP]
     in drawFrame cfg ax legend

{- | Create a heatmap visualization of a 2D matrix.

Values are mapped to a color gradient from blue (low) to red (high).

==== __Example__

@
let matrix = [[x * y | x <- [1..10]] | y <- [1..10]]
    chart = heatmap matrix defPlot { plotTitle = "Multiplication Table" }
@
-}
heatmap ::
    -- | 2D matrix of values (rows × columns)
    [[Double]] ->
    -- | Plot configuration
    Plot ->
    -- | Rendered chart as Text
    Text
heatmap matrix cfg =
    let rows = length matrix
        cols = gridWidth matrix

        allVals = concat matrix
        vmin = if null allVals then 0 else minimum' allVals
        vmax = if null allVals then 1 else maximum' allVals
        vrange = vmax - vmin

        intensityColors =
            [ Blue
            , BrightBlue
            , Cyan
            , BrightCyan
            , Green
            , BrightGreen
            , Yellow
            , BrightYellow
            , Magenta
            , BrightRed
            , Red
            ]

        colorForValue v =
            if vrange < eps
                then Green
                else
                    let norm = clamp 0 1 ((v - vmin) / vrange)
                        idx = floor (norm * fromIntegral (length intensityColors - 1))
                        idx' = clamp 0 (length intensityColors - 1) idx
                     in intensityColors !! idx'

        displayGrid =
            [ [ let
                    matrixRow = min (rows - 1) ((plotH - 1 - i) * rows `div` plotH)
                    matrixCol = min (cols - 1) (j * cols `div` plotW)
                    val = matrix !! matrixRow !! matrixCol
                 in
                    ('█', Just (colorForValue val))
              | j <- [0 .. plotW - 1]
              ]
            | i <- [0 .. plotH - 1]
            ]

        plotW = widthChars cfg
        plotH = heightChars cfg

        colLabels = [Text.pack (show i) | i <- [0 .. cols - 1]]
        rowLabels = [Text.pack (show i) | i <- reverse [0 .. rows - 1]]

        cellHeight = fromIntegral plotH / fromIntegral rows
        yTicks =
            [ ( round @Double @Int (fromIntegral i * cellHeight + cellHeight / 2)
              , rowLabels !! i
              )
            | i <- [0 .. rows - 1]
            ]

        left = leftMargin cfg
        baseLbl = replicate plotH (Text.replicate left " ")
        yLabels =
            List.foldl'
                ( \acc (row, lbl) ->
                    setAt acc row (justifyRight left (ellipsisize left lbl))
                )
                baseLbl
                yTicks

        renderRow cells =
            Text.concat
                (fmap (\(ch, mc) -> maybe (Text.singleton ch) (`paint` ch) mc) cells)
        attachY = zipWith (\lbl cells -> lbl <> "│" <> renderRow cells) yLabels displayGrid

        xBar = Text.replicate left " " <> "└" <> Text.replicate plotW "─"
        xLine =
            placeGridLabels
                (Text.replicate (left + 1) " ")
                (plotW `div` cols)
                colLabels

        ax = Text.unlines (attachY <> [xBar, xLine])

        gradientLegend =
            Text.pack (printf "%.2f " vmin)
                <> Text.concat (fmap (`paint` '█') intensityColors)
                <> Text.pack (printf " %.2f" vmax)
     in drawFrame cfg ax gradientLegend

{- | Create a box plot showing statistical distributions.

Displays quartiles, median, and min/max values for each dataset.

==== __Example__

@
let data1 = [1.2, 2.3, 2.1, 3.4, 2.8, 4.1, 3.9]
    data2 = [5.1, 4.8, 6.2, 5.9, 7.1, 6.5, 5.5]
    chart = boxPlot [("Group A", data1), ("Group B", data2)] defPlot
@

The box plot displays:

  * Box: First quartile (Q1) to third quartile (Q3)
  * Line inside box: Median (Q2)
  * Whiskers: Minimum and maximum values
-}
boxPlot ::
    -- | Named datasets
    [(Text, [Double])] ->
    -- | Plot configuration
    Plot ->
    -- | Rendered chart as Text
    Text
boxPlot datasets cfg =
    let wC = widthChars cfg
        hC = heightChars cfg

        stats = [(name, quartiles vals) | (name, vals) <- datasets]

        allVals = concatMap snd datasets
        ymin = if null allVals then 0 else minimum' allVals - abs (minimum' allVals) * 0.1
        ymax = if null allVals then 1 else maximum' allVals + abs (maximum' allVals) * 0.1

        nBoxes = length datasets
        boxWidth = if nBoxes == 0 then 1 else max 1 (wC `div` (nBoxes * 2))
        spacing = if nBoxes <= 1 then 0 else (wC - boxWidth * nBoxes) `div` (nBoxes - 1)

        scaleY v =
            clamp 0 (hC - 1) $
                round ((ymax - v) / (ymax - ymin + eps) * fromIntegral (hC - 1))

        emptyGrid = replicate hC (replicate wC (' ', Nothing))

        drawBox grid (idx, (_name, (minV, q1, median, q3, maxV))) =
            let xStart = idx * (boxWidth + spacing)
                xMid = xStart + boxWidth `div` 2
                xEnd = xStart + boxWidth - 1

                minRow = scaleY minV
                q1Row = scaleY q1
                medRow = scaleY median
                q3Row = scaleY q3
                maxRow = scaleY maxV

                col = pieColors !! (idx `mod` length pieColors)

                grid1 = drawVLine grid xMid minRow q1Row '│' (Just col)
                grid2 = drawVLine grid1 xMid q3Row maxRow '│' (Just col)

                grid3 = drawHLine grid2 xStart xEnd q1Row '─' (Just col)
                grid4 = drawHLine grid3 xStart xEnd q3Row '─' (Just col)
                grid5 = drawVLine grid4 xStart q1Row q3Row '│' (Just col)
                grid6 = drawVLine grid5 xEnd q1Row q3Row '│' (Just col)

                grid7 = drawHLine grid6 xStart xEnd medRow '═' (Just col)

                grid8 = setGridChar grid7 xMid minRow '┴' (Just col)
                grid9 = setGridChar grid8 xMid maxRow '┬' (Just col)
             in grid9

        finalGrid = List.foldl' drawBox emptyGrid (zip [0 ..] stats)

        left = leftMargin cfg
        baseLbl = replicate hC (Text.replicate left " ")

        yTicks = ticks1D hC (yNumTicks cfg) (ymin, ymax) True
        yEnv n = AxisEnv (ymin, ymax) n (yNumTicks cfg)
        ySlot = max 1 left
        yLabels =
            List.foldl'
                ( \acc (row, v) ->
                    setAt acc row . ellipsisize left . justifyRight left $
                        yFormatter cfg (yEnv row) ySlot v
                )
                baseLbl
                yTicks

        renderRow cells =
            Text.concat
                (fmap (\(ch, mc) -> maybe (Text.singleton ch) (`paint` ch) mc) cells)
        attachY = zipWith (\lbl cells -> lbl <> "│" <> renderRow cells) yLabels finalGrid

        xBar = Text.replicate left " " <> "└" <> Text.replicate wC "─"

        xLine =
            List.foldl'
                ( \acc (idx, name) ->
                    let boxCenter = idx * (boxWidth + spacing) + boxWidth `div` 2
                        lblWidth = wcswidth name
                        lblStart = left + 1 + boxCenter - lblWidth `div` 2
                     in Text.take lblStart acc <> name <> Text.drop (lblStart + wcswidth name) acc
                )
                (Text.replicate (left + 1 + wC) " ")
                (zip [0 ..] (map fst datasets))

        ax = Text.unlines (attachY <> [xBar, xLine])

        legend =
            legendBlock
                (legendPos cfg)
                (leftMargin cfg + widthChars cfg)
                [ (name, Solid, pieColors !! (i `mod` length pieColors))
                | (i, (name, _)) <- zip [0 ..] stats
                ]
     in drawFrame cfg ax legend
  where
    drawVLine grid x y1 y2 ch col =
        let yStart = min y1 y2
            yEnd = max y1 y2
         in List.foldl' (\g y -> setGridChar g x y ch col) grid [yStart .. yEnd]

    drawHLine grid x1 x2 y ch col =
        let xStart = min x1 x2
            xEnd = max x1 x2
         in List.foldl' (\g x -> setGridChar g x y ch col) grid [xStart .. xEnd]

    setGridChar grid x y ch col =
        updateAt grid y (\row -> setAt row x (ch, col))

data Pat = Solid | Checker | DiagA | DiagB | Sparse deriving (Eq, Show)

ink :: Pat -> Int -> Int -> Bool
ink Solid _ _ = True
ink Checker x y = (x `xor` y) .&. 1 == 0
ink DiagA x y = (x + y) `mod` 3 /= 1
ink DiagB x y = (x - y) `mod` 3 /= 1
ink Sparse x y = x .&. 1 == 0 && y `mod` 3 == 0

palette :: [Pat]
palette = [Solid, Checker, DiagA, DiagB, Sparse]

fmt :: AxisEnv -> Int -> Double -> Text
fmt _ _ v
    | abs v >= 10000 || abs v < 0.01 && v /= 0 =
        Text.pack (showEFloat (Just 1) v "")
    | otherwise = Text.pack (showFFloat (Just 1) v "")

drawFrame :: Plot -> Text -> Text -> Text
drawFrame cfg contentWithAxes legendBlockStr =
    Text.unlines $
        filter
            (not . Text.null)
            [plotTitle cfg, contentWithAxes, legendBlockStr]

slotBudget :: Int -> Int -> Int
slotBudget plotPixels numTicks =
    max 1 (plotPixels `div` max 1 numTicks)

axisify :: Plot -> Canvas -> (Double, Double) -> (Double, Double) -> Text
axisify cfg c (xmin, xmax) (ymin, ymax) =
    let plotW = cW c
        plotH = cH c
        left = leftMargin cfg
        pad = Text.replicate left " "

        yTicks :: [(Int, Double)]
        yTicks = ticks1D plotH (yNumTicks cfg) (ymin, ymax) True

        baseLbl :: [Text]
        baseLbl = replicate plotH pad

        yEnv n = AxisEnv (ymin, ymax) n 3
        ySlot = max 1 left
        yLabels =
            List.foldl'
                ( \acc (row, v) ->
                    setAt acc row
                        . ellipsisize left
                        . justifyRight left
                        $ yFormatter cfg (yEnv row) ySlot v
                )
                baseLbl
                yTicks

        canvasLines = Text.lines (renderCanvas c)
        attachY = zipWith (\lbl line -> lbl <> "│" <> line) yLabels canvasLines

        xBar = pad <> "└" <> Text.replicate plotW "─"

        xTicks :: [(Int, Double)]
        xTicks = ticks1D plotW (xNumTicks cfg) (xmin, xmax) False

        xEnv n = AxisEnv (xmin, xmax) n 3
        slotW = slotBudget plotW (max 1 (length xTicks))
        xLine =
            placeLabels
                (Text.replicate (left + 1 + plotW) " ")
                (left + 1)
                [(x, xFormatter cfg (xEnv i) slotW v) | (i, (x, v)) <- zip [0 ..] xTicks]
     in Text.unlines (attachY <> [xBar, xLine])

axisifyGrid ::
    Plot ->
    [[(Char, Maybe Color)]] ->
    (Double, Double) ->
    (Double, Double) ->
    [Text] ->
    Maybe Int ->
    Text
axisifyGrid cfg grid (xmin, xmax) (ymin, ymax) categories w =
    let plotH = length grid
        plotW = gridWidth grid
        left = leftMargin cfg
        pad = Text.replicate left " "

        yTicks = ticks1D plotH (yNumTicks cfg) (ymin, ymax) True
        baseLbl = List.replicate plotH pad

        yEnv n = AxisEnv (ymin, ymax) n (yNumTicks cfg)
        ySlot = max 1 left
        yLabels =
            List.foldl'
                ( \acc (row, v) ->
                    setAt acc row
                        . ellipsisize left
                        . justifyRight left
                        $ yFormatter cfg (yEnv row) ySlot v
                )
                baseLbl
                yTicks

        renderRow :: [(Char, Maybe Color)] -> Text
        renderRow cells =
            Text.concat
                (fmap (\(ch, mc) -> maybe (Text.singleton ch) (`paint` ch) mc) cells)

        attachY = zipWith (\lbl cells -> lbl <> "│" <> renderRow cells) yLabels grid

        xBar = pad <> "└" <> Text.replicate plotW "─"

        hasCategories = not (null categories) && not (all Text.null categories)

        xLine =
            if hasCategories
                then
                    let slotW =
                            fromMaybe
                                ( slotBudget
                                    plotW
                                    (max 1 (xNumTicks cfg))
                                )
                                w
                        nSlots = plotW `div` slotW
                        xTicks = ticks1D plotW nSlots (xmin, xmax) False
                     in placeGridLabels
                            (Text.replicate (left + 1) " ")
                            slotW
                            (keepPercentiles (length categories) (length xTicks + 1) categories)
                else
                    let xTicks = ticks1D plotW (xNumTicks cfg) (xmin, xmax) False
                        xEnv i = AxisEnv (xmin, xmax) i (xNumTicks cfg)
                        slotW = slotBudget plotW (max 1 (length xTicks))
                     in placeLabels
                            (Text.replicate (left + 1 + plotW) " ")
                            (left + 1)
                            [(x, xFormatter cfg (xEnv i) slotW v) | (i, (x, v)) <- zip [0 ..] xTicks]
     in Text.unlines (attachY <> [xBar, xLine])

keepPercentiles :: Int -> Int -> [Text] -> [Text]
keepPercentiles n k xs
    | k <= 0 = []
    | null xs = replicate k ""
    | n <= 1 = replicate k ""
    | otherwise = map valueAt [0 .. k - 2] ++ [last xs]
  where
    m = length xs
    pairs :: [(Int, Text)]
    pairs =
        [ ( slotIx
          , xs !! srcIx
          )
        | i <- [0 .. n - 2]
        , let srcIx = (i * (m - 1)) `div` (n - 1)
        , let slotIx = (i * (k - 1)) `div` (n - 1)
        ]

    valueAt :: Int -> Text
    valueAt i = fromMaybe "" $ List.lookup i pairs

placeLabels :: Text -> Int -> [(Int, Text)] -> Text
placeLabels base off = List.foldl' place base
  where
    place :: Text -> (Int, Text) -> Text
    place acc (x, s) =
        let i = off + x
         in Text.take i acc <> s <> Text.drop (i + wcswidth s) acc

placeGridLabels :: Text -> Int -> [Text] -> Text
placeGridLabels base slotW = List.foldl' place base
  where
    place :: Text -> Text -> Text
    place acc s =
        let lblWidth = wcswidth s
            padding = max 0 ((slotW - lblWidth) `div` 2)
            centered = Text.replicate padding " " <> s
         in acc <> Text.take slotW (centered <> Text.replicate slotW " ")

legendBlock :: LegendPos -> Int -> [(Text, Pat, Color)] -> Text
legendBlock LegendBottom width entries =
    let cells = [sample pat col <> " " <> name | (name, pat, col) <- entries]
        line = Text.intercalate "   " cells
        pad =
            let vis = wcswidth line
             in if vis < width then Text.replicate ((width - vis) `div` 2) " " else ""
     in pad <> line
legendBlock LegendRight _ entries =
    Text.unlines $
        fmap (\(name, pat, col) -> sample pat col <> " " <> name) entries
legendBlock LegendNone _ _ = ""

sample :: Pat -> Color -> Text
sample p col =
    let c =
            List.foldl'
                ( \cv (dx, dy) -> if ink p dx dy then setDotC cv (dx `mod` 2) (dy `mod` 4) (Just col) else cv
                )
                (newCanvas 1 1)
                [(x, y) | y <- [0 .. 3], x <- [0 .. 1]]
        s = renderCanvas c
     in Text.dropWhileEnd (== '\n') s

boundsXY :: Plot -> [(Double, Double)] -> (Double, Double, Double, Double)
boundsXY cfg pts =
    let (xs, ys) = unzip pts
        xmin = minimum' xs
        xmax = maximum' xs
        ymin = minimum' ys
        ymax = maximum' ys
        padx = (xmax - xmin) * 0.05 + 1e-9
        pady = (ymax - ymin) * 0.05 + 1e-9
     in ( fromMaybe (xmin - padx) (fst (xBounds cfg))
        , fromMaybe (xmax + padx) (snd (xBounds cfg))
        , fromMaybe (ymin - pady) (fst (yBounds cfg))
        , fromMaybe (ymax + pady) (snd (yBounds cfg))
        )

blockChar :: Int -> Char
blockChar n = case clamp 0 8 n of
    0 -> ' '
    1 -> '▁'
    2 -> '▂'
    3 -> '▃'
    4 -> '▄'
    5 -> '▅'
    6 -> '▆'
    7 -> '▇'
    _ -> '█'

colGlyphs :: Int -> Double -> String
colGlyphs hC frac =
    let total = hC * 8
        ticks = clamp 0 total (round (frac * fromIntegral total))
        full = ticks `div` 8
        rem8 = ticks - full * 8
        topPad = hC - full - (if rem8 > 0 then 1 else 0)
        middle = [blockChar rem8 | rem8 > 0]
     in replicate topPad ' ' <> middle <> replicate full '█'

resampleToWidth :: Int -> [Double] -> [Double]
resampleToWidth w xs
    | w <= 0 = []
    | null xs = replicate w 0
    | n == w = xs
    | n > w = avgGroup (ceiling (fromIntegral n / (fromIntegral w :: Double)))
    | otherwise = replicateOut
  where
    n = length xs
    avgGroup g =
        [avg (take g (drop (i * g) xs)) | i <- [0 .. w - 1]]
      where
        avg ys = if null ys then 0 else sum ys / fromIntegral (length ys)
    replicateOut =
        let base = w `div` n
            extra = w - base * n
         in concat
                [ replicate (base + (if i < extra then 1 else 0)) v
                | (i, v) <- zip [0 ..] xs
                ]

-- | Filled-area chart with the curve closed down to @y=0@.
area :: [(Text, [(Double, Double)])] -> Plot -> Text
area sers cfg =
    renderChartTerminal $
        LC.areaChart sers (widthChars cfg) (heightChars cfg) (plotTitle cfg)

-- | Filled band between @(x, ymin, ymax)@ curves — e.g. CI envelopes.
ribbon :: [(Text, [(Double, Double, Double)])] -> Plot -> Text
ribbon sers cfg =
    renderChartTerminal $
        LC.ribbonChart sers (widthChars cfg) (heightChars cfg) (plotTitle cfg)

-- | Gaussian KDE per series (Silverman bandwidth).
density :: [(Text, [Double])] -> Plot -> Text
density sers cfg =
    renderChartTerminal $
        LC.densityChart sers (widthChars cfg) (heightChars cfg) (plotTitle cfg)

-- | Points with vertical error bars: @(x, y, ymin, ymax)@ per row.
errorBars :: [(Text, [(Double, Double, Double, Double)])] -> Plot -> Text
errorBars sers cfg =
    renderChartTerminal $
        LC.errorBarsChart sers (widthChars cfg) (heightChars cfg) (plotTitle cfg)

-- | Horizontal bars sized by their values.
funnel :: [(Text, Double)] -> Plot -> Text
funnel stages cfg =
    renderChartTerminal $
        LC.funnelChart stages (widthChars cfg) (heightChars cfg) (plotTitle cfg)

-- | Polar line chart; theta in radians CCW from +x.
polarLine :: [(Text, [(Double, Double)])] -> Plot -> Text
polarLine sers cfg =
    renderChartTerminal $
        LC.polarLineChart sers (widthChars cfg) (heightChars cfg) (plotTitle cfg)

-- | Waterfall chart: rows are @(label, start, end)@.
waterfall :: [(Text, Double, Double)] -> Plot -> Text
waterfall rows cfg =
    renderChartTerminal $
        LC.waterfallChart rows (widthChars cfg) (heightChars cfg) (plotTitle cfg)

-- | Histogram + KDE overlay per series.
distPlot :: [(Text, [Double])] -> Plot -> Text
distPlot sers cfg =
    renderChartTerminal $
        LC.distPlotChart sers (widthChars cfg) (heightChars cfg) (plotTitle cfg)