renderreport 0.2.0

Data-driven report generation with Typst as embedded render engine — no CLI dependency
Documentation
// Chart component template
#let chart(data) = {
  let title = data.title
  let chart_type = data.chart_type
  let series = data.series
  let x_label = data.at("x_label", default: none)
  let y_label = data.at("y_label", default: none)
  let show_legend = data.at("show_legend", default: true)
  let width = data.at("width", default: "100%")
  let height = data.at("height", default: "200pt")

  // Color palette for multiple series/data points
  let colors = (
    rgb("#3b82f6"),
    rgb("#22c55e"),
    rgb("#f59e0b"),
    rgb("#ef4444"),
    rgb("#8b5cf6"),
    rgb("#06b6d4"),
    rgb("#ec4899"),
    rgb("#f97316"),
  )

  block(
    width: 100%,
    breakable: false,
    {
      // Title
      if title != "" {
        align(center, text(weight: "bold", size: 12pt, title))
        v(8pt)
      }

      if chart_type == "bar" {
        // Bar chart — render actual data points from first series
        let points = if series.len() > 0 { series.at(0).data } else { () }
        let max_val = if points.len() > 0 {
          calc.max(..points.map(p => p.value), 1)
        } else { 100 }

        rect(
          width: 100%,
          stroke: 0.5pt + color-border,
          radius: 4pt,
          fill: color-surface,
          inset: 16pt,
          {
            if y_label != none {
              align(left, text(size: 8pt, fill: color-text-muted, y_label))
              v(4pt)
            }

            let chart_height = 140
            grid(
              columns: (1fr,) * points.len(),
              column-gutter: 12pt,
              ..points.enumerate().map(((i, p)) => {
                let bar_height = calc.max(p.value / max_val * chart_height, 4)
                align(center, {
                  text(size: 8pt, weight: "bold", fill: color-text, str(calc.round(p.value)))
                  v(4pt)
                  rect(
                    width: 100%,
                    height: bar_height * 1pt,
                    fill: colors.at(calc.rem(i, colors.len())),
                    radius: (top: 3pt),
                  )
                  v(6pt)
                  text(size: 7pt, fill: color-text-muted, p.label)
                })
              })
            )

            if x_label != none {
              v(8pt)
              align(center, text(size: 8pt, fill: color-text-muted, x_label))
            }
          }
        )
      } else if chart_type == "pie" {
        // Pie chart — show data as colored segments legend (actual circle drawing is limited in Typst)
        let points = if series.len() > 0 { series.at(0).data } else { () }
        let total = if points.len() > 0 {
          points.map(p => p.value).sum()
        } else { 1 }

        rect(
          width: 100%,
          stroke: 0.5pt + color-border,
          radius: 4pt,
          fill: color-surface,
          inset: 16pt,
          {
            if points.len() > 0 {
              // Stacked horizontal bar as pie chart approximation
              let bar_width = 100
              stack(
                dir: ltr,
                ..points.enumerate().map(((i, p)) => {
                  let segment_width = calc.max(p.value / total * bar_width, 2)
                  rect(
                    width: segment_width * 1%,
                    height: 32pt,
                    fill: colors.at(calc.rem(i, colors.len())),
                  )
                })
              )
              v(12pt)
              // Legend with values
              grid(
                columns: (auto,) * calc.min(points.len(), 3),
                column-gutter: 16pt,
                row-gutter: 6pt,
                ..points.enumerate().map(((i, p)) => {
                  let pct = calc.round(p.value / total * 100)
                  [
                    #box(width: 8pt, height: 8pt, fill: colors.at(calc.rem(i, colors.len())), radius: 1pt)
                    #h(4pt)
                    #text(size: 8pt)[#p.label: #str(calc.round(p.value)) (#str(pct)%)]
                  ]
                })
              )
            }
          }
        )
      } else if chart_type == "line" or chart_type == "area" {
        // Line/Area chart — data-driven with dots and connecting lines
        let all_values = series.map(s => s.data.map(p => p.value)).flatten()
        let max_val = if all_values.len() > 0 { calc.max(..all_values, 1) } else { 100 }
        let min_val = if all_values.len() > 0 { calc.min(..all_values, 0) } else { 0 }
        let val_range = if max_val > min_val { max_val - min_val } else { 1 }
        let labels = if series.len() > 0 { series.at(0).data.map(p => p.label) } else { () }
        let chart_h = 140
        let chart_w_pt = 100 // percentage

        rect(
          width: 100%,
          stroke: 0.5pt + color-border,
          radius: 4pt,
          fill: color-surface,
          inset: 16pt,
          {
            if y_label != none {
              align(left, text(size: 8pt, fill: color-text-muted, y_label))
              v(4pt)
            }

            // Chart area with placed elements
            let n_points = if labels.len() > 0 { labels.len() } else { 1 }

            for (si, s) in series.enumerate() {
              let color = colors.at(calc.rem(si, colors.len()))
              let points_data = s.data

              if points_data.len() > 1 {
                // Render columns with bars for area, dots for line
                grid(
                  columns: (1fr,) * points_data.len(),
                  column-gutter: 0pt,
                  ..points_data.enumerate().map(((i, p)) => {
                    let bar_height = calc.max((p.value - min_val) / val_range * chart_h, 2)
                    align(center, {
                      // Value label
                      text(size: 7pt, weight: "bold", fill: color-text, str(calc.round(p.value)))
                      v(2pt)
                      // Spacer to push down (inverse height)
                      v((chart_h - bar_height) * 1pt)
                      if chart_type == "area" {
                        // Filled area bar
                        rect(
                          width: 100%,
                          height: bar_height * 1pt,
                          fill: color.lighten(60%),
                          stroke: (top: 2.5pt + color),
                        )
                      } else {
                        // Line chart: thin bar with dot on top
                        rect(
                          width: 100%,
                          height: bar_height * 1pt,
                          fill: none,
                          stroke: none,
                          {
                            place(center + top, circle(radius: 4pt, fill: color, stroke: 1pt + color-background))
                            place(center + top, line(
                              length: 100%,
                              stroke: 2pt + color,
                            ))
                          }
                        )
                      }
                    })
                  })
                )

                // X-axis labels
                grid(
                  columns: (1fr,) * points_data.len(),
                  column-gutter: 0pt,
                  ..points_data.map(p => {
                    align(center, text(size: 7pt, fill: color-text-muted, p.label))
                  })
                )
              }
            }

            if x_label != none {
              v(8pt)
              align(center, text(size: 8pt, fill: color-text-muted, x_label))
            }
          }
        )
      } else if chart_type == "scatter" {
        // Scatter chart — dots positioned by value
        let all_values = series.map(s => s.data.map(p => p.value)).flatten()
        let max_val = if all_values.len() > 0 { calc.max(..all_values, 1) } else { 100 }
        let min_val = if all_values.len() > 0 { calc.min(..all_values, 0) } else { 0 }
        let val_range = if max_val > min_val { max_val - min_val } else { 1 }
        let chart_h = 140

        rect(
          width: 100%,
          stroke: 0.5pt + color-border,
          radius: 4pt,
          fill: color-surface,
          inset: 16pt,
          {
            if y_label != none {
              align(left, text(size: 8pt, fill: color-text-muted, y_label))
              v(4pt)
            }

            for (si, s) in series.enumerate() {
              let color = colors.at(calc.rem(si, colors.len()))
              let points_data = s.data

              if points_data.len() > 0 {
                grid(
                  columns: (1fr,) * points_data.len(),
                  column-gutter: 4pt,
                  ..points_data.enumerate().map(((i, p)) => {
                    let dot_y = calc.max((p.value - min_val) / val_range * chart_h, 4)
                    align(center, {
                      text(size: 6pt, fill: color-text-muted, str(calc.round(p.value)))
                      v(2pt)
                      v((chart_h - dot_y) * 1pt)
                      circle(radius: 5pt, fill: color, stroke: 1.5pt + color-background)
                      v(dot_y * 1pt - 10pt)
                    })
                  })
                )

                // X-axis labels
                grid(
                  columns: (1fr,) * points_data.len(),
                  column-gutter: 4pt,
                  ..points_data.map(p => {
                    align(center, text(size: 6pt, fill: color-text-muted, p.label))
                  })
                )
              }
            }

            if x_label != none {
              v(8pt)
              align(center, text(size: 8pt, fill: color-text-muted, x_label))
            }
          }
        )
      } else if chart_type == "radar" {
        // Radar chart — horizontal bar comparison per axis
        let labels = if series.len() > 0 { series.at(0).data.map(p => p.label) } else { () }
        let all_values = series.map(s => s.data.map(p => p.value)).flatten()
        let max_val = if all_values.len() > 0 { calc.max(..all_values, 1) } else { 100 }

        rect(
          width: 100%,
          stroke: 0.5pt + color-border,
          radius: 4pt,
          fill: color-surface,
          inset: 16pt,
          {
            for (li, label) in labels.enumerate() {
              // Axis label + value summary on same line
              for (si, s) in series.enumerate() {
                let color = colors.at(calc.rem(si, colors.len()))
                let val = if li < s.data.len() { s.data.at(li).value } else { 0 }
                let pct = calc.min(val / max_val * 100, 100)
                let val-color = if val >= 85 { rgb("#22c55e") } else if val >= 70 { rgb("#3b82f6") } else if val >= 50 { rgb("#f59e0b") } else { rgb("#ef4444") }

                grid(
                  columns: (120pt, 1fr, 36pt),
                  gutter: 8pt,
                  align: (left + horizon, left + horizon, right + horizon),
                  text(size: 9pt, weight: "semibold", fill: color-text, label),
                  box(
                    width: 100%,
                    height: 18pt,
                    radius: 4pt,
                    fill: color-surface-alt,
                    stroke: 0.5pt + color-border,
                  )[
                    #place(left + horizon,
                      box(width: pct * 1%, height: 100%, radius: 4pt, fill: val-color.lighten(20%))
                    )
                    #if pct >= 15 [
                      #place(left + horizon, dx: 6pt,
                        text(size: 7pt, weight: "bold", fill: val-color.darken(30%))[#str(calc.round(val))]
                      )
                    ]
                  ],
                  text(size: 9pt, weight: "bold", fill: val-color)[#str(calc.round(val))],
                )
                v(5pt)
              }
            }
          }
        )
      } else {
        rect(
          width: 100%,
          height: eval(height),
          stroke: 0.5pt + gray,
          radius: 4pt,
          fill: color-surface,
          inset: 12pt,
          align(center + horizon, text(size: 9pt, fill: gray, [Chart: ] + chart_type))
        )
      }

      // Legend for multi-series
      if show_legend and series.len() > 1 {
        v(8pt)
        grid(
          columns: (auto,) * calc.min(series.len(), 4),
          column-gutter: 12pt,
          ..series.enumerate().map(((i, s)) => [
            #box(width: 8pt, height: 8pt, fill: colors.at(calc.rem(i, colors.len())), radius: 1pt)
            #h(4pt)
            #text(size: 9pt, s.name)
          ])
        )
      }
    }
  )
}