// Copyright 2026 hua1104. Apache-2.0.
//
// A dependency-free SVG renderer for actuarial charts.
//
// Mortality and survivorship are read on a logarithmic axis, and reserve
// trajectories are read linearly, so the renderer supports both. Nothing here
// reads files or shells out: the chart is assembled as text and returned, which
// makes it usable from the CLI, from a WASM host, or from a test.
///|
/// A single plotted observation.
pub struct Point {
x : Double
y : Double
}
///|
/// A labelled run of observations drawn as one polyline.
pub struct Series {
name : String
colour : String
points : Array[Point]
}
///|
/// A chart: one or more series sharing axes.
pub struct Chart {
title : String
subtitle : String
x_label : String
y_label : String
series : Array[Series]
/// Whether the vertical axis is drawn with a logarithmic scale.
logarithmic : Bool
width : Int32
height : Int32
}
///|
/// A chart with sensible defaults, all of which can be overridden by rebuilding
/// the value with the builders below.
pub fn Chart::new(title : String) -> Chart {
Chart::{
title,
subtitle: "",
x_label: "",
y_label: "",
series: [],
logarithmic: false,
width: 900,
height: 480,
}
}
///|
/// Set the chart subtitle.
pub fn Chart::subtitle(self : Chart, text : String) -> Chart {
Chart::{ ..self, subtitle: text, }
}
///|
/// Label the horizontal axis.
pub fn Chart::x_label(self : Chart, text : String) -> Chart {
Chart::{ ..self, x_label: text, }
}
///|
/// Label the vertical axis.
pub fn Chart::y_label(self : Chart, text : String) -> Chart {
Chart::{ ..self, y_label: text, }
}
///|
/// Draw the vertical axis on a logarithmic scale.
pub fn Chart::logarithmic(self : Chart) -> Chart {
Chart::{ ..self, logarithmic: true, }
}
///|
/// Override the output pixel size.
pub fn Chart::size(self : Chart, width : Int32, height : Int32) -> Chart {
Chart::{ ..self, width, height, }
}
///|
/// Append a series to the chart.
pub fn Chart::series(
self : Chart,
name : String,
colour : String,
points : Array[Point],
) -> Chart {
let mut all = []
for s in self.series {
all = concat(all, [s])
}
all = concat(all, [Series::{ name, colour, points, }])
Chart::{ ..self, series: all, }
}
///|
/// Construct a point.
pub fn Point::of(x : Double, y : Double) -> Point {
Point::{ x, y, }
}
///|
/// Series are built up in order, so append rather than rely on a mutable field.
fn concat(left : Array[Series], right : Array[Series]) -> Array[Series] {
let out = Array::make(left.length() + right.length(), Series::{
name: "",
colour: "",
points: [],
})
let mut i = 0
for v in left {
out[i] = v
i = i + 1
}
for v in right {
out[i] = v
i = i + 1
}
out
}
///|
/// Escape text destined for an XML text node or attribute.
fn escape(text : String) -> String {
let mut out = ""
for c in text {
if c == '&' {
out = out + "&"
} else if c == '<' {
out = out + "<"
} else if c == '>' {
out = out + ">"
} else if c == '"' {
out = out + """
} else if c == '\'' {
out = out + "'"
} else {
out = out + "\{c}"
}
}
out
}
///|
/// Choose how many decimal places a tick label needs to stay readable. Small
/// ticks need more places than large ones.
fn tick_places(value : Double) -> Int32 {
let magnitude = value.abs()
if magnitude == 0.0 {
0
} else if magnitude >= 10.0 {
0
} else if magnitude >= 1.0 {
1
} else {
let exponent = 0.0 - @math.floor(@math.log10(magnitude))
let places = exponent.to_int()
if places > 6 {
6
} else {
places
}
}
}
///|
/// Render the chart to a standalone SVG document.
pub fn Chart::render(self : Chart) -> String {
let margin_left = 84.0
let margin_right = 28.0
let margin_top = 66.0
let margin_bottom = 62.0
let width = self.width.to_double()
let height = self.height.to_double()
let plot_w = width - margin_left - margin_right
let plot_h = height - margin_top - margin_bottom
let bounds = self.bounds()
let x_min = bounds.0
let x_max = bounds.1
let y_min = bounds.2
let y_max = bounds.3
let x_span = if x_max > x_min { x_max - x_min } else { 1.0 }
// Tick generation and point projection must agree on exactly one vertical
// domain, otherwise the outermost grid line lands off the canvas.
let (domain_lo, domain_hi) = self.y_axis(y_min, y_max)
let project_x = fn(v : Double) -> Double {
margin_left + clamp01((v - x_min) / x_span) * plot_w
}
let project_y = fn(v : Double) -> Double {
let value = if self.logarithmic { @math.log10(max_positive(v)) } else { v }
let span = domain_hi - domain_lo
let fraction = if span > 0.0 {
clamp01((value - domain_lo) / span)
} else {
0.5
}
margin_top + plot_h - fraction * plot_h
}
let mut body = "\n"
}
///|
/// The data extent across all series, expanded slightly so the extremes are not
/// drawn flush against the frame.
fn Chart::bounds(self : Chart) -> (Double, Double, Double, Double) {
let mut x_min = 0.0
let mut x_max = 1.0
let mut y_min = 0.0
let mut y_max = 1.0
let mut started = false
for s in self.series {
for p in s.points {
if started {
if p.x < x_min {
x_min = p.x
}
if p.x > x_max {
x_max = p.x
}
if p.y < y_min {
y_min = p.y
}
if p.y > y_max {
y_max = p.y
}
} else {
x_min = p.x
x_max = p.x
y_min = p.y
y_max = p.y
started = true
}
}
}
if !started {
(0.0, 1.0, 0.0, 1.0)
} else if x_min == x_max {
(x_min - 0.5, x_max + 0.5, y_min, y_max)
} else {
(x_min, x_max, y_min, y_max)
}
}
///|
/// The vertical domain actually plotted. On a logarithmic axis this is widened
/// out to whole decades so that ticks are round powers of ten; on a linear axis
/// the degenerate single-valued case is given room to breathe. Every consumer
/// of the vertical axis goes through here.
fn Chart::y_axis(self : Chart, lo : Double, hi : Double) -> (Double, Double) {
if self.logarithmic {
let low = @math.floor(@math.log10(max_positive(lo)))
let high = @math.ceil(@math.log10(max_positive(hi)))
if high > low {
(low, high)
} else {
(low, low + 1.0)
}
} else if hi > lo {
(lo, hi)
} else {
(lo - 0.5, lo + 0.5)
}
}
///|
/// Keep a projection ratio inside the unit interval, so nothing is ever drawn
/// outside the plot frame however extreme the data.
fn clamp01(value : Double) -> Double {
if value < 0.0 {
0.0
} else if value > 1.0 {
1.0
} else {
value
}
}
///|
/// Vertical tick positions, either by decade on a log axis or evenly spaced.
fn Chart::y_ticks(self : Chart, lo : Double, hi : Double) -> Array[Double] {
let (domain_lo, domain_hi) = self.y_axis(lo, hi)
if self.logarithmic {
let mut out : Array[Double] = []
let mut k = domain_lo
while k <= domain_hi + 0.5 {
out = push(out, @math.pow(10.0, k))
k = k + 1.0
}
out
} else {
linear_ticks(domain_lo, domain_hi, 7)
}
}
///|
/// Evenly spaced ticks snapped to a 1-2-5 step so labels stay round numbers.
fn linear_ticks(lo : Double, hi : Double, target : Int32) -> Array[Double] {
let span = hi - lo
if span <= 0.0 {
return [lo]
}
let raw = span / target.to_double()
let magnitude = @math.pow(10.0, @math.floor(@math.log10(raw)))
let norm = raw / magnitude
let step = if norm <= 1.0 {
magnitude
} else if norm <= 2.0 {
2.0 * magnitude
} else if norm <= 5.0 {
5.0 * magnitude
} else {
10.0 * magnitude
}
let mut out : Array[Double] = []
let mut v = @math.ceil(lo / step) * step
while v <= hi + step * 0.001 {
out = push(out, v)
v = v + step
}
out
}
///|
/// A logarithmic axis cannot plot non-positive values, so clamp upward before
/// taking a log.
fn max_positive(value : Double) -> Double {
if value > 1.0e-12 {
value
} else {
1.0e-12
}
}
///|
/// Append to an array without mutating the original.
fn push(source : Array[Double], value : Double) -> Array[Double] {
let out = Array::make(source.length() + 1, 0.0)
let mut i = 0
for v in source {
out[i] = v
i = i + 1
}
out[i] = value
out
}