granite 0.7.1.1 → 0.7.2.0
raw patch · 11 files changed
+597/−72 lines, 11 filesPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
API changes (from Hackage documentation)
+ Granite.Render.Chrome: AxisLayout :: !XLabelMode -> !Double -> !Double -> AxisLayout
+ Granite.Render.Chrome: Margins :: !Bool -> !Bool -> !Bool -> !Double -> !Double -> Margins
+ Granite.Render.Chrome: [alExtraBottom] :: AxisLayout -> !Double
+ Granite.Render.Chrome: [alLeftWanted] :: AxisLayout -> !Double
+ Granite.Render.Chrome: [alMode] :: AxisLayout -> !XLabelMode
+ Granite.Render.Chrome: [marExtraBottom] :: Margins -> !Double
+ Granite.Render.Chrome: [marHasBottomLegend] :: Margins -> !Bool
+ Granite.Render.Chrome: [marHasRightLegend] :: Margins -> !Bool
+ Granite.Render.Chrome: [marHasTitle] :: Margins -> !Bool
+ Granite.Render.Chrome: [marLeftWanted] :: Margins -> !Double
+ Granite.Render.Chrome: axisLayout :: Theme -> Size -> Bool -> Bool -> [Text] -> [Text] -> AxisLayout
+ Granite.Render.Chrome: data AxisLayout
+ Granite.Render.Chrome: data Margins
+ Granite.Render.Chrome: data XLabelMode
+ Granite.Render.Chrome: domainLabels :: TrainedScale -> [Text]
+ Granite.Render.Chrome: instance GHC.Classes.Eq Granite.Render.Chrome.Margins
+ Granite.Render.Chrome: instance GHC.Classes.Eq Granite.Render.Chrome.XPolicy
+ Granite.Render.Chrome: instance GHC.Show.Show Granite.Render.Chrome.Margins
+ Granite.Render.Chrome: instance GHC.Show.Show Granite.Render.Chrome.XPolicy
+ Granite.Render.Chrome: xModeNoRotate :: XLabelMode
+ Granite.Render.Scene: [textRotate] :: TextStyle -> !Double
+ Granite.Render.Scene: [textTitle] :: TextStyle -> !Maybe Text
- Granite.Render.Chrome: axesMarks :: Theme -> PlotBox -> Coord -> TrainedScale -> TrainedScale -> [Mark]
+ Granite.Render.Chrome: axesMarks :: Theme -> PlotBox -> Coord -> XLabelMode -> TrainedScale -> TrainedScale -> [Mark]
- Granite.Render.Chrome: computePlotBox :: Size -> Theme -> Bool -> Bool -> Bool -> PlotBox
+ Granite.Render.Chrome: computePlotBox :: Size -> Theme -> Margins -> PlotBox
- Granite.Render.Scene: TextStyle :: !Color -> !Double -> !TextAnchor -> TextStyle
+ Granite.Render.Scene: TextStyle :: !Color -> !Double -> !TextAnchor -> !Double -> !Maybe Text -> TextStyle
Files
- granite.cabal +1/−1
- src/Granite/Internal/Util.hs +31/−0
- src/Granite/Render/Chrome.hs +251/−49
- src/Granite/Render/Pipeline.hs +78/−19
- src/Granite/Render/Scene.hs +5/−1
- src/Granite/Render/Svg.hs +33/−2
- test/GoldenSpec.hs +74/−0
- test/golden/bar-long-names-flip.svg +24/−0
- test/golden/bar-long-names.svg +24/−0
- test/golden/facet-long-names.svg +60/−0
- test/golden/thin-numeric-x.svg +16/−0
granite.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: granite-version: 0.7.1.1+version: 0.7.2.0 synopsis: Easy terminal plotting. description: A terminal plotting library for quick and easy visualisation. license: MIT
src/Granite/Internal/Util.hs view
@@ -24,6 +24,9 @@ gridWidth, wcswidth, ellipsisize,+ estLabelWidthPx,+ estMaxGlyphs,+ truncatePx, justifyRight, showD, escXml,@@ -128,6 +131,34 @@ | maxWidth <= 0 = "" | wcswidth lbl > maxWidth = Text.take (maxWidth - 1) lbl <> "…" | otherwise = lbl++-- | Average glyph advance as a fraction of the em, for proportional fonts.+glyphAspectEm :: Double+glyphAspectEm = 0.6++{- | Rough rendered pixel width of a label at a given font size: visible+glyph count times 'glyphAspectEm'. Good enough to decide when axis labels+would collide.+-}+estLabelWidthPx :: Double -> Text -> Double+estLabelWidthPx fontSize t = fromIntegral (wcswidth t) * fontSize * glyphAspectEm++{- | Inverse of 'estLabelWidthPx': the most glyphs that fit within @limitPx@+at the given font size. Pair with 'ellipsisize' to truncate to a pixel budget.+-}+estMaxGlyphs :: Double -> Double -> Int+estMaxGlyphs fontSize limitPx = floor (limitPx / (fontSize * glyphAspectEm))++{- | Fit @full@ within @limitPx@ at the given font size: returns the+(possibly ellipsised) label and, when it was shortened, the full text for a+tooltip. Used wherever a label must not overrun its slot (axis ticks, facet+strips).+-}+truncatePx :: Double -> Double -> Text -> (Text, Maybe Text)+truncatePx fontSize limitPx full+ | estLabelWidthPx fontSize full <= limitPx = (full, Nothing)+ | otherwise =+ (ellipsisize (max 1 (estMaxGlyphs fontSize limitPx)) full, Just full) justifyRight :: Int -> Text -> Text justifyRight n s = Text.replicate (max 0 (n - wcswidth s)) " " <> s
src/Granite/Render/Chrome.hs view
@@ -11,15 +11,25 @@ -} module Granite.Render.Chrome ( PlotBox (..),+ Margins (..), computePlotBox,+ AxisLayout (..),+ XLabelMode,+ xModeNoRotate,+ axisLayout,+ domainLabels, axesMarks, titleMarks, legendMarks, ) where import Data.Char (isDigit)+import Data.List (nub)+import Data.Maybe (isJust) import Data.Text (Text) import Data.Text qualified as Text+import Granite.Internal.Util (estLabelWidthPx, truncatePx)+import Text.Read (readMaybe) import Granite.Color (Color (..)) import Granite.Render.Scene (@@ -55,28 +65,134 @@ } deriving (Eq, Show) -computePlotBox :: Size -> Theme -> Bool -> Bool -> Bool -> PlotBox-computePlotBox sz theme hasTitle hasRightLegend hasBottomLegend =- let (sceneW, sceneH) = case sz of- SizeChars w h ->- (fromIntegral w * themePxPerChar theme, fromIntegral h * themePxPerLine theme)- SizePixels w h -> (fromIntegral w, fromIntegral h)- SizeResponsive ar ->- let h = 320- in (h * ar, h)- topMargin = if hasTitle then themeTitleSize theme + 20 else 10- bottomMargin = (if hasBottomLegend then 30 else 0) + themeFontSize theme * 1.8 + 8- leftMargin = themeFontSize theme * 5- rightMargin = if hasRightLegend then 120 else 20+-- | Scene pixel dimensions for a 'Size'.+sceneDims :: Size -> Theme -> (Double, Double)+sceneDims sz theme = case sz of+ SizeChars w h ->+ (fromIntegral w * themePxPerChar theme, fromIntegral h * themePxPerLine theme)+ SizePixels w h -> (fromIntegral w, fromIntegral h)+ SizeResponsive ar -> let h = 320 in (h * ar, h)++{- | Inputs that grow the plot margins beyond the theme defaults. @marExtraBottom@+enlarges the bottom margin (for rotated x-labels) and @marLeftWanted@ requests a+left margin (to fit long y-labels / the rotated labels' leftward reach); both+fall back to the defaults when 0\/small.+-}+data Margins = Margins+ { marHasTitle :: !Bool+ , marHasRightLegend :: !Bool+ , marHasBottomLegend :: !Bool+ , marExtraBottom :: !Double+ , marLeftWanted :: !Double+ }+ deriving (Eq, Show)++computePlotBox :: Size -> Theme -> Margins -> PlotBox+computePlotBox sz theme m =+ let (sceneW, sceneH) = sceneDims sz theme+ topMargin = if marHasTitle m then themeTitleSize theme + 20 else 10+ bottomMargin =+ (if marHasBottomLegend m then 30 else 0)+ + max (themeFontSize theme * 1.8 + 8) (marExtraBottom m)+ leftMargin = max (themeFontSize theme * 5) (marLeftWanted m)+ rightMargin = rightMarginPx (marHasRightLegend m) plotW = max 1 (sceneW - leftMargin - rightMargin) plotH = max 1 (sceneH - topMargin - bottomMargin) in PlotBox sceneW sceneH leftMargin topMargin plotW plotH -axesMarks :: Theme -> PlotBox -> Coord -> TrainedScale -> TrainedScale -> [Mark]-axesMarks theme box coord xs ys = case coord of- CoordCartesian -> cartesianAxes theme box False xs ys+rightMarginPx :: Bool -> Double+rightMarginPx hasRightLegend = if hasRightLegend then 120 else 20++-- | The automatic policy for bottom-axis labels given their per-tick budget.+data XPolicy = XUpright | XThin | XRotate+ deriving (Eq, Show)++xLabelPolicy :: Double -> Double -> [Text] -> XPolicy+xLabelPolicy fontSize budget labels+ | maxLabelWidth fontSize labels <= budget = XUpright+ | not (null labels) && all isNumericLabel labels = XThin+ | otherwise = XRotate++maxLabelWidth :: Double -> [Text] -> Double+maxLabelWidth fontSize = maximum . (0 :) . map (estLabelWidthPx fontSize)++-- | Rotated x-labels wider than this (px) are truncated with a hover @\<title\>@.+rotatedLabelLimitPx :: Double+rotatedLabelLimitPx = 130++-- | @sin 45°@ — a rotated label's horizontal/vertical reach as a fraction of its width.+sin45 :: Double+sin45 = sqrt 2 / 2++{- | How the bottom (x) axis labels are laid out, plus the extra margins that+choice needs. Decided exactly once per chart so margin sizing and the actual+label rendering can never disagree (the bug when each recomputed the policy+from a slightly different plot width). 'XLabelMode' is threaded to 'axesMarks';+'alExtraBottom'\/'alLeftWanted' feed 'computePlotBox'.+-}+data AxisLayout = AxisLayout+ { alMode :: !XLabelMode+ , alExtraBottom :: !Double+ , alLeftWanted :: !Double+ }++{- | What a panel's x-axis renderer should do. 'XFixed' carries the+chart-global decision verbatim (single panel). 'XLocalNoRotate' lets a faceted+panel pick upright\/thin from its own width, but never rotate — rotation needs a+per-cell margin reservation the facet layout doesn't make.+-}+data XLabelMode = XFixed !XPolicy | XLocalNoRotate++-- | The mode for axes that never rotate their labels (polar; also the faceted default).+xModeNoRotate :: XLabelMode+xModeNoRotate = XLocalNoRotate++{- | Decide the bottom-axis label policy and the margins it needs, in one pass.+A wide left label grows the left margin (up to ~42% of the scene); rotated+bottom labels also reach down (bottom margin) and left (left margin) by+@sin 45°@ of their width. Faceted charts never rotate, so they reserve no extra+bottom margin.+-}+axisLayout :: Theme -> Size -> Bool -> Bool -> [Text] -> [Text] -> AxisLayout+axisLayout theme sz hasRightLegend faceted bottomLbls leftLbls =+ AxisLayout+ { alMode = mode+ , alExtraBottom = bottomExtra+ , alLeftWanted = leftWanted+ }+ where+ (sceneW, _) = sceneDims sz theme+ fontSize = themeFontSize theme+ cap = 0.42 * sceneW+ -- Left margin from y-labels alone, used to set the plot width that decides+ -- x rotation. Rotation can grow it further (its leftward reach), but the+ -- rotate/thin decision itself is fixed here and threaded, so it never drifts.+ provLeft = min cap (max (fontSize * 5) (maxLabelWidth fontSize leftLbls + 12))+ provPlotW = max 1 (sceneW - provLeft - rightMarginPx hasRightLegend)+ n = length bottomLbls+ budget = if n <= 1 then provPlotW else provPlotW / fromIntegral n+ policy = xLabelPolicy fontSize budget bottomLbls+ rotates = not faceted && policy == XRotate+ rotatedExtent = sin45 * min (maxLabelWidth fontSize bottomLbls) rotatedLabelLimitPx+ mode = if faceted then XLocalNoRotate else XFixed policy+ -- A rotated label's baseline sits @fontSize + 4@ below the axis and reaches+ -- down a further @rotatedExtent@; +8 keeps a descender's clearance above the+ -- scene edge.+ bottomExtra = if rotates then fontSize + 4 + rotatedExtent + 8 else 0+ leftWanted = min cap (max provLeft (if rotates then rotatedExtent else 0))++axesMarks ::+ Theme ->+ PlotBox ->+ Coord ->+ XLabelMode ->+ TrainedScale ->+ TrainedScale ->+ [Mark]+axesMarks theme box coord xmode xs ys = case coord of+ CoordCartesian -> cartesianAxes theme box False xmode xs ys -- CoordFlip rotates 90° CW so the Y axis grows top-down.- CoordFlip -> cartesianAxes theme box True ys xs+ CoordFlip -> cartesianAxes theme box True xmode ys xs CoordPolar aes a0 dir -> polarAxes theme box aes a0 dir xs ys {- | Axes are 'MAxisLine' spines (clean box-drawing chars in terminal,@@ -85,8 +201,8 @@ break. @flipY@ inverts Y for 'CoordFlip'. -} cartesianAxes ::- Theme -> PlotBox -> Bool -> TrainedScale -> TrainedScale -> [Mark]-cartesianAxes theme box flipY xs ys =+ Theme -> PlotBox -> Bool -> XLabelMode -> TrainedScale -> TrainedScale -> [Mark]+cartesianAxes theme box flipY xmode xs ys = let axisColor = colorOfSpec (themeAxisColor theme) textColor = colorOfSpec (themeTextColor theme) @@ -100,38 +216,120 @@ xAxis = MAxisLine (Point px (py + ph)) (Point (px + pw) (py + ph)) spineStyle yAxis = MAxisLine (Point px py) (Point px (py + ph)) spineStyle - xLabels =- [ MText- (Point xPos (py + ph + themeFontSize theme + 4))- lbl- defaultTextStyle- { textFill = textColor- , textSize = themeFontSize theme- , textAnchor = AnchorMiddle- }- | (v, lbl) <- zip (tsBreaks xs) (tsLabels xs)- , inDomain (tsDomain xs) v- , let xPos = px + tsProject xs v * pw- ]-- yLabels =- [ MText- (Point (px - 6) (yPos + themeFontSize theme / 3))- lbl- defaultTextStyle- { textFill = textColor- , textSize = themeFontSize theme- , textAnchor = AnchorEnd- }- | (v, lbl) <- zip (tsBreaks ys) (tsLabels ys)- , inDomain (tsDomain ys) v- , let yPos =- if flipY- then py + tsProject ys v * ph- else py + ph - tsProject ys v * ph- ]+ xLabels = xAxisLabels theme px py pw ph textColor xmode xs+ yLabels = yAxisLabels theme px py ph flipY textColor ys in [xAxis, yAxis] <> xLabels <> yLabels +{- | X-axis tick labels that automatically adapt to long words, following the+chart-global 'XLabelMode' so margin sizing and rendering agree. If the labels+fit their per-tick slot they render upright (unchanged). Otherwise, keyed on the+data: numeric labels are thinned to a subset that fits (dropping ticks on a+continuum is fine, and the first\/last are always kept so the axis bounds stay+labelled), while categorical labels are rotated −45° and, past+'rotatedLabelLimitPx', truncated with a hover @\<title\>@ — a category name is+never silently dropped. Rotation and the @\<title\>@ tooltip are SVG-only; the+terminal backend draws the (truncated) text upright.+-}+xAxisLabels ::+ Theme ->+ Double ->+ Double ->+ Double ->+ Double ->+ Color ->+ XLabelMode ->+ TrainedScale ->+ [Mark]+xAxisLabels theme px py pw ph textColor xmode xs = case resolvedPolicy of+ XUpright -> map (upright budget) raw+ XThin ->+ let keep = thinIndices n maxW pw+ slot = pw / fromIntegral (max 1 (length keep))+ in [upright slot (raw !! i) | i <- keep]+ XRotate -> map rotated raw+ where+ fontSize = themeFontSize theme+ baseY = py + ph + fontSize + 4+ raw =+ [ (px + tsProject xs v * pw, lbl)+ | (v, lbl) <- zip (tsBreaks xs) (tsLabels xs)+ , inDomain (tsDomain xs) v+ ]+ n = length raw+ maxW = maxLabelWidth fontSize (map snd raw)+ budget = if n <= 1 then pw else pw / fromIntegral n+ resolvedPolicy = case xmode of+ XFixed p -> p+ -- Faceted: pick from this panel's own width but never rotate.+ XLocalNoRotate -> case xLabelPolicy fontSize budget (map snd raw) of+ XRotate -> XUpright+ p -> p+ sty anchor rot title =+ defaultTextStyle+ { textFill = textColor+ , textSize = fontSize+ , textAnchor = anchor+ , 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)+ rotated (xp, full) =+ let (lbl, title) = truncatePx fontSize rotatedLabelLimitPx full+ in MText (Point xp baseY) lbl (sty AnchorEnd (-45) title)++{- | Indices of a thinned label subset: the most evenly-spaced labels that fit+@plotW@ at width @maxW@, always including the first and last so the axis bounds+stay labelled. (Even-index thinning could drop the max tick on an even count.)+-}+thinIndices :: Int -> Double -> Double -> [Int]+thinIndices n maxW plotW+ | n <= 2 = [0 .. n - 1]+ | otherwise =+ let fit = max 2 (min n (floor (plotW / max 1 maxW)))+ pick j = round (fromIntegral (j * (n - 1)) / fromIntegral (fit - 1) :: Double)+ in nub (map pick [0 .. fit - 1])++{- | Y-axis (left) tick labels, one per break. Truncated with a hover+@\<title\>@ to fit the left margin (the margin itself is grown to hold long+labels up to a cap; this only bites past that cap).+-}+yAxisLabels ::+ Theme -> Double -> Double -> Double -> Bool -> Color -> TrainedScale -> [Mark]+yAxisLabels theme px py ph flipY textColor ys =+ [ MText (Point (px - 6) (yPos + fontSize / 3)) lbl (sty title)+ | (v, full) <- zip (tsBreaks ys) (tsLabels ys)+ , inDomain (tsDomain ys) v+ , let yPos =+ if flipY then py + tsProject ys v * ph else py + ph - tsProject ys v * ph+ (lbl, title) = truncatePx fontSize (px - 10) full+ ]+ where+ fontSize = themeFontSize theme+ sty title =+ defaultTextStyle+ { textFill = textColor+ , textSize = fontSize+ , textAnchor = AnchorEnd+ , textTitle = title+ }++{- | A label produced by a numeric axis — only decimal\/scientific digits,+so categorical values like @\"NaN\"@, @\"Infinity\"@ or @\"0x10\"@ (which+'readMaybe' would otherwise accept) are correctly treated as categories.+-}+isNumericLabel :: Text -> Bool+isNumericLabel t =+ not (Text.null s)+ && Text.all (`elem` ("0123456789.eE+-" :: String)) s+ && isJust (readMaybe (Text.unpack s) :: Maybe Double)+ where+ s = Text.strip t+ {- | Concentric rings (radial breaks) + radial spokes (angular breaks) + tick labels. Centred in the plot box with radius = half its shortest side.@@ -232,6 +430,10 @@ inDomain :: (Double, Double) -> Double -> Bool inDomain (lo, hi) v = v >= lo - 1e-9 && v <= hi + 1e-9++-- | A trained scale's in-domain tick labels, in break order.+domainLabels :: TrainedScale -> [Text]+domainLabels s = [lbl | (v, lbl) <- zip (tsBreaks s) (tsLabels s), inDomain (tsDomain s) v] titleMarks :: Theme -> PlotBox -> Maybe Text -> [Mark] titleMarks _ _ Nothing = []
src/Granite/Render/Pipeline.hs view
@@ -32,13 +32,20 @@ filterByRows, lookupColumn, )+import Granite.Internal.Util (truncatePx) import Granite.Position (applyPosition) import Granite.Render.Chrome (+ AxisLayout (..),+ Margins (..), PlotBox (..),+ XLabelMode, axesMarks,+ axisLayout, computePlotBox,+ domainLabels, legendMarks, titleMarks,+ xModeNoRotate, ) import Granite.Render.Scene ( Mark (..),@@ -153,10 +160,34 @@ _ -> Nothing legendEntries = collectLegend (chartLayers chart) palette colorMaps hasRightLegend = not (null legendEntries)- box = computePlotBox (chartSize chart) theme hasTitle hasRightLegend False+ -- Size margins to the actual labels so long words never clip, and decide+ -- the bottom-axis label policy once (threaded as 'XLabelMode') so margin+ -- sizing and rendering agree. The on-screen bottom\/left axes swap under+ -- CoordFlip; polar has no rectangular axes; faceting forbids rotation.+ (xLbls, yLbls) = globalAxisLabels chart+ (bottomLbls, leftLbls) = case coord of+ CoordFlip -> (yLbls, xLbls)+ _ -> (xLbls, yLbls)+ faceted = case chartFacet chart of FacetNull -> False; _ -> True+ layout = case coord of+ CoordPolar{} -> AxisLayout xModeNoRotate 0 0+ _ ->+ axisLayout theme (chartSize chart) hasRightLegend faceted bottomLbls leftLbls+ box =+ computePlotBox (chartSize chart) theme $+ Margins+ { marHasTitle = hasTitle+ , marHasRightLegend = hasRightLegend+ , marHasBottomLegend = False+ , marExtraBottom = alExtraBottom layout+ , marLeftWanted = alLeftWanted layout+ } panels = layoutPanels theme box chart- panelMarks = concatMap (renderPanel theme palette coord colorMaps fillCols) panels+ panelMarks =+ concatMap+ (renderPanel theme palette coord (alMode layout) colorMaps fillCols)+ panels legend = legendMarks theme box legendEntries title = titleMarks theme box (chartTitle chart)@@ -177,11 +208,12 @@ Theme -> [ColorSpec] -> Coord ->+ XLabelMode -> [Maybe [(Text, ColorSpec)]] -> Maybe [ColorSpec] -> PanelSpec -> [Mark]-renderPanel theme palette coord colorMaps fillCols ps =+renderPanel theme palette coord xmode colorMaps fillCols ps = let proj = buildProjector coord (panelBox ps) (panelXScale ps) (panelYScale ps) cmap i = if i < length colorMaps then colorMaps !! i else Nothing layerMarks =@@ -198,7 +230,7 @@ layer | (i, layer) <- zip [0 :: Int ..] (panelLayers ps) ]- axes = axesMarks theme (panelBox ps) coord (panelXScale ps) (panelYScale ps)+ axes = axesMarks theme (panelBox ps) coord xmode (panelXScale ps) (panelYScale ps) strip = stripMark theme (panelBox ps) (panelLabel ps) in axes <> layerMarks <> strip @@ -206,15 +238,17 @@ stripMark theme box label | Text.null label = [] | otherwise =- [ MText- (Point (boxX box + boxW box / 2) (boxY box - 4))- label- defaultTextStyle- { textFill = colorOfSpec (themeTextColor theme)- , textSize = themeFontSize theme- , textAnchor = AnchorMiddle- }- ]+ let (lbl, title) = truncatePx (themeFontSize theme) (boxW box) label+ in [ MText+ (Point (boxX box + boxW box / 2) (boxY box - 4))+ lbl+ defaultTextStyle+ { textFill = colorOfSpec (themeTextColor theme)+ , textSize = themeFontSize theme+ , textAnchor = AnchorMiddle+ , textTitle = title+ }+ ] where colorOfSpec spec = case spec of NamedColor c -> c@@ -248,14 +282,39 @@ , let cellBox = cells !! (rowIx * ncol + colIx) ] -singlePanel :: Chart -> PlotBox -> Text -> PanelSpec-singlePanel chart box label =+{- | The whole-chart x and y trained scales (no faceting/filtering). Shared by+'singlePanel' and 'globalAxisLabels' so the labels that size the margins match+the scales the single panel renders.+-}+trainGlobalScales :: Chart -> (TrainedScale, TrainedScale)+trainGlobalScales chart = let xRange = paddedRange (chartLayers chart) (chartData chart) xRangeRefs True yRange = paddedRange (chartLayers chart) (chartData chart) yRangeRefs False- xs0 = train (scaleX (chartScales chart)) xRange- ys0 = train (scaleY (chartScales chart)) yRange- xs = applyCategorical aesX (chartLayers chart) (chartData chart) xs0- ys = applyCategorical aesY (chartLayers chart) (chartData chart) ys0+ xs =+ applyCategorical+ aesX+ (chartLayers chart)+ (chartData chart)+ (train (scaleX (chartScales chart)) xRange)+ ys =+ applyCategorical+ aesY+ (chartLayers chart)+ (chartData chart)+ (train (scaleY (chartScales chart)) yRange)+ in (xs, ys)++{- | The on-render x and y tick labels (domain-filtered), independent of the+plot box, so margins can be sized to fit them before the box is computed.+-}+globalAxisLabels :: Chart -> ([Text], [Text])+globalAxisLabels chart =+ let (xs, ys) = trainGlobalScales chart+ in (domainLabels xs, domainLabels ys)++singlePanel :: Chart -> PlotBox -> Text -> PanelSpec+singlePanel chart box label =+ let (xs, ys) = trainGlobalScales chart in PanelSpec { panelBox = box , panelLabel = label
src/Granite/Render/Scene.hs view
@@ -58,11 +58,15 @@ { textFill :: !Color , textSize :: !Double , textAnchor :: !TextAnchor+ , textRotate :: !Double+ -- ^ Rotation in degrees about the anchor point; 0 = upright.+ , textTitle :: !(Maybe Text)+ -- ^ A @\<title\>@ tooltip (the untruncated label); 'Nothing' = none. } deriving (Eq, Show) defaultTextStyle :: TextStyle-defaultTextStyle = TextStyle Default 11 AnchorStart+defaultTextStyle = TextStyle Default 11 AnchorStart 0 Nothing {- | Primitive drawable marks. 'MArc' takes (center, radius, a0, a1) in radians CCW from +x. 'MPolygon' is a closed filled polyline.
src/Granite/Render/Svg.hs view
@@ -105,17 +105,46 @@ <> attr "stroke-linecap" "round" <> "/>\n" +-- | Simple text element (legacy callers); no rotation or tooltip. svgText :: Double -> Double -> Text -> Text -> Double -> Text -> Text-svgText x y anchor fill size content =+svgText x y anchor fill size = svgTextRT x y anchor fill size 0 Nothing++{- | Text element with optional rotation (degrees about @(x,y)@) and an+optional @\<title\>@ tooltip (the untruncated label). @rot = 0@ and+@title = Nothing@ produce byte-identical output to 'svgText'.+-}+svgTextRT ::+ Double ->+ Double ->+ Text ->+ Text ->+ Double ->+ Double ->+ Maybe Text ->+ Text ->+ Text+svgTextRT x y anchor fill size rot mTitle content = "<text" <> attr "x" (showD x) <> attr "y" (showD y) <> attr "text-anchor" anchor <> attr "fill" fill <> attr "font-size" (showD size)+ <> rotAttr <> ">"+ <> titleEl <> escXml content <> "</text>\n"+ where+ rotAttr+ | rot == 0 = ""+ | otherwise =+ attr+ "transform"+ ("rotate(" <> showD rot <> " " <> showD x <> " " <> showD y <> ")")+ titleEl = case mTitle of+ Nothing -> ""+ Just t -> "<title>" <> escXml t <> "</title>" svgPath :: Text -> Text -> Text -> Text svgPath d fill extra =@@ -154,12 +183,14 @@ <> opacityAttr sty <> "/>\n" MText (Point x y) txt ts ->- svgText+ svgTextRT x y (anchorText (textAnchor ts)) (colorHex (textFill ts)) (textSize ts)+ (textRotate ts)+ (textTitle ts) txt MPolyline pts sty -> let stroke = maybe "#000000" colorHex (styleStroke sty)
test/GoldenSpec.hs view
@@ -193,6 +193,68 @@ , chartSize = SizeChars 40 14 } +-- Categorical bars whose names are far too wide for their slots: the axis+-- label policy must auto-rotate them, and truncate the longest with a hover+-- <title>. Exercises the long-word path of 'xAxisLabels'.+barLongNames :: Chart+barLongNames =+ let names =+ [ "GHC.Core.Opt.Simplify.Iteration.Inline.Worker"+ , "mkFastStringBytes"+ , "GHC.Tc.Solver.Monad"+ , "GHC.CmmToAsm.X86.Ppr"+ , "GHC.Stg.Unarise"+ , "GHC.HsToCore.Match.Constructor"+ ]+ ys = [29.0, 18.5, 12.3, 9.1, 6.4, 4.2] :: [Double]+ df = fromColumns [("fn", ColCat names), ("mb", ColNum ys)]+ layer =+ (defLayer GeomBar)+ { layerMapping =+ emptyMapping+ { aesX = Just (ColumnRef "fn")+ , aesY = Just (ColumnRef "mb")+ }+ , layerStat = StatIdentity+ }+ in emptyChart+ { chartData = df+ , chartLayers = [layer]+ , chartTitle = Just "Allocation by function"+ , chartSize = SizeChars 44 16+ }++-- The same long names under CoordFlip: now they fall on the (left) y-axis, so+-- the policy must grow the left margin and truncate names past the ~42% cap+-- with a hover <title> rather than rotate. Exercises the y-label cap + the+-- CoordFlip bottom/left label swap.+barLongNamesFlip :: Chart+barLongNamesFlip =+ barLongNames{chartCoord = CoordFlip, chartTitle = Just "Allocation (flipped)"}++-- A numeric x-axis whose wide tick labels (millions) overflow their slots in a+-- narrow chart: the policy must thin them to an evenly-spaced subset that keeps+-- the first and last tick, never rotate. Exercises the XThin path + isNumericLabel.+numericThinX :: Chart+numericThinX =+ ( lineChart+ [("alloc", [(x, x / 1.0e5) | x <- [0, 1.5e6 .. 6.0e6]])]+ (Just "Bytes allocated")+ )+ { 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.+facetedLongNames :: Chart+facetedLongNames =+ barLongNames+ { chartFacet = FacetWrap (ColumnRef "fn") (Just 2) Nothing ScalesFixed+ , chartTitle = Just "Faceted long names"+ , chartSize = SizeChars 60 20+ }+ -- Histogram of 50 numeric values bucketed into 8 bins. Exercises -- StatBin + GeomHistogram. aesY points to the @count@ column that -- StatBin emits, so the bar heights track the bin counts.@@ -403,6 +465,18 @@ goldenText "bar-basic.txt" (renderChartTerminal barBasic) it "bar chart renders to expected SVG output" $ goldenText "bar-basic.svg" (renderChartSvg barBasic)++ it "long categorical bar labels auto-rotate and truncate (SVG)" $+ goldenText "bar-long-names.svg" (renderChartSvg barLongNames)++ it "flipped long labels grow the left margin and truncate at the cap (SVG)" $+ goldenText "bar-long-names-flip.svg" (renderChartSvg barLongNamesFlip)++ it "wide numeric x labels thin to a fitting subset keeping the ends (SVG)" $+ goldenText "thin-numeric-x.svg" (renderChartSvg numericThinX)++ it "faceted long labels stay upright (no rotation) (SVG)" $+ goldenText "facet-long-names.svg" (renderChartSvg facetedLongNames) it "histogram renders to expected terminal output" $ goldenText "hist-basic.txt" (renderChartTerminal histBasic)
+ test/golden/bar-long-names-flip.svg view
@@ -0,0 +1,24 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 256" width="440" height="256" font-family="system-ui, -apple-system, sans-serif">+<rect width="100%" height="100%" fill="white"/>+<line x1="184.80" y1="228.20" x2="320" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>+<line x1="184.80" y1="34" x2="184.80" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>+<text x="190.95" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>+<text x="233.33" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">10.0</text>+<text x="275.71" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">20.0</text>+<text x="318.09" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">30.0</text>+<text x="178.80" y="58.67" text-anchor="end" fill="#7f8c8d" font-size="11"><title>GHC.Core.Opt.Simplify.Iteration.Inline.Worker</title>GHC.Core.Opt.Simplify.Ite…</text>+<text x="178.80" y="89.11" text-anchor="end" fill="#7f8c8d" font-size="11">mkFastStringBytes</text>+<text x="178.80" y="119.55" text-anchor="end" fill="#7f8c8d" font-size="11">GHC.Tc.Solver.Monad</text>+<text x="178.80" y="149.99" text-anchor="end" fill="#7f8c8d" font-size="11">GHC.CmmToAsm.X86.Ppr</text>+<text x="178.80" y="180.42" text-anchor="end" fill="#7f8c8d" font-size="11">GHC.Stg.Unarise</text>+<text x="178.80" y="210.86" text-anchor="end" fill="#7f8c8d" font-size="11"><title>GHC.HsToCore.Match.Constructor</title>GHC.HsToCore.Match.Constr…</text>+<rect x="190.95" y="42.83" width="122.91" height="24.35" fill="#3498db"/>+<rect x="190.95" y="73.27" width="78.41" height="24.35" fill="#3498db"/>+<rect x="190.95" y="103.71" width="52.13" height="24.35" fill="#3498db"/>+<rect x="190.95" y="134.14" width="38.57" height="24.35" fill="#3498db"/>+<rect x="190.95" y="164.58" width="27.12" height="24.35" fill="#3498db"/>+<rect x="190.95" y="195.02" width="17.80" height="24.35" fill="#3498db"/>+<text x="252.40" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Allocation (flipped)</text>+<rect x="335" y="39" width="12" height="12" fill="#3498db"/>+<text x="351" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>+</svg>
+ test/golden/bar-long-names.svg view
@@ -0,0 +1,24 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 256" width="440" height="256" font-family="system-ui, -apple-system, sans-serif">+<rect width="100%" height="100%" fill="white"/>+<line x1="91.92" y1="141.08" x2="320" y2="141.08" stroke="#ecf0f1" stroke-width="1"/>+<line x1="91.92" y1="34" x2="91.92" y2="141.08" stroke="#ecf0f1" stroke-width="1"/>+<text x="116.59" y="156.08" text-anchor="end" fill="#7f8c8d" font-size="11" transform="rotate(-45 116.59 156.08)"><title>GHC.Core.Opt.Simplify.Iteration.Inline.Worker</title>GHC.Core.Opt.Simpl…</text>+<text x="152.34" y="156.08" text-anchor="end" fill="#7f8c8d" font-size="11" transform="rotate(-45 152.34 156.08)">mkFastStringBytes</text>+<text x="188.09" y="156.08" text-anchor="end" fill="#7f8c8d" font-size="11" transform="rotate(-45 188.09 156.08)">GHC.Tc.Solver.Monad</text>+<text x="223.84" y="156.08" text-anchor="end" fill="#7f8c8d" font-size="11" transform="rotate(-45 223.84 156.08)"><title>GHC.CmmToAsm.X86.Ppr</title>GHC.CmmToAsm.X86.P…</text>+<text x="259.58" y="156.08" text-anchor="end" fill="#7f8c8d" font-size="11" transform="rotate(-45 259.58 156.08)">GHC.Stg.Unarise</text>+<text x="295.33" y="156.08" text-anchor="end" fill="#7f8c8d" font-size="11" transform="rotate(-45 295.33 156.08)"><title>GHC.HsToCore.Match.Constructor</title>GHC.HsToCore.Match…</text>+<text x="85.92" y="139.88" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>+<text x="85.92" y="106.31" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>+<text x="85.92" y="72.74" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>+<text x="85.92" y="39.18" text-anchor="end" fill="#7f8c8d" font-size="11">30.0</text>+<rect x="102.29" y="38.87" width="28.60" height="97.34" fill="#3498db"/>+<rect x="138.04" y="74.11" width="28.60" height="62.10" fill="#3498db"/>+<rect x="173.79" y="94.92" width="28.60" height="41.29" fill="#3498db"/>+<rect x="209.54" y="105.66" width="28.60" height="30.55" fill="#3498db"/>+<rect x="245.29" y="114.73" width="28.60" height="21.48" fill="#3498db"/>+<rect x="281.03" y="122.11" width="28.60" height="14.10" fill="#3498db"/>+<text x="205.96" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Allocation by function</text>+<rect x="335" y="39" width="12" height="12" fill="#3498db"/>+<text x="351" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>+</svg>
+ test/golden/facet-long-names.svg view
@@ -0,0 +1,60 @@+<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">+<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>+</svg>
+ test/golden/thin-numeric-x.svg view
@@ -0,0 +1,16 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 224" width="300" height="224" font-family="system-ui, -apple-system, sans-serif">+<rect width="100%" height="100%" fill="white"/>+<line x1="55" y1="196.20" x2="180" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>+<line x1="55" y1="34" x2="55" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>+<text x="60.68" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>+<text x="136.44" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0e6</text>+<text x="174.32" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">6.0e6</text>+<text x="49" y="192.49" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>+<text x="49" y="143.34" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>+<text x="49" y="94.19" text-anchor="end" fill="#7f8c8d" font-size="11">40.0</text>+<text x="49" y="45.04" text-anchor="end" fill="#7f8c8d" font-size="11">60.0</text>+<polyline points="60.68,188.83 89.09,151.96 117.50,115.10 145.91,78.24 174.32,41.37" fill="none" stroke="#3498db" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>+<text x="117.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Bytes allocated</text>+<rect x="195" y="39" width="12" height="12" fill="#3498db"/>+<text x="211" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">alloc</text>+</svg>