// Pie / donut chart.
///|
/// A point on the circle of radius `r` centered at `(cx, cy)` at `angle`
/// radians (0 = 3 o'clock, increasing clockwise in SVG's y-down space).
fn polar(
cx : Double,
cy : Double,
r : Double,
angle : Double,
) -> (Double, Double) {
(cx + r * @math.cos(angle), cy + r * @math.sin(angle))
}
///|
/// SVG path data for one slice from `a0` to `a1`. With `inner <= 0` it is a
/// full wedge from the center; otherwise it is a donut segment between radii
/// `inner` and `r`.
fn arc_path(
cx : Double,
cy : Double,
r : Double,
inner : Double,
a0 : Double,
a1 : Double,
) -> String {
let (x0, y0) = polar(cx, cy, r, a0)
let (x1, y1) = polar(cx, cy, r, a1)
let large = if a1 - a0 > @math.PI { "1" } else { "0" }
if inner <= 0.0 {
"M " +
num(cx) +
" " +
num(cy) +
" L " +
num(x0) +
" " +
num(y0) +
" A " +
num(r) +
" " +
num(r) +
" 0 " +
large +
" 1 " +
num(x1) +
" " +
num(y1) +
" Z"
} else {
let (xi0, yi0) = polar(cx, cy, inner, a0)
let (xi1, yi1) = polar(cx, cy, inner, a1)
"M " +
num(x0) +
" " +
num(y0) +
" A " +
num(r) +
" " +
num(r) +
" 0 " +
large +
" 1 " +
num(x1) +
" " +
num(y1) +
" L " +
num(xi1) +
" " +
num(yi1) +
" A " +
num(inner) +
" " +
num(inner) +
" 0 " +
large +
" 0 " +
num(xi0) +
" " +
num(yi0) +
" Z"
}
}
///|
/// Append one slice to `body`, splitting a full-circle slice into two halves so
/// the arc is never degenerate.
fn emit_slice(
body : StringBuilder,
cx : Double,
cy : Double,
r : Double,
inner : Double,
a0 : Double,
a1 : Double,
fill : String,
) -> Unit {
if a1 - a0 >= @math.PI * 2.0 - 0.000001 {
let mid = a0 + @math.PI
body.write_string(
elem("path", [("d", arc_path(cx, cy, r, inner, a0, mid)), ("fill", fill)]),
)
body.write_string(
elem("path", [("d", arc_path(cx, cy, r, inner, mid, a1)), ("fill", fill)]),
)
} else {
body.write_string(
elem("path", [("d", arc_path(cx, cy, r, inner, a0, a1)), ("fill", fill)]),
)
}
}
///|
/// Render a pie chart (or a donut when `donut` is set to an inner-radius ratio
/// in `0.0..1.0`) as a standalone SVG document string. `data` is a list of
/// `(label, value)` pairs; non-positive values are skipped in the ring but
/// still shown in the legend. `title`, `width` and `height` are optional.
pub fn pie_chart(
data : Array[(String, Double)],
title? : String = "",
width? : Double = 380.0,
height? : Double = 300.0,
donut? : Double = 0.0,
theme? : Theme = Theme::light(),
) -> String {
let top = if title == "" { 16.0 } else { 40.0 }
let mut total = 0.0
for d in data {
if d.1 > 0.0 {
total += d.1
}
}
if total <= 0.0 {
total = 1.0
}
let cy = top + (height - top) / 2.0
let mut r = (height - top - 24.0) / 2.0
let max_r_by_width = (width - 140.0) / 2.0
if r > max_r_by_width {
r = max_r_by_width
}
let cx = 16.0 + r
let inner = if donut > 0.0 { r * donut } else { 0.0 }
let body = StringBuilder::new()
body.write_string(background_rect(theme, width, height))
let mut angle = -@math.PI / 2.0
let n = data.length()
let item_h = 20.0
let mut legend_y = cy - n.to_double() * item_h / 2.0 + item_h / 2.0
let legend_x = cx + r + 20.0
for i in 0.. 0.0 {
let a0 = angle
let a1 = angle + value / total * @math.PI * 2.0
emit_slice(body, cx, cy, r, inner, a0, a1, theme.color_at(i))
angle = a1
}
// Legend swatch and label for every item.
body.write_string(
elem("rect", [
("x", num(legend_x)),
("y", num(legend_y - 9.0)),
("width", "12"),
("height", "12"),
("fill", theme.color_at(i)),
("rx", "2"),
]),
)
body.write_string(
label(
legend_x + 18.0,
legend_y,
name + " (" + num(value) + ")",
anchor="start",
fill=theme.text,
),
)
legend_y += item_h
}
if title != "" {
body.write_string(
label(width / 2.0, 24.0, title, size=16, weight="bold", fill=theme.title),
)
}
document(width, height, body.to_string())
}