granite 0.3.0.4 → 0.3.0.5
raw patch · 6 files changed
+151/−114 lines, 6 filesdep ~QuickCheckPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: QuickCheck
API changes (from Hackage documentation)
Files
- CHANGELOG.md +3/−0
- README.md +20/−17
- app/Main.hs +3/−3
- granite.cabal +12/−7
- src/Granite.hs +104/−78
- src/Granite/String.hs +9/−9
CHANGELOG.md view
@@ -1,5 +1,8 @@ # Revision history for granite +## 0.3.0.5 -- 2025-11-10+* Fix x-axis label alignment for continuous histograms.+ ## 0.3.0.4 -- 2025-09-26 * Fix issue with x-axis spacing when categories is greater than width.
README.md view
@@ -15,11 +15,11 @@ ## Examples ### Scatter plot-+ ```haskell-import Control.Monad-import System.Random.Stateful+import Control.Monad+import System.Random.Stateful import Granite.String @@ -31,12 +31,12 @@ ptsA_y <- replicateM 600 (uniformRM range g) ptsB_x <- replicateM 600 (uniformRM range g) ptsB_y <- replicateM 600 (uniformRM range g)- putStrLn (scatter "Random points" [series "A" (zip ptsA_x ptsA_y), series "B" (zip ptsB_x ptsB_y)]+ putStrLn (scatter [series "A" (zip ptsA_x ptsA_y), series "B" (zip ptsB_x ptsB_y)] defPlot{widthChars=68,heightChars=22,plotTitle="Random points"}) ``` ### Bar chart-+ ```haskell {-# LANGUAGE OverloadedStrings #-}@@ -49,7 +49,7 @@ ``` ### Stacked bar chart-+ ```haskell {-# LANGUAGE OverloadedStrings #-}@@ -66,7 +66,7 @@ ``` ### Pie chart-+ ```haskell {-# LANGUAGE OverloadedStrings #-}@@ -79,7 +79,7 @@ ``` ### Box plot-+ ```haskell {-# LANGUAGE OverloadedStrings #-}@@ -96,7 +96,7 @@ ``` ### Line graph-+ ```haskell {-# LANGUAGE OverloadedStrings #-}@@ -106,13 +106,13 @@ main :: IO () main = T.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"}+ , ("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"} ``` ### Heatmap-+ ```haskell {-# LANGUAGE OverloadedStrings #-}@@ -132,18 +132,21 @@ ``` ### Histogram-+ ```haskell {-# LANGUAGE OverloadedStrings #-}+import qualified Data.Text as T import qualified Data.Text.IO as T-+import Control.Monad import Granite+import System.Random.Stateful main :: IO () main = do+ g <- newIOGenM =<< newStdGen heights <- replicateM 5000 (uniformRM (160 :: Double, 190 :: Double) g)- Text.putStrLn $+ T.putStrLn $ histogram (bins 30 155 195) heights@@ -151,7 +154,7 @@ { widthChars = 68 , heightChars = 18 , legendPos = LegendBottom- , xFormatter = \_ _ v -> Text.pack (show (round v :: Int))+ , xFormatter = \_ _ v -> T.pack (show (round v :: Int)) , xNumTicks = 10 , yNumTicks = 5 , plotTitle = "Heights (cm)"
app/Main.hs view
@@ -21,10 +21,10 @@ defPlot{widthChars = 68, heightChars = 22, plotTitle = "Random points"} -- histogram- let heights = [100 .. 200]+ let heights = concat $ zipWith replicate [0 .. 40] [160 .. 200] Text.putStrLn $ histogram- (bins 30 155 195)+ (bins 40 155 195) heights defPlot { widthChars = 68@@ -70,7 +70,7 @@ , ("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"}+ defPlot{plotTitle = "Test Score Distribution by Class", xNumTicks = 4} -- stacked bar chart Text.putStrLn $
granite.cabal view
@@ -1,12 +1,17 @@ cabal-version: 3.0 name: granite-version: 0.3.0.4+version: 0.3.0.5 synopsis: Easy terminal plotting. description: A terminal plotting library for quick and easy visualisation. license: MIT license-file: LICENSE author: Michael Chavinda-tested-with: GHC ==9.8.2+tested-with: GHC ==9.4.8+ || ==9.6.7+ || ==9.8.4+ || ==9.10.3+ || ==9.12.2+ maintainer: mschavinda@gmail.com copyright: (c) 2024-2025 Michael Chavinda bug-reports: https://github.com/mchav/granite/issues@@ -58,18 +63,18 @@ type: exitcode-stdio-1.0 default-language: GHC2021 main-is: Spec.hs- + other-modules: GraniteSpec- + build-depends: base >= 4.14 && < 5 , granite ^>= 0.3 , text >= 1.2 && < 3 , hspec >= 2.7 && < 2.12- , QuickCheck >= 2.14 && < 2.15- + , QuickCheck >= 2.14 && < 2.17+ hs-source-dirs: test- + build-tool-depends: hspec-discover:hspec-discover >= 2.7
src/Granite.hs view
@@ -152,8 +152,8 @@ , plotTitle = "" , legendPos = LegendRight , colorPalette = [ BrightBlue, BrightMagenta, BrightCyan, BrightGreen, BrightYellow, BrightRed, BrightWhite, BrightBlack]- , xFormatter = \ _ _ v -> show v- , yFormatter = \ _ _ v -> show v+ , xFormatter = \\ _ _ v -> show v+ , yFormatter = \\ _ _ v -> show v , xNumTicks = 2 , yNumTicks = 2 }@@ -181,9 +181,11 @@ {- | Axis-aware, width-limited, tick-label formatter. Given:+ * axis context * a per-tick width budget (in terminal cells) * and the raw tick value.+ returns the label to render. -} type LabelFormatter =@@ -278,7 +280,7 @@ pats = cycle palette cols = cycle (colorPalette cfg) withSty = zipWith3 (\(n, ps) p c -> (n, ps, p, c)) sers pats cols- drawOne (_name, pts, pat, col) c0 =+ drawOne c0 (_name, pts, pat, col) = List.foldl' ( \c (x, y) -> let xd = sx x; yd = sy y@@ -286,7 +288,7 @@ ) c0 pts- cDone = List.foldl' (flip drawOne) plotC withSty+ cDone = List.foldl' drawOne plotC withSty ax = axisify cfg cDone (xmin, xmax) (ymin, ymax) legend = legendBlock@@ -358,8 +360,8 @@ ==== __Example__ @-let data = [("Apple", 45.2), ("Banana", 38.1), ("Orange", 52.7)]- chart = bars data defPlot { plotTitle = "Fruit Sales" }+let fruits = [(\"Apple\", 45.2), (\"Banana\", 38.1), (\"Orange\", 52.7)]+ chart = bars fruits defPlot { plotTitle = "Fruit Sales" } @ -} bars ::@@ -372,7 +374,7 @@ bars kvs cfg = let wC = widthChars cfg hC = heightChars cfg- vals = map snd kvs+ (catNames, vals) = unzip kvs vmax = maximum' (map abs vals) cats :: [(Text, Double, Color)]@@ -408,8 +410,8 @@ grid (0, fromIntegral (max 1 nCats)) (0, vmax)- (map fst kvs)- (fmap (+ 1) (safeHead widths))+ catNames+ (fmap (+ 1) (listToMaybe widths)) legendWidth = leftMargin cfg + 1 + gridWidth grid legend = legendBlock@@ -425,9 +427,9 @@ ==== __Example__ @-let data = [("Q1", [("Product A", 100), ("Product B", 150)]),- ("Q2", [("Product A", 120), ("Product B", 180)])]- chart = stackedBars data defPlot+let sales = [(\"Q1\", [(\"Product A\", 100), (\"Product B\", 150)]),+ (\"Q2\", [(\"Product A\", 120), (\"Product B\", 180)])]+ chart = stackedBars sales defPlot @ -} stackedBars ::@@ -489,7 +491,7 @@ (0, fromIntegral (max 1 nCats)) (0, maxHeight) (map fst categories)- (fmap (+ 1) (safeHead widths))+ (fmap (+ 1) (listToMaybe widths)) legend :: Text legend = legendBlock@@ -562,17 +564,14 @@ colsF = resampleToWidth wData fracs0 dataCols = [(colGlyphs hC f, Just BrightCyan) | f <- colsF]- gutterCol = (replicate hC ' ', Nothing)- columns = List.intercalate [gutterCol] (map pure dataCols)- grid :: [[(Char, Maybe Color)]] grid =- [ [(fst col !! y, snd col) | col <- columns]+ [ [(fst col !! y, snd col) | col <- dataCols] | y <- [0 .. hC - 1] ] ax =- axisifyGrid cfg grid (a, b) (0, fromIntegral (maximum (1 : counts))) [] Nothing+ axisifyGrid cfg grid (a, b) (0, maxC) [] Nothing legendWidth = leftMargin cfg + 1 + gridWidth grid legend = legendBlock (legendPos cfg) legendWidth [("count", Solid, BrightCyan)] in drawFrame cfg ax legend@@ -584,8 +583,8 @@ ==== __Example__ @-let data = [("Chrome", 65), ("Firefox", 20), ("Safari", 10), ("Other", 5)]- chart = pie data defPlot { plotTitle = "Browser Market Share" }+let browsers = [(\"Chrome\", 65), (\"Firefox\", 20), (\"Safari\", 10), (\"Other\", 5)]+ chart = pie browsers defPlot { plotTitle = "Browser Market Share" } @ -} pie ::@@ -780,8 +779,8 @@ grid7 = drawHLine grid6 xStart xEnd medRow '═' (Just col) - grid8 = setGridChar grid7 xMid minRow '┬' (Just col)- grid9 = setGridChar grid8 xMid maxRow '┴' (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)@@ -814,11 +813,7 @@ 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+ updateAt grid y (\row -> setAt row x (ch, col)) ansiCode :: Color -> Int ansiCode Black = 30@@ -923,10 +918,8 @@ 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+ let (cx, rx) = xDot `divMod` 2+ (cy, ry) = yDot `divMod` 4 b = toBit ry rx m = getA2D (buffer c) cx cy c' = c{buffer = setA2D (buffer c) cx cy (m .|. b)}@@ -1038,17 +1031,15 @@ baseLbl :: [Text] 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- yEnv n = AxisEnv (ymin, ymax) n 3 ySlot = max 1 left yLabels = List.foldl' ( \acc (row, v) ->- setAt acc row (justifyRight left (yFormatter cfg (yEnv row) ySlot v))+ setAt acc row+ . ellipsisize left+ . justifyRight left+ $ yFormatter cfg (yEnv row) ySlot v ) baseLbl yTicks@@ -1067,7 +1058,7 @@ placeLabels (Text.replicate (left + 1 + plotW) " ") (left + 1)- [(x, xFormatter cfg (xEnv x) slotW v) | (x, v) <- xTicks]+ [(x, xFormatter cfg (xEnv i) slotW v) | (i, (x, v)) <- zip [0 ..] xTicks] in Text.unlines (attachY <> [xBar, xLine]) axisifyGrid ::@@ -1087,16 +1078,15 @@ yTicks = ticks1D plotH (yNumTicks cfg) (ymin, ymax) True 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-- yEnv n = AxisEnv (ymin, ymax) n 3+ yEnv n = AxisEnv (ymin, ymax) n (yNumTicks cfg) ySlot = max 1 left yLabels = List.foldl'- ( \acc (row, v) -> setAt acc row (justifyRight left (yFormatter cfg (yEnv row) ySlot v))+ ( \acc (row, v) ->+ setAt acc row+ . ellipsisize left+ . justifyRight left+ $ yFormatter cfg (yEnv row) ySlot v ) baseLbl yTicks@@ -1110,25 +1100,32 @@ xBar = pad <> "└" <> Text.replicate plotW "─" - slotW =- fromMaybe- ( slotBudget- plotW- (max 1 (xNumTicks cfg))- )- w- nSlots = plotW `div` slotW- hasCategories = not (null (filter (not . Text.null) categories))- xTicks = ticks1D plotW nSlots (xmin, xmax) False- xEnv n = AxisEnv (xmin, xmax) n nSlots+ hasCategories = not (null categories) && not (all Text.null categories)+ xLine =- placeGridLabels- (Text.replicate (left + 1) " ")- slotW- ( if hasCategories- then (keepPercentiles (xNumTicks cfg) (length xTicks + 1) categories)- else [xFormatter cfg (xEnv i) slotW v | (i, (_, v)) <- zip [0 ..] xTicks]- )+ if hasCategories+ then+ let slotW =+ fromMaybe+ ( slotBudget+ plotW+ (max 1 (xNumTicks cfg))+ )+ w+ nSlots = plotW `div` slotW+ xTicks = ticks1D plotW nSlots (xmin, xmax) False+ in placeGridLabels+ (Text.replicate (left + 1) " ")+ slotW+ (keepPercentiles (xNumTicks cfg) (length xTicks + 1) categories)+ else+ let xTicks = ticks1D plotW (xNumTicks cfg) (xmin, xmax) False+ xEnv i = AxisEnv (xmin, xmax) i (xNumTicks cfg)+ slotW = slotBudget plotW (max 1 (length xTicks))+ in placeLabels+ (Text.replicate (left + 1 + plotW) " ")+ (left + 1)+ [(x, xFormatter cfg (xEnv i) slotW v) | (i, (x, v)) <- zip [0 ..] xTicks] in Text.unlines (attachY <> [xBar, xLine]) keepPercentiles :: Int -> Int -> [Text] -> [Text]@@ -1136,7 +1133,7 @@ | k <= 0 = [] | null xs = replicate k "" | n <= 1 = replicate k ""- | otherwise = (init (map (valueAt pairs) [0 .. k - 1])) ++ [last xs]+ | otherwise = map valueAt [0 .. k - 2] ++ [last xs] where m = length xs pairs :: [(Int, Text)]@@ -1148,12 +1145,10 @@ , let srcIx = (i * (m - 1)) `div` (n - 1) , let slotIx = (i * (k - 1)) `div` (n - 1) ]- valueAt :: [(Int, Text)] -> Int -> Text- valueAt [] _ = ""- valueAt ((j, v) : rest) i- | i == j = v- | otherwise = valueAt rest i + valueAt :: Int -> Text+ valueAt i = fromMaybe "" $ List.lookup i pairs+ placeLabels :: Text -> Int -> [(Int, Text)] -> Text placeLabels base off = List.foldl' place base where@@ -1166,7 +1161,7 @@ placeGridLabels base slotW = List.foldl' place base where place :: Text -> Text -> Text- place acc s = acc <> Text.take slotW (s <> (Text.replicate slotW " "))+ place acc s = acc <> Text.take slotW (s <> Text.replicate slotW " ") legendBlock :: LegendPos -> Int -> [(Text, Pat, Color)] -> Text legendBlock LegendBottom width entries =@@ -1200,8 +1195,7 @@ boundsXY :: Plot -> [(Double, Double)] -> (Double, Double, Double, Double) boundsXY cfg pts =- let xs = map fst pts- ys = map snd pts+ let (xs, ys) = unzip pts xmin = minimum' xs xmax = maximum' xs ymin = minimum' ys@@ -1261,7 +1255,7 @@ ] addAt :: [Int] -> Int -> Int -> [Int]-addAt xs i v = take i xs <> [xs !! i + v] <> drop (i + 1) xs+addAt xs i v = updateAt xs i (+ v) normalize :: [(Text, Double)] -> [(Text, Double)] normalize xs =@@ -1300,7 +1294,7 @@ in if n < 5 then let m = sum xs / fromIntegral n in (m, m, m, m, m) else- ( maybe 0 fst (List.uncons sorted)+ ( fromMaybe 0 (listToMaybe sorted) , getIdx q1Idx , getIdx q2Idx , getIdx q3Idx@@ -1318,10 +1312,6 @@ maximum' [] = 1 maximum' xs = maximum xs -safeHead :: [a] -> Maybe a-safeHead [] = Nothing-safeHead (x : _) = Just x- -- 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@@ -1420,3 +1410,39 @@ (v : vs) -> (v, vs) (r, ys3) = build (n - n `div` 2 - 1) ys2 in (mk l x r, ys3)++-- >>> setAt "abc" (-1) 'x'+-- "abc"+-- >>> setAt "abc" 0 'x'+-- "xbc"+-- >>> setAt "abc" 2 'x'+-- "abx"+-- >>> setAt "abc" 10 'x'+-- "abc"+setAt :: [a] -> Int -> a -> [a]+setAt xs i v = updateAt xs i (const v)++updateAt :: [a] -> Int -> (a -> a) -> [a]+updateAt xs i f+ | i < 0 = xs+ | otherwise = go xs i+ where+ go [] _ = []+ go (x : rest) 0 = f x : rest+ go (x : rest) n = x : go rest (n - 1)++{- | Ensure the text fits within maxWidth. If it doesn't, truncate and append an ellipsis.+>>> ellipsisize 5 "Hello, World!"+"Hell\8230"+>>> ellipsisize 1 "Hi"+"\8230"+>>> ellipsisize 0 "Hello, World!"+""+>>> ellipsisize 20 "Hello, World!"+"Hello, World!"+-}+ellipsisize :: Int -> Text -> Text+ellipsisize maxWidth lbl+ | maxWidth <= 0 = ""+ | wcswidth lbl > maxWidth = Text.take (maxWidth - 1) lbl <> "…"+ | otherwise = lbl
src/Granite/String.hs view
@@ -142,8 +142,8 @@ , plotTitle = "" , legendPos = LegendRight , colorPalette = [BrightBlue, BrightMagenta, BrightCyan, BrightGreen, BrightYellow, BrightRed, BrightWhite, BrightBlack]- , xFormatter = \_ d -> show d- , yFormatter = \_ d -> show d+ , xFormatter = \\_ _ d -> show d+ , yFormatter = \\_ _ d -> show d , xNumTicks = 2 , yNumTicks = 2 }@@ -241,8 +241,8 @@ ==== __Example__ @-let data = [("Apple", 45.2), ("Banana", 38.1), ("Orange", 52.7)]- chart = bars data defPlot { plotTitle = "Fruit Sales" }+let fruits = [(\"Apple\", 45.2), (\"Banana\", 38.1), (\"Orange\", 52.7)]+ chart = bars fruits defPlot { plotTitle = "Fruit Sales" } @ -} bars ::@@ -262,9 +262,9 @@ ==== __Example__ @-let data = [("Q1", [("Product A", 100), ("Product B", 150)]),- ("Q2", [("Product A", 120), ("Product B", 180)])]- chart = stackedBars data defPlot+let sales = [(\"Q1\", [(\"Product A\", 100), (\"Product B\", 150)]),+ (\"Q2\", [(\"Product A\", 120), (\"Product B\", 180)])]+ chart = stackedBars sales defPlot @ -} stackedBars ::@@ -313,8 +313,8 @@ ==== __Example__ @-let data = [("Chrome", 65), ("Firefox", 20), ("Safari", 10), ("Other", 5)]- chart = pie data defPlot { plotTitle = "Browser Market Share" }+let browsers = [(\"Chrome\", 65), (\"Firefox\", 20), (\"Safari\", 10), (\"Other\", 5)]+ chart = pie browsers defPlot { plotTitle = "Browser Market Share" } @ -} pie ::