Skip to main content

datui_lib/widgets/
axes.rs

1//! Axis ticks, labels, titles and the grid for every chart. ratatui places whatever
2//! labels it is given, cutting them or running them together on a narrow plot, and
3//! draws the axis titles over the plot's corners. So datui chooses the ticks, draws
4//! their labels and marks, and gives each title a row of its own or none.
5//!
6//! Ticks fall on nice values: 1, 2 or 5 times a power of ten, or calendar boundaries
7//! on a time axis, as many as the space holds (about one label per 15 columns, one
8//! per 4 rows). The rule for labels, on every chart: they never touch, two cells
9//! between them. A crowded axis first takes a coarser step, then a shorter form of
10//! its labels (`12.3k`, or a date without its year); the ends of a fixed axis stay
11//! while anything fits. A title never covers the plot: it is cut to its row, and
12//! dropped when the plot has no rows to spare.
13
14use ratatui::{
15    buffer::Buffer,
16    layout::Rect,
17    style::Style,
18    symbols::Marker,
19    text::Span,
20    widgets::{Axis, Chart, Widget},
21};
22use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
23
24use crate::chart_data::{AxisFormat, AxisNumbers, XAxisTemporalKind, x_axis_label_at};
25use crate::glyphs::Glyphs;
26use crate::widgets::ticks;
27
28/// Rows the plot keeps before an axis title gives up its row.
29const MIN_PLOT_ROWS: u16 = 3;
30/// Forms a label steps through at most, so a label that never runs out stops.
31const MAX_LEVELS: usize = 8;
32/// Cells between two x labels: one would do, but two dates a space apart read as a
33/// range.
34const LABEL_GAP: u16 = 2;
35/// Columns between x labels the axis aims for, and the fewest it takes.
36const X_SPACING: f64 = 15.0;
37const X_LEAST: f64 = 6.0;
38/// Rows between y labels the axis aims for, and the fewest it takes.
39const Y_SPACING: f64 = 4.0;
40const Y_LEAST: f64 = 2.0;
41/// The fewest cells between minor ticks, across and down.
42const X_MINOR_GAP: f64 = 3.0;
43const Y_MINOR_GAP: f64 = 3.0;
44
45/// A tick's label at a level of detail, 0 the fullest; `None` past the shortest.
46pub type TickLabel<'a> = Box<dyn Fn(f64, usize) -> Option<String> + 'a>;
47
48/// How an axis chooses its ticks.
49enum Scale<'a> {
50    /// Nice numbers, written in one format; `widen` stretches the axis out to the
51    /// ticks either side of its ends, so a y axis starts and ends on a label.
52    Numbers { numbers: AxisNumbers, widen: bool },
53    /// Calendar boundaries on a time axis.
54    Calendar {
55        kind: XAxisTemporalKind,
56        numbers: AxisNumbers,
57    },
58    /// A log scale, where position `v` stands for the value `exp_m1(v)`, from zero
59    /// up: ticks at each power of ten, with 2 and 5 between when there is room.
60    Log { numbers: AxisNumbers },
61    /// Ticks given in advance: evenly spaced ones thin out keeping both ends.
62    Fixed {
63        ticks: Vec<f64>,
64        label: TickLabel<'a>,
65    },
66}
67
68/// One axis: its range, how its ticks are chosen and written, and its title.
69pub struct AxisSpec<'a> {
70    pub bounds: [f64; 2],
71    scale: Scale<'a>,
72    pub title: &'a str,
73    /// Labels right-aligned in at least this many cells.
74    pad: usize,
75}
76
77/// A way to tick an axis: where, the minor ticks between, and the labels at each
78/// level of detail.
79#[derive(Clone, Debug, Default)]
80pub struct TickSet {
81    pub ticks: Vec<f64>,
82    pub minor: Vec<f64>,
83    pub levels: Vec<Vec<String>>,
84    /// The axis's range under this set: widened to the outer ticks, or as given.
85    pub bounds: [f64; 2],
86}
87
88impl<'a> AxisSpec<'a> {
89    /// An axis ticked at `ticks`, written by `label`. Evenly spaced ticks thin out
90    /// keeping both ends.
91    pub fn fixed(bounds: [f64; 2], ticks: Vec<f64>, label: TickLabel<'a>, title: &'a str) -> Self {
92        Self {
93            bounds,
94            scale: Scale::Fixed { ticks, label },
95            title,
96            pad: 0,
97        }
98    }
99
100    /// Ticks at the ends of `bounds` and halfway between.
101    pub fn ends_and_middle(bounds: [f64; 2], label: TickLabel<'a>, title: &'a str) -> Self {
102        let [lo, hi] = bounds;
103        Self::fixed(bounds, vec![lo, (lo + hi) / 2.0, hi], label, title)
104    }
105
106    /// A numeric axis over `bounds` holding `numbers`, ticked at nice values (whole
107    /// ones when the numbers are whole), every tick in one format.
108    pub fn numbers(bounds: [f64; 2], numbers: &AxisNumbers, title: &'a str) -> Self {
109        Self {
110            bounds,
111            scale: Scale::Numbers {
112                numbers: numbers.clone(),
113                widen: false,
114            },
115            title,
116            pad: 0,
117        }
118    }
119
120    /// A numeric y axis: widened out to the nice ticks either side of its range, so
121    /// it starts and ends on a label, short of a tick that would leave most of a
122    /// step empty ([`ticks::widen_snug`]).
123    pub fn y_numbers(bounds: [f64; 2], numbers: &AxisNumbers, title: &'a str) -> Self {
124        Self {
125            scale: Scale::Numbers {
126                numbers: numbers.clone(),
127                widen: true,
128            },
129            ..Self::numbers(bounds, numbers, title)
130        }
131    }
132
133    /// A time axis of `kind` over `bounds`, ticked on calendar boundaries; `numbers`
134    /// writes a value past what a date can stand for.
135    pub fn calendar(
136        bounds: [f64; 2],
137        kind: XAxisTemporalKind,
138        numbers: &AxisNumbers,
139        title: &'a str,
140    ) -> Self {
141        if kind == XAxisTemporalKind::Numeric {
142            return Self::numbers(bounds, numbers, title);
143        }
144        Self {
145            bounds,
146            scale: Scale::Calendar {
147                kind,
148                numbers: numbers.clone(),
149            },
150            title,
151            pad: 0,
152        }
153    }
154
155    /// A numeric axis whose position `v` stands for the number `shown(v)`, as on a log
156    /// scale: ticked at its ends and middle, its format chosen from the numbers they
157    /// stand for.
158    pub fn numbers_as(
159        bounds: [f64; 2],
160        numbers: &AxisNumbers,
161        title: &'a str,
162        shown: impl Fn(f64) -> f64 + 'a,
163    ) -> Self {
164        let [lo, hi] = bounds;
165        let ticks = vec![lo, (lo + hi) / 2.0, hi];
166        let shown_ticks: Vec<f64> = ticks.iter().map(|&v| shown(v)).collect();
167        let format = AxisFormat::new(&shown_ticks, numbers);
168        let label = Box::new(move |v, level| format.label(shown(v), level));
169        Self::fixed(bounds, ticks, label, title)
170    }
171
172    /// A y axis on a log scale, position `v` standing for the value `exp_m1(v)` as
173    /// the chart draws it: ticked at nice values (1, 10, 100, and 2 and 5 between
174    /// when there is room) and widened to the ticks either side of its range, every
175    /// tick in one format.
176    pub fn y_log(bounds: [f64; 2], numbers: &AxisNumbers, title: &'a str) -> Self {
177        Self {
178            bounds,
179            scale: Scale::Log {
180                numbers: numbers.clone(),
181            },
182            title,
183            pad: 0,
184        }
185    }
186
187    /// The same axis, its labels right-aligned in at least `width` cells.
188    pub fn padded(self, width: usize) -> Self {
189        Self { pad: width, ..self }
190    }
191
192    /// The ways to tick this axis along `track`, the preferred first: about one label
193    /// per `spacing` cells, then coarser. A second group follows when the first may
194    /// come up empty: a time axis's dates at its ends and middle.
195    fn tick_sets(
196        &self,
197        track: Track,
198        spacing: f64,
199        least: f64,
200        minor_gap: f64,
201    ) -> Vec<Vec<TickSet>> {
202        let [lo, hi] = self.bounds;
203        let length = track.length();
204        match &self.scale {
205            Scale::Fixed { ticks, label } => vec![
206                strides(ticks.len())
207                    .map(|subset| {
208                        let ticks: Vec<f64> = subset.iter().map(|&i| ticks[i]).collect();
209                        let levels = (0..MAX_LEVELS)
210                            .map_while(|level| ticks.iter().map(|&v| label(v, level)).collect())
211                            .collect();
212                        TickSet {
213                            ticks,
214                            minor: Vec::new(),
215                            levels,
216                            bounds: self.bounds,
217                        }
218                    })
219                    .collect(),
220            ],
221            Scale::Numbers { numbers, widen } => {
222                vec![number_sets(
223                    self.bounds,
224                    numbers,
225                    *widen,
226                    length,
227                    spacing,
228                    least,
229                    minor_gap,
230                )]
231            }
232            Scale::Log { numbers } => vec![log_sets(
233                self.bounds,
234                numbers,
235                length,
236                spacing,
237                least,
238                minor_gap,
239            )],
240            Scale::Calendar { kind, numbers } => {
241                let primary = calendar_sets(self.bounds, *kind, length, spacing, least, minor_gap);
242                let format = AxisFormat::ends_and_middle(self.bounds, numbers);
243                let kind = *kind;
244                let ends = AxisSpec::ends_and_middle(
245                    self.bounds,
246                    Box::new(move |v, level| x_axis_label_at(v, kind, (lo, hi), level, &format)),
247                    "",
248                );
249                let fallback = ends.tick_sets(track, spacing, least, minor_gap).remove(0);
250                vec![primary, fallback]
251            }
252        }
253    }
254}
255
256/// Whether `sets` hold more than one tick each where it counts.
257fn with_ticks(sets: Vec<TickSet>) -> Vec<TickSet> {
258    sets.into_iter().filter(|s| s.ticks.len() >= 2).collect()
259}
260
261/// One way to tick an axis, as [`preferred`] weighs it: the fewest cells between two
262/// of its ticks, and how many ticks it has.
263struct Candidate<T> {
264    set: T,
265    gap: f64,
266    ticks: usize,
267}
268
269/// Of the options, finest first: the one nearest `spacing` apart and every coarser
270/// one, those at least `least` apart. Two ticks say little, so when the nearest has
271/// only two and a finer one is still `least` apart, the finer one comes first: a
272/// narrow 0 to 7 reads `0 2 4 6`, not `0 5`. Its labels may still not fit, and then
273/// the two do.
274fn preferred<T>(options: Vec<Candidate<T>>, spacing: f64, least: f64) -> Vec<T> {
275    let closeness = |gap: f64| (gap / spacing).ln().abs();
276    let best = options
277        .iter()
278        .enumerate()
279        .filter(|(_, o)| o.gap >= least)
280        .min_by(|a, b| closeness(a.1.gap).total_cmp(&closeness(b.1.gap)))
281        .map(|(i, _)| i);
282    let best = best.map(|best| {
283        if options[best].ticks > 2 {
284            return best;
285        }
286        (0..best)
287            .rev()
288            .find(|&i| options[i].gap >= least)
289            .unwrap_or(best)
290    });
291    match best {
292        Some(best) => options.into_iter().skip(best).map(|o| o.set).collect(),
293        // Nothing is far enough apart: the coarsest, which may still have room.
294        None => options
295            .into_iter()
296            .last()
297            .map(|o| o.set)
298            .into_iter()
299            .collect(),
300    }
301}
302
303/// Nice-number tick sets over `bounds` for an axis `length` cells long.
304fn number_sets(
305    bounds: [f64; 2],
306    numbers: &AxisNumbers,
307    widen: bool,
308    length: f64,
309    spacing: f64,
310    least: f64,
311    minor_gap: f64,
312) -> Vec<TickSet> {
313    let [lo, hi] = bounds;
314    if hi.partial_cmp(&lo) != Some(std::cmp::Ordering::Greater) || length <= 0.0 {
315        // A point or nothing: the one value there is.
316        let format = AxisFormat::new(&[lo], numbers);
317        let levels = (0..2)
318            .map_while(|level| format.label(lo, level).map(|l| vec![l]))
319            .collect();
320        return vec![TickSet {
321            ticks: vec![lo],
322            minor: Vec::new(),
323            levels,
324            bounds,
325        }];
326    }
327    let finest = (hi - lo) * least.min(minor_gap) / length;
328    let options: Vec<Candidate<(f64, [f64; 2])>> = ticks::nice_steps(lo, hi, finest, numbers.whole)
329        .into_iter()
330        .map(|step| {
331            let range = if widen {
332                ticks::widen_snug(lo, hi, step, numbers.whole)
333            } else {
334                bounds
335            };
336            Candidate {
337                set: (step, range),
338                gap: step / (range[1] - range[0]) * length,
339                ticks: ticks::multiples(range[0], range[1], step).len(),
340            }
341        })
342        .filter(|o| o.ticks >= 2)
343        .collect();
344    let sets = preferred(options, spacing, least)
345        .into_iter()
346        .map(|(step, range)| {
347            let majors = ticks::multiples(range[0], range[1], step);
348            let format = AxisFormat::new(&majors, numbers);
349            let levels = (0..2)
350                .map_while(|level| majors.iter().map(|&v| format.label(v, level)).collect())
351                .collect();
352            let per_cell = length / (range[1] - range[0]);
353            let minor = ticks::minor_steps(step, numbers.whole)
354                .into_iter()
355                .find(|m| m * per_cell >= minor_gap)
356                .map(|m| {
357                    ticks::multiples(range[0], range[1], m)
358                        .into_iter()
359                        .filter(|v| !majors.iter().any(|t| (t - v).abs() < m * 1e-6))
360                        .collect()
361                })
362                .unwrap_or_default();
363            TickSet {
364                ticks: majors,
365                minor,
366                levels,
367                bounds: range,
368            }
369        })
370        .collect();
371    with_ticks(sets)
372}
373
374/// Log-scale tick sets over `bounds`, positions standing for `exp_m1` of them, for an
375/// axis `length` cells long. From one up, the values at each power of ten (with 2 and
376/// 5 between, or every second or third power), 0 below them where the axis starts
377/// there; under one, nice steps as on a plain axis, which a log scale this close to
378/// zero nearly is. Each set widens the axis to its ticks either side of the data.
379fn log_sets(
380    bounds: [f64; 2],
381    numbers: &AxisNumbers,
382    length: f64,
383    spacing: f64,
384    least: f64,
385    minor_gap: f64,
386) -> Vec<TickSet> {
387    let [lo, hi] = bounds;
388    let (low, high) = (lo.exp_m1().max(0.0), hi.exp_m1());
389    if hi.partial_cmp(&lo) != Some(std::cmp::Ordering::Greater) || length <= 0.0 || high <= 0.0 {
390        let format = AxisFormat::log(&[low], numbers);
391        return vec![TickSet {
392            ticks: vec![lo],
393            minor: Vec::new(),
394            levels: vec![vec![format.label(low, 0).unwrap_or_default()]],
395            bounds,
396        }];
397    }
398    // Each option: its values (before the log) and the minor ones between.
399    let options: Vec<(Vec<f64>, Vec<f64>)> = if high <= 1.0 {
400        // Under one a log scale is close to linear, never more than twice as steep.
401        let finest = (high - low) * least.min(minor_gap) / length / 2.0;
402        ticks::nice_steps(low, high, finest, false)
403            .into_iter()
404            .map(|step| {
405                let [a, b] = ticks::widen(low, high, step);
406                (ticks::multiples(a, b, step), Vec::new())
407            })
408            .collect()
409    } else {
410        let decade = |v: f64| (v.log10() + 1e-9).floor() as i32;
411        // Every `every` powers of ten from the one at or under the data's least (one
412        // at the least, or zero) to the one at or over its most.
413        let values = |mantissas: &[f64], every: i32| {
414            let first = if low >= 1.0 { decade(low) } else { 0 };
415            let first = first - first.rem_euclid(every);
416            let mut values: Vec<f64> = if low < 1.0 { vec![0.0] } else { Vec::new() };
417            let mut k = first;
418            'up: loop {
419                for &m in mantissas {
420                    // Parsed, so 2e21 is the double nearest it and not 2 times one.
421                    let v: f64 = format!("{m}e{k}").parse().unwrap_or(f64::INFINITY);
422                    values.push(v);
423                    if v >= high * (1.0 - 1e-9) {
424                        break 'up;
425                    }
426                }
427                k += every;
428                if k > 308 {
429                    break;
430                }
431            }
432            // A zero under them stands in for the one, which sits too close to it
433            // past a step of a whole power.
434            if every > 1 || mantissas.len() == 1 {
435                values.retain(|&v| v != 1.0 || low >= 1.0);
436            }
437            // From the tick at or under the data's least.
438            let start = values
439                .iter()
440                .rposition(|&v| v <= low * (1.0 + 1e-9))
441                .unwrap_or(0);
442            values.split_off(start)
443        };
444        let fine = values(&[1.0, 2.0, 5.0], 1);
445        let mut options = vec![(fine.clone(), Vec::new())];
446        for every in [1, 2, 3, 5, 10, 20, 50, 100] {
447            let majors = values(&[1.0], every);
448            let minor = if every == 1 {
449                fine.iter()
450                    .copied()
451                    .filter(|v| !majors.contains(v))
452                    .collect()
453            } else {
454                Vec::new()
455            };
456            options.push((majors, minor));
457        }
458        options
459    };
460    let candidates: Vec<Candidate<(Vec<f64>, Vec<f64>)>> = options
461        .into_iter()
462        .filter(|(values, _)| values.len() >= 2)
463        .map(|(values, minor)| {
464            let at: Vec<f64> = values.iter().map(|v| v.ln_1p()).collect();
465            let per_cell = length / (at[at.len() - 1] - at[0]);
466            let gap = at
467                .windows(2)
468                .map(|w| (w[1] - w[0]) * per_cell)
469                .fold(f64::INFINITY, f64::min);
470            Candidate {
471                ticks: values.len(),
472                set: (values, minor),
473                gap,
474            }
475        })
476        .collect();
477    preferred(candidates, spacing, least)
478        .into_iter()
479        .map(|(values, minor)| {
480            let format = AxisFormat::log(&values, numbers);
481            let levels = (0..2)
482                .map_while(|level| values.iter().map(|&v| format.label(v, level)).collect())
483                .collect();
484            let at: Vec<f64> = values.iter().map(|v| v.ln_1p()).collect();
485            let bounds = [at[0], at[at.len() - 1]];
486            let per_cell = length / (bounds[1] - bounds[0]);
487            // Minor ticks inside the axis, and only where every one keeps its
488            // distance from the next.
489            let minor: Vec<f64> = minor
490                .iter()
491                .filter(|v| **v > values[0] && **v < values[values.len() - 1])
492                .map(|v| v.ln_1p())
493                .collect();
494            let mut all: Vec<f64> = minor.iter().chain(&at).copied().collect();
495            all.sort_by(f64::total_cmp);
496            let roomy = all
497                .windows(2)
498                .all(|w| (w[1] - w[0]) * per_cell >= minor_gap);
499            let minor = if roomy { minor } else { Vec::new() };
500            TickSet {
501                ticks: at,
502                minor,
503                levels,
504                bounds,
505            }
506        })
507        .collect()
508}
509
510/// Calendar tick sets over `bounds` for a `kind` axis `length` cells long.
511fn calendar_sets(
512    bounds: [f64; 2],
513    kind: XAxisTemporalKind,
514    length: f64,
515    spacing: f64,
516    least: f64,
517    minor_gap: f64,
518) -> Vec<TickSet> {
519    let [lo, hi] = bounds;
520    let (Some(start), Some(end)) = (ticks::to_datetime(lo, kind), ticks::to_datetime(hi, kind))
521    else {
522        return Vec::new();
523    };
524    if hi.partial_cmp(&lo) != Some(std::cmp::Ordering::Greater) || length <= 0.0 {
525        return Vec::new();
526    }
527    let per_cell = length / (hi - lo);
528    // Every step with two ticks or more, finest first, each with the fewest cells
529    // between two of its ticks.
530    let all: Vec<(
531        ticks::CalendarStep,
532        Vec<chrono::NaiveDateTime>,
533        Vec<f64>,
534        f64,
535    )> = ticks::calendar_steps(kind)
536        .filter_map(|step| {
537            let at = ticks::calendar_ticks(start, end, step);
538            let values: Vec<f64> = at
539                .iter()
540                .map(|t| ticks::from_datetime(*t, kind))
541                .collect::<Option<_>>()?;
542            let gap = values
543                .windows(2)
544                .map(|w| (w[1] - w[0]) * per_cell)
545                .fold(f64::INFINITY, f64::min);
546            (values.len() >= 2).then_some((step, at, values, gap))
547        })
548        .collect();
549    let options = all
550        .iter()
551        .enumerate()
552        .map(|(i, (_, _, values, gap))| Candidate {
553            set: i,
554            gap: *gap,
555            ticks: values.len(),
556        })
557        .collect();
558    preferred(options, spacing, least)
559        .into_iter()
560        .map(|i| {
561            let (step, at, values, _) = &all[i];
562            // Minor ticks: the finest step that ticks at every one of these, and
563            // between, with room.
564            let minor = all[..i]
565                .iter()
566                .filter(|(.., gap)| *gap >= minor_gap)
567                .find(|(_, _, finer, _)| values.iter().all(|v| finer.contains(v)))
568                .map(|(_, _, finer, _)| {
569                    finer
570                        .iter()
571                        .copied()
572                        .filter(|v| !values.contains(v))
573                        .collect()
574                })
575                .unwrap_or_default();
576            TickSet {
577                ticks: values.clone(),
578                minor,
579                levels: vec![
580                    ticks::calendar_labels(at, step.unit, kind, true),
581                    ticks::calendar_labels(at, step.unit, kind, false),
582                ],
583                bounds,
584            }
585        })
586        .collect()
587}
588
589/// Where an axis's values land on screen: `cells` cells from `start`, each split in
590/// `sub` dots by the marker the plot draws with.
591#[derive(Clone, Copy, Debug)]
592pub struct Track {
593    pub start: u16,
594    pub cells: u16,
595    pub sub: u16,
596}
597
598impl Track {
599    /// The cell `f` of the way along, as ratatui's canvas rounds a point to its dot.
600    pub fn cell(&self, f: f64) -> u16 {
601        let dots = u32::from(self.cells) * u32::from(self.sub.max(1));
602        let dot = (f.clamp(0.0, 1.0) * f64::from(dots.saturating_sub(1))).round() as u32;
603        self.start + (dot / u32::from(self.sub.max(1))) as u16
604    }
605
606    /// Cells from the first value's to the last's.
607    fn length(&self) -> f64 {
608        let sub = f64::from(self.sub.max(1));
609        (f64::from(self.cells) * sub - 1.0).max(0.0) / sub
610    }
611}
612
613/// The dots per cell, across and down, of a canvas drawn with `marker`.
614pub fn resolution(marker: Marker) -> (u16, u16) {
615    match marker {
616        Marker::Braille | Marker::Octant => (2, 4),
617        Marker::Sextant => (2, 3),
618        Marker::Quadrant => (2, 2),
619        Marker::HalfBlock => (1, 2),
620        _ => (1, 1),
621    }
622}
623
624/// An axis's ticks as placed: each labeled tick's cell and label, and the cells of
625/// every major and minor tick.
626#[derive(Clone, Debug, Default)]
627pub struct Placed {
628    pub labels: Vec<(u16, String)>,
629    pub majors: Vec<u16>,
630    pub minors: Vec<u16>,
631    pub bounds: [f64; 2],
632}
633
634/// A chart's legend: a name per series, each in the style its series draws in.
635#[derive(Clone, Debug, Default)]
636pub struct Legend {
637    pub entries: Vec<(String, Style)>,
638}
639
640/// The widest a legend name is drawn before it is cut.
641const LEGEND_NAME_MAX: usize = 24;
642
643impl Legend {
644    /// The cells it covers for names `name_width` wide: a cell of air each side, the
645    /// swatch and a space, then the name; a row per series.
646    fn size(&self, name_width: usize) -> (u16, u16) {
647        (name_width as u16 + 4, self.entries.len() as u16)
648    }
649}
650
651/// A chart's two axes and how they are drawn.
652pub struct PlotAxes<'a> {
653    pub x: AxisSpec<'a>,
654    pub y: AxisSpec<'a>,
655    /// The axis lines and their tick marks.
656    pub line: Style,
657    pub labels: Style,
658    pub titles: Style,
659    /// The grid at the major ticks, in this style; `None` draws none.
660    pub grid: Option<Style>,
661    /// The marker the series draw with: ticks sit on the cells their values land on.
662    pub marker: Marker,
663    /// The legend, placed where it covers the fewest marks; `None` draws none.
664    pub legend: Option<Legend>,
665}
666
667/// Where a chart's parts sit in its area.
668pub struct PlotFrame {
669    pub y_title: Option<Rect>,
670    /// What ratatui's `Chart` is drawn in: the plot, its axes and y labels.
671    pub chart: Rect,
672    /// The plot inside `chart`, right of the y axis and above the x axis.
673    pub graph: Rect,
674    /// The x labels' row, under the x axis.
675    pub labels: Option<Rect>,
676    pub x_title: Option<Rect>,
677    /// The y axis's ticks, labels and range, rows counted on screen.
678    pub y: Placed,
679    y_width: u16,
680}
681
682impl<'a> PlotAxes<'a> {
683    /// Plain axes in `style`, as the chart view draws them: no grid, no legend, ticks
684    /// placed for `marker`.
685    pub fn new(x: AxisSpec<'a>, y: AxisSpec<'a>, style: Style, marker: Marker) -> Self {
686        Self {
687            x,
688            y,
689            line: style,
690            labels: style,
691            titles: style,
692            grid: None,
693            marker,
694            legend: None,
695        }
696    }
697
698    /// The frame for `area`: title rows while the plot keeps its rows, then the
699    /// plot as ratatui lays it out beside the y labels that fit.
700    pub fn frame(&self, area: Rect) -> PlotFrame {
701        // The x axis line and the label row under it.
702        let base = 2 + MIN_PLOT_ROWS;
703        let y_title = !self.y.title.is_empty() && area.height > base;
704        let x_title = !self.x.title.is_empty() && area.height > base + u16::from(y_title);
705        let mut chart = area;
706        let y_title = y_title.then(|| {
707            chart.y += 1;
708            chart.height -= 1;
709            Rect { height: 1, ..area }
710        });
711        let x_title = x_title.then(|| {
712            chart.height -= 1;
713            Rect {
714                y: chart.bottom(),
715                height: 1,
716                ..area
717            }
718        });
719        // The plot's rows do not depend on the labels' width; its columns do.
720        let rows = graph_area(chart, 0).0;
721        let track = Track {
722            start: rows.top(),
723            cells: rows.height,
724            sub: resolution(self.marker).1,
725        };
726        let y = fit_y_labels(&self.y, track, chart.width / 3);
727        let width = y.labels.iter().map(|(_, l)| l.width()).max().unwrap_or(0) as u16;
728        let (graph, labels) = graph_area(chart, width);
729        PlotFrame {
730            y_title,
731            chart,
732            graph,
733            labels,
734            x_title,
735            y,
736            y_width: width,
737        }
738    }
739
740    /// Draw `chart`'s datasets with these axes in `area`: the grid under them, the
741    /// legend over them, then the tick marks, labels and titles.
742    pub fn render(&self, chart: Chart<'_>, area: Rect, buf: &mut Buffer, g: &Glyphs) -> PlotFrame {
743        let frame = self.frame(area);
744        let x_track = Track {
745            start: frame.graph.left(),
746            cells: frame.graph.width,
747            sub: resolution(self.marker).0,
748        };
749        let x = frame
750            .labels
751            .map(|row| fit_x_labels(&self.x, (row.left(), row.right()), x_track))
752            .unwrap_or_default();
753        // Two empty x labels have ratatui keep the label row and draw the x axis line;
754        // blank y labels as wide as datui's keep the space left of the y axis, where
755        // datui writes them on the rows of their ticks.
756        let blank = " ".repeat(frame.y_width as usize);
757        let y_labels = if frame.y_width > 0 {
758            vec![Span::raw(blank.as_str()), Span::raw(blank.as_str())]
759        } else {
760            Vec::new()
761        };
762        let chart = chart
763            .x_axis(
764                Axis::default()
765                    .bounds(self.x.bounds)
766                    .style(self.line)
767                    .labels(["", ""]),
768            )
769            .y_axis(
770                Axis::default()
771                    .bounds(frame.y.bounds)
772                    .style(self.line)
773                    .labels(y_labels),
774            )
775            .legend_position(None);
776        // Placed on the marks alone, before the grid is drawn under them.
777        let legend = self
778            .legend
779            .as_ref()
780            .and_then(|legend| place_legend(&chart, &frame, legend, g));
781        if let Some(style) = self.grid {
782            draw_grid(buf, frame.graph, &x.majors, &frame.y.majors, style, g);
783        }
784        chart.render(frame.chart, buf);
785        g.plot.redraw_axes(frame.chart, buf);
786        draw_tick_marks(buf, &frame, &x, self.line, g);
787        let label_x = frame.chart.left();
788        for (row, label) in &frame.y.labels {
789            let pad = (frame.y_width as usize).saturating_sub(label.width());
790            buf.set_string(label_x + pad as u16, *row, label, self.labels);
791        }
792        if let Some(row) = frame.labels {
793            for (x, label) in &x.labels {
794                buf.set_string(*x, row.y, label, self.labels);
795            }
796        }
797        if let Some(row) = frame.y_title {
798            let title = cut(self.y.title, row.width as usize, g);
799            buf.set_string(row.x, row.y, title, self.titles);
800        }
801        if let Some(row) = frame.x_title {
802            let title = cut(self.x.title, row.width as usize, g);
803            let x = row.right() - title.width() as u16;
804            buf.set_string(x, row.y, title, self.titles);
805        }
806        if let (Some(legend), Some(place)) = (&self.legend, legend) {
807            draw_legend(buf, legend, place, self.labels, g);
808        }
809        frame
810    }
811}
812
813/// Where a legend goes: its area, and how wide its names are drawn.
814#[derive(Clone, Copy, Debug)]
815struct LegendPlace {
816    area: Rect,
817    name_width: usize,
818}
819
820/// Where in `frame`'s plot the legend covers the fewest of `chart`'s marks: a
821/// corner, or the middle of an edge. A braille cell counts its dots, so a sparse
822/// patch wins over a dense one. Corners first on a tie, the top right first. `None`
823/// when the plot is too small to give it a quarter.
824fn place_legend(
825    chart: &Chart<'_>,
826    frame: &PlotFrame,
827    legend: &Legend,
828    g: &Glyphs,
829) -> Option<LegendPlace> {
830    let graph = frame.graph;
831    let widest = legend
832        .entries
833        .iter()
834        .map(|(name, _)| name.width())
835        .max()
836        .unwrap_or(0);
837    let name_width = widest
838        .min(LEGEND_NAME_MAX)
839        .min((graph.width / 2).saturating_sub(4) as usize);
840    let (w, h) = legend.size(name_width);
841    if legend.entries.is_empty()
842        || name_width == 0
843        || name_width < widest.min(4)
844        || w > graph.width / 2
845        || h > graph.height / 2
846    {
847        return None;
848    }
849    let mut probe = Buffer::empty(frame.chart);
850    chart.clone().render(frame.chart, &mut probe);
851    let weight = |symbol: &str| -> usize {
852        let mut chars = symbol.chars();
853        match (chars.next(), chars.next()) {
854            (None | Some(' '), _) => 0,
855            (Some(c), None) if ('\u{2800}'..='\u{28ff}').contains(&c) => {
856                (c as u32 - 0x2800).count_ones() as usize
857            }
858            _ => 1,
859        }
860    };
861    // The axes ratatui drew are not marks.
862    let axis = [g.plot.axis.vertical, g.plot.axis.horizontal];
863    let marks = |x0: u16, y0: u16| -> usize {
864        (y0..y0 + h)
865            .flat_map(|y| (x0..x0 + w).map(move |x| (x, y)))
866            .map(|(x, y)| probe[(x, y)].symbol())
867            .filter(|symbol| !axis.contains(symbol))
868            .map(weight)
869            .sum()
870    };
871    let (left, right) = (graph.left(), graph.right() - w);
872    let (top, bottom) = (graph.top(), graph.bottom() - h);
873    let (center, middle) = (left + (right - left) / 2, top + (bottom - top) / 2);
874    [
875        (right, top),
876        (left, top),
877        (right, bottom),
878        (left, bottom),
879        (center, top),
880        (center, bottom),
881        (left, middle),
882        (right, middle),
883    ]
884    .into_iter()
885    .min_by_key(|&(x, y)| marks(x, y))
886    .map(|(x, y)| LegendPlace {
887        area: Rect::new(x, y, w, h),
888        name_width,
889    })
890}
891
892/// The legend in `place`: on the plot's background, cleared of the marks under it,
893/// a swatch in each series' color and its name beside it. No frame: the cleared
894/// patch sets it off.
895fn draw_legend(buf: &mut Buffer, legend: &Legend, place: LegendPlace, text: Style, g: &Glyphs) {
896    let area = place.area;
897    for y in area.top()..area.bottom() {
898        for x in area.left()..area.right() {
899            buf[(x, y)].reset();
900        }
901    }
902    for ((name, style), y) in legend.entries.iter().zip(area.top()..) {
903        buf.set_string(area.x + 1, y, g.bar_eighths[7], *style);
904        let name = cut(name, place.name_width, g);
905        buf.set_string(area.x + 3, y, name, text);
906    }
907}
908
909/// The grid: a dotted line across at each y tick and down at each x tick, but not
910/// beside the axes, where it would double them. Drawn before the series, whose marks
911/// replace it where they fall.
912fn draw_grid(
913    buf: &mut Buffer,
914    graph: Rect,
915    columns: &[u16],
916    rows: &[u16],
917    style: Style,
918    g: &Glyphs,
919) {
920    let rows: Vec<u16> = rows
921        .iter()
922        .copied()
923        .filter(|&y| y + 1 < graph.bottom())
924        .collect();
925    let columns: Vec<u16> = columns
926        .iter()
927        .copied()
928        .filter(|&x| x > graph.left())
929        .collect();
930    for &y in &rows {
931        for x in graph.left()..graph.right() {
932            buf[(x, y)].set_symbol(g.plot.grid_across).set_style(style);
933        }
934    }
935    for &x in &columns {
936        for y in graph.top()..graph.bottom() {
937            buf[(x, y)].set_symbol(g.plot.grid_down).set_style(style);
938        }
939    }
940}
941
942/// A mark on the axis line at every tick, major or minor.
943fn draw_tick_marks(buf: &mut Buffer, frame: &PlotFrame, x: &Placed, style: Style, g: &Glyphs) {
944    let graph = frame.graph;
945    if graph.left() > frame.chart.left() {
946        let column = graph.left() - 1;
947        for &row in frame.y.majors.iter().chain(&frame.y.minors) {
948            let cell = &mut buf[(column, row)];
949            if cell.symbol() == g.plot.axis.vertical {
950                cell.set_symbol(g.plot.tick_y).set_style(style);
951            }
952        }
953    }
954    let row = graph.bottom();
955    if row < frame.chart.bottom() {
956        for &column in x.majors.iter().chain(&x.minors) {
957            let cell = &mut buf[(column, row)];
958            if cell.symbol() == g.plot.axis.horizontal {
959                cell.set_symbol(g.plot.tick_x).set_style(style);
960            }
961        }
962    }
963}
964
965/// The plot and the x label row ratatui's `Chart` lays out in `chart` beside y
966/// labels `y_label_width` wide, when its x axis has labels. Kept in step with
967/// `Chart::layout`; `the_frame_matches_ratatuis_layout` checks it.
968fn graph_area(chart: Rect, y_label_width: u16) -> (Rect, Option<Rect>) {
969    if chart.is_empty() {
970        return (Rect::default(), None);
971    }
972    let mut x = chart.left();
973    let mut y = chart.bottom() - 1;
974    let mut labels = None;
975    if y > chart.top() {
976        labels = Some(Rect {
977            y,
978            height: 1,
979            ..chart
980        });
981        y -= 1;
982    }
983    x += y_label_width.min(chart.width / 3);
984    if y > chart.top() {
985        y -= 1;
986    }
987    if x + 1 < chart.right() {
988        x += 1;
989    }
990    let graph = Rect::new(
991        x,
992        chart.top(),
993        chart.right().saturating_sub(x),
994        y - chart.top() + 1,
995    );
996    (graph, labels)
997}
998
999/// Every subset of `n` evenly spaced ticks that keeps them evenly spaced and keeps
1000/// both ends, most ticks first.
1001fn strides(n: usize) -> impl Iterator<Item = Vec<usize>> {
1002    let last = n.saturating_sub(1);
1003    (1..=last.max(1))
1004        .filter(move |s| last.is_multiple_of(*s))
1005        .map(move |s| (0..n).step_by(s).collect())
1006}
1007
1008/// Where `v` falls along `bounds`, from 0 to 1.
1009fn fraction(v: f64, [lo, hi]: [f64; 2]) -> f64 {
1010    if hi > lo { (v - lo) / (hi - lo) } else { 0.0 }
1011}
1012
1013/// The y labels for a plot whose rows are `track`, with at most `width` cells left of
1014/// its axis: about one per four rows, each on its tick's row, in the fullest form
1015/// that fits the width and tells them apart.
1016pub fn fit_y_labels(axis: &AxisSpec<'_>, track: Track, width: u16) -> Placed {
1017    let groups = axis.tick_sets(track, Y_SPACING, Y_LEAST, Y_MINOR_GAP);
1018    let place = |set: &TickSet, labels: Option<&Vec<String>>| {
1019        let row = |v: f64| track.cell(1.0 - fraction(v, set.bounds));
1020        Placed {
1021            labels: labels
1022                .map(|labels| {
1023                    set.ticks
1024                        .iter()
1025                        .zip(labels)
1026                        .map(|(&v, l)| (row(v), format!("{l:>w$}", w = axis.pad)))
1027                        .collect()
1028                })
1029                .unwrap_or_default(),
1030            majors: set.ticks.iter().map(|&v| row(v)).collect(),
1031            minors: set.minor.iter().map(|&v| row(v)).collect(),
1032            bounds: set.bounds,
1033        }
1034    };
1035    let fits = |set: &TickSet, labels: &Vec<String>| {
1036        let rows: Vec<u16> = set
1037            .ticks
1038            .iter()
1039            .map(|&v| track.cell(1.0 - fraction(v, set.bounds)))
1040            .collect();
1041        set.ticks.len() <= usize::from(track.cells.max(2))
1042            && rows.windows(2).all(|w| w[0] != w[1])
1043            && labels
1044                .iter()
1045                .all(|l| l.width().max(axis.pad) <= width as usize)
1046            && distinct(labels.iter())
1047    };
1048    for set in groups.iter().flatten() {
1049        if let Some(labels) = set.levels.iter().find(|labels| fits(set, labels)) {
1050            return place(set, Some(labels));
1051        }
1052    }
1053    // Nothing fits: the first set in its fullest form, cut short by the plot.
1054    match groups.iter().flatten().next() {
1055        Some(set) => place(set, set.levels.first()),
1056        None => Placed {
1057            bounds: axis.bounds,
1058            ..Placed::default()
1059        },
1060    }
1061}
1062
1063/// Neighbors differ; two alike say nothing about the space between them.
1064fn distinct<'a>(labels: impl Iterator<Item = &'a String>) -> bool {
1065    let labels: Vec<_> = labels.collect();
1066    labels.windows(2).all(|w| w[0] != w[1])
1067}
1068
1069/// The x labels that fit on a row spanning columns `span` (start, end) under a plot
1070/// whose columns are `track`, each with its column: centered under its tick, kept on
1071/// the row, `LABEL_GAP` cells from the next. About one label per 15 columns; a
1072/// coarser step when they crowd, then shorter forms; none at all when even two do
1073/// not fit.
1074pub fn fit_x_labels(axis: &AxisSpec<'_>, span: (u16, u16), track: Track) -> Placed {
1075    let (start, end) = span;
1076    let column = |v: f64| track.cell(fraction(v, axis.bounds));
1077    let groups = axis.tick_sets(track, X_SPACING, X_LEAST, X_MINOR_GAP);
1078    for sets in &groups {
1079        for level in 0..MAX_LEVELS {
1080            for set in sets {
1081                let Some(labels) = set.levels.get(level) else {
1082                    continue;
1083                };
1084                let mut placed: Vec<(u16, String)> = Vec::with_capacity(labels.len());
1085                let mut next_free = start;
1086                let fits = set.ticks.iter().zip(labels).all(|(&v, label)| {
1087                    let w = label.width() as u16;
1088                    if w > end.saturating_sub(start) {
1089                        return false;
1090                    }
1091                    let x = column(v).saturating_sub(w / 2).clamp(start, end - w);
1092                    let clear =
1093                        x >= next_free && placed.last().is_none_or(|(_, prev)| prev != label);
1094                    next_free = x + w + LABEL_GAP;
1095                    placed.push((x, label.clone()));
1096                    clear
1097                });
1098                if fits {
1099                    return Placed {
1100                        labels: placed,
1101                        majors: set.ticks.iter().map(|&v| column(v)).collect(),
1102                        minors: set.minor.iter().map(|&v| column(v)).collect(),
1103                        bounds: axis.bounds,
1104                    };
1105                }
1106            }
1107        }
1108    }
1109    Placed {
1110        bounds: axis.bounds,
1111        ..Placed::default()
1112    }
1113}
1114
1115/// `text` in at most `width` cells, cut with the ellipsis when longer.
1116pub fn cut(text: &str, width: usize, g: &Glyphs) -> String {
1117    if text.width() <= width {
1118        return text.to_string();
1119    }
1120    let keep = width.saturating_sub(g.ellipsis.width());
1121    let mut used = 0;
1122    let mut out: String = text
1123        .chars()
1124        .take_while(|c| {
1125            used += c.width().unwrap_or(0);
1126            used <= keep
1127        })
1128        .collect();
1129    if keep + g.ellipsis.width() <= width {
1130        out.push_str(g.ellipsis);
1131    }
1132    out
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::*;
1138    use crate::chart_data::{XAxisTemporalKind, x_axis_label_at};
1139    use ratatui::widgets::{Dataset, GraphType};
1140
1141    /// Days since the epoch of 2020-01-01 and 2024-12-31.
1142    const FIVE_YEARS: [f64; 2] = [18262.0, 20088.0];
1143
1144    /// Date labels for fixed ticks on an axis spanning `bounds`.
1145    fn dates(bounds: (f64, f64)) -> TickLabel<'static> {
1146        let numbers = AxisFormat::new(&[], &AxisNumbers::default());
1147        Box::new(move |v, level| {
1148            x_axis_label_at(v, XAxisTemporalKind::Date, bounds, level, &numbers)
1149        })
1150    }
1151
1152    /// A plot starting four cells in, past the y labels and the axis, one dot a cell.
1153    fn track(width: u16) -> Track {
1154        Track {
1155            start: 4,
1156            cells: width - 4,
1157            sub: 1,
1158        }
1159    }
1160
1161    fn labels_on(axis: &AxisSpec<'_>, width: u16) -> Vec<String> {
1162        fit_x_labels(axis, (0, width), track(width))
1163            .labels
1164            .into_iter()
1165            .map(|(_, l)| l)
1166            .collect()
1167    }
1168
1169    /// Fixed ticks drop the middle first, then shorten; the ends stay while anything
1170    /// fits.
1171    #[test]
1172    fn fixed_labels_drop_the_middle_then_shorten() {
1173        let [lo, hi] = FIVE_YEARS;
1174        let axis = AxisSpec::ends_and_middle(FIVE_YEARS, dates((lo, hi)), "");
1175        assert_eq!(
1176            labels_on(&axis, 40),
1177            ["2020-01-01", "2022-07-02", "2024-12-31"]
1178        );
1179        assert_eq!(labels_on(&axis, 24), ["2020-01-01", "2024-12-31"]);
1180        assert_eq!(labels_on(&axis, 17), ["2020-01", "2024-12"]);
1181        assert_eq!(labels_on(&axis, 16), ["2020", "2024"]);
1182        assert!(labels_on(&axis, 10).is_empty());
1183    }
1184
1185    /// Placed labels stand apart, never leave the row, and keep the ends longest.
1186    #[test]
1187    fn labels_stand_apart_on_the_row() {
1188        let names = |i: f64, level| (level == 0).then(|| format!("column_{i}"));
1189        let axis = AxisSpec::fixed(
1190            [-0.5, 6.5],
1191            (0..7).map(f64::from).collect(),
1192            Box::new(names),
1193            "",
1194        );
1195        for width in 10..120 {
1196            let placed = fit_x_labels(&axis, (0, width), track(width)).labels;
1197            for pair in placed.windows(2) {
1198                let (x, label) = &pair[0];
1199                assert!(
1200                    x + label.width() as u16 + LABEL_GAP <= pair[1].0,
1201                    "{width}: {placed:?}"
1202                );
1203            }
1204            if let Some((x, label)) = placed.last() {
1205                assert!(x + label.width() as u16 <= width, "{width}: {placed:?}");
1206                assert_eq!(label, "column_6", "the last end stays: {placed:?}");
1207                assert_eq!(placed[0].1, "column_0", "the first end stays: {placed:?}");
1208            }
1209        }
1210    }
1211
1212    /// The step behind a set of labels, when they are evenly stepped numbers.
1213    fn step_of(labels: &[String]) -> f64 {
1214        let values: Vec<f64> = labels.iter().map(|l| l.parse().unwrap()).collect();
1215        let step = values[1] - values[0];
1216        for w in values.windows(2) {
1217            assert!((w[1] - w[0] - step).abs() < 1e-9, "uneven: {labels:?}");
1218        }
1219        step
1220    }
1221
1222    /// 1, 2, 2.5 or 5 times a power of ten, and every value a multiple of it.
1223    fn assert_nice(labels: &[String]) {
1224        let step = step_of(labels);
1225        let mantissa = step / 10f64.powf(step.log10().floor());
1226        assert!(
1227            [1.0, 2.0, 2.5, 5.0]
1228                .iter()
1229                .any(|m| (mantissa - m).abs() < 1e-9),
1230            "step {step}: {labels:?}"
1231        );
1232        for l in labels {
1233            let k = l.parse::<f64>().unwrap() / step;
1234            assert!((k - k.round()).abs() < 1e-9, "{l} off the step {step}");
1235        }
1236    }
1237
1238    /// The x axis carries about one label per 15 columns, every one a nice value:
1239    /// three or so at 60 columns, near twenty at 300.
1240    #[test]
1241    fn x_tick_density_follows_the_width() {
1242        let axis = AxisSpec::numbers([3.0, 997.0], &AxisNumbers::default(), "");
1243        for (width, least, most) in [(40, 2, 4), (60, 3, 5), (120, 6, 10), (300, 15, 22)] {
1244            let labels = labels_on(&axis, width);
1245            assert!(
1246                (least..=most).contains(&labels.len()),
1247                "{width} columns: {labels:?}"
1248            );
1249            assert_nice(&labels);
1250        }
1251        // Minor ticks between the labeled ones, where there is room.
1252        let wide = fit_x_labels(&axis, (0, 400), track(400));
1253        assert!(wide.minors.len() >= wide.majors.len(), "{wide:?}");
1254        let narrow = fit_x_labels(&axis, (0, 40), track(40));
1255        assert!(narrow.minors.iter().all(|m| !narrow.majors.contains(m)));
1256    }
1257
1258    /// A y axis widens to the nice values either side of its data and labels about
1259    /// one row in four.
1260    #[test]
1261    fn y_tick_density_follows_the_height() {
1262        let axis = AxisSpec::y_numbers([3.0, 997.0], &AxisNumbers::default(), "");
1263        for rows in [12u16, 20, 40, 76] {
1264            let track = Track {
1265                start: 0,
1266                cells: rows,
1267                sub: 4,
1268            };
1269            let placed = fit_y_labels(&axis, track, 20);
1270            let labels: Vec<String> = placed
1271                .labels
1272                .iter()
1273                .map(|(_, l)| l.trim().to_string())
1274                .collect();
1275            let per_label = f64::from(rows) / labels.len() as f64;
1276            assert!((2.2..=7.0).contains(&per_label), "{rows} rows: {labels:?}");
1277            assert_nice(&labels);
1278            // Bottom up, the ends of the widened axis on the last and first rows.
1279            assert_eq!(placed.labels[0], (rows - 1, "0".to_string()), "{placed:?}");
1280            let (top, label) = placed.labels.last().unwrap();
1281            assert_eq!(*top, 0, "{rows} rows: {placed:?}");
1282            assert_eq!(placed.bounds[0], 0.0);
1283            assert!(placed.bounds[1] >= 997.0);
1284            assert_eq!(&placed.bounds[1].to_string(), label);
1285        }
1286    }
1287
1288    /// Whole numbers tick whole, however much room there is.
1289    #[test]
1290    fn whole_numbers_tick_whole() {
1291        let whole = AxisNumbers {
1292            whole: true,
1293            ..AxisNumbers::default()
1294        };
1295        let axis = AxisSpec::numbers([0.0, 7.0], &whole, "");
1296        assert_eq!(
1297            labels_on(&axis, 200),
1298            ["0", "1", "2", "3", "4", "5", "6", "7"]
1299        );
1300        // Narrow: more labels when they fit, not the two of the nearest step.
1301        assert_eq!(labels_on(&axis, 30), ["0", "2", "4", "6"]);
1302        assert_eq!(labels_on(&axis, 20), ["0", "5"]);
1303        let axis = AxisSpec::y_numbers([3.0, 10.0], &whole, "");
1304        let track = Track {
1305            start: 0,
1306            cells: 9,
1307            sub: 1,
1308        };
1309        let placed = fit_y_labels(&axis, track, 10);
1310        assert_eq!(
1311            placed.labels,
1312            [
1313                (8, "0".to_string()),
1314                (4, "5".to_string()),
1315                (0, "10".to_string())
1316            ]
1317        );
1318    }
1319
1320    /// A date axis ticks on calendar boundaries and labels the unit that turns: the
1321    /// years on a narrow plot, half years between them on a wide one; the dates at
1322    /// the ends when nothing else fits.
1323    #[test]
1324    fn date_ticks_fall_on_the_calendar() {
1325        let axis = AxisSpec::calendar(
1326            FIVE_YEARS,
1327            XAxisTemporalKind::Date,
1328            &AxisNumbers::default(),
1329            "",
1330        );
1331        assert_eq!(
1332            labels_on(&axis, 80),
1333            ["2020", "2021", "2022", "2023", "2024"]
1334        );
1335        let wide = labels_on(&axis, 300);
1336        assert_eq!(
1337            wide[..4],
1338            ["2020", "Apr", "Jul", "Oct"],
1339            "quarters, the year at its turn: {wide:?}"
1340        );
1341        assert!(wide.contains(&"2024".to_string()), "{wide:?}");
1342        let narrow = labels_on(&axis, 16);
1343        assert_eq!(narrow, ["2020", "2024"], "{narrow:?}");
1344
1345        // Inside a month, days; the first tick names the month and year.
1346        let march = [19783.0, 19813.0]; // 2024-03-01 to 2024-03-31
1347        let axis = AxisSpec::calendar(march, XAxisTemporalKind::Date, &AxisNumbers::default(), "");
1348        let labels = labels_on(&axis, 80);
1349        assert_eq!(labels[0], "Mar 1 2024", "{labels:?}");
1350        assert!(
1351            labels[1..].iter().all(|l| l.parse::<u32>().is_ok()),
1352            "{labels:?}"
1353        );
1354
1355        // A day of microsecond timestamps ticks on the hours.
1356        let day = [1_709_251_200e6, 1_709_337_600e6]; // 2024-03-01T00:00 to 03-02T00:00
1357        let axis = AxisSpec::calendar(
1358            day,
1359            XAxisTemporalKind::DatetimeUs,
1360            &AxisNumbers::default(),
1361            "",
1362        );
1363        let labels = labels_on(&axis, 120);
1364        assert_eq!(labels[0], "Mar 1 2024", "{labels:?}");
1365        assert!(labels.contains(&"12:00".to_string()), "{labels:?}");
1366        assert_eq!(labels.last().unwrap(), "Mar 2", "{labels:?}");
1367    }
1368
1369    /// A short form that says the same at both ends is no label: numbers that round
1370    /// alike show none.
1371    #[test]
1372    fn short_forms_tell_ticks_apart() {
1373        let axis = AxisSpec::numbers([1000.1, 1000.3], &AxisNumbers::default(), "");
1374        assert!(labels_on(&axis, 12).is_empty());
1375        assert_eq!(labels_on(&axis, 40), ["1000.1", "1000.2", "1000.3"]);
1376    }
1377
1378    fn axes<'a>(grid: bool) -> PlotAxes<'a> {
1379        PlotAxes {
1380            grid: grid.then(Style::default),
1381            ..PlotAxes::new(
1382                AxisSpec::numbers([0.0, 10.0], &AxisNumbers::default(), "x title"),
1383                AxisSpec::y_numbers([0.0, 1000.0], &AxisNumbers::default(), "y title"),
1384                Style::default(),
1385                Marker::Braille,
1386            )
1387        }
1388    }
1389
1390    fn render_with(axes: &PlotAxes<'_>, area: Rect, g: &Glyphs) -> (Buffer, PlotFrame) {
1391        let points = [(0.0, 0.0), (10.0, 1000.0)];
1392        let chart = Chart::new(vec![
1393            Dataset::default()
1394                .marker(Marker::Braille)
1395                .graph_type(GraphType::Line)
1396                .data(&points),
1397        ]);
1398        let mut buf = Buffer::empty(area);
1399        let frame = axes.render(chart, area, &mut buf, g);
1400        (buf, frame)
1401    }
1402
1403    fn render(area: Rect, g: &Glyphs) -> (Buffer, PlotFrame) {
1404        render_with(&axes(false), area, g)
1405    }
1406
1407    /// The frame datui computes is where ratatui draws: the axis corner sits just
1408    /// left of the plot and just under it.
1409    #[test]
1410    fn the_frame_matches_ratatuis_layout() {
1411        let g = crate::glyphs::unicode();
1412        for (w, h) in [(9, 6), (20, 5), (20, 7), (30, 12), (60, 20), (120, 40)] {
1413            let (buf, frame) = render(Rect::new(0, 0, w, h), g);
1414            let corner = (frame.graph.left() - 1, frame.graph.bottom());
1415            assert_eq!(buf[corner].symbol(), "└", "{w}x{h}: {:?}", frame.graph);
1416        }
1417    }
1418
1419    /// The titles take rows of their own while the plot keeps three rows, the x
1420    /// title the first to go.
1421    #[test]
1422    fn titles_give_up_their_rows_to_the_plot() {
1423        let g = crate::glyphs::unicode();
1424        let (_, frame) = render(Rect::new(0, 0, 30, 7), g);
1425        assert!(frame.y_title.is_some() && frame.x_title.is_some());
1426        assert_eq!(frame.graph.height, 3);
1427        let (_, frame) = render(Rect::new(0, 0, 30, 6), g);
1428        assert!(frame.y_title.is_some() && frame.x_title.is_none());
1429        let (_, frame) = render(Rect::new(0, 0, 30, 5), g);
1430        assert!(frame.y_title.is_none() && frame.x_title.is_none());
1431    }
1432
1433    /// Each tick carries a mark on its axis line, and its label sits beside it: the
1434    /// y label on the tick's row, the x label centered under its column.
1435    #[test]
1436    fn labels_sit_on_their_tick_marks() {
1437        for g in [crate::glyphs::unicode(), crate::glyphs::ascii()] {
1438            let (buf, frame) = render(Rect::new(0, 0, 60, 20), g);
1439            let axis_x = frame.graph.left() - 1;
1440            for (row, label) in &frame.y.labels {
1441                assert_eq!(buf[(axis_x, *row)].symbol(), g.plot.tick_y, "{label}");
1442                let text: String = (frame.chart.left()..axis_x)
1443                    .map(|x| buf[(x, *row)].symbol())
1444                    .collect();
1445                assert_eq!(text.trim(), label.trim());
1446            }
1447            let row = frame.graph.bottom();
1448            let ticks: Vec<u16> = (frame.graph.left()..frame.graph.right())
1449                .filter(|&x| buf[(x, row)].symbol() == g.plot.tick_x)
1450                .collect();
1451            assert!(ticks.len() >= 3, "{ticks:?}");
1452        }
1453    }
1454
1455    fn text(buf: &Buffer) -> Vec<String> {
1456        let area = buf.area;
1457        (area.top()..area.bottom())
1458            .map(|y| {
1459                (area.left()..area.right())
1460                    .map(|x| buf[(x, y)].symbol())
1461                    .collect()
1462            })
1463            .collect()
1464    }
1465
1466    /// The grid draws at the major ticks when on and not at all when off, in either
1467    /// glyph set, and never takes a cell the series drew in.
1468    #[test]
1469    fn the_grid_toggles_and_never_hides_a_series() {
1470        for g in [crate::glyphs::unicode(), crate::glyphs::ascii()] {
1471            let area = Rect::new(0, 0, 60, 20);
1472            let (off, frame) = render_with(&axes(false), area, g);
1473            let (on, _) = render_with(&axes(true), area, g);
1474            let grid = |buf: &Buffer| {
1475                let all = text(buf).concat();
1476                all.matches(g.plot.grid_across).count() + all.matches(g.plot.grid_down).count()
1477            };
1478            assert_eq!(grid(&off), 0, "{:#?}", text(&off));
1479            assert!(grid(&on) > 50, "{:#?}", text(&on));
1480            let graph = frame.graph;
1481            for y in graph.top()..graph.bottom() {
1482                for x in graph.left()..graph.right() {
1483                    let mark = off[(x, y)].symbol();
1484                    if !matches!(mark, " " | "\u{2800}") {
1485                        assert_eq!(on[(x, y)].symbol(), mark, "({x}, {y})");
1486                    }
1487                }
1488            }
1489            // Down at each labeled x tick but the one against the y axis; across at
1490            // each y tick but the one on the x axis.
1491            for (row, _) in &frame.y.labels {
1492                let across = (graph.left()..graph.right())
1493                    .filter(|&x| on[(x, *row)].symbol() == g.plot.grid_across)
1494                    .count();
1495                if row + 1 < graph.bottom() {
1496                    assert!(across > graph.width as usize / 2, "row {row}");
1497                } else {
1498                    assert_eq!(across, 0, "the x axis row");
1499                }
1500            }
1501        }
1502    }
1503
1504    fn two_names() -> Legend {
1505        Legend {
1506            entries: vec![
1507                ("first".to_string(), Style::default()),
1508                ("second".to_string(), Style::default()),
1509            ],
1510        }
1511    }
1512
1513    /// The legend has no frame: a swatch and a name per series on the plot's
1514    /// background, cleared of the marks under it. A line rising to the right leaves
1515    /// the top left.
1516    #[test]
1517    fn the_legend_takes_the_emptiest_corner_without_a_frame() {
1518        let g = crate::glyphs::unicode();
1519        let mut axes = axes(false);
1520        axes.legend = Some(two_names());
1521        let rising: Vec<(f64, f64)> = (0..=100)
1522            .map(|i| (f64::from(i) / 10.0, f64::from(i) * 10.0))
1523            .collect();
1524        let chart = Chart::new(vec![
1525            Dataset::default()
1526                .marker(Marker::Braille)
1527                .graph_type(GraphType::Line)
1528                .data(&rising),
1529        ]);
1530        let area = Rect::new(0, 0, 60, 24);
1531        let mut buf = Buffer::empty(area);
1532        let frame = axes.render(chart, area, &mut buf, g);
1533        let (x, y) = (frame.graph.left(), frame.graph.top());
1534        let row = |y: u16| -> String {
1535            (x..x + 10)
1536                .map(|x| buf[(x, y)].symbol())
1537                .collect::<String>()
1538        };
1539        assert_eq!(
1540            row(y),
1541            format!(" {} first  ", g.bar_eighths[7]),
1542            "{:#?}",
1543            text(&buf)
1544        );
1545        assert_eq!(row(y + 1), format!(" {} second ", g.bar_eighths[7]));
1546        let all = text(&buf).join("\n");
1547        for frame_mark in ["┌", "┐", "┘"] {
1548            assert!(!all.contains(frame_mark), "{all}");
1549        }
1550    }
1551
1552    /// Points in every corner: the legend goes where they are fewest, counted by the
1553    /// dots they set, not by the cells they touch.
1554    #[test]
1555    fn the_legend_avoids_a_dense_corner() {
1556        let g = crate::glyphs::unicode();
1557        let mut axes = axes(false);
1558        axes.legend = Some(two_names());
1559        // A dense cloud over the plot but the bottom left, which has a few points:
1560        // every place the legend could go touches some.
1561        let mut points = Vec::new();
1562        for i in 0..=100 {
1563            for j in 0..=100 {
1564                let (x, y) = (f64::from(i) / 10.0, f64::from(j) * 10.0);
1565                if x > 3.0 || y > 300.0 {
1566                    points.push((x, y));
1567                }
1568            }
1569        }
1570        for i in 0..10 {
1571            points.push((f64::from(i) * 0.3, f64::from(i) * 30.0));
1572        }
1573        let chart = Chart::new(vec![
1574            Dataset::default()
1575                .marker(Marker::Braille)
1576                .graph_type(GraphType::Scatter)
1577                .data(&points),
1578        ]);
1579        let area = Rect::new(0, 0, 60, 24);
1580        let mut buf = Buffer::empty(area);
1581        let frame = axes.render(chart, area, &mut buf, g);
1582        let swatch = (frame.graph.left() + 1, frame.graph.bottom() - 2);
1583        assert_eq!(buf[swatch].symbol(), g.bar_eighths[7], "{:#?}", text(&buf));
1584    }
1585
1586    /// On a narrow plot of 0 to 7 the x row reads `0 2 4 6`: more labels when they
1587    /// fit, rather than the two of the step nearest the spacing.
1588    #[test]
1589    fn a_narrow_axis_takes_more_labels_when_they_fit() {
1590        let g = crate::glyphs::unicode();
1591        let whole = AxisNumbers {
1592            whole: true,
1593            ..AxisNumbers::default()
1594        };
1595        let axes = PlotAxes::new(
1596            AxisSpec::numbers([0.0, 7.0], &whole, ""),
1597            AxisSpec::y_numbers([0.0, 1000.0], &AxisNumbers::default(), ""),
1598            Style::default(),
1599            Marker::Braille,
1600        );
1601        let (buf, frame) = render_with(&axes, Rect::new(0, 0, 34, 12), g);
1602        let row = frame.labels.expect("a label row").y;
1603        let labels: Vec<String> = text(&buf)[row as usize]
1604            .split_whitespace()
1605            .map(str::to_string)
1606            .collect();
1607        assert_eq!(labels, ["0", "2", "4", "6"], "{:#?}", text(&buf));
1608    }
1609
1610    /// A title longer than its row is cut with the set's ellipsis.
1611    #[test]
1612    fn a_long_title_is_cut_to_its_row() {
1613        assert_eq!(cut("a long title", 6, crate::glyphs::unicode()), "a lon…");
1614        assert_eq!(cut("a long title", 6, crate::glyphs::ascii()), "a l...");
1615        assert_eq!(cut("short", 6, crate::glyphs::ascii()), "short");
1616    }
1617}