packages feed

granite 0.7.2.0 → 0.7.3.0

raw patch · 16 files changed

+604/−138 lines, 16 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Granite: FlameNode :: Text -> Double -> Double -> [FlameNode] -> FlameNode
+ Granite: FlameOpts :: Double -> Double -> Int -> Text -> Double -> FlameOpts
+ Granite: [fnChildren] :: FlameNode -> [FlameNode]
+ Granite: [fnLabel] :: FlameNode -> Text
+ Granite: [fnNeg] :: FlameNode -> Double
+ Granite: [fnPos] :: FlameNode -> Double
+ Granite: [foMaxDepth] :: FlameOpts -> Int
+ Granite: [foMinPx] :: FlameOpts -> Double
+ Granite: [foRowH] :: FlameOpts -> Double
+ Granite: [foTitle] :: FlameOpts -> Text
+ Granite: [foWidth] :: FlameOpts -> Double
+ Granite: data FlameNode
+ Granite: data FlameOpts
+ Granite: defFlameOpts :: FlameOpts
+ Granite: flameDiff :: FlameNode -> FlameOpts -> Text
+ Granite.Flame: FlameNode :: Text -> Double -> Double -> [FlameNode] -> FlameNode
+ Granite.Flame: FlameOpts :: Double -> Double -> Int -> Text -> Double -> FlameOpts
+ Granite.Flame: [fnChildren] :: FlameNode -> [FlameNode]
+ Granite.Flame: [fnLabel] :: FlameNode -> Text
+ Granite.Flame: [fnNeg] :: FlameNode -> Double
+ Granite.Flame: [fnPos] :: FlameNode -> Double
+ Granite.Flame: [foMaxDepth] :: FlameOpts -> Int
+ Granite.Flame: [foMinPx] :: FlameOpts -> Double
+ Granite.Flame: [foRowH] :: FlameOpts -> Double
+ Granite.Flame: [foTitle] :: FlameOpts -> Text
+ Granite.Flame: [foWidth] :: FlameOpts -> Double
+ Granite.Flame: data FlameNode
+ Granite.Flame: data FlameOpts
+ Granite.Flame: defFlameOpts :: FlameOpts
+ Granite.Flame: flameDiff :: FlameNode -> FlameOpts -> Text
+ Granite.Flame: instance GHC.Classes.Eq Granite.Flame.FlameNode
+ Granite.Flame: instance GHC.Classes.Eq Granite.Flame.FlameOpts
+ Granite.Flame: instance GHC.Show.Show Granite.Flame.FlameNode
+ Granite.Flame: instance GHC.Show.Show Granite.Flame.FlameOpts
+ Granite.Spec: SColorManual :: [(Text, ColorSpec)] -> Scale

Files

CHANGELOG.md view
@@ -1,5 +1,10 @@ # Revision history for granite +## 0.7.3.0 -- 2026-06-19+* improved long text/axis display+* flame graphs+* fix exact colour in SVG charts+ ## 0.7.1.1 -- 2026-05-25 * `GeomTile` honours a continuous `scaleFill` (`SColorContinuous`) via smooth RGB interpolation, and no longer emits a stray category legend entry. 
granite.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               granite-version:            0.7.2.0+version:            0.7.3.0 synopsis:           Easy terminal plotting. description:        A terminal plotting library for quick and easy visualisation. license:            MIT@@ -48,6 +48,7 @@                       Granite.Chart,                       Granite.Color,                       Granite.Data.Frame,+                      Granite.Flame,                       Granite.Format,                       Granite.Position,                       Granite.Render.Chrome,@@ -84,6 +85,7 @@     main-is: Spec.hs      other-modules:+        FlameSpec         Golden         GoldenSpec         GraniteSpec
src/Granite.hs view
@@ -85,6 +85,12 @@     polarLine,     waterfall,     distPlot,++    -- * Differential flame graphs+    FlameNode (..),+    FlameOpts (..),+    defFlameOpts,+    flameDiff, ) where  import Data.Bits (xor, (.&.))@@ -96,6 +102,12 @@ import Text.Printf  import Granite.Color (Color (..), paint, paletteColors, pieColors)+import Granite.Flame (+    FlameNode (..),+    FlameOpts (..),+    defFlameOpts,+    flameDiff,+ ) import Granite.Internal.LegacyChart qualified as LC import Granite.Internal.Util (     addAt,
+ src/Granite/Flame.hs view
@@ -0,0 +1,208 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE Strict #-}++{- |+Module      : Granite.Flame+Copyright   : (c) 2025+License     : MIT+Maintainer  : mschavinda@gmail.com++Differential flame\/icicle graphs. A 'FlameNode' tree carries, per frame, the+summed positive delta ('fnPos', an increase) and the magnitude of the summed+negative delta ('fnNeg', a decrease, kept non-negative). 'flameDiff' lays the+tree out top→down by depth and emits a standalone @\<svg\>@ document.++A frame's width is its /churn/ (@fnPos + fnNeg@) relative to the root churn.+Within a frame a red part (the increase) and a blue part (the decrease) are+drawn side by side, so a relocation — equal @+x@\/@-x@ — reads as half-red\/+half-blue rather than as a net regression.+-}+module Granite.Flame (+    FlameNode (..),+    FlameOpts (..),+    defFlameOpts,+    flameDiff,+) where++import Data.List (sortOn)+import Data.Ord (Down (..))+import Data.Text (Text)+import Data.Text qualified as T++import Granite.Color (Color (..), colorHex)+import Granite.Internal.Util (escXml, showD, truncatePx)+import Granite.Render.Svg (attr, svgDoc)++data FlameNode = FlameNode+    { fnLabel :: Text+    -- ^ Frame name.+    , fnPos :: Double+    {- ^ Summed positive self-delta in this subtree (an increase), in bytes+    (tooltips format it as MB).+    -}+    , fnNeg :: Double+    {- ^ Summed negative magnitude in this subtree (a decrease, @>= 0@), in bytes+    (tooltips format it as MB).+    -}+    , fnChildren :: [FlameNode]+    }+    deriving (Eq, Show)++data FlameOpts = FlameOpts+    { foWidth :: Double+    -- ^ Total SVG width in px.+    , foRowH :: Double+    -- ^ Height of one depth row in px.+    , foMaxDepth :: Int+    -- ^ Deepest depth drawn (root is depth 0).+    , foTitle :: Text+    -- ^ Heading drawn above the chart.+    , foMinPx :: Double+    -- ^ Frames narrower than this (in px) are dropped.+    }+    deriving (Eq, Show)++defFlameOpts :: FlameOpts+defFlameOpts =+    FlameOpts+        { foWidth = 1200+        , foRowH = 18+        , foMaxDepth = 24+        , foTitle = "Differential flame graph"+        , foMinPx = 1.5+        }++flameRed :: Color+flameRed = Color 206 80 80++flameBlue :: Color+flameBlue = Color 80 100 206++-- | The churn (rendered width unit) of a frame: its red + blue magnitude.+churn :: FlameNode -> Double+churn n = fnPos n + fnNeg n++{- | Render a differential flame graph as a full standalone @\<svg\>@ document.+Rows go top→down by depth; a frame's width is proportional to its churn over the+root churn; children are laid left→right sorted by descending churn; frames+narrower than 'foMinPx' are dropped and recursion stops past 'foMaxDepth'.+-}+flameDiff :: FlameNode -> FlameOpts -> Text+flameDiff root opts =+    let titleH = if T.null (foTitle opts) then 0 else 24+        body = layout opts root 0 0 (foWidth opts) titleH+        depthUsed = maxDepthDrawn opts root 0 (foWidth opts)+        svgH = titleH + fromIntegral (depthUsed + 1) * foRowH opts + 4+        heading+            | T.null (foTitle opts) = ""+            | otherwise =+                "<text"+                    <> attr "x" (showD 8)+                    <> attr "y" (showD 16)+                    <> attr "font-size" (showD 13)+                    <> attr "font-weight" "600"+                    <> attr "fill" "#222"+                    <> ">"+                    <> escXml (foTitle opts)+                    <> "</text>\n"+     in svgDoc (foWidth opts) svgH (heading <> body)++{- | Children paired with their pixel width given the parent's width @w@,+left→right in draw order (descending churn). Shared by 'layout' and+'maxDepthDrawn' so the height estimate uses the same widths that are drawn.+-}+childWidths :: FlameNode -> Double -> [(FlameNode, Double)]+childWidths node w+    | total <= 0 = []+    | otherwise =+        [(k, churn k / total * w) | k <- sortOn (Down . churn) (fnChildren node)]+  where+    total = churn node++{- | The deepest depth that will actually be drawn, so the SVG height fits the+visible frames rather than 'foMaxDepth'. Measures child widths against the+parent's narrowed width (via 'childWidths'), matching 'layout'.+-}+maxDepthDrawn :: FlameOpts -> FlameNode -> Int -> Double -> Int+maxDepthDrawn opts node depth w+    | depth >= foMaxDepth opts = depth+    | otherwise = case visible of+        [] -> depth+        kids -> maximum (depth : [maxDepthDrawn opts k (depth + 1) kw | (k, kw) <- kids])+  where+    visible = [(k, kw) | (k, kw) <- childWidths node w, kw >= foMinPx opts]++{- | Emit a frame and its children. @x0@ is the frame's left edge in px, @w@ its+width in px; children share the parent's width split by churn proportion.+-}+layout :: FlameOpts -> FlameNode -> Int -> Double -> Double -> Double -> Text+layout opts node depth x0 w yOff+    | depth > foMaxDepth opts = ""+    | w < foMinPx opts = ""+    | otherwise = frame <> childMarks+  where+    y = yOff + fromIntegral depth * foRowH opts+    frame = drawFrame opts node x0 y w+    childMarks = T.concat (go x0 (childWidths node w))+    go _ [] = []+    go cx ((k, kw) : ks) =+        layout opts k (depth + 1) cx kw yOff : go (cx + kw) ks++{- | One frame: a red rect (width ∝ 'fnPos') then a blue rect (width ∝ 'fnNeg')+side by side, a thin white stroke, a clipped label when wide enough, and a+@\<title\>@ tooltip carrying the full label plus the pos\/neg in MB.+-}+drawFrame :: FlameOpts -> FlameNode -> Double -> Double -> Double -> Text+drawFrame opts node x y w =+    "<g>" <> tooltip <> redRect <> blueRect <> label <> "</g>\n"+  where+    h = foRowH opts - 1+    total = churn node+    posW = if total <= 0 then 0 else fnPos node / total * w+    negW = w - posW+    redRect = rectAt x y posW h flameRed+    blueRect = rectAt (x + posW) y negW h flameBlue+    tooltip = "<title>" <> escXml titleTxt <> "</title>"+    titleTxt =+        fnLabel node+            <> " (+"+            <> mb (fnPos node)+            <> " / -"+            <> mb (fnNeg node)+            <> ")"+    label+        | w < 24 = ""+        | otherwise =+            let (txt, _) = truncatePx 11 (w - 6) (fnLabel node)+             in "<text"+                    <> attr "x" (showD (x + 3))+                    <> attr "y" (showD (y + h - 4))+                    <> attr "font-size" (showD 11)+                    <> attr "fill" "#fff"+                    <> ">"+                    <> escXml txt+                    <> "</text>\n"++rectAt :: Double -> Double -> Double -> Double -> Color -> Text+rectAt x y w h fill+    | w <= 0 = ""+    | otherwise =+        "<rect"+            <> attr "x" (showD x)+            <> attr "y" (showD y)+            <> attr "width" (showD w)+            <> attr "height" (showD h)+            <> attr "fill" (colorHex fill)+            <> attr "stroke" "#ffffff"+            <> attr "stroke-width" (showD 0.5)+            <> "/>\n"++-- | Format a byte magnitude as megabytes to one decimal place (e.g. @9.4MB@).+mb :: Double -> Text+mb bytes = showD (roundTo 1 (bytes / 1e6)) <> "MB"++-- | Round to @n@ decimal places.+roundTo :: Int -> Double -> Double+roundTo n x = fromIntegral (round (x * f) :: Integer) / f+  where+    f = 10 ^ n
src/Granite/Render/Chrome.hs view
@@ -272,9 +272,6 @@             , textRotate = rot             , textTitle = title             }-    -- Upright labels are truncated to their per-tick slot so they never overrun-    -- it — the only path that bites is the faceted fallback, where rotation is-    -- forbidden and a long category would otherwise overflow its cell.     upright slot (xp, full) =         let (lbl, title) = truncatePx fontSize slot full          in MText (Point xp baseY) lbl (sty AnchorMiddle 0 title)@@ -459,7 +456,7 @@                   swatch =                     MRect                         (Rect lx yy 12 12)-                        defaultStyle{styleFill = Just (colorOfSpec col)}+                        defaultStyle{styleFill = Just (exactColor col)}                   label =                     MText                         (Point (lx + 16) (yy + themeFontSize theme - 1))@@ -472,6 +469,17 @@                in [swatch, label]             | (i, (name, col)) <- zip [0 :: Int ..] entries             ]++{- | The exact RGB 'Color' for a spec, so a legend swatch matches the data it+labels (the SVG backend renders it exactly; the terminal backend quantises+'Color' to ANSI at draw time). Contrast 'colorOfSpec', which quantises eagerly.+-}+exactColor :: ColorSpec -> Color+exactColor (NamedColor c) = c+exactColor (RGB r g b) = Color r g b+exactColor (Hex h) = case parseHex h of+    Just (r, g, b) -> Color (round r) (round g) (round b)+    Nothing -> Default  {- | Quantise a 'ColorSpec' to the nearest ANSI 'Color' for the terminal backend; SVG reads RGB / Hex directly.
src/Granite/Render/Pipeline.hs view
@@ -18,6 +18,7 @@     chartToScene, ) where +import Control.Applicative ((<|>)) import Data.List qualified as List import Data.Maybe (fromMaybe) import Data.Text (Text)@@ -154,10 +155,17 @@         hasTitle = case chartTitle chart of             Just t -> not (Text.null t)             Nothing -> False-        colorMaps = map (layerColorMap palette (chartData chart)) (chartLayers chart)         fillCols = case scaleFill (chartScales chart) of             Just (SColorContinuous cs) -> Just cs             _ -> Nothing+        fillManual = case scaleFill (chartScales chart) of+            Just (SColorManual pairs) -> Just pairs+            Just (SColorDiscrete cs) -> Just (discreteFillMap chart cs)+            _ -> Nothing+        -- The fill scale is folded into 'colorMaps' here so a single+        -- category→colour map drives both the bars and the legend swatches.+        colorMaps =+            map (layerColorMap palette fillManual (chartData chart)) (chartLayers chart)         legendEntries = collectLegend (chartLayers chart) palette colorMaps         hasRightLegend = not (null legendEntries)         -- Size margins to the actual labels so long words never clip, and decide@@ -186,7 +194,14 @@         panels = layoutPanels theme box chart         panelMarks =             concatMap-                (renderPanel theme palette coord (alMode layout) colorMaps fillCols)+                ( renderPanel+                    theme+                    palette+                    coord+                    (alMode layout)+                    colorMaps+                    fillCols+                )                 panels          legend = legendMarks theme box legendEntries@@ -514,6 +529,13 @@                 | Just cats <- categoricalColorColumn frame m ->                     map (\cat -> specToColor (fromMaybe colorSpec (lookup cat levelColors))) cats             _ -> repeat col+        -- Per-bar fill colours: a categorical 'aesFill' (falling back to+        -- 'aesColor') maps each bar to its colour from 'colorMap', which already+        -- folds in any manual\/discrete fill scale (see 'layerColorMap'), so the+        -- bars and the legend swatches agree. Unmapped categories keep 'col'.+        barColors = case categoricalFillColumn frame m of+            Just cats -> map (\cat -> maybe col specToColor (colorMap >>= lookup cat)) cats+            Nothing -> repeat col         pointAlphas = case resolveNumColumn frame (aesAlpha m) of             Just vs@(_ : _) ->                 let lo = minimum vs@@ -525,9 +547,9 @@      in case layerGeom layer of             GeomPoint -> drawPoints proj frame m pointColors radius pointAlphas             GeomLine -> drawLine proj frame m colorSpec lineW-            GeomBar -> drawBars proj frame m col-            GeomCol -> drawBars proj frame m col-            GeomHistogram -> drawBars proj frame m col+            GeomBar -> drawBars proj frame m barColors+            GeomCol -> drawBars proj frame m barColors+            GeomHistogram -> drawBars proj frame m barColors             GeomRibbon -> drawRibbon proj frame m col             GeomErrorbar -> drawErrorbar proj frame m col             GeomTile -> drawTiles proj frame m col fillCols@@ -579,21 +601,26 @@                 ]         _ -> [] -drawBars :: Projector -> DataFrame -> Mapping -> Color -> [Mark]-drawBars proj frame m col =+{- | One rect per row; each painted its own colour from 'cols', cycled when+shorter than the rows, so a categorical @aesFill@\/@aesColor@ gives each bar a+distinct fill rather than a single constant colour. Empty 'cols' draws nothing.+-}+drawBars :: Projector -> DataFrame -> Mapping -> [Color] -> [Mark]+drawBars proj frame m cols =     case (resolveNumColumn frame (aesX m), resolveNumColumn frame (aesY m)) of-        (Just xs, Just ys) ->-            let bases = case lookupColumn "__ybase" frame >>= columnAsNum of-                    Just bs | length bs == length xs -> bs-                    _ -> replicate (length xs) 0-                w = barWidth xs-                halfW = w / 2-             in [ rectFromCorners-                    (proj (x - halfW) y0)-                    (proj (x + halfW) y1)-                    col-                | (x, y1, y0) <- zip3 xs ys bases-                ]+        (Just xs, Just ys)+            | not (null cols) ->+                let bases = case lookupColumn "__ybase" frame >>= columnAsNum of+                        Just bs | length bs == length xs -> bs+                        _ -> replicate (length xs) 0+                    w = barWidth xs+                    halfW = w / 2+                 in [ rectFromCorners+                        (proj (x - halfW) y0)+                        (proj (x + halfW) y1)+                        c+                    | ((x, y1, y0), c) <- zip (zip3 xs ys bases) (cycle cols)+                    ]         _ -> []  barWidth :: [Double] -> Double@@ -1010,21 +1037,41 @@         | null palette = NamedColor BrightBlue         | otherwise = palette !! (i `mod` length palette) -{- | The per-category colour map for a layer, when its 'aesColor' maps to a-categorical ('ColCat') column. Computed once from the whole-chart layer frame so-colours stay consistent across facets and the legend; 'Nothing' otherwise-(numeric colour columns keep the single per-layer colour).+{- | The per-category colour map for a layer, when its 'aesColor' (points) or+'aesFill' (bars) maps to a categorical ('ColCat') column. Computed once from the+whole-chart layer frame so colours stay consistent across facets, bars, and the+legend; 'Nothing' otherwise (numeric colour columns keep the single per-layer+colour). For bars, any manual\/discrete fill scale ('fillOverride') is layered+on top of the palette so the legend swatches match the bars. -}-layerColorMap :: [ColorSpec] -> DataFrame -> Layer -> Maybe [(Text, ColorSpec)]-layerColorMap palette globalFrame layer+layerColorMap ::+    [ColorSpec] ->+    Maybe [(Text, ColorSpec)] ->+    DataFrame ->+    Layer ->+    Maybe [(Text, ColorSpec)]+layerColorMap palette fillOverride globalFrame layer     | GeomPoint <- layerGeom layer-    , Just cats <--        categoricalColorColumn-            (fromMaybe globalFrame (layerData layer))-            (layerMapping layer) =+    , Just cats <- categoricalColorColumn frame (layerMapping layer) =         Just (categoricalColorMap palette (List.nub cats))+    | isBarGeom (layerGeom layer)+    , Just cats <- categoricalFillColumn frame (layerMapping layer) =+        Just (overrideColors fillOverride (categoricalColorMap palette (List.nub cats)))     | otherwise = Nothing+  where+    frame = fromMaybe globalFrame (layerData layer) +{- | Replace the colour of each category that appears in 'override', leaving the+rest at their base (palette) colour.+-}+overrideColors ::+    Maybe [(Text, ColorSpec)] -> [(Text, ColorSpec)] -> [(Text, ColorSpec)]+overrideColors override base =+    [(cat, fromMaybe spec (override >>= lookup cat)) | (cat, spec) <- base]++isBarGeom :: Geom -> Bool+isBarGeom g = g `elem` [GeomBar, GeomCol, GeomHistogram]+ {- | The categorical ('ColCat') colour column's per-row values, if 'aesColor' maps to one. -}@@ -1034,6 +1081,36 @@         Just (ColCat xs) -> Just xs         _ -> Nothing     Nothing -> Nothing++{- | The categorical ('ColCat') fill column's per-row values: 'aesFill' if it+maps to one, else 'aesColor'. Drives per-bar colours for bar\/col\/histogram.+-}+categoricalFillColumn :: DataFrame -> Mapping -> Maybe [Text]+categoricalFillColumn frame m =+    catColumn (aesFill m) <|> categoricalColorColumn frame m+  where+    catColumn ref = case ref of+        Just (ColumnRef n) -> case lookupColumn n frame of+            Just (ColCat xs) -> Just xs+            _ -> Nothing+        Nothing -> Nothing++{- | Resolve a discrete fill scale ('SColorDiscrete') into an explicit+category→colour map by zipping the chart's fill categories against the supplied+colours (cycled), so it reduces to the same manual-map path bars consume.+-}+discreteFillMap :: Chart -> [ColorSpec] -> [(Text, ColorSpec)]+discreteFillMap chart specs+    | null specs = []+    | otherwise = zip cats (cycle specs)+  where+    cats =+        List.nub $+            concat+                [ fromMaybe [] (categoricalFillColumn (layerFrame l) (layerMapping l))+                | l <- chartLayers chart+                ]+    layerFrame l = fromMaybe (chartData chart) (layerData l)  {- | Legend entries. A categorical-'aesColor' layer contributes one entry per category value (colours matching the points via 'layerColorMap'); 'GeomText'
src/Granite/Scale.hs view
@@ -46,6 +46,7 @@     SDiscrete -> trainLinear (defaultOpts BreaksNice) dataRange     SColorContinuous _ -> trainLinear (defaultOpts BreaksNice) dataRange     SColorDiscrete _ -> trainLinear (defaultOpts BreaksNice) dataRange+    SColorManual _ -> trainLinear (defaultOpts BreaksNice) dataRange  defaultOpts :: BreaksSpec -> ScaleOpts defaultOpts brk =
src/Granite/Spec.hs view
@@ -237,6 +237,11 @@     | SDiscrete     | SColorContinuous [ColorSpec]     | SColorDiscrete [ColorSpec]+    | {- | Map specific category values to specific colours (e.g.+      @[("NEW", Hex "#ce5050"), ("grown", Hex "#e0a030")]@); values+      not listed fall back to the layer\/constant colour.+      -}+      SColorManual [(Text, ColorSpec)]     deriving (Eq, Show, Read)  data Scales = Scales
+ test/FlameSpec.hs view
@@ -0,0 +1,51 @@+{-# LANGUAGE OverloadedStrings #-}++module FlameSpec (spec) where++import Data.Text qualified as Text++import Granite.Flame+import Test.Hspec++-- A 3-level tree: a root with two children, one of which has a child. Mixes+-- increases (fnPos) and decreases (fnNeg) so both colours must appear.+sampleTree :: FlameNode+sampleTree =+    FlameNode+        { fnLabel = "root"+        , fnPos = 9.0e6+        , fnNeg = 7.0e6+        , fnChildren =+            [ FlameNode+                { fnLabel = "Parse.parseModule"+                , fnPos = 8.0e6+                , fnNeg = 2.0e6+                , fnChildren =+                    [ FlameNode "lexer" 1.0e6 3.0e6 []+                    ]+                }+            , FlameNode "Typecheck.check" 1.0e6 5.0e6 []+            ]+        }++spec :: Spec+spec = describe "Granite.Flame.flameDiff" $ do+    let svg = flameDiff sampleTree defFlameOpts++    it "emits a standalone <svg> document" $ do+        svg `shouldSatisfy` Text.isInfixOf "<svg"+        svg `shouldSatisfy` Text.isInfixOf "viewBox"+        svg `shouldSatisfy` Text.isInfixOf "</svg>"++    it "draws both red (increase) and blue (decrease) rects" $ do+        svg `shouldSatisfy` Text.isInfixOf "#ce50"+        svg `shouldSatisfy` Text.isInfixOf "#5064ce"++    it "carries a <title> tooltip with the full label and pos/neg detail" $ do+        svg `shouldSatisfy` Text.isInfixOf "<title>"+        svg `shouldSatisfy` Text.isInfixOf "Parse.parseModule"+        svg `shouldSatisfy` Text.isInfixOf "MB"++    it "renders the title heading" $ do+        flameDiff sampleTree defFlameOpts{foTitle = "My flame"}+            `shouldSatisfy` Text.isInfixOf "My flame"
test/GoldenSpec.hs view
@@ -244,16 +244,39 @@         { chartSize = SizeChars 30 14         } --- Long category names under FacetWrap: rotation reserves no per-cell margin, so--- faceted panels must fall back to upright (never rotate/clip). Guards the--- facet branch of the x-label policy.+-- Long category names under FacetWrap (faceted by a separate column, so each+-- panel still shows all the long x categories): rotation reserves no per-cell+-- margin, so faceted panels fall back to upright and truncate each label to its+-- per-tick slot rather than rotate/clip. Guards the facet branch of the policy. facetedLongNames :: Chart facetedLongNames =-    barLongNames-        { chartFacet = FacetWrap (ColumnRef "fn") (Just 2) Nothing ScalesFixed-        , chartTitle = Just "Faceted long names"-        , chartSize = SizeChars 60 20-        }+    let names =+            [ "GHC.Core.Opt.Simplify.Iteration.Inline.Worker"+            , "mkFastStringBytes"+            , "GHC.Tc.Solver.Monad"+            , "GHC.CmmToAsm.X86.Ppr"+            ]+        grp = ["before", "before", "after", "after"]+        ys = [29.0, 18.5, 12.3, 9.1] :: [Double]+        df =+            fromColumns+                [("fn", ColCat names), ("phase", ColCat grp), ("mb", ColNum ys)]+        layer =+            (defLayer GeomBar)+                { layerMapping =+                    emptyMapping+                        { aesX = Just (ColumnRef "fn")+                        , aesY = Just (ColumnRef "mb")+                        }+                , layerStat = StatIdentity+                }+     in emptyChart+            { chartData = df+            , chartLayers = [layer]+            , chartFacet = FacetWrap (ColumnRef "phase") (Just 2) Nothing ScalesFree+            , chartTitle = Just "Faceted long names"+            , chartSize = SizeChars 70 18+            }  -- Histogram of 50 numeric values bucketed into 8 bins. Exercises -- StatBin + GeomHistogram. aesY points to the @count@ column that
test/PipelineSpec.hs view
@@ -2,6 +2,7 @@  module PipelineSpec (spec) where +import Data.List qualified as List import Data.Text qualified as Text  import Granite.Color qualified as Col@@ -41,6 +42,57 @@                         }                 } +-- A horizontal bar chart whose bars are categorically filled by "status".+statusBarChart :: Chart+statusBarChart =+    emptyChart+        { chartSize = SizeChars 40 20+        , chartCoord = CoordFlip+        , chartData =+            fromColumns+                [ ("module", ColNum [0, 1, 2])+                , ("delta", ColNum [40, 20, 30])+                , ("status", ColCat ["NEW", "grown", "shrunk"])+                ]+        , chartLayers =+            [ (defLayer GeomCol)+                { layerMapping =+                    emptyMapping+                        { aesX = Just (ColumnRef "module")+                        , aesY = Just (ColumnRef "delta")+                        , aesFill = Just (ColumnRef "status")+                        }+                }+            ]+        }++-- The distinct @fill="#…"@ values on @<rect>@ elements (data bars), ignoring+-- the white background rect.+distinctRectFills :: Text.Text -> [Text.Text]+distinctRectFills svg =+    let rects = drop 1 (Text.splitOn "<rect" svg)+        fillOf chunk = case Text.splitOn "fill=\"" chunk of+            (_ : rest : _) -> Just (Text.takeWhile (/= '"') rest)+            _ -> Nothing+        fills = [f | Just f <- map fillOf rects, f /= "white"]+     in dedup fills+  where+    dedup = foldr (\x acc -> if x `elem` acc then acc else x : acc) []++-- The distinct fills on the 12×12 legend swatch rects, so a test can assert the+-- legend swatches use the same colours as the bars.+legendSwatchFills :: Text.Text -> [Text.Text]+legendSwatchFills svg =+    let rects = drop 1 (Text.splitOn "<rect" svg)+        swatch chunk = Text.isInfixOf "width=\"12\"" chunk && Text.isInfixOf "height=\"12\"" chunk+        fillOf chunk = case Text.splitOn "fill=\"" chunk of+            (_ : rest : _) -> Just (Text.takeWhile (/= '"') rest)+            _ -> Nothing+        fills = [f | chunk <- rects, swatch chunk, Just f <- [fillOf chunk]]+     in dedup fills+  where+    dedup = foldr (\x acc -> if x `elem` acc then acc else x : acc) []+ spec :: Spec spec = describe "Granite.Render.Pipeline" $ do     describe "scatter Chart through the IR" $ do@@ -150,6 +202,53 @@             svg `shouldSatisfy` Text.isInfixOf ">a</text>"             svg `shouldSatisfy` Text.isInfixOf ">c</text>"             svg `shouldNotSatisfy` Text.isInfixOf "series 0"++        it "horizontal bars honour a categorical aesFill (distinct fills)" $ do+            let svg = renderChartSvg statusBarChart{chartScales = defScales}+            length (distinctRectFills svg) `shouldSatisfy` (>= 2)++        it "a manual fill scale maps status values to specific colours" $ do+            let chart =+                    statusBarChart+                        { chartScales =+                            defScales+                                { scaleFill =+                                    Just+                                        ( SColorManual+                                            [ ("NEW", Hex "#ce5050")+                                            , ("grown", Hex "#e0a030")+                                            , ("shrunk", Hex "#50a050")+                                            ]+                                        )+                                }+                        }+                svg = renderChartSvg chart+            svg `shouldSatisfy` Text.isInfixOf "#ce5050"+            svg `shouldSatisfy` Text.isInfixOf "#e0a030"+            svg `shouldSatisfy` Text.isInfixOf "#50a050"++        it "the legend swatches use the same colours as the bars" $ do+            let chart =+                    statusBarChart+                        { chartScales =+                            defScales+                                { scaleFill =+                                    Just+                                        ( SColorManual+                                            [ ("NEW", Hex "#ce5050")+                                            , ("grown", Hex "#e0a030")+                                            , ("shrunk", Hex "#50a050")+                                            ]+                                        )+                                }+                        }+                svg = renderChartSvg chart+            -- Bars and swatches draw from one map, so no palette colour leaks in:+            -- the only rect fills are the three manual colours.+            List.sort (distinctRectFills svg)+                `shouldBe` ["#50a050", "#ce5050", "#e0a030"]+            List.sort (legendSwatchFills svg)+                `shouldBe` ["#50a050", "#ce5050", "#e0a030"]      describe "Log-scale chart" $ do         let logChart =
test/golden/facet-long-names.svg view
@@ -1,60 +1,27 @@-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 320" width="600" height="320" font-family="system-ui, -apple-system, sans-serif">+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 288" width="700" height="288" font-family="system-ui, -apple-system, sans-serif"> <rect width="100%" height="100%" fill="white"/>-<line x1="60" y1="106.07" x2="262.50" y2="106.07" stroke="#ecf0f1" stroke-width="1"/>-<line x1="60" y1="66" x2="60" y2="106.07" stroke="#ecf0f1" stroke-width="1"/>-<text x="81.90" y="121.07" text-anchor="middle" fill="#7f8c8d" font-size="11"><title>GHC.Core.Opt.Simplify.Iteration.Inline.Worker</title>GHC.Core.Opt.Simplify.Iterati…</text>-<text x="54" y="107.91" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>-<text x="54" y="95.35" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>-<text x="54" y="82.79" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>-<text x="54" y="70.23" text-anchor="end" fill="#7f8c8d" font-size="11">30.0</text>-<rect x="69.20" y="67.82" width="25.39" height="36.42" fill="#3498db"/>-<text x="161.25" y="62" text-anchor="middle" fill="#555555" font-size="11"><title>GHC.Core.Opt.Simplify.Iteration.Inline.Worker</title>GHC.Core.Opt.Simplify.Iterati…</text>-<line x1="272.50" y1="106.07" x2="475" y2="106.07" stroke="#ecf0f1" stroke-width="1"/>-<line x1="272.50" y1="66" x2="272.50" y2="106.07" stroke="#ecf0f1" stroke-width="1"/>-<text x="294.40" y="121.07" text-anchor="middle" fill="#7f8c8d" font-size="11">mkFastStringBytes</text>-<text x="266.50" y="107.91" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>-<text x="266.50" y="95.35" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>-<text x="266.50" y="82.79" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>-<text x="266.50" y="70.23" text-anchor="end" fill="#7f8c8d" font-size="11">30.0</text>-<rect x="281.70" y="81.01" width="25.39" height="23.24" fill="#3498db"/>-<text x="373.75" y="62" text-anchor="middle" fill="#555555" font-size="11">mkFastStringBytes</text>-<line x1="60" y1="192.13" x2="262.50" y2="192.13" stroke="#ecf0f1" stroke-width="1"/>-<line x1="60" y1="152.07" x2="60" y2="192.13" stroke="#ecf0f1" stroke-width="1"/>-<text x="81.90" y="207.13" text-anchor="middle" fill="#7f8c8d" font-size="11">GHC.Tc.Solver.Monad</text>-<text x="54" y="193.98" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>-<text x="54" y="181.42" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>-<text x="54" y="168.86" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>-<text x="54" y="156.30" text-anchor="end" fill="#7f8c8d" font-size="11">30.0</text>-<rect x="69.20" y="174.86" width="25.39" height="15.45" fill="#3498db"/>-<text x="161.25" y="148.07" text-anchor="middle" fill="#555555" font-size="11">GHC.Tc.Solver.Monad</text>-<line x1="272.50" y1="192.13" x2="475" y2="192.13" stroke="#ecf0f1" stroke-width="1"/>-<line x1="272.50" y1="152.07" x2="272.50" y2="192.13" stroke="#ecf0f1" stroke-width="1"/>-<text x="294.40" y="207.13" text-anchor="middle" fill="#7f8c8d" font-size="11">GHC.CmmToAsm.X86.Ppr</text>-<text x="266.50" y="193.98" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>-<text x="266.50" y="181.42" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>-<text x="266.50" y="168.86" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>-<text x="266.50" y="156.30" text-anchor="end" fill="#7f8c8d" font-size="11">30.0</text>-<rect x="281.70" y="178.88" width="25.39" height="11.43" fill="#3498db"/>-<text x="373.75" y="148.07" text-anchor="middle" fill="#555555" font-size="11">GHC.CmmToAsm.X86.Ppr</text>-<line x1="60" y1="278.20" x2="262.50" y2="278.20" stroke="#ecf0f1" stroke-width="1"/>-<line x1="60" y1="238.13" x2="60" y2="278.20" stroke="#ecf0f1" stroke-width="1"/>-<text x="81.90" y="293.20" text-anchor="middle" fill="#7f8c8d" font-size="11">GHC.Stg.Unarise</text>-<text x="54" y="280.05" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>-<text x="54" y="267.49" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>-<text x="54" y="254.93" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>-<text x="54" y="242.37" text-anchor="end" fill="#7f8c8d" font-size="11">30.0</text>-<rect x="69.20" y="268.34" width="25.39" height="8.04" fill="#3498db"/>-<text x="161.25" y="234.13" text-anchor="middle" fill="#555555" font-size="11">GHC.Stg.Unarise</text>-<line x1="272.50" y1="278.20" x2="475" y2="278.20" stroke="#ecf0f1" stroke-width="1"/>-<line x1="272.50" y1="238.13" x2="272.50" y2="278.20" stroke="#ecf0f1" stroke-width="1"/>-<text x="294.40" y="293.20" text-anchor="middle" fill="#7f8c8d" font-size="11">GHC.HsToCore.Match.Constructor</text>-<text x="266.50" y="280.05" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>-<text x="266.50" y="267.49" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>-<text x="266.50" y="254.93" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>-<text x="266.50" y="242.37" text-anchor="end" fill="#7f8c8d" font-size="11">30.0</text>-<rect x="281.70" y="271.10" width="25.39" height="5.28" fill="#3498db"/>-<text x="373.75" y="234.13" text-anchor="middle" fill="#555555" font-size="11">GHC.HsToCore.Match.Constructor</text>-<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Faceted long names</text>-<rect x="495" y="39" width="12" height="12" fill="#3498db"/>-<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>+<line x1="60" y1="246.20" x2="312.50" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>+<line x1="60" y1="66" x2="60" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>+<text x="122.49" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11"><title>GHC.Core.Opt.Simplify.Iteration.Inline.Worker</title>GHC.Core.Opt.Simpl…</text>+<text x="250.01" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">mkFastStringBytes</text>+<text x="54" y="241.68" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>+<text x="54" y="185.19" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>+<text x="54" y="128.70" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>+<text x="54" y="72.21" text-anchor="end" fill="#7f8c8d" font-size="11">30.0</text>+<rect x="71.48" y="74.19" width="102.02" height="163.82" fill="#3498db"/>+<rect x="199.00" y="133.50" width="102.02" height="104.50" fill="#3498db"/>+<text x="186.25" y="62" text-anchor="middle" fill="#555555" font-size="11">before</text>+<line x1="322.50" y1="246.20" x2="575" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>+<line x1="322.50" y1="66" x2="322.50" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>+<text x="384.99" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">GHC.Tc.Solver.Monad</text>+<text x="512.51" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11"><title>GHC.CmmToAsm.X86.Ppr</title>GHC.CmmToAsm.X86.P…</text>+<text x="316.50" y="241.68" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>+<text x="316.50" y="175.08" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>+<text x="316.50" y="108.49" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>+<rect x="333.98" y="74.19" width="102.02" height="163.82" fill="#3498db"/>+<rect x="461.50" y="116.81" width="102.02" height="121.20" fill="#3498db"/>+<text x="448.75" y="62" text-anchor="middle" fill="#555555" font-size="11">after</text>+<text x="317.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Faceted long names</text>+<rect x="595" y="39" width="12" height="12" fill="#3498db"/>+<text x="611" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text> </svg>
test/golden/plotly-bar-grouped.svg view
@@ -10,18 +10,22 @@ <text x="49" y="142.79" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text> <text x="49" y="62.54" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text> <rect x="74.32" y="123.08" width="20.88" height="96.30" fill="#3498db"/>-<rect x="100.42" y="155.17" width="20.88" height="64.20" fill="#3498db"/>-<rect x="126.53" y="187.27" width="20.88" height="32.10" fill="#3498db"/>+<rect x="100.42" y="155.17" width="20.88" height="64.20" fill="#9b59b6"/>+<rect x="126.53" y="187.27" width="20.88" height="32.10" fill="#1abc9c"/> <rect x="178.74" y="99.00" width="20.88" height="120.37" fill="#3498db"/>-<rect x="204.85" y="139.12" width="20.88" height="80.25" fill="#3498db"/>-<rect x="230.95" y="171.22" width="20.88" height="48.15" fill="#3498db"/>+<rect x="204.85" y="139.12" width="20.88" height="80.25" fill="#9b59b6"/>+<rect x="230.95" y="171.22" width="20.88" height="48.15" fill="#1abc9c"/> <rect x="283.16" y="74.93" width="20.88" height="144.45" fill="#3498db"/>-<rect x="309.27" y="123.08" width="20.88" height="96.30" fill="#3498db"/>-<rect x="335.37" y="155.17" width="20.88" height="64.20" fill="#3498db"/>+<rect x="309.27" y="123.08" width="20.88" height="96.30" fill="#9b59b6"/>+<rect x="335.37" y="155.17" width="20.88" height="64.20" fill="#1abc9c"/> <rect x="387.59" y="42.83" width="20.88" height="176.55" fill="#3498db"/>-<rect x="413.69" y="107.03" width="20.88" height="112.35" fill="#3498db"/>-<rect x="439.80" y="139.12" width="20.88" height="80.25" fill="#3498db"/>+<rect x="413.69" y="107.03" width="20.88" height="112.35" fill="#9b59b6"/>+<rect x="439.80" y="139.12" width="20.88" height="80.25" fill="#1abc9c"/> <text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Sales by quarter (grouped)</text> <rect x="495" y="39" width="12" height="12" fill="#3498db"/>-<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">product</text>+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">Product A</text>+<rect x="495" y="56" width="12" height="12" fill="#9b59b6"/>+<text x="511" y="66" text-anchor="start" fill="#7f8c8d" font-size="11">Product B</text>+<rect x="495" y="73" width="12" height="12" fill="#1abc9c"/>+<text x="511" y="83" text-anchor="start" fill="#7f8c8d" font-size="11">Product C</text> </svg>
test/golden/plotly-bar-grouped.txt view
@@ -1,16 +1,16 @@                                                                           [90mSales by quarter (grouped)                     [0m      [37m│                                [0m[94m███        ██         [0m-[90m20.0 [0m[37m│                                [0m[94m███        ██[0m[90mproduct  [0m-     [37m│                      [0m[94m███       ███                   [0m-     [37m│                      [0m[94m███       ███                   [0m-     [37m│           [0m[94m███        ███       ██████                [0m-     [37m│ [0m[94m███       ███        █████     ██████                [0m-[90m10.0 [0m[37m│ [0m[94m███       ██████     █████     ████████              [0m-     [37m│ [0m[94m██████    ██████     ████████  ████████              [0m-     [37m│ [0m[94m██████    █████████  ████████  ████████              [0m-     [37m│ [0m[94m████████  █████████  ████████  ████████              [0m-     [37m│ [0m[94m████████  █████████  ████████  ████████              [0m- [90m0.0 [0m[37m│ [0m[94m████████  █████████  ████████  ████████              [0m+[90m20.0 [0m[37m│                                [0m[94m███        [0m[95m██[0m[90mProduct A[0m+     [37m│                      [0m[94m███       ███        [0m[96m██[0m[90mProduct B[0m+     [37m│                      [0m[94m███       ███        [0m[96m██[0m[90mProduct C[0m+     [37m│           [0m[94m███        ███       ███[0m[95m███                [0m+     [37m│ [0m[94m███       ███        ██[0m[95m███     [0m[94m███[0m[95m███                [0m+[90m10.0 [0m[37m│ [0m[94m███       ███[0m[95m███     [0m[94m██[0m[95m███     [0m[94m███[0m[95m██[0m[96m███              [0m+     [37m│ [0m[94m███[0m[95m███    [0m[94m███[0m[95m███     [0m[94m██[0m[95m███[0m[96m███  [0m[94m███[0m[95m██[0m[96m███              [0m+     [37m│ [0m[94m███[0m[95m███    [0m[94m███[0m[95m███[0m[96m███  [0m[94m██[0m[95m███[0m[96m███  [0m[94m███[0m[95m██[0m[96m███              [0m+     [37m│ [0m[94m███[0m[95m██[0m[96m███  [0m[94m███[0m[95m███[0m[96m███  [0m[94m██[0m[95m███[0m[96m███  [0m[94m███[0m[95m██[0m[96m███              [0m+     [37m│ [0m[94m███[0m[95m██[0m[96m███  [0m[94m███[0m[95m███[0m[96m███  [0m[94m██[0m[95m███[0m[96m███  [0m[94m███[0m[95m██[0m[96m███              [0m+ [90m0.0 [0m[37m│ [0m[94m███[0m[95m██[0m[96m███  [0m[94m███[0m[95m███[0m[96m███  [0m[94m██[0m[95m███[0m[96m███  [0m[94m███[0m[95m██[0m[96m███              [0m      [37m┼───────────────────────────────────────────           [0m           [90m0.0       1.0       2.0        3.0                [0m
test/golden/plotly-bar-stacked.svg view
@@ -10,18 +10,22 @@ <text x="49" y="146.28" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text> <text x="49" y="69.52" text-anchor="end" fill="#7f8c8d" font-size="11">40.0</text> <rect x="74.32" y="173.32" width="81.34" height="46.06" fill="#3498db"/>-<rect x="74.32" y="142.61" width="81.34" height="30.70" fill="#3498db"/>-<rect x="74.32" y="127.26" width="81.34" height="15.35" fill="#3498db"/>+<rect x="74.32" y="142.61" width="81.34" height="30.70" fill="#9b59b6"/>+<rect x="74.32" y="127.26" width="81.34" height="15.35" fill="#1abc9c"/> <rect x="175.99" y="161.80" width="81.34" height="57.57" fill="#3498db"/>-<rect x="175.99" y="123.42" width="81.34" height="38.38" fill="#3498db"/>-<rect x="175.99" y="100.40" width="81.34" height="23.03" fill="#3498db"/>+<rect x="175.99" y="123.42" width="81.34" height="38.38" fill="#9b59b6"/>+<rect x="175.99" y="100.40" width="81.34" height="23.03" fill="#1abc9c"/> <rect x="277.67" y="150.29" width="81.34" height="69.08" fill="#3498db"/>-<rect x="277.67" y="104.23" width="81.34" height="46.06" fill="#3498db"/>-<rect x="277.67" y="73.53" width="81.34" height="30.70" fill="#3498db"/>+<rect x="277.67" y="104.23" width="81.34" height="46.06" fill="#9b59b6"/>+<rect x="277.67" y="73.53" width="81.34" height="30.70" fill="#1abc9c"/> <rect x="379.34" y="134.94" width="81.34" height="84.43" fill="#3498db"/>-<rect x="379.34" y="81.21" width="81.34" height="53.73" fill="#3498db"/>-<rect x="379.34" y="42.83" width="81.34" height="38.38" fill="#3498db"/>+<rect x="379.34" y="81.21" width="81.34" height="53.73" fill="#9b59b6"/>+<rect x="379.34" y="42.83" width="81.34" height="38.38" fill="#1abc9c"/> <text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Sales by quarter (stacked)</text> <rect x="495" y="39" width="12" height="12" fill="#3498db"/>-<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">product</text>+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">Product A</text>+<rect x="495" y="56" width="12" height="12" fill="#9b59b6"/>+<text x="511" y="66" text-anchor="start" fill="#7f8c8d" font-size="11">Product B</text>+<rect x="495" y="73" width="12" height="12" fill="#1abc9c"/>+<text x="511" y="83" text-anchor="start" fill="#7f8c8d" font-size="11">Product C</text> </svg>
test/golden/plotly-bar-stacked.txt view
@@ -1,14 +1,14 @@                                                                           [90mSales by quarter (stacked)                     [0m-     [37m│                               [0m[94m█████████   ██         [0m-     [37m│                               [0m[94m█████████   ██[0m[90mproduct  [0m-[90m40.0 [0m[37m│                     [0m[94m█████████ █████████              [0m-     [37m│                     [0m[94m█████████ █████████              [0m-     [37m│           [0m[94m█████████ █████████ █████████              [0m-     [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m-     [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m-[90m20.0 [0m[37m│ [0m[94m█████████ █████████ █████████ █████████              [0m-     [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m+     [37m│                               [0m[96m█████████   [0m[94m██         [0m+     [37m│                               [0m[96m█████████   [0m[95m██[0m[90mProduct A[0m+[90m40.0 [0m[37m│                     [0m[96m█████████ █████████   ██[0m[90mProduct B[0m+     [37m│                     [0m[96m█████████ █████████   ██[0m[90mProduct C[0m+     [37m│           [0m[96m█████████ █████████ [0m[95m█████████              [0m+     [37m│ [0m[96m█████████ █████████ [0m[95m█████████ █████████              [0m+     [37m│ [0m[96m█████████ [0m[95m█████████ █████████ █████████              [0m+[90m20.0 [0m[37m│ [0m[95m█████████ █████████ █████████ [0m[94m█████████              [0m+     [37m│ [0m[95m█████████ █████████ [0m[94m█████████ █████████              [0m      [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m      [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m  [90m0.0 [0m[37m│ [0m[94m█████████ █████████ █████████ █████████              [0m