Skip to main content

fret_chart/retained/
tooltip.rs

1use std::collections::BTreeMap;
2
3use delinea::engine::window::DataWindow;
4use delinea::engine::{AxisPointerOutput, model::ChartModel};
5use delinea::{AxisId, ChartEngine, SeriesId};
6
7#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
8pub enum TooltipTextLineKind {
9    /// Plain unstyled line (default).
10    #[default]
11    Body,
12    /// Axis header row (used by axis-trigger tooltips).
13    AxisHeader,
14    /// Series row (value for a series).
15    SeriesRow,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub struct TooltipTextLine {
20    pub source_series: Option<SeriesId>,
21    pub text: String,
22    pub columns: Option<(String, String)>,
23    pub kind: TooltipTextLineKind,
24    pub value_emphasis: bool,
25    pub is_missing: bool,
26}
27
28impl TooltipTextLine {
29    pub fn plain(text: impl Into<String>) -> Self {
30        Self {
31            source_series: None,
32            text: text.into(),
33            columns: None,
34            kind: TooltipTextLineKind::Body,
35            value_emphasis: false,
36            is_missing: false,
37        }
38    }
39
40    pub fn for_series(series: SeriesId, text: impl Into<String>) -> Self {
41        Self {
42            source_series: Some(series),
43            text: text.into(),
44            columns: None,
45            kind: TooltipTextLineKind::SeriesRow,
46            value_emphasis: false,
47            is_missing: false,
48        }
49    }
50
51    pub fn columns(left: impl Into<String>, right: impl Into<String>) -> Self {
52        let left = left.into();
53        let right = right.into();
54        Self {
55            source_series: None,
56            text: format!("{left}: {right}"),
57            columns: Some((left, right)),
58            kind: TooltipTextLineKind::Body,
59            value_emphasis: true,
60            is_missing: false,
61        }
62    }
63
64    pub fn columns_for_series(
65        series: SeriesId,
66        left: impl Into<String>,
67        right: impl Into<String>,
68    ) -> Self {
69        let left = left.into();
70        let right = right.into();
71        Self {
72            source_series: Some(series),
73            text: format!("{left}: {right}"),
74            columns: Some((left, right)),
75            kind: TooltipTextLineKind::SeriesRow,
76            value_emphasis: true,
77            is_missing: false,
78        }
79    }
80
81    pub fn with_kind(mut self, kind: TooltipTextLineKind) -> Self {
82        self.kind = kind;
83        self
84    }
85
86    pub fn with_value_emphasis(mut self, value_emphasis: bool) -> Self {
87        self.value_emphasis = value_emphasis;
88        self
89    }
90
91    pub fn with_missing(mut self, is_missing: bool) -> Self {
92        self.is_missing = is_missing;
93        self
94    }
95}
96
97pub trait TooltipFormatter: Send + Sync {
98    fn format_axis_pointer(
99        &self,
100        engine: &ChartEngine,
101        axis_windows: &BTreeMap<AxisId, DataWindow>,
102        axis_pointer: &AxisPointerOutput,
103    ) -> Vec<TooltipTextLine>;
104}
105
106#[derive(Clone, Copy)]
107pub struct TooltipFormatContext<'a> {
108    pub engine: &'a ChartEngine,
109    pub axis_windows: &'a BTreeMap<AxisId, DataWindow>,
110    pub axis_pointer: &'a AxisPointerOutput,
111}
112
113impl<'a> TooltipFormatContext<'a> {
114    pub fn model(&self) -> &ChartModel {
115        self.engine.model()
116    }
117
118    pub fn tooltip(&self) -> &'a delinea::TooltipOutput {
119        &self.axis_pointer.tooltip
120    }
121}
122
123pub struct TooltipFormatterFn<F> {
124    f: F,
125}
126
127impl<F> TooltipFormatterFn<F> {
128    pub fn new(f: F) -> Self {
129        Self { f }
130    }
131}
132
133impl<F> TooltipFormatter for TooltipFormatterFn<F>
134where
135    F: for<'a> Fn(&TooltipFormatContext<'a>) -> Vec<TooltipTextLine> + Send + Sync,
136{
137    fn format_axis_pointer(
138        &self,
139        engine: &ChartEngine,
140        axis_windows: &BTreeMap<AxisId, DataWindow>,
141        axis_pointer: &AxisPointerOutput,
142    ) -> Vec<TooltipTextLine> {
143        (self.f)(&TooltipFormatContext {
144            engine,
145            axis_windows,
146            axis_pointer,
147        })
148    }
149}
150
151#[derive(Debug, Default)]
152pub struct DefaultTooltipFormatter;
153
154impl DefaultTooltipFormatter {
155    fn apply_line_template(template: &str, label: &str, value: &str) -> String {
156        template.replace("{label}", label).replace("{value}", value)
157    }
158
159    fn apply_range_template(template: &str, min: &str, max: &str) -> String {
160        template.replace("{min}", min).replace("{max}", max)
161    }
162
163    fn format_value_value_axis_decimals(
164        value: f64,
165        decimals: u8,
166        trim_trailing_zeros: bool,
167    ) -> String {
168        if !value.is_finite() {
169            return value.to_string();
170        }
171
172        let mut out = format!("{:.*}", decimals as usize, value);
173        if !trim_trailing_zeros {
174            return out;
175        }
176
177        while out.ends_with('0') {
178            out.pop();
179        }
180        if out.ends_with('.') {
181            out.pop();
182        }
183        if out.is_empty() { "0".to_string() } else { out }
184    }
185
186    fn format_value_for_tooltip(
187        model: &ChartModel,
188        axis: AxisId,
189        window: DataWindow,
190        value: f64,
191        spec: &delinea::TooltipSpecV1,
192    ) -> String {
193        let Some(axis_model) = model.axes.get(&axis) else {
194            return delinea::engine::axis::format_value_for(model, axis, window, value);
195        };
196
197        match &axis_model.scale {
198            delinea::AxisScale::Value(_) if spec.value_decimals.is_some() => {
199                Self::format_value_value_axis_decimals(
200                    value,
201                    spec.value_decimals.unwrap_or(0),
202                    spec.trim_trailing_zeros,
203                )
204            }
205            _ => delinea::engine::axis::format_value_for(model, axis, window, value),
206        }
207    }
208
209    fn series_override(
210        spec: &delinea::TooltipSpecV1,
211        series: SeriesId,
212    ) -> Option<&delinea::TooltipSeriesOverrideV1> {
213        spec.series_overrides.iter().find(|o| o.series == series)
214    }
215
216    fn effective_series_line_template<'a>(
217        spec: &'a delinea::TooltipSpecV1,
218        override_spec: Option<&'a delinea::TooltipSeriesOverrideV1>,
219    ) -> &'a str {
220        override_spec
221            .and_then(|o| o.series_line_template.as_deref())
222            .unwrap_or(spec.series_line_template.as_str())
223    }
224
225    fn effective_missing_value<'a>(
226        spec: &'a delinea::TooltipSpecV1,
227        override_spec: Option<&'a delinea::TooltipSeriesOverrideV1>,
228    ) -> &'a str {
229        override_spec
230            .and_then(|o| o.missing_value.as_deref())
231            .unwrap_or(spec.missing_value.as_str())
232    }
233
234    fn effective_range_template<'a>(
235        spec: &'a delinea::TooltipSpecV1,
236        override_spec: Option<&'a delinea::TooltipSeriesOverrideV1>,
237    ) -> &'a str {
238        override_spec
239            .and_then(|o| o.range_template.as_deref())
240            .unwrap_or(spec.range_template.as_str())
241    }
242
243    fn effective_value_decimals(
244        spec: &delinea::TooltipSpecV1,
245        override_spec: Option<&delinea::TooltipSeriesOverrideV1>,
246    ) -> Option<u8> {
247        override_spec
248            .and_then(|o| o.value_decimals)
249            .or(spec.value_decimals)
250    }
251
252    fn effective_trim_trailing_zeros(
253        spec: &delinea::TooltipSpecV1,
254        override_spec: Option<&delinea::TooltipSeriesOverrideV1>,
255    ) -> bool {
256        override_spec
257            .and_then(|o| o.trim_trailing_zeros)
258            .unwrap_or(spec.trim_trailing_zeros)
259    }
260
261    fn format_value_for_tooltip_with_override(
262        model: &ChartModel,
263        axis: AxisId,
264        window: DataWindow,
265        value: f64,
266        spec: &delinea::TooltipSpecV1,
267        override_spec: Option<&delinea::TooltipSeriesOverrideV1>,
268    ) -> String {
269        let Some(axis_model) = model.axes.get(&axis) else {
270            return delinea::engine::axis::format_value_for(model, axis, window, value);
271        };
272
273        let value_decimals = Self::effective_value_decimals(spec, override_spec);
274        let trim_trailing_zeros = Self::effective_trim_trailing_zeros(spec, override_spec);
275
276        match &axis_model.scale {
277            delinea::AxisScale::Value(_) if value_decimals.is_some() => {
278                Self::format_value_value_axis_decimals(
279                    value,
280                    value_decimals.unwrap_or(0),
281                    trim_trailing_zeros,
282                )
283            }
284            _ => delinea::engine::axis::format_value_for(model, axis, window, value),
285        }
286    }
287
288    fn axis_label(model: &ChartModel, axis: AxisId) -> String {
289        let kind = model
290            .axes
291            .get(&axis)
292            .map(|a| a.kind)
293            .unwrap_or(delinea::AxisKind::X);
294
295        let name = model.axes.get(&axis).and_then(|a| a.name.as_deref());
296
297        match (kind, name) {
298            (delinea::AxisKind::X, Some(name)) => format!("x ({name})"),
299            (delinea::AxisKind::Y, Some(name)) => format!("y ({name})"),
300            (delinea::AxisKind::X, None) => "x".to_string(),
301            (delinea::AxisKind::Y, None) => "y".to_string(),
302        }
303    }
304
305    fn series_label(model: &ChartModel, series: SeriesId) -> String {
306        model
307            .series
308            .get(&series)
309            .and_then(|s| s.name.as_deref())
310            .map(|n| n.to_string())
311            .unwrap_or_else(|| format!("Series {}", series.0))
312    }
313}
314
315impl TooltipFormatter for DefaultTooltipFormatter {
316    fn format_axis_pointer(
317        &self,
318        engine: &ChartEngine,
319        axis_windows: &BTreeMap<AxisId, DataWindow>,
320        axis_pointer: &AxisPointerOutput,
321    ) -> Vec<TooltipTextLine> {
322        let model = engine.model();
323        let default_spec = delinea::TooltipSpecV1::default();
324        let spec = model.tooltip.as_ref().unwrap_or(&default_spec);
325
326        match &axis_pointer.tooltip {
327            delinea::TooltipOutput::Item(item) => {
328                let axis_pointer_label_enabled =
329                    model.axis_pointer.as_ref().is_some_and(|p| p.label.show);
330                let show_axis_line = match spec.item_axis_line {
331                    delinea::spec::TooltipItemAxisLineMode::Auto => !axis_pointer_label_enabled,
332                    delinea::spec::TooltipItemAxisLineMode::Show => true,
333                    delinea::spec::TooltipItemAxisLineMode::Hide => false,
334                };
335
336                let mut lines = Vec::with_capacity(if show_axis_line { 2 } else { 1 });
337
338                if show_axis_line {
339                    let x_window = axis_windows.get(&item.x_axis).copied().unwrap_or_default();
340                    let x_label = Self::axis_label(model, item.x_axis);
341                    let mut x_is_missing = false;
342                    let x_value = if item.x_value.is_finite() {
343                        Self::format_value_for_tooltip(
344                            model,
345                            item.x_axis,
346                            x_window,
347                            item.x_value,
348                            spec,
349                        )
350                    } else {
351                        x_is_missing = true;
352                        spec.missing_value.clone()
353                    };
354                    if spec.axis_line_template == "{label}: {value}" {
355                        lines.push(
356                            TooltipTextLine::columns(x_label, x_value)
357                                .with_kind(TooltipTextLineKind::AxisHeader)
358                                .with_missing(x_is_missing),
359                        );
360                    } else {
361                        lines.push(TooltipTextLine {
362                            source_series: None,
363                            text: Self::apply_line_template(
364                                &spec.axis_line_template,
365                                &x_label,
366                                &x_value,
367                            ),
368                            columns: None,
369                            kind: TooltipTextLineKind::AxisHeader,
370                            value_emphasis: false,
371                            is_missing: x_is_missing,
372                        });
373                    }
374                }
375
376                let series_label = Self::series_label(model, item.series);
377                let series_override = Self::series_override(spec, item.series);
378                let series_template = Self::effective_series_line_template(spec, series_override);
379                let y_window = axis_windows.get(&item.y_axis).copied().unwrap_or_default();
380
381                let mut y_is_missing = false;
382                let y_value = if item.y_value.is_finite() {
383                    Self::format_value_for_tooltip_with_override(
384                        model,
385                        item.y_axis,
386                        y_window,
387                        item.y_value,
388                        spec,
389                        series_override,
390                    )
391                } else {
392                    y_is_missing = true;
393                    Self::effective_missing_value(spec, series_override).to_string()
394                };
395
396                if series_template == "{label}: {value}" {
397                    lines.push(
398                        TooltipTextLine::columns_for_series(item.series, series_label, y_value)
399                            .with_missing(y_is_missing),
400                    );
401                } else {
402                    lines.push(TooltipTextLine {
403                        source_series: Some(item.series),
404                        text: Self::apply_line_template(series_template, &series_label, &y_value),
405                        columns: None,
406                        kind: TooltipTextLineKind::SeriesRow,
407                        value_emphasis: false,
408                        is_missing: y_is_missing,
409                    });
410                }
411
412                lines
413            }
414            delinea::TooltipOutput::Axis(axis) => {
415                let mut lines = Vec::with_capacity(1 + axis.series.len());
416                let axis_window = axis_windows.get(&axis.axis).copied().unwrap_or_default();
417                let axis_label = Self::axis_label(model, axis.axis);
418                let axis_value = Self::format_value_for_tooltip(
419                    model,
420                    axis.axis,
421                    axis_window,
422                    axis.axis_value,
423                    spec,
424                );
425                if spec.axis_line_template == "{label}: {value}" {
426                    lines.push(
427                        TooltipTextLine::columns(axis_label, axis_value)
428                            .with_kind(TooltipTextLineKind::AxisHeader),
429                    );
430                } else {
431                    lines.push(TooltipTextLine {
432                        source_series: None,
433                        text: Self::apply_line_template(
434                            &spec.axis_line_template,
435                            &axis_label,
436                            &axis_value,
437                        ),
438                        columns: None,
439                        kind: TooltipTextLineKind::AxisHeader,
440                        value_emphasis: false,
441                        is_missing: false,
442                    });
443                }
444
445                for entry in &axis.series {
446                    let label = Self::series_label(model, entry.series);
447                    let series_override = Self::series_override(spec, entry.series);
448                    let series_template =
449                        Self::effective_series_line_template(spec, series_override);
450                    let window = axis_windows
451                        .get(&entry.value_axis)
452                        .copied()
453                        .unwrap_or_default();
454
455                    let mut is_missing = false;
456                    let value = match &entry.value {
457                        delinea::TooltipSeriesValue::Missing => {
458                            is_missing = true;
459                            Self::effective_missing_value(spec, series_override).to_string()
460                        }
461                        delinea::TooltipSeriesValue::Scalar(v) => {
462                            Self::format_value_for_tooltip_with_override(
463                                model,
464                                entry.value_axis,
465                                window,
466                                *v,
467                                spec,
468                                series_override,
469                            )
470                        }
471                        delinea::TooltipSeriesValue::Range { min, max } => {
472                            let a = Self::format_value_for_tooltip_with_override(
473                                model,
474                                entry.value_axis,
475                                window,
476                                *min,
477                                spec,
478                                series_override,
479                            );
480                            let b = Self::format_value_for_tooltip_with_override(
481                                model,
482                                entry.value_axis,
483                                window,
484                                *max,
485                                spec,
486                                series_override,
487                            );
488                            Self::apply_range_template(
489                                Self::effective_range_template(spec, series_override),
490                                &a,
491                                &b,
492                            )
493                        }
494                    };
495
496                    if series_template == "{label}: {value}" {
497                        lines.push(
498                            TooltipTextLine::columns_for_series(entry.series, label, value)
499                                .with_missing(is_missing),
500                        );
501                    } else {
502                        lines.push(TooltipTextLine {
503                            source_series: Some(entry.series),
504                            text: Self::apply_line_template(series_template, &label, &value),
505                            columns: None,
506                            kind: TooltipTextLineKind::SeriesRow,
507                            value_emphasis: false,
508                            is_missing,
509                        });
510                    }
511                }
512
513                lines
514            }
515        }
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use delinea::text::{TextMeasurer, TextMetrics};
523    use delinea::{
524        AxisKind, ChartSpec, DatasetSpec, FieldSpec, GridSpec, SeriesEncode, SeriesKind,
525        SeriesSpec, WorkBudget,
526    };
527    use fret_core::{Point, Px, Rect, Size};
528
529    #[derive(Debug, Default)]
530    struct NullTextMeasurer;
531
532    impl TextMeasurer for NullTextMeasurer {
533        fn measure(
534            &mut self,
535            _text: delinea::ids::StringId,
536            _style: delinea::text::TextStyleId,
537        ) -> TextMetrics {
538            TextMetrics::default()
539        }
540    }
541
542    #[test]
543    fn default_formatter_formats_axis_trigger_tooltip_lines() {
544        let dataset_id = delinea::DatasetId::new(1);
545        let grid_id = delinea::GridId::new(1);
546        let x_axis = delinea::AxisId::new(1);
547        let y_axis = delinea::AxisId::new(2);
548        let series_a = delinea::SeriesId::new(1);
549        let series_b = delinea::SeriesId::new(2);
550        let x_field = delinea::FieldId::new(1);
551        let y_a_field = delinea::FieldId::new(2);
552        let y_b_field = delinea::FieldId::new(3);
553
554        let spec = ChartSpec {
555            id: delinea::ChartId::new(1),
556            viewport: Some(Rect::new(
557                Point::new(Px(0.0), Px(0.0)),
558                Size::new(Px(100.0), Px(100.0)),
559            )),
560            datasets: vec![DatasetSpec {
561                id: dataset_id,
562                fields: vec![
563                    FieldSpec {
564                        id: x_field,
565                        column: 0,
566                    },
567                    FieldSpec {
568                        id: y_a_field,
569                        column: 1,
570                    },
571                    FieldSpec {
572                        id: y_b_field,
573                        column: 2,
574                    },
575                ],
576
577                from: None,
578                transforms: Vec::new(),
579            }],
580            grids: vec![GridSpec { id: grid_id }],
581            axes: vec![
582                delinea::AxisSpec {
583                    id: x_axis,
584                    name: Some("Time".to_string()),
585                    kind: AxisKind::X,
586                    grid: grid_id,
587                    position: None,
588                    scale: Default::default(),
589                    range: None,
590                },
591                delinea::AxisSpec {
592                    id: y_axis,
593                    name: Some("Value".to_string()),
594                    kind: AxisKind::Y,
595                    grid: grid_id,
596                    position: None,
597                    scale: Default::default(),
598                    range: None,
599                },
600            ],
601            data_zoom_x: vec![],
602            data_zoom_y: vec![],
603            tooltip: None,
604            axis_pointer: Some(delinea::AxisPointerSpec {
605                enabled: true,
606                trigger: delinea::AxisPointerTrigger::Axis,
607                pointer_type: delinea::AxisPointerType::Line,
608                label: Default::default(),
609                snap: false,
610                trigger_distance_px: 0.0,
611                throttle_px: 0.0,
612            }),
613            visual_maps: vec![],
614            series: vec![
615                SeriesSpec {
616                    id: series_a,
617                    name: Some("A".to_string()),
618                    kind: SeriesKind::Line,
619                    dataset: dataset_id,
620                    encode: SeriesEncode {
621                        x: x_field,
622                        y: y_a_field,
623                        y2: None,
624                    },
625                    x_axis,
626                    y_axis,
627                    stack: None,
628                    stack_strategy: Default::default(),
629                    bar_layout: Default::default(),
630                    area_baseline: None,
631                    lod: None,
632                },
633                SeriesSpec {
634                    id: series_b,
635                    name: Some("B".to_string()),
636                    kind: SeriesKind::Line,
637                    dataset: dataset_id,
638                    encode: SeriesEncode {
639                        x: x_field,
640                        y: y_b_field,
641                        y2: None,
642                    },
643                    x_axis,
644                    y_axis,
645                    stack: None,
646                    stack_strategy: Default::default(),
647                    bar_layout: Default::default(),
648                    area_baseline: None,
649                    lod: None,
650                },
651            ],
652        };
653
654        let mut engine = ChartEngine::new(spec).unwrap();
655        let mut table = delinea::data::DataTable::default();
656        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
657        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
658        table.push_column(delinea::data::Column::F64(vec![0.0, 2.0]));
659        engine.datasets_mut().insert(dataset_id, table);
660
661        let mut measurer = NullTextMeasurer;
662        let step = engine
663            .step(&mut measurer, WorkBudget::new(262_144, 0, 32))
664            .unwrap();
665        assert!(!step.unfinished);
666
667        engine.apply_action(delinea::Action::HoverAt {
668            point: Point::new(Px(50.0), Px(50.0)),
669        });
670        let step = engine
671            .step(&mut measurer, WorkBudget::new(32_768, 0, 8))
672            .unwrap();
673        assert!(!step.unfinished);
674
675        let axis_pointer = engine.output().axis_pointer.as_ref().unwrap();
676        let formatter = DefaultTooltipFormatter;
677        let lines =
678            formatter.format_axis_pointer(&engine, &engine.output().axis_windows, axis_pointer);
679        assert_eq!(lines.len(), 3);
680        assert_eq!(lines[0].source_series, None);
681        assert_eq!(lines[0].text, "x (Time): 0.5");
682        assert_eq!(
683            lines[0]
684                .columns
685                .as_ref()
686                .map(|(l, r)| (l.as_str(), r.as_str())),
687            Some(("x (Time)", "0.5"))
688        );
689        assert_eq!(lines[0].kind, TooltipTextLineKind::AxisHeader);
690        assert!(lines[0].value_emphasis);
691        assert!(!lines[0].is_missing);
692        assert_eq!(lines[1].source_series, Some(series_a));
693        assert_eq!(lines[1].text, "A: 0.5");
694        assert_eq!(
695            lines[1]
696                .columns
697                .as_ref()
698                .map(|(l, r)| (l.as_str(), r.as_str())),
699            Some(("A", "0.5"))
700        );
701        assert_eq!(lines[1].kind, TooltipTextLineKind::SeriesRow);
702        assert!(lines[1].value_emphasis);
703        assert!(!lines[1].is_missing);
704        assert_eq!(lines[2].source_series, Some(series_b));
705        assert_eq!(lines[2].text, "B: 1");
706        assert_eq!(
707            lines[2]
708                .columns
709                .as_ref()
710                .map(|(l, r)| (l.as_str(), r.as_str())),
711            Some(("B", "1"))
712        );
713        assert_eq!(lines[2].kind, TooltipTextLineKind::SeriesRow);
714        assert!(lines[2].value_emphasis);
715        assert!(!lines[2].is_missing);
716    }
717
718    #[test]
719    fn default_formatter_marks_missing_axis_values() {
720        let dataset_id = delinea::DatasetId::new(1);
721        let grid_id = delinea::GridId::new(1);
722        let x_axis = delinea::AxisId::new(1);
723        let y_axis = delinea::AxisId::new(2);
724        let series_a = delinea::SeriesId::new(1);
725        let series_b = delinea::SeriesId::new(2);
726        let x_field = delinea::FieldId::new(1);
727        let y_a_field = delinea::FieldId::new(2);
728        let y_b_field = delinea::FieldId::new(3);
729
730        let spec = ChartSpec {
731            id: delinea::ChartId::new(1),
732            viewport: Some(Rect::new(
733                Point::new(Px(0.0), Px(0.0)),
734                Size::new(Px(100.0), Px(100.0)),
735            )),
736            datasets: vec![DatasetSpec {
737                id: dataset_id,
738                fields: vec![
739                    FieldSpec {
740                        id: x_field,
741                        column: 0,
742                    },
743                    FieldSpec {
744                        id: y_a_field,
745                        column: 1,
746                    },
747                    FieldSpec {
748                        id: y_b_field,
749                        column: 2,
750                    },
751                ],
752
753                from: None,
754                transforms: Vec::new(),
755            }],
756            grids: vec![GridSpec { id: grid_id }],
757            axes: vec![
758                delinea::AxisSpec {
759                    id: x_axis,
760                    name: Some("Time".to_string()),
761                    kind: AxisKind::X,
762                    grid: grid_id,
763                    position: None,
764                    scale: Default::default(),
765                    range: None,
766                },
767                delinea::AxisSpec {
768                    id: y_axis,
769                    name: Some("Value".to_string()),
770                    kind: AxisKind::Y,
771                    grid: grid_id,
772                    position: None,
773                    scale: Default::default(),
774                    range: None,
775                },
776            ],
777            data_zoom_x: vec![],
778            data_zoom_y: vec![],
779            tooltip: None,
780            axis_pointer: Some(delinea::AxisPointerSpec {
781                enabled: true,
782                trigger: delinea::AxisPointerTrigger::Axis,
783                pointer_type: delinea::AxisPointerType::Line,
784                label: Default::default(),
785                snap: false,
786                trigger_distance_px: 0.0,
787                throttle_px: 0.0,
788            }),
789            visual_maps: vec![],
790            series: vec![
791                SeriesSpec {
792                    id: series_a,
793                    name: Some("A".to_string()),
794                    kind: SeriesKind::Line,
795                    dataset: dataset_id,
796                    encode: SeriesEncode {
797                        x: x_field,
798                        y: y_a_field,
799                        y2: None,
800                    },
801                    x_axis,
802                    y_axis,
803                    stack: None,
804                    stack_strategy: Default::default(),
805                    bar_layout: Default::default(),
806                    area_baseline: None,
807                    lod: None,
808                },
809                SeriesSpec {
810                    id: series_b,
811                    name: Some("B".to_string()),
812                    kind: SeriesKind::Line,
813                    dataset: dataset_id,
814                    encode: SeriesEncode {
815                        x: x_field,
816                        y: y_b_field,
817                        y2: None,
818                    },
819                    x_axis,
820                    y_axis,
821                    stack: None,
822                    stack_strategy: Default::default(),
823                    bar_layout: Default::default(),
824                    area_baseline: None,
825                    lod: None,
826                },
827            ],
828        };
829
830        let mut engine = ChartEngine::new(spec).unwrap();
831        let mut table = delinea::data::DataTable::default();
832        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
833        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
834        table.push_column(delinea::data::Column::F64(vec![0.0, f64::NAN]));
835        engine.datasets_mut().insert(dataset_id, table);
836
837        let mut measurer = NullTextMeasurer;
838        let step = engine
839            .step(&mut measurer, WorkBudget::new(262_144, 0, 32))
840            .unwrap();
841        assert!(!step.unfinished);
842
843        engine.apply_action(delinea::Action::HoverAt {
844            point: Point::new(Px(50.0), Px(50.0)),
845        });
846        let step = engine
847            .step(&mut measurer, WorkBudget::new(32_768, 0, 8))
848            .unwrap();
849        assert!(!step.unfinished);
850
851        let axis_pointer = engine.output().axis_pointer.as_ref().unwrap();
852        let formatter = DefaultTooltipFormatter;
853        let lines =
854            formatter.format_axis_pointer(&engine, &engine.output().axis_windows, axis_pointer);
855
856        assert_eq!(lines.len(), 3);
857        assert_eq!(lines[2].source_series, Some(series_b));
858        assert_eq!(lines[2].text, "B: -");
859        assert_eq!(
860            lines[2]
861                .columns
862                .as_ref()
863                .map(|(l, r)| (l.as_str(), r.as_str())),
864            Some(("B", "-"))
865        );
866        assert_eq!(lines[2].kind, TooltipTextLineKind::SeriesRow);
867        assert!(lines[2].value_emphasis);
868        assert!(lines[2].is_missing);
869    }
870
871    #[test]
872    fn tooltip_spec_v1_customizes_templates_and_decimals() {
873        let dataset_id = delinea::DatasetId::new(1);
874        let grid_id = delinea::GridId::new(1);
875        let x_axis = delinea::AxisId::new(1);
876        let y_axis = delinea::AxisId::new(2);
877        let series_a = delinea::SeriesId::new(1);
878        let series_b = delinea::SeriesId::new(2);
879        let x_field = delinea::FieldId::new(1);
880        let y_a_field = delinea::FieldId::new(2);
881        let y_b_field = delinea::FieldId::new(3);
882
883        let tooltip = delinea::TooltipSpecV1 {
884            axis_line_template: "{value} @ {label}".to_string(),
885            series_line_template: "[{label}]={value}".to_string(),
886            item_axis_line: Default::default(),
887            missing_value: "(missing)".to_string(),
888            range_template: "{min}..{max}".to_string(),
889            value_decimals: Some(2),
890            trim_trailing_zeros: false,
891            series_overrides: Vec::default(),
892        };
893
894        let spec = ChartSpec {
895            id: delinea::ChartId::new(1),
896            viewport: Some(Rect::new(
897                Point::new(Px(0.0), Px(0.0)),
898                Size::new(Px(100.0), Px(100.0)),
899            )),
900            datasets: vec![DatasetSpec {
901                id: dataset_id,
902                fields: vec![
903                    FieldSpec {
904                        id: x_field,
905                        column: 0,
906                    },
907                    FieldSpec {
908                        id: y_a_field,
909                        column: 1,
910                    },
911                    FieldSpec {
912                        id: y_b_field,
913                        column: 2,
914                    },
915                ],
916
917                from: None,
918                transforms: Vec::new(),
919            }],
920            grids: vec![GridSpec { id: grid_id }],
921            axes: vec![
922                delinea::AxisSpec {
923                    id: x_axis,
924                    name: Some("Time".to_string()),
925                    kind: AxisKind::X,
926                    grid: grid_id,
927                    position: None,
928                    scale: Default::default(),
929                    range: None,
930                },
931                delinea::AxisSpec {
932                    id: y_axis,
933                    name: Some("Value".to_string()),
934                    kind: AxisKind::Y,
935                    grid: grid_id,
936                    position: None,
937                    scale: Default::default(),
938                    range: None,
939                },
940            ],
941            data_zoom_x: vec![],
942            data_zoom_y: vec![],
943            tooltip: Some(tooltip),
944            axis_pointer: Some(delinea::AxisPointerSpec {
945                enabled: true,
946                trigger: delinea::AxisPointerTrigger::Axis,
947                pointer_type: delinea::AxisPointerType::Line,
948                label: Default::default(),
949                snap: false,
950                trigger_distance_px: 0.0,
951                throttle_px: 0.0,
952            }),
953            visual_maps: vec![],
954            series: vec![
955                SeriesSpec {
956                    id: series_a,
957                    name: Some("A".to_string()),
958                    kind: SeriesKind::Line,
959                    dataset: dataset_id,
960                    encode: SeriesEncode {
961                        x: x_field,
962                        y: y_a_field,
963                        y2: None,
964                    },
965                    x_axis,
966                    y_axis,
967                    stack: None,
968                    stack_strategy: Default::default(),
969                    bar_layout: Default::default(),
970                    area_baseline: None,
971                    lod: None,
972                },
973                SeriesSpec {
974                    id: series_b,
975                    name: Some("B".to_string()),
976                    kind: SeriesKind::Line,
977                    dataset: dataset_id,
978                    encode: SeriesEncode {
979                        x: x_field,
980                        y: y_b_field,
981                        y2: None,
982                    },
983                    x_axis,
984                    y_axis,
985                    stack: None,
986                    stack_strategy: Default::default(),
987                    bar_layout: Default::default(),
988                    area_baseline: None,
989                    lod: None,
990                },
991            ],
992        };
993
994        let mut engine = ChartEngine::new(spec).unwrap();
995        let mut table = delinea::data::DataTable::default();
996        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
997        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
998        table.push_column(delinea::data::Column::F64(vec![0.0, 2.0]));
999        engine.datasets_mut().insert(dataset_id, table);
1000
1001        let mut measurer = NullTextMeasurer;
1002        let step = engine
1003            .step(&mut measurer, WorkBudget::new(262_144, 0, 32))
1004            .unwrap();
1005        assert!(!step.unfinished);
1006
1007        engine.apply_action(delinea::Action::HoverAt {
1008            point: Point::new(Px(50.0), Px(50.0)),
1009        });
1010        let step = engine
1011            .step(&mut measurer, WorkBudget::new(32_768, 0, 8))
1012            .unwrap();
1013        assert!(!step.unfinished);
1014
1015        let axis_pointer = engine.output().axis_pointer.as_ref().unwrap();
1016        let formatter = DefaultTooltipFormatter;
1017        let lines =
1018            formatter.format_axis_pointer(&engine, &engine.output().axis_windows, axis_pointer);
1019        assert_eq!(lines.len(), 3);
1020        assert_eq!(lines[0].source_series, None);
1021        assert_eq!(lines[0].text, "0.50 @ x (Time)");
1022        assert_eq!(lines[0].columns, None);
1023        assert_eq!(lines[0].kind, TooltipTextLineKind::AxisHeader);
1024        assert!(!lines[0].value_emphasis);
1025        assert_eq!(lines[1].source_series, Some(series_a));
1026        assert_eq!(lines[1].text, "[A]=0.50");
1027        assert_eq!(lines[1].columns, None);
1028        assert_eq!(lines[1].kind, TooltipTextLineKind::SeriesRow);
1029        assert!(!lines[1].value_emphasis);
1030        assert_eq!(lines[2].source_series, Some(series_b));
1031        assert_eq!(lines[2].text, "[B]=1.00");
1032        assert_eq!(lines[2].columns, None);
1033        assert_eq!(lines[2].kind, TooltipTextLineKind::SeriesRow);
1034        assert!(!lines[2].value_emphasis);
1035    }
1036
1037    #[test]
1038    fn default_formatter_formats_item_trigger_tooltip_lines() {
1039        let dataset_id = delinea::DatasetId::new(1);
1040        let grid_id = delinea::GridId::new(1);
1041        let x_axis = delinea::AxisId::new(1);
1042        let y_axis = delinea::AxisId::new(2);
1043        let series_a = delinea::SeriesId::new(1);
1044        let x_field = delinea::FieldId::new(1);
1045        let y_a_field = delinea::FieldId::new(2);
1046
1047        let spec = ChartSpec {
1048            id: delinea::ChartId::new(1),
1049            viewport: Some(Rect::new(
1050                Point::new(Px(0.0), Px(0.0)),
1051                Size::new(Px(100.0), Px(100.0)),
1052            )),
1053            datasets: vec![DatasetSpec {
1054                id: dataset_id,
1055                fields: vec![
1056                    FieldSpec {
1057                        id: x_field,
1058                        column: 0,
1059                    },
1060                    FieldSpec {
1061                        id: y_a_field,
1062                        column: 1,
1063                    },
1064                ],
1065
1066                from: None,
1067                transforms: Vec::new(),
1068            }],
1069            grids: vec![GridSpec { id: grid_id }],
1070            axes: vec![
1071                delinea::AxisSpec {
1072                    id: x_axis,
1073                    name: Some("Time".to_string()),
1074                    kind: AxisKind::X,
1075                    grid: grid_id,
1076                    position: None,
1077                    scale: Default::default(),
1078                    range: None,
1079                },
1080                delinea::AxisSpec {
1081                    id: y_axis,
1082                    name: Some("Value".to_string()),
1083                    kind: AxisKind::Y,
1084                    grid: grid_id,
1085                    position: None,
1086                    scale: Default::default(),
1087                    range: None,
1088                },
1089            ],
1090            data_zoom_x: vec![],
1091            data_zoom_y: vec![],
1092            tooltip: None,
1093            axis_pointer: Some(delinea::AxisPointerSpec {
1094                enabled: true,
1095                trigger: delinea::AxisPointerTrigger::Item,
1096                pointer_type: delinea::AxisPointerType::Line,
1097                label: Default::default(),
1098                snap: false,
1099                trigger_distance_px: 100.0,
1100                throttle_px: 0.0,
1101            }),
1102            visual_maps: vec![],
1103            series: vec![SeriesSpec {
1104                id: series_a,
1105                name: Some("A".to_string()),
1106                kind: SeriesKind::Line,
1107                dataset: dataset_id,
1108                encode: SeriesEncode {
1109                    x: x_field,
1110                    y: y_a_field,
1111                    y2: None,
1112                },
1113                x_axis,
1114                y_axis,
1115                stack: None,
1116                stack_strategy: Default::default(),
1117                bar_layout: Default::default(),
1118                area_baseline: None,
1119                lod: None,
1120            }],
1121        };
1122
1123        let mut engine = ChartEngine::new(spec).unwrap();
1124        let mut table = delinea::data::DataTable::default();
1125        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1126        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1127        engine.datasets_mut().insert(dataset_id, table);
1128
1129        let mut measurer = NullTextMeasurer;
1130        let step = engine
1131            .step(&mut measurer, WorkBudget::new(262_144, 0, 32))
1132            .unwrap();
1133        assert!(!step.unfinished);
1134
1135        let axis_pointer = delinea::engine::AxisPointerOutput {
1136            grid: Some(grid_id),
1137            axis_kind: AxisKind::X,
1138            axis: x_axis,
1139            axis_value: 0.5,
1140            crosshair_px: Point::new(Px(50.0), Px(50.0)),
1141            hit: None,
1142            shadow_rect_px: None,
1143            tooltip: delinea::TooltipOutput::Item(delinea::TooltipItemOutput {
1144                series: series_a,
1145                data_index: 0,
1146                x_axis,
1147                y_axis,
1148                x_value: 0.5,
1149                y_value: 0.5,
1150            }),
1151        };
1152
1153        let formatter = DefaultTooltipFormatter;
1154        let lines =
1155            formatter.format_axis_pointer(&engine, &engine.output().axis_windows, &axis_pointer);
1156
1157        assert_eq!(lines.len(), 1);
1158        assert_eq!(lines[0].source_series, Some(series_a));
1159        assert_eq!(lines[0].kind, TooltipTextLineKind::SeriesRow);
1160        assert!(lines[0].value_emphasis);
1161        assert!(!lines[0].is_missing);
1162        assert_eq!(
1163            lines[0]
1164                .columns
1165                .as_ref()
1166                .map(|(l, r)| (l.as_str(), r.as_str())),
1167            Some(("A", "0.5"))
1168        );
1169    }
1170
1171    #[test]
1172    fn default_formatter_marks_missing_item_values() {
1173        let dataset_id = delinea::DatasetId::new(1);
1174        let grid_id = delinea::GridId::new(1);
1175        let x_axis = delinea::AxisId::new(1);
1176        let y_axis = delinea::AxisId::new(2);
1177        let series_a = delinea::SeriesId::new(1);
1178        let x_field = delinea::FieldId::new(1);
1179        let y_a_field = delinea::FieldId::new(2);
1180
1181        let spec = ChartSpec {
1182            id: delinea::ChartId::new(1),
1183            viewport: Some(Rect::new(
1184                Point::new(Px(0.0), Px(0.0)),
1185                Size::new(Px(100.0), Px(100.0)),
1186            )),
1187            datasets: vec![DatasetSpec {
1188                id: dataset_id,
1189                fields: vec![
1190                    FieldSpec {
1191                        id: x_field,
1192                        column: 0,
1193                    },
1194                    FieldSpec {
1195                        id: y_a_field,
1196                        column: 1,
1197                    },
1198                ],
1199
1200                from: None,
1201                transforms: Vec::new(),
1202            }],
1203            grids: vec![GridSpec { id: grid_id }],
1204            axes: vec![
1205                delinea::AxisSpec {
1206                    id: x_axis,
1207                    name: Some("Time".to_string()),
1208                    kind: AxisKind::X,
1209                    grid: grid_id,
1210                    position: None,
1211                    scale: Default::default(),
1212                    range: None,
1213                },
1214                delinea::AxisSpec {
1215                    id: y_axis,
1216                    name: Some("Value".to_string()),
1217                    kind: AxisKind::Y,
1218                    grid: grid_id,
1219                    position: None,
1220                    scale: Default::default(),
1221                    range: None,
1222                },
1223            ],
1224            data_zoom_x: vec![],
1225            data_zoom_y: vec![],
1226            tooltip: None,
1227            axis_pointer: Some(delinea::AxisPointerSpec {
1228                enabled: true,
1229                trigger: delinea::AxisPointerTrigger::Item,
1230                pointer_type: delinea::AxisPointerType::Line,
1231                label: Default::default(),
1232                snap: false,
1233                trigger_distance_px: 100.0,
1234                throttle_px: 0.0,
1235            }),
1236            visual_maps: vec![],
1237            series: vec![SeriesSpec {
1238                id: series_a,
1239                name: Some("A".to_string()),
1240                kind: SeriesKind::Line,
1241                dataset: dataset_id,
1242                encode: SeriesEncode {
1243                    x: x_field,
1244                    y: y_a_field,
1245                    y2: None,
1246                },
1247                x_axis,
1248                y_axis,
1249                stack: None,
1250                stack_strategy: Default::default(),
1251                bar_layout: Default::default(),
1252                area_baseline: None,
1253                lod: None,
1254            }],
1255        };
1256
1257        let mut engine = ChartEngine::new(spec).unwrap();
1258        let mut table = delinea::data::DataTable::default();
1259        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1260        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1261        engine.datasets_mut().insert(dataset_id, table);
1262
1263        let mut measurer = NullTextMeasurer;
1264        let step = engine
1265            .step(&mut measurer, WorkBudget::new(262_144, 0, 32))
1266            .unwrap();
1267        assert!(!step.unfinished);
1268
1269        let axis_pointer = delinea::engine::AxisPointerOutput {
1270            grid: Some(grid_id),
1271            axis_kind: AxisKind::X,
1272            axis: x_axis,
1273            axis_value: 0.5,
1274            crosshair_px: Point::new(Px(50.0), Px(50.0)),
1275            hit: None,
1276            shadow_rect_px: None,
1277            tooltip: delinea::TooltipOutput::Item(delinea::TooltipItemOutput {
1278                series: series_a,
1279                data_index: 0,
1280                x_axis,
1281                y_axis,
1282                x_value: 0.5,
1283                y_value: f64::NAN,
1284            }),
1285        };
1286
1287        let formatter = DefaultTooltipFormatter;
1288        let lines =
1289            formatter.format_axis_pointer(&engine, &engine.output().axis_windows, &axis_pointer);
1290
1291        assert_eq!(lines.len(), 1);
1292        assert_eq!(lines[0].source_series, Some(series_a));
1293        assert_eq!(lines[0].text, "A: -");
1294        assert_eq!(
1295            lines[0]
1296                .columns
1297                .as_ref()
1298                .map(|(l, r)| (l.as_str(), r.as_str())),
1299            Some(("A", "-"))
1300        );
1301        assert!(lines[0].is_missing);
1302    }
1303
1304    #[test]
1305    fn default_formatter_hides_item_axis_line_when_axis_pointer_label_enabled() {
1306        let dataset_id = delinea::DatasetId::new(1);
1307        let grid_id = delinea::GridId::new(1);
1308        let x_axis = delinea::AxisId::new(1);
1309        let y_axis = delinea::AxisId::new(2);
1310        let series_a = delinea::SeriesId::new(1);
1311        let x_field = delinea::FieldId::new(1);
1312        let y_a_field = delinea::FieldId::new(2);
1313
1314        let spec = ChartSpec {
1315            id: delinea::ChartId::new(1),
1316            viewport: Some(Rect::new(
1317                Point::new(Px(0.0), Px(0.0)),
1318                Size::new(Px(100.0), Px(100.0)),
1319            )),
1320            datasets: vec![DatasetSpec {
1321                id: dataset_id,
1322                fields: vec![
1323                    FieldSpec {
1324                        id: x_field,
1325                        column: 0,
1326                    },
1327                    FieldSpec {
1328                        id: y_a_field,
1329                        column: 1,
1330                    },
1331                ],
1332
1333                from: None,
1334                transforms: Vec::new(),
1335            }],
1336            grids: vec![GridSpec { id: grid_id }],
1337            axes: vec![
1338                delinea::AxisSpec {
1339                    id: x_axis,
1340                    name: Some("Time".to_string()),
1341                    kind: AxisKind::X,
1342                    grid: grid_id,
1343                    position: None,
1344                    scale: Default::default(),
1345                    range: None,
1346                },
1347                delinea::AxisSpec {
1348                    id: y_axis,
1349                    name: Some("Value".to_string()),
1350                    kind: AxisKind::Y,
1351                    grid: grid_id,
1352                    position: None,
1353                    scale: Default::default(),
1354                    range: None,
1355                },
1356            ],
1357            data_zoom_x: vec![],
1358            data_zoom_y: vec![],
1359            tooltip: None,
1360            axis_pointer: Some(delinea::AxisPointerSpec {
1361                enabled: true,
1362                trigger: delinea::AxisPointerTrigger::Item,
1363                pointer_type: delinea::AxisPointerType::Line,
1364                label: delinea::AxisPointerLabelSpec {
1365                    show: true,
1366                    template: "{value}".to_string(),
1367                },
1368                snap: false,
1369                trigger_distance_px: 100.0,
1370                throttle_px: 0.0,
1371            }),
1372            visual_maps: vec![],
1373            series: vec![SeriesSpec {
1374                id: series_a,
1375                name: Some("A".to_string()),
1376                kind: SeriesKind::Line,
1377                dataset: dataset_id,
1378                encode: SeriesEncode {
1379                    x: x_field,
1380                    y: y_a_field,
1381                    y2: None,
1382                },
1383                x_axis,
1384                y_axis,
1385                stack: None,
1386                stack_strategy: Default::default(),
1387                bar_layout: Default::default(),
1388                area_baseline: None,
1389                lod: None,
1390            }],
1391        };
1392
1393        let mut engine = ChartEngine::new(spec).unwrap();
1394        let mut table = delinea::data::DataTable::default();
1395        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1396        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1397        engine.datasets_mut().insert(dataset_id, table);
1398
1399        let mut measurer = NullTextMeasurer;
1400        let step = engine
1401            .step(&mut measurer, WorkBudget::new(262_144, 0, 32))
1402            .unwrap();
1403        assert!(!step.unfinished);
1404
1405        let axis_pointer = delinea::engine::AxisPointerOutput {
1406            grid: Some(grid_id),
1407            axis_kind: AxisKind::X,
1408            axis: x_axis,
1409            axis_value: 0.5,
1410            crosshair_px: Point::new(Px(50.0), Px(50.0)),
1411            hit: None,
1412            shadow_rect_px: None,
1413            tooltip: delinea::TooltipOutput::Item(delinea::TooltipItemOutput {
1414                series: series_a,
1415                data_index: 0,
1416                x_axis,
1417                y_axis,
1418                x_value: 0.5,
1419                y_value: 0.5,
1420            }),
1421        };
1422
1423        let formatter = DefaultTooltipFormatter;
1424        let lines =
1425            formatter.format_axis_pointer(&engine, &engine.output().axis_windows, &axis_pointer);
1426
1427        assert_eq!(lines.len(), 1);
1428        assert_eq!(lines[0].source_series, Some(series_a));
1429        assert_eq!(lines[0].kind, TooltipTextLineKind::SeriesRow);
1430        assert!(lines[0].value_emphasis);
1431    }
1432
1433    #[test]
1434    fn tooltip_spec_item_axis_line_show_overrides_auto_hiding() {
1435        let dataset_id = delinea::DatasetId::new(1);
1436        let grid_id = delinea::GridId::new(1);
1437        let x_axis = delinea::AxisId::new(1);
1438        let y_axis = delinea::AxisId::new(2);
1439        let series_a = delinea::SeriesId::new(1);
1440        let x_field = delinea::FieldId::new(1);
1441        let y_a_field = delinea::FieldId::new(2);
1442
1443        let tooltip = delinea::TooltipSpecV1 {
1444            item_axis_line: delinea::TooltipItemAxisLineMode::Show,
1445            ..Default::default()
1446        };
1447
1448        let spec = ChartSpec {
1449            id: delinea::ChartId::new(1),
1450            viewport: Some(Rect::new(
1451                Point::new(Px(0.0), Px(0.0)),
1452                Size::new(Px(100.0), Px(100.0)),
1453            )),
1454            datasets: vec![DatasetSpec {
1455                id: dataset_id,
1456                fields: vec![
1457                    FieldSpec {
1458                        id: x_field,
1459                        column: 0,
1460                    },
1461                    FieldSpec {
1462                        id: y_a_field,
1463                        column: 1,
1464                    },
1465                ],
1466
1467                from: None,
1468                transforms: Vec::new(),
1469            }],
1470            grids: vec![GridSpec { id: grid_id }],
1471            axes: vec![
1472                delinea::AxisSpec {
1473                    id: x_axis,
1474                    name: Some("Time".to_string()),
1475                    kind: AxisKind::X,
1476                    grid: grid_id,
1477                    position: None,
1478                    scale: Default::default(),
1479                    range: None,
1480                },
1481                delinea::AxisSpec {
1482                    id: y_axis,
1483                    name: Some("Value".to_string()),
1484                    kind: AxisKind::Y,
1485                    grid: grid_id,
1486                    position: None,
1487                    scale: Default::default(),
1488                    range: None,
1489                },
1490            ],
1491            data_zoom_x: vec![],
1492            data_zoom_y: vec![],
1493            tooltip: Some(tooltip),
1494            axis_pointer: Some(delinea::AxisPointerSpec {
1495                enabled: true,
1496                trigger: delinea::AxisPointerTrigger::Item,
1497                pointer_type: delinea::AxisPointerType::Line,
1498                label: delinea::AxisPointerLabelSpec {
1499                    show: true,
1500                    template: "{value}".to_string(),
1501                },
1502                snap: false,
1503                trigger_distance_px: 100.0,
1504                throttle_px: 0.0,
1505            }),
1506            visual_maps: vec![],
1507            series: vec![SeriesSpec {
1508                id: series_a,
1509                name: Some("A".to_string()),
1510                kind: SeriesKind::Line,
1511                dataset: dataset_id,
1512                encode: SeriesEncode {
1513                    x: x_field,
1514                    y: y_a_field,
1515                    y2: None,
1516                },
1517                x_axis,
1518                y_axis,
1519                stack: None,
1520                stack_strategy: Default::default(),
1521                bar_layout: Default::default(),
1522                area_baseline: None,
1523                lod: None,
1524            }],
1525        };
1526
1527        let mut engine = ChartEngine::new(spec).unwrap();
1528        let mut table = delinea::data::DataTable::default();
1529        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1530        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1531        engine.datasets_mut().insert(dataset_id, table);
1532
1533        let mut measurer = NullTextMeasurer;
1534        let step = engine
1535            .step(&mut measurer, WorkBudget::new(262_144, 0, 32))
1536            .unwrap();
1537        assert!(!step.unfinished);
1538
1539        let axis_pointer = delinea::engine::AxisPointerOutput {
1540            grid: Some(grid_id),
1541            axis_kind: AxisKind::X,
1542            axis: x_axis,
1543            axis_value: 0.5,
1544            crosshair_px: Point::new(Px(50.0), Px(50.0)),
1545            hit: None,
1546            shadow_rect_px: None,
1547            tooltip: delinea::TooltipOutput::Item(delinea::TooltipItemOutput {
1548                series: series_a,
1549                data_index: 0,
1550                x_axis,
1551                y_axis,
1552                x_value: 0.5,
1553                y_value: 0.5,
1554            }),
1555        };
1556
1557        let formatter = DefaultTooltipFormatter;
1558        let lines =
1559            formatter.format_axis_pointer(&engine, &engine.output().axis_windows, &axis_pointer);
1560
1561        assert_eq!(lines.len(), 2);
1562        assert_eq!(lines[0].kind, TooltipTextLineKind::AxisHeader);
1563        assert_eq!(lines[1].kind, TooltipTextLineKind::SeriesRow);
1564    }
1565
1566    #[test]
1567    fn tooltip_spec_v1_per_series_overrides_apply_to_series_rows() {
1568        let dataset_id = delinea::DatasetId::new(1);
1569        let grid_id = delinea::GridId::new(1);
1570        let x_axis = delinea::AxisId::new(1);
1571        let y_axis = delinea::AxisId::new(2);
1572        let series_a = delinea::SeriesId::new(1);
1573        let series_b = delinea::SeriesId::new(2);
1574        let x_field = delinea::FieldId::new(1);
1575        let y_a_field = delinea::FieldId::new(2);
1576        let y_b_field = delinea::FieldId::new(3);
1577
1578        let tooltip = delinea::TooltipSpecV1 {
1579            axis_line_template: "{label}: {value}".to_string(),
1580            series_line_template: "{label}={value}".to_string(),
1581            item_axis_line: Default::default(),
1582            missing_value: "-".to_string(),
1583            range_template: "{min}..{max}".to_string(),
1584            value_decimals: Some(2),
1585            trim_trailing_zeros: false,
1586            series_overrides: vec![delinea::TooltipSeriesOverrideV1 {
1587                series: series_b,
1588                series_line_template: Some("B only: {value}".to_string()),
1589                missing_value: Some("(none)".to_string()),
1590                range_template: None,
1591                value_decimals: Some(0),
1592                trim_trailing_zeros: Some(true),
1593            }],
1594        };
1595
1596        let spec = ChartSpec {
1597            id: delinea::ChartId::new(1),
1598            viewport: Some(Rect::new(
1599                Point::new(Px(0.0), Px(0.0)),
1600                Size::new(Px(100.0), Px(100.0)),
1601            )),
1602            datasets: vec![DatasetSpec {
1603                id: dataset_id,
1604                fields: vec![
1605                    FieldSpec {
1606                        id: x_field,
1607                        column: 0,
1608                    },
1609                    FieldSpec {
1610                        id: y_a_field,
1611                        column: 1,
1612                    },
1613                    FieldSpec {
1614                        id: y_b_field,
1615                        column: 2,
1616                    },
1617                ],
1618
1619                from: None,
1620                transforms: Vec::new(),
1621            }],
1622            grids: vec![GridSpec { id: grid_id }],
1623            axes: vec![
1624                delinea::AxisSpec {
1625                    id: x_axis,
1626                    name: Some("Time".to_string()),
1627                    kind: AxisKind::X,
1628                    grid: grid_id,
1629                    position: None,
1630                    scale: Default::default(),
1631                    range: None,
1632                },
1633                delinea::AxisSpec {
1634                    id: y_axis,
1635                    name: Some("Value".to_string()),
1636                    kind: AxisKind::Y,
1637                    grid: grid_id,
1638                    position: None,
1639                    scale: Default::default(),
1640                    range: None,
1641                },
1642            ],
1643            data_zoom_x: vec![],
1644            data_zoom_y: vec![],
1645            tooltip: Some(tooltip),
1646            axis_pointer: Some(delinea::AxisPointerSpec {
1647                enabled: true,
1648                trigger: delinea::AxisPointerTrigger::Axis,
1649                pointer_type: delinea::AxisPointerType::Line,
1650                label: Default::default(),
1651                snap: false,
1652                trigger_distance_px: 0.0,
1653                throttle_px: 0.0,
1654            }),
1655            visual_maps: vec![],
1656            series: vec![
1657                SeriesSpec {
1658                    id: series_a,
1659                    name: Some("A".to_string()),
1660                    kind: SeriesKind::Line,
1661                    dataset: dataset_id,
1662                    encode: SeriesEncode {
1663                        x: x_field,
1664                        y: y_a_field,
1665                        y2: None,
1666                    },
1667                    x_axis,
1668                    y_axis,
1669                    stack: None,
1670                    stack_strategy: Default::default(),
1671                    bar_layout: Default::default(),
1672                    area_baseline: None,
1673                    lod: None,
1674                },
1675                SeriesSpec {
1676                    id: series_b,
1677                    name: Some("B".to_string()),
1678                    kind: SeriesKind::Line,
1679                    dataset: dataset_id,
1680                    encode: SeriesEncode {
1681                        x: x_field,
1682                        y: y_b_field,
1683                        y2: None,
1684                    },
1685                    x_axis,
1686                    y_axis,
1687                    stack: None,
1688                    stack_strategy: Default::default(),
1689                    bar_layout: Default::default(),
1690                    area_baseline: None,
1691                    lod: None,
1692                },
1693            ],
1694        };
1695
1696        let mut engine = ChartEngine::new(spec).unwrap();
1697        let mut table = delinea::data::DataTable::default();
1698        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1699        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0]));
1700        table.push_column(delinea::data::Column::F64(vec![0.0, 2.0]));
1701        engine.datasets_mut().insert(dataset_id, table);
1702
1703        let mut measurer = NullTextMeasurer;
1704        let step = engine
1705            .step(&mut measurer, WorkBudget::new(262_144, 0, 32))
1706            .unwrap();
1707        assert!(!step.unfinished);
1708
1709        engine.apply_action(delinea::Action::HoverAt {
1710            point: Point::new(Px(50.0), Px(50.0)),
1711        });
1712        let step = engine
1713            .step(&mut measurer, WorkBudget::new(32_768, 0, 8))
1714            .unwrap();
1715        assert!(!step.unfinished);
1716
1717        let axis_pointer = engine.output().axis_pointer.as_ref().unwrap();
1718        let formatter = DefaultTooltipFormatter;
1719        let lines =
1720            formatter.format_axis_pointer(&engine, &engine.output().axis_windows, axis_pointer);
1721        assert_eq!(lines.len(), 3);
1722        assert_eq!(lines[0].source_series, None);
1723        assert_eq!(lines[0].text, "x (Time): 0.50");
1724        assert_eq!(
1725            lines[0]
1726                .columns
1727                .as_ref()
1728                .map(|(l, r)| (l.as_str(), r.as_str())),
1729            Some(("x (Time)", "0.50"))
1730        );
1731        assert_eq!(lines[0].kind, TooltipTextLineKind::AxisHeader);
1732        assert!(lines[0].value_emphasis);
1733        assert_eq!(lines[1].source_series, Some(series_a));
1734        assert_eq!(lines[1].text, "A=0.50");
1735        assert_eq!(lines[1].columns, None);
1736        assert_eq!(lines[1].kind, TooltipTextLineKind::SeriesRow);
1737        assert!(!lines[1].value_emphasis);
1738        assert_eq!(lines[2].source_series, Some(series_b));
1739        assert_eq!(lines[2].text, "B only: 1");
1740        assert_eq!(lines[2].columns, None);
1741        assert_eq!(lines[2].kind, TooltipTextLineKind::SeriesRow);
1742        assert!(!lines[2].value_emphasis);
1743    }
1744
1745    #[test]
1746    fn closure_formatter_can_render_axis_trigger_tooltip() {
1747        let dataset_id = delinea::DatasetId::new(1);
1748        let grid_id = delinea::GridId::new(1);
1749        let x_axis = delinea::AxisId::new(1);
1750        let y_axis = delinea::AxisId::new(2);
1751        let series = delinea::SeriesId::new(1);
1752        let x_field = delinea::FieldId::new(1);
1753        let y_field = delinea::FieldId::new(2);
1754
1755        let spec = ChartSpec {
1756            id: delinea::ChartId::new(1),
1757            viewport: Some(Rect::new(
1758                Point::new(Px(0.0), Px(0.0)),
1759                Size::new(Px(100.0), Px(100.0)),
1760            )),
1761            datasets: vec![DatasetSpec {
1762                id: dataset_id,
1763                fields: vec![
1764                    FieldSpec {
1765                        id: x_field,
1766                        column: 0,
1767                    },
1768                    FieldSpec {
1769                        id: y_field,
1770                        column: 1,
1771                    },
1772                ],
1773
1774                from: None,
1775                transforms: Vec::new(),
1776            }],
1777            grids: vec![GridSpec { id: grid_id }],
1778            axes: vec![
1779                delinea::AxisSpec {
1780                    id: x_axis,
1781                    name: Some("Time".to_string()),
1782                    kind: AxisKind::X,
1783                    grid: grid_id,
1784                    position: None,
1785                    scale: Default::default(),
1786                    range: None,
1787                },
1788                delinea::AxisSpec {
1789                    id: y_axis,
1790                    name: Some("Value".to_string()),
1791                    kind: AxisKind::Y,
1792                    grid: grid_id,
1793                    position: None,
1794                    scale: Default::default(),
1795                    range: None,
1796                },
1797            ],
1798            data_zoom_x: vec![],
1799            data_zoom_y: vec![],
1800            tooltip: None,
1801            axis_pointer: Some(delinea::AxisPointerSpec {
1802                enabled: true,
1803                trigger: delinea::AxisPointerTrigger::Axis,
1804                pointer_type: delinea::AxisPointerType::Line,
1805                label: Default::default(),
1806                snap: true,
1807                trigger_distance_px: 10_000.0,
1808                throttle_px: 0.0,
1809            }),
1810            visual_maps: vec![],
1811            series: vec![SeriesSpec {
1812                id: series,
1813                name: Some("A".to_string()),
1814                kind: SeriesKind::Line,
1815                dataset: dataset_id,
1816                encode: SeriesEncode {
1817                    x: x_field,
1818                    y: y_field,
1819                    y2: None,
1820                },
1821                x_axis,
1822                y_axis,
1823                stack: None,
1824                stack_strategy: Default::default(),
1825                bar_layout: Default::default(),
1826                area_baseline: None,
1827                lod: None,
1828            }],
1829        };
1830
1831        let mut engine = ChartEngine::new(spec).unwrap();
1832        let mut table = delinea::data::DataTable::default();
1833        table.push_column(delinea::data::Column::F64(vec![0.0, 1.0, 2.0]));
1834        table.push_column(delinea::data::Column::F64(vec![10.0, 20.0, 30.0]));
1835        engine.datasets_mut().insert(dataset_id, table);
1836
1837        engine.apply_action(delinea::Action::HoverAt {
1838            point: Point::new(Px(50.0), Px(50.0)),
1839        });
1840
1841        let mut measurer = NullTextMeasurer;
1842        let step = engine
1843            .step(&mut measurer, WorkBudget::new(262_144, 0, 32))
1844            .unwrap();
1845        assert!(!step.unfinished);
1846
1847        let axis_pointer = engine.output().axis_pointer.as_ref().unwrap();
1848        let axis_windows = &engine.output().axis_windows;
1849
1850        let formatter = TooltipFormatterFn::new(|cx: &TooltipFormatContext<'_>| {
1851            let delinea::TooltipOutput::Axis(axis) = cx.tooltip() else {
1852                return vec![];
1853            };
1854            vec![TooltipTextLine::plain(format!(
1855                "axis={} value={} series={}",
1856                axis.axis.0,
1857                axis.axis_value,
1858                axis.series.len()
1859            ))]
1860        });
1861
1862        let lines = formatter.format_axis_pointer(&engine, axis_windows, axis_pointer);
1863        assert_eq!(lines.len(), 1);
1864        assert!(lines[0].text.contains("axis="));
1865        assert_eq!(lines[0].kind, TooltipTextLineKind::Body);
1866        assert!(!lines[0].value_emphasis);
1867    }
1868}