use crate::aes::Aesthetic;
use crate::coord::Coord;
use crate::data::{DataFrame, Value};
use crate::position::identity::PositionIdentity;
use crate::position::Position;
use crate::render::backend::{
DrawBackend, FontFace, LineStyle, Linetype, PointStyle, RectStyle, TextAnchor, TextStyle,
};
use crate::render::RenderError;
use crate::scale::ScaleSet;
use crate::stat::identity::StatIdentity;
use crate::stat::Stat;
use crate::theme::Theme;
use super::{Geom, GeomParams};
pub struct GeomTimeline {
pub end: Option<String>,
pub color: (u8, u8, u8),
pub line_color: (u8, u8, u8),
pub label_size: f64,
pub label_color: (u8, u8, u8),
pub box_fill: (u8, u8, u8),
pub spacing: f64,
}
impl Default for GeomTimeline {
fn default() -> Self {
GeomTimeline {
end: None,
color: (31, 119, 180),
line_color: (120, 120, 120),
label_size: 9.5,
label_color: (30, 30, 30),
box_fill: (255, 255, 255),
spacing: 6.0,
}
}
}
impl GeomTimeline {
pub fn with_end(mut self, column: impl Into<String>) -> Self {
self.end = Some(column.into());
self
}
}
fn level(k: usize) -> i32 {
let n = (k / 2 + 1) as i32;
if k.is_multiple_of(2) {
n
} else {
-n
}
}
pub(crate) fn pick_level(
occupied: &mut Vec<(i32, f64, f64)>,
l: f64,
r: f64,
max_levels: usize,
) -> Option<i32> {
(0..2 * max_levels.max(1)).map(level).find(|&lv| {
let free = !occupied.iter().any(|&(o, a, b)| o == lv && l < b && a < r);
if free {
occupied.push((lv, l, r));
}
free
})
}
impl Geom for GeomTimeline {
fn draw(
&self,
data: &DataFrame,
coord: &dyn Coord,
scales: &ScaleSet,
theme: &Theme,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let x_col = data
.column("x")
.ok_or(RenderError::MissingAesthetic("x".into()))?;
let label = data.column("label");
let xend = data.column("xmax");
let color_col = data.column("color");
let area = backend.plot_area();
let mx = |v: &Value| scales.get(&Aesthetic::X).map(|s| s.map(v)).unwrap_or(0.5);
let px = |nx: f64| coord.transform((nx, 0.5), &area).0;
let base = area.y + area.height / 2.0;
backend.draw_line(
&[(area.x, base), (area.x + area.width, base)],
&LineStyle {
color: self.line_color,
alpha: 1.0,
width: 1.5,
linetype: Linetype::Solid,
..Default::default()
},
)?;
let box_h = self.label_size + 8.0;
let step = box_h + 6.0;
let max_levels = (((area.height / 2.0) - 12.0) / step).floor().max(1.0) as usize;
let mut order: Vec<usize> = (0..data.nrows()).collect();
order.sort_by(|&a, &b| mx(&x_col[a]).total_cmp(&mx(&x_col[b])));
let mut occupied = Vec::new();
let family = super::resolve_font_family("", theme);
for i in order {
let x = px(mx(&x_col[i]));
if !x.is_finite() {
continue;
}
let color = color_col
.and_then(|c| scales.map_color(&Aesthetic::Color, &c[i]))
.unwrap_or(self.color);
let text = label.map(|c| super::tip_value(&c[i])).unwrap_or_default();
let when = super::tip_value(&x_col[i]);
let tip = match xend.map(|c| &c[i]).filter(|v| !v.is_na()) {
Some(e) => format!("{when} – {}: {text}", super::tip_value(e)),
None => format!("{when}: {text}"),
};
super::set_mark(
backend,
Some(tip),
Some(when),
super::series_key(data, i),
None,
);
super::set_row_extras(backend, data, i);
if let Some(xe) = xend.and_then(|c| (!c[i].is_na()).then(|| px(mx(&c[i])))) {
if xe.is_finite() {
backend.draw_rect(
(x.min(xe), base - 4.0),
(x.max(xe), base + 4.0),
&RectStyle {
fill: Some(color),
stroke: None,
stroke_width: 0.0,
alpha: 0.5,
clip: true,
corner_radius: 4.0,
},
)?;
}
}
backend.draw_circle(
(x, base),
4.5,
&PointStyle {
color,
alpha: 1.0,
filled: true,
..Default::default()
},
)?;
if text.is_empty() {
continue;
}
let w = crate::render::text::text_width(&text, self.label_size) + 10.0;
let cx = x.clamp(area.x + w / 2.0, area.x + area.width - w / 2.0);
let Some(lv) = pick_level(
&mut occupied,
cx - w / 2.0 - self.spacing / 2.0,
cx + w / 2.0 + self.spacing / 2.0,
max_levels,
) else {
continue;
};
let cy = base - lv as f64 * step;
let edge = if lv > 0 {
cy + box_h / 2.0
} else {
cy - box_h / 2.0
};
super::clear_mark(backend);
backend.draw_line(
&[(x, base), (x, edge)],
&LineStyle {
color,
alpha: 0.8,
width: 1.0,
linetype: Linetype::Solid,
..Default::default()
},
)?;
backend.draw_rect(
(cx - w / 2.0, cy - box_h / 2.0),
(cx + w / 2.0, cy + box_h / 2.0),
&RectStyle {
fill: Some(self.box_fill),
stroke: Some(color),
stroke_width: 1.0,
alpha: 1.0,
clip: false,
corner_radius: 3.0,
},
)?;
backend.draw_text(
&text,
(cx, cy),
&TextStyle {
color: self.label_color,
size: self.label_size,
anchor: TextAnchor::Middle,
angle: 0.0,
family: family.clone(),
face: FontFace::Plain,
bg_colour: None,
bg_r: 0.0,
},
)?;
}
super::clear_mark(backend);
super::clear_row_extras(backend);
Ok(())
}
fn required_aes(&self) -> Vec<Aesthetic> {
vec![Aesthetic::X]
}
fn setup_data(&self, data: &mut DataFrame, _warnings: &mut Vec<String>) {
let n = data.nrows();
for (c, v) in [("ymin", -1.0), ("ymax", 1.0)] {
if !data.has_column(c) {
data.add_column(c.into(), vec![Value::Float(v); n]);
}
}
if let Some(end) = self.end.as_deref().and_then(|c| data.column(c)) {
let end = end.to_vec();
if !data.has_column("xmax") {
data.add_column("xmax".into(), end);
}
}
}
fn default_stat(&self) -> Box<dyn Stat> {
Box::new(StatIdentity)
}
fn default_position(&self) -> Box<dyn Position> {
Box::new(PositionIdentity)
}
fn default_params(&self) -> GeomParams {
GeomParams::default()
}
fn name(&self) -> &str {
"timeline"
}
fn consumes_tooltips(&self) -> bool {
true
}
fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
if let Some(ink) = el.ink {
self.label_color = ink;
}
if let (true, Some(paper)) = (self.box_fill == (255, 255, 255), el.paper) {
self.box_fill = paper;
}
}
fn set_series_color(&mut self, color: (u8, u8, u8)) {
self.color = color;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_alternate_and_avoid_overlap() {
assert_eq!((0..4).map(level).collect::<Vec<_>>(), vec![1, -1, 2, -2]);
let mut occ = Vec::new();
assert_eq!(pick_level(&mut occ, 0.0, 10.0, 3), Some(1));
assert_eq!(pick_level(&mut occ, 5.0, 15.0, 3), Some(-1));
assert_eq!(pick_level(&mut occ, 8.0, 12.0, 3), Some(2));
assert_eq!(pick_level(&mut occ, 20.0, 30.0, 3), Some(1), "free again");
assert_eq!(
pick_level(&mut occ, 0.0, 30.0, 1),
None,
"only ±1 allowed, both busy"
);
}
}