packages feed

nano-ui-diagrams (empty) → 0.1.0.0

raw patch · 18 files changed

+2570/−0 lines, 18 filesdep +basedep +colourdep +containers

Dependencies added: base, colour, containers, diagrams-core, diagrams-lib, dlist, effectful-core, hashable, hspec, lens, nano-ui, nano-ui-diagrams, primitive, text, vector

Files

+ CHANGELOG.md view
@@ -0,0 +1,10 @@+# Changelog++## 0.1.0.0++First release.++- `diagram` places a diagrams-lib `Diagram` in a nano-ui layout, drawn+  through a nano-ui backend for diagrams.+- `NanoUI.Plot` builds line, bar, scatter, area and step charts with axes,+  legends, and hover lookup, and decimates long series before drawing them.
+ LICENSE view
@@ -0,0 +1,20 @@+Copyright (c) 2026 goolord++Permission is hereby granted, free of charge, to any person obtaining+a copy of this software and associated documentation files (the+"Software"), to deal in the Software without restriction, including+without limitation the rights to use, copy, modify, merge, publish,+distribute, sublicense, and/or sell copies of the Software, and to+permit persons to whom the Software is furnished to do so, subject to+the following conditions:++The above copyright notice and this permission notice shall be included+in all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ README.md view
@@ -0,0 +1,34 @@+# nano-ui-diagrams++Charts and [diagrams](https://diagrams.github.io/) drawings for+[nano-ui](https://github.com/goolord/nano-ui).++`diagram` places a diagrams-lib `Diagram` in a nano-ui layout. `NanoUI.Plot`+builds line, bar, scatter, area, and step charts with axes, legends, and hover+lookup, and thins out long series before drawing them.++```haskell+{-# LANGUAGE OverloadedStrings #-}++import Data.Text qualified as T+import NanoUI+import NanoUI.Plot++temperatures :: Chart+temperatures =+  withTitle "Temperature" . withLegend LegendBottom $+    chart+      [ line "Indoor" [(0, 20), (1, 21.5), (2, 22), (3, 21)]+      , line "Outdoor" [(0, 8), (1, 11), (2, 14), (3, 12)]+      ]++view :: NanoUI ()+view = column $ do+  resp <- plot (minH 240 . fillW) temperatures+  case plotHover resp of+    Just h -> label (T.pack (show (hoverDataX h, hoverDataY h)))+    Nothing -> pure ()+```++The Plots tab of `nano-ui-sdl-demo` in `nano-ui-demo` shows more charts and a+diagram.
+ lib/NanoUI/Diagrams.hs view
@@ -0,0 +1,11 @@+-- | The whole nano-ui-diagrams API: the diagrams-lib backend, the diagram+-- widgets, and "NanoUI.Plot".+module NanoUI.Diagrams+  ( module NanoUI.Diagrams.Backend+  , module NanoUI.Diagrams.Widget+  , module NanoUI.Plot+  ) where++import NanoUI.Diagrams.Backend+import NanoUI.Diagrams.Widget+import NanoUI.Plot
+ lib/NanoUI/Diagrams/Backend.hs view
@@ -0,0 +1,252 @@+-- | A diagrams-lib backend that renders a diagram to nano-ui @DrawOp@ values,+-- scaled into a target size.+module NanoUI.Diagrams.Backend+  ( NanoUIBackend (..)+  , B+  , diagramOps+  , diagramTextOps+  , letterbox+  )+where++import Control.Lens (Lens', (^.), (^?))+import Data.Colour (AlphaColour, alphaChannel, black, over)+import Data.Colour.SRGB (RGB (..), toSRGB)+import Data.DList (DList)+import Data.DList qualified as DL+import Data.Maybe (fromMaybe)+import Data.Text qualified as T+import Data.Tree (Tree (Node))+import Data.Typeable (Typeable)+import Data.Primitive.SmallArray (SmallArray, emptySmallArray, mapSmallArray', smallArrayFromList)+import Diagrams.Attributes (_lineWidthU)+import Diagrams.Core+  ( Backend (..)+  , N+  , QDiagram+  , Renderable (..)+  , V+  , renderDia+  )+import Diagrams.Core qualified as DiaCore+import Diagrams.Core.Types (Annotation, RNode (..), RTree)+import Diagrams.Located (Located, unLoc)+import Diagrams.Path (Path, pathTrails)+import Diagrams.Prelude+  ( Any+  , P2+  , SizeSpec+  , Trail+  , V2 (..)+  , origin+  , p2+  , papply+  , reflectY+  , reflectionY+  , size+  , unp2+  , (#)+  )+import Diagrams.Segment (FixedSegment (..))+import Diagrams.Trail (fixTrail, isLoop)+import Diagrams.TwoD.Adjust (adjustDia2D)+import Diagrams.TwoD.Attributes (_AC, _fillTexture, _lineTexture)+import Diagrams.TwoD.Size (mkHeight)+import Diagrams.TwoD.Text (Text (..), TextAlignment (..))+import NanoUI+  ( Color+  , DrawOp (..)+  , colorA+  , colorRGBA+  , defaultTheme+  , shiftDrawOp+  , themeMuted+  )+import NanoUI.Diagrams.Tessellation+  ( fillPolygon+  , flattenCubic+  , strokePolyline+  )++data NanoUIBackend = NanoUIBackend+  deriving (Eq, Show)++type B = NanoUIBackend++type instance V NanoUIBackend = V2++type instance N NanoUIBackend = Double++fullSize :: Lens' (Options NanoUIBackend V2 n) (SizeSpec V2 n)+fullSize f (NanoUIOptions sz textOnly) = fmap (\sz' -> NanoUIOptions sz' textOnly) (f sz)++instance (Typeable n, RealFloat n) => Backend NanoUIBackend V2 n where+  newtype Render NanoUIBackend V2 n+    = NRenderFull (Bool -> DiaCore.Style V2 n -> DList DrawOp)+  type Result NanoUIBackend V2 n = SmallArray DrawOp+  data Options NanoUIBackend V2 n = NanoUIOptions (SizeSpec V2 n) Bool+  renderRTree _ (NanoUIOptions _ textOnly) rt = smallArrayFromList (DL.toList (walkFull textOnly mempty rt))+  adjustDia c opts d = (sz, t <> reflectionY, d')+   where+    (sz, t, d') = adjustDia2D fullSize c opts (d # reflectY)++instance Semigroup (Render NanoUIBackend V2 n) where+  NRenderFull f <> NRenderFull g = NRenderFull (\textOnly sty -> f textOnly sty <> g textOnly sty)++instance Monoid (Render NanoUIBackend V2 n) where+  mempty = NRenderFull (\_ _ -> DL.empty)++walkFull ::+  (Typeable n, RealFloat n) =>+  Bool+  -> DiaCore.Style V2 n+  -> RTree NanoUIBackend V2 n Annotation+  -> DList DrawOp+walkFull textOnly sty (Node n cs) =+  case n of+    RPrim prim ->+      let+        NRenderFull f = render NanoUIBackend prim+       in+        f textOnly sty+    RStyle s -> foldMap (walkFull textOnly (sty <> s)) cs+    _ -> foldMap (walkFull textOnly sty) cs++instance (Typeable n, RealFloat n) => Renderable (Path V2 n) NanoUIBackend where+  render _ path = NRenderFull $ \textOnly sty ->+    if textOnly+      then DL.empty+      else foldMap (DL.fromList . trailOps sty) (pathTrails path)++textOps ::+  (Typeable n, RealFloat n) => Text n -> DiaCore.Style V2 n -> DList DrawOp+textOps (Text tr align str) sty+  | null str = DL.empty+  | otherwise =+      let+        p = papply tr origin+        (x, y) = unp2 p+        (ax, ay) =+          case align of+            BaselineText -> (0, -1)+            BoxAlignedText bx by -> (toF bx, toF by)+        col =+          case solidColour (sty ^? (_fillTexture . _AC)) of+            Just c -> c+            Nothing ->+              fromMaybe+                (themeMuted defaultTheme)+                (solidColour (sty ^? (_lineTexture . _AC)))+       in+        DL.singleton (DrawText (toF x) (toF y) ax ay (T.pack str) col)++instance (Typeable n, RealFloat n) => Renderable (Text n) NanoUIBackend where+  render _ t = NRenderFull (const (textOps t))++trailOps ::+  (Typeable n, RealFloat n) =>+  DiaCore.Style V2 n -> Located (Trail V2 n) -> [DrawOp]+trailOps sty lt =+  let+    pts = [(toF x, toF y) | (x, y) <- map unp2 (trailSamples lt)]+    lineW0 = sty ^. _lineWidthU+    lineW =+      case fmap toF lineW0 of+        Nothing -> 1+        Just w+          | w <= 0 -> 0+          | w < 1 -> 1+          | otherwise -> w+    fillC = solidColour (sty ^? (_fillTexture . _AC))+    lineC = solidColour (sty ^? (_lineTexture . _AC))+    closed = isLoop (unLoc lt)+    fills =+      case fillC of+        Just c+          | colorA c > 0 && closed && length pts >= 3 -> fillPolygon c pts+        _ -> []+    strokes =+      case lineC of+        Just c+          | colorA c > 0 && lineW > 0 && length pts >= 2 ->+              strokePolyline c lineW closed pts+        _ -> []+   in+    fills ++ strokes++trailSamples :: RealFloat n => Located (Trail V2 n) -> [P2 n]+trailSamples lt =+  case map sampleSeg (fixTrail lt) of+    [] -> []+    (firstSeg : rest) ->+      let+        pts = firstSeg ++ concatMap (drop 1) rest+       in+        if isLoop (unLoc lt) && not (null pts)+          then pts ++ take 1 pts+          else pts++sampleSeg :: RealFloat n => FixedSegment V2 n -> [P2 n]+sampleSeg (FLinear p0 p1) = [p0, p1]+sampleSeg (FCubic p0 c1 c2 p1) =+  [ p2 (realToFrac x, realToFrac y)+  | (x, y) <- flattenCubic (f p0) (f c1) (f c2) (f p1)+  ]+ where+  f p = let (x, y) = unp2 p in (toF x, toF y)++solidColour :: Maybe (AlphaColour Double) -> Maybe Color+solidColour mc = do+  ac <- mc+  let+    a = alphaChannel ac+  if a <= 0+    then Nothing+    else+      let+        RGB r g b = toSRGB (ac `over` black)+        q x = round (clamp01 x * 255)+       in+        Just (colorRGBA (q r) (q g) (q b) (q a))++clamp01 :: Double -> Double+clamp01 x = max 0 (min 1 x)++toF :: Real n => n -> Float+toF = realToFrac++diagramOps ::+  Double -> Double -> QDiagram NanoUIBackend V2 Double Any -> SmallArray DrawOp+diagramOps = renderFull False++diagramTextOps ::+  Double -> Double -> QDiagram NanoUIBackend V2 Double Any -> SmallArray DrawOp+-- Text uses the same backend and viewport as geometry. In particular, do not+-- coerce a QDiagram between backends: its primitives carry Renderable dictionaries.+diagramTextOps = renderFull True++renderFull ::+  Bool+  -> Double+  -> Double+  -> QDiagram NanoUIBackend V2 Double Any+  -> SmallArray DrawOp+renderFull textOnly w h d+  | w <= 0 || h <= 0 = emptySmallArray+  | otherwise =+      let+        V2 dw dh = size d+        (_, outH, dx, dy) = letterbox dw dh w h+        ops = renderDia NanoUIBackend (NanoUIOptions (mkHeight outH) textOnly) d+       in+        mapSmallArray' (shiftDrawOp (realToFrac dx) (realToFrac dy)) ops++-- | Scale a @dw@ by @dh@ diagram uniformly to fit a @w@ by @h@ box and centre+-- it: the drawn width and height, and the x and y offsets inside the box.+letterbox :: Double -> Double -> Double -> Double -> (Double, Double, Double, Double)+letterbox dw dh w h =+  let+    outH = if dw <= 1e-9 || dh <= 1e-9 then h else min h (w * dh / dw)+    outW = if dh <= 1e-9 then w else outH * dw / dh+   in+    (outW, outH, (w - outW) / 2, (h - outH) / 2)
+ lib/NanoUI/Diagrams/Tessellation.hs view
@@ -0,0 +1,250 @@+-- | Polygon triangulation and filling, polyline stroking, and cubic Bezier+-- flattening for the diagrams backend.+module NanoUI.Diagrams.Tessellation+  ( triangulatePolygon+  , fillPolygon+  , strokePolyline+  , flattenCubic+  ) where++import Control.Monad (forM_)+import Control.Monad.ST (runST)+import Data.Primitive.PrimArray+  ( PrimArray+  , indexPrimArray+  , newPrimArray+  , readPrimArray+  , runPrimArray+  , sizeofPrimArray+  , writePrimArray+  )+import NanoUI (Color, DrawOp (..), Rect (..))++bezierTolerance :: Float+bezierTolerance = 0.5++triangulatePolygon :: [(Float, Float)] -> [((Float, Float), (Float, Float), (Float, Float))]+triangulatePolygon [] = []+triangulatePolygon [_] = []+triangulatePolygon pts0 =+  let pts = stripClosed pts0+    in earClip (pointsArray pts)++stripClosed :: [(Float, Float)] -> [(Float, Float)]+stripClosed [] = []+stripClosed [p] = [p]+stripClosed (p : rest)+  | p == last rest = p : init rest+  | otherwise = p : rest++-- | Points stored as x then y.+pointsArray :: [(Float, Float)] -> PrimArray Float+pointsArray pts = runPrimArray $ do+  out <- newPrimArray (2 * length pts)+  let fill !_ [] = pure out+      fill !i ((x, y) : rest) = do+        writePrimArray out (2 * i) x+        writePrimArray out (2 * i + 1) y+        fill (i + 1) rest+  fill 0 pts++{-# INLINE pointAt #-}+pointAt :: PrimArray Float -> Int -> (Float, Float)+pointAt vs i = (indexPrimArray vs (2 * i), indexPrimArray vs (2 * i + 1))++signedArea :: PrimArray Float -> Float+signedArea vs =+  let n = sizeofPrimArray vs `div` 2+   in foldl' (\acc i -> acc + cross (pointAt vs i) (pointAt vs ((i + 1) `mod` n)) / 2) 0 [0 .. n - 1]++cross :: (Float, Float) -> (Float, Float) -> Float+cross (x0, y0) (x1, y1) = x0 * y1 - x1 * y0++diff :: (Float, Float) -> (Float, Float) -> (Float, Float)+diff (x0, y0) (x1, y1) = (x1 - x0, y1 - y0)++isConvex :: Bool -> (Float, Float) -> (Float, Float) -> (Float, Float) -> Bool+isConvex ccw a b c =+  let ab = diff a b+      bc = diff b c+   in if ccw then cross ab bc >= 0 else cross ab bc <= 0++pointInTri :: (Float, Float) -> (Float, Float) -> (Float, Float) -> (Float, Float) -> Bool+pointInTri p a b c =+  let sign (p1, p2, p3) = cross (diff p1 p3) (diff p2 p3)+      d1 = sign (p, a, b)+      d2 = sign (p, b, c)+      d3 = sign (p, c, a)+   in not ((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0))++earClip :: PrimArray Float -> [((Float, Float), (Float, Float), (Float, Float))]+earClip vs+  | n < 3 = []+  | n == 3 = [(at 0, at 1, at 2)]+  | otherwise = runST $ do+      let !ccw = signedArea vs >= 0+      -- Coordinates never move. Remove an ear by relinking two neighbours,+      -- instead of copying the remaining coordinates at every step.+      prevs <- newPrimArray n+      nexts <- newPrimArray n+      forM_ [0 .. n - 1] $ \i -> do+        writePrimArray prevs i ((i - 1 + n) `mod` n)+        writePrimArray nexts i ((i + 1) `mod` n)+      let triangle i = do+            p <- readPrimArray prevs i+            q <- readPrimArray nexts i+            pure (p, q, (at p, at i, at q))+          isEarAt first count i p q (a, b, c)+            | not (isConvex ccw a b c) = pure False+            | otherwise = outside first count+            where+              outside !_ 0 = pure True+              outside !j !left+                | j /= p && j /= i && j /= q && pointInTri (at j) a b c = pure False+                | otherwise = do+                    next <- readPrimArray nexts j+                    outside next (left - 1)+          convex !_ 0 = pure True+          convex !i !left = do+            (_, q, (a, b, c)) <- triangle i+            if isConvex ccw a b c then convex q (left - 1) else pure False+          fan origin i left+            | left <= 0 = pure []+            | otherwise = do+                q <- readPrimArray nexts i+                rest <- fan origin q (left - 1)+                pure ((at origin, at i, at q) : rest)+          go !first !count !idx !tries tris+            | count == 3 = do+                second <- readPrimArray nexts first+                third <- readPrimArray nexts second+                pure ((at first, at second, at third) : tris)+            | tries >= count = do+                isConvexRing <- convex first count+                if isConvexRing then do+                  second <- readPrimArray nexts first+                  rest <- fan first second (count - 2)+                  pure (tris ++ rest)+                else pure tris+            | otherwise = do+                (p, q, tri) <- triangle idx+                ear <- isEarAt first count idx p q tri+                if ear then do+                  writePrimArray nexts p q+                  writePrimArray prevs q p+                  let !first' = if idx == first then q else first+                  go first' (count - 1) first' 0 (tri : tris)+                else go first count q (tries + 1) tris+      reverse <$> go 0 n 0 0 []+  where+    n = sizeofPrimArray vs `div` 2+    at = pointAt vs++fillPolygon :: Color -> [(Float, Float)] -> [DrawOp]+fillPolygon col pts =+  case axisAlignedRect pts of+    Just r -> [FillRect r col]+    Nothing ->+      [ FillTriangle x0 y0 x1 y1 x2 y2 col+      | ((x0, y0), (x1, y1), (x2, y2)) <- triangulatePolygon pts+      ]++axisAlignedRect :: [(Float, Float)] -> Maybe Rect+axisAlignedRect pts =+  case stripClosed pts of+    [(x0, y0), (x1, y1), (x2, y2), (x3, y3)]+      | near y0 y1 && near x1 x2 && near y2 y3 && near x3 x0 ->+          Just (Rect (min x0 x3) (min y0 y2) (abs (x1 - x0)) (abs (y2 - y0)))+    [(x0, y0), (x1, y1), (x2, y2), (x3, y3)]+      | near x0 x1 && near y1 y2 && near x2 x3 && near y3 y0 ->+          Just (Rect (min x0 x2) (min y0 y1) (abs (x2 - x0)) (abs (y1 - y0)))+    _ -> Nothing+  where+    near a b = abs (a - b) <= 1e-3+++strokePolyline :: Color -> Float -> Bool -> [(Float, Float)] -> [DrawOp]+strokePolyline _ _ _ [] = []+strokePolyline _ _ _ [_] = []+strokePolyline col w closed pts0 =+  let pts = if closed && length pts0 > 2 then stripClosed pts0 else pts0+      hw = w / 2+      !vPts = pointsArray pts+      !n = sizeofPrimArray vPts `div` 2+   in if n < 2+        then []+        else+          let !segCount = if closed then n else n - 1+              -- Each segment's unit normal, x then y.+              !segNormals = runPrimArray $ do+                out <- newPrimArray (2 * segCount)+                forM_ [0 .. segCount - 1] $ \i -> do+                  let !(p0x, p0y) = pointAt vPts i+                      !(p1x, p1y) = pointAt vPts ((i + 1) `mod` n)+                      nx = p0y - p1y+                      ny = p1x - p0x+                      d = sqrt (nx * nx + ny * ny)+                  writePrimArray out (2 * i) (if d <= 1e-9 then 0 else nx / d)+                  writePrimArray out (2 * i + 1) (if d <= 1e-9 then 0 else ny / d)+                pure out+              joinNormal !i+                | not closed && i <= 0 = pointAt segNormals 0+                | not closed && i >= n - 1 = pointAt segNormals (segCount - 1)+                | otherwise =+                    let (ax, ay) = pointAt segNormals ((i - 1 + segCount) `mod` segCount)+                        (bx, by) = pointAt segNormals (i `mod` segCount)+                        sx = ax + bx+                        sy = ay + by+                        d = sqrt (sx * sx + sy * sy)+                     in if d <= 1e-9 then (0, 0) else (sx / d, sy / d)+              -- Adjacent quads share a vertex, so offset each vertex once: the+              -- two sides' points, four numbers a vertex.+              !offsets = runPrimArray $ do+                out <- newPrimArray (4 * n)+                forM_ [0 .. n - 1] $ \i -> do+                  let (!px, !py) = pointAt vPts i+                      (!nx, !ny) = joinNormal i+                  writePrimArray out (4 * i) (px + hw * nx)+                  writePrimArray out (4 * i + 1) (py + hw * ny)+                  writePrimArray out (4 * i + 2) (px - hw * nx)+                  writePrimArray out (4 * i + 3) (py - hw * ny)+                pure out+              offset i k = indexPrimArray offsets (4 * i + k)+              buildQuads !i+                | i >= segCount = []+                | otherwise =+                    let !j = if closed then (i + 1) `mod` n else i + 1+                     in FillTriangle (offset i 0) (offset i 1) (offset j 0) (offset j 1) (offset j 2) (offset j 3) col+                          : FillTriangle (offset i 0) (offset i 1) (offset j 2) (offset j 3) (offset i 2) (offset i 3) col+                          : buildQuads (i + 1)+           in buildQuads 0++flattenCubic ::+  (Float, Float) ->+  (Float, Float) ->+  (Float, Float) ->+  (Float, Float) ->+  [(Float, Float)]+flattenCubic p0 c1 c2 p1 = go p0 c1 c2 p1+  where+    go a b c d =+      let mid (p, q) = ((fst p + fst q) / 2, (snd p + snd q) / 2)+          ab = mid (a, b)+          bc = mid (b, c)+          cd = mid (c, d)+          abbc = mid (ab, bc)+          bccd = mid (bc, cd)+          mid12 = mid (abbc, bccd)+          flat =+            let (dx, dy) = diff d a+                len = sqrt (dx * dx + dy * dy)+                dist =+                  if len <= 1e-9+                    then 0+                    else abs (cross (diff b a) (dx, dy)) / len+             in dist <= bezierTolerance+       in if flat+            then [a, d]+            else+              let rest = go mid12 bccd cd d+               in go a ab abbc mid12 ++ drop 1 rest
+ lib/NanoUI/Diagrams/Widget.hs view
@@ -0,0 +1,327 @@+-- | Widgets that place a diagram in a nano-ui layout, and the plot style+-- derived from the current theme.+module NanoUI.Diagrams.Widget+  ( diagram+  , diagramWithEnvelope+  , diagramWithKeyAndEnvelope+  , fitLayout+  , labelFitScale+  , diagramFrame+  , frameInner+  , PlotStyle (..)+  , themePlotStyle+  , defaultPlotStyle+  , uiPlotStyle+  , colourOf+  , themePlotKey+  ) where+++import Data.Colour (Colour)+import Data.Colour.SRGB (sRGB24)+import Data.Hashable (hash, hashWithSalt)+import Data.Primitive.PrimArray (indexPrimArray, newPrimArray, runPrimArray, writePrimArray)+import Data.Primitive.SmallArray (SmallArray, emptySmallArray, indexSmallArray, mapSmallArray', sizeofSmallArray, smallArrayFromList)+import Diagrams.Core (QDiagram)+import Diagrams.Prelude (Any, Diagram, V2 (..), size)+import Effectful (Eff, type (:>))+import NanoUI+  ( Color+  , DrawOp (..)+  , FontMetrics (..)+  , Layout (..)+  , Rect (..)+  , Response+  , Sizing (..)+  , Style (..)+  , Theme (..)+  , Ui+  , colorB+  , colorG+  , colorR+  , colorToWord32+  , defaultLayout+  , defaultTheme+  , drawTextBox+  , drawingCached+  , lerpColor+  , shiftDrawOp+  , styleBg+  , styleBorder+  , themeAccent+  , themeGreen+  , themeMuted+  , themeOrange+  , themePurple+  , themeRed+  , themeSeparator+  , themeWindow+  , themeYellow+  , uiFontMetrics+  , prepareFontMetricsMany+  , uiTheme+  )+import NanoUI.Context (lookupDrawFitEnvelope)+import NanoUI.Monad (askContext, currentId, uiIO)+import NanoUI.Diagrams.Backend+  ( B+  , NanoUIBackend+  , diagramOps+  , diagramTextOps+  )++data PlotStyle = PlotStyle+  { plotInk :: Colour Double+  , plotFill :: Colour Double+  , plotGrid :: Colour Double+  , plotMuted :: Colour Double+  , plotFrameBg :: Color+  , plotFrameBorder :: Color+  }+  deriving (Eq, Show)++colourOf :: Color -> Colour Double+colourOf c = sRGB24 (colorR c) (colorG c) (colorB c)++themePlotStyle :: Theme -> PlotStyle+themePlotStyle t =+  let muted = themeMuted t+      panel = themePanel t+   in PlotStyle+        { plotInk = colourOf (themeRed t)+        , plotFill = colourOf (lerpColor (themeAccent t) muted 0.22)+        , plotGrid = colourOf (lerpColor (themeSeparator t) muted 0.30)+        , plotMuted = colourOf muted+        , plotFrameBg = styleBg (themeInput t)+        , plotFrameBorder = styleBorder panel+        }++defaultPlotStyle :: PlotStyle+defaultPlotStyle = themePlotStyle defaultTheme++themePlotKey :: Theme -> Int+themePlotKey t =+  hash (colorToWord32 (themeAccent t))+    `hashWithSalt` colorToWord32 (themeMuted t)+    `hashWithSalt` colorToWord32 (themeRed t)+    `hashWithSalt` colorToWord32 (themeOrange t)+    `hashWithSalt` colorToWord32 (themeYellow t)+    `hashWithSalt` colorToWord32 (themeGreen t)+    `hashWithSalt` colorToWord32 (themePurple t)+    `hashWithSalt` colorToWord32 (themeSeparator t)+    `hashWithSalt` colorToWord32 (themeWindow t)+    `hashWithSalt` colorToWord32 (styleBg (themePanel t))+    `hashWithSalt` colorToWord32 (styleBorder (themePanel t))+    `hashWithSalt` colorToWord32 (styleBg (themeInput t))++uiPlotStyle :: Ui :> es => Eff es PlotStyle+uiPlotStyle = fmap themePlotStyle uiTheme++labelFitScale :: FontMetrics -> SmallArray DrawOp -> Double+labelFitScale fm ops =+  let -- Six numbers a label: its anchor, and its box's origin and size.+      !n = foldl' (\c op -> case op of DrawText {} -> c + 1; _ -> c) 0 ops+      !ts = runPrimArray $ do+        out <- newPrimArray (n * 6)+        let fill !i !b+              | i >= sizeofSmallArray ops = pure out+              | otherwise = case indexSmallArray ops i of+                  DrawText x y ax ay t _ -> do+                    let Rect px py tw th = drawTextBox fm x y ax ay t+                    writePrimArray out b x+                    writePrimArray out (b + 1) y+                    writePrimArray out (b + 2) px+                    writePrimArray out (b + 3) py+                    writePrimArray out (b + 4) tw+                    writePrimArray out (b + 5) th+                    fill (i + 1) (b + 6)+                  _ -> fill (i + 1) b+        fill 0 0+      at i field = indexPrimArray ts (i * 6 + field)+      !k = outerLoop 0 (1.0 :: Float)+        where+          outerLoop !i !acc+            | i >= n - 1 = acc+            | otherwise  =+                let innerLoop !j !m+                      | j >= n    = m+                      | otherwise =+                          let !pairVal = pairK (at i 0) (at i 1) (at i 2) (at i 3) (at i 4) (at i 5) (at j 0) (at j 1) (at j 2) (at j 3) (at j 4) (at j 5)+                          in innerLoop (j + 1) (max m pairVal)+                in outerLoop (i + 1) (innerLoop (i + 1) acc)+   in min 2 (realToFrac k)+  where++    pairK !x1 !y1 !px1 !py1 !tw1 !th1 !x2 !y2 !px2 !py2 !tw2 !th2 =+      let !overlapX = px1 < px2 + tw2 && px2 < px1 + tw1+          !overlapY = py1 < py2 + th2 && py2 < py1 + th1+       in if overlapX && overlapY+            then+              max+                (axisK x1 x2 (px1 - x1) tw1 (px2 - x2) tw2)+                (axisK y1 y2 (py1 - y1) th1 (py2 - y2) th2)+            else 1.0++    axisK !a1 !a2 !o1 !size1 !o2 !size2 =+      let (!loA, !loO, !loS, !hiA, !hiO) =+            if a1 <= a2+              then (a1, o1, size1, a2, o2)+              else (a2, o2, size2, a1, o1)+          !den  = hiA - loA+          !need = loO + loS + 2 - hiO+       in if den <= 1e-6 then 1.0 else max 1.0 (need / den)++diagramFrame :: PlotStyle -> Float -> Rect -> SmallArray DrawOp+diagramFrame ps bw (Rect x y w h) =+  smallArrayFromList+    [ FillRect (Rect x y w h) (plotFrameBg ps)+    , Stroke x y (x + w) y bw (plotFrameBorder ps)+    , Stroke (x + w) y (x + w) (y + h) bw (plotFrameBorder ps)+    , Stroke (x + w) (y + h) x (y + h) bw (plotFrameBorder ps)+    , Stroke x (y + h) x y bw (plotFrameBorder ps)+    ]++-- Grow plots cap here unless the caller set a tighter layoutMaxH.+growPlotCapH :: Float+growPlotCapH = 260++fitLayout :: FontMetrics -> Layout -> Diagram B -> Layout+fitLayout fm layout d =+  let V2 dw dh = size d+      ar = if dh <= 1e-9 then 1 else dw / dh+      growW =+        case layoutWidth layout of+          Grow _ -> True+          _ -> False+      (baseW, baseH) =+        case (layoutWidth layout, layoutHeight layout) of+          (Fixed bw, Fixed bh)+            | dw > 1e-9 && dh > 1e-9 ->+                let s = min (realToFrac bw / dw) (realToFrac bh / dh)+                 in (dw * s, dh * s)+          (_, Fixed bh) ->+            let h = realToFrac bh :: Double+             in (h * ar, h)+          (Fixed bw, _) ->+            let w = realToFrac bw :: Double+             in (w, w / ar)+          _ ->+            let h =+                  if layoutMinH layout > 0+                    then realToFrac (layoutMinH layout)+                    else 160+             in (h * ar, h)+      clampSize x = realToFrac (max 8 x) :: Float+   in if growW+        then+          let capH = min growPlotCapH (layoutMaxH layout)+              floorH = if layoutMinH layout > 0 then layoutMinH layout else 180+              probeH0 =+                realToFrac+                  ( if layoutMinH layout > 0+                      then layoutMinH layout+                      else 200+                  ) ::+                  Double+              probeH = min probeH0 (realToFrac capH)+              probeW = probeH * ar+              k = labelFitScale fm (diagramTextOps probeW probeH d)+              needW = clampSize (probeW * k)+              needH = min capH (max floorH (clampSize (probeH * k)))+           in layout+                { layoutHeight = Fit+                , layoutMinW = max (layoutMinW layout) needW+                , layoutMinH = needH+                , layoutMaxH = max needH capH+                }+        else+          let k = labelFitScale fm (diagramTextOps baseW baseH d)+              wF = clampSize (baseW * k)+              hF = clampSize (baseH * k)+           in layout+                { layoutWidth = Fixed wF+                , layoutHeight = Fixed hF+                , layoutMinW = wF+                , layoutMaxW = wF+                , layoutMinH = hF+                , layoutMaxH = hF+                }++-- | 'diagramWithEnvelope' whose cached draw ops are also keyed by @userKey@.+-- Change the key when the diagram's content changes.+diagramWithKeyAndEnvelope ::+  Ui :> es =>+  Int ->+  Double ->+  Double ->+  (Layout -> Layout) ->+  QDiagram NanoUIBackend V2 Double Any ->+  Eff es Response+diagramWithKeyAndEnvelope userKey dw dh f =+  framedDiagram (\t -> hash (userKey, themePlotKey t)) dw dh (f defaultLayout)++-- | 'diagram' with an explicit envelope width and height.+diagramWithEnvelope ::+  Ui :> es =>+  Double ->+  Double ->+  (Layout -> Layout) ->+  QDiagram NanoUIBackend V2 Double Any ->+  Eff es Response+diagramWithEnvelope dw dh f = framedDiagram themePlotKey dw dh (f defaultLayout)++-- | Draw a diagram inside the plot frame. Its draw ops are cached under the+-- content key the caller derives from the current theme.+framedDiagram ::+  Ui :> es =>+  (Theme -> Int) ->+  Double ->+  Double ->+  Layout ->+  QDiagram NanoUIBackend V2 Double Any ->+  Eff es Response+framedDiagram contentKey dw dh layout d = do+  fm <- uiFontMetrics+  theme <- uiTheme+  let ps = themePlotStyle theme+  drawingCached dw dh (fmLineHeight fm) (contentKey theme) (const layout) (fitLayoutIO fm layout d) $ \rectBox ->+    let inner = frameInner rectBox+        w = realToFrac (rectW inner) :: Double+        h = realToFrac (rectH inner)+        plot =+          if w <= 0 || h <= 0+            then emptySmallArray+            else mapSmallArray' (shiftDrawOp (rectX inner) (rectY inner)) (diagramOps w h d)+     in diagramFrame ps frameBorder rectBox <> plot++frameBorder :: Float+frameBorder = 1++-- | The box a framed diagram draws into, inside its border.+frameInner :: Rect -> Rect+frameInner (Rect x y w h) =+  Rect (x + frameBorder) (y + frameBorder) (max 0 (w - 2 * frameBorder)) (max 0 (h - 2 * frameBorder))++fitLayoutIO :: FontMetrics -> Layout -> Diagram B -> IO Layout+fitLayoutIO fm layout d = do+  let texts = foldr (\op rest -> case op of+        DrawText _ _ _ _ t _ -> t : rest+        _ -> rest) [] (diagramTextOps 100 100 d)+  prepared <- prepareFontMetricsMany fm texts+  pure (fitLayout prepared layout d)++-- | Draw a diagram inside a framed box sized by the layout modifier. Text in+-- the diagram is measured with the current font so labels fit.+diagram :: Ui :> es => (Layout -> Layout) -> QDiagram NanoUIBackend V2 Double Any -> Eff es Response+diagram f d = do+  ctx <- askContext+  wid <- currentId+  fm <- uiFontMetrics+  theme <- uiTheme+  let content = themePlotKey theme+  mEnv <- uiIO (lookupDrawFitEnvelope ctx wid (fmLineHeight fm) content (f defaultLayout))+  case mEnv of+    Just (dw, dh) -> diagramWithEnvelope dw dh f d+    Nothing ->+      let V2 dw dh = size d+       in diagramWithEnvelope dw dh f d
+ lib/NanoUI/Plot.hs view
@@ -0,0 +1,20 @@+-- | Charting and plotting API for nano-ui.+module NanoUI.Plot+  ( module NanoUI.Plot.Types+  , module NanoUI.Plot.Series+  , module NanoUI.Plot.Scale+  , module NanoUI.Plot.Decimate+  , module NanoUI.Plot.Chrome+  , module NanoUI.Plot.Widget+  , module NanoUI.Plot.Builder+  , module NanoUI.Plot.Hit+  ) where++import NanoUI.Plot.Builder+import NanoUI.Plot.Hit+import NanoUI.Plot.Chrome+import NanoUI.Plot.Decimate+import NanoUI.Plot.Scale+import NanoUI.Plot.Series+import NanoUI.Plot.Types+import NanoUI.Plot.Widget
+ lib/NanoUI/Plot/Builder.hs view
@@ -0,0 +1,45 @@+-- | Functions that build a 'NanoUI.Plot.Types.Chart' from series and set its+-- titles, legend, grid and decimation.+module NanoUI.Plot.Builder+  ( chart+  , withTitle+  , withXAxis+  , withYAxis+  , withLegend+  , withGrid+  , withDecimate+  , addSeries+  ) where++import Data.Text (Text)+import NanoUI.Plot.Types+  ( Chart (..)+  , GridMode+  , LegendPos+  , Series+  , emptyChart+  )++chart :: [Series] -> Chart+chart series = emptyChart {chartSeries = series}++withTitle :: Text -> Chart -> Chart+withTitle t c = c {chartTitle = Just t}++withXAxis :: Text -> Chart -> Chart+withXAxis t c = c {chartXTitle = Just t}++withYAxis :: Text -> Chart -> Chart+withYAxis t c = c {chartYTitle = Just t}++withLegend :: LegendPos -> Chart -> Chart+withLegend p c = c {chartLegend = p}++withGrid :: GridMode -> Chart -> Chart+withGrid g c = c {chartGrid = g}++withDecimate :: Bool -> Chart -> Chart+withDecimate b c = c {chartDecimate = b}++addSeries :: Series -> Chart -> Chart+addSeries s c = c {chartSeries = chartSeries c ++ [s]}
+ lib/NanoUI/Plot/Chrome.hs view
@@ -0,0 +1,399 @@+-- | 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)))
+ lib/NanoUI/Plot/Decimate.hs view
@@ -0,0 +1,108 @@+-- | Point reduction for long series: largest-triangle-three-buckets and+-- per-bucket minimum and maximum.+module NanoUI.Plot.Decimate+  ( lttb+  , minMaxDecimate+  ) where++import Control.Monad.ST (runST)+import Data.Vector.Generic (Vector)+import qualified Data.Vector.Generic as V+import qualified Data.Vector.Generic.Mutable as MV+import qualified Data.Vector.Unboxed as U++-- | Largest-Triangle-Three-Buckets sampling. Returns at most the requested+-- number of points in input order, preserving both endpoints for budgets >= 2.+{-# INLINABLE lttb #-}+{-# SPECIALIZE lttb :: Int -> U.Vector (Double, Double) -> U.Vector (Double, Double) #-}+lttb :: Vector v (Double, Double) => Int -> v (Double, Double) -> v (Double, Double)+lttb k0 pts+  | k0 <= 0 = V.empty+  | n <= k0 = pts+  | k0 == 1 = V.take 1 pts+  | k0 == 2 = V.fromList [V.head pts, V.last pts]+  | otherwise = V.create $ do+      out <- MV.new k0+      let !k = k0+          !bucketSize = fromIntegral (n - 2) / (fromIntegral (k - 2) :: Double)+          !firstPt = pts V.! 0+          !lastPt = pts V.! (n - 1)+          go !i !prevIdx+            | i >= k - 2 = MV.write out (k - 1) lastPt+            | otherwise =+                let !rangeStart = floor (fromIntegral i * bucketSize) + 1+                    !rangeEnd = min (n - 1) (floor (fromIntegral (i + 1) * bucketSize) + 1)+                    !avgStart = rangeEnd+                    !avgEnd = min n (floor (fromIntegral (i + 2) * bucketSize) + 1)+                    (!avgX, !avgY) = bucketAvg pts avgStart avgEnd+                    !prevPt = pts V.! prevIdx+                    findBest !j !bestIdx !bestArea+                      | j >= rangeEnd = bestIdx+                      | otherwise =+                          let !area = triArea prevPt (pts V.! j) (avgX, avgY)+                           in if area > bestArea+                                then findBest (j + 1) j area+                                else findBest (j + 1) bestIdx bestArea+                    -- An empty range keeps rangeStart; areas are never negative.+                    !best = findBest rangeStart rangeStart (-1)+                 in do+                   MV.write out (i + 1) (pts V.! best)+                   go (i + 1) best+      MV.write out 0 firstPt+      go 0 0+      pure out+  where+    !n = V.length pts++{-# INLINE bucketAvg #-}+bucketAvg :: Vector v (Double, Double) => v (Double, Double) -> Int -> Int -> (Double, Double)+bucketAvg pts !start !end+  | start >= end = (0, 0)+  | otherwise =+      let !len = end - start+          !denom = fromIntegral len :: Double+          go !i !sx !sy+            | i >= end = (sx / denom, sy / denom)+            | otherwise =+                let (!x, !y) = pts V.! i+                 in go (i + 1) (sx + x) (sy + y)+       in go start 0 0++triArea :: (Double, Double) -> (Double, Double) -> (Double, Double) -> Double+triArea (!x0, !y0) (!x1, !y1) (!x2, !y2) =+  abs ((x0 - x2) * (y1 - y0) - (x0 - x1) * (y2 - y0)) * 0.5++-- | Split into at most @k@ buckets and retain each bucket's Y extrema in+-- input order. The output has at most @2*k@ points; non-positive budgets+-- return an empty vector. A point selected as both extrema is emitted once.+{-# INLINABLE minMaxDecimate #-}+{-# SPECIALIZE minMaxDecimate :: Int -> U.Vector (Double, Double) -> U.Vector (Double, Double) #-}+minMaxDecimate :: Vector v (Double, Double) => Int -> v (Double, Double) -> v (Double, Double)+minMaxDecimate k pts+  | k <= 0 = V.empty+  | len <= k = pts+  | otherwise = runST $ do+      out <- MV.new (min len (2 * numChunks))+      let chunks !start !written+            | start >= len = V.freeze (MV.slice 0 written out)+            | otherwise = do+                let !end = start + min bucket (len - start)+                    extrema !i !lowIdx !highIdx+                      | i >= end = (lowIdx, highIdx)+                      | otherwise =+                          let !y = snd (pts V.! i)+                              !lo' = if y < snd (pts V.! lowIdx) then i else lowIdx+                              !hi' = if y > snd (pts V.! highIdx) then i else highIdx+                           in extrema (i + 1) lo' hi'+                    (!lo, !hi) = extrema (start + 1) start start+                MV.write out written (pts V.! min lo hi)+                if lo == hi+                  then chunks end (written + 1)+                  else do+                    MV.write out (written + 1) (pts V.! max lo hi)+                    chunks end (written + 2)+      chunks 0 0+  where+    !len = V.length pts+    bucket = (len - 1) `div` k + 1+    numChunks = (len - 1) `div` bucket + 1
+ lib/NanoUI/Plot/Hit.hs view
@@ -0,0 +1,64 @@+-- | Finding the data point nearest the pointer on a drawn chart.+module NanoUI.Plot.Hit+  ( hitTestChartCached+  , nearestPlotHover+  ) where++import NanoUI (Rect (..), V2, rectContains, v2X, v2Y)+import NanoUI.Diagrams.Backend (letterbox)+import NanoUI.Diagrams.Widget (frameInner)+import NanoUI.Plot.Scale (plotToDomain)+import NanoUI.Plot.Types (Domain, PlotHover (..))+import qualified Data.Vector.Unboxed as U++-- | The hover target under the pointer, for a diagram of envelope @dw@ by+-- @dh@ with extents @(x0, x1)@ and @(y0, y1)@ drawn framed in @widgetRect@.+hitTestChartCached ::+  Double ->+  Double ->+  (Double, Double) ->+  (Double, Double) ->+  (Domain, Domain) ->+  [U.Vector (Double, Double)] ->+  Rect ->+  V2 ->+  Maybe PlotHover+hitTestChartCached dw dh (x0, x1) (y0, y1) domains points widgetRect mouse+  | not (rectContains inner mouse) || w <= 0 || h <= 0 = Nothing+  | lx < offX || ly < offY || lx > offX + outW || ly > offY + outH = Nothing+  | otherwise =+      nearestPlotHover+        domains+        points+        (x0 + (lx - offX) / outW * (x1 - x0))+        (y1 - (ly - offY) / outH * (y1 - y0))+ where+  inner = frameInner widgetRect+  w = realToFrac (rectW inner)+  h = realToFrac (rectH inner)+  (outW, outH, offX, offY) = letterbox dw dh w h+  lx = realToFrac (v2X mouse - rectX inner)+  ly = realToFrac (v2Y mouse - rectY inner)++-- | The data point nearest a position in the unit plot box, given the chart's+-- domains and each series' drawn points.+nearestPlotHover :: (Domain, Domain) -> [U.Vector (Double, Double)] -> Double -> Double -> Maybe PlotHover+nearestPlotHover (xDom, yDom) points gx gy+  | gx < 0 || gx > 1 || gy < 0 || gy > 1 = Nothing+  | otherwise = snd <$> scanSeries 0 Nothing points+ where+  dataX = plotToDomain xDom gx+  dataY = plotToDomain yDom gy+  -- The best hover carries its squared distance, so each point computes only+  -- its own and a point that loses allocates nothing.+  scanSeries !_ !best [] = best+  scanSeries !si !best (pts : rest) =+    let pick current !ptIdx (!x, !y) =+          let !dx = x - dataX+              !dy = y - dataY+              !d = dx * dx + dy * dy+           in case current of+                Just (bestD, _) | bestD <= d -> current+                _ -> Just (d, PlotHover x y si ptIdx)+        !best' = U.ifoldl' pick best pts+     in scanSeries (si + 1) best' rest
+ lib/NanoUI/Plot/Scale.hs view
@@ -0,0 +1,118 @@+-- | Mapping between data domains and plot coordinates, tick placement and+-- labels, and domain extents.+module NanoUI.Plot.Scale+  ( domainToPlot+  , plotToDomain+  , niceTicks+  , formatTick+  , domainExtent+  , domainExtentBy+  , mergeDomains+  , padDomain+  ) where++import Data.Text (Text)+import Data.Text qualified as T+import NanoUI.Plot.Types (Domain (..))+import qualified Data.Text.Lazy as TL+import qualified Data.Text.Lazy.Builder as TB+import qualified Data.Text.Lazy.Builder.Int as TB+import qualified Data.Text.Lazy.Builder.RealFloat as TB+import qualified Data.Vector.Generic as GV++-- | Map a domain value into the unit plot box: @lo@ goes to 0, @hi@ to 1.+domainToPlot :: Domain -> Double -> Double+domainToPlot (Domain lo hi) v = (v - lo) / max 1e-9 (hi - lo)++-- | Inverse of 'domainToPlot'.+plotToDomain :: Domain -> Double -> Double+plotToDomain (Domain lo hi) v = lo + v * max 1e-9 (hi - lo)++-- Works with Data.Vector, Data.Vector.Unboxed, or Data.Vector.Storable+domainExtent :: (GV.Vector v Double) => v Double -> Domain+domainExtent = domainExtentBy id+{-# INLINE domainExtent #-}++-- | Project while reducing, without materialising a mapped numeric vector.+domainExtentBy :: GV.Vector v a => (a -> Double) -> v a -> Domain+domainExtentBy project xs+  | lo > hi = Domain 0 1+  | lo == hi = Domain (lo - 0.5) (hi + 0.5)+  | otherwise = extent+  where+    extent@(Domain lo hi) =+      GV.foldl' (\(Domain mn mx) value -> let !x = project value in Domain (min mn x) (max mx x)) (Domain (1 / 0) (-1 / 0)) xs+{-# INLINE domainExtentBy #-}++mergeDomains :: Domain -> Domain -> Domain+mergeDomains (Domain a b) (Domain c d) = Domain (min a c) (max b d)++padDomain :: Double -> Domain -> Domain+padDomain frac (Domain lo hi) =+  let dSpan = max 1e-9 (hi - lo)+      pad = dSpan * frac+   in Domain (lo - pad) (hi + pad)++finite :: Double -> Bool+finite x = not (isNaN x || isInfinite x)++-- Heckbert-style nice tick step.+niceStep :: Double -> Double+niceStep raw =+  let exp10 = floor (logBase 10 raw) :: Int+      f = raw / (10 ** fromIntegral exp10)+      nf+        | f <= 1 = 1+        | f <= 2 = 2+        | f <= 5 = 5+        | otherwise = 10+   in nf * (10 ** fromIntegral exp10)++niceTicks :: Int -> Domain -> [Double]+niceTicks maxTicks (Domain lo hi)+  | maxTicks <= 0 || not (finite lo && finite hi) || hi < lo = []+  | lo == hi = [lo]+  | not (finite dSpan) = []+  | not (finite step) || step <= 0 || not (finite (lo / step)) = []+  | otherwise = go start 0 []+  where+    dSpan = max 1e-9 (hi - lo)+    step = niceStep (dSpan / fromIntegral (max 2 maxTicks))+    start = fromIntegral (ceiling (lo / step - 1e-9) :: Integer) * step+    go !v !count acc+      | not (finite v) || v > hi + step * 0.001 = reverse acc+      | otherwise =+          let !accepted = v >= lo - step * 0.001+              acc' = if accepted then v : acc else acc+              !next = v + step+           in if count >= maxTicks || next <= v+                then reverse acc'+                else go next (count + 1) acc'++formatTick :: Double -> Text+formatTick v+  | not (finite v) = T.empty+  | otherwise =+      let snapped = snapNoise v+       in if abs snapped >= 1e6 || (abs snapped > 0 && abs snapped < 1e-6)+            then render (TB.formatRealFloat TB.Exponent (Just 3) snapped)+            else+              let n = round snapped :: Integer+               in if abs (snapped - fromIntegral n) < 1e-6+                    then render (TB.decimal n)+                    else stripZeros (render (TB.formatRealFloat TB.Fixed (Just 6) snapped))+  where+    render = TL.toStrict . TB.toLazyText++snapNoise :: Double -> Double+snapNoise v =+  let s = 1e10+      scaled = v * s+   in if finite scaled then fromIntegral (round scaled :: Integer) / s else v++stripZeros :: Text -> Text+stripZeros s =+  let t = T.dropWhileEnd (== '0') s+   in case T.unsnoc t of+        Just (rest, '.') -> rest+        _ -> t
+ lib/NanoUI/Plot/Series.hs view
@@ -0,0 +1,101 @@+-- | Series constructors (line, scatter, bar, area, step) and their style+-- modifiers.+module NanoUI.Plot.Series+  ( line+  , scatter+  , bar+  , area+  , step+  , withColor+  , withStrokeWidth+  , withMarker+  , withBaseline+  , lineVec+  , scatterVec+  , areaVec+  , stepVec+  , barVec+  ) where++import Data.Text (Text)+import Data.Foldable (toList)+import Data.Primitive.PrimArray (generatePrimArray)+import Data.Primitive.SmallArray (createSmallArray, writeSmallArray)+import Data.Vector (Vector)+import Data.Vector qualified as V+import Data.Vector.Generic qualified as G+import Data.Vector.Unboxed qualified as U+import NanoUI (Color)+import NanoUI.Plot.Types+  ( MarkShape (..)+  , Series (..)+  , SeriesData (..)+  , SeriesKind (..)+  )++line :: Foldable f => Text -> f (Double, Double) -> Series+line name = lineVec name . U.fromList . toList++scatter :: Foldable f => Text -> f (Double, Double) -> Series+scatter name = scatterVec name . U.fromList . toList++bar :: Foldable f => Text -> f (Text, Double) -> Series+bar name = barVec name . V.fromList . toList++area :: Foldable f => Text -> f (Double, Double) -> Series+area name = areaVec name . U.fromList . toList++step :: Foldable f => Text -> f (Double, Double) -> Series+step name = stepVec name . U.fromList . toList++withColor :: Color -> Series -> Series+withColor c s = s {seriesColor = Just c}++withStrokeWidth :: Float -> Series -> Series+withStrokeWidth w s =+  case seriesKind s of+    LineSeries _ m -> s {seriesKind = LineSeries w m}+    ScatterSeries _ mk -> s {seriesKind = ScatterSeries w mk}+    BarSeries _ -> s {seriesKind = BarSeries w}+    StepSeries _ -> s {seriesKind = StepSeries w}+    _ -> s++withMarker :: MarkShape -> Series -> Series+withMarker mk s =+  case seriesKind s of+    LineSeries w _ -> s {seriesKind = LineSeries w (Just mk)}+    ScatterSeries w _ -> s {seriesKind = ScatterSeries w mk}+    _ -> s++withBaseline :: Double -> Series -> Series+withBaseline b s =+  case seriesKind s of+    AreaSeries _ -> s {seriesKind = AreaSeries b}+    _ -> s++-- | Numeric series retain unboxed coordinates. Unboxed inputs are shared;+-- boxed/storable inputs are converted once at the construction boundary.+{-# INLINE lineVec #-}+lineVec :: G.Vector v (Double, Double) => Text -> v (Double, Double) -> Series+lineVec name pts = Series name Nothing (LineSeries 1.5 Nothing) (PointsXY (G.convert pts))++{-# INLINE scatterVec #-}+scatterVec :: G.Vector v (Double, Double) => Text -> v (Double, Double) -> Series+scatterVec name pts = Series name Nothing (ScatterSeries 3 MarkCircle) (PointsXY (G.convert pts))++{-# INLINE areaVec #-}+areaVec :: G.Vector v (Double, Double) => Text -> v (Double, Double) -> Series+areaVec name pts = Series name Nothing (AreaSeries 0) (PointsXY (G.convert pts))++{-# INLINE stepVec #-}+stepVec :: G.Vector v (Double, Double) => Text -> v (Double, Double) -> Series+stepVec name pts = Series name Nothing (StepSeries 1.5) (PointsXY (G.convert pts))++barVec :: Text -> Vector (Text, Double) -> Series+barVec name rows =+  Series name Nothing (BarSeries 0.72) $+    CategoryY+      (createSmallArray n "" (\out -> V.imapM_ (\i (t, _) -> writeSmallArray out i t) rows))+      (generatePrimArray n (snd . V.unsafeIndex rows))+  where+    n = V.length rows
+ lib/NanoUI/Plot/Types.hs view
@@ -0,0 +1,91 @@+-- | Chart, series, domain and hover types.+module NanoUI.Plot.Types+  ( Domain (..)+  , SeriesData (..)+  , MarkShape (..)+  , SeriesKind (..)+  , Series (..)+  , LegendPos (..)+  , GridMode (..)+  , Chart (..)+  , PlotHover (..)+  , PlotResponse (..)+  , emptyChart+  ) where++import Data.Primitive.PrimArray (PrimArray)+import Data.Primitive.SmallArray (SmallArray)+import Data.Text (Text)+import Data.Vector.Unboxed qualified as U+import NanoUI (Color, Response)++data Domain = Domain !Double !Double+  deriving (Eq, Show)++data SeriesData+  = PointsXY !(U.Vector (Double, Double))+  | -- | Bar labels and their values, in order.+    CategoryY !(SmallArray Text) !(PrimArray Double)+  deriving (Eq, Show)++data MarkShape = MarkCircle | MarkSquare | MarkDiamond | MarkTriangle | MarkCross+  deriving (Eq, Show)++data SeriesKind+  = LineSeries !Float (Maybe MarkShape)+  | ScatterSeries !Float !MarkShape+  | BarSeries !Float+  | AreaSeries !Double+  | StepSeries !Float+  deriving (Eq, Show)++data Series = Series+  { seriesName :: !Text+  , seriesColor :: !(Maybe Color)+  , seriesKind :: !SeriesKind+  , seriesData :: !SeriesData+  }+  deriving (Eq, Show)++data LegendPos = LegendRight | LegendBottom | LegendTop | LegendInside | LegendNone+  deriving (Eq, Show)++data GridMode = GridBoth | GridHorizontal | GridVertical | GridNone+  deriving (Eq, Show)++data Chart = Chart+  { chartTitle :: !(Maybe Text)+  , chartXTitle :: !(Maybe Text)+  , chartYTitle :: !(Maybe Text)+  , chartSeries :: ![Series]+  , chartLegend :: !LegendPos+  , chartGrid :: !GridMode+  , chartDecimate :: !Bool+  }+  deriving (Eq, Show)++data PlotHover = PlotHover+  { hoverDataX :: !Double+  , hoverDataY :: !Double+  , hoverSeriesIdx :: !Int+  , hoverPointIdx :: !Int+  }+  deriving (Eq, Show)++data PlotResponse = PlotResponse+  { plotResponse :: !Response+  , plotHover :: !(Maybe PlotHover)+  }+  deriving (Eq, Show)++emptyChart :: Chart+emptyChart =+  Chart+    { chartTitle = Nothing+    , chartXTitle = Nothing+    , chartYTitle = Nothing+    , chartSeries = []+    , chartLegend = LegendRight+    , chartGrid = GridBoth+    , chartDecimate = True+    }
+ lib/NanoUI/Plot/Widget.hs view
@@ -0,0 +1,120 @@+-- | Chart widgets: 'plot' draws a chart and reports the hovered point, and+-- 'lineChart', 'barChart', 'scatterChart' and 'areaChart' draw one series.+module NanoUI.Plot.Widget+  ( plot+  , lineChart+  , barChart+  , scatterChart+  , areaChart+  ) where++import Data.Dynamic (fromDynamic, toDyn)+import Data.IORef (readIORef)+import qualified Data.IntMap.Strict as IM+import Data.Maybe (fromMaybe, catMaybes)+import Data.Text (Text)+import Data.Vector.Unboxed qualified as U+import Diagrams.Prelude (Diagram, V2 (..), extentX, extentY, size)+import Effectful (Eff, type (:>))+import NanoUI+  ( FontMetrics+  , Layout+  , Theme+  , Ui+  , WidgetId+  , uiFontMetrics+  , uiMousePos+  , uiTheme+  , prepareFontMetricsMany+  , respRect+  )+import NanoUI.Context (Context (..), WidgetStore (..), getStore, intKey, setStore)+import NanoUI.Monad (askContext, nextId, uiIO)+import NanoUI.Diagrams.Backend (B)+import NanoUI.Diagrams.Widget (PlotStyle, diagramWithKeyAndEnvelope, uiPlotStyle)+import NanoUI.Plot.Builder qualified as Builder+import NanoUI.Plot.Chrome (chartDiagram, seriesDomains, seriesPoints)+import NanoUI.Plot.Scale (formatTick, niceTicks)+import NanoUI.Plot.Hit (hitTestChartCached)+import NanoUI.Plot.Series (area, bar, line, scatter)+import NanoUI.Plot.Types+  ( Chart (..)+  , Domain+  , Series (..)+  , LegendPos (..)+  , PlotResponse (..)+  )++data CachedChart = CachedChart+  { ccChart :: !Chart+  , ccTheme :: !Theme+  , ccFont :: {-# UNPACK #-} !Int+  , ccStyle :: !PlotStyle+  , ccVersion :: {-# UNPACK #-} !Int+  , ccDiagram :: !(Diagram B)+  , ccWidth :: {-# UNPACK #-} !Double+  , ccHeight :: {-# UNPACK #-} !Double+  , ccExtX :: !(Double, Double)+  , ccExtY :: !(Double, Double)+  , ccDomains :: !(Domain, Domain)+  , ccPoints :: ![U.Vector (Double, Double)]+  }++-- Keep the cache in the owning context's widget store. Versions only need+-- to distinguish successive contents of this widget's draw-op cache.+cachedChartDiagram :: Context -> WidgetId -> FontMetrics -> Theme -> PlotStyle -> Chart -> IO CachedChart+cachedChartDiagram ctx wid fm theme ps chart = do+  let k = intKey wid+  font <- readIORef (ctxMetricGen ctx)+  store <- getStore ctx+  let previous = IM.lookup k (storeDyn store) >>= fromDynamic+  case previous of+    Just cc | ccChart cc == chart && ccTheme cc == theme && ccFont cc == font && ccStyle cc == ps -> pure cc+    _ -> do+      let domains@(xDom, yDom) = seriesDomains chart+          points = map (seriesPoints chart) (chartSeries chart)+          labels = catMaybes [chartTitle chart, chartXTitle chart, chartYTitle chart]+            ++ map seriesName (chartSeries chart)+            ++ map formatTick (niceTicks 6 xDom ++ niceTicks 6 yDom)+      prepared <- prepareFontMetricsMany fm labels+      let !d = chartDiagram prepared theme ps domains points chart+          !(V2 dw dh) = size d+          extX = fromMaybe (0, dw) (extentX d)+          extY = fromMaybe (0, dh) (extentY d)+      let !v = maybe 1 ((+ 1) . ccVersion) previous+          !cc = CachedChart chart theme font ps v d dw dh extX extY domains points+      setStore ctx (store {storeDyn = IM.insert k (toDyn cc) (storeDyn store)})+      pure cc++-- | Draw a chart sized by the layout modifier. The response reports the+-- nearest data point under the pointer.+plot :: Ui :> es => (Layout -> Layout) -> Chart -> Eff es PlotResponse+plot f chart = do+  wid <- nextId+  ctx <- askContext+  fm <- uiFontMetrics+  theme <- uiTheme+  ps <- uiPlotStyle+  cc <- uiIO (cachedChartDiagram ctx wid fm theme ps chart)+  resp <- diagramWithKeyAndEnvelope (ccVersion cc) (ccWidth cc) (ccHeight cc) f (ccDiagram cc)+  mouse <- uiMousePos+  let hover = hitTestChartCached (ccWidth cc) (ccHeight cc) (ccExtX cc) (ccExtY cc) (ccDomains cc) (ccPoints cc) (respRect resp) mouse+  pure PlotResponse {plotResponse = resp, plotHover = hover}++-- | One line series with a grid and no legend.+lineChart :: Ui :> es => (Layout -> Layout) -> [(Double, Double)] -> Eff es PlotResponse+lineChart f pts = plot f (singleSeries True (line "series" pts))++barChart :: Ui :> es => (Layout -> Layout) -> [(Text, Double)] -> Eff es PlotResponse+barChart f pts = plot f (singleSeries False (bar "series" pts))++scatterChart :: Ui :> es => (Layout -> Layout) -> [(Double, Double)] -> Eff es PlotResponse+scatterChart f pts = plot f (singleSeries False (scatter "series" pts))++areaChart :: Ui :> es => (Layout -> Layout) -> [(Double, Double)] -> Eff es PlotResponse+areaChart f pts = plot f (singleSeries True (area "series" pts))++-- | A gridded chart of one series without a legend, optionally decimated.+singleSeries :: Bool -> Series -> Chart+singleSeries decimate s =+  Builder.withDecimate decimate (Builder.withLegend LegendNone (Builder.chart [s]))
+ nano-ui-diagrams.cabal view
@@ -0,0 +1,95 @@+cabal-version:      3.4+name:               nano-ui-diagrams+version:            0.1.0.0+synopsis:           Charts and diagrams-lib drawings for nano-ui+description:+    Places diagrams-lib drawings in nano-ui layouts and builds line, bar,+    scatter, area, and step charts with axes, legends, and hover lookup.+license:            MIT+license-file:       LICENSE+author:             goolord+maintainer:         zacharyachurchill@gmail.com+category:           Graphics+homepage:           https://github.com/goolord/nano-ui+bug-reports:        https://github.com/goolord/nano-ui/issues+build-type:         Simple+tested-with:        GHC ==9.10.3 || ==9.14.1+extra-doc-files:+    CHANGELOG.md+    README.md++source-repository head+    type:     git+    location: https://github.com/goolord/nano-ui.git+    subdir:   packages/nano-ui-diagrams++common extensions+    default-language: GHC2024+    default-extensions:+        DuplicateRecordFields+        OverloadedStrings+        TypeFamilies++common warnings+  ghc-options:+    -Wall+    -Wextra+    -Wcompat+    -Widentities+    -Wincomplete-record-updates+    -Wincomplete-uni-patterns+    -Wmissing-export-lists+    -Wmissing-home-modules+    -Wpartial-fields+    -Wredundant-constraints+    -Wunused-packages++library+    import:           extensions+    import:           warnings+    exposed-modules:+        NanoUI.Diagrams+        NanoUI.Diagrams.Backend+        NanoUI.Diagrams.Tessellation+        NanoUI.Diagrams.Widget+        NanoUI.Plot+        NanoUI.Plot.Types+        NanoUI.Plot.Scale+        NanoUI.Plot.Decimate+        NanoUI.Plot.Series+        NanoUI.Plot.Chrome+        NanoUI.Plot.Builder+        NanoUI.Plot.Hit+        NanoUI.Plot.Widget+    build-depends:+        base >=4.20 && <4.23,+        colour >=2.3.3 && <2.4,+        containers >=0.6.7 && <0.9,+        diagrams-core >=1.5 && <1.6,+        diagrams-lib >=1.4.6 && <1.7,+        dlist >=1.0 && <1.1,+        effectful-core >=2.5 && <2.8,+        hashable >=1.4 && <1.6,+        lens >=5.0 && <5.4,+        nano-ui ^>=0.1,+        primitive >=0.8 && <0.10,+        text >=2.0 && <2.2,+        vector >=0.13 && <0.14+    hs-source-dirs:   lib++test-suite nano-ui-diagrams-test+    import:           extensions+    import:           warnings+    type:             exitcode-stdio-1.0+    main-is:          Main.hs+    build-depends:+        base >=4.20 && <4.23,+        colour >=2.3.3 && <2.4,+        diagrams-lib >=1.4.6 && <1.7,+        hspec >=2.10 && <2.12,+        nano-ui,+        nano-ui-diagrams,+        primitive >=0.8 && <0.10,+        text >=2.0 && <2.2,+        vector >=0.13 && <0.14+    hs-source-dirs:   test
+ test/Main.hs view
@@ -0,0 +1,505 @@+module Main (main) where++import Control.Monad (forM_, unless)+import Data.Colour.Names (coral, steelblue)+import Data.Foldable (toList)+import Data.IORef (readIORef)+import Data.List (tails)+import Data.Maybe (fromMaybe, listToMaybe)+import Data.Text qualified as T+import Data.Primitive.SmallArray (SmallArray, emptySmallArray)+import Data.Vector qualified as V+import Diagrams.Prelude+  ( Diagram+  , circle+  , fc+  , lw+  , lwO+  , none+  , (#)+  )+import NanoUI+import NanoUI.Context (Context (..), DrawingCacheState (..), withFontMetrics)+import NanoUI.Context.Types (DrawOpCacheEntry (..))+import NanoUI.Diagrams+  ( B+  , defaultPlotStyle+  , diagram+  , diagramOps+  , fitLayout+  )+import NanoUI.Diagrams.Backend (diagramTextOps)+import NanoUI.Diagrams.Tessellation+  ( strokePolyline+  , triangulatePolygon+  )+import NanoUI.Plot.Chrome+  ( Margins (..)+  , chartDiagram+  , chartMargins+  , seriesDomains+  , seriesPoints+  )+import NanoUI.Plot.Decimate (lttb, minMaxDecimate)+import NanoUI.Plot.Hit (nearestPlotHover)+import NanoUI.Plot.Scale (formatTick, mergeDomains, niceTicks)+import NanoUI.Plot.Series+  ( area+  , bar+  , line+  , scatter+  , withColor+  , withMarker+  )+import NanoUI.Plot.Types+  ( Chart (..)+  , Domain (..)+  , GridMode (..)+  , LegendPos (..)+  , MarkShape (..)+  , PlotHover (..)+  , Series (..)+  )+import NanoUI.Plot.Widget qualified as Plot+import NanoUI.Testing (DrawData (..), drawCmdNull, newPixelContext, runFrame)+import Test.Hspec (describe, hspec, it)++main :: IO ()+main = hspec $ do+  let+    fm = monospaceMetrics 16+  describe "rendering" $ do+    it "draws and redraws a filled diagram" $ do+      ctx <- newPixelContext+      testRendering ctx (emptyInput {inputWindowSize = Size 240 120})+    it "renders chart labels apart from geometry" (testTextOnlyRendering fm)+    it "reuses cached chart content within one context" testChartCache+  describe "tessellation" $ do+    it "triangulates indexed polygons with full coverage" testIndexedTriangulation+    it "covers polyline strokes end to end" testStrokeCoversMidpoint+  describe "scales and domains" $ do+    it "picks and formats nice ticks" testNiceTicks+    it "shares bounds across series" testMultiSeriesDomains+  describe "decimation" $ do+    it "keeps LTTB extrema and endpoints" testLttb+    it "keeps min/max extrema" testMinMaxDecimate+  describe "chart chrome" $ do+    it "keeps labels, titles and legends apart" (testLabelFit fm)+    it "colors legend entries like their series" (testLegendColors fm)+    it "picks the nearest hover point" testPlotHover+    it "fills closed series and markers" (testClosedSeriesFills fm)+    it "caps the height of growing plots" (testGrowPlotHeight fm)++-- | A chart of the given series with no legend, grid or decimation.+bareChart :: [Series] -> Chart+bareChart ss =+  Chart+    { chartTitle = Nothing+    , chartXTitle = Nothing+    , chartYTitle = Nothing+    , chartSeries = ss+    , chartLegend = LegendNone+    , chartGrid = GridNone+    , chartDecimate = False+    }++chartDia :: FontMetrics -> Chart -> Diagram B+chartDia fm c = chartDiagram fm defaultTheme defaultPlotStyle (seriesDomains c) (map (seriesPoints c) (chartSeries c)) c++rectsOverlap :: Rect -> Rect -> Bool+rectsOverlap (Rect x1 y1 w1 h1) (Rect x2 y2 w2 h2) =+  x1 < x2 + w2 && x2 < x1 + w1 && y1 < y2 + h2 && y2 < y1 + h1++testTextOnlyRendering :: FontMetrics -> IO ()+testTextOnlyRendering fm = do+  let+    d = linePlotDiag fm [(0, 0), (1, 2), (2, 1)]+    isText DrawText {} = True+    isText _ = False+  forM_ [(400, 240), (120, 400), (0, 100), (100, -1)] $ \(w, h) -> do+    let+      full = diagramOps w h d+      labels = diagramTextOps w h d+    unless (toList labels == filter isText (toList full)) $+      fail "text-only rendering differs from the full render's text"+    unless+      (w <= 0 || h <= 0 || (not (null labels) && length labels < length full)) $+      fail "text-only rendering did not separate chart labels from geometry"++testChartCache :: IO ()+testChartCache = do+  base <- newPixelContext+  other <- newPixelContext+  let+    inp = emptyInput {inputWindowSize = Size 400 240}+    fm = monospaceMetrics 16+    larger = monospaceMetrics 24+    ctx = withFontMetrics base fm+    render c = do+      _ <-+        runFrame c inp $ Plot.lineChart (fixedWH 360 200) [(0, 0), (1, 1)]+      cache <- readIORef (ctxDrawingCache c)+      pure (map doeContent (toList (dcsDrawOpCache cache)))+  first <- render ctx+  again <- render ctx+  unless (not (null first) && first == again) $+    fail "chart cache did not reuse unchanged content"+  changed <- render (withFontMetrics base larger)+  unless (changed /= first) $+    fail "chart cache ignored changed font metrics"+  independent <- render (withFontMetrics other larger)+  unless (independent == first) $+    fail "chart cache version leaked across contexts"++testRendering :: Context -> Input -> IO ()+testRendering ctx inp = do+  let+    ok d = drawIndexCount d > 0 && not (drawCmdNull d)+  (_, _, filled, _) <-+    runFrame ctx inp $+      diagram (fixedWH 200 80) (circle 1 # fc coral # lw none)+  unless (ok filled) $+    fail "diagram produced no draw commands"+  (_, _, filledAgain, _) <-+    runFrame ctx inp $+      diagram (fixedWH 200 80) (circle 1 # fc coral # lw none)+  unless (ok filledAgain) $+    fail "cached diagram produced no draw commands"++linePlotDiag :: FontMetrics -> [(Double, Double)] -> Diagram B+linePlotDiag fm pts =+  chartDia fm (bareChart [line "s" pts])+    # lwO 2+    # fc steelblue++triArea :: (Float, Float) -> (Float, Float) -> (Float, Float) -> Float+triArea (x0, y0) (x1, y1) (x2, y2) =+  abs ((x0 - x2) * (y1 - y0) - (x0 - x1) * (y2 - y0)) * 0.5++testIndexedTriangulation :: IO ()+testIndexedTriangulation = do+  forM_ [[], [(0, 0)], [(0, 0), (1, 1)], [(0, 0), (1, 1), (0, 0)]] $ \pts ->+    unless (null (triangulatePolygon pts)) $+      fail "undersized polygon emitted triangles"+  -- Alternating radii exercise repeated ear removal and wraparound indices.+  forM_ [3, 16, 127, 256 :: Int] $ \n -> do+    let+      points =+        [ let+            angle = 2 * pi * fromIntegral i / fromIntegral n+            radius = if even i then 10 else 6+           in+            (radius * cos angle, radius * sin angle)+        | i <- [0 .. n - 1]+        ]+      polygonArea =+        abs+          (sum [x * y' - x' * y | ((x, y), (x', y')) <- zip points (drop 1 (cycle points))])+          / 2+    forM_ [points, reverse points, points ++ take 1 points] $ \pts -> do+      let+        triangles = triangulatePolygon pts+        areaSum = sum [triArea a b c | (a, b, c) <- triangles]+      unless (length triangles == n - 2 && abs (areaSum - polygonArea) < 0.01) $+        fail+          ( "indexed triangulation changed polygon coverage: "+              ++ show (n, length triangles, areaSum, polygonArea)+          )++testStrokeCoversMidpoint :: IO ()+testStrokeCoversMidpoint = do+  let+    col = themeRed defaultTheme+    ops = strokePolyline col 2 False [(0, 0), (20, 0), (20, 20)]+    tris =+      [ ((x0, y0), (x1, y1), (x2, y2))+      | FillTriangle x0 y0 x1 y1 x2 y2 _ <- ops+      ]+    covered p = any (inTri p) tris+  unless (covered (10, 0) && covered (1, 0) && covered (20, 10)) $+    fail "stroke polyline left a gap along the segment"++inTri ::+  (Float, Float) -> ((Float, Float), (Float, Float), (Float, Float)) -> Bool+inTri p (a, b, c) =+  let+    s = triArea a b c+    s' = triArea p b c + triArea a p c + triArea a b p+   in+    s > 1e-6 && abs (s' - s) <= 1e-3++testNiceTicks :: IO ()+testNiceTicks = do+  let+    t0 = niceTicks 6 (Domain 0 100)+    t1 = niceTicks 6 (Domain (-5) 5)+  unless+    ( maybe False (<= 0) (listToMaybe t0)+        && maybe False (>= 100) (listToMaybe (reverse t0))+    ) $+    fail "nice ticks failed for [0,100]"+  unless (any (== 0) t1) $+    fail "nice ticks failed for [-5,5]"+  unless (formatTick 6 == "6") $+    fail "formatTick integer"+  unless (formatTick 0.2 == "0.2" && formatTick 0.4 == "0.4") $+    fail "formatTick fractional"+  unless (formatTick (0.2 + 0.2 + 0.2) == "0.6") $+    fail "formatTick binary residue"+  unless (formatTick 0.0008 == "0.0008") $+    fail "formatTick small decimal"+  unless (formatTick 1e308 == "1.000e308" && formatTick (-1e308) == "-1.000e308") $+    fail "formatTick overflowed while snapping a finite value"+  unless+    ( niceTicks 6 (Domain (-1e308) 1e308) == []+        && niceTicks 6 (Domain 1e308 1e308) == [1e308]+        && niceTicks 0 (Domain 0 100) == []+    )+    $ fail "niceTicks failed on overflowing, singleton, or empty-budget domains"++testMultiSeriesDomains :: IO ()+testMultiSeriesDomains = do+  let+    s1 = line "a" (V.fromList [(0, 0), (1, 1)])+    s2 = line "b" [(0, 10), (1, 20)]+    (Domain xLo xHi, Domain yLo yHi) = seriesDomains (bareChart [s1, s2])+  unless (yLo <= 0 && yHi >= 20 && xLo <= 0 && xHi >= 1) $+    fail "multi-series domains do not share bounds"+  unless (mergeDomains (Domain 0 1) (Domain 0 10) == Domain 0 10) $+    fail "mergeDomains broken"+  let+    (Domain fitXLo _, Domain fitYLo _) = seriesDomains (bareChart [scatter "s" [(4, 3), (9, 8)]])+  unless (fitXLo > 2 && fitYLo > 1) $+    fail "seriesDomains seeded with 0..1"++testLttb :: IO ()+testLttb = do+  let+    pts =+      V.fromList+        [(fromIntegral i, sin (fromIntegral i / 10)) | i <- [0 .. 9999 :: Int]]+    out = lttb 500 pts+  unless (V.length out == 500) $+    fail "LTTB did not downsample to target count"+  let+    ys = toList (V.map snd out)+  unless (minimum ys < -0.5 && maximum ys > 0.5) $+    fail "LTTB lost waveform extrema"+  let+    spike = V.fromList [(x, if x == 1 then 10 else 0) | x <- [0 .. 9]]+  unless (lttb 3 spike == V.fromList [(0, 0), (1, 10), (9, 0)]) $+    fail "LTTB skipped the first bucket's spike"+  forM_ [0 .. 60] $ \n -> forM_ [-1 .. n + 1] $ \k -> do+    let+      input = V.generate n (\i -> (fromIntegral i, sin (fromIntegral i)))+      sampled = lttb k input+    unless (V.length sampled == min n (max 0 k) && orderedPoints sampled) $+      fail "LTTB violated its point budget or input order"+    unless (V.null sampled || V.head sampled == V.head input) $+      fail "LTTB lost the first endpoint"+    unless (V.length sampled < 2 || V.last sampled == V.last input) $+      fail "LTTB lost the last endpoint"++testMinMaxDecimate :: IO ()+testMinMaxDecimate = do+  unless+    (minMaxDecimate 1 (V.fromList [(0, 2), (1, 2), (2, 2)]) == V.singleton (0, 2)) $+    fail "min/max decimation changed equal-extrema tie handling"+  let+    descending = V.fromList [(x, 9 - x) | x <- [0 .. 8]]+  unless+    (minMaxDecimate 2 descending == V.fromList [(0, 9), (4, 5), (5, 4), (8, 1)]) $+    fail "min/max decimation lost extrema or reversed their order"+  forM_ [0 .. 60] $ \n -> forM_ [-1 .. n + 1] $ \k -> do+    let+      input = V.generate n (\i -> (fromIntegral i, sin (fromIntegral i)))+      sampled = minMaxDecimate k input+    unless (V.length sampled <= max 0 (2 * k) && orderedPoints sampled) $+      fail "min/max decimation violated its bucket budget or input order"+    unless (V.all (`V.elem` input) sampled) $+      fail "min/max decimation invented a point"+    unless+      ( V.null sampled+          || ( V.minimum (V.map snd input) == V.minimum (V.map snd sampled)+                 && V.maximum (V.map snd input) == V.maximum (V.map snd sampled)+             )+      )+      $ fail "min/max decimation lost a global extremum"++orderedPoints :: V.Vector (Double, Double) -> Bool+orderedPoints points = V.and (V.zipWith (\a b -> fst a < fst b) points (V.drop 1 points))++testLabelFit :: FontMetrics -> IO ()+testLabelFit fm = do+  let+    dump = chartDia fm barChartSample+    fitted = fitLayout fm (fixedH 180 defaultLayout) dump+    ops =+      case (layoutWidth fitted, layoutHeight fitted) of+        (Fixed bw, Fixed bh) -> diagramOps (realToFrac bw) (realToFrac bh) dump+        _ -> emptySmallArray+    texts = [(x, y, ax, ay, t) | DrawText x y ax ay t _ <- toList ops]+    xs = [x | (x, _, _, _, _) <- texts]+    boxes = [drawTextBox fm x y ax ay t | (x, y, ax, ay, t) <- texts]+  unless (length xs >= 3 && maximum xs - minimum xs > 20) $+    fail "axis labels did not spread along x"+  unless (not (or [rectsOverlap a b | (a : rest) <- tails boxes, b <- rest])) $+    fail "axis label boxes overlap"+  let+    sleepChart =+      (bareChart [scatter "focus" [(4, 3), (9, 8)], line "trend" [(4, 3), (9, 8)]])+        { chartLegend = LegendRight+        , chartYTitle = Just "focus"+        , chartXTitle = Just "hours slept"+        }+    legendDump = chartDia fm sleepChart+    legendOps = diagramOps 400 240 legendDump+    tightOps = diagramOps 220 150 legendDump+    barTightOps = diagramOps 220 150 dump+    botChart = sleepChart {chartLegend = LegendBottom}+    botOps = diagramOps 400 240 (chartDia fm botChart)+    tickText t =+      T.all (\c -> c == '-' || c == '.' || c >= '0' && c <= '9') t && not (T.null t)+    overlapTitleTick chart w h drawOps =+      let+        ts =+          [(drawTextBox fm x y ax ay t, t) | DrawText x y ax ay t _ <- toList drawOps]+        titles =+          [ b+          | (b@(Rect bx by _ _), t) <- ts+          , (chartXTitle chart == Just t && by < h * 0.45)+              || (chartYTitle chart == Just t && bx < w * 0.4)+          ]+        ticks = [b | (b, t) <- ts, tickText t]+       in+        or [rectsOverlap a b | a <- titles, b <- ticks]+    overlapLegendTick chart w h drawOps =+      let+        names = map seriesName (chartSeries chart)+        ts =+          [(drawTextBox fm x y ax ay t, t) | DrawText x y ax ay t _ <- toList drawOps]+        legends =+          [ b+          | (b@(Rect bx by _ _), t) <- ts+          , t `elem` names+          , case chartLegend chart of+              LegendRight -> bx > w * 0.55+              LegendBottom -> by < h * 0.45+              _ -> False+          ]+        ticks = [b | (b, t) <- ts, tickText t]+       in+        or [rectsOverlap a b | a <- legends, b <- ticks]+  unless (not (overlapTitleTick sleepChart 400 240 legendOps)) $+    fail "axis titles overlap ticks"+  unless (not (overlapLegendTick sleepChart 400 240 legendOps)) $+    fail "legend overlaps ticks"+  unless (not (overlapTitleTick sleepChart 220 150 tightOps)) $+    fail "axis titles overlap ticks on a small plot"+  unless (not (overlapTitleTick barChartSample 220 150 barTightOps)) $+    fail "bar axis titles overlap ticks on a small plot"+  unless (not (overlapTitleTick botChart 400 240 botOps)) $+    fail "axis titles overlap ticks with bottom legend"+  unless (not (overlapLegendTick botChart 400 240 botOps)) $+    fail "bottom legend overlaps ticks"+  let+    shortTitles =+      (bareChart [line "sin(x)" [(0, 0), (1, 1)]])+        { chartLegend = LegendRight+        , chartYTitle = Just "y"+        , chartXTitle = Just "x"+        }+    shortM = chartMargins fm shortTitles+  unless (marginLeft shortM < 0.85 && marginBottom shortM < 0.65) $+    fail "short axis titles left a huge gutter"++barChartSample :: Chart+barChartSample =+  (bareChart [bar "count" [("Mon", 2), ("Tue", 5), ("Wed", 4), ("Thu", 7), ("Fri", 3)]])+    { chartXTitle = Just "day"+    , chartYTitle = Just "count"+    , chartLegend = LegendRight+    , chartGrid = GridBoth+    }++testPlotHover :: IO ()+testPlotHover = do+  let hoverAt c = nearestPlotHover (seriesDomains c) (map (seriesPoints c) (chartSeries c)) 0.5 0.5+  forM_ [bareChart [], bareChart [line "empty" []]] $ \chart ->+    unless (hoverAt chart == Nothing) $+      fail "empty chart produced a hover target"+  let+    tied = bareChart [line "first" [(0, 0), (0, 0)], line "second" [(0, 0)]]+  unless+    ( fmap (\h -> (hoverSeriesIdx h, hoverPointIdx h)) (hoverAt tied)+        == Just (0, 0)+    ) $+    fail "equidistant hover targets did not prefer the first point"+  case hoverAt (bareChart [line "a" [(0, 0), (1, 1), (2, 4)]]) of+    Nothing -> fail "nearestPlotHover missed center point"+    Just h ->+      unless+        ( hoverSeriesIdx h == 0+            && hoverPointIdx h == 1+            && hoverDataX h == 1+            && hoverDataY h == 1+        ) $+        fail "nearestPlotHover picked wrong point"++-- Empty series isolate the legend strokes from data geometry. Every placement+-- must retain the labels and use the same color overrides as the series.+testLegendColors :: FontMetrics -> IO ()+testLegendColors fm = do+  let+    custom = colorRGBA 17 211 83 255+    chart = bareChart [withColor custom (line "custom" []), line "default" []]+    fallback = themeSeries defaultTheme !! 1+  forM_ [LegendNone, LegendRight, LegendBottom, LegendTop, LegendInside] $ \position -> do+    let+      ops = toList (diagramOps 400 280 (chartDia fm chart {chartLegend = position}))+      labels =+        [text | DrawText _ _ _ _ text _ <- ops, text == "custom" || text == "default"]+      colors = [color | FillTriangle _ _ _ _ _ _ color <- ops]+    if position == LegendNone+      then+        unless (null labels && custom `notElem` colors) $+          fail "hidden legend rendered entries"+      else do+        unless (length labels == 2 && "custom" `elem` labels && "default" `elem` labels) $+          fail "legend lost or duplicated a series label"+        unless (custom `elem` colors && fallback `elem` colors) $+          fail "legend colors differ from series colors"++fillTriCount :: SmallArray DrawOp -> Int+fillTriCount ops = length [() | FillTriangle {} <- toList ops]++testClosedSeriesFills :: FontMetrics -> IO ()+testClosedSeriesFills fm = do+  let+    seriesOps s = diagramOps 200 120 (chartDia fm (bareChart [s]))+    areaOps = seriesOps (area "a" [(0, 1), (1, 2), (2, 0)])+    diamondOps = seriesOps (withMarker MarkDiamond (scatter "d" [(1, 1), (2, 3)]))+    triOps = seriesOps (withMarker MarkTriangle (scatter "t" [(1, 1)]))+    crossOps = seriesOps (withMarker MarkCross (scatter "x" [(8, 8)]))+    ink = fromMaybe (themeRed defaultTheme) (listToMaybe (themeSeries defaultTheme))+    inkXs =+      [ x+      | FillTriangle x0 _ x1 _ x2 _ c <- toList crossOps+      , c == ink+      , x <- [x0, x1, x2]+      ]+  unless (fillTriCount areaOps >= 2) $+    fail "area series produced no fill triangles"+  unless (fillTriCount diamondOps >= 2) $+    fail "diamond marker produced no fill"+  unless (fillTriCount triOps >= 1) $+    fail "triangle marker produced no fill"+  unless (not (null inkXs) && maximum inkXs - minimum inkXs < 40) $+    fail "MarkCross arm left at origin"++testGrowPlotHeight :: FontMetrics -> IO ()+testGrowPlotHeight fm = do+  let+    fitted = fitLayout fm (fillW defaultLayout) (chartDia fm barChartSample)+  unless (layoutMinH fitted <= 260 && layoutMaxH fitted <= 260) $+    fail "plot grow height ballooned"