///|
pub(all) struct ScatterChart {
title : String
x_label : String
y_label : String
series_list : Array[Series]
width : Float
height : Float
config : ChartConfig
point_size : Float
}
///|
pub fn ScatterChart::new() -> ScatterChart {
{
title: "",
x_label: "",
y_label: "",
series_list: [],
width: 800.0,
height: 400.0,
config: ChartConfig::default(),
point_size: 4.0,
}
}
///|
pub fn ScatterChart::title(self : ScatterChart, t : String) -> ScatterChart {
{ ..self, title: t }
}
///|
pub fn ScatterChart::x_label(
self : ScatterChart,
label : String,
) -> ScatterChart {
{ ..self, x_label: label }
}
///|
pub fn ScatterChart::y_label(
self : ScatterChart,
label : String,
) -> ScatterChart {
{ ..self, y_label: label }
}
///|
pub fn ScatterChart::series(self : ScatterChart, s : Series) -> ScatterChart {
let new_list = self.series_list
new_list.push(s)
{ ..self, series_list: new_list }
}
///|
pub fn ScatterChart::width(self : ScatterChart, w : Float) -> ScatterChart {
{ ..self, width: w }
}
///|
pub fn ScatterChart::height(self : ScatterChart, h : Float) -> ScatterChart {
{ ..self, height: h }
}
///|
pub fn ScatterChart::config(
self : ScatterChart,
c : ChartConfig,
) -> ScatterChart {
{ ..self, config: c }
}
///|
pub fn ScatterChart::point_size(
self : ScatterChart,
ps : Float,
) -> ScatterChart {
{ ..self, point_size: ps }
}
// Find data range across all series
///|
fn find_scatter_data_range(series_list : Array[Series]) -> (Float, Float) {
fn loop_series(
i : Int,
min : Float,
max : Float,
found : Bool,
) -> (Float, Float) {
if i < series_list.length() {
let (new_min, new_max, new_found) = find_scatter_values_range(
series_list[i].values,
0,
min,
max,
found,
)
loop_series(i + 1, new_min, new_max, new_found)
} else {
(min, max)
}
}
loop_series(0, 0.0, 0.0, false)
}
///|
fn find_scatter_values_range(
values : Array[Float],
j : Int,
min : Float,
max : Float,
found : Bool,
) -> (Float, Float, Bool) {
if j < values.length() {
let v = values[j]
if found {
find_scatter_values_range(
values,
j + 1,
if v < min {
v
} else {
min
},
if v > max {
v
} else {
max
},
true,
)
} else {
find_scatter_values_range(values, j + 1, v, v, true)
}
} else {
(min, max, found)
}
}
// Render y-axis grid lines and labels recursively
///|
fn render_scatter_y_grid(
result : String,
axis : AxisLayout,
height : Float,
mt : Float,
mb : Float,
width : Float,
ml : Float,
mr : Float,
idx : Int,
config : ChartConfig,
) -> String {
if idx < axis.ticks.length() {
let y_pos = y_to_svg(
axis.ticks[idx],
axis.y_min,
axis.y_max,
height,
mt,
mb,
)
let label = text(
ml - 8.0,
y_pos + 4.0,
axis.tick_labels[idx],
config.axis_font_size,
"end",
)
let new_result = if config.show_grid {
let grid_line = line(ml - 5.0, y_pos, width - mr, y_pos, "#e0e0e0", 1.0)
result + grid_line + label
} else {
result + label
}
render_scatter_y_grid(
new_result,
axis,
height,
mt,
mb,
width,
ml,
mr,
idx + 1,
config,
)
} else {
result
}
}
// Bounds check: minimum categories across all series to avoid index out of bounds
///|
fn min_scatter_categories(
series_list : Array[Series],
cur_min : Int,
idx : Int,
) -> Int {
if idx >= series_list.length() {
cur_min
} else {
let len = series_list[idx].values.length()
let new_min = if len < cur_min { len } else { cur_min }
min_scatter_categories(series_list, new_min, idx + 1)
}
}
// Find max categories across all series (used for X axis extent)
///|
fn max_scatter_categories(
series_list : Array[Series],
cur_max : Int,
idx : Int,
) -> Int {
if idx >= series_list.length() {
cur_max
} else {
let len = series_list[idx].values.length()
let new_max = if len > cur_max { len } else { cur_max }
max_scatter_categories(series_list, new_max, idx + 1)
}
}
// Render circles for a single series
///|
fn render_scatter_circles(
result : String,
values : Array[Float],
num_cat : Int,
axis : AxisLayout,
height : Float,
mt : Float,
mb : Float,
width : Float,
ml : Float,
mr : Float,
color : String,
radius : Float,
idx : Int,
) -> String {
if idx >= num_cat {
result
} else {
let cx = x_to_svg(
Float::from_int(idx),
Float::from_int(num_cat),
width,
ml,
mr,
)
let cy = y_to_svg(values[idx], axis.y_min, axis.y_max, height, mt, mb)
let c = circle(cx, cy, radius, color)
render_scatter_circles(
result + c,
values,
num_cat,
axis,
height,
mt,
mb,
width,
ml,
mr,
color,
radius,
idx + 1,
)
}
}
// Render all series (outer loop over series_list)
///|
fn render_scatter_series(
result : String,
series_list : Array[Series],
num_cat : Int,
axis : AxisLayout,
height : Float,
mt : Float,
mb : Float,
width : Float,
ml : Float,
mr : Float,
point_size : Float,
si : Int,
config : ChartConfig,
) -> String {
if si >= series_list.length() {
result
} else {
let values = series_list[si].values
let color = get_chart_color(config, si)
let with_circles = render_scatter_circles(
result, values, num_cat, axis, height, mt, mb, width, ml, mr, color, point_size,
0,
)
render_scatter_series(
with_circles,
series_list,
num_cat,
axis,
height,
mt,
mb,
width,
ml,
mr,
point_size,
si + 1,
config,
)
}
}
///|
pub fn ScatterChart::render(self : ScatterChart) -> String {
let mt : Float = 40.0
let mb : Float = 60.0
let ml : Float = 60.0
let mr : Float = 30.0
let (d_min, d_max) = find_scatter_data_range(self.series_list)
let data_min = if d_min > 0.0 { Float::from_int(0) } else { d_min }
let axis = compute_y_axis(data_min, d_max, self.height, mt, mb)
let base = svg_open(self.width, self.height)
// Title
let with_title = if self.title != "" {
base +
text(
self.width / 2.0,
mt - 12.0,
self.title,
self.config.title_font_size,
"middle",
)
} else {
base
}
// Y-axis grid lines and labels
let with_y_grid = render_scatter_y_grid(
with_title,
axis,
self.height,
mt,
mb,
self.width,
ml,
mr,
0,
self.config,
)
// X-axis baseline
let baseline_y = y_to_svg(
axis.y_min,
axis.y_min,
axis.y_max,
self.height,
mt,
mb,
)
let with_baseline = with_y_grid +
line(ml, baseline_y, self.width - mr, baseline_y, "#888888", 1.0)
// Y-axis label
let with_y_axis_label = if self.y_label != "" {
with_baseline +
text(
14.0,
mt + (self.height - mt - mb) / 2.0,
self.y_label,
self.config.axis_font_size,
"middle",
)
} else {
with_baseline
}
// X-axis label
let with_x_axis_label = if self.x_label != "" {
with_y_axis_label +
text(
self.width / 2.0,
baseline_y + 36.0,
self.x_label,
self.config.axis_font_size,
"middle",
)
} else {
with_y_axis_label
}
// Scatter circles
let num_max = max_scatter_categories(self.series_list, 0, 0)
let num_cat = min_scatter_categories(self.series_list, num_max, 0)
let with_circles = if num_cat > 0 && self.series_list.length() > 0 {
render_scatter_series(
with_x_axis_label,
self.series_list,
num_cat,
axis,
self.height,
mt,
mb,
self.width,
ml,
mr,
self.point_size,
0,
self.config,
)
} else {
with_x_axis_label
}
// Legend
let names : Array[String] = []
for i = 0; i < self.series_list.length(); i = i + 1 {
names.push(self.series_list[i].name)
}
let with_legend = with_circles +
render_legend(names, self.width, self.height - 10.0, self.config)
with_legend + svg_close()
}