packages feed

granite-0.7.2.0: src/Granite/Render/Svg.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE Strict #-}

{- |
Module      : Granite.Render.Svg
Copyright   : (c) 2025
License     : MIT
Maintainer  : mschavinda@gmail.com

SVG backend.
-}
module Granite.Render.Svg (
    renderScene,
    renderSceneResponsive,
    attr,
    svgDoc,
    svgDocWith,
    svgRect,
    svgCircle,
    svgLine,
    svgPolyline,
    svgPath,
    svgText,
) where

import Data.Text (Text)
import Data.Text qualified as T

import Granite.Color (colorHex)
import Granite.Internal.Util (escXml, showD)
import Granite.Render.Scene (
    Mark (..),
    Point (..),
    Rect (..),
    Scene (..),
    Style (..),
    TextAnchor (..),
    TextStyle (..),
 )

attr :: Text -> Text -> Text
attr k v = " " <> k <> "=\"" <> v <> "\""

svgDoc :: Double -> Double -> Text -> Text
svgDoc = svgDocWith False

svgDocWith :: Bool -> Double -> Double -> Text -> Text
svgDocWith responsive w h content =
    "<svg xmlns=\"http://www.w3.org/2000/svg\""
        <> attr "viewBox" ("0 0 " <> showD w <> " " <> showD h)
        <> sizing
        <> attr "font-family" "system-ui, -apple-system, sans-serif"
        <> ">\n"
        <> "<rect width=\"100%\" height=\"100%\" fill=\"white\"/>\n"
        <> content
        <> "</svg>\n"
  where
    sizing
        | responsive =
            attr "preserveAspectRatio" "xMidYMid meet"
                <> attr "width" "100%"
                <> attr "style" "height:auto"
        | otherwise =
            attr "width" (showD w) <> attr "height" (showD h)

svgRect :: Double -> Double -> Double -> Double -> Text -> Text -> Text
svgRect x y w h fill extra =
    "<rect"
        <> attr "x" (showD x)
        <> attr "y" (showD y)
        <> attr "width" (showD w)
        <> attr "height" (showD h)
        <> attr "fill" fill
        <> extra
        <> "/>\n"

svgCircle :: Double -> Double -> Double -> Text -> Text
svgCircle cx cy r fill =
    "<circle"
        <> attr "cx" (showD cx)
        <> attr "cy" (showD cy)
        <> attr "r" (showD r)
        <> attr "fill" fill
        <> "/>\n"

svgLine :: Double -> Double -> Double -> Double -> Text -> Double -> Text
svgLine x1 y1 x2 y2 stroke strokeW =
    "<line"
        <> attr "x1" (showD x1)
        <> attr "y1" (showD y1)
        <> attr "x2" (showD x2)
        <> attr "y2" (showD y2)
        <> attr "stroke" stroke
        <> attr "stroke-width" (showD strokeW)
        <> "/>\n"

svgPolyline :: [(Double, Double)] -> Text -> Double -> Text
svgPolyline pts stroke strokeW =
    "<polyline"
        <> attr "points" (T.intercalate " " [showD x <> "," <> showD y | (x, y) <- pts])
        <> attr "fill" "none"
        <> attr "stroke" stroke
        <> attr "stroke-width" (showD strokeW)
        <> attr "stroke-linejoin" "round"
        <> attr "stroke-linecap" "round"
        <> "/>\n"

-- | Simple text element (legacy callers); no rotation or tooltip.
svgText :: Double -> Double -> Text -> Text -> Double -> Text -> Text
svgText x y anchor fill size = svgTextRT x y anchor fill size 0 Nothing

{- | Text element with optional rotation (degrees about @(x,y)@) and an
optional @\<title\>@ tooltip (the untruncated label). @rot = 0@ and
@title = Nothing@ produce byte-identical output to 'svgText'.
-}
svgTextRT ::
    Double ->
    Double ->
    Text ->
    Text ->
    Double ->
    Double ->
    Maybe Text ->
    Text ->
    Text
svgTextRT x y anchor fill size rot mTitle content =
    "<text"
        <> attr "x" (showD x)
        <> attr "y" (showD y)
        <> attr "text-anchor" anchor
        <> attr "fill" fill
        <> attr "font-size" (showD size)
        <> rotAttr
        <> ">"
        <> titleEl
        <> escXml content
        <> "</text>\n"
  where
    rotAttr
        | rot == 0 = ""
        | otherwise =
            attr
                "transform"
                ("rotate(" <> showD rot <> " " <> showD x <> " " <> showD y <> ")")
    titleEl = case mTitle of
        Nothing -> ""
        Just t -> "<title>" <> escXml t <> "</title>"

svgPath :: Text -> Text -> Text -> Text
svgPath d fill extra =
    "<path"
        <> attr "d" d
        <> attr "fill" fill
        <> extra
        <> "/>\n"

renderScene :: Scene -> Text
renderScene scene =
    svgDoc (sceneWidth scene) (sceneHeight scene) $
        T.concat (map renderMark (sceneMarks scene))

renderSceneResponsive :: Scene -> Text
renderSceneResponsive scene =
    svgDocWith True (sceneWidth scene) (sceneHeight scene) $
        T.concat (map renderMark (sceneMarks scene))

renderMark :: Mark -> Text
renderMark m = case m of
    MRect (Rect x y w h) sty ->
        let fill = maybe "none" colorHex (styleFill sty)
            extras =
                strokeAttrs sty
                    <> opacityAttr sty
         in svgRect x y w h fill extras
    MCircle (Point x y) r sty ->
        let fill = maybe "none" colorHex (styleFill sty)
         in "<circle"
                <> attr "cx" (showD x)
                <> attr "cy" (showD y)
                <> attr "r" (showD r)
                <> attr "fill" fill
                <> strokeAttrs sty
                <> opacityAttr sty
                <> "/>\n"
    MText (Point x y) txt ts ->
        svgTextRT
            x
            y
            (anchorText (textAnchor ts))
            (colorHex (textFill ts))
            (textSize ts)
            (textRotate ts)
            (textTitle ts)
            txt
    MPolyline pts sty ->
        let stroke = maybe "#000000" colorHex (styleStroke sty)
            pairs = [(px, py) | Point px py <- pts]
         in svgPolyline pairs stroke (styleStrokeWidth sty)
    MPolygon pts sty ->
        let fill = maybe "none" colorHex (styleFill sty)
            pairs = [(px, py) | Point px py <- pts]
            ptsStr = T.intercalate " " [showD px <> "," <> showD py | (px, py) <- pairs]
         in "<polygon"
                <> attr "points" ptsStr
                <> attr "fill" fill
                <> strokeAttrs sty
                <> opacityAttr sty
                <> "/>\n"
    MAxisLine (Point x1 y1) (Point x2 y2) sty ->
        let stroke = maybe "#000000" colorHex (styleStroke sty)
         in svgLine x1 y1 x2 y2 stroke (styleStrokeWidth sty)
    MPath d sty ->
        let fill = maybe "none" colorHex (styleFill sty)
         in svgPath d fill (strokeAttrs sty)
    MArc (Point cx cy) r a0 a1 sty ->
        let x0 = cx + r * cos a0
            y0 = cy + r * sin a0
            x1 = cx + r * cos a1
            y1 = cy + r * sin a1
            largeArc = if a1 - a0 > pi then "1" else "0"
            d =
                "M "
                    <> showD cx
                    <> " "
                    <> showD cy
                    <> " L "
                    <> showD x0
                    <> " "
                    <> showD y0
                    <> " A "
                    <> showD r
                    <> " "
                    <> showD r
                    <> " 0 "
                    <> largeArc
                    <> " 0 "
                    <> showD x1
                    <> " "
                    <> showD y1
                    <> " Z"
            fill = maybe "none" colorHex (styleFill sty)
         in svgPath d fill (strokeAttrs sty)
    MGroup ms ->
        "<g>" <> T.concat (map renderMark ms) <> "</g>\n"

anchorText :: TextAnchor -> Text
anchorText AnchorStart = "start"
anchorText AnchorMiddle = "middle"
anchorText AnchorEnd = "end"

strokeAttrs :: Style -> Text
strokeAttrs sty =
    case styleStroke sty of
        Nothing -> ""
        Just c ->
            attr "stroke" (colorHex c) <> attr "stroke-width" (showD (styleStrokeWidth sty))

opacityAttr :: Style -> Text
opacityAttr sty
    | styleFillOpacity sty < 1 = attr "fill-opacity" (showD (styleFillOpacity sty))
    | otherwise = ""