use super::common::{
LEGEND_BAND, OUTER_PAD, TEXT_BASELINE_RATIO, TITLE_BAND, TITLE_FONT, X_LABEL_BAND,
X_LABEL_CENTER_RATIO, draw_vertical_legend, legend_band_width_vertical, legend_entry_width,
};
use crate::ir::{AxisSpec, ChartKind, ChartSpec, Color, LegendPos, Point};
use crate::num::fmt_num;
use crate::scale::{LinearScale, NiceTicks, nice_ticks};
use crate::scene::{Anchor, Prim, Scene};
use crate::text::TextMeasurer;
const DEFAULT_POINT_R: f64 = 3.0;
const DEFAULT_BUBBLE_R: f64 = 5.0;
#[derive(Debug, Clone, PartialEq)]
pub struct PointBox {
pub series: usize,
pub index: usize,
pub kind: &'static str, pub cx: f64,
pub cy: f64,
pub r: f64,
}
#[derive(Debug, Clone)]
pub struct ScatterLayout {
pub xs: LinearScale,
pub ys: LinearScale,
pub x_ticks: NiceTicks,
pub y_ticks: NiceTicks,
pub plot_left: f64,
pub plot_right: f64,
pub plot_top: f64,
pub plot_bottom: f64,
}
pub fn compute_scatter_layout(spec: &ChartSpec, m: &TextMeasurer) -> ScatterLayout {
let label_font = spec.theme.font_size;
let (xmin, xmax) = axis_domain(spec, &spec.x_axis, |p| p.x);
let (ymin, ymax) = axis_domain(spec, &spec.y_axis, |p| p.y);
let x_ticks = nice_ticks(xmin, xmax, 10);
let y_ticks = nice_ticks(ymin, ymax, 10);
let mut max_y_w = 0.0_f32;
for &t in &y_ticks.ticks {
let w = m.width(&crate::num::fmt_num(t), label_font as f32);
if w > max_y_w {
max_y_w = w;
}
}
let y_axis_w = max_y_w as f64 + 10.0;
let legend = has_legend(spec);
let title_band = if spec.title.is_some() {
TITLE_BAND
} else {
0.0
};
let legend_top = if legend && spec.legend == LegendPos::Top {
LEGEND_BAND
} else {
0.0
};
let legend_bottom = if legend && spec.legend == LegendPos::Bottom {
LEGEND_BAND
} else {
0.0
};
let (legend_left, legend_right_w) =
if legend && (spec.legend == LegendPos::Left || spec.legend == LegendPos::Right) {
let series_names: Vec<String> = spec.series.iter().map(|s| s.name.clone()).collect();
let w = legend_band_width_vertical(m, &series_names, label_font);
if spec.legend == LegendPos::Left {
(w, 0.0)
} else {
(0.0, w)
}
} else {
(0.0, 0.0)
};
let plot_left = OUTER_PAD + y_axis_w + legend_left;
let plot_right = spec.width - OUTER_PAD - legend_right_w;
let plot_top = OUTER_PAD + title_band + legend_top;
let plot_bottom = spec.height - OUTER_PAD - X_LABEL_BAND - legend_bottom;
ScatterLayout {
xs: LinearScale::new(x_ticks.min, x_ticks.max, plot_left, plot_right),
ys: LinearScale::new(y_ticks.min, y_ticks.max, plot_bottom, plot_top),
x_ticks,
y_ticks,
plot_left,
plot_right,
plot_top,
plot_bottom,
}
}
pub fn scatter_points(spec: &ChartSpec, layout: &ScatterLayout) -> Vec<PointBox> {
let kind = match &spec.kind {
ChartKind::Bubble => "bubble",
_ => "scatter",
};
let mut pts = Vec::new();
for (sidx, ser) in spec.series.iter().enumerate() {
for (i, p) in ser.points.iter().enumerate() {
if !p.x.is_finite() || !p.y.is_finite() {
continue;
}
pts.push(PointBox {
series: sidx,
index: i,
kind,
cx: layout.xs.map(p.x),
cy: layout.ys.map(p.y),
r: point_radius(&spec.kind, p, ser.point_radius),
});
}
}
pts
}
fn point_radius(kind: &ChartKind, point: &Point, dataset_radius: Option<f64>) -> f64 {
let valid = |r: f64, fallback: f64| {
if r.is_finite() && r >= 0.0 {
r
} else {
fallback
}
};
match kind {
ChartKind::Bubble => {
let r = point.r.or(dataset_radius).unwrap_or(DEFAULT_BUBBLE_R);
valid(r, DEFAULT_BUBBLE_R)
}
_ => valid(dataset_radius.unwrap_or(DEFAULT_POINT_R), DEFAULT_POINT_R),
}
}
fn has_legend(spec: &ChartSpec) -> bool {
matches!(
spec.legend,
LegendPos::Top | LegendPos::Bottom | LegendPos::Left | LegendPos::Right
) && spec.series.iter().any(|s| !s.name.is_empty())
}
pub(crate) fn axis_domain(
spec: &ChartSpec,
axis_spec: &AxisSpec,
select: impl Fn(&Point) -> f64,
) -> (f64, f64) {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for s in &spec.series {
for p in &s.points {
let v = select(p);
if v.is_finite() {
if v < lo {
lo = v;
}
if v > hi {
hi = v;
}
}
}
}
if !lo.is_finite() || !hi.is_finite() {
lo = axis_spec
.suggested_min
.filter(|s| s.is_finite())
.unwrap_or(0.0);
hi = axis_spec
.suggested_max
.filter(|s| s.is_finite())
.unwrap_or(if lo == 0.0 { 1.0 } else { lo + 1.0 });
if axis_spec.begin_at_zero {
lo = lo.min(0.0);
hi = hi.max(0.0);
}
return (lo, if hi > lo { hi } else { lo + 1.0 });
}
if axis_spec.begin_at_zero {
lo = lo.min(0.0);
hi = hi.max(0.0);
}
if let Some(s) = axis_spec.suggested_min
&& s.is_finite()
&& s < lo
{
lo = s;
}
if let Some(s) = axis_spec.suggested_max
&& s.is_finite()
&& s > hi
{
hi = s;
}
(lo, hi)
}
pub fn build(spec: &ChartSpec, m: &TextMeasurer) -> Scene {
let ink = spec.theme.text_color;
let label_font = spec.theme.font_size;
let layout = compute_scatter_layout(spec, m);
let xs = layout.xs.clone();
let ys = layout.ys.clone();
let plot_left = layout.plot_left;
let plot_right = layout.plot_right;
let plot_top = layout.plot_top;
let plot_bottom = layout.plot_bottom;
let x_ticks = &layout.x_ticks;
let y_ticks = &layout.y_ticks;
let legend = has_legend(spec);
let title_band = if spec.title.is_some() {
TITLE_BAND
} else {
0.0
};
let legend_right = if legend && spec.legend == LegendPos::Right {
let series_names: Vec<String> = spec.series.iter().map(|s| s.name.clone()).collect();
legend_band_width_vertical(m, &series_names, label_font)
} else {
0.0
};
let mut items: Vec<Prim> = Vec::new();
if let Some(title) = &spec.title {
items.push(Prim::Text {
x: spec.width / 2.0,
y: OUTER_PAD + TITLE_FONT,
size: TITLE_FONT,
anchor: Anchor::Middle,
fill: ink,
content: title.clone(),
rotate_deg: None,
});
}
for &t in &y_ticks.ticks {
let y = ys.map(t);
items.push(Prim::Line {
x1: plot_left,
y1: y,
x2: plot_right,
y2: y,
stroke: spec.theme.grid_color,
stroke_width: 1.0,
});
items.push(Prim::Text {
x: plot_left - 6.0,
y: y + label_font * TEXT_BASELINE_RATIO,
size: label_font,
anchor: Anchor::End,
fill: ink,
content: fmt_num(t),
rotate_deg: None,
});
}
for &t in &x_ticks.ticks {
let x = xs.map(t);
items.push(Prim::Line {
x1: x,
y1: plot_top,
x2: x,
y2: plot_bottom,
stroke: spec.theme.grid_color,
stroke_width: 1.0,
});
items.push(Prim::Text {
x,
y: plot_bottom + X_LABEL_BAND * X_LABEL_CENTER_RATIO,
size: label_font,
anchor: Anchor::Middle,
fill: ink,
content: fmt_num(t),
rotate_deg: None,
});
}
items.push(Prim::Line {
x1: plot_left,
y1: plot_bottom,
x2: plot_right,
y2: plot_bottom,
stroke: ink,
stroke_width: 1.0,
});
items.push(Prim::Line {
x1: plot_left,
y1: plot_top,
x2: plot_left,
y2: plot_bottom,
stroke: ink,
stroke_width: 1.0,
});
for b in scatter_points(spec, &layout) {
let ser = &spec.series[b.series];
items.push(Prim::Circle {
cx: b.cx,
cy: b.cy,
r: b.r,
fill: ser.fill_at(b.index),
stroke: ser.stroke_at(b.index),
stroke_width: ser.stroke_width,
});
}
if legend && matches!(spec.legend, LegendPos::Top | LegendPos::Bottom) {
let mut total = 0.0_f64;
for (k, ser) in spec.series.iter().enumerate() {
let ew = legend_entry_width(m, &ser.name, label_font);
total += ew;
if k == spec.series.len() - 1 {
total -= 16.0;
}
}
let start_x = (spec.width - total) / 2.0;
let legend_cy = if spec.legend == LegendPos::Top {
OUTER_PAD + title_band + LEGEND_BAND / 2.0
} else {
spec.height - OUTER_PAD - LEGEND_BAND / 2.0
};
let mut cursor = start_x;
for ser in &spec.series {
items.push(Prim::Rect {
x: cursor,
y: legend_cy - 6.0,
w: 12.0,
h: 12.0,
fill: ser.fill_at(0),
});
items.push(Prim::Text {
x: cursor + 16.0,
y: legend_cy + label_font * TEXT_BASELINE_RATIO,
size: label_font,
anchor: Anchor::Start,
fill: ink,
content: ser.name.clone(),
rotate_deg: None,
});
cursor += legend_entry_width(m, &ser.name, label_font);
}
}
if legend && matches!(spec.legend, LegendPos::Left | LegendPos::Right) {
let entries: Vec<(String, Color)> = spec
.series
.iter()
.map(|s| (s.name.clone(), s.fill_at(0)))
.collect();
let band_x = if spec.legend == LegendPos::Left {
OUTER_PAD
} else {
spec.width - OUTER_PAD - legend_right
};
draw_vertical_legend(
&mut items,
&entries,
band_x,
plot_top,
plot_bottom,
ink,
label_font,
);
}
Scene {
width: spec.width,
height: spec.height,
items,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::font::DEFAULT_FONT;
use crate::ir::{AxisSpec, ChartKind, ChartSpec, LegendPos, Point, Series, SeriesType};
use crate::text::TextMeasurer;
fn make_scatter_spec(points: &[(f64, f64)]) -> ChartSpec {
let palette = crate::palette::PALETTE.to_vec();
ChartSpec {
kind: ChartKind::Scatter,
categories: vec![],
series: vec![Series {
name: String::new(),
values: vec![],
points: points
.iter()
.map(|&(x, y)| Point { x, y, r: None })
.collect(),
fill: vec![palette[0]],
stroke: vec![],
stroke_width: 1.0,
area: false,
tension: 0.0,
series_type: SeriesType::Bar,
point_radius: None,
box_points: vec![],
tree: vec![],
links: vec![],
}],
x_axis: AxisSpec {
title: None,
min: None,
max: None,
suggested_min: None,
suggested_max: None,
begin_at_zero: false,
offset: false,
grid: true,
},
y_axis: AxisSpec {
title: None,
min: None,
max: None,
suggested_min: None,
suggested_max: None,
begin_at_zero: false,
offset: false,
grid: true,
},
legend: LegendPos::None,
title: None,
width: 600.0,
height: 400.0,
data_labels: false,
theme: crate::ir::Theme::default(),
decimation: crate::ir::Decimation::default(),
}
}
#[test]
fn axis_domain_suggested_min_expands_below_data() {
let mut spec = make_scatter_spec(&[(1.0, 0.0), (10.0, 0.0)]);
spec.x_axis.suggested_min = Some(-5.0);
let (lo, _hi) = axis_domain(&spec, &spec.x_axis, |p| p.x);
assert_eq!(
lo, -5.0,
"suggested_min=-5 はドメインを正確に -5.0 に設定すべき: 実際 lo={lo}"
);
}
#[test]
fn axis_domain_suggested_min_noop_when_data_lower() {
let mut spec = make_scatter_spec(&[(1.0, 0.0), (10.0, 0.0)]);
spec.x_axis.suggested_min = Some(5.0);
let (lo, _hi) = axis_domain(&spec, &spec.x_axis, |p| p.x);
assert_eq!(
lo, 1.0,
"suggested_min=5 はデータの下端(1.0)を維持すべき: 実際 lo={lo}"
);
}
#[test]
fn axis_domain_suggested_max_expands_above_data() {
let mut spec = make_scatter_spec(&[(1.0, 0.0), (10.0, 0.0)]);
spec.x_axis.suggested_max = Some(15.0);
let (_lo, hi) = axis_domain(&spec, &spec.x_axis, |p| p.x);
assert_eq!(
hi, 15.0,
"suggested_max=15 はドメインを正確に 15.0 に設定すべき: 実際 hi={hi}"
);
}
#[test]
fn axis_domain_suggested_max_noop_when_data_higher() {
let mut spec = make_scatter_spec(&[(1.0, 0.0), (10.0, 0.0)]);
spec.x_axis.suggested_max = Some(5.0);
let (_lo, hi) = axis_domain(&spec, &spec.x_axis, |p| p.x);
assert_eq!(
hi, 10.0,
"suggested_max=5 はデータの上端(10.0)を縮小してはいけない: 実際 hi={hi}"
);
}
#[test]
fn scatter_points_covers_all_series_and_indices() {
let spec = make_scatter_spec(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]);
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let layout = compute_scatter_layout(&spec, &m);
let pts = scatter_points(&spec, &layout);
assert_eq!(pts.len(), 3);
for (i, p) in pts.iter().enumerate() {
assert_eq!(p.series, 0);
assert_eq!(p.index, i);
assert_eq!(p.kind, "scatter");
}
}
#[test]
fn scatter_points_cx_monotone_with_x_values() {
let spec = make_scatter_spec(&[(1.0, 0.0), (5.0, 0.0), (10.0, 0.0)]);
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let layout = compute_scatter_layout(&spec, &m);
let pts = scatter_points(&spec, &layout);
assert!(pts[0].cx < pts[1].cx && pts[1].cx < pts[2].cx);
}
#[test]
fn scatter_points_skips_non_finite() {
let spec = make_scatter_spec(&[(1.0, 2.0), (f64::NAN, 3.0), (5.0, 6.0)]);
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let layout = compute_scatter_layout(&spec, &m);
let pts = scatter_points(&spec, &layout);
assert_eq!(pts.len(), 2);
}
}