Skip to main content

tdsl_render/
layout.rs

1use std::collections::HashMap;
2
3use tdsl_core::ir::{Item, Lane, TimelineIr, end_frac, start_frac};
4
5/// Colorblind-friendly 8-color palette for per-lane fill colors.
6///
7/// Single source of truth for palette shared by all emitters.
8pub(crate) const LANE_PALETTE: &[&str] = &[
9    "#4682B4", // steel blue
10    "#E67E22", // orange
11    "#27AE60", // green
12    "#8E44AD", // purple
13    "#E74C3C", // red
14    "#1ABC9C", // teal
15    "#F39C12", // amber
16    "#2980B9", // blue
17];
18
19/// Timeline layout orientation.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub enum Orientation {
22    /// Time axis runs left→right; lanes are stacked top→bottom. (default)
23    #[default]
24    Horizontal,
25    /// Time axis runs top→bottom; lanes are arranged left→right.
26    Vertical,
27}
28
29/// Color/style theme for HTML output.
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub enum Theme {
32    #[default]
33    Default,
34    Dark,
35    Print,
36    Pastel,
37}
38
39/// Grid line style for the time axis.
40///
41/// Auxiliary grid lines are drawn at regular intervals to improve readability
42/// on long timelines. `None` disables all grid lines (default, preserves
43/// existing SVG output unchanged).
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub enum GridStyle {
46    /// No grid lines (default). SVG output is identical to pre-grid behavior.
47    #[default]
48    None,
49    /// Grid lines every 10 years.
50    Decade,
51    /// Grid lines every year.
52    Year,
53    /// Grid lines every month.
54    ///
55    /// Note: month-grid uses 1/12-year intervals regardless of item precision.
56    /// This is a visual aid only and does not require `unit = "month"`.
57    Month,
58}
59
60/// Rendering options. Pixel dimensions and styling parameters.
61#[derive(Debug, Clone)]
62pub struct RenderOptions {
63    /// Pixels per year on the horizontal axis.
64    pub scale: f64,
65    /// Height of each lane in pixels.
66    pub lane_height: f64,
67    /// Width of the left-hand gutter that holds lane labels.
68    pub left_gutter: f64,
69    /// Top margin reserved for the time axis.
70    pub top_margin: f64,
71    /// Right margin.
72    pub right_margin: f64,
73    /// Bottom margin.
74    pub bottom_margin: f64,
75    /// Color/style theme.
76    pub theme: Theme,
77    /// Optional custom CSS (content, not a file path) injected after the theme CSS.
78    pub custom_css: Option<String>,
79    /// Tag-to-color overrides. Key: tag name, Value: CSS color string (e.g. "#cc0000").
80    pub color_map: std::collections::HashMap<String, String>,
81    /// Enable interactive mode (zoom, pan, search, legend, detail panel).
82    pub interactive: bool,
83    /// Custom font-family CSS value for SVG text. When None, uses the built-in CJK-friendly stack.
84    pub font_family: Option<String>,
85    /// Timeline layout orientation: horizontal (default) or vertical.
86    pub orientation: Orientation,
87    /// Auxiliary grid line style. `None` (default) disables grid lines entirely.
88    pub grid: GridStyle,
89    /// When true, an HTML table listing all items is appended after the SVG in HTML output.
90    /// Has no effect for SVG, PNG, or PDF output formats.
91    pub show_table: bool,
92    /// When true, labels (and optionally dates) are always rendered next to Event and EventRange
93    /// dots/bars as SVG text elements.  Disabled by default to keep the chart uncluttered.
94    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/// Pre-computed lane background band geometry.
120#[derive(Debug, Clone)]
121pub struct LaneBandModel {
122    pub x: f64,
123    pub y: f64,
124    pub width: f64,
125    pub height: f64,
126    /// `true` for even-indexed lanes (0-based), `false` for odd.
127    pub even: bool,
128}
129
130/// Item kind in its laid-out form (y offset from lane center already applied).
131///
132/// `color` is the resolved CSS color string (from tag overrides or lane palette).
133/// `tooltip` is the formatted tooltip text before XML escaping.
134#[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        /// Resolved CSS color (e.g. `"#4682B4"`).
143        color: String,
144        /// Formatted tooltip text (XML-unescaped).
145        tooltip: String,
146    },
147    EventRange {
148        item: &'a Item,
149        x: f64,
150        y: f64,
151        width: f64,
152        height: f64,
153        /// Resolved CSS color (base; emitters may add fill-opacity).
154        color: String,
155        /// Formatted tooltip text (XML-unescaped).
156        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        /// Resolved CSS color.
165        color: String,
166        /// Formatted tooltip text (XML-unescaped).
167        tooltip: String,
168    },
169}
170
171/// Pre-computed layout: every coordinate needed by the renderer.
172pub 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    /// Pre-computed lane background bands (index-ordered, same order as `lanes_ordered`).
184    pub lane_bands: Vec<LaneBandModel>,
185    /// Mapping from lane ID to resolved CSS color (palette-assigned).
186    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            // 同一年内のレンジ(例: range 1939-09..1939-10): items から導出せず一年幅を確保
196            (year_min, year_max + 1)
197        } else {
198            // Fallback: if range is degenerate, derive from items.
199            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        // lane_y stores:
210        //   horizontal → lane center Y coordinate
211        //   vertical   → lane center X coordinate (reusing the same field for "lane primary axis")
212        let mut lane_y = HashMap::new();
213        if is_vertical {
214            for (idx, lane) in lanes_ordered.iter().enumerate() {
215                // left_gutter is reserved for the time-axis labels on the left; lanes go rightward.
216                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            // vertical: time axis is Y, lanes are X columns.
228            // lane_height is reused as the lane column width.
229            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        // lane_colors: palette-assigned CSS color per lane ID.
241        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        // lane_bands: background band geometry per lane.
253        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    /// Returns `true` when the layout uses a vertical (top-to-bottom time axis) orientation.
322    pub fn is_vertical(&self) -> bool {
323        self.opts.orientation == Orientation::Vertical
324    }
325
326    /// Convert a year to the primary axis coordinate.
327    ///
328    /// - Horizontal: returns the X coordinate.
329    /// - Vertical:   returns the Y coordinate.
330    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    /// Month minor-tick positions for `unit=month` timelines.
343    ///
344    /// Returns `(year, month)` pairs where month ∈ 2..=12 (month=1 overlaps the year tick).
345    /// Empty when `unit != "month"` or when the scale is too small to show sub-year ticks.
346    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    /// X coordinate for a (year, month) fractional position.
366    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    /// X coordinate for a (year, month, day) fractional position.
372    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    /// Day-level minor-tick positions for `unit=day` timelines.
378    ///
379    /// Returns `(year, month, day)` triples covering the visible range.
380    /// 過密回避のため、1日あたりの pixel-per-day が小さい場合は step を 7/14/30 日に切り替える。
381    /// `unit != "day"` または 1 日あたりのピクセルが小さすぎる場合は空配列を返す。
382    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        // 最低でも 1px の間隔を要求。完全に詰まる場合は描画しない(年単位描画に委ねる)
389        if pixels_per_day < 0.5 {
390            return Vec::new();
391        }
392
393        // 1 tick あたり最低 6 px を確保するための step(日数)
394        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    /// Tick positions (year values) within [year_min, year_max], inclusive of year_min if aligned.
427    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    /// Grid line positions for the current `GridStyle`.
442    ///
443    /// Returns fractional year values (f64) covering [year_min, year_max].
444    /// - `GridStyle::None`   → empty (no grid lines drawn)
445    /// - `GridStyle::Decade` → one position per 10 years
446    /// - `GridStyle::Year`   → one position per year
447    /// - `GridStyle::Month`  → one position per 1/12 year (12 per year)
448    ///
449    /// Positions that coincide with existing axis ticks are included; the SVG
450    /// renderer draws grid lines behind tick marks so duplicates are invisible.
451    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
483// --- item layout helpers ---
484
485/// Arguments for [`compute_item`].
486///
487/// Bundling them collapses the orientation-specific compute functions into one
488/// and removes the `too_many_arguments` clippy escape that the previous
489/// horizontal/vertical pair required.
490struct ItemLayoutArgs<'a> {
491    /// Lane axis position. For horizontal layouts this is the lane center Y
492    /// coordinate; for vertical layouts it is the lane center X coordinate.
493    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
502/// Compute the laid-out coordinates for a single item.
503///
504/// The orientation-specific projection collapses into a single primary/cross
505/// axis pair: the time axis is the *primary* axis (X horizontally, Y
506/// vertically) and the lane axis is the *cross* axis. The final
507/// [`LaidItem`] fields are populated by mapping (primary, cross) back into
508/// (x, y) using [`ItemLayoutArgs::orientation`].
509///
510/// For [`Item::Event`] in vertical orientation, the `LaidItem::Event` fields
511/// are reused with shifted semantics: `x` holds the lane axis, and
512/// `y_top`/`y_bottom`/`y_dot` hold time-axis values. The SVG emitter detects
513/// this via [`LayoutModel::is_vertical`] and renders the stem horizontally.
514fn 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            // 仕様 §1.4: start は year/月の頭、end は year/月の末日を採用(混在精度補完)
542            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            // Horizontal bands sit just below the lane center
577            // (EVENT_RANGE_Y_OFFSET); vertical bands are centered on the lane
578            // axis. This asymmetry is preserved verbatim from the original
579            // split implementation.
580            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                // x = lane axis; y_top/y_bottom/y_dot all live on the time axis.
618                (
619                    lane_axis,
620                    primary - EVENT_STEM_H,
621                    primary + EVENT_STEM_H,
622                    primary,
623                )
624            } else {
625                // x = time axis; y_top/y_bottom/y_dot live on the lane axis.
626                (
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
646// --- sub-layout constants ---
647const SPAN_HALF_H: f64 = 12.0;
648/// Approximate rendered width (px) of the longest axis label ("BC9999" at 11 px font-size).
649const 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
666/// Resolve item fill color: tag overrides take priority over lane palette.
667pub(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
684/// Format a year for display: negative years get a "BC" prefix.
685pub(crate) fn format_year(year: i64) -> String {
686    if year < 0 {
687        format!("BC{}", -year)
688    } else {
689        format!("{year}")
690    }
691}
692
693/// Short three-letter English month abbreviation.
694pub(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
712/// Format a date for display, with optional month and day precision.
713pub(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
741/// Build the tooltip text for an item (XML-unescaped).
742fn 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
812/// Convert year + optional month + optional day to a fractional year value.
813fn 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
832/// Compute the (start, extent) of a span/event-range projected onto the time
833/// (primary) axis.
834///
835/// `anchor` is the pixel coordinate where `year_min` falls on the primary
836/// axis: `left_gutter` for horizontal layouts, `top_margin` for vertical
837/// layouts. The same formula serves both orientations.
838fn 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
876/// Pick a tick step so that labels do not visually overlap.
877/// `step * scale` must be at least `label_px + 8` px (minimum inter-label gap).
878fn 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        // With scale=2.0 and left_gutter=120, year -500 → x=120, year 0 → x=120+500*2=1120
930        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        // scale=2.0, label_px=40.0 → min_pitch=48 → step=25 (25*2=50 ≥ 48)
938        assert_eq!(pick_tick_step(80, 2.0, 40.0), 25);
939        // range=79 previously jumped to step=5 (10px pitch) which caused overlap; now stays 25
940        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        // scale=4.0 → step=20 (20*4=80 ≥ 48)
944        assert_eq!(pick_tick_step(80, 4.0, 40.0), 20);
945        // scale=1.0 → step=50 (50*1=50 ≥ 48)
946        assert_eq!(pick_tick_step(100, 1.0, 40.0), 50);
947        // scale=0.5 → step=100 (100*0.5=50 ≥ 48)
948        assert_eq!(pick_tick_step(2500, 0.5, 40.0), 100);
949    }
950
951    #[test]
952    fn tick_step_no_overlap_invariant() {
953        // Core invariant: step * scale >= label_px + min_gap for all representative combinations.
954        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    // ─── unit day レンダリング (#248) ─────────────────────────────────
977
978    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        // 1ヶ月分(30日)を大きめスケールで描画 → 1日 step
1018        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, // pixels_per_day = 6 → step=1
1027            ..RenderOptions::default()
1028        };
1029        let layout = LayoutModel::compute(&ir, opts);
1030        let ticks = layout.day_ticks();
1031        // 1939年内+1940年の日々
1032        assert!(!ticks.is_empty(), "expected day ticks but got none");
1033        // 1939-01-01 が含まれる
1034        assert!(ticks.contains(&(1939, 1, 1)));
1035        // 1939-12-31 が含まれる
1036        assert!(ticks.contains(&(1939, 12, 31)));
1037    }
1038
1039    #[test]
1040    fn day_ticks_step_thins_for_lower_density() {
1041        // 中スケール → 1日あたり 3px (step=2): 月初+奇数日が描画される
1042        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        // 月初は常に含まれる
1056        assert!(ticks.contains(&(1939, 1, 1)));
1057        assert!(ticks.contains(&(1939, 2, 1)));
1058        // step=2 のとき、1, 3, 5, ... のみが描画される
1059        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        // pixels_per_day ≈ 1.5 → step=7
1066        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, // pixels_per_day=2 → step=7
1075            ..RenderOptions::default()
1076        };
1077        let layout = LayoutModel::compute(&ir, opts);
1078        let ticks = layout.day_ticks();
1079        // 月初は描画
1080        assert!(ticks.contains(&(1939, 1, 1)));
1081        // 1, 8, 15, 22, 29 が含まれる(step=7)
1082        assert!(ticks.contains(&(1939, 1, 8)));
1083        // 2, 3, 4 は含まれない
1084        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, // pixels_per_day ≈ 0.0055 → 描画不可
1099            ..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        // `span x 1939..1945` は start=1939-01-01, end=1945-12-31 として描画されるべき
1108        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        // start_frac(1939)=1939.0, end_frac(1945)≈1945.998
1146        // x = left_gutter(120) + (1939-1900)*scale(2) = 120 + 78 = 198
1147        // width = (end_frac - start_frac) * scale ≈ 6.998 * 2 ≈ 13.996
1148        assert!(
1149            (span.0 - 198.0).abs() < 0.01,
1150            "expected x ≈ 198, got {}",
1151            span.0
1152        );
1153        // 旧実装 (to_year_frac) なら width = (1945 - 1939) * 2 = 12.0、
1154        // 新実装 (end_frac) なら ≈ 13.996。明確に差が出る。
1155        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        // start clamped to -500 → x=120
1214        assert_eq!(x, 120.0);
1215        // end clamped to 200 → width = (200-(-500))*2 = 1400
1216        assert_eq!(w, 1400.0);
1217    }
1218
1219    #[test]
1220    fn primary_axis_segment_matches_anchor_for_vertical() {
1221        // Same arithmetic as the horizontal case but with a different anchor
1222        // (top_margin instead of left_gutter); ensures the unified helper
1223        // covers the orientation that previously had its own
1224        // span_y_height_frac_vertical implementation.
1225        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        // February (month=2) should be 1/12 of a year to the right of January (no month).
1233        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    // ─── to_year_frac 精度テスト ──────────────────────────────────────────
1239
1240    #[test]
1241    fn to_year_frac_year_only() {
1242        // 年のみ指定: フラクショナル値 = 整数年
1243        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        // month=1 は +0/12、month=7 は +6/12 ≈ +0.5
1251        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        // month=12 は +11/12 ≈ +0.917
1258        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        // month=1, day=1: オフセットなし
1268        assert_eq!(to_year_frac(1939, Some(1), Some(1)), 1939.0);
1269        // month=1, day=2: +1/365.25 オフセット
1270        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        // month=3, day=15: month offset + day offset
1276        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    // ─── month_ticks テスト ──────────────────────────────────────────────
1285
1286    #[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        // scale/12 < 1.0 のとき空配列
1302        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, // 6/12 = 0.5 < 1.0
1311            ..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        // scale/12 >= 1.0 のとき month=2..=12 のティックを返す
1320        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, // 24/12 = 2.0 >= 1.0
1329            ..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        // 月初 (month=1) はティックに含まれない(年目盛と重複回避)
1335        assert!(
1336            !ticks.contains(&(1939, 1)),
1337            "month=1 should not appear in month_ticks"
1338        );
1339        // February は含まれる
1340        assert!(
1341            ticks.contains(&(1939, 2)),
1342            "expected (1939,2) in month_ticks"
1343        );
1344        // December は含まれる
1345        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}