use super::common;
use crate::ir::ChartSpec;
use crate::num::fmt_num;
use crate::scene::{Anchor, Prim, Scene};
use crate::text::TextMeasurer;
use std::fmt::Write;
const MARKER_R: f64 = 3.0;
pub fn line_points(
spec: &crate::ir::ChartSpec,
frame: &common::Frame,
) -> Vec<crate::layout::scatter::PointBox> {
let n = spec.categories.len().max(1);
let mut pts = Vec::new();
for (sidx, ser) in spec.series.iter().enumerate() {
for i in 0..spec.categories.len() {
let x = common::line_category_x(spec, frame, i, n);
let v = ser.values.get(i).copied().unwrap_or(0.0);
pts.push(crate::layout::scatter::PointBox {
series: sidx,
index: i,
kind: "line",
cx: x,
cy: frame.ys.map(v),
r: MARKER_R,
});
}
}
pts
}
pub fn build(spec: &ChartSpec, m: &TextMeasurer) -> Scene {
let frame = common::compute(spec, m);
let mut items: Vec<Prim> = Vec::new();
common::draw_frame(&mut items, spec, &frame, m);
let n = spec.categories.len().max(1);
for ser in &spec.series {
let valid: Vec<(f64, f64, usize)> = (0..spec.categories.len())
.filter_map(|i| {
let v = ser.values.get(i).copied()?;
if !v.is_finite() {
return None;
}
let x = common::line_category_x(spec, &frame, i, n);
Some((x, frame.ys.map(v), i))
})
.collect();
let segments: Vec<Vec<(f64, f64, usize)>> = {
let mut segs: Vec<Vec<(f64, f64, usize)>> = Vec::new();
let mut cur: Vec<(f64, f64, usize)> = Vec::new();
let mut prev_cat: Option<usize> = None;
for &(x, y, cat) in &valid {
if prev_cat.is_some_and(|pc| cat != pc + 1) && !cur.is_empty() {
segs.push(std::mem::take(&mut cur));
}
cur.push((x, y, cat));
prev_cat = Some(cat);
}
if !cur.is_empty() {
segs.push(cur);
}
segs
};
let plot_width = frame.plot_right - frame.plot_left;
let dec = crate::layout::decimate::resolve(&spec.decimation, plot_width, valid.len());
let decimated = dec.is_some();
let segments: Vec<Vec<(f64, f64, usize)>> = if let Some((algo, samples)) = dec {
crate::layout::decimate::decimate_segments(&segments, algo, samples)
} else {
segments
};
let valid: Vec<(f64, f64, usize)> = segments.iter().flatten().copied().collect();
if ser.area && !valid.is_empty() {
let baseline_y = frame
.ys
.map(0.0_f64.clamp(frame.ticks.min, frame.ticks.max));
let mut d = String::new();
for (k, &(x, y, _)) in valid.iter().enumerate() {
let cmd = if k == 0 { 'M' } else { 'L' };
write!(d, "{} {} {} ", cmd, fmt_num(x), fmt_num(y)).unwrap();
}
let (last_x, _, _) = valid[valid.len() - 1];
let (first_x, _, _) = valid[0];
write!(
d,
"L {} {} L {} {} Z",
fmt_num(last_x),
fmt_num(baseline_y),
fmt_num(first_x),
fmt_num(baseline_y)
)
.unwrap();
items.push(Prim::Path {
d,
fill: Some(ser.fill_at(0)),
stroke: None,
stroke_width: 0.0,
});
}
for seg in &segments {
if seg.len() < 2 {
continue;
}
let xy: Vec<(f64, f64)> = seg.iter().map(|&(x, y, _)| (x, y)).collect();
if ser.tension <= 0.0 {
items.push(Prim::Polyline {
points: xy,
stroke: ser.stroke_at(0),
stroke_width: ser.stroke_width,
});
} else {
let d = catmull_rom_path(&xy, ser.tension);
items.push(Prim::Path {
d,
fill: None,
stroke: Some(ser.stroke_at(0)),
stroke_width: ser.stroke_width,
});
}
}
for seg in &segments {
let r = match (decimated, ser.point_radius) {
(false, _) => Some(MARKER_R),
(true, Some(r)) if r > 0.0 => Some(r),
(true, Some(_)) => None,
(true, None) if seg.len() < 2 => Some(MARKER_R),
(true, None) => None,
};
if let Some(r) = r {
for &(cx, cy, _) in seg {
items.push(Prim::Circle {
cx,
cy,
r,
fill: ser.stroke_at(0),
stroke: ser.stroke_at(0),
stroke_width: 0.0,
});
}
}
}
if spec.data_labels {
for &(x, y, cat) in &valid {
items.push(common::value_label(
x,
y - MARKER_R - common::LABEL_GAP,
spec.theme.font_size,
Anchor::Middle,
spec.theme.text_color,
ser.values[cat],
));
}
}
}
Scene {
width: spec.width,
height: spec.height,
items,
}
}
fn catmull_rom_path(pts: &[(f64, f64)], tension: f64) -> String {
let k = pts.len();
let mut d = String::new();
write!(d, "M {} {} ", fmt_num(pts[0].0), fmt_num(pts[0].1)).unwrap();
for i in 0..k - 1 {
let p0 = pts[i.saturating_sub(1)];
let p1 = pts[i];
let p2 = pts[i + 1];
let p3 = pts[(i + 2).min(k - 1)];
let cp1 = (
p1.0 + (p2.0 - p0.0) / 6.0 * tension,
p1.1 + (p2.1 - p0.1) / 6.0 * tension,
);
let cp2 = (
p2.0 - (p3.0 - p1.0) / 6.0 * tension,
p2.1 - (p3.1 - p1.1) / 6.0 * tension,
);
write!(
d,
"C {} {} {} {} {} {} ",
fmt_num(cp1.0),
fmt_num(cp1.1),
fmt_num(cp2.0),
fmt_num(cp2.1),
fmt_num(p2.0),
fmt_num(p2.1)
)
.unwrap();
}
d
}
#[cfg(test)]
mod tests {
use super::*;
use crate::font::DEFAULT_FONT;
use crate::frontend::chartjs;
use crate::layout::common;
use crate::text::TextMeasurer;
fn pts_for(json: &str) -> Vec<crate::layout::scatter::PointBox> {
let spec = chartjs::parse(json, false).unwrap();
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let frame = common::compute(&spec, &m);
line_points(&spec, &frame)
}
#[test]
fn line_points_count_is_series_times_categories() {
let ps = pts_for(
r#"{"type":"line","data":{"labels":["a","b","c","d","e","f","g"],
"datasets":[{"data":[1,2,3,4,5,6,7]},{"data":[7,6,5,4,3,2,1]}]}}"#,
);
assert_eq!(ps.len(), 14);
for p in &ps {
assert_eq!(p.kind, "line");
}
}
#[test]
fn line_points_x_is_edge_to_edge() {
let spec = chartjs::parse(
r#"{"type":"line","data":{"labels":["a","b","c"],
"datasets":[{"data":[10,20,30]}]}}"#,
false,
)
.unwrap();
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let frame = common::compute(&spec, &m);
let ps = line_points(&spec, &frame);
let s0: Vec<_> = ps.iter().filter(|p| p.series == 0).collect();
assert!((s0[0].cx - frame.plot_left).abs() < 1e-9);
assert!((s0[2].cx - frame.plot_right).abs() < 1e-9);
assert!((s0[1].cx - (frame.plot_left + frame.plot_right) / 2.0).abs() < 1e-9);
}
#[test]
fn line_frame_stays_valid_when_edge_labels_exceed_width() {
let mut spec = chartjs::parse(
r#"{"type":"line","data":{"labels":["VeryLongCategoryLabelLeft","VeryLongCategoryLabelRight"],
"datasets":[{"data":[1,2]}]}}"#,
false,
)
.unwrap();
spec.width = 60.0; let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let frame = common::compute(&spec, &m);
assert!(
frame.plot_right >= frame.plot_left,
"plot area inverted: left={} right={}",
frame.plot_left,
frame.plot_right
);
let n = spec.categories.len();
let x0 = common::line_x(&frame, 0, n);
let x_last = common::line_x(&frame, n - 1, n);
assert!(x0.is_finite() && x_last.is_finite());
assert!(x_last >= x0);
}
#[test]
fn line_points_cx_monotone_with_category_order() {
let ps = pts_for(
r#"{"type":"line","data":{"labels":["a","b","c"],
"datasets":[{"data":[10,20,30]}]}}"#,
);
let ser0: Vec<_> = ps.iter().filter(|p| p.series == 0).collect();
assert!(ser0[0].cx < ser0[1].cx && ser0[1].cx < ser0[2].cx);
}
#[test]
fn line_points_cy_tracks_value() {
let ps = pts_for(
r#"{"type":"line","data":{"labels":["a","b"],
"datasets":[{"data":[10,100]}]}}"#,
);
let ser0: Vec<_> = ps.iter().filter(|p| p.series == 0).collect();
assert!(
ser0[1].cy < ser0[0].cy,
"大きい値は小さい cy(上方向): ser0[0].cy={}, ser0[1].cy={}",
ser0[0].cy,
ser0[1].cy
);
}
#[test]
fn line_points_x_is_band_centered_when_offset() {
let spec = chartjs::parse(
r#"{"type":"line","data":{"labels":["a","b","c"],
"datasets":[{"data":[10,20,30]}]},
"options":{"scales":{"x":{"offset":true}}}}"#,
false,
)
.unwrap();
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let frame = common::compute(&spec, &m);
let ps = line_points(&spec, &frame);
let s0: Vec<_> = ps.iter().filter(|p| p.series == 0).collect();
let band_w = (frame.plot_right - frame.plot_left) / 3.0;
for (i, p) in s0.iter().enumerate() {
let expect = frame.plot_left + (i as f64 + 0.5) * band_w;
assert!(
(p.cx - expect).abs() < 1e-9,
"offset:true の点は band 中心: i={i} cx={} expect={expect}",
p.cx
);
}
assert!(s0[0].cx > frame.plot_left);
assert!(s0[2].cx < frame.plot_right);
}
#[test]
fn offset_line_skips_edge_padding() {
let parse = |opts: &str| {
chartjs::parse(
&format!(
r#"{{"type":"line","data":{{"labels":["Jan","Feb","Mar"],
"datasets":[{{"data":[10,20,30]}}]}}{opts}}}"#
),
false,
)
.unwrap()
};
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let edge = common::compute(&parse(""), &m);
let off = common::compute(&parse(r#","options":{"scales":{"x":{"offset":true}}}"#), &m);
assert!(
off.plot_right > edge.plot_right,
"offset:true は端余白を取らないため plot_right がより外側: off={} edge={}",
off.plot_right,
edge.plot_right
);
}
#[test]
fn offset_line_labels_align_to_band_centers() {
let spec = chartjs::parse(
r#"{"type":"line","data":{"labels":["a","b","c"],
"datasets":[{"data":[10,20,30]}]},
"options":{"scales":{"x":{"offset":true}}}}"#,
false,
)
.unwrap();
let m = TextMeasurer::new(DEFAULT_FONT).unwrap();
let frame = common::compute(&spec, &m);
let scene = build(&spec, &m);
let label_xs: Vec<f64> = scene
.items
.iter()
.filter_map(|p| match p {
Prim::Text {
x,
anchor: Anchor::Middle,
..
} => Some(*x),
_ => None,
})
.collect();
assert_eq!(label_xs.len(), 3, "x ラベルは 3 個");
let band_w = (frame.plot_right - frame.plot_left) / 3.0;
for (i, &x) in label_xs.iter().enumerate() {
let expect = frame.plot_left + (i as f64 + 0.5) * band_w;
assert!(
(x - expect).abs() < 1e-9,
"offset ラベルは band 中心: i={i} x={x} expect={expect}"
);
}
}
}