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 #[default]
11 Body,
12 AxisHeader,
14 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}