Skip to main content

lib/pdf/component/chart.ex

defmodule Pdf.Component.Chart do
  @moduledoc """
  Chart component for PDF documents — renders bar, line, and pie charts.

  All charts are rendered using PDF drawing primitives (rectangles, lines,
  arcs) with no external dependencies.

  ## Bar chart

      Pdf.Component.Chart.bar_chart(doc, {50, 500}, %{
        width: 300, height: 200,
        data: [
          %{label: "Q1", value: 120, color: {0.2, 0.6, 0.9}},
          %{label: "Q2", value: 180, color: {0.9, 0.4, 0.2}},
          %{label: "Q3", value: 95,  color: {0.3, 0.8, 0.4}},
          %{label: "Q4", value: 210, color: {0.8, 0.2, 0.5}}
        ],
        title: "Quarterly Revenue"
      })

  ## Line chart

      Pdf.Component.Chart.line_chart(doc, {50, 500}, %{
        width: 300, height: 200,
        series: [
          %{label: "Sales",    values: [10, 25, 18, 42, 35], color: {0.2, 0.5, 0.9}},
          %{label: "Expenses", values: [8, 15, 22, 30, 28],  color: {0.9, 0.3, 0.3}}
        ],
        x_labels: ["Jan", "Feb", "Mar", "Apr", "May"],
        title: "Monthly Trend"
      })

  ## Pie chart

      Pdf.Component.Chart.pie_chart(doc, {50, 500}, %{
        radius: 80,
        data: [
          %{label: "Mobile",  value: 45, color: {0.2, 0.6, 0.9}},
          %{label: "Desktop", value: 35, color: {0.9, 0.4, 0.2}},
          %{label: "Tablet",  value: 20, color: {0.3, 0.8, 0.4}}
        ],
        title: "Device Distribution"
      })
  """

  @default_font "Helvetica"
  @default_font_size 8
  @default_title_size 11
  @default_axis_color {0.4, 0.4, 0.4}
  @default_grid_color {0.85, 0.85, 0.85}
  @default_text_color {0.2, 0.2, 0.2}
  @default_colors [
    {0.23, 0.55, 0.83},
    {0.90, 0.38, 0.27},
    {0.30, 0.75, 0.47},
    {0.95, 0.68, 0.20},
    {0.60, 0.35, 0.75},
    {0.15, 0.70, 0.68},
    {0.85, 0.25, 0.50},
    {0.50, 0.50, 0.50}
  ]

  # ════════════════════════════════════════════════════════════════
  # BAR CHART
  # ════════════════════════════════════════════════════════════════

  @doc """
  Render a vertical bar chart.

  `{x, y}` is the top-left corner of the chart area.

  ## Style options

  - `:data` — list of `%{label: str, value: number}` (required)
  - `:width` / `:height` — chart dimensions (default `300` × `200`)
  - `:title` — optional title above chart
  - `:show_values` — display value on top of each bar (default `true`)
  - `:show_grid` — horizontal grid lines (default `true`)
  - `:grid_lines` — number of grid lines (default `5`)
  - `:bar_gap` — fraction of bar width used as gap (default `0.3`)
  - `:bar_radius` — top corner radius (default `0`)
  - `:colors` — list of colors to cycle through
  - `:axis_color` / `:grid_color` / `:text_color`
  """
  def bar_chart(doc, _pos, %{data: []}), do: doc
  def bar_chart(doc, _pos, %{data: nil}), do: doc

  def bar_chart(doc, {x, y}, style) do
    data = Map.get(style, :data, [])
    w = Map.get(style, :width, 300)
    h = Map.get(style, :height, 200)
    title = Map.get(style, :title)
    show_values = Map.get(style, :show_values, true)
    show_grid = Map.get(style, :show_grid, true)
    grid_lines = Map.get(style, :grid_lines, 5)
    bar_gap = Map.get(style, :bar_gap, 0.3)
    colors = Map.get(style, :colors, @default_colors)
    axis_color = Map.get(style, :axis_color, @default_axis_color)
    grid_color = Map.get(style, :grid_color, @default_grid_color)
    text_color = Map.get(style, :text_color, @default_text_color)
    font = Map.get(style, :font, @default_font)
    font_size = Map.get(style, :font_size, @default_font_size)
    title_size = Map.get(style, :title_font_size, @default_title_size)

    # Layout: leave margin for axis labels
    left_margin = 40
    bottom_margin = 20
    top_margin = if title, do: title_size + 8, else: 4

    chart_x = x + left_margin
    chart_y = y - h + bottom_margin
    chart_w = w - left_margin
    chart_h = h - bottom_margin - top_margin

    max_val = data |> Enum.map(& &1.value) |> Enum.max() |> max(1)
    # Round up to nice number
    max_val = nice_max(max_val)
    n = length(data)

    # Title
    doc = if title do
      doc
      |> Pdf.set_font(font, title_size, bold: true)
      |> Pdf.set_fill_color(text_color)
      |> Pdf.text_at({x + w / 2 - String.length(title) * title_size * 0.25, y - title_size}, title)
    else
      doc
    end

    # Grid lines
    doc = if show_grid do
      Enum.reduce(0..grid_lines, doc, fn i, d ->
        gy = chart_y + chart_h * i / grid_lines
        val = round(max_val * i / grid_lines)
        val_str = format_number(val)

        d
        |> Pdf.save_state()
        |> Pdf.set_stroke_color(grid_color)
        |> Pdf.set_line_width(0.5)
        |> Pdf.line({chart_x, gy}, {chart_x + chart_w, gy})
        |> Pdf.stroke()
        |> Pdf.restore_state()
        |> Pdf.set_font(font, font_size - 1)
        |> Pdf.set_fill_color(axis_color)
        |> Pdf.text_at({chart_x - String.length(val_str) * (font_size - 1) * 0.55 - 4, gy - 3}, val_str)
      end)
    else
      doc
    end

    # Axes
    doc =
      doc
      |> Pdf.save_state()
      |> Pdf.set_stroke_color(axis_color)
      |> Pdf.set_line_width(1)
      |> Pdf.line({chart_x, chart_y}, {chart_x, chart_y + chart_h})
      |> Pdf.stroke()
      |> Pdf.line({chart_x, chart_y}, {chart_x + chart_w, chart_y})
      |> Pdf.stroke()
      |> Pdf.restore_state()

    # Bars
    bar_total_w = chart_w / n
    bar_w = bar_total_w * (1 - bar_gap)
    bar_offset = bar_total_w * bar_gap / 2

    doc =
      data
      |> Enum.with_index()
      |> Enum.reduce(doc, fn {item, i}, d ->
        color = Map.get(item, :color, Enum.at(colors, rem(i, length(colors))))
        bar_h = item.value / max_val * chart_h
        bx = chart_x + i * bar_total_w + bar_offset
        by = chart_y

        d =
          d
          |> Pdf.save_state()
          |> Pdf.set_fill_color(color)
          |> Pdf.rectangle({bx, by}, {bar_w, bar_h})
          |> Pdf.fill()
          |> Pdf.restore_state()

        # Value on top
        d = if show_values do
          val_str = format_number(item.value)
          tw = String.length(val_str) * font_size * 0.5
          d
          |> Pdf.set_font(font, font_size)
          |> Pdf.set_fill_color(text_color)
          |> Pdf.text_at({bx + bar_w / 2 - tw / 2, by + bar_h + 3}, val_str)
        else
          d
        end

        # Label below
        label = Map.get(item, :label, "")
        lw = String.length(label) * font_size * 0.5
        d
        |> Pdf.set_font(font, font_size)
        |> Pdf.set_fill_color(text_color)
        |> Pdf.text_at({bx + bar_w / 2 - lw / 2, chart_y - font_size - 2}, label)
      end)

    doc
  end

  # ════════════════════════════════════════════════════════════════
  # LINE CHART
  # ════════════════════════════════════════════════════════════════

  @doc """
  Render a line chart with one or more series.

  `{x, y}` is the top-left corner.

  ## Style options

  - `:series` — list of `%{label: str, values: [number], color: color}` (required)
  - `:x_labels` — labels for the x-axis points
  - `:width` / `:height` — chart dimensions (default `300` × `200`)
  - `:title` — optional title
  - `:show_dots` — render dots at data points (default `true`)
  - `:show_grid` — horizontal grid lines (default `true`)
  - `:grid_lines` — number of grid lines (default `5`)
  - `:dot_radius` — radius of data point dots (default `2.5`)
  - `:line_width` — stroke width for lines (default `1.5`)
  - `:show_legend` — show legend below chart (default `true`)
  """
  def line_chart(doc, _pos, %{series: []}), do: doc
  def line_chart(doc, _pos, %{series: nil}), do: doc

  def line_chart(doc, {x, y}, style) do
    series = Map.get(style, :series, [])
    x_labels = Map.get(style, :x_labels, [])
    w = Map.get(style, :width, 300)
    h = Map.get(style, :height, 200)
    title = Map.get(style, :title)
    show_dots = Map.get(style, :show_dots, true)
    show_grid = Map.get(style, :show_grid, true)
    show_legend = Map.get(style, :show_legend, true)
    grid_lines = Map.get(style, :grid_lines, 5)
    dot_radius = Map.get(style, :dot_radius, 2.5)
    line_w = Map.get(style, :line_width, 1.5)
    colors = Map.get(style, :colors, @default_colors)
    axis_color = Map.get(style, :axis_color, @default_axis_color)
    grid_color = Map.get(style, :grid_color, @default_grid_color)
    text_color = Map.get(style, :text_color, @default_text_color)
    font = Map.get(style, :font, @default_font)
    font_size = Map.get(style, :font_size, @default_font_size)
    title_size = Map.get(style, :title_font_size, @default_title_size)

    left_margin = 40
    bottom_margin = 20
    top_margin = if title, do: title_size + 8, else: 4
    legend_margin = if show_legend, do: 16, else: 0

    chart_x = x + left_margin
    chart_y = y - h + bottom_margin + legend_margin
    chart_w = w - left_margin
    chart_h = h - bottom_margin - top_margin - legend_margin

    all_values = Enum.flat_map(series, & &1.values)
    max_val = all_values |> Enum.max() |> nice_max()
    min_val = min(0, Enum.min(all_values))

    max_points = series |> Enum.map(&length(&1.values)) |> Enum.max(fn -> 0 end)

    # Title
    doc = if title do
      doc
      |> Pdf.set_font(font, title_size, bold: true)
      |> Pdf.set_fill_color(text_color)
      |> Pdf.text_at({x + w / 2 - String.length(title) * title_size * 0.25, y - title_size}, title)
    else
      doc
    end

    # Grid
    doc = if show_grid do
      Enum.reduce(0..grid_lines, doc, fn i, d ->
        gy = chart_y + chart_h * i / grid_lines
        val = round(min_val + (max_val - min_val) * i / grid_lines)
        val_str = format_number(val)

        d
        |> Pdf.save_state()
        |> Pdf.set_stroke_color(grid_color)
        |> Pdf.set_line_width(0.5)
        |> Pdf.line({chart_x, gy}, {chart_x + chart_w, gy})
        |> Pdf.stroke()
        |> Pdf.restore_state()
        |> Pdf.set_font(font, font_size - 1)
        |> Pdf.set_fill_color(axis_color)
        |> Pdf.text_at({chart_x - String.length(val_str) * (font_size - 1) * 0.55 - 4, gy - 3}, val_str)
      end)
    else
      doc
    end

    # Axes
    doc =
      doc
      |> Pdf.save_state()
      |> Pdf.set_stroke_color(axis_color)
      |> Pdf.set_line_width(1)
      |> Pdf.line({chart_x, chart_y}, {chart_x, chart_y + chart_h})
      |> Pdf.stroke()
      |> Pdf.line({chart_x, chart_y}, {chart_x + chart_w, chart_y})
      |> Pdf.stroke()
      |> Pdf.restore_state()

    # X-axis labels
    doc =
      if x_labels != [] do
        x_labels
        |> Enum.with_index()
        |> Enum.reduce(doc, fn {label, i}, d ->
          px = chart_x + i / max(max_points - 1, 1) * chart_w
          lw = String.length(label) * font_size * 0.5
          d
          |> Pdf.set_font(font, font_size)
          |> Pdf.set_fill_color(text_color)
          |> Pdf.text_at({px - lw / 2, chart_y - font_size - 2}, label)
        end)
      else
        doc
      end

    # Draw each series
    range = max_val - min_val

    doc =
      series
      |> Enum.with_index()
      |> Enum.reduce(doc, fn {s, si}, d ->
        color = Map.get(s, :color, Enum.at(colors, rem(si, length(colors))))
        points = s.values
        n = length(points)

        coords =
          points
          |> Enum.with_index()
          |> Enum.map(fn {v, i} ->
            px = chart_x + i / max(n - 1, 1) * chart_w
            py = chart_y + (v - min_val) / max(range, 1) * chart_h
            {px, py}
          end)

        # Draw line segments
        d =
          case coords do
            [first | rest] ->
              d = d
                |> Pdf.save_state()
                |> Pdf.set_stroke_color(color)
                |> Pdf.set_line_width(line_w)
                |> Pdf.move_to(first)

              d = Enum.reduce(rest, d, fn pt, acc -> Pdf.line_append(acc, pt) end)

              d |> Pdf.stroke() |> Pdf.restore_state()

            _ -> d
          end

        # Draw dots
        if show_dots do
          Enum.reduce(coords, d, fn {cx, cy}, acc ->
            draw_filled_circle(acc, cx, cy, dot_radius, color)
          end)
        else
          d
        end
      end)

    # Legend
    doc = if show_legend and length(series) > 1 do
      legend_y = chart_y - bottom_margin - legend_margin + 2
      series
      |> Enum.with_index()
      |> Enum.reduce({doc, chart_x}, fn {s, si}, {d, lx} ->
        color = Map.get(s, :color, Enum.at(colors, rem(si, length(colors))))
        label = Map.get(s, :label, "Series #{si + 1}")

        d =
          d
          |> Pdf.save_state()
          |> Pdf.set_fill_color(color)
          |> Pdf.rectangle({lx, legend_y}, {10, 8})
          |> Pdf.fill()
          |> Pdf.restore_state()
          |> Pdf.set_font(font, font_size)
          |> Pdf.set_fill_color(text_color)
          |> Pdf.text_at({lx + 13, legend_y}, label)

        {d, lx + 13 + String.length(label) * font_size * 0.55 + 15}
      end)
      |> elem(0)
    else
      doc
    end

    doc
  end

  # ════════════════════════════════════════════════════════════════
  # PIE CHART
  # ════════════════════════════════════════════════════════════════

  @doc """
  Render a pie chart.

  `{x, y}` is the center of the pie.

  ## Style options

  - `:data` — list of `%{label: str, value: number}` (required)
  - `:radius` — pie radius (default `80`)
  - `:title` — optional title above
  - `:donut` — inner radius fraction for donut chart (default `0`, set to `0.5` for donut)
  - `:show_labels` — draw labels with lines (default `true`)
  - `:show_percentages` — show % in labels (default `true`)
  - `:colors` — color cycle
  """
  def pie_chart(doc, _pos, %{data: []}), do: doc
  def pie_chart(doc, _pos, %{data: nil}), do: doc

  def pie_chart(doc, {cx, cy}, style) do
    data = Map.get(style, :data, [])
    radius = Map.get(style, :radius, 80)
    title = Map.get(style, :title)
    donut = Map.get(style, :donut, 0)
    show_labels = Map.get(style, :show_labels, true)
    show_pct = Map.get(style, :show_percentages, true)
    colors = Map.get(style, :colors, @default_colors)
    text_color = Map.get(style, :text_color, @default_text_color)
    font = Map.get(style, :font, @default_font)
    font_size = Map.get(style, :font_size, @default_font_size)
    title_size = Map.get(style, :title_font_size, @default_title_size)

    total = Enum.reduce(data, 0, fn item, acc -> acc + item.value end)
    total = max(total, 1)

    # Title
    doc = if title do
      doc
      |> Pdf.set_font(font, title_size, bold: true)
      |> Pdf.set_fill_color(text_color)
      |> Pdf.text_at({cx - String.length(title) * title_size * 0.25, cy + radius + title_size + 4}, title)
    else
      doc
    end

    inner_r = radius * donut
    segments = 36

    # Draw slices
    {doc, _} =
      data
      |> Enum.with_index()
      |> Enum.reduce({doc, -:math.pi() / 2}, fn {item, i}, {d, start_angle} ->
        fraction = item.value / total
        sweep = fraction * 2 * :math.pi()
        end_angle = start_angle + sweep
        color = Map.get(item, :color, Enum.at(colors, rem(i, length(colors))))

        # Draw slice as filled polygon (pie wedge approximation)
        steps = max(round(segments * fraction), 2)

        outer_points =
          for s <- 0..steps do
            a = start_angle + sweep * s / steps
            {cx + radius * :math.cos(a), cy + radius * :math.sin(a)}
          end

        points = if donut > 0 do
          inner_points =
            for s <- steps..0//-1 do
              a = start_angle + sweep * s / steps
              {cx + inner_r * :math.cos(a), cy + inner_r * :math.sin(a)}
            end
          outer_points ++ inner_points
        else
          [{cx, cy} | outer_points]
        end

        d = draw_filled_polygon(d, points, color)

        # Slice outline
        d =
          d
          |> Pdf.save_state()
          |> Pdf.set_stroke_color({1, 1, 1})
          |> Pdf.set_line_width(1.5)

        d = case points do
          [first | rest] ->
            d = Pdf.move_to(d, first)
            d = Enum.reduce(rest, d, fn pt, acc -> Pdf.line_append(acc, pt) end)
            d |> Pdf.close_path() |> Pdf.stroke()
          _ -> d
        end

        d = Pdf.restore_state(d)

        # Label with leader line
        d = if show_labels do
          mid_angle = start_angle + sweep / 2
          label_r = radius + 15
          lx = cx + label_r * :math.cos(mid_angle)
          ly = cy + label_r * :math.sin(mid_angle)

          tip_x = cx + (radius + 4) * :math.cos(mid_angle)
          tip_y = cy + (radius + 4) * :math.sin(mid_angle)

          label = Map.get(item, :label, "")
          pct_str = if show_pct do
            " (#{round(fraction * 100)}%)"
          else
            ""
          end
          full_label = label <> pct_str

          # Leader line
          d =
            d
            |> Pdf.save_state()
            |> Pdf.set_stroke_color(text_color)
            |> Pdf.set_line_width(0.5)
            |> Pdf.line({tip_x, tip_y}, {lx, ly})
            |> Pdf.stroke()
            |> Pdf.restore_state()

          # Text
          text_x = if :math.cos(mid_angle) >= 0, do: lx + 3, else: lx - String.length(full_label) * font_size * 0.5 - 3

          d
          |> Pdf.set_font(font, font_size)
          |> Pdf.set_fill_color(text_color)
          |> Pdf.text_at({text_x, ly - 3}, full_label)
        else
          d
        end

        {d, end_angle}
      end)

    doc
  end

  # ════════════════════════════════════════════════════════════════
  # HORIZONTAL BAR CHART
  # ════════════════════════════════════════════════════════════════

  @doc """
  Render a horizontal bar chart — useful for ranking or comparison data.

  `{x, y}` is the top-left corner.

  ## Style options

  Same as `bar_chart/3` but bars grow left-to-right.
  """
  def horizontal_bar_chart(doc, _pos, %{data: []}), do: doc
  def horizontal_bar_chart(doc, _pos, %{data: nil}), do: doc

  def horizontal_bar_chart(doc, {x, y}, style) do
    data = Map.get(style, :data, [])
    w = Map.get(style, :width, 300)
    h = Map.get(style, :height, 200)
    title = Map.get(style, :title)
    show_values = Map.get(style, :show_values, true)
    colors = Map.get(style, :colors, @default_colors)
    text_color = Map.get(style, :text_color, @default_text_color)
    font = Map.get(style, :font, @default_font)
    font_size = Map.get(style, :font_size, @default_font_size)
    title_size = Map.get(style, :title_font_size, @default_title_size)
    bar_gap = Map.get(style, :bar_gap, 0.25)

    left_margin = 80
    top_margin = if title, do: title_size + 8, else: 4

    chart_x = x + left_margin
    chart_y = y - h
    chart_w = w - left_margin - 40
    chart_h = h - top_margin

    max_val = data |> Enum.map(& &1.value) |> Enum.max() |> nice_max()
    n = length(data)

    # Title
    doc = if title do
      doc
      |> Pdf.set_font(font, title_size, bold: true)
      |> Pdf.set_fill_color(text_color)
      |> Pdf.text_at({x + w / 2 - String.length(title) * title_size * 0.25, y - title_size}, title)
    else
      doc
    end

    bar_total_h = chart_h / n
    bar_h = bar_total_h * (1 - bar_gap)
    bar_offset = bar_total_h * bar_gap / 2

    doc =
      data
      |> Enum.with_index()
      |> Enum.reduce(doc, fn {item, i}, d ->
        color = Map.get(item, :color, Enum.at(colors, rem(i, length(colors))))
        bar_w = item.value / max_val * chart_w
        by = chart_y + chart_h - (i + 1) * bar_total_h + bar_offset

        # Bar
        d =
          d
          |> Pdf.save_state()
          |> Pdf.set_fill_color(color)
          |> Pdf.rectangle({chart_x, by}, {bar_w, bar_h})
          |> Pdf.fill()
          |> Pdf.restore_state()

        # Label on left
        label = Map.get(item, :label, "")
        d =
          d
          |> Pdf.set_font(font, font_size)
          |> Pdf.set_fill_color(text_color)
          |> Pdf.text_at({x + 2, by + bar_h / 2 - 3}, label)

        # Value on right of bar
        if show_values do
          val_str = format_number(item.value)
          d
          |> Pdf.set_font(font, font_size)
          |> Pdf.set_fill_color(text_color)
          |> Pdf.text_at({chart_x + bar_w + 4, by + bar_h / 2 - 3}, val_str)
        else
          d
        end
      end)

    doc
  end

  # ════════════════════════════════════════════════════════════════
  # PRIVATE HELPERS
  # ════════════════════════════════════════════════════════════════

  @kappa 0.5522847498

  defp draw_filled_circle(doc, cx, cy, r, color) do
    k = r * @kappa

    doc
    |> Pdf.save_state()
    |> Pdf.set_fill_color(color)
    |> Pdf.move_to({cx + r, cy})
    |> Pdf.curve_to({cx + r, cy + k}, {cx + k, cy + r}, {cx, cy + r})
    |> Pdf.curve_to({cx - k, cy + r}, {cx - r, cy + k}, {cx - r, cy})
    |> Pdf.curve_to({cx - r, cy - k}, {cx - k, cy - r}, {cx, cy - r})
    |> Pdf.curve_to({cx + k, cy - r}, {cx + r, cy - k}, {cx + r, cy})
    |> Pdf.close_path()
    |> Pdf.fill()
    |> Pdf.restore_state()
  end

  defp draw_filled_polygon(doc, points, color) do
    case points do
      [first | rest] ->
        doc =
          doc
          |> Pdf.save_state()
          |> Pdf.set_fill_color(color)
          |> Pdf.move_to(first)

        doc = Enum.reduce(rest, doc, fn pt, d -> Pdf.line_append(d, pt) end)

        doc
        |> Pdf.close_path()
        |> Pdf.fill()
        |> Pdf.restore_state()

      _ ->
        doc
    end
  end

  defp nice_max(val) when val <= 0, do: 10
  defp nice_max(val) do
    magnitude = :math.pow(10, floor(:math.log10(val)))
    normalized = val / magnitude

    nice = cond do
      normalized <= 1.0 -> 1.0
      normalized <= 1.5 -> 1.5
      normalized <= 2.0 -> 2.0
      normalized <= 3.0 -> 3.0
      normalized <= 5.0 -> 5.0
      normalized <= 7.5 -> 7.5
      true -> 10.0
    end

    round(nice * magnitude)
  end

  defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
  defp format_number(n) when is_integer(n) and n >= 1_000_000 do
    "#{Float.round(n / 1_000_000, 1)}M"
  end
  defp format_number(n) when is_integer(n) and n >= 10_000 do
    "#{Float.round(n / 1_000, 1)}K"
  end
  defp format_number(n), do: "#{n}"
end