1use std::collections::HashMap;
2
3use tdsl_core::ir::{Item, Lane, TimelineIr, end_frac, start_frac};
4
5pub(crate) const LANE_PALETTE: &[&str] = &[
9 "#4682B4", "#E67E22", "#27AE60", "#8E44AD", "#E74C3C", "#1ABC9C", "#F39C12", "#2980B9", ];
18
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub enum Orientation {
22 #[default]
24 Horizontal,
25 Vertical,
27}
28
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub enum Theme {
32 #[default]
33 Default,
34 Dark,
35 Print,
36 Pastel,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub enum GridStyle {
46 #[default]
48 None,
49 Decade,
51 Year,
53 Month,
58}
59
60#[derive(Debug, Clone)]
62pub struct RenderOptions {
63 pub scale: f64,
65 pub lane_height: f64,
67 pub left_gutter: f64,
69 pub top_margin: f64,
71 pub right_margin: f64,
73 pub bottom_margin: f64,
75 pub theme: Theme,
77 pub custom_css: Option<String>,
79 pub color_map: std::collections::HashMap<String, String>,
81 pub interactive: bool,
83 pub font_family: Option<String>,
85 pub orientation: Orientation,
87 pub grid: GridStyle,
89 pub show_table: bool,
92 pub show_event_labels: bool,
95}
96
97impl Default for RenderOptions {
98 fn default() -> Self {
99 Self {
100 scale: 2.0,
101 lane_height: 60.0,
102 left_gutter: 120.0,
103 top_margin: 40.0,
104 right_margin: 20.0,
105 bottom_margin: 20.0,
106 theme: Theme::Default,
107 custom_css: None,
108 color_map: std::collections::HashMap::new(),
109 interactive: false,
110 font_family: None,
111 orientation: Orientation::Horizontal,
112 grid: GridStyle::None,
113 show_table: false,
114 show_event_labels: false,
115 }
116 }
117}
118
119#[derive(Debug, Clone)]
121pub struct LaneBandModel {
122 pub x: f64,
123 pub y: f64,
124 pub width: f64,
125 pub height: f64,
126 pub even: bool,
128}
129
130#[derive(Debug, Clone)]
135pub enum LaidItem<'a> {
136 Span {
137 item: &'a Item,
138 x: f64,
139 y: f64,
140 width: f64,
141 height: f64,
142 color: String,
144 tooltip: String,
146 },
147 EventRange {
148 item: &'a Item,
149 x: f64,
150 y: f64,
151 width: f64,
152 height: f64,
153 color: String,
155 tooltip: String,
157 },
158 Event {
159 item: &'a Item,
160 x: f64,
161 y_top: f64,
162 y_bottom: f64,
163 y_dot: f64,
164 color: String,
166 tooltip: String,
168 },
169}
170
171pub struct LayoutModel<'a> {
173 pub ir: &'a TimelineIr,
174 pub opts: RenderOptions,
175 pub year_min: i64,
176 pub year_max: i64,
177 pub total_width: f64,
178 pub total_height: f64,
179 pub lanes_ordered: Vec<&'a Lane>,
180 pub lane_y: HashMap<String, f64>,
181 pub tick_step: i64,
182 pub items: Vec<LaidItem<'a>>,
183 pub lane_bands: Vec<LaneBandModel>,
185 pub lane_colors: HashMap<String, String>,
187}
188
189impl<'a> LayoutModel<'a> {
190 pub fn compute(ir: &'a TimelineIr, opts: RenderOptions) -> Self {
191 let (year_min, year_max) = ir.meta.range;
192 let (year_min, year_max) = if year_max > year_min {
193 (year_min, year_max)
194 } else if year_max == year_min {
195 (year_min, year_max + 1)
197 } else {
198 derive_range_from_items(ir).unwrap_or((0, 2000))
200 };
201
202 let mut lanes_ordered: Vec<&Lane> = ir.lanes.iter().collect();
203 lanes_ordered.sort_by_key(|l| (l.order, l.id.clone()));
204
205 let is_vertical = opts.orientation == Orientation::Vertical;
206 let n_lanes = lanes_ordered.len();
207 let time_span = (year_max - year_min) as f64;
208
209 let mut lane_y = HashMap::new();
213 if is_vertical {
214 for (idx, lane) in lanes_ordered.iter().enumerate() {
215 let center = opts.left_gutter + (idx as f64 + 0.5) * opts.lane_height;
217 lane_y.insert(lane.id.clone(), center);
218 }
219 } else {
220 for (idx, lane) in lanes_ordered.iter().enumerate() {
221 let center = opts.top_margin + (idx as f64 + 0.5) * opts.lane_height;
222 lane_y.insert(lane.id.clone(), center);
223 }
224 }
225
226 let (total_width, total_height) = if is_vertical {
227 let w = opts.left_gutter + n_lanes as f64 * opts.lane_height + opts.right_margin;
230 let h = opts.top_margin + time_span * opts.scale + opts.bottom_margin;
231 (w, h)
232 } else {
233 let w = opts.left_gutter + time_span * opts.scale + opts.right_margin;
234 let h = opts.top_margin + n_lanes as f64 * opts.lane_height + opts.bottom_margin;
235 (w, h)
236 };
237
238 let tick_step = pick_tick_step(year_max - year_min, opts.scale, AXIS_LABEL_PX);
239
240 let lane_colors: HashMap<String, String> = lanes_ordered
242 .iter()
243 .enumerate()
244 .map(|(idx, lane)| {
245 (
246 lane.id.clone(),
247 LANE_PALETTE[idx % LANE_PALETTE.len()].to_string(),
248 )
249 })
250 .collect();
251
252 let lane_bands: Vec<LaneBandModel> = if is_vertical {
254 let content_height = total_height - opts.top_margin - opts.bottom_margin;
255 lanes_ordered
256 .iter()
257 .enumerate()
258 .map(|(idx, _lane)| LaneBandModel {
259 x: opts.left_gutter + idx as f64 * opts.lane_height,
260 y: opts.top_margin,
261 width: opts.lane_height,
262 height: content_height,
263 even: idx % 2 == 0,
264 })
265 .collect()
266 } else {
267 let content_width = total_width - opts.left_gutter - opts.right_margin;
268 lanes_ordered
269 .iter()
270 .enumerate()
271 .map(|(idx, _lane)| LaneBandModel {
272 x: opts.left_gutter,
273 y: opts.top_margin + idx as f64 * opts.lane_height,
274 width: content_width,
275 height: opts.lane_height,
276 even: idx % 2 == 0,
277 })
278 .collect()
279 };
280
281 let mut items = Vec::new();
282 for item in &ir.items {
283 let lane_id = item_lane_id(item);
284 let Some(&lane_axis) = lane_y.get(lane_id) else {
285 continue;
286 };
287 let item_tags = get_item_tags(item);
288 let color = resolve_item_color(item_tags, &opts.color_map, lane_id, &lane_colors);
289 let tooltip = item_tooltip(item);
290 compute_item(
291 item,
292 &mut items,
293 ItemLayoutArgs {
294 lane_axis,
295 year_min,
296 year_max,
297 opts: &opts,
298 orientation: opts.orientation.clone(),
299 color,
300 tooltip,
301 },
302 );
303 }
304
305 Self {
306 ir,
307 opts,
308 year_min,
309 year_max,
310 total_width,
311 total_height,
312 lanes_ordered,
313 lane_y,
314 tick_step,
315 items,
316 lane_bands,
317 lane_colors,
318 }
319 }
320
321 pub fn is_vertical(&self) -> bool {
323 self.opts.orientation == Orientation::Vertical
324 }
325
326 pub fn year_to_primary(&self, year: i64) -> f64 {
331 if self.is_vertical() {
332 self.opts.top_margin + (year - self.year_min) as f64 * self.opts.scale
333 } else {
334 year_to_x(year, self.year_min, self.opts.scale, self.opts.left_gutter)
335 }
336 }
337
338 pub fn year_to_x(&self, year: i64) -> f64 {
339 year_to_x(year, self.year_min, self.opts.scale, self.opts.left_gutter)
340 }
341
342 pub fn month_ticks(&self) -> Vec<(i64, u8)> {
347 if self.ir.meta.unit != "month" {
348 return Vec::new();
349 }
350 if self.opts.scale / 12.0 < 1.0 {
351 return Vec::new();
352 }
353 let mut ticks = Vec::new();
354 for year in self.year_min..=self.year_max {
355 for month in 2u8..=12 {
356 let frac = to_year_frac(year, Some(month), None);
357 if frac < self.year_max as f64 {
358 ticks.push((year, month));
359 }
360 }
361 }
362 ticks
363 }
364
365 pub fn frac_year_to_x(&self, year: i64, month: u8) -> f64 {
367 let frac = to_year_frac(year, Some(month), None);
368 frac_to_x(frac, self.year_min, self.opts.scale, self.opts.left_gutter)
369 }
370
371 pub fn day_frac_to_x(&self, year: i64, month: u8, day: u8) -> f64 {
373 let frac = to_year_frac(year, Some(month), Some(day));
374 frac_to_x(frac, self.year_min, self.opts.scale, self.opts.left_gutter)
375 }
376
377 pub fn day_ticks(&self) -> Vec<(i64, u8, u8)> {
383 if self.ir.meta.unit != "day" {
384 return Vec::new();
385 }
386
387 let pixels_per_day = self.opts.scale / 365.25;
388 if pixels_per_day < 0.5 {
390 return Vec::new();
391 }
392
393 let step = if pixels_per_day >= 6.0 {
395 1
396 } else if pixels_per_day >= 3.0 {
397 2
398 } else if pixels_per_day >= 1.5 {
399 7
400 } else {
401 30
402 };
403
404 let mut ticks = Vec::new();
405 for year in self.year_min..=self.year_max {
406 for month in 1u8..=12 {
407 let last = tdsl_core::ir::days_in_month(year, month);
408 let mut day = 1u8;
409 while day <= last {
410 if day == 1 || ((day - 1) as usize).is_multiple_of(step) {
411 let frac = to_year_frac(year, Some(month), Some(day));
412 if frac < self.year_max as f64 {
413 ticks.push((year, month, day));
414 }
415 }
416 day = day.saturating_add(1);
417 if day == 0 {
418 break;
419 }
420 }
421 }
422 }
423 ticks
424 }
425
426 pub fn ticks(&self) -> Vec<i64> {
428 let step = self.tick_step.max(1);
429 let first = div_floor(self.year_min, step) * step;
430 let mut ticks = Vec::new();
431 let mut y = first;
432 while y <= self.year_max {
433 if y >= self.year_min {
434 ticks.push(y);
435 }
436 y += step;
437 }
438 ticks
439 }
440
441 pub fn grid_positions(&self) -> Vec<f64> {
452 match self.opts.grid {
453 GridStyle::None => Vec::new(),
454 GridStyle::Decade => {
455 let first = div_floor(self.year_min, 10) * 10;
456 let mut positions = Vec::new();
457 let mut y = first;
458 while y <= self.year_max {
459 if y >= self.year_min {
460 positions.push(y as f64);
461 }
462 y += 10;
463 }
464 positions
465 }
466 GridStyle::Year => (self.year_min..=self.year_max).map(|y| y as f64).collect(),
467 GridStyle::Month => {
468 let mut positions = Vec::new();
469 for year in self.year_min..=self.year_max {
470 for month in 0u8..12 {
471 let frac = year as f64 + month as f64 / 12.0;
472 if frac >= self.year_min as f64 && frac <= self.year_max as f64 {
473 positions.push(frac);
474 }
475 }
476 }
477 positions
478 }
479 }
480 }
481}
482
483struct ItemLayoutArgs<'a> {
491 lane_axis: f64,
494 year_min: i64,
495 year_max: i64,
496 opts: &'a RenderOptions,
497 orientation: Orientation,
498 color: String,
499 tooltip: String,
500}
501
502fn compute_item<'a>(item: &'a Item, items: &mut Vec<LaidItem<'a>>, args: ItemLayoutArgs<'_>) {
515 let ItemLayoutArgs {
516 lane_axis,
517 year_min,
518 year_max,
519 opts,
520 orientation,
521 color,
522 tooltip,
523 } = args;
524 let is_vertical = orientation == Orientation::Vertical;
525 let primary_anchor = if is_vertical {
526 opts.top_margin
527 } else {
528 opts.left_gutter
529 };
530
531 match item {
532 Item::Span {
533 start,
534 end,
535 start_month,
536 start_day,
537 end_month,
538 end_day,
539 ..
540 } => {
541 let sf = start_frac(*start, *start_month, *start_day);
543 let ef = end_frac(*end, *end_month, *end_day);
544 let (primary_start, primary_extent) =
545 primary_axis_segment(sf, ef, year_min, year_max, opts.scale, primary_anchor);
546 let cross_start = lane_axis - SPAN_HALF_H;
547 let cross_extent = SPAN_HALF_H * 2.0;
548 let (x, y, width, height) = if is_vertical {
549 (cross_start, primary_start, cross_extent, primary_extent)
550 } else {
551 (primary_start, cross_start, primary_extent, cross_extent)
552 };
553 items.push(LaidItem::Span {
554 item,
555 x,
556 y,
557 width,
558 height,
559 color,
560 tooltip,
561 });
562 }
563 Item::EventRange {
564 start,
565 end,
566 start_month,
567 start_day,
568 end_month,
569 end_day,
570 ..
571 } => {
572 let sf = start_frac(*start, *start_month, *start_day);
573 let ef = end_frac(*end, *end_month, *end_day);
574 let (primary_start, primary_extent) =
575 primary_axis_segment(sf, ef, year_min, year_max, opts.scale, primary_anchor);
576 let (x, y, width, height) = if is_vertical {
581 (
582 lane_axis - EVENT_RANGE_H / 2.0,
583 primary_start,
584 EVENT_RANGE_H,
585 primary_extent,
586 )
587 } else {
588 (
589 primary_start,
590 lane_axis + EVENT_RANGE_Y_OFFSET,
591 primary_extent,
592 EVENT_RANGE_H,
593 )
594 };
595 items.push(LaidItem::EventRange {
596 item,
597 x,
598 y,
599 width,
600 height,
601 color,
602 tooltip,
603 });
604 }
605 Item::Event {
606 time,
607 time_month,
608 time_day,
609 ..
610 } => {
611 if !year_in_range(*time, year_min, year_max) {
612 return;
613 }
614 let frac = to_year_frac(*time, *time_month, *time_day);
615 let primary = primary_anchor + (frac - year_min as f64) * opts.scale;
616 let (x, y_top, y_bottom, y_dot) = if is_vertical {
617 (
619 lane_axis,
620 primary - EVENT_STEM_H,
621 primary + EVENT_STEM_H,
622 primary,
623 )
624 } else {
625 (
627 primary,
628 lane_axis - EVENT_STEM_H,
629 lane_axis + EVENT_STEM_H,
630 lane_axis,
631 )
632 };
633 items.push(LaidItem::Event {
634 item,
635 x,
636 y_top,
637 y_bottom,
638 y_dot,
639 color,
640 tooltip,
641 });
642 }
643 }
644}
645
646const SPAN_HALF_H: f64 = 12.0;
648const AXIS_LABEL_PX: f64 = 40.0;
650const EVENT_RANGE_Y_OFFSET: f64 = 14.0;
651const EVENT_RANGE_H: f64 = 10.0;
652const EVENT_STEM_H: f64 = 20.0;
653
654fn item_lane_id(item: &Item) -> &str {
655 match item {
656 Item::Span { lane, .. } | Item::Event { lane, .. } | Item::EventRange { lane, .. } => lane,
657 }
658}
659
660fn get_item_tags(item: &Item) -> &[String] {
661 match item {
662 Item::Span { tags, .. } | Item::Event { tags, .. } | Item::EventRange { tags, .. } => tags,
663 }
664}
665
666pub(crate) fn resolve_item_color(
668 tags: &[String],
669 color_map: &HashMap<String, String>,
670 lane_id: &str,
671 lane_colors: &HashMap<String, String>,
672) -> String {
673 for tag in tags {
674 if let Some(color) = color_map.get(tag.as_str()) {
675 return color.clone();
676 }
677 }
678 lane_colors
679 .get(lane_id)
680 .cloned()
681 .unwrap_or_else(|| "#4682B4".to_string())
682}
683
684pub(crate) fn format_year(year: i64) -> String {
686 if year < 0 {
687 format!("BC{}", -year)
688 } else {
689 format!("{year}")
690 }
691}
692
693pub(crate) fn month_abbr(m: u8) -> &'static str {
695 match m {
696 1 => "Jan",
697 2 => "Feb",
698 3 => "Mar",
699 4 => "Apr",
700 5 => "May",
701 6 => "Jun",
702 7 => "Jul",
703 8 => "Aug",
704 9 => "Sep",
705 10 => "Oct",
706 11 => "Nov",
707 12 => "Dec",
708 _ => "?",
709 }
710}
711
712pub(crate) fn format_date(year: i64, month: Option<u8>, day: Option<u8>) -> String {
714 let y = format_year(year);
715 match (month, day) {
716 (Some(m), Some(d)) => format!("{} {} {}", y, month_abbr(m), d),
717 (Some(m), None) => format!("{} {}", y, month_abbr(m)),
718 _ => y,
719 }
720}
721
722fn push_common(
723 lines: &mut Vec<String>,
724 tags: &[String],
725 source: &Option<String>,
726 origin: &Option<String>,
727 id: &str,
728) {
729 if !tags.is_empty() {
730 lines.push(format!("tags: {}", tags.join(", ")));
731 }
732 if let Some(src) = source {
733 lines.push(format!("source: {src}"));
734 }
735 if let Some(org) = origin {
736 lines.push(format!("origin: {org}"));
737 }
738 lines.push(format!("id: {id}"));
739}
740
741fn item_tooltip(item: &Item) -> String {
743 let mut lines = Vec::new();
744 match item {
745 Item::Span {
746 label,
747 start,
748 end,
749 tags,
750 source,
751 origin,
752 id,
753 start_month,
754 start_day,
755 end_month,
756 end_day,
757 ..
758 } => {
759 lines.push(label.to_string());
760 lines.push(format!(
761 "{}〜{}",
762 format_date(*start, *start_month, *start_day),
763 format_date(*end, *end_month, *end_day),
764 ));
765 push_common(&mut lines, tags, source, origin, id);
766 }
767 Item::Event {
768 label,
769 time,
770 tags,
771 source,
772 origin,
773 id,
774 time_month,
775 time_day,
776 ..
777 } => {
778 lines.push(label.to_string());
779 lines.push(format_date(*time, *time_month, *time_day));
780 push_common(&mut lines, tags, source, origin, id);
781 }
782 Item::EventRange {
783 label,
784 start,
785 end,
786 tags,
787 source,
788 origin,
789 id,
790 start_month,
791 start_day,
792 end_month,
793 end_day,
794 ..
795 } => {
796 lines.push(label.to_string());
797 lines.push(format!(
798 "{}〜{}",
799 format_date(*start, *start_month, *start_day),
800 format_date(*end, *end_month, *end_day),
801 ));
802 push_common(&mut lines, tags, source, origin, id);
803 }
804 }
805 lines.join("\n")
806}
807
808fn year_to_x(year: i64, year_min: i64, scale: f64, left_gutter: f64) -> f64 {
809 left_gutter + (year - year_min) as f64 * scale
810}
811
812fn to_year_frac(year: i64, month: Option<u8>, day: Option<u8>) -> f64 {
814 let mut frac = year as f64;
815 if let Some(m) = month {
816 frac += (m.clamp(1, 12) - 1) as f64 / 12.0;
817 if let Some(d) = day {
818 frac += (d.clamp(1, 31) - 1) as f64 / 365.25;
819 }
820 }
821 frac
822}
823
824fn frac_to_x(frac: f64, year_min: i64, scale: f64, left_gutter: f64) -> f64 {
825 left_gutter + (frac - year_min as f64) * scale
826}
827
828fn year_in_range(year: i64, year_min: i64, year_max: i64) -> bool {
829 year >= year_min && year <= year_max
830}
831
832fn primary_axis_segment(
839 start_frac: f64,
840 end_frac: f64,
841 year_min: i64,
842 year_max: i64,
843 scale: f64,
844 anchor: f64,
845) -> (f64, f64) {
846 let s = start_frac.max(year_min as f64);
847 let e = end_frac.min(year_max as f64);
848 if e < s {
849 return (anchor + (start_frac - year_min as f64) * scale, 0.0);
850 }
851 (anchor + (s - year_min as f64) * scale, (e - s) * scale)
852}
853
854fn derive_range_from_items(ir: &TimelineIr) -> Option<(i64, i64)> {
855 let mut min: Option<i64> = None;
856 let mut max: Option<i64> = None;
857 for item in &ir.items {
858 match item {
859 Item::Span { start, end, .. } | Item::EventRange { start, end, .. } => {
860 min = Some(min.map_or(*start, |m| m.min(*start)));
861 max = Some(max.map_or(*end, |m| m.max(*end)));
862 }
863 Item::Event { time, .. } => {
864 min = Some(min.map_or(*time, |m| m.min(*time)));
865 max = Some(max.map_or(*time, |m| m.max(*time)));
866 }
867 }
868 }
869 match (min, max) {
870 (Some(a), Some(b)) if b > a => Some((a, b)),
871 (Some(a), Some(b)) => Some((a - 10, b + 10)),
872 _ => None,
873 }
874}
875
876fn pick_tick_step(range: i64, scale: f64, label_px: f64) -> i64 {
879 if range <= 0 {
880 return 1;
881 }
882 let min_pitch = label_px + 8.0;
883 const CANDIDATES: &[i64] = &[
884 1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000,
885 ];
886 for &step in CANDIDATES {
887 if (step as f64) * scale >= min_pitch {
888 return step;
889 }
890 }
891 10000
892}
893
894fn div_floor(a: i64, b: i64) -> i64 {
895 let q = a / b;
896 let r = a % b;
897 if (r != 0) && ((r < 0) != (b < 0)) {
898 q - 1
899 } else {
900 q
901 }
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907
908 fn mk_meta(range: (i64, i64)) -> tdsl_core::ir::Meta {
909 tdsl_core::ir::Meta {
910 title: "t".into(),
911 unit: "year".into(),
912 range,
913 calendar: "proleptic_gregorian".into(),
914 color_map: std::collections::HashMap::new(),
915 ..Default::default()
916 }
917 }
918
919 #[test]
920 fn year_to_x_basic() {
921 let ir = TimelineIr {
922 meta: mk_meta((-500, 2000)),
923 lanes: vec![],
924 items: vec![],
925 imports: vec![],
926 sources: vec![],
927 };
928 let layout = LayoutModel::compute(&ir, RenderOptions::default());
929 assert_eq!(layout.year_to_x(-500), 120.0);
931 assert_eq!(layout.year_to_x(0), 1120.0);
932 assert_eq!(layout.year_to_x(2000), 120.0 + 2500.0 * 2.0);
933 }
934
935 #[test]
936 fn tick_step_no_overlap_for_various_scales() {
937 assert_eq!(pick_tick_step(80, 2.0, 40.0), 25);
939 assert_eq!(pick_tick_step(79, 2.0, 40.0), 25);
941 assert_eq!(pick_tick_step(20, 2.0, 40.0), 25);
942 assert_eq!(pick_tick_step(10, 2.0, 40.0), 25);
943 assert_eq!(pick_tick_step(80, 4.0, 40.0), 20);
945 assert_eq!(pick_tick_step(100, 1.0, 40.0), 50);
947 assert_eq!(pick_tick_step(2500, 0.5, 40.0), 100);
949 }
950
951 #[test]
952 fn tick_step_no_overlap_invariant() {
953 let label_px = 40.0_f64;
955 let min_gap = 8.0_f64;
956 for range in [10_i64, 20, 79, 80] {
957 for scale in [0.5_f64, 1.0, 2.0, 4.0] {
958 let step = pick_tick_step(range, scale, label_px);
959 let pitch = (step as f64) * scale;
960 assert!(
961 pitch >= label_px + min_gap,
962 "range={range}, scale={scale}: step={step}, pitch={pitch:.1} < min_pitch={min_pitch}",
963 min_pitch = label_px + min_gap,
964 );
965 }
966 }
967 }
968
969 #[test]
970 fn div_floor_handles_negative() {
971 assert_eq!(div_floor(-500, 100), -5);
972 assert_eq!(div_floor(-501, 100), -6);
973 assert_eq!(div_floor(501, 100), 5);
974 }
975
976 fn mk_meta_with_unit(unit: &str, range: (i64, i64)) -> tdsl_core::ir::Meta {
979 tdsl_core::ir::Meta {
980 title: "t".into(),
981 unit: unit.into(),
982 range,
983 calendar: "proleptic_gregorian".into(),
984 color_map: std::collections::HashMap::new(),
985 ..Default::default()
986 }
987 }
988
989 #[test]
990 fn day_ticks_empty_when_unit_not_day() {
991 let ir = TimelineIr {
992 meta: mk_meta_with_unit("year", (1939, 1945)),
993 lanes: vec![],
994 items: vec![],
995 imports: vec![],
996 sources: vec![],
997 };
998 let layout = LayoutModel::compute(&ir, RenderOptions::default());
999 assert!(layout.day_ticks().is_empty());
1000 }
1001
1002 #[test]
1003 fn day_ticks_empty_when_unit_month() {
1004 let ir = TimelineIr {
1005 meta: mk_meta_with_unit("month", (1939, 1945)),
1006 lanes: vec![],
1007 items: vec![],
1008 imports: vec![],
1009 sources: vec![],
1010 };
1011 let layout = LayoutModel::compute(&ir, RenderOptions::default());
1012 assert!(layout.day_ticks().is_empty());
1013 }
1014
1015 #[test]
1016 fn day_ticks_produced_for_short_unit_day_range() {
1017 let ir = TimelineIr {
1019 meta: mk_meta_with_unit("day", (1939, 1940)),
1020 lanes: vec![],
1021 items: vec![],
1022 imports: vec![],
1023 sources: vec![],
1024 };
1025 let opts = RenderOptions {
1026 scale: 365.25 * 6.0, ..RenderOptions::default()
1028 };
1029 let layout = LayoutModel::compute(&ir, opts);
1030 let ticks = layout.day_ticks();
1031 assert!(!ticks.is_empty(), "expected day ticks but got none");
1033 assert!(ticks.contains(&(1939, 1, 1)));
1035 assert!(ticks.contains(&(1939, 12, 31)));
1037 }
1038
1039 #[test]
1040 fn day_ticks_step_thins_for_lower_density() {
1041 let ir = TimelineIr {
1043 meta: mk_meta_with_unit("day", (1939, 1940)),
1044 lanes: vec![],
1045 items: vec![],
1046 imports: vec![],
1047 sources: vec![],
1048 };
1049 let opts = RenderOptions {
1050 scale: 365.25 * 3.0,
1051 ..RenderOptions::default()
1052 };
1053 let layout = LayoutModel::compute(&ir, opts);
1054 let ticks = layout.day_ticks();
1055 assert!(ticks.contains(&(1939, 1, 1)));
1057 assert!(ticks.contains(&(1939, 2, 1)));
1058 assert!(ticks.contains(&(1939, 1, 3)));
1060 assert!(!ticks.contains(&(1939, 1, 2)));
1061 }
1062
1063 #[test]
1064 fn day_ticks_thinning_to_weekly_for_low_density() {
1065 let ir = TimelineIr {
1067 meta: mk_meta_with_unit("day", (1939, 1940)),
1068 lanes: vec![],
1069 items: vec![],
1070 imports: vec![],
1071 sources: vec![],
1072 };
1073 let opts = RenderOptions {
1074 scale: 365.25 * 2.0, ..RenderOptions::default()
1076 };
1077 let layout = LayoutModel::compute(&ir, opts);
1078 let ticks = layout.day_ticks();
1079 assert!(ticks.contains(&(1939, 1, 1)));
1081 assert!(ticks.contains(&(1939, 1, 8)));
1083 assert!(!ticks.contains(&(1939, 1, 2)));
1085 assert!(!ticks.contains(&(1939, 1, 4)));
1086 }
1087
1088 #[test]
1089 fn day_ticks_empty_when_scale_too_small() {
1090 let ir = TimelineIr {
1091 meta: mk_meta_with_unit("day", (1900, 2000)),
1092 lanes: vec![],
1093 items: vec![],
1094 imports: vec![],
1095 sources: vec![],
1096 };
1097 let opts = RenderOptions {
1098 scale: 2.0, ..RenderOptions::default()
1100 };
1101 let layout = LayoutModel::compute(&ir, opts);
1102 assert!(layout.day_ticks().is_empty());
1103 }
1104
1105 #[test]
1106 fn span_uses_start_frac_end_frac_for_year_precision() {
1107 let ir = TimelineIr {
1109 meta: mk_meta_with_unit("year", (1900, 2000)),
1110 lanes: vec![Lane {
1111 id: "x".into(),
1112 label: "X".into(),
1113 kind: "custom".into(),
1114 order: 1,
1115 group: None,
1116 source_span: None,
1117 }],
1118 items: vec![Item::Span {
1119 id: "s1".into(),
1120 lane: "x".into(),
1121 start: 1939,
1122 end: 1945,
1123 label: "WW2".into(),
1124 tags: vec![],
1125 source: None,
1126 origin: None,
1127 start_month: None,
1128 start_day: None,
1129 end_month: None,
1130 end_day: None,
1131 source_span: None,
1132 }],
1133 imports: vec![],
1134 sources: vec![],
1135 };
1136 let layout = LayoutModel::compute(&ir, RenderOptions::default());
1137 let span = layout
1138 .items
1139 .iter()
1140 .find_map(|i| match i {
1141 LaidItem::Span { x, width, .. } => Some((*x, *width)),
1142 _ => None,
1143 })
1144 .expect("span should be laid out");
1145 assert!(
1149 (span.0 - 198.0).abs() < 0.01,
1150 "expected x ≈ 198, got {}",
1151 span.0
1152 );
1153 assert!(
1156 span.1 > 13.0,
1157 "expected width > 13 (end-of-year extension), got {}",
1158 span.1
1159 );
1160 }
1161
1162 #[test]
1163 fn lane_y_ordered_by_order_field() {
1164 let ir = TimelineIr {
1165 meta: mk_meta((-100, 100)),
1166 lanes: vec![
1167 Lane {
1168 id: "b".into(),
1169 label: "B".into(),
1170 kind: "k".into(),
1171 order: 20,
1172 group: None,
1173 source_span: None,
1174 },
1175 Lane {
1176 id: "a".into(),
1177 label: "A".into(),
1178 kind: "k".into(),
1179 order: 10,
1180 group: None,
1181 source_span: None,
1182 },
1183 ],
1184 items: vec![],
1185 imports: vec![],
1186 sources: vec![],
1187 };
1188 let layout = LayoutModel::compute(&ir, RenderOptions::default());
1189 let ya = layout.lane_y["a"];
1190 let yb = layout.lane_y["b"];
1191 assert!(
1192 ya < yb,
1193 "lane a (order 10) should be above lane b (order 20)"
1194 );
1195 }
1196
1197 #[test]
1198 fn empty_ir_does_not_panic() {
1199 let ir = TimelineIr {
1200 meta: mk_meta((0, 100)),
1201 lanes: vec![],
1202 items: vec![],
1203 imports: vec![],
1204 sources: vec![],
1205 };
1206 let layout = LayoutModel::compute(&ir, RenderOptions::default());
1207 assert!(layout.items.is_empty());
1208 }
1209
1210 #[test]
1211 fn span_clamps_to_range() {
1212 let (x, w) = primary_axis_segment(-600.0, 300.0, -500, 200, 2.0, 120.0);
1213 assert_eq!(x, 120.0);
1215 assert_eq!(w, 1400.0);
1217 }
1218
1219 #[test]
1220 fn primary_axis_segment_matches_anchor_for_vertical() {
1221 let (y, h) = primary_axis_segment(-600.0, 300.0, -500, 200, 2.0, 40.0);
1226 assert_eq!(y, 40.0);
1227 assert_eq!(h, 1400.0);
1228 }
1229
1230 #[test]
1231 fn month_precision_shifts_x_position() {
1232 let x_jan = frac_to_x(to_year_frac(100, None, None), 0, 2.0, 0.0);
1234 let x_feb = frac_to_x(to_year_frac(100, Some(2), None), 0, 2.0, 0.0);
1235 assert!((x_feb - x_jan - 2.0 / 12.0).abs() < 0.001);
1236 }
1237
1238 #[test]
1241 fn to_year_frac_year_only() {
1242 assert_eq!(to_year_frac(1939, None, None), 1939.0);
1244 assert_eq!(to_year_frac(-206, None, None), -206.0);
1245 assert_eq!(to_year_frac(0, None, None), 0.0);
1246 }
1247
1248 #[test]
1249 fn to_year_frac_with_month() {
1250 assert_eq!(to_year_frac(1939, Some(1), None), 1939.0);
1252 let mid = to_year_frac(1939, Some(7), None);
1253 assert!(
1254 (mid - 1939.5).abs() < 0.001,
1255 "month=7 should be ~0.5 offset, got {mid}"
1256 );
1257 let dec = to_year_frac(1939, Some(12), None);
1259 assert!(
1260 (dec - (1939.0 + 11.0 / 12.0)).abs() < 0.001,
1261 "month=12 offset wrong, got {dec}"
1262 );
1263 }
1264
1265 #[test]
1266 fn to_year_frac_with_month_and_day() {
1267 assert_eq!(to_year_frac(1939, Some(1), Some(1)), 1939.0);
1269 let d2 = to_year_frac(1939, Some(1), Some(2));
1271 assert!(
1272 (d2 - (1939.0 + 1.0 / 365.25)).abs() < 0.0001,
1273 "day=2 offset wrong, got {d2}"
1274 );
1275 let m3d15 = to_year_frac(1939, Some(3), Some(15));
1277 let expected = 1939.0 + 2.0 / 12.0 + 14.0 / 365.25;
1278 assert!(
1279 (m3d15 - expected).abs() < 0.0001,
1280 "month=3,day=15 wrong, got {m3d15}"
1281 );
1282 }
1283
1284 #[test]
1287 fn month_ticks_empty_when_unit_not_month() {
1288 let ir = TimelineIr {
1289 meta: mk_meta_with_unit("year", (1939, 1945)),
1290 lanes: vec![],
1291 items: vec![],
1292 imports: vec![],
1293 sources: vec![],
1294 };
1295 let layout = LayoutModel::compute(&ir, RenderOptions::default());
1296 assert!(layout.month_ticks().is_empty());
1297 }
1298
1299 #[test]
1300 fn month_ticks_empty_when_scale_too_small() {
1301 let ir = TimelineIr {
1303 meta: mk_meta_with_unit("month", (1939, 1945)),
1304 lanes: vec![],
1305 items: vec![],
1306 imports: vec![],
1307 sources: vec![],
1308 };
1309 let opts = RenderOptions {
1310 scale: 6.0, ..RenderOptions::default()
1312 };
1313 let layout = LayoutModel::compute(&ir, opts);
1314 assert!(layout.month_ticks().is_empty());
1315 }
1316
1317 #[test]
1318 fn month_ticks_produced_for_month_unit_sufficient_scale() {
1319 let ir = TimelineIr {
1321 meta: mk_meta_with_unit("month", (1939, 1940)),
1322 lanes: vec![],
1323 items: vec![],
1324 imports: vec![],
1325 sources: vec![],
1326 };
1327 let opts = RenderOptions {
1328 scale: 24.0, ..RenderOptions::default()
1330 };
1331 let layout = LayoutModel::compute(&ir, opts);
1332 let ticks = layout.month_ticks();
1333 assert!(!ticks.is_empty(), "expected month ticks for month unit");
1334 assert!(
1336 !ticks.contains(&(1939, 1)),
1337 "month=1 should not appear in month_ticks"
1338 );
1339 assert!(
1341 ticks.contains(&(1939, 2)),
1342 "expected (1939,2) in month_ticks"
1343 );
1344 assert!(
1346 ticks.contains(&(1939, 12)),
1347 "expected (1939,12) in month_ticks"
1348 );
1349 }
1350
1351 #[test]
1352 fn event_outside_range_is_skipped() {
1353 let ir = TimelineIr {
1354 meta: mk_meta((0, 100)),
1355 lanes: vec![Lane {
1356 id: "x".into(),
1357 label: "X".into(),
1358 kind: "k".into(),
1359 order: 1,
1360 group: None,
1361 source_span: None,
1362 }],
1363 items: vec![Item::Event {
1364 id: "e1".into(),
1365 lane: "x".into(),
1366 time: 500,
1367 label: "outside".into(),
1368 tags: vec![],
1369 source: None,
1370 origin: None,
1371 time_month: None,
1372 time_day: None,
1373 source_span: None,
1374 }],
1375 imports: vec![],
1376 sources: vec![],
1377 };
1378 let layout = LayoutModel::compute(&ir, RenderOptions::default());
1379 assert!(layout.items.is_empty());
1380 }
1381}