packages feed

nano-ui-diagrams-0.1.0.0: lib/NanoUI/Plot/Chrome.hs

-- | Chart drawing: axes, ticks, grid, title and legend around the series, as
-- a diagram.
module NanoUI.Plot.Chrome
  ( chartDiagram
  , chartMargins
  , Margins (..)
  , seriesDomains
  , seriesPoints
  ) where

import Data.Colour (Colour)
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Primitive.PrimArray (indexPrimArray, sizeofPrimArray)
import Data.Vector.Unboxed qualified as U
import Diagrams.Prelude
  ( Diagram
  , P2
  , alignedText
  , alignBL
  , circle
  , closeTrail
  , fc
  , fontSizeL
  , fromVertices
  , lc
  , lw
  , lwO
  , moveTo
  , none
  , p2
  , phantom
  , rect
  , strokeTrail
  , translate
  , (^&)
  , ( # )
  )
import NanoUI
  ( Color
  , FontMetrics (..)
  , Rect (..)
  , Theme (..)
  , drawTextBox
  , fmLineHeight
  , lerpColor
  , themeSeries
  )
import NanoUI.Diagrams.Backend (B)
import NanoUI.Diagrams.Widget (PlotStyle (..), colourOf)
import NanoUI.Plot.Decimate (lttb)
import NanoUI.Plot.Scale
  ( domainExtentBy
  , domainToPlot
  , formatTick
  , mergeDomains
  , niceTicks
  , padDomain
  )
import NanoUI.Plot.Types
  ( Chart (..)
  , Domain (..)
  , GridMode (..)
  , LegendPos (..)
  , MarkShape (..)
  , Series (..)
  , SeriesData (..)
  , SeriesKind (..)
  )

-- lwO is output pixels. Do not scale into the 0..1 plot box.
plotStroke :: Float -> Double
plotStroke pt = realToFrac (max 1 pt)

-- Marker radius in plot-box units. Sized for ~160-200px charts.
plotMarkerRadius :: Float -> Double
plotMarkerRadius sz = realToFrac sz * 0.006

-- Host glyphs do not scale. Convert px using the intended data-box height.
-- Do not iterate against the full letterbox: that grows pads, shrinks the
-- data box, then grows pads again.
plotGapRef :: FontMetrics -> Double
plotGapRef fm = max 120 (realToFrac (fmLineHeight fm) * 7.5)

data Margins = Margins
  { marginLeft :: !Double
  , marginRight :: !Double
  , marginBottom :: !Double
  , marginTop :: !Double
  }
  deriving (Eq, Show)

data ChartChrome = ChartChrome
  { ccMargins :: !Margins
  , ccTickPad :: !Double
  , ccXTickPad :: !Double
  , ccPx :: Float -> Double
  , ccYTitleX :: !Double
  , ccXTitleY :: !Double
  , ccLegendW :: !Float
  }

chartMargins :: FontMetrics -> Chart -> Margins
chartMargins fm chart = ccMargins (chartChrome fm (snd (seriesDomains chart)) chart)

-- | Chrome for a chart whose y domain the caller has already computed.
chartChrome :: FontMetrics -> Domain -> Chart -> ChartChrome
chartChrome fm yDom chart =
  let yLabels = map formatTick (niceTicks 6 yDom)
      maxYW = maximum (0 : map (textWidth fm) yLabels)
      lh = fmLineHeight fm
      yTitleW =
        case chartYTitle chart of
          Nothing -> 0
          Just t -> textWidth fm t
      legendW =
        case chartLegend chart of
          LegendNone -> 0
          _ ->
            maximum
              ( 0
                  : map (textWidth fm . seriesName) (chartSeries chart)
              )
      s = plotGapRef fm
      px u = realToFrac u / s
      tickPad = px 6
      xTickPad = px 2
      titleGap = px 10
      -- Glyphs grow in plot units when the data box is shorter than
      -- plotGapRef. Pad tick size so titles stay just outside the ticks.
      tickW = px maxYW * 1.35
      tickH = px lh * 1.35
      yTitleX = -tickPad - tickW - titleGap
      xTitleY = -xTickPad - tickH - titleGap
      leftTick = tickPad + tickW + px 4
      botTick = xTickPad + tickH + px 4
      topM =
        if chartTitle chart /= Nothing
          then px lh + px 8
          else px 4
      rightM =
        case chartLegend chart of
          LegendRight -> px legendW + 0.22
          _ -> px 4
      leftTitle =
        if chartYTitle chart /= Nothing then titleGap + px yTitleW else 0
      botTitle =
        if chartXTitle chart /= Nothing then titleGap + tickH else 0
      botLegend =
        case chartLegend chart of
          LegendBottom -> px lh + px 6
          _ -> 0
      leftM = leftTick + leftTitle
      botM = botTick + botTitle + botLegend
   in ChartChrome
        { ccMargins =
            Margins
              { marginLeft = leftM
              , marginRight = rightM
              , marginBottom = botM
              , marginTop = topM
              }
        , ccTickPad = tickPad
        , ccXTickPad = xTickPad
        , ccPx = px
        , ccYTitleX = yTitleX
        , ccXTitleY = xTitleY
        , ccLegendW = legendW
        }

textWidth :: FontMetrics -> T.Text -> Float
textWidth fm s = rectW (drawTextBox fm 0 0 0 (-1) s)

seriesDomains :: Chart -> (Domain, Domain)
seriesDomains chart =
  case map seriesExtent (chartSeries chart) of
    [] -> (Domain 0 1, Domain 0 1)
    d : ds -> foldl' mergePair d ds
  where
    mergePair (dx, dy) (xd, yd) = (mergeDomains dx xd, mergeDomains dy yd)

seriesExtent :: Series -> (Domain, Domain)
seriesExtent s =
  case seriesData s of
    PointsXY pts ->
      (padDomain 0.05 (domainExtentBy fst pts), padDomain 0.05 (domainExtentBy snd pts))
    CategoryY _ values ->
      let n = sizeofPrimArray values
       in (Domain (-0.5) (fromIntegral n - 0.5), padDomain 0.05 (domainExtentBy (indexPrimArray values) (U.enumFromN 0 n)))

-- | Draw a chart from its 'seriesDomains' and each series' 'seriesPoints'.
chartDiagram :: FontMetrics -> Theme -> PlotStyle -> (Domain, Domain) -> [U.Vector (Double, Double)] -> Chart -> Diagram B
chartDiagram fm theme ps (xDom, yDom) points chart =
  let chrome = chartChrome fm yDom chart
      margins = ccMargins chrome
      leftM = marginLeft margins
      rightM = marginRight margins
      botM = marginBottom margins
      topM = marginTop margins
      xTicks = niceTicks 6 xDom
      yTicks = niceTicks 6 yDom
      tickPad = ccTickPad chrome
      xTickPad = ccXTickPad chrome
      toX = domainToPlot xDom
      toY = domainToPlot yDom
      horizontalGrid = mconcat [fromVertices [p2 (0, toY y), p2 (1, toY y)] | y <- yTicks]
      verticalGrid = mconcat [fromVertices [p2 (toX x, 0), p2 (toX x, 1)] | x <- xTicks]
      grid =
        case chartGrid chart of
          GridNone -> mempty
          GridHorizontal -> horizontalGrid
          GridVertical -> verticalGrid
          GridBoth -> horizontalGrid <> verticalGrid
      axes =
        fromVertices [p2 (0, 0), p2 (1, 0)]
          <> fromVertices [p2 (0, 0), p2 (0, 1)]
          <> mconcat [fromVertices [p2 (toX x, 0), p2 (toX x, 0.03)] | x <- xTicks]
          <> mconcat [fromVertices [p2 (0, toY y), p2 (0.03, toY y)] | y <- yTicks]
      xLabs =
        mconcat
          [ plotLbl ps 0.5 1 (T.unpack (formatTick x)) # moveTo (p2 (toX x, -xTickPad))
          | x <- xTicks
          ]
      yLabs =
        mconcat
          [ plotLbl ps 1 0.5 (T.unpack (formatTick y)) # moveTo (p2 (-tickPad, toY y))
          | y <- yTicks
          ]
      title =
        case chartTitle chart of
          Nothing -> mempty
          Just t -> plotLbl ps 0.5 0 (T.unpack t) # moveTo (p2 (0.5, 1.03))
      xt =
        case chartXTitle chart of
          Nothing -> mempty
          Just t ->
            plotLbl ps 0.5 1 (T.unpack t)
              # moveTo (p2 (0.5, ccXTitleY chrome))
      yt =
        case chartYTitle chart of
          Nothing -> mempty
          Just t ->
            plotLbl ps 1 0.5 (T.unpack t)
              # moveTo (p2 (ccYTitleX chrome, 0.5))
      coloredSeries =
        [ (fromMaybe fallback (seriesColor s), s)
        | (fallback, s) <- zip (cycle (themeSeries theme)) (chartSeries chart)
        ]
      seriesDia =
        mconcat
          [ renderSeries ps color xDom yDom s pts
          | ((color, s), pts) <- zip coloredSeries points
          ]
      legend = renderLegend fm ps coloredSeries chart chrome
      marginBox :: Diagram B
      marginBox =
        rect (1 + leftM + rightM) (1 + botM + topM)
          # alignBL
          # moveTo (p2 (-leftM, -botM))
      gridDia = grid # lc (plotGrid ps) # lwO (plotStroke 1)
      axesDia = axes # lc (plotMuted ps) # lwO (plotStroke 1)
      -- Diagrams composes front-to-back: keep the grid behind the data.
   in xLabs <> yLabs <> title <> xt <> yt <> legend <> seriesDia <> axesDia <> gridDia <> phantom marginBox

plotLbl :: PlotStyle -> Double -> Double -> String -> Diagram B
plotLbl ps ax ay s =
  alignedText ax ay s # fontSizeL 0.085 # fc (plotMuted ps) # lc (plotMuted ps) # lw none

renderSeries :: PlotStyle -> Color -> Domain -> Domain -> Series -> U.Vector (Double, Double) -> Diagram B
renderSeries ps c xDom yDom s pts =
  let ink = colourOf c
      fillCol = lerpColor c (plotFrameBg ps) 0.18
      fill = colourOf fillCol
      toP (x, y) = p2 (domainToPlot xDom x, domainToPlot yDom y)
   in case seriesKind s of
        LineSeries w _ ->
          fromVertices (U.foldr (\p acc -> toP p : acc) [] pts) # lc ink # lwO (plotStroke w)
        ScatterSeries w mk ->
          U.foldl' (\acc p -> acc <> markShape mk w ink (toP p)) mempty pts
        BarSeries frac ->
          renderBars ink frac pts
        AreaSeries baseline ->
          areaPath baseline xDom yDom pts # fc fill # lw none
        StepSeries w ->
          fromVertices (stepPoints pts xDom yDom) # lc ink # lwO (plotStroke w)

seriesPoints :: Chart -> Series -> U.Vector (Double, Double)
seriesPoints chart s =
  case seriesData s of
    PointsXY pts ->
      let k = decimateK (U.length pts)
       in if chartDecimate chart && U.length pts > k then lttb k pts else pts
    CategoryY _ values ->
       U.generate (sizeofPrimArray values) (\i -> (fromIntegral i, indexPrimArray values i))

decimateK :: Int -> Int
decimateK n = min n (max 64 (min 2000 (n `div` 2)))

renderBars :: Colour Double -> Float -> U.Vector (Double, Double) -> Diagram B
renderBars fill frac pts
  | U.null pts = mempty
  | otherwise =
      let !len  = U.length pts
          !n    = fromIntegral len :: Double
          !w    = realToFrac frac / n
          !invN = 1.0 / n
          !xOff = 0.5 * invN

          -- Single-pass strict fold for maxY (avoids allocating a list or intermediate vector)
          !maxY = U.foldl' (\ !acc (_, y) -> max acc (abs y)) 1e-9 pts
          !invMaxY = 1.0 / maxY

          drawBar (x, y) =
            let !absY = abs y
                !h    = absY * invMaxY
                !posX = x * invN + xOff
                !posY = signum y * h * 0.5
             in rect w h
                  # fc fill
                  # lw none
                  # translate (posX ^& posY)
       in U.foldl' (\acc p -> acc <> drawBar p) mempty pts

areaPath :: Double -> Domain -> Domain -> U.Vector (Double, Double) -> Diagram B
areaPath baseline xDom yDom pts
  | U.null pts = mempty
  | otherwise =
      let !baseY = domainToPlot yDom baseline
          toTop (!x, !y) = p2 (domainToPlot xDom x, domainToPlot yDom y)
          toBase (!x, !_) = p2 (domainToPlot xDom x, baseY)

          -- Forward traversal builds `top` in order
          top = U.foldr (\p acc -> toTop p : acc) [] pts
          -- Left fold naturally yields reverse order without allocating an intermediate reversed vector
          base = U.foldl' (\acc p -> toBase p : acc) [] pts
       in closedPoly (top ++ base)

closedPoly :: [P2 Double] -> Diagram B
closedPoly pts = fromVertices pts # closeTrail # strokeTrail

stepPoints :: U.Vector (Double, Double) -> Domain -> Domain -> [P2 Double]
stepPoints pts xDom yDom =
  let toP (x, y) = p2 (domainToPlot xDom x, domainToPlot yDom y)
   in U.foldr
        (\((x0, y0), (x1, _)) acc -> toP (x0, y0) : toP (x1, y0) : acc)
        []
        (U.zip pts (U.drop 1 pts))

markShape :: MarkShape -> Float -> Colour Double -> P2 Double -> Diagram B
markShape MarkCircle w c p =
  circle (plotMarkerRadius w) # fc c # lw none # moveTo p
markShape MarkSquare w c p =
  let s = plotMarkerRadius w * 2
   in rect s s # fc c # lw none # moveTo p
markShape MarkDiamond w c p =
  let r = plotMarkerRadius w * 1.4
   in closedPoly [p2 (0, r), p2 (r, 0), p2 (0, -r), p2 (-r, 0)]
        # fc c
        # lw none
        # moveTo p
markShape MarkTriangle w c p =
  let r = plotMarkerRadius w * 1.6
   in closedPoly [p2 (0, r), p2 (-r, -r * 0.6), p2 (r, -r * 0.6)]
        # fc c
        # lw none
        # moveTo p
markShape MarkCross w c p =
  let r = plotMarkerRadius w * 1.4
      sw = plotStroke w
   in ( (fromVertices [p2 (-r, -r), p2 (r, r)] # lc c # lwO sw)
          <> (fromVertices [p2 (-r, r), p2 (r, -r)] # lc c # lwO sw)
      )
        # moveTo p

renderLegend :: FontMetrics -> PlotStyle -> [(Color, Series)] -> Chart -> ChartChrome -> Diagram B
renderLegend _ _ _ Chart {chartLegend = LegendNone} _ = mempty
renderLegend fm ps coloredSeries chart chrome =
  let px = ccPx chrome
      row = px (fmLineHeight fm + 8)
      col = px (ccLegendW chrome) + 0.22
      botLegendY =
        case chartXTitle chart of
          Nothing -> -(ccXTickPad chrome) - px (fmLineHeight fm) - px 6
          Just _ -> ccXTitleY chrome - px (fmLineHeight fm) - px 6
      position i = case chartLegend chart of
        LegendRight -> (1.04, 0.98 - i * row)
        LegendBottom -> (i * col, botLegendY)
        LegendTop -> (i * col, 1.12)
        LegendInside -> (0.02, 0.98 - i * row)
        LegendNone -> (0, 0)
   in mconcat
        [ legendEntry ps color (T.unpack (seriesName s)) # moveTo (p2 (position i))
        | (i, (color, s)) <- zip [0 ..] coloredSeries
        ]

legendEntry :: PlotStyle -> Color -> String -> Diagram B
legendEntry ps col name =
  (fromVertices [p2 (0, 0), p2 (0.12, 0)] # lc (colourOf col) # lwO (plotStroke 1.5))
    <> (plotLbl ps 0 0.5 name # moveTo (p2 (0.16, 0)))