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