packages feed

granite 0.3.0.5 → 0.4.0.0

raw patch · 5 files changed

+997/−27 lines, 5 filesdep ~granitePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: granite

API changes (from Hackage documentation)

+ Granite.Svg: AxisEnv :: (Double, Double) -> Int -> Int -> AxisEnv
+ Granite.Svg: Bins :: Int -> Double -> Double -> Bins
+ Granite.Svg: Black :: Color
+ Granite.Svg: Blue :: Color
+ Granite.Svg: BrightBlack :: Color
+ Granite.Svg: BrightBlue :: Color
+ Granite.Svg: BrightCyan :: Color
+ Granite.Svg: BrightGreen :: Color
+ Granite.Svg: BrightMagenta :: Color
+ Granite.Svg: BrightRed :: Color
+ Granite.Svg: BrightWhite :: Color
+ Granite.Svg: BrightYellow :: Color
+ Granite.Svg: Cyan :: Color
+ Granite.Svg: Default :: Color
+ Granite.Svg: Green :: Color
+ Granite.Svg: LegendBottom :: LegendPos
+ Granite.Svg: LegendNone :: LegendPos
+ Granite.Svg: LegendRight :: LegendPos
+ Granite.Svg: Magenta :: Color
+ Granite.Svg: Plot :: Int -> Int -> Int -> Int -> Int -> (Maybe Double, Maybe Double) -> (Maybe Double, Maybe Double) -> Text -> LegendPos -> [Color] -> LabelFormatter -> LabelFormatter -> Int -> Int -> Plot
+ Granite.Svg: Red :: Color
+ Granite.Svg: White :: Color
+ Granite.Svg: Yellow :: Color
+ Granite.Svg: [bottomMargin] :: Plot -> Int
+ Granite.Svg: [colorPalette] :: Plot -> [Color]
+ Granite.Svg: [domain] :: AxisEnv -> (Double, Double)
+ Granite.Svg: [heightChars] :: Plot -> Int
+ Granite.Svg: [hi] :: Bins -> Double
+ Granite.Svg: [leftMargin] :: Plot -> Int
+ Granite.Svg: [legendPos] :: Plot -> LegendPos
+ Granite.Svg: [lo] :: Bins -> Double
+ Granite.Svg: [nBins] :: Bins -> Int
+ Granite.Svg: [plotTitle] :: Plot -> Text
+ Granite.Svg: [tickCount] :: AxisEnv -> Int
+ Granite.Svg: [tickIndex] :: AxisEnv -> Int
+ Granite.Svg: [titleMargin] :: Plot -> Int
+ Granite.Svg: [widthChars] :: Plot -> Int
+ Granite.Svg: [xBounds] :: Plot -> (Maybe Double, Maybe Double)
+ Granite.Svg: [xFormatter] :: Plot -> LabelFormatter
+ Granite.Svg: [xNumTicks] :: Plot -> Int
+ Granite.Svg: [yBounds] :: Plot -> (Maybe Double, Maybe Double)
+ Granite.Svg: [yFormatter] :: Plot -> LabelFormatter
+ Granite.Svg: [yNumTicks] :: Plot -> Int
+ Granite.Svg: bars :: [(Text, Double)] -> Plot -> Text
+ Granite.Svg: bins :: Int -> Double -> Double -> Bins
+ Granite.Svg: boxPlot :: [(Text, [Double])] -> Plot -> Text
+ Granite.Svg: data AxisEnv
+ Granite.Svg: data Bins
+ Granite.Svg: data Color
+ Granite.Svg: data LegendPos
+ Granite.Svg: data Plot
+ Granite.Svg: defPlot :: Plot
+ Granite.Svg: heatmap :: [[Double]] -> Plot -> Text
+ Granite.Svg: histogram :: Bins -> [Double] -> Plot -> Text
+ Granite.Svg: lineGraph :: [(Text, [(Double, Double)])] -> Plot -> Text
+ Granite.Svg: pie :: [(Text, Double)] -> Plot -> Text
+ Granite.Svg: scatter :: [(Text, [(Double, Double)])] -> Plot -> Text
+ Granite.Svg: series :: Text -> [(Double, Double)] -> (Text, [(Double, Double)])
+ Granite.Svg: stackedBars :: [(Text, [(Text, Double)])] -> Plot -> Text
+ Granite.Svg: type LabelFormatter = AxisEnv -> Int -> Double -> Text

Files

CHANGELOG.md view
@@ -1,5 +1,8 @@ # Revision history for granite +## 0.4.0.0 -- 2026-02-27+* Add Svg support.+ ## 0.3.0.5 -- 2025-11-10 * Fix x-axis label alignment for continuous histograms. 
app/Main.hs view
@@ -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", xNumTicks = 4}+            defPlot{plotTitle = "Test Score Distribution by Class"}      -- stacked bar chart     Text.putStrLn $@@ -80,7 +80,7 @@             , ("Q3", [("Hardware", 110), ("Software", 240), ("Services", 110)])             , ("Q4", [("Hardware", 145), ("Software", 260), ("Services", 125)])             ]-            defPlot{plotTitle = "Quarterly Revenue Breakdown", xNumTicks = 4}+            defPlot{plotTitle = "Quarterly Revenue Breakdown"}      -- heatmap     let matrix =
granite.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               granite-version:            0.3.0.5+version:            0.4.0.0 synopsis:           Easy terminal plotting. description:        A terminal plotting library for quick and easy visualisation. license:            MIT@@ -42,7 +42,8 @@ library     import:           ghc-options     exposed-modules:  Granite,-                      Granite.String+                      Granite.String,+                      Granite.Svg     build-depends:         base >=4 && <5,         text >= 1 && < 3@@ -53,7 +54,7 @@     main-is:          Main.hs     build-depends:         base >=4 && <5,-        granite ^>= 0.3,+        granite ^>= 0.4,         text >= 1 && < 3     hs-source-dirs:   app     default-language: GHC2021@@ -69,7 +70,7 @@      build-depends:         base >= 4.14 && < 5-        , granite ^>= 0.3+        , granite ^>= 0.4         , text >= 1.2 && < 3         , hspec >= 2.7 && < 2.12         , QuickCheck >= 2.14 && < 2.17
src/Granite.hs view
@@ -681,9 +681,6 @@                         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)@@ -696,15 +693,44 @@             | i <- [0 .. plotH - 1]             ] -        ax =-            axisifyGrid-                cfg-                displayGrid-                (0, fromIntegral cols - 1)-                (0, fromIntegral rows - 1)-                []-                (Just (plotW `div` cols))+        plotW = widthChars cfg+        plotH = heightChars cfg +        colLabels = [Text.pack (show i) | i <- [0 .. cols - 1]]+        rowLabels = [Text.pack (show i) | i <- reverse [0 .. rows - 1]]++        cellHeight = fromIntegral plotH / fromIntegral rows+        yTicks =+            [ ( round @Double @Int (fromIntegral i * cellHeight + cellHeight / 2)+              , rowLabels !! i+              )+            | i <- [0 .. rows - 1]+            ]++        left = leftMargin cfg+        baseLbl = replicate plotH (Text.replicate left " ")+        yLabels =+            List.foldl'+                ( \acc (row, lbl) ->+                    setAt acc row (justifyRight left (ellipsisize left lbl))+                )+                baseLbl+                yTicks++        renderRow cells =+            Text.concat+                (fmap (\(ch, mc) -> maybe (Text.singleton ch) (`paint` ch) mc) cells)+        attachY = zipWith (\lbl cells -> lbl <> "│" <> renderRow cells) yLabels displayGrid++        xBar = Text.replicate left " " <> "└" <> Text.replicate plotW "─"+        xLine =+            placeGridLabels+                (Text.replicate (left + 1) " ")+                (plotW `div` cols)+                colLabels++        ax = Text.unlines (attachY <> [xBar, xLine])+         gradientLegend =             Text.pack (printf "%.2f " vmin)                 <> Text.concat (fmap (`paint` '█') intensityColors)@@ -785,14 +811,41 @@          finalGrid = List.foldl' drawBox emptyGrid (zip [0 ..] stats) -        ax =-            axisifyGrid-                cfg-                finalGrid-                (0, fromIntegral nBoxes)-                (ymin, ymax)-                (map fst datasets)-                (Just (boxWidth + spacing))+        left = leftMargin cfg+        baseLbl = replicate hC (Text.replicate left " ")++        yTicks = ticks1D hC (yNumTicks cfg) (ymin, ymax) True+        yEnv n = AxisEnv (ymin, ymax) n (yNumTicks cfg)+        ySlot = max 1 left+        yLabels =+            List.foldl'+                ( \acc (row, v) ->+                    setAt acc row . ellipsisize left . justifyRight left $+                        yFormatter cfg (yEnv row) ySlot v+                )+                baseLbl+                yTicks++        renderRow cells =+            Text.concat+                (fmap (\(ch, mc) -> maybe (Text.singleton ch) (`paint` ch) mc) cells)+        attachY = zipWith (\lbl cells -> lbl <> "│" <> renderRow cells) yLabels finalGrid++        xBar = Text.replicate left " " <> "└" <> Text.replicate wC "─"++        xLine =+            List.foldl'+                ( \acc (idx, name) ->+                    let boxCenter = idx * (boxWidth + spacing) + boxWidth `div` 2+                        lblWidth = wcswidth name+                        lblStart = left + 1 + boxCenter - lblWidth `div` 2+                     in Text.take lblStart acc <> name <> Text.drop (lblStart + wcswidth name) acc+                )+                (Text.replicate (left + 1 + wC) " ")+                (zip [0 ..] (map fst datasets))++        ax = Text.unlines (attachY <> [xBar, xLine])+         legend =             legendBlock                 (legendPos cfg)@@ -1117,7 +1170,7 @@                      in placeGridLabels                             (Text.replicate (left + 1) " ")                             slotW-                            (keepPercentiles (xNumTicks cfg) (length xTicks + 1) categories)+                            (keepPercentiles (length categories) (length xTicks + 1) categories)                 else                     let xTicks = ticks1D plotW (xNumTicks cfg) (xmin, xmax) False                         xEnv i = AxisEnv (xmin, xmax) i (xNumTicks cfg)@@ -1161,7 +1214,11 @@ 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 =+        let lblWidth = wcswidth s+            padding = max 0 ((slotW - lblWidth) `div` 2)+            centered = Text.replicate padding " " <> s+         in acc <> Text.take slotW (centered <> Text.replicate slotW " ")  legendBlock :: LegendPos -> Int -> [(Text, Pat, Color)] -> Text legendBlock LegendBottom width entries =
+ src/Granite/Svg.hs view
@@ -0,0 +1,909 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE Strict #-}++{- |+Module      : Granite.Svg+Copyright   : (c) 2025+License     : MIT+Maintainer  : mschavinda@gmail.com+Stability   : experimental+Portability : POSIX++An SVG-based plotting backend that mirrors the API of "Granite".+Every chart function returns a self-contained SVG document as 'Text'.++= Basic Usage++@+{\-# LANGUAGE OverloadedStrings #-\}+import qualified Data.Text.IO as T+import Granite.Svg++main = do+  let chart = bars [(\"Q1\",12),(\"Q2\",18),(\"Q3\",9),(\"Q4\",15)]+                   defPlot { plotTitle = \"Sales\" }+  T.writeFile \"chart.svg\" chart+@+-}+module Granite.Svg (+    -- * Re-exports from Granite+    Plot (..),+    defPlot,+    LegendPos (..),+    Color (..),+    AxisEnv (..),+    LabelFormatter,+    Bins (..),+    bins,+    series,++    -- * Chart types (SVG output)+    scatter,+    lineGraph,+    bars,+    stackedBars,+    histogram,+    pie,+    heatmap,+    boxPlot,+) where++import Data.List qualified as List+import Data.Maybe (fromMaybe, listToMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Granite (+    AxisEnv (..),+    Bins (..),+    Color (..),+    LabelFormatter,+    LegendPos (..),+    Plot (..),+    bins,+    defPlot,+    series,+ )+import Numeric (showFFloat)++------------------------------------------------------------------------+-- Scaling constants+------------------------------------------------------------------------++-- | Pixels per terminal character width.+cW :: Double+cW = 10++-- | Pixels per terminal character height.+cH :: Double+cH = 16++-- | Font size for axis labels (px).+labelFontSize :: Double+labelFontSize = 11++-- | Font size for chart title (px).+titleFontSize :: Double+titleFontSize = 14++------------------------------------------------------------------------+-- Colour mapping  (ANSI → hex)+------------------------------------------------------------------------++colorHex :: Color -> Text+colorHex Default = "#555555"+colorHex Black = "#2c3e50"+colorHex Red = "#c0392b"+colorHex Green = "#27ae60"+colorHex Yellow = "#f39c12"+colorHex Blue = "#2980b9"+colorHex Magenta = "#8e44ad"+colorHex Cyan = "#16a085"+colorHex White = "#ecf0f1"+colorHex BrightBlack = "#7f8c8d"+colorHex BrightRed = "#e74c3c"+colorHex BrightGreen = "#2ecc71"+colorHex BrightYellow = "#f1c40f"+colorHex BrightBlue = "#3498db"+colorHex BrightMagenta = "#9b59b6"+colorHex BrightCyan = "#1abc9c"+colorHex BrightWhite = "#bdc3c7"++-- Heatmap intensity palette (low → high).+heatColors :: [Text]+heatColors =+    [ "#2980b9"+    , "#3498db"+    , "#16a085"+    , "#1abc9c"+    , "#27ae60"+    , "#2ecc71"+    , "#f1c40f"+    , "#f39c12"+    , "#9b59b6"+    , "#e74c3c"+    , "#c0392b"+    ]++------------------------------------------------------------------------+-- Helpers copied from Granite (not exported there)+------------------------------------------------------------------------++clamp :: (Ord a) => a -> a -> a -> a+clamp lo hi x = max lo (min hi x)++eps :: Double+eps = 1e-12++minimum', maximum' :: [Double] -> Double+minimum' [] = 0+minimum' xs = minimum xs+maximum' [] = 1+maximum' xs = maximum xs++boundsXY :: Plot -> [(Double, Double)] -> (Double, Double, Double, Double)+boundsXY cfg pts =+    let (xs, ys) = unzip pts+        xmin = minimum' xs+        xmax = maximum' xs+        ymin = minimum' ys+        ymax = maximum' ys+        padx = (xmax - xmin) * 0.05 + 1e-9+        pady = (ymax - ymin) * 0.05 + 1e-9+     in ( fromMaybe (xmin - padx) (fst (xBounds cfg))+        , fromMaybe (xmax + padx) (snd (xBounds cfg))+        , fromMaybe (ymin - pady) (fst (yBounds cfg))+        , fromMaybe (ymax + pady) (snd (yBounds cfg))+        )++quartiles :: [Double] -> (Double, Double, Double, Double, Double)+quartiles [] = (0, 0, 0, 0, 0)+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 (listToMaybe sorted)+                , getIdx q1Idx+                , getIdx q2Idx+                , getIdx q3Idx+                , last sorted+                )++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]++ticks1D ::+    Int ->+    Int ->+    (Double, Double) ->+    Bool ->+    [(Int, Double)]+ticks1D screenLen want (vmin, vmax) invertY =+    let n = max 2 want+        lastIx = max 0 (screenLen - 1)+        toVal t =+            if invertY+                then vmax - t * (vmax - vmin)+                else vmin + t * (vmax - vmin)+        mk' k =+            let t = if n == 1 then 0 else fromIntegral k / fromIntegral (n - 1)+                pos = round (t * fromIntegral lastIx)+             in (pos, toVal t)+        raw = [mk' k | k <- [0 .. n - 1]]+     in List.nubBy (\a b -> fst a == fst b) raw++------------------------------------------------------------------------+-- Layout record+------------------------------------------------------------------------++data Layout = Layout+    { svgW :: !Double+    , svgH :: !Double+    , plotX :: !Double -- left edge of plot area+    , plotY :: !Double -- top edge of plot area+    , plotW :: !Double -- width of plot area+    , plotH :: !Double -- height of plot area+    }++mkLayout :: Plot -> Layout+mkLayout cfg =+    let pw = fromIntegral (widthChars cfg) * cW+        ph = fromIntegral (heightChars cfg) * cH+        lm = fromIntegral (leftMargin cfg) * cW + 10 -- extra padding+        tm =+            if T.null (plotTitle cfg)+                then 10+                else titleFontSize + 20+        bm = fromIntegral (bottomMargin cfg) * cH + 10+        rm = case legendPos cfg of+            LegendRight -> 120+            _ -> 20+        lb = case legendPos cfg of+            LegendBottom -> 30+            _ -> 0+     in Layout+            { svgW = lm + pw + rm+            , svgH = tm + ph + bm + lb+            , plotX = lm+            , plotY = tm+            , plotW = pw+            , plotH = ph+            }++------------------------------------------------------------------------+-- SVG primitives+------------------------------------------------------------------------++showD :: Double -> Text+showD d+    | d == fromIntegral (round d :: Int) = T.pack (show (round d :: Int))+    | otherwise = T.pack (showFFloat (Just 2) d "")++escXml :: Text -> Text+escXml =+    T.replace "&" "&amp;"+        . T.replace "<" "&lt;"+        . T.replace ">" "&gt;"+        . T.replace "\"" "&quot;"++attr :: Text -> Text -> Text+attr k v = " " <> k <> "=\"" <> v <> "\""++svgDoc :: Double -> Double -> Text -> Text+svgDoc w h content =+    "<svg xmlns=\"http://www.w3.org/2000/svg\""+        <> attr "viewBox" ("0 0 " <> showD w <> " " <> showD h)+        <> attr "width" (showD w)+        <> attr "height" (showD h)+        <> attr "font-family" "system-ui, -apple-system, sans-serif"+        <> ">\n"+        <> "<rect width=\"100%\" height=\"100%\" fill=\"white\"/>\n"+        <> content+        <> "</svg>\n"++svgRect :: Double -> Double -> Double -> Double -> Text -> Text -> Text+svgRect x y w h fill extra =+    "<rect"+        <> attr "x" (showD x)+        <> attr "y" (showD y)+        <> attr "width" (showD w)+        <> attr "height" (showD h)+        <> attr "fill" fill+        <> extra+        <> "/>\n"++svgCircle :: Double -> Double -> Double -> Text -> Text+svgCircle cx cy r fill =+    "<circle"+        <> attr "cx" (showD cx)+        <> attr "cy" (showD cy)+        <> attr "r" (showD r)+        <> attr "fill" fill+        <> "/>\n"++svgLine :: Double -> Double -> Double -> Double -> Text -> Double -> Text+svgLine x1 y1 x2 y2 stroke strokeW =+    "<line"+        <> attr "x1" (showD x1)+        <> attr "y1" (showD y1)+        <> attr "x2" (showD x2)+        <> attr "y2" (showD y2)+        <> attr "stroke" stroke+        <> attr "stroke-width" (showD strokeW)+        <> "/>\n"++svgPolyline :: [(Double, Double)] -> Text -> Double -> Text+svgPolyline pts stroke strokeW =+    "<polyline"+        <> attr "points" (T.intercalate " " [showD x <> "," <> showD y | (x, y) <- pts])+        <> attr "fill" "none"+        <> attr "stroke" stroke+        <> attr "stroke-width" (showD strokeW)+        <> attr "stroke-linejoin" "round"+        <> attr "stroke-linecap" "round"+        <> "/>\n"++svgText :: Double -> Double -> Text -> Text -> Double -> Text -> Text+svgText x y anchor fill size content =+    "<text"+        <> attr "x" (showD x)+        <> attr "y" (showD y)+        <> attr "text-anchor" anchor+        <> attr "fill" fill+        <> attr "font-size" (showD size)+        <> ">"+        <> escXml content+        <> "</text>\n"++svgPath :: Text -> Text -> Text -> Text+svgPath d fill extra =+    "<path"+        <> attr "d" d+        <> attr "fill" fill+        <> extra+        <> "/>\n"++------------------------------------------------------------------------+-- Shared drawing: title, axes, legend+------------------------------------------------------------------------++drawTitle :: Plot -> Layout -> Text+drawTitle cfg lay+    | T.null (plotTitle cfg) = ""+    | otherwise =+        svgText+            (plotX lay + plotW lay / 2)+            (plotY lay - 8)+            "middle"+            "#222"+            titleFontSize+            (plotTitle cfg)++drawAxes :: Plot -> Layout -> (Double, Double) -> (Double, Double) -> Text+drawAxes cfg lay (xmin, xmax) (ymin, ymax) =+    let px = plotX lay+        py = plotY lay+        pw = plotW lay+        ph = plotH lay++        -- Axis lines+        xAxis = svgLine px (py + ph) (px + pw) (py + ph) "#aaa" 1+        yAxis = svgLine px py px (py + ph) "#aaa" 1++        -- Y ticks+        yN = yNumTicks cfg+        yTks = ticks1D (round ph) yN (ymin, ymax) True+        ySlot = max 1 (leftMargin cfg)+        yEnv i = AxisEnv (ymin, ymax) i yN+        yElems =+            T.concat+                [ let frac = fromIntegral pos / max 1 (ph - 1)+                      yy = py + frac * ph+                      lbl = yFormatter cfg (yEnv i) ySlot v+                   in svgLine px yy (px - 4) yy "#aaa" 1+                        <> svgText (px - 8) (yy + 4) "end" "#555" labelFontSize lbl+                        <> svgLine px yy (px + pw) yy "#eee" 0.5 -- grid line+                | (i, (pos, v)) <- zip [0 ..] yTks+                ]++        -- X ticks+        xN = xNumTicks cfg+        xTks = ticks1D (round pw) xN (xmin, xmax) False+        xSlot = max 1 (round pw `div` max 1 xN)+        xEnv i = AxisEnv (xmin, xmax) i xN+        xElems =+            T.concat+                [ let frac = fromIntegral pos / max 1 (pw - 1)+                      xx = px + frac * pw+                      lbl = xFormatter cfg (xEnv i) xSlot v+                   in svgLine xx (py + ph) xx (py + ph + 4) "#aaa" 1+                        <> svgText xx (py + ph + 16) "middle" "#555" labelFontSize lbl+                        <> svgLine xx py xx (py + ph) "#eee" 0.5 -- grid line+                | (i, (pos, v)) <- zip [0 ..] xTks+                ]+     in xAxis <> yAxis <> yElems <> xElems++drawCatAxes :: Plot -> Layout -> (Double, Double) -> [Text] -> Text+drawCatAxes cfg lay (ymin, ymax) catNames =+    let px = plotX lay+        py = plotY lay+        pw = plotW lay+        ph = plotH lay++        xAxis = svgLine px (py + ph) (px + pw) (py + ph) "#aaa" 1+        yAxis = svgLine px py px (py + ph) "#aaa" 1++        -- Y ticks (value axis)+        yN = yNumTicks cfg+        yTks = ticks1D (round ph) yN (ymin, ymax) True+        ySlot = max 1 (leftMargin cfg)+        yEnv i = AxisEnv (ymin, ymax) i yN+        yElems =+            T.concat+                [ let frac = fromIntegral pos / max 1 (ph - 1)+                      yy = py + frac * ph+                      lbl = yFormatter cfg (yEnv i) ySlot v+                   in svgLine px yy (px - 4) yy "#aaa" 1+                        <> svgText (px - 8) (yy + 4) "end" "#555" labelFontSize lbl+                        <> svgLine px yy (px + pw) yy "#eee" 0.5+                | (i, (pos, v)) <- zip [0 ..] yTks+                ]++        -- X category labels+        n = length catNames+        catElems =+            T.concat+                [ let xx = px + (fromIntegral i + 0.5) * pw / fromIntegral n+                   in svgText xx (py + ph + 16) "middle" "#555" labelFontSize name+                | (i, name) <- zip [0 :: Int ..] catNames+                ]+     in xAxis <> yAxis <> yElems <> catElems++drawLegend :: Plot -> Layout -> [(Text, Color)] -> Text+drawLegend cfg lay entries = case legendPos cfg of+    LegendNone -> ""+    LegendRight ->+        let lx = plotX lay + plotW lay + 15+            ly = plotY lay + 5+         in T.concat+                [ svgRect lx (ly + fromIntegral i * 20) 12 12 (colorHex col) ""+                    <> svgText+                        (lx + 16)+                        (ly + fromIntegral i * 20 + 10)+                        "start"+                        "#555"+                        labelFontSize+                        name+                | (i, (name, col)) <- zip [0 :: Int ..] entries+                ]+    LegendBottom ->+        let ly = svgH lay - 18+            totalW = sum [30 + fromIntegral (T.length name) * 7 | (name, _) <- entries]+            startX = plotX lay + (plotW lay - totalW) / 2+         in snd $+                List.foldl'+                    ( \(x, acc) (name, col) ->+                        let elem' =+                                svgRect x (ly - 1) 12 12 (colorHex col) ""+                                    <> svgText (x + 16) (ly + 9) "start" "#555" labelFontSize name+                            w = 30 + fromIntegral (T.length name) * 7+                         in (x + w, acc <> elem')+                    )+                    (startX, "")+                    entries++------------------------------------------------------------------------+-- Coordinate mapping+------------------------------------------------------------------------++-- | Map data X to SVG X.+mapX :: Layout -> Double -> Double -> Double -> Double+mapX lay xmin xmax x =+    plotX lay + (x - xmin) / (xmax - xmin + eps) * plotW lay++-- | Map data Y to SVG Y (flipped: higher values → lower Y).+mapY :: Layout -> Double -> Double -> Double -> Double+mapY lay ymin ymax y =+    plotY lay + plotH lay - (y - ymin) / (ymax - ymin + eps) * plotH lay++------------------------------------------------------------------------+-- SCATTER+------------------------------------------------------------------------++scatter ::+    [(Text, [(Double, Double)])] ->+    Plot ->+    Text+scatter sers cfg =+    let lay = mkLayout cfg+        (xmin, xmax, ymin, ymax) = boundsXY cfg (concatMap snd sers)+        cols = cycle (colorPalette cfg)+        withCol = zip sers cols++        points =+            T.concat+                [ T.concat+                    [ svgCircle+                        (mapX lay xmin xmax x)+                        (mapY lay ymin ymax y)+                        3+                        (colorHex col)+                    | (x, y) <- pts+                    ]+                | ((_, pts), col) <- withCol+                ]++        axes = drawAxes cfg lay (xmin, xmax) (ymin, ymax)+        title = drawTitle cfg lay+        legend = drawLegend cfg lay [(n, col) | ((n, _), col) <- withCol]+     in svgDoc (svgW lay) (svgH lay) (title <> axes <> points <> legend)++------------------------------------------------------------------------+-- LINE GRAPH+------------------------------------------------------------------------++lineGraph ::+    [(Text, [(Double, Double)])] ->+    Plot ->+    Text+lineGraph sers cfg =+    let lay = mkLayout cfg+        (xmin, xmax, ymin, ymax) = boundsXY cfg (concatMap snd sers)+        cols = cycle (colorPalette cfg)+        withCol = zip sers cols++        lines' =+            T.concat+                [ let sortedPts = List.sortOn fst pts+                      svgPts =+                        [ (mapX lay xmin xmax x, mapY lay ymin ymax y)+                        | (x, y) <- sortedPts+                        ]+                   in svgPolyline svgPts (colorHex col) 2+                | ((_, pts), col) <- withCol+                ]++        axes = drawAxes cfg lay (xmin, xmax) (ymin, ymax)+        title = drawTitle cfg lay+        legend = drawLegend cfg lay [(n, col) | ((n, _), col) <- withCol]+     in svgDoc (svgW lay) (svgH lay) (title <> axes <> lines' <> legend)++------------------------------------------------------------------------+-- BARS+------------------------------------------------------------------------++bars ::+    [(Text, Double)] ->+    Plot ->+    Text+bars kvs cfg =+    let lay = mkLayout cfg+        (catNames, vals) = unzip kvs+        vmax = maximum' (map abs vals)+        cols = cycle (colorPalette cfg)+        n = length kvs++        barGap = 0.15 -- 15% gap on each side+        groupW = plotW lay / fromIntegral (max 1 n)++        rects =+            T.concat+                [ let barW = groupW * (1 - 2 * barGap)+                      barH = abs v / (vmax + eps) * plotH lay+                      bx = plotX lay + fromIntegral i * groupW + groupW * barGap+                      by = plotY lay + plotH lay - barH+                   in svgRect bx by barW barH (colorHex col) (attr "rx" "2")+                | (i, (_, v), col) <- zip3 [0 :: Int ..] kvs cols+                ]++        axes = drawCatAxes cfg lay (0, vmax) catNames+        title = drawTitle cfg lay+        legend = drawLegend cfg lay [(name, col) | ((name, _), col) <- zip kvs cols]+     in svgDoc (svgW lay) (svgH lay) (title <> axes <> rects <> legend)++------------------------------------------------------------------------+-- STACKED BARS+------------------------------------------------------------------------++stackedBars ::+    [(Text, [(Text, Double)])] ->+    Plot ->+    Text+stackedBars categories cfg =+    let lay = mkLayout cfg+        seriesNames = case categories of+            [] -> []+            (c : _) -> map fst (snd c)++        totals = [sum (map snd s) | (_, s) <- categories]+        maxH = maximum (1e-12 : totals)++        cols = cycle (colorPalette cfg)+        seriesColors = zip seriesNames cols++        nCats = length categories+        barGap = 0.1+        groupW = plotW lay / fromIntegral (max 1 nCats)++        rects =+            T.concat+                [ let barW = groupW * (1 - 2 * barGap)+                      bx = plotX lay + fromIntegral ci * groupW + groupW * barGap+                      cumHeights = scanl (+) 0 [v / maxH | (_, v) <- segs]+                   in T.concat+                        [ let bot = cumHeights !! si+                              top = cumHeights !! (si + 1)+                              segH = (top - bot) * plotH lay+                              segY = plotY lay + plotH lay - top * plotH lay+                              col = fromMaybe BrightBlue (lookup sn seriesColors)+                           in svgRect bx segY barW segH (colorHex col) (attr "rx" "1")+                        | (si, (sn, _)) <- zip [0 :: Int ..] segs+                        ]+                | (ci, (_, segs)) <- zip [0 :: Int ..] categories+                ]++        catNames = map fst categories+        axes = drawCatAxes cfg lay (0, maxH) catNames+        title = drawTitle cfg lay+        legend = drawLegend cfg lay [(n, col) | (n, col) <- seriesColors]+     in svgDoc (svgW lay) (svgH lay) (title <> axes <> rects <> legend)++------------------------------------------------------------------------+-- HISTOGRAM+------------------------------------------------------------------------++histogram ::+    Bins ->+    [Double] ->+    Plot ->+    Text+histogram (Bins nB a b) xs cfg =+    let lay = mkLayout cfg+        step = (b - a) / fromIntegral nB+        binIx x = clamp 0 (nB - 1) $ floor ((x - a) / step)+        counts0 :: [Int]+        counts0 =+            List.foldl'+                ( \acc x ->+                    if x < a || x > b+                        then acc+                        else addAt acc (binIx x) 1+                )+                (replicate nB 0)+                xs+        maxC = fromIntegral (maximum (1 : counts0)) :: Double++        barW = plotW lay / fromIntegral nB+        rects =+            T.concat+                [ let v = fromIntegral c+                      barH = v / (maxC + eps) * plotH lay+                      bx = plotX lay + fromIntegral i * barW+                      by = plotY lay + plotH lay - barH+                   in svgRect bx by (barW - 1) barH (colorHex BrightCyan) ""+                | (i, c) <- zip [0 :: Int ..] counts0+                ]++        axes = drawAxes cfg lay (a, b) (0, maxC)+        title = drawTitle cfg lay+        legend = drawLegend cfg lay [("count", BrightCyan)]+     in svgDoc (svgW lay) (svgH lay) (title <> axes <> rects <> legend)++------------------------------------------------------------------------+-- PIE+------------------------------------------------------------------------++pie ::+    [(Text, Double)] ->+    Plot ->+    Text+pie parts0 cfg =+    let lay = mkLayout cfg+        parts = normalize parts0++        cxP = plotX lay + plotW lay / 2+        cyP = plotY lay + plotH lay / 2+        r = min (plotW lay / 2) (plotH lay / 2) * 0.85++        toAng p = p * 2 * pi+        wedgeAngles = scanl (\acc (_, p) -> acc + toAng p) 0 parts+        angles = zip wedgeAngles (drop 1 wedgeAngles)++        cols = cycle (colorPalette cfg)+        withP = zip3 (map fst parts) angles cols++        slices =+            T.concat+                [ let (a0, a1) = ang+                      x0 = cxP + r * cos a0+                      y0 = cyP - r * sin a0+                      x1 = cxP + r * cos a1+                      y1 = cyP - r * sin a1+                      largeArc = if a1 - a0 > pi then "1" else "0"+                      d =+                        "M "+                            <> showD cxP+                            <> " "+                            <> showD cyP+                            <> " L "+                            <> showD x0+                            <> " "+                            <> showD y0+                            <> " A "+                            <> showD r+                            <> " "+                            <> showD r+                            <> " 0 "+                            <> largeArc+                            <> " 0 "+                            <> showD x1+                            <> " "+                            <> showD y1+                            <> " Z"+                   in svgPath d (colorHex col) (attr "stroke" "white" <> attr "stroke-width" "2")+                | (_, ang, col) <- withP+                ]++        title = drawTitle cfg lay+        legend = drawLegend cfg lay [(n, col) | (n, _, col) <- withP]+     in svgDoc (svgW lay) (svgH lay) (title <> slices <> legend)++------------------------------------------------------------------------+-- HEATMAP+------------------------------------------------------------------------++heatmap ::+    [[Double]] ->+    Plot ->+    Text+heatmap matrix cfg =+    let lay = mkLayout cfg+        rows = length matrix+        cols = case matrix of+            [] -> 0+            (r : _) -> length r++        allVals = concat matrix+        vmin = if null allVals then 0 else minimum' allVals+        vmax = if null allVals then 1 else maximum' allVals+        vrange = vmax - vmin++        nColors = length heatColors+        colorForVal v =+            if vrange < eps+                then heatColors !! (nColors `div` 2)+                else+                    let norm = clamp 0 1 ((v - vmin) / vrange)+                        idx = clamp 0 (nColors - 1) (floor (norm * fromIntegral (nColors - 1)))+                     in heatColors !! idx++        cellW = plotW lay / fromIntegral (max 1 cols)+        cellH = plotH lay / fromIntegral (max 1 rows)++        cells =+            T.concat+                [ T.concat+                    [ let cx = plotX lay + fromIntegral c * cellW+                          cy = plotY lay + fromIntegral r * cellH+                          dataRow = rows - 1 - r -- flip: row 0 at bottom+                          v = (matrix !! dataRow) !! c+                       in svgRect cx cy cellW cellH (colorForVal v) ""+                    | c <- [0 .. cols - 1]+                    ]+                | r <- [0 .. rows - 1]+                ]++        -- Axis labels: column indices on x, row indices on y+        colLabels =+            T.concat+                [ svgText+                    (plotX lay + (fromIntegral c + 0.5) * cellW)+                    (plotY lay + plotH lay + 16)+                    "middle"+                    "#555"+                    labelFontSize+                    (T.pack (show c))+                | c <- [0 .. cols - 1]+                ]+        rowLabels =+            T.concat+                [ svgText+                    (plotX lay - 8)+                    (plotY lay + (fromIntegral r + 0.5) * cellH + 4)+                    "end"+                    "#555"+                    labelFontSize+                    (T.pack (show (rows - 1 - r)))+                | r <- [0 .. rows - 1]+                ]++        -- Draw border+        border =+            svgRect+                (plotX lay)+                (plotY lay)+                (plotW lay)+                (plotH lay)+                "none"+                (attr "stroke" "#aaa" <> attr "stroke-width" "1")++        title = drawTitle cfg lay++        -- Gradient legend+        gradLegend =+            let gw = min 200 (plotW lay * 0.5)+                gh = 12+                gx = plotX lay + (plotW lay - gw) / 2+                gy = svgH lay - 22+                nSteps = 20 :: Int+                stepW = gw / fromIntegral nSteps+             in svgText+                    (gx - 5)+                    (gy + 10)+                    "end"+                    "#555"+                    labelFontSize+                    (T.pack (showFFloat (Just 2) vmin ""))+                    <> T.concat+                        [ let t = fromIntegral i / fromIntegral nSteps+                              v = vmin + t * vrange+                           in svgRect (gx + fromIntegral i * stepW) gy stepW gh (colorForVal v) ""+                        | i <- [0 .. nSteps - 1]+                        ]+                    <> svgText+                        (gx + gw + 5)+                        (gy + 10)+                        "start"+                        "#555"+                        labelFontSize+                        (T.pack (showFFloat (Just 2) vmax ""))+     in svgDoc+            (svgW lay)+            (svgH lay)+            (title <> cells <> colLabels <> rowLabels <> border <> gradLegend)++------------------------------------------------------------------------+-- BOX PLOT+------------------------------------------------------------------------++boxPlot ::+    [(Text, [Double])] ->+    Plot ->+    Text+boxPlot datasets cfg =+    let lay = mkLayout 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+        cols = cycle (colorPalette cfg)++        groupW = plotW lay / fromIntegral (max 1 nBoxes)+        boxW = groupW * 0.6++        scY v = plotY lay + plotH lay - (v - ymin) / (ymax - ymin + eps) * plotH lay++        boxes =+            T.concat+                [ let bx = plotX lay + fromIntegral i * groupW + (groupW - boxW) / 2+                      midX = bx + boxW / 2+                      (minV, q1, med, q3, maxV) = qs+                      col = colorHex c++                      -- Whisker: min to Q1+                      whiskerBot = svgLine midX (scY minV) midX (scY q1) col 1.5+                      capBot = svgLine (bx + boxW * 0.25) (scY minV) (bx + boxW * 0.75) (scY minV) col 1.5++                      -- Box: Q1 to Q3+                      boxY = scY q3+                      boxH = scY q1 - scY q3+                      box =+                        svgRect+                            bx+                            boxY+                            boxW+                            boxH+                            col+                            ( attr "fill-opacity" "0.3"+                                <> attr "stroke" col+                                <> attr "stroke-width" "1.5"+                                <> attr "rx" "2"+                            )++                      -- Median line+                      medLine = svgLine bx (scY med) (bx + boxW) (scY med) col 2.5++                      -- Whisker: Q3 to max+                      whiskerTop = svgLine midX (scY q3) midX (scY maxV) col 1.5+                      capTop = svgLine (bx + boxW * 0.25) (scY maxV) (bx + boxW * 0.75) (scY maxV) col 1.5+                   in whiskerBot <> capBot <> whiskerTop <> capTop <> box <> medLine+                | (i, (_, qs), c) <- zip3 [0 :: Int ..] stats cols+                ]++        catNames = map fst datasets+        axes = drawCatAxes cfg lay (ymin, ymax) catNames+        title = drawTitle cfg lay+        legend = drawLegend cfg lay [(name, col) | ((name, _), col) <- zip stats cols]+     in svgDoc (svgW lay) (svgH lay) (title <> axes <> boxes <> legend)++addAt :: [Int] -> Int -> Int -> [Int]+addAt xs i v = updateAt xs i (+ 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)