use std::collections::HashMap;
use tdsl_core::ir::{Item, Lane, TimelineIr, end_frac, start_frac};
pub(crate) const LANE_PALETTE: &[&str] = &[
"#4682B4", "#E67E22", "#27AE60", "#8E44AD", "#E74C3C", "#1ABC9C", "#F39C12", "#2980B9", ];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Orientation {
#[default]
Horizontal,
Vertical,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Theme {
#[default]
Default,
Dark,
Print,
Pastel,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum GridStyle {
#[default]
None,
Decade,
Year,
Month,
}
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub scale: f64,
pub lane_height: f64,
pub left_gutter: f64,
pub top_margin: f64,
pub right_margin: f64,
pub bottom_margin: f64,
pub theme: Theme,
pub custom_css: Option<String>,
pub color_map: std::collections::HashMap<String, String>,
pub interactive: bool,
pub font_family: Option<String>,
pub orientation: Orientation,
pub grid: GridStyle,
pub show_table: bool,
pub show_event_labels: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
scale: 2.0,
lane_height: 60.0,
left_gutter: 120.0,
top_margin: 40.0,
right_margin: 20.0,
bottom_margin: 20.0,
theme: Theme::Default,
custom_css: None,
color_map: std::collections::HashMap::new(),
interactive: false,
font_family: None,
orientation: Orientation::Horizontal,
grid: GridStyle::None,
show_table: false,
show_event_labels: false,
}
}
}
#[derive(Debug, Clone)]
pub struct LaneBandModel {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub even: bool,
}
#[derive(Debug, Clone)]
pub enum LaidItem<'a> {
Span {
item: &'a Item,
x: f64,
y: f64,
width: f64,
height: f64,
color: String,
tooltip: String,
},
EventRange {
item: &'a Item,
x: f64,
y: f64,
width: f64,
height: f64,
color: String,
tooltip: String,
},
Event {
item: &'a Item,
x: f64,
y_top: f64,
y_bottom: f64,
y_dot: f64,
color: String,
tooltip: String,
},
}
pub struct LayoutModel<'a> {
pub ir: &'a TimelineIr,
pub opts: RenderOptions,
pub year_min: i64,
pub year_max: i64,
pub total_width: f64,
pub total_height: f64,
pub lanes_ordered: Vec<&'a Lane>,
pub lane_y: HashMap<String, f64>,
pub tick_step: i64,
pub items: Vec<LaidItem<'a>>,
pub lane_bands: Vec<LaneBandModel>,
pub lane_colors: HashMap<String, String>,
}
impl<'a> LayoutModel<'a> {
pub fn compute(ir: &'a TimelineIr, opts: RenderOptions) -> Self {
let (year_min, year_max) = ir.meta.range;
let (year_min, year_max) = if year_max > year_min {
(year_min, year_max)
} else if year_max == year_min {
(year_min, year_max + 1)
} else {
derive_range_from_items(ir).unwrap_or((0, 2000))
};
let mut lanes_ordered: Vec<&Lane> = ir.lanes.iter().collect();
lanes_ordered.sort_by_key(|l| (l.order, l.id.clone()));
let is_vertical = opts.orientation == Orientation::Vertical;
let n_lanes = lanes_ordered.len();
let time_span = (year_max - year_min) as f64;
let mut lane_y = HashMap::new();
if is_vertical {
for (idx, lane) in lanes_ordered.iter().enumerate() {
let center = opts.left_gutter + (idx as f64 + 0.5) * opts.lane_height;
lane_y.insert(lane.id.clone(), center);
}
} else {
for (idx, lane) in lanes_ordered.iter().enumerate() {
let center = opts.top_margin + (idx as f64 + 0.5) * opts.lane_height;
lane_y.insert(lane.id.clone(), center);
}
}
let (total_width, total_height) = if is_vertical {
let w = opts.left_gutter + n_lanes as f64 * opts.lane_height + opts.right_margin;
let h = opts.top_margin + time_span * opts.scale + opts.bottom_margin;
(w, h)
} else {
let w = opts.left_gutter + time_span * opts.scale + opts.right_margin;
let h = opts.top_margin + n_lanes as f64 * opts.lane_height + opts.bottom_margin;
(w, h)
};
let tick_step = pick_tick_step(year_max - year_min, opts.scale, AXIS_LABEL_PX);
let lane_colors: HashMap<String, String> = lanes_ordered
.iter()
.enumerate()
.map(|(idx, lane)| {
(
lane.id.clone(),
LANE_PALETTE[idx % LANE_PALETTE.len()].to_string(),
)
})
.collect();
let lane_bands: Vec<LaneBandModel> = if is_vertical {
let content_height = total_height - opts.top_margin - opts.bottom_margin;
lanes_ordered
.iter()
.enumerate()
.map(|(idx, _lane)| LaneBandModel {
x: opts.left_gutter + idx as f64 * opts.lane_height,
y: opts.top_margin,
width: opts.lane_height,
height: content_height,
even: idx % 2 == 0,
})
.collect()
} else {
let content_width = total_width - opts.left_gutter - opts.right_margin;
lanes_ordered
.iter()
.enumerate()
.map(|(idx, _lane)| LaneBandModel {
x: opts.left_gutter,
y: opts.top_margin + idx as f64 * opts.lane_height,
width: content_width,
height: opts.lane_height,
even: idx % 2 == 0,
})
.collect()
};
let mut items = Vec::new();
for item in &ir.items {
let lane_id = item_lane_id(item);
let Some(&lane_axis) = lane_y.get(lane_id) else {
continue;
};
let item_tags = get_item_tags(item);
let color = resolve_item_color(item_tags, &opts.color_map, lane_id, &lane_colors);
let tooltip = item_tooltip(item);
compute_item(
item,
&mut items,
ItemLayoutArgs {
lane_axis,
year_min,
year_max,
opts: &opts,
orientation: opts.orientation.clone(),
color,
tooltip,
},
);
}
Self {
ir,
opts,
year_min,
year_max,
total_width,
total_height,
lanes_ordered,
lane_y,
tick_step,
items,
lane_bands,
lane_colors,
}
}
pub fn is_vertical(&self) -> bool {
self.opts.orientation == Orientation::Vertical
}
pub fn year_to_primary(&self, year: i64) -> f64 {
if self.is_vertical() {
self.opts.top_margin + (year - self.year_min) as f64 * self.opts.scale
} else {
year_to_x(year, self.year_min, self.opts.scale, self.opts.left_gutter)
}
}
pub fn year_to_x(&self, year: i64) -> f64 {
year_to_x(year, self.year_min, self.opts.scale, self.opts.left_gutter)
}
pub fn month_ticks(&self) -> Vec<(i64, u8)> {
if self.ir.meta.unit != "month" {
return Vec::new();
}
if self.opts.scale / 12.0 < 1.0 {
return Vec::new();
}
let mut ticks = Vec::new();
for year in self.year_min..=self.year_max {
for month in 2u8..=12 {
let frac = to_year_frac(year, Some(month), None);
if frac < self.year_max as f64 {
ticks.push((year, month));
}
}
}
ticks
}
pub fn frac_year_to_x(&self, year: i64, month: u8) -> f64 {
let frac = to_year_frac(year, Some(month), None);
frac_to_x(frac, self.year_min, self.opts.scale, self.opts.left_gutter)
}
pub fn day_frac_to_x(&self, year: i64, month: u8, day: u8) -> f64 {
let frac = to_year_frac(year, Some(month), Some(day));
frac_to_x(frac, self.year_min, self.opts.scale, self.opts.left_gutter)
}
pub fn day_ticks(&self) -> Vec<(i64, u8, u8)> {
if self.ir.meta.unit != "day" {
return Vec::new();
}
let pixels_per_day = self.opts.scale / 365.25;
if pixels_per_day < 0.5 {
return Vec::new();
}
let step = if pixels_per_day >= 6.0 {
1
} else if pixels_per_day >= 3.0 {
2
} else if pixels_per_day >= 1.5 {
7
} else {
30
};
let mut ticks = Vec::new();
for year in self.year_min..=self.year_max {
for month in 1u8..=12 {
let last = tdsl_core::ir::days_in_month(year, month);
let mut day = 1u8;
while day <= last {
if day == 1 || ((day - 1) as usize).is_multiple_of(step) {
let frac = to_year_frac(year, Some(month), Some(day));
if frac < self.year_max as f64 {
ticks.push((year, month, day));
}
}
day = day.saturating_add(1);
if day == 0 {
break;
}
}
}
}
ticks
}
pub fn ticks(&self) -> Vec<i64> {
let step = self.tick_step.max(1);
let first = div_floor(self.year_min, step) * step;
let mut ticks = Vec::new();
let mut y = first;
while y <= self.year_max {
if y >= self.year_min {
ticks.push(y);
}
y += step;
}
ticks
}
pub fn grid_positions(&self) -> Vec<f64> {
match self.opts.grid {
GridStyle::None => Vec::new(),
GridStyle::Decade => {
let first = div_floor(self.year_min, 10) * 10;
let mut positions = Vec::new();
let mut y = first;
while y <= self.year_max {
if y >= self.year_min {
positions.push(y as f64);
}
y += 10;
}
positions
}
GridStyle::Year => (self.year_min..=self.year_max).map(|y| y as f64).collect(),
GridStyle::Month => {
let mut positions = Vec::new();
for year in self.year_min..=self.year_max {
for month in 0u8..12 {
let frac = year as f64 + month as f64 / 12.0;
if frac >= self.year_min as f64 && frac <= self.year_max as f64 {
positions.push(frac);
}
}
}
positions
}
}
}
}
struct ItemLayoutArgs<'a> {
lane_axis: f64,
year_min: i64,
year_max: i64,
opts: &'a RenderOptions,
orientation: Orientation,
color: String,
tooltip: String,
}
fn compute_item<'a>(item: &'a Item, items: &mut Vec<LaidItem<'a>>, args: ItemLayoutArgs<'_>) {
let ItemLayoutArgs {
lane_axis,
year_min,
year_max,
opts,
orientation,
color,
tooltip,
} = args;
let is_vertical = orientation == Orientation::Vertical;
let primary_anchor = if is_vertical {
opts.top_margin
} else {
opts.left_gutter
};
match item {
Item::Span {
start,
end,
start_month,
start_day,
end_month,
end_day,
..
} => {
let sf = start_frac(*start, *start_month, *start_day);
let ef = end_frac(*end, *end_month, *end_day);
let (primary_start, primary_extent) =
primary_axis_segment(sf, ef, year_min, year_max, opts.scale, primary_anchor);
let cross_start = lane_axis - SPAN_HALF_H;
let cross_extent = SPAN_HALF_H * 2.0;
let (x, y, width, height) = if is_vertical {
(cross_start, primary_start, cross_extent, primary_extent)
} else {
(primary_start, cross_start, primary_extent, cross_extent)
};
items.push(LaidItem::Span {
item,
x,
y,
width,
height,
color,
tooltip,
});
}
Item::EventRange {
start,
end,
start_month,
start_day,
end_month,
end_day,
..
} => {
let sf = start_frac(*start, *start_month, *start_day);
let ef = end_frac(*end, *end_month, *end_day);
let (primary_start, primary_extent) =
primary_axis_segment(sf, ef, year_min, year_max, opts.scale, primary_anchor);
let (x, y, width, height) = if is_vertical {
(
lane_axis - EVENT_RANGE_H / 2.0,
primary_start,
EVENT_RANGE_H,
primary_extent,
)
} else {
(
primary_start,
lane_axis + EVENT_RANGE_Y_OFFSET,
primary_extent,
EVENT_RANGE_H,
)
};
items.push(LaidItem::EventRange {
item,
x,
y,
width,
height,
color,
tooltip,
});
}
Item::Event {
time,
time_month,
time_day,
..
} => {
if !year_in_range(*time, year_min, year_max) {
return;
}
let frac = to_year_frac(*time, *time_month, *time_day);
let primary = primary_anchor + (frac - year_min as f64) * opts.scale;
let (x, y_top, y_bottom, y_dot) = if is_vertical {
(
lane_axis,
primary - EVENT_STEM_H,
primary + EVENT_STEM_H,
primary,
)
} else {
(
primary,
lane_axis - EVENT_STEM_H,
lane_axis + EVENT_STEM_H,
lane_axis,
)
};
items.push(LaidItem::Event {
item,
x,
y_top,
y_bottom,
y_dot,
color,
tooltip,
});
}
}
}
const SPAN_HALF_H: f64 = 12.0;
const AXIS_LABEL_PX: f64 = 40.0;
const EVENT_RANGE_Y_OFFSET: f64 = 14.0;
const EVENT_RANGE_H: f64 = 10.0;
const EVENT_STEM_H: f64 = 20.0;
fn item_lane_id(item: &Item) -> &str {
match item {
Item::Span { lane, .. } | Item::Event { lane, .. } | Item::EventRange { lane, .. } => lane,
}
}
fn get_item_tags(item: &Item) -> &[String] {
match item {
Item::Span { tags, .. } | Item::Event { tags, .. } | Item::EventRange { tags, .. } => tags,
}
}
pub(crate) fn resolve_item_color(
tags: &[String],
color_map: &HashMap<String, String>,
lane_id: &str,
lane_colors: &HashMap<String, String>,
) -> String {
for tag in tags {
if let Some(color) = color_map.get(tag.as_str()) {
return color.clone();
}
}
lane_colors
.get(lane_id)
.cloned()
.unwrap_or_else(|| "#4682B4".to_string())
}
pub(crate) fn format_year(year: i64) -> String {
if year < 0 {
format!("BC{}", -year)
} else {
format!("{year}")
}
}
pub(crate) fn month_abbr(m: u8) -> &'static str {
match m {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "?",
}
}
pub(crate) fn format_date(year: i64, month: Option<u8>, day: Option<u8>) -> String {
let y = format_year(year);
match (month, day) {
(Some(m), Some(d)) => format!("{} {} {}", y, month_abbr(m), d),
(Some(m), None) => format!("{} {}", y, month_abbr(m)),
_ => y,
}
}
fn push_common(
lines: &mut Vec<String>,
tags: &[String],
source: &Option<String>,
origin: &Option<String>,
id: &str,
) {
if !tags.is_empty() {
lines.push(format!("tags: {}", tags.join(", ")));
}
if let Some(src) = source {
lines.push(format!("source: {src}"));
}
if let Some(org) = origin {
lines.push(format!("origin: {org}"));
}
lines.push(format!("id: {id}"));
}
fn item_tooltip(item: &Item) -> String {
let mut lines = Vec::new();
match item {
Item::Span {
label,
start,
end,
tags,
source,
origin,
id,
start_month,
start_day,
end_month,
end_day,
..
} => {
lines.push(label.to_string());
lines.push(format!(
"{}〜{}",
format_date(*start, *start_month, *start_day),
format_date(*end, *end_month, *end_day),
));
push_common(&mut lines, tags, source, origin, id);
}
Item::Event {
label,
time,
tags,
source,
origin,
id,
time_month,
time_day,
..
} => {
lines.push(label.to_string());
lines.push(format_date(*time, *time_month, *time_day));
push_common(&mut lines, tags, source, origin, id);
}
Item::EventRange {
label,
start,
end,
tags,
source,
origin,
id,
start_month,
start_day,
end_month,
end_day,
..
} => {
lines.push(label.to_string());
lines.push(format!(
"{}〜{}",
format_date(*start, *start_month, *start_day),
format_date(*end, *end_month, *end_day),
));
push_common(&mut lines, tags, source, origin, id);
}
}
lines.join("\n")
}
fn year_to_x(year: i64, year_min: i64, scale: f64, left_gutter: f64) -> f64 {
left_gutter + (year - year_min) as f64 * scale
}
fn to_year_frac(year: i64, month: Option<u8>, day: Option<u8>) -> f64 {
let mut frac = year as f64;
if let Some(m) = month {
frac += (m.clamp(1, 12) - 1) as f64 / 12.0;
if let Some(d) = day {
frac += (d.clamp(1, 31) - 1) as f64 / 365.25;
}
}
frac
}
fn frac_to_x(frac: f64, year_min: i64, scale: f64, left_gutter: f64) -> f64 {
left_gutter + (frac - year_min as f64) * scale
}
fn year_in_range(year: i64, year_min: i64, year_max: i64) -> bool {
year >= year_min && year <= year_max
}
fn primary_axis_segment(
start_frac: f64,
end_frac: f64,
year_min: i64,
year_max: i64,
scale: f64,
anchor: f64,
) -> (f64, f64) {
let s = start_frac.max(year_min as f64);
let e = end_frac.min(year_max as f64);
if e < s {
return (anchor + (start_frac - year_min as f64) * scale, 0.0);
}
(anchor + (s - year_min as f64) * scale, (e - s) * scale)
}
fn derive_range_from_items(ir: &TimelineIr) -> Option<(i64, i64)> {
let mut min: Option<i64> = None;
let mut max: Option<i64> = None;
for item in &ir.items {
match item {
Item::Span { start, end, .. } | Item::EventRange { start, end, .. } => {
min = Some(min.map_or(*start, |m| m.min(*start)));
max = Some(max.map_or(*end, |m| m.max(*end)));
}
Item::Event { time, .. } => {
min = Some(min.map_or(*time, |m| m.min(*time)));
max = Some(max.map_or(*time, |m| m.max(*time)));
}
}
}
match (min, max) {
(Some(a), Some(b)) if b > a => Some((a, b)),
(Some(a), Some(b)) => Some((a - 10, b + 10)),
_ => None,
}
}
fn pick_tick_step(range: i64, scale: f64, label_px: f64) -> i64 {
if range <= 0 {
return 1;
}
let min_pitch = label_px + 8.0;
const CANDIDATES: &[i64] = &[
1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000,
];
for &step in CANDIDATES {
if (step as f64) * scale >= min_pitch {
return step;
}
}
10000
}
fn div_floor(a: i64, b: i64) -> i64 {
let q = a / b;
let r = a % b;
if (r != 0) && ((r < 0) != (b < 0)) {
q - 1
} else {
q
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk_meta(range: (i64, i64)) -> tdsl_core::ir::Meta {
tdsl_core::ir::Meta {
title: "t".into(),
unit: "year".into(),
range,
calendar: "proleptic_gregorian".into(),
color_map: std::collections::HashMap::new(),
..Default::default()
}
}
#[test]
fn year_to_x_basic() {
let ir = TimelineIr {
meta: mk_meta((-500, 2000)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let layout = LayoutModel::compute(&ir, RenderOptions::default());
assert_eq!(layout.year_to_x(-500), 120.0);
assert_eq!(layout.year_to_x(0), 1120.0);
assert_eq!(layout.year_to_x(2000), 120.0 + 2500.0 * 2.0);
}
#[test]
fn tick_step_no_overlap_for_various_scales() {
assert_eq!(pick_tick_step(80, 2.0, 40.0), 25);
assert_eq!(pick_tick_step(79, 2.0, 40.0), 25);
assert_eq!(pick_tick_step(20, 2.0, 40.0), 25);
assert_eq!(pick_tick_step(10, 2.0, 40.0), 25);
assert_eq!(pick_tick_step(80, 4.0, 40.0), 20);
assert_eq!(pick_tick_step(100, 1.0, 40.0), 50);
assert_eq!(pick_tick_step(2500, 0.5, 40.0), 100);
}
#[test]
fn tick_step_no_overlap_invariant() {
let label_px = 40.0_f64;
let min_gap = 8.0_f64;
for range in [10_i64, 20, 79, 80] {
for scale in [0.5_f64, 1.0, 2.0, 4.0] {
let step = pick_tick_step(range, scale, label_px);
let pitch = (step as f64) * scale;
assert!(
pitch >= label_px + min_gap,
"range={range}, scale={scale}: step={step}, pitch={pitch:.1} < min_pitch={min_pitch}",
min_pitch = label_px + min_gap,
);
}
}
}
#[test]
fn div_floor_handles_negative() {
assert_eq!(div_floor(-500, 100), -5);
assert_eq!(div_floor(-501, 100), -6);
assert_eq!(div_floor(501, 100), 5);
}
fn mk_meta_with_unit(unit: &str, range: (i64, i64)) -> tdsl_core::ir::Meta {
tdsl_core::ir::Meta {
title: "t".into(),
unit: unit.into(),
range,
calendar: "proleptic_gregorian".into(),
color_map: std::collections::HashMap::new(),
..Default::default()
}
}
#[test]
fn day_ticks_empty_when_unit_not_day() {
let ir = TimelineIr {
meta: mk_meta_with_unit("year", (1939, 1945)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let layout = LayoutModel::compute(&ir, RenderOptions::default());
assert!(layout.day_ticks().is_empty());
}
#[test]
fn day_ticks_empty_when_unit_month() {
let ir = TimelineIr {
meta: mk_meta_with_unit("month", (1939, 1945)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let layout = LayoutModel::compute(&ir, RenderOptions::default());
assert!(layout.day_ticks().is_empty());
}
#[test]
fn day_ticks_produced_for_short_unit_day_range() {
let ir = TimelineIr {
meta: mk_meta_with_unit("day", (1939, 1940)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let opts = RenderOptions {
scale: 365.25 * 6.0, ..RenderOptions::default()
};
let layout = LayoutModel::compute(&ir, opts);
let ticks = layout.day_ticks();
assert!(!ticks.is_empty(), "expected day ticks but got none");
assert!(ticks.contains(&(1939, 1, 1)));
assert!(ticks.contains(&(1939, 12, 31)));
}
#[test]
fn day_ticks_step_thins_for_lower_density() {
let ir = TimelineIr {
meta: mk_meta_with_unit("day", (1939, 1940)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let opts = RenderOptions {
scale: 365.25 * 3.0,
..RenderOptions::default()
};
let layout = LayoutModel::compute(&ir, opts);
let ticks = layout.day_ticks();
assert!(ticks.contains(&(1939, 1, 1)));
assert!(ticks.contains(&(1939, 2, 1)));
assert!(ticks.contains(&(1939, 1, 3)));
assert!(!ticks.contains(&(1939, 1, 2)));
}
#[test]
fn day_ticks_thinning_to_weekly_for_low_density() {
let ir = TimelineIr {
meta: mk_meta_with_unit("day", (1939, 1940)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let opts = RenderOptions {
scale: 365.25 * 2.0, ..RenderOptions::default()
};
let layout = LayoutModel::compute(&ir, opts);
let ticks = layout.day_ticks();
assert!(ticks.contains(&(1939, 1, 1)));
assert!(ticks.contains(&(1939, 1, 8)));
assert!(!ticks.contains(&(1939, 1, 2)));
assert!(!ticks.contains(&(1939, 1, 4)));
}
#[test]
fn day_ticks_empty_when_scale_too_small() {
let ir = TimelineIr {
meta: mk_meta_with_unit("day", (1900, 2000)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let opts = RenderOptions {
scale: 2.0, ..RenderOptions::default()
};
let layout = LayoutModel::compute(&ir, opts);
assert!(layout.day_ticks().is_empty());
}
#[test]
fn span_uses_start_frac_end_frac_for_year_precision() {
let ir = TimelineIr {
meta: mk_meta_with_unit("year", (1900, 2000)),
lanes: vec![Lane {
id: "x".into(),
label: "X".into(),
kind: "custom".into(),
order: 1,
group: None,
source_span: None,
}],
items: vec![Item::Span {
id: "s1".into(),
lane: "x".into(),
start: 1939,
end: 1945,
label: "WW2".into(),
tags: vec![],
source: None,
origin: None,
start_month: None,
start_day: None,
end_month: None,
end_day: None,
source_span: None,
}],
imports: vec![],
sources: vec![],
};
let layout = LayoutModel::compute(&ir, RenderOptions::default());
let span = layout
.items
.iter()
.find_map(|i| match i {
LaidItem::Span { x, width, .. } => Some((*x, *width)),
_ => None,
})
.expect("span should be laid out");
assert!(
(span.0 - 198.0).abs() < 0.01,
"expected x ≈ 198, got {}",
span.0
);
assert!(
span.1 > 13.0,
"expected width > 13 (end-of-year extension), got {}",
span.1
);
}
#[test]
fn lane_y_ordered_by_order_field() {
let ir = TimelineIr {
meta: mk_meta((-100, 100)),
lanes: vec![
Lane {
id: "b".into(),
label: "B".into(),
kind: "k".into(),
order: 20,
group: None,
source_span: None,
},
Lane {
id: "a".into(),
label: "A".into(),
kind: "k".into(),
order: 10,
group: None,
source_span: None,
},
],
items: vec![],
imports: vec![],
sources: vec![],
};
let layout = LayoutModel::compute(&ir, RenderOptions::default());
let ya = layout.lane_y["a"];
let yb = layout.lane_y["b"];
assert!(
ya < yb,
"lane a (order 10) should be above lane b (order 20)"
);
}
#[test]
fn empty_ir_does_not_panic() {
let ir = TimelineIr {
meta: mk_meta((0, 100)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let layout = LayoutModel::compute(&ir, RenderOptions::default());
assert!(layout.items.is_empty());
}
#[test]
fn span_clamps_to_range() {
let (x, w) = primary_axis_segment(-600.0, 300.0, -500, 200, 2.0, 120.0);
assert_eq!(x, 120.0);
assert_eq!(w, 1400.0);
}
#[test]
fn primary_axis_segment_matches_anchor_for_vertical() {
let (y, h) = primary_axis_segment(-600.0, 300.0, -500, 200, 2.0, 40.0);
assert_eq!(y, 40.0);
assert_eq!(h, 1400.0);
}
#[test]
fn month_precision_shifts_x_position() {
let x_jan = frac_to_x(to_year_frac(100, None, None), 0, 2.0, 0.0);
let x_feb = frac_to_x(to_year_frac(100, Some(2), None), 0, 2.0, 0.0);
assert!((x_feb - x_jan - 2.0 / 12.0).abs() < 0.001);
}
#[test]
fn to_year_frac_year_only() {
assert_eq!(to_year_frac(1939, None, None), 1939.0);
assert_eq!(to_year_frac(-206, None, None), -206.0);
assert_eq!(to_year_frac(0, None, None), 0.0);
}
#[test]
fn to_year_frac_with_month() {
assert_eq!(to_year_frac(1939, Some(1), None), 1939.0);
let mid = to_year_frac(1939, Some(7), None);
assert!(
(mid - 1939.5).abs() < 0.001,
"month=7 should be ~0.5 offset, got {mid}"
);
let dec = to_year_frac(1939, Some(12), None);
assert!(
(dec - (1939.0 + 11.0 / 12.0)).abs() < 0.001,
"month=12 offset wrong, got {dec}"
);
}
#[test]
fn to_year_frac_with_month_and_day() {
assert_eq!(to_year_frac(1939, Some(1), Some(1)), 1939.0);
let d2 = to_year_frac(1939, Some(1), Some(2));
assert!(
(d2 - (1939.0 + 1.0 / 365.25)).abs() < 0.0001,
"day=2 offset wrong, got {d2}"
);
let m3d15 = to_year_frac(1939, Some(3), Some(15));
let expected = 1939.0 + 2.0 / 12.0 + 14.0 / 365.25;
assert!(
(m3d15 - expected).abs() < 0.0001,
"month=3,day=15 wrong, got {m3d15}"
);
}
#[test]
fn month_ticks_empty_when_unit_not_month() {
let ir = TimelineIr {
meta: mk_meta_with_unit("year", (1939, 1945)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let layout = LayoutModel::compute(&ir, RenderOptions::default());
assert!(layout.month_ticks().is_empty());
}
#[test]
fn month_ticks_empty_when_scale_too_small() {
let ir = TimelineIr {
meta: mk_meta_with_unit("month", (1939, 1945)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let opts = RenderOptions {
scale: 6.0, ..RenderOptions::default()
};
let layout = LayoutModel::compute(&ir, opts);
assert!(layout.month_ticks().is_empty());
}
#[test]
fn month_ticks_produced_for_month_unit_sufficient_scale() {
let ir = TimelineIr {
meta: mk_meta_with_unit("month", (1939, 1940)),
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let opts = RenderOptions {
scale: 24.0, ..RenderOptions::default()
};
let layout = LayoutModel::compute(&ir, opts);
let ticks = layout.month_ticks();
assert!(!ticks.is_empty(), "expected month ticks for month unit");
assert!(
!ticks.contains(&(1939, 1)),
"month=1 should not appear in month_ticks"
);
assert!(
ticks.contains(&(1939, 2)),
"expected (1939,2) in month_ticks"
);
assert!(
ticks.contains(&(1939, 12)),
"expected (1939,12) in month_ticks"
);
}
#[test]
fn event_outside_range_is_skipped() {
let ir = TimelineIr {
meta: mk_meta((0, 100)),
lanes: vec![Lane {
id: "x".into(),
label: "X".into(),
kind: "k".into(),
order: 1,
group: None,
source_span: None,
}],
items: vec![Item::Event {
id: "e1".into(),
lane: "x".into(),
time: 500,
label: "outside".into(),
tags: vec![],
source: None,
origin: None,
time_month: None,
time_day: None,
source_span: None,
}],
imports: vec![],
sources: vec![],
};
let layout = LayoutModel::compute(&ir, RenderOptions::default());
assert!(layout.items.is_empty());
}
}