packages feed

granite 0.2.0.1 → 0.2.0.2

raw patch · 6 files changed

+1704/−1079 lines, 6 filesPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

API changes (from Hackage documentation)

- Granite: instance GHC.Classes.Eq Granite.Plot
- Granite: instance GHC.Show.Show Granite.Plot
+ Granite: Bins :: Int -> Double -> Double -> Bins
+ Granite: Black :: Color
+ Granite: Blue :: Color
+ Granite: BrightBlack :: Color
+ Granite: BrightBlue :: Color
+ Granite: BrightCyan :: Color
+ Granite: BrightGreen :: Color
+ Granite: BrightMagenta :: Color
+ Granite: BrightRed :: Color
+ Granite: BrightWhite :: Color
+ Granite: BrightYellow :: Color
+ Granite: Cyan :: Color
+ Granite: Default :: Color
+ Granite: Green :: Color
+ Granite: Magenta :: Color
+ Granite: Red :: Color
+ Granite: White :: Color
+ Granite: Yellow :: Color
+ Granite: [colorPalette] :: Plot -> [Color]
+ Granite: [hi] :: Bins -> Double
+ Granite: [lo] :: Bins -> Double
+ Granite: [nBins] :: Bins -> Int
+ Granite: [xFormatter] :: Plot -> Int -> Double -> Text
+ Granite: [yFormatter] :: Plot -> Int -> Double -> Text
+ Granite: data Bins
+ Granite: data Color
+ Granite.String: Black :: Color
+ Granite.String: Blue :: Color
+ Granite.String: BrightBlack :: Color
+ Granite.String: BrightBlue :: Color
+ Granite.String: BrightCyan :: Color
+ Granite.String: BrightGreen :: Color
+ Granite.String: BrightMagenta :: Color
+ Granite.String: BrightRed :: Color
+ Granite.String: BrightWhite :: Color
+ Granite.String: BrightYellow :: Color
+ Granite.String: Cyan :: Color
+ Granite.String: Default :: Color
+ Granite.String: Green :: Color
+ Granite.String: LegendBottom :: LegendPos
+ Granite.String: LegendRight :: LegendPos
+ Granite.String: Magenta :: Color
+ Granite.String: Plot :: Int -> Int -> Int -> Int -> Int -> (Maybe Double, Maybe Double) -> (Maybe Double, Maybe Double) -> String -> LegendPos -> [Color] -> (Int -> Double -> String) -> (Int -> Double -> String) -> Plot
+ Granite.String: Red :: Color
+ Granite.String: White :: Color
+ Granite.String: Yellow :: Color
+ Granite.String: [bottomMargin] :: Plot -> Int
+ Granite.String: [colorPalette] :: Plot -> [Color]
+ Granite.String: [heightChars] :: Plot -> Int
+ Granite.String: [leftMargin] :: Plot -> Int
+ Granite.String: [legendPos] :: Plot -> LegendPos
+ Granite.String: [plotTitle] :: Plot -> String
+ Granite.String: [titleMargin] :: Plot -> Int
+ Granite.String: [widthChars] :: Plot -> Int
+ Granite.String: [xBounds] :: Plot -> (Maybe Double, Maybe Double)
+ Granite.String: [xFormatter] :: Plot -> Int -> Double -> String
+ Granite.String: [yBounds] :: Plot -> (Maybe Double, Maybe Double)
+ Granite.String: [yFormatter] :: Plot -> Int -> Double -> String
+ Granite.String: bars :: [(String, Double)] -> Plot -> String
+ Granite.String: bins :: Int -> Double -> Double -> Bins
+ Granite.String: boxPlot :: [(String, [Double])] -> Plot -> String
+ Granite.String: data Bins
+ Granite.String: data Color
+ Granite.String: data LegendPos
+ Granite.String: data Plot
+ Granite.String: defPlot :: Plot
+ Granite.String: heatmap :: [[Double]] -> Plot -> String
+ Granite.String: histogram :: Bins -> [Double] -> Plot -> String
+ Granite.String: lineGraph :: [(String, [(Double, Double)])] -> Plot -> String
+ Granite.String: pie :: [(String, Double)] -> Plot -> String
+ Granite.String: scatter :: [(String, [(Double, Double)])] -> Plot -> String
+ Granite.String: series :: String -> [(Double, Double)] -> (String, [(Double, Double)])
+ Granite.String: stackedBars :: [(String, [(String, Double)])] -> Plot -> String
- Granite: Plot :: Int -> Int -> Int -> Int -> Int -> (Maybe Double, Maybe Double) -> (Maybe Double, Maybe Double) -> Text -> LegendPos -> Plot
+ Granite: Plot :: Int -> Int -> Int -> Int -> Int -> (Maybe Double, Maybe Double) -> (Maybe Double, Maybe Double) -> Text -> LegendPos -> [Color] -> (Int -> Double -> Text) -> (Int -> Double -> Text) -> Plot

Files

CHANGELOG.md view
@@ -1,5 +1,8 @@ # Revision history for granite +## 0.2.0.2 -- 2025-08-30+* Add plot option to define the format of labels on both axes.+ ## 0.2.0.1 -- 2025-08-26 * Loosen bounds for text 
README.md view
@@ -1,5 +1,5 @@ # Granite-A library for producing terminal plots. It depends only on Haskell's base and text (only for efficieny but it could use `String`) packages so it should be easy to use and install.+A library for producing terminal plots. It depends only on Haskell's base and text (only for efficieny but it could use `String`. In fact, we expose an API that uses `String` for easier use in GHCi) packages so it should be easy to use and install.  # Supported graph types @@ -18,12 +18,10 @@ ![Scatter Plot](https://github.com/mchav/granite/blob/main/static/scatter_plot.png)  ```haskell-{-# LANGUAGE OverloadedStrings #-} import           Control.Monad-import qualified Data.Text.IO as T import           System.Random.Stateful -import Granite+import Granite.String  main :: IO () main = do@@ -33,8 +31,8 @@   ptsA_y <- replicateM 600 (uniformRM range g)   ptsB_x <- replicateM 600 (uniformRM range g)   ptsB_y <- replicateM 600 (uniformRM range g)-  T.putStrLn (scatter "Random points" [series "A" (zip ptsA_x ptsA_y), series "B" (zip ptsB_x ptsB_y)]-              defPlot{widthChars=68,heightChars=22,plotTitle="Random points"})+  putStrLn (scatter "Random points" [series "A" (zip ptsA_x ptsA_y), series "B" (zip ptsB_x ptsB_y)]+            defPlot{widthChars=68,heightChars=22,plotTitle="Random points"}) ```  ### Bar chart
app/Main.hs view
@@ -1,59 +1,83 @@ {-# LANGUAGE OverloadedStrings #-}+ module Main where  import Control.Monad-import System.Random.Stateful+import Data.Text qualified as Text import Data.Text.IO qualified as Text+import System.Random.Stateful  import Granite  main :: IO () main = do-  -- scatter-  g <- newIOGenM =<< newStdGen-  let range = (0 :: Double, 1 :: Double)-  ptsA_x <- replicateM 600 (uniformRM range g)-  ptsA_y <- replicateM 600 (uniformRM range g)-  ptsB_x <- replicateM 600 (uniformRM range g)-  ptsB_y <- replicateM 600 (uniformRM range g)-  Text.putStrLn $ scatter [series "A" (zip ptsA_x ptsA_y), series "B" (zip ptsB_x ptsB_y)] defPlot{widthChars=68,heightChars=22,plotTitle="Random points"}+    -- scatter+    g <- newIOGenM =<< newStdGen+    let range = (0 :: Double, 1 :: Double)+    ptsA_x <- replicateM 600 (uniformRM range g)+    ptsA_y <- replicateM 600 (uniformRM range g)+    ptsB_x <- replicateM 600 (uniformRM range g)+    ptsB_y <- replicateM 600 (uniformRM range g)+    Text.putStrLn $ scatter [series "A" (zip ptsA_x ptsA_y), series "B" (zip ptsB_x ptsB_y)] defPlot{widthChars = 68, heightChars = 22, plotTitle = "Random points"} -  -- histogram-  heights <- replicateM 5000 (uniformRM (160 :: Double, 190 :: Double) g)-  Text.putStrLn $ histogram (bins 30 155 195) heights defPlot{widthChars=68,heightChars=18,legendPos=LegendBottom, plotTitle="Heights (cm)"}+    -- histogram+    heights <- replicateM 5000 (uniformRM (160 :: Double, 190 :: Double) g)+    Text.putStrLn $+        histogram+            (bins 30 155 195)+            heights+            defPlot+                { widthChars = 68+                , heightChars = 18+                , legendPos = LegendBottom+                , xFormatter = \_ v -> Text.pack (show (round v))+                , plotTitle = "Heights (cm)"+                } -  -- bars-  Text.putStrLn $ bars [("Q1",12),("Q2",18),("Q3",9),("Q4",15)] defPlot { plotTitle = "Sales"}+    -- bars+    Text.putStrLn $ bars [("Q1", 12), ("Q2", 18), ("Q3", 9), ("Q4", 15)] defPlot{plotTitle = "Sales"} -  -- pie-  Text.putStrLn $ pie [("Alpha",0.35),("Beta",0.25),("Gamma",0.20),("Delta",0.20)]-                         defPlot{widthChars=46,heightChars=18,legendPos=LegendRight, plotTitle = "Share"}+    -- pie+    Text.putStrLn $+        pie+            [("Alpha", 0.35), ("Beta", 0.25), ("Gamma", 0.20), ("Delta", 0.20)]+            defPlot{widthChars = 46, heightChars = 18, legendPos = LegendRight, plotTitle = "Share"} -  -- line graph-  Text.putStrLn $ lineGraph [ ("Product A", [(1, 100), (2, 120), (3, 115), (4, 140), (5, 155), (6, 148)])-                                              , ("Product B", [(1, 80), (2, 85), (3, 95), (4, 92), (5, 110), (6, 125)])-                                              , ("Product C", [(1, 60), (2, 62), (3, 70), (4, 85), (5, 82), (6, 90)])-                                              ] defPlot { plotTitle = "Monthly Sales Trends"}-  -  -- box plot-  Text.putStrLn $ boxPlot [ ("Class A", [78, 82, 85, 88, 90, 92, 85, 87, 89, 91, 76, 94, 88])-                                                        , ("Class B", [70, 75, 72, 80, 85, 78, 82, 77, 79, 81, 74, 83])-                                                        , ("Class C", [88, 92, 95, 90, 93, 89, 91, 94, 96, 87, 90, 92])-                                                        , ("Class D", [65, 70, 72, 68, 75, 80, 73, 71, 69, 74, 77, 76])-                                                        ] defPlot { plotTitle = "Test Score Distribution by Class"}-  -  -- stacked bar chart-  Text.putStrLn $ stackedBars [ ("Q1", [("Hardware", 120), ("Software", 200), ("Services", 80)])-                                                       , ("Q2", [("Hardware", 135), ("Software", 220), ("Services", 95)])-                                                       , ("Q3", [("Hardware", 110), ("Software", 240), ("Services", 110)])-                                                       , ("Q4", [("Hardware", 145), ("Software", 260), ("Services", 125)])-                                                       ] defPlot { plotTitle = "Quarterly Revenue Breakdown"}-  -  -- heatmap-  let matrix = [ [1.0,  0.8,  0.3, -0.2,  0.1]-               , [0.8,  1.0,  0.5, -0.1,  0.2]-               , [0.3,  0.5,  1.0,  0.6,  0.4]-               , [-0.2, -0.1, 0.6,  1.0,  0.7]-               , [0.1,  0.2,  0.4,  0.7,  1.0]-               ]-  Text.putStrLn $ heatmap matrix defPlot { plotTitle = "Correlation Matrix" }+    -- line graph+    Text.putStrLn $+        lineGraph+            [ ("Product A", [(1, 100), (2, 120), (3, 115), (4, 140), (5, 155), (6, 148)])+            , ("Product B", [(1, 80), (2, 85), (3, 95), (4, 92), (5, 110), (6, 125)])+            , ("Product C", [(1, 60), (2, 62), (3, 70), (4, 85), (5, 82), (6, 90)])+            ]+            defPlot{plotTitle = "Monthly Sales Trends"}++    -- box plot+    Text.putStrLn $+        boxPlot+            [ ("Class A", [78, 82, 85, 88, 90, 92, 85, 87, 89, 91, 76, 94, 88])+            , ("Class B", [70, 75, 72, 80, 85, 78, 82, 77, 79, 81, 74, 83])+            , ("Class C", [88, 92, 95, 90, 93, 89, 91, 94, 96, 87, 90, 92])+            , ("Class D", [65, 70, 72, 68, 75, 80, 73, 71, 69, 74, 77, 76])+            ]+            defPlot{plotTitle = "Test Score Distribution by Class"}++    -- stacked bar chart+    Text.putStrLn $+        stackedBars+            [ ("Q1", [("Hardware", 120), ("Software", 200), ("Services", 80)])+            , ("Q2", [("Hardware", 135), ("Software", 220), ("Services", 95)])+            , ("Q3", [("Hardware", 110), ("Software", 240), ("Services", 110)])+            , ("Q4", [("Hardware", 145), ("Software", 260), ("Services", 125)])+            ]+            defPlot{plotTitle = "Quarterly Revenue Breakdown"}++    -- heatmap+    let matrix =+            [ [1.0, 0.8, 0.3, -0.2, 0.1]+            , [0.8, 1.0, 0.5, -0.1, 0.2]+            , [0.3, 0.5, 1.0, 0.6, 0.4]+            , [-0.2, -0.1, 0.6, 1.0, 0.7]+            , [0.1, 0.2, 0.4, 0.7, 1.0]+            ]+    Text.putStrLn $ heatmap matrix defPlot{plotTitle = "Correlation Matrix"}
granite.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               granite-version:            0.2.0.1+version:            0.2.0.2 synopsis:           Easy terminal plotting. description:        A terminal plotting library for quick and easy visualisation. license:            MIT@@ -35,7 +35,8 @@  library     import:           ghc-options-    exposed-modules:  Granite+    exposed-modules:  Granite,+                      Granite.String     build-depends:         base >=4 && <5,         text >= 1 && < 3
src/Granite.hs view
@@ -1,1026 +1,1231 @@-{-# LANGUAGE StrictData #-}-{-# 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(..)-  -- * Data Preparation-  , series-  , bins-  -- * Chart Types-  , histogram-  , bars-  , scatter-  , pie-  , stackedBars-  , heatmap-  , lineGraph-  , boxPlot-  ) where--import Data.Bits ((.&.), (.|.), xor)-import Data.Char (chr)-import Data.List qualified as List-import Data.Maybe-import Data.Text (Text)-import Data.Text qualified as Text-import Numeric (showFFloat, showEFloat)-import Text.Printf---- | Position of the legend in the plot.-data LegendPos -  = LegendRight   -- ^ Display legend on the right side of the plot-  | LegendBottom  -- ^ Display legend below the plot-  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')-  } deriving (Eq, Show)---- | 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---   }--- @-defPlot :: Plot-defPlot = Plot-  { widthChars   = 60-  , heightChars  = 20-  , leftMargin   = 6-  , bottomMargin = 2-  , titleMargin  = 1-  , xBounds      = (Nothing, Nothing)-  , yBounds      = (Nothing, Nothing)-  , plotTitle    = ""-  , legendPos    = LegendRight-  }---- | 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 :: Text                    -- ^ Name of the series (appears in legend)-       -> [(Double, Double)]       -- ^ List of (x, y) data points-       -> (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 :: [(Text, [(Double, Double)])]  -- ^ List of named data series-        -> Plot                           -- ^ Plot configuration-        -> Text                           -- ^ Rendered chart as 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 paletteColors-      withSty = zipWith3 (\(n,ps) p c -> (n,ps,p,c)) sers pats cols-      drawOne (_name, pts, pat, col) c0 =-        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' (flip 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 :: [(Text, [(Double, Double)])]  -- ^ List of named data series-          -> Plot                           -- ^ Plot configuration-          -> Text                           -- ^ Rendered chart as 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 paletteColors-      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 data = [("Apple", 45.2), ("Banana", 38.1), ("Orange", 52.7)]---     chart = bars data defPlot { plotTitle = "Fruit Sales" }--- @-bars :: [(Text, Double)]  -- ^ List of (category, value) pairs-     -> Plot               -- ^ Plot configuration-     -> Text               -- ^ Rendered chart as Text-bars kvs cfg =-  let wC   = widthChars cfg-      hC   = heightChars cfg-      vals = map snd kvs-      vmax = maximum' (map abs vals)--      cats :: [(Text, Double, Color)]-      cats = [ (name, abs v / vmax, col)-             | ((name, v), col) <- zip kvs (cycle paletteColors) ]--      nCats = 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   = concat (List.intersperse [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)-      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 data = [("Q1", [("Product A", 100), ("Product B", 150)]),---             ("Q2", [("Product A", 120), ("Product B", 180)])]---     chart = stackedBars data defPlot--- @-stackedBars :: [(Text, [(Text, Double)])]  -- ^ Categories with stacked components-            -> Plot                         -- ^ Plot configuration-            -> Text                         -- ^ Rendered chart as 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 paletteColors-      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 = concat (List.intersperse [gutterCol] allBars)-      -      grid = [[col !! y | col <- columns] | y <- [0..hC-1]]-      -      ax :: Text-      ax = axisifyGrid cfg grid (0, fromIntegral (max 1 nCats)) (0, maxHeight)-      legend :: Text-      legend = legendBlock (legendPos cfg) (leftMargin cfg + 1 + -                          (gridWidth grid))-                 [(name, Solid, col) | (name, col) <- seriesColors]-  in drawFrame cfg ax legend--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 :: Bins         -- ^ Binning configuration-          -> [Double]     -- ^ Raw data values to bin-          -> Plot         -- ^ Plot configuration-          -> Text         -- ^ Rendered chart as 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 ]-      gutterCol = (replicate hC ' ', Nothing)-      columns   = concat (List.intersperse [gutterCol] (map pure dataCols))--      grid :: [[(Char, Maybe Color)]]-      grid = [ [ (fst col !! y, snd col) | col <- columns ]-             | y <- [0 .. hC-1] ]--      ax     = axisifyGrid cfg grid (a,b) (0, fromIntegral (maximum (1:counts)))-      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 data = [("Chrome", 65), ("Firefox", 20), ("Safari", 10), ("Other", 5)]---     chart = pie data defPlot { plotTitle = "Browser Market Share" }--- @-pie :: [(Text, Double)]  -- ^ List of (category, value) pairs-    -> Plot               -- ^ Plot configuration-    -> Text               -- ^ Rendered chart as 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  = zipWith3 (\n ang col -> (n,ang,col)) 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) (\x y -> inside x y) (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 :: [[Double]]  -- ^ 2D matrix of values (rows × columns)-        -> Plot         -- ^ Plot configuration-        -> Text         -- ^ Rendered chart as 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'--      plotW = widthChars cfg-      plotH = heightChars cfg-      -      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]]--      ax = axisifyGrid cfg displayGrid (0, fromIntegral cols - 1) (0, fromIntegral rows - 1)-      -      gradientLegend = (Text.pack $ printf "%.2f " vmin) <> -                      Text.concat (fmap (\col -> paint col '█') 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 :: [(Text, [Double])]  -- ^ Named datasets-        -> Plot                 -- ^ Plot configuration-        -> Text                 -- ^ Rendered chart as 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)-      -      ax = axisifyGrid cfg finalGrid (0, fromIntegral nBoxes) (ymin, ymax)-      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 =-      if y >= 0 && y < length grid && x >= 0 && x < gridWidth grid-      then take y grid <> [setAt (grid !! y) x (ch, col)] <> drop (y+1) grid-      else grid-      where setAt row i v = take i row <> [v] <> drop (i+1) row--data Color-  = Default | Black | Red | Green | Yellow | Blue | Magenta | Cyan | White-  | BrightBlack | BrightRed | BrightGreen | BrightYellow | BrightBlue-  | BrightMagenta | BrightCyan | BrightWhite-  deriving (Eq, Show)--ansiCode :: Color -> Int-ansiCode Black         = 30-ansiCode Red           = 31-ansiCode Green         = 32-ansiCode Yellow        = 33-ansiCode Blue          = 34-ansiCode Magenta       = 35-ansiCode Cyan          = 36-ansiCode White         = 37-ansiCode BrightBlack   = 90-ansiCode BrightRed     = 91-ansiCode BrightGreen   = 92-ansiCode BrightYellow  = 93-ansiCode BrightBlue    = 94-ansiCode BrightMagenta = 95-ansiCode BrightCyan    = 96-ansiCode BrightWhite   = 97-ansiCode Default       = 39--ansiOn :: Color -> Text-ansiOn c = "\ESC[" <> Text.pack (show (ansiCode c)) <> "m"--ansiOff :: Text-ansiOff = "\ESC[0m"--paint :: Color -> Char -> Text-paint c ch = if ch == ' ' then " " else ansiOn c <> (Text.singleton ch) <> ansiOff--paletteColors :: [Color]-paletteColors =-  [ BrightBlue, BrightMagenta, BrightCyan, BrightGreen-  , BrightYellow, BrightRed, BrightWhite, BrightBlack-  ]--pieColors :: [Color]-pieColors =-  [ BrightRed, BrightGreen, BrightYellow, BrightBlue-  , BrightMagenta, BrightCyan, BrightWhite, BrightBlack-  ]--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]--data Array2D a = A2D Int Int (Arr a)--getA2D :: Array2D a -> Int -> Int -> a-getA2D (A2D w _ xs) x y = indexA xs (y*w + x)--setA2D :: Array2D a -> Int -> Int -> a -> Array2D a-setA2D (A2D w h xs) x y v =-  let i = y*w + x-  in A2D w h (setA xs i v)--newA2D :: Int -> Int -> a -> Array2D a-newA2D w h v = A2D w h (fromList (replicate (w*h) v))--toBit :: Int -> Int -> Int-toBit ry rx = case (ry,rx) of-  (0,0) -> 1-  (1,0) -> 2-  (2,0) -> 4-  (3,0) -> 64-  (0,1) -> 8-  (1,1) -> 16-  (2,1) -> 32-  (3,1) -> 128-  _     -> 0--data Canvas = Canvas-  { cW     :: Int-  , cH     :: Int-  , buffer :: (Array2D Int)-  , cbuf   :: (Array2D (Maybe Color))-  }--newCanvas :: Int -> Int -> Canvas-newCanvas w h = Canvas w h (newA2D w h 0) (newA2D w h Nothing)--setDotC :: Canvas -> Int -> Int -> Maybe Color -> Canvas-setDotC c xDot yDot mcol-  | xDot < 0 || yDot < 0 || xDot >= cW c * 2 || yDot >= cH c * 4 = c-  | otherwise =-      let cx = xDot `div` 2-          cy = yDot `div` 4-          rx = xDot - 2*cx-          ry = yDot - 4*cy-          b  = toBit ry rx-          m  = getA2D (buffer c) cx cy-          c' = c { buffer = setA2D (buffer c) cx cy (m .|. b) }-      in case mcol of-           Nothing -> c'-           Just col -> c' { cbuf = setA2D (cbuf c) cx cy (Just col) }--fillDotsC :: (Int,Int) -> (Int,Int) -> (Int -> Int -> Bool) -> Maybe Color -> Canvas -> Canvas-fillDotsC (x0,y0) (x1,y1) p mcol c0 =-  let xs = [max 0 x0 .. min (cW c0*2-1) x1]-      ys = [max 0 y0 .. min (cH c0*4-1) y1]-  in  List.foldl' (\c y -> List.foldl' (\c' x -> if p x y then setDotC c' x y mcol else c') c xs) c0 ys--renderCanvas :: Canvas -> Text-renderCanvas (Canvas w h a colA) =-  let glyph 0 = ' ' -      glyph m = chr (0x2800 + m)-      rows = flip fmap [0..h-1] (\y -> flip fmap [0..w-1] $ \x -> -        let m = getA2D a x y-            ch = glyph m-            mc = getA2D colA x y-        in maybe (Text.singleton ch) (\c -> paint c ch) mc)-  in Text.unlines (fmap Text.concat rows)--justifyRight :: Int -> Text -> Text-justifyRight n s = Text.replicate (max 0 (n - wcswidth s)) " " <> s--wcswidth :: Text -> Int-wcswidth t = go 0 (Text.unpack t)-  where-    go acc [] = acc-    go acc ('\ESC':'[':rest) = let rest' = dropWhile (\c -> c /= 'm') rest-                                in case rest' of-                                     []     -> acc-                                     (_:xs) -> go acc xs-    go acc (_:xs) = go (acc+1) xs--fmt :: 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 | not (Text.null (plotTitle _cfg))]-   <> [contentWithAxes]-   <> [legendBlockStr | not (Text.null legendBlockStr)] )--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  = [(0, ymax), (plotH `div` 2, (ymin+ymax)/2), (plotH-1, ymin)]-      baseLbl = replicate plotH pad--      setAt :: [Text] -> Int -> Text -> [Text]-      setAt xs i v | i < 0 || i >= length xs = xs-                   | otherwise = take i xs <> [v] <> drop (i+1) xs--      yLabels = List.foldl' (\acc (row,v) -> setAt acc row (justifyRight left (fmt v)))-                       baseLbl yTicks--      canvasLines = Text.lines (renderCanvas c)-      attachY :: [Text]-      attachY = zipWith (\lbl line -> lbl <> "│" <> line) yLabels canvasLines--      xBar   = pad <> "│" <> Text.replicate plotW "─"-      xLbls  = [(0, xmin), (plotW `div` 2, (xmin+xmax)/2), (plotW-1, xmax)]-      xLine  = placeLabels (Text.replicate (left + 1 + plotW) " ") (left + 1)-                           [ (x, fmt v) | (x,v) <- xLbls ]-  in Text.unlines (attachY <> [xBar, xLine])--axisifyGrid :: Plot -> [[(Char, Maybe Color)]] -> (Double,Double) -> (Double,Double) -> Text-axisifyGrid cfg grid (xmin,xmax) (ymin,ymax) =-  let plotH = length grid-      plotW = gridWidth grid-      left  = leftMargin cfg-      pad   = Text.replicate left " "--      yTicks :: [(Int, Double)]-      yTicks  = [(0, ymax), (plotH `div` 2, (ymin+ymax)/2), (plotH-1, ymin)]--      baseLbl :: [Text]-      baseLbl = List.replicate plotH pad--      setAt :: [Text] -> Int -> Text -> [Text]-      setAt xs i v | i < 0 || i >= length xs = xs-                   | otherwise = take i xs <> [v] <> drop (i+1) xs--      yLabels :: [Text]-      yLabels = List.foldl' -          (\acc (row,v) -> setAt acc row (justifyRight left (fmt v)))-          baseLbl-          yTicks--      renderRow :: [(Char, Maybe Color)] -> Text-      renderRow cells = Text.concat $ -        fmap (\(ch, mc) -> maybe (Text.singleton ch) (\c -> paint c ch) mc) cells--      attachY :: [Text]-      attachY = zipWith (\lbl cells -> lbl <> "│" <> renderRow cells) yLabels grid--      xBar :: Text-      xBar   = pad <> "│" <> Text.replicate plotW "─"-      xLbls  = [(0, xmin), (plotW `div` 2, (xmin+xmax)/2), (plotW-1, xmax)]-      xLine  = placeLabels -                 (Text.replicate (left + 1 + plotW) " ")-                 (left + 1)-                 (fmap (\(x,v) -> (x, fmt v)) xLbls)-  in Text.unlines (attachY <> [xBar, xLine])--placeLabels :: Text -> Int -> [(Int,Text)] -> Text-placeLabels base off xs = List.foldl' place base xs-  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--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--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--clamp :: Ord a => a -> a -> a -> a-clamp low high x = max low (min high x)--eps :: Double-eps = 1e-12--boundsXY :: Plot -> [(Double,Double)] -> (Double,Double,Double,Double)-boundsXY cfg pts =-  let xs = map fst pts; ys = map snd 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)))--mod' :: Double -> Double -> Double-mod' a m = a - fromIntegral (floor (a / m) :: Int) * m--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 ]--addAt :: [Int] -> Int -> Int -> [Int]-addAt xs i v = take i xs <> [xs !! i + v] <> drop (i+1) xs--normalize :: [(Text, Double)] -> [(Text, Double)]-normalize xs =-  let s = sum (map (abs . snd) xs) + 1e-12-  in [ (n, max 0 (v / s)) | (n,v) <- xs ]--angleWithin :: Double -> Double -> Double -> Bool-angleWithin ang a0 a1-  | a1 >= a0  = ang >= a0 && ang <= a1-  | otherwise = ang >= a0 || ang <= a1--lineDotsC :: (Int,Int) -> (Int,Int) -> Maybe Color -> Canvas -> Canvas-lineDotsC (x0,y0) (x1,y1) mcol c0 =-  let dx = abs (x1 - x0)-      sx = if x0 < x1 then 1 else -1-      dy = negate (abs (y1 - y0))-      sy = if y0 < y1 then 1 else -1-      go x y err c-        | x == x1 && y == y1 = setDotC c x y mcol-        | otherwise =-            let e2 = 2*err-                (x', err') = if e2 >= dy then (x + sx, err + dy) else (x, err)-                (y', err'')= if e2 <= dx then (y + sy, err' + dx) else (y, err')-            in go x' y' err'' (setDotC c x y mcol)-  in go x0 y0 (dx + dy) c0--quartiles :: [Double] -> (Double, Double, Double, Double, Double)-quartiles [] = (0, 0, 0, 0, 0) -- Idk. Maybe throw an error here???-quartiles xs = -  let sorted = List.sort xs-      n = length sorted-      q1Idx = n `div` 4-      q2Idx = n `div` 2-      q3Idx = (3 * n) `div` 4-      getIdx i = if i < n then sorted !! i else last sorted-  in if n < 5 -     then let m = sum xs / fromIntegral n in (m,m,m,m,m)-     else (fromMaybe 0 (fmap fst (List.uncons sorted)), getIdx q1Idx, getIdx q2Idx, getIdx q3Idx, last sorted)--gridWidth :: [[a]] -> Int-gridWidth []     = 0-gridWidth (x:_) = length x---- | Min and max function for axis bounds which defaults to 0 and 1 when empty.-minimum', maximum' :: [Double] -> Double-minimum' [] = 0-minimum' xs = minimum xs-maximum' [] = 1-maximum' xs = maximum xs---- AVL Tree we'll use as an array.--- This improves upon the previous implementation that relies--- on linked list for indexing and update (both O(n)) while keeping--- the dependencies very light (wouldn't want to install all of containers--- just to get an int map).-data Arr a -  = E -  | N Int Int (Arr a) a (Arr a)--size :: Arr a -> Int-size E               = 0-size (N sz _ _ _ _)  = sz--height :: Arr a -> Int-height E               = 0-height (N _ h _ _ _)   = h--mk :: Arr a -> a -> Arr a -> Arr a-mk l x r = N sz h l x r-  where-    sl = size l-    sr = size r-    hl = height l-    hr = height r-    sz = 1 + sl + sr-    h  = 1 + (if hl >= hr then hl else hr)--rotateL :: Arr a -> Arr a-rotateL (N _ _ l x (N _ _ rl y rr)) = mk (mk l x rl) y rr-rotateL _ = error "rotateL: malformed tree"--rotateR :: Arr a -> Arr a-rotateR (N _ _ (N _ _ ll y lr) x r) = mk ll y (mk lr x r)-rotateR _ = error "rotateR: malformed tree"--balance :: Arr a -> Arr a-balance t@(N _ _ l x r)-  | height l > height r + 1 =-      case l of-        N _ _ ll _ lr ->-          if height ll >= height lr-             then rotateR t-             else rotateR (mk (rotateL l) x r)-        _ -> t-  | height r > height l + 1 =-      case r of-        N _ _ rl _ rr ->-          if height rr >= height rl-             then rotateL t-             else rotateL (mk l x (rotateR r))-        _ -> t-  | otherwise = mk l x r-balance t = t--indexA :: Arr a -> Int -> a-indexA t i =-  case t of-    E -> error ("index out of bounds: " <> show i)-    N _ _ l x r ->-      let sl = size l in-      if i < 0 || i >= 1 + sl + size r then error ("index out of bounds: " <> show i)-      else if i < sl then indexA l i-      else if i == sl then x-      else indexA r (i - sl - 1)--setA :: Arr a -> Int -> a -> Arr a-setA t i y =-  case t of-    E -> error ("index out of bounds when setting: " <> show i)-    N _ _ l x r ->-      let sl = size l in-      if i < 0 || i >= 1 + sl + size r then error ("index out of bounds: " <> show i)-      else if i < sl then balance (mk (setA l i y) x r)-      else if i == sl then mk l y r-      else balance (mk l x (setA r (i - sl - 1) y))--fromList :: [a] -> Arr a-fromList xs = fst (build (length xs) xs)-  where-    build :: Int -> [a] -> (Arr a, [a])-    build 0 ys = (E, ys)-    build n ys =-      let (l, ys1)   = build (n `div` 2) ys-          (x,ys2)      = case ys1 of-            []     -> error "IMPOSSIBLE"-            (v:vs) -> (v, vs)-          (r, ys3)   = build (n - n `div` 2 - 1) ys2-      in (mk l x r, ys3)+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE Strict #-}+{-# LANGUAGE StrictData #-}++{- |+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 (..),++    -- * Data Preparation+    series,+    bins,+    Bins (..),++    -- * Chart Types+    histogram,+    bars,+    scatter,+    pie,+    stackedBars,+    heatmap,+    lineGraph,+    boxPlot,+) where++import Data.Bits (xor, (.&.), (.|.))+import Data.Char (chr)+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++-- | 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+    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 :: (Int -> Double -> Text)+    -- ^ Formatter for x-axis labels.+    , yFormatter :: (Int -> Double -> Text)+    -- ^ Formatter for y-axis labels.+    }++{- | 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+  }+@+-}+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+        }++-- | Supported ANSI colo(u)rs.+data Color+    = Default+    | Black+    | Red+    | Green+    | Yellow+    | Blue+    | Magenta+    | Cyan+    | White+    | BrightBlack+    | BrightRed+    | BrightGreen+    | BrightYellow+    | BrightBlue+    | BrightMagenta+    | BrightCyan+    | BrightWhite+    deriving (Eq, Show)++{- | 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 paletteColors+        withSty = zipWith3 (\(n, ps) p c -> (n, ps, p, c)) sers pats cols+        drawOne (_name, pts, pat, col) c0 =+            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' (flip 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 paletteColors+        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 data = [("Apple", 45.2), ("Banana", 38.1), ("Orange", 52.7)]+    chart = bars data 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+        vals = map snd kvs+        vmax = maximum' (map abs vals)++        cats :: [(Text, Double, Color)]+        cats =+            [ (name, abs v / vmax, col)+            | ((name, v), col) <- zip kvs (cycle paletteColors)+            ]++        nCats = 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 = concat (List.intersperse [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)+        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 data = [("Q1", [("Product A", 100), ("Product B", 150)]),+            ("Q2", [("Product A", 120), ("Product B", 180)])]+    chart = stackedBars data 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 paletteColors+        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 = concat (List.intersperse [gutterCol] allBars)++        grid = [[col !! y | col <- columns] | y <- [0 .. hC - 1]]++        ax :: Text+        ax = axisifyGrid cfg grid (0, fromIntegral (max 1 nCats)) (0, maxHeight)+        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]+        gutterCol = (replicate hC ' ', Nothing)+        columns = concat (List.intersperse [gutterCol] (map pure dataCols))++        grid :: [[(Char, Maybe Color)]]+        grid =+            [ [(fst col !! y, snd col) | col <- columns]+            | y <- [0 .. hC - 1]+            ]++        ax = axisifyGrid cfg grid (a, b) (0, fromIntegral (maximum (1 : counts)))+        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 data = [("Chrome", 65), ("Firefox", 20), ("Safari", 10), ("Other", 5)]+    chart = pie data 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 = zipWith3 (\n ang col -> (n, ang, col)) 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) (\x y -> inside x y) (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'++        plotW = widthChars cfg+        plotH = heightChars cfg++        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]+            ]++        ax = axisifyGrid cfg displayGrid (0, fromIntegral cols - 1) (0, fromIntegral rows - 1)++        gradientLegend =+            (Text.pack $ printf "%.2f " vmin)+                <> Text.concat (fmap (\col -> paint col '█') 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)++        ax = axisifyGrid cfg finalGrid (0, fromIntegral nBoxes) (ymin, ymax)+        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 =+        if y >= 0 && y < length grid && x >= 0 && x < gridWidth grid+            then take y grid <> [setAt (grid !! y) x (ch, col)] <> drop (y + 1) grid+            else grid+      where+        setAt row i v = take i row <> [v] <> drop (i + 1) row++ansiCode :: Color -> Int+ansiCode Black = 30+ansiCode Red = 31+ansiCode Green = 32+ansiCode Yellow = 33+ansiCode Blue = 34+ansiCode Magenta = 35+ansiCode Cyan = 36+ansiCode White = 37+ansiCode BrightBlack = 90+ansiCode BrightRed = 91+ansiCode BrightGreen = 92+ansiCode BrightYellow = 93+ansiCode BrightBlue = 94+ansiCode BrightMagenta = 95+ansiCode BrightCyan = 96+ansiCode BrightWhite = 97+ansiCode Default = 39++ansiOn :: Color -> Text+ansiOn c = "\ESC[" <> Text.pack (show (ansiCode c)) <> "m"++ansiOff :: Text+ansiOff = "\ESC[0m"++paint :: Color -> Char -> Text+paint c ch = if ch == ' ' then " " else ansiOn c <> (Text.singleton ch) <> ansiOff++paletteColors :: [Color]+paletteColors =+    [ BrightBlue+    , BrightMagenta+    , BrightCyan+    , BrightGreen+    , BrightYellow+    , BrightRed+    , BrightWhite+    , BrightBlack+    ]++pieColors :: [Color]+pieColors =+    [ BrightRed+    , BrightGreen+    , BrightYellow+    , BrightBlue+    , BrightMagenta+    , BrightCyan+    , BrightWhite+    , BrightBlack+    ]++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]++data Array2D a = A2D Int Int (Arr a)++getA2D :: Array2D a -> Int -> Int -> a+getA2D (A2D w _ xs) x y = indexA xs (y * w + x)++setA2D :: Array2D a -> Int -> Int -> a -> Array2D a+setA2D (A2D w h xs) x y v =+    let i = y * w + x+     in A2D w h (setA xs i v)++newA2D :: Int -> Int -> a -> Array2D a+newA2D w h v = A2D w h (fromList (replicate (w * h) v))++toBit :: Int -> Int -> Int+toBit ry rx = case (ry, rx) of+    (0, 0) -> 1+    (1, 0) -> 2+    (2, 0) -> 4+    (3, 0) -> 64+    (0, 1) -> 8+    (1, 1) -> 16+    (2, 1) -> 32+    (3, 1) -> 128+    _ -> 0++data Canvas = Canvas+    { cW :: Int+    , cH :: Int+    , buffer :: (Array2D Int)+    , cbuf :: (Array2D (Maybe Color))+    }++newCanvas :: Int -> Int -> Canvas+newCanvas w h = Canvas w h (newA2D w h 0) (newA2D w h Nothing)++setDotC :: Canvas -> Int -> Int -> Maybe Color -> Canvas+setDotC c xDot yDot mcol+    | xDot < 0 || yDot < 0 || xDot >= cW c * 2 || yDot >= cH c * 4 = c+    | otherwise =+        let cx = xDot `div` 2+            cy = yDot `div` 4+            rx = xDot - 2 * cx+            ry = yDot - 4 * cy+            b = toBit ry rx+            m = getA2D (buffer c) cx cy+            c' = c{buffer = setA2D (buffer c) cx cy (m .|. b)}+         in case mcol of+                Nothing -> c'+                Just col -> c'{cbuf = setA2D (cbuf c) cx cy (Just col)}++fillDotsC :: (Int, Int) -> (Int, Int) -> (Int -> Int -> Bool) -> Maybe Color -> Canvas -> Canvas+fillDotsC (x0, y0) (x1, y1) p mcol c0 =+    let xs = [max 0 x0 .. min (cW c0 * 2 - 1) x1]+        ys = [max 0 y0 .. min (cH c0 * 4 - 1) y1]+     in List.foldl' (\c y -> List.foldl' (\c' x -> if p x y then setDotC c' x y mcol else c') c xs) c0 ys++renderCanvas :: Canvas -> Text+renderCanvas (Canvas w h a colA) =+    let glyph 0 = ' '+        glyph m = chr (0x2800 + m)+        rows =+            flip+                fmap+                [0 .. h - 1]+                ( \y -> flip fmap [0 .. w - 1] $ \x ->+                    let m = getA2D a x y+                        ch = glyph m+                        mc = getA2D colA x y+                     in maybe (Text.singleton ch) (\c -> paint c ch) mc+                )+     in Text.unlines (fmap Text.concat rows)++justifyRight :: Int -> Text -> Text+justifyRight n s = Text.replicate (max 0 (n - wcswidth s)) " " <> s++wcswidth :: Text -> Int+wcswidth t = go 0 t+  where+    go acc xs+        | Text.null xs = acc+        | Text.isPrefixOf "\ESC[" xs =+            let+                rest' = Text.dropWhile (\c -> c /= 'm') xs+             in+                if Text.null rest' then acc else go acc (Text.tail rest')+        | otherwise = go (acc + 1) (Text.tail xs)++fmt :: 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 | not (Text.null (plotTitle _cfg))]+                <> [contentWithAxes]+                <> [legendBlockStr | not (Text.null legendBlockStr)]+            )++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 = [(0, ymax), (plotH `div` 2, (ymin + ymax) / 2), (plotH - 1, ymin)]+        baseLbl = replicate plotH pad++        setAt :: [Text] -> Int -> Text -> [Text]+        setAt xs i v+            | i < 0 || i >= length xs = xs+            | otherwise = take i xs <> [v] <> drop (i + 1) xs++        yLabels =+            List.foldl'+                (\acc (row, v) -> setAt acc row (justifyRight left (yFormatter cfg row v)))+                baseLbl+                yTicks++        canvasLines = Text.lines (renderCanvas c)+        attachY :: [Text]+        attachY = zipWith (\lbl line -> lbl <> "│" <> line) yLabels canvasLines++        xBar = pad <> "│" <> Text.replicate plotW "─"+        xLbls = [(0, xmin), (plotW `div` 2, (xmin + xmax) / 2), (plotW - 1, xmax)]+        xLine =+            placeLabels+                (Text.replicate (left + 1 + plotW) " ")+                (left + 1)+                [(x, xFormatter cfg x v) | (x, v) <- xLbls]+     in Text.unlines (attachY <> [xBar, xLine])++axisifyGrid :: Plot -> [[(Char, Maybe Color)]] -> (Double, Double) -> (Double, Double) -> Text+axisifyGrid cfg grid (xmin, xmax) (ymin, ymax) =+    let plotH = length grid+        plotW = gridWidth grid+        left = leftMargin cfg+        pad = Text.replicate left " "++        yTicks :: [(Int, Double)]+        yTicks = [(0, ymax), (plotH `div` 2, (ymin + ymax) / 2), (plotH - 1, ymin)]++        baseLbl :: [Text]+        baseLbl = List.replicate plotH pad++        setAt :: [Text] -> Int -> Text -> [Text]+        setAt xs i v+            | i < 0 || i >= length xs = xs+            | otherwise = take i xs <> [v] <> drop (i + 1) xs++        yLabels :: [Text]+        yLabels =+            List.foldl'+                (\acc (row, v) -> setAt acc row (justifyRight left (yFormatter cfg row v)))+                baseLbl+                yTicks++        renderRow :: [(Char, Maybe Color)] -> Text+        renderRow cells =+            Text.concat $+                fmap (\(ch, mc) -> maybe (Text.singleton ch) (\c -> paint c ch) mc) cells++        attachY :: [Text]+        attachY = zipWith (\lbl cells -> lbl <> "│" <> renderRow cells) yLabels grid++        xBar :: Text+        xBar = pad <> "│" <> Text.replicate plotW "─"+        xLbls = [(0, xmin), (plotW `div` 2, (xmin + xmax) / 2), (plotW - 1, xmax)]+        xLine =+            placeLabels+                (Text.replicate (left + 1 + plotW) " ")+                (left + 1)+                (fmap (\(x, v) -> (x, xFormatter cfg x v)) xLbls)+     in Text.unlines (attachY <> [xBar, xLine])++placeLabels :: Text -> Int -> [(Int, Text)] -> Text+placeLabels base off xs = List.foldl' place base xs+  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++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++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++clamp :: (Ord a) => a -> a -> a -> a+clamp low high x = max low (min high x)++eps :: Double+eps = 1e-12++boundsXY :: Plot -> [(Double, Double)] -> (Double, Double, Double, Double)+boundsXY cfg pts =+    let xs = map fst pts+        ys = map snd 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))+        )++mod' :: Double -> Double -> Double+mod' a m = a - fromIntegral (floor (a / m) :: Int) * m++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+                ]++addAt :: [Int] -> Int -> Int -> [Int]+addAt xs i v = take i xs <> [xs !! i + v] <> drop (i + 1) xs++normalize :: [(Text, Double)] -> [(Text, Double)]+normalize xs =+    let s = sum (map (abs . snd) xs) + 1e-12+     in [(n, max 0 (v / s)) | (n, v) <- xs]++angleWithin :: Double -> Double -> Double -> Bool+angleWithin ang a0 a1+    | a1 >= a0 = ang >= a0 && ang <= a1+    | otherwise = ang >= a0 || ang <= a1++lineDotsC :: (Int, Int) -> (Int, Int) -> Maybe Color -> Canvas -> Canvas+lineDotsC (x0, y0) (x1, y1) mcol c0 =+    let dx = abs (x1 - x0)+        sx = if x0 < x1 then 1 else -1+        dy = negate (abs (y1 - y0))+        sy = if y0 < y1 then 1 else -1+        go x y err c+            | x == x1 && y == y1 = setDotC c x y mcol+            | otherwise =+                let e2 = 2 * err+                    (x', err') = if e2 >= dy then (x + sx, err + dy) else (x, err)+                    (y', err'') = if e2 <= dx then (y + sy, err' + dx) else (y, err')+                 in go x' y' err'' (setDotC c x y mcol)+     in go x0 y0 (dx + dy) c0++quartiles :: [Double] -> (Double, Double, Double, Double, Double)+quartiles [] = (0, 0, 0, 0, 0) -- Idk. Maybe throw an error here???+quartiles xs =+    let sorted = List.sort xs+        n = length sorted+        q1Idx = n `div` 4+        q2Idx = n `div` 2+        q3Idx = (3 * n) `div` 4+        getIdx i = if i < n then sorted !! i else last sorted+     in if n < 5+            then let m = sum xs / fromIntegral n in (m, m, m, m, m)+            else (fromMaybe 0 (fmap fst (List.uncons sorted)), getIdx q1Idx, getIdx q2Idx, getIdx q3Idx, last sorted)++gridWidth :: [[a]] -> Int+gridWidth [] = 0+gridWidth (x : _) = length x++-- | Min and max function for axis bounds which defaults to 0 and 1 when empty.+minimum', maximum' :: [Double] -> Double+minimum' [] = 0+minimum' xs = minimum xs+maximum' [] = 1+maximum' xs = maximum xs++-- AVL Tree we'll use as an array.+-- This improves upon the previous implementation that relies+-- on linked list for indexing and update (both O(n)) while keeping+-- the dependencies very light (wouldn't want to install all of containers+-- just to get an int map).+data Arr a+    = E+    | N Int Int (Arr a) a (Arr a)++size :: Arr a -> Int+size E = 0+size (N sz _ _ _ _) = sz++height :: Arr a -> Int+height E = 0+height (N _ h _ _ _) = h++mk :: Arr a -> a -> Arr a -> Arr a+mk l x r = N sz h l x r+  where+    sl = size l+    sr = size r+    hl = height l+    hr = height r+    sz = 1 + sl + sr+    h = 1 + (if hl >= hr then hl else hr)++rotateL :: Arr a -> Arr a+rotateL (N _ _ l x (N _ _ rl y rr)) = mk (mk l x rl) y rr+rotateL _ = error "rotateL: malformed tree"++rotateR :: Arr a -> Arr a+rotateR (N _ _ (N _ _ ll y lr) x r) = mk ll y (mk lr x r)+rotateR _ = error "rotateR: malformed tree"++balance :: Arr a -> Arr a+balance t@(N _ _ l x r)+    | height l > height r + 1 =+        case l of+            N _ _ ll _ lr ->+                if height ll >= height lr+                    then rotateR t+                    else rotateR (mk (rotateL l) x r)+            _ -> t+    | height r > height l + 1 =+        case r of+            N _ _ rl _ rr ->+                if height rr >= height rl+                    then rotateL t+                    else rotateL (mk l x (rotateR r))+            _ -> t+    | otherwise = mk l x r+balance t = t++indexA :: Arr a -> Int -> a+indexA t i =+    case t of+        E -> error ("index out of bounds: " <> show i)+        N _ _ l x r ->+            let sl = size l+             in if i < 0 || i >= 1 + sl + size r+                    then error ("index out of bounds: " <> show i)+                    else+                        if i < sl+                            then indexA l i+                            else+                                if i == sl+                                    then x+                                    else indexA r (i - sl - 1)++setA :: Arr a -> Int -> a -> Arr a+setA t i y =+    case t of+        E -> error ("index out of bounds when setting: " <> show i)+        N _ _ l x r ->+            let sl = size l+             in if i < 0 || i >= 1 + sl + size r+                    then error ("index out of bounds: " <> show i)+                    else+                        if i < sl+                            then balance (mk (setA l i y) x r)+                            else+                                if i == sl+                                    then mk l y r+                                    else balance (mk l x (setA r (i - sl - 1) y))++fromList :: [a] -> Arr a+fromList xs = fst (build (length xs) xs)+  where+    build :: Int -> [a] -> (Arr a, [a])+    build 0 ys = (E, ys)+    build n ys =+        let (l, ys1) = build (n `div` 2) ys+            (x, ys2) = case ys1 of+                [] -> error "IMPOSSIBLE"+                (v : vs) -> (v, vs)+            (r, ys3) = build (n - n `div` 2 - 1) ys2+         in (mk l x r, ys3)
+ src/Granite/String.hs view
@@ -0,0 +1,394 @@+{-# LANGUAGE PatternSynonyms #-}++{- |+Module      : Granite.String+Copyright   : (c) 2024+License     : BSD3+Maintainer  : your-email@example.com+Stability   : experimental+Portability : POSIX++A String-based interface to the Granite plotting library. This module provides+the same functionality as "Granite" but uses 'String' instead of 'Text' for+easier use in simple scripts and educational contexts.++= Basic Usage++Create a simple scatter plot:++@+import Granite.String++main = do+  let points = [(x, sin x) | x <- [0, 0.1 .. 6.28]]+      chart = scatter [series "sin(x)" points] defPlot+  putStrLn chart+@++= Note on Performance++This module internally converts between 'String' and 'Text'. For performance-critical+applications with large datasets, consider using the "Granite" module directly which+works with 'Text' natively.++= 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.String (+    -- * Plot Configuration+    Plot (..),+    defPlot,++    -- * Data Preparation+    series,+    module RE,+    bins,++    -- * Chart Types+    scatter,+    lineGraph,+    bars,+    stackedBars,+    histogram,+    pie,+    heatmap,+    boxPlot,+) where++import Data.Text (Text)++import Data.Text qualified as Text+import Granite qualified as G++import Granite as RE (Bins, Color (..), LegendPos (..))++{- | 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 :: String+    -- ^ Title displayed above the plot (default: empty)+    , legendPos :: G.LegendPos+    -- ^ Position of the legend (default: 'LegendRight')+    , colorPalette :: [G.Color]+    -- ^ Color palette that'll be used by the plot.+    , xFormatter :: (Int -> Double -> String)+    -- ^ Formatter for x-axis labels.+    , yFormatter :: (Int -> Double -> String)+    -- ^ Formatter for y-axis labels.+    }++{- | 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   = \_ d -> show d+  , yFormatter   = \_ d -> show d+  }+@+-}+defPlot :: Plot+defPlot = fromGranitePlot G.defPlot++{- | 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 ::+    -- | Number of bins (will be clamped to minimum 1)+    Int ->+    -- | Lower bound+    Double ->+    -- | Upper bound+    Double ->+    G.Bins+bins = G.bins++{- | 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)+    String ->+    -- | List of (x, y) data points+    [(Double, Double)] ->+    (String, [(Double, Double)])+series name points = (name, points)++{- | 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+    [(String, [(Double, Double)])] ->+    -- | Plot configuration+    Plot ->+    -- | Rendered chart as String+    String+scatter seriesList plot =+    Text.unpack $ G.scatter (map (mapFirst Text.pack) seriesList) (toGranitePlot plot)++{- | 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+    [(String, [(Double, Double)])] ->+    -- | Plot configuration+    Plot ->+    -- | Rendered chart as String+    String+lineGraph seriesList plot =+    Text.unpack $ G.lineGraph (map (mapFirst Text.pack) seriesList) (toGranitePlot plot)++{- | Create a bar chart from categorical data.++Each bar is colored differently and labeled with its category name.++==== __Example__++@+let data = [("Apple", 45.2), ("Banana", 38.1), ("Orange", 52.7)]+    chart = bars data defPlot { plotTitle = "Fruit Sales" }+@+-}+bars ::+    -- | List of (category, value) pairs+    [(String, Double)] ->+    -- | Plot configuration+    Plot ->+    -- | Rendered chart as String+    String+bars categories plot =+    Text.unpack $ G.bars (map (mapFirst Text.pack) categories) (toGranitePlot plot)++{- | Create a stacked bar chart.++Each category can have multiple stacked components.++==== __Example__++@+let data = [("Q1", [("Product A", 100), ("Product B", 150)]),+            ("Q2", [("Product A", 120), ("Product B", 180)])]+    chart = stackedBars data defPlot+@+-}+stackedBars ::+    -- | Categories with stacked components+    [(String, [(String, Double)])] ->+    -- | Plot configuration+    Plot ->+    -- | Rendered chart as String+    String+stackedBars categories plot =+    Text.unpack $+        G.stackedBars+            (map (\(cat, items) -> (Text.pack cat, map (mapFirst Text.pack) items)) categories)+            (toGranitePlot plot)++{- | 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+    G.Bins ->+    -- | Raw data values to bin+    [Double] ->+    -- | Plot configuration+    Plot ->+    -- | Rendered chart as String+    String+histogram binConfig values plot =+    Text.unpack $ G.histogram binConfig values (toGranitePlot plot)++{- | Create a pie chart showing proportions.++Values are normalized to sum to 100%. Negative values are treated as zero.++==== __Example__++@+let data = [("Chrome", 65), ("Firefox", 20), ("Safari", 10), ("Other", 5)]+    chart = pie data defPlot { plotTitle = "Browser Market Share" }+@+-}+pie ::+    -- | List of (category, value) pairs+    [(String, Double)] ->+    -- | Plot configuration+    Plot ->+    -- | Rendered chart as String+    String+pie slices plot =+    Text.unpack $ G.pie (map (mapFirst Text.pack) slices) (toGranitePlot plot)++{- | 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 String+    String+heatmap matrix plot =+    Text.unpack $ G.heatmap matrix (toGranitePlot plot)++{- | 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+    [(String, [Double])] ->+    -- | Plot configuration+    Plot ->+    -- | Rendered chart as String+    String+boxPlot datasets plot =+    Text.unpack $ G.boxPlot (map (mapFirst Text.pack) datasets) (toGranitePlot plot)++-- | Convert our String-based Plot to Granite's Text-based Plot+toGranitePlot :: Plot -> G.Plot+toGranitePlot p =+    G.Plot+        { G.widthChars = widthChars p+        , G.heightChars = heightChars p+        , G.leftMargin = leftMargin p+        , G.bottomMargin = bottomMargin p+        , G.titleMargin = titleMargin p+        , G.xBounds = xBounds p+        , G.yBounds = yBounds p+        , G.plotTitle = Text.pack (plotTitle p)+        , G.legendPos = legendPos p+        , G.colorPalette = colorPalette p+        , G.xFormatter = formatWithText (xFormatter p)+        , G.yFormatter = formatWithText (yFormatter p)+        }++-- | Convert Granite's Text-based Plot to our String-based Plot+fromGranitePlot :: G.Plot -> Plot+fromGranitePlot p =+    Plot+        { widthChars = G.widthChars p+        , heightChars = G.heightChars p+        , leftMargin = G.leftMargin p+        , bottomMargin = G.bottomMargin p+        , titleMargin = G.titleMargin p+        , xBounds = G.xBounds p+        , yBounds = G.yBounds p+        , plotTitle = Text.unpack (G.plotTitle p)+        , legendPos = G.legendPos p+        , colorPalette = G.colorPalette p+        , xFormatter = formatWithString (G.xFormatter p)+        , yFormatter = formatWithString (G.yFormatter p)+        }++mapFirst :: (a -> b) -> (a, c) -> (b, c)+mapFirst f (a, c) = (f a, c)++formatWithText :: (Int -> Double -> String) -> Int -> Double -> Text+formatWithText f i d = Text.pack (f i d)++formatWithString :: (Int -> Double -> Text) -> Int -> Double -> String+formatWithString f i d = Text.unpack (f i d)