granite-0.2.0.2: src/Granite/String.hs
{-# 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)