leptos_chartistry/overlay/
tooltip.rs

1use crate::{
2    debug::DebugRect,
3    series::{Snippet, UseY},
4    state::State,
5    Tick, TickLabels, AXIS_MARKER_COLOUR,
6};
7use leptos::prelude::*;
8use std::cmp::{Ordering, Reverse};
9
10/// Default gap distance from cursor to tooltip when shown.
11pub const TOOLTIP_CURSOR_DISTANCE: f64 = 10.0;
12
13/// Builds a mouse tooltip that shows X and Y values for the nearest data. Drawn in HTML as an overlay.
14#[derive(Clone, Debug, PartialEq)]
15#[non_exhaustive]
16pub struct Tooltip<X: Tick, Y: Tick> {
17    /// Where the tooltip is placed when shown.
18    pub placement: RwSignal<TooltipPlacement>,
19    /// How the tooltip Y value table is sorted.
20    pub sort_by: RwSignal<TooltipSortBy>,
21    /// Gap distance from cursor to tooltip when shown.
22    pub cursor_distance: RwSignal<f64>,
23    /// If true, skips Y values that are `f64::NAN`.
24    pub skip_missing: RwSignal<bool>,
25    /// Whether to show X ticks. Default is true.
26    // TODO: move to TickLabels
27    pub show_x_ticks: RwSignal<bool>,
28    /// X axis formatter.
29    pub x_ticks: TickLabels<X>,
30    /// Y axis formatter.
31    pub y_ticks: TickLabels<Y>,
32}
33
34/// Where the tooltip is place when shown.
35#[derive(Copy, Clone, Debug, Default, PartialEq)]
36#[non_exhaustive]
37pub enum TooltipPlacement {
38    /// Does not show a tooltip.
39    #[default]
40    Hide,
41    /// Shows the tooltip to the left of the cursor.
42    LeftCursor,
43}
44
45/// How the tooltip Y value table is sorted.
46#[derive(Copy, Clone, Debug, Default, PartialEq)]
47#[non_exhaustive]
48pub enum TooltipSortBy {
49    /// Sorts by line name.
50    #[default]
51    Lines,
52    /// Sorts by Y value in ascending order.
53    Ascending,
54    /// Sorts by Y value in descending order.
55    Descending,
56}
57
58impl<X: Tick, Y: Tick> Tooltip<X, Y> {
59    /// Creates a new tooltip with the given placement, X ticks, and Y ticks.
60    pub fn new(
61        placement: impl Into<TooltipPlacement>,
62        x_ticks: impl Into<TickLabels<X>>,
63        y_ticks: impl Into<TickLabels<Y>>,
64    ) -> Self {
65        Self {
66            placement: RwSignal::new(placement.into()),
67            x_ticks: x_ticks.into(),
68            y_ticks: y_ticks.into(),
69            ..Default::default()
70        }
71    }
72
73    /// Creates a new tooltip with the given placement. Uses default X and Y ticks.
74    pub fn from_placement(placement: impl Into<TooltipPlacement>) -> Self {
75        Self::new(
76            placement,
77            TickLabels::from_generator(X::tooltip_generator()),
78            TickLabels::from_generator(Y::tooltip_generator()),
79        )
80    }
81
82    /// Creates a new tooltip left of the cursor. Uses default X and Y ticks.
83    pub fn left_cursor() -> Self {
84        Self::from_placement(TooltipPlacement::LeftCursor)
85    }
86
87    /// Sets the sort order of the Y value table.
88    pub fn with_sort_by(self, sort_by: impl Into<TooltipSortBy>) -> Self {
89        self.sort_by.set(sort_by.into());
90        self
91    }
92
93    /// Sets the gap distance from cursor to tooltip when shown.
94    pub fn with_cursor_distance(self, distance: impl Into<f64>) -> Self {
95        self.cursor_distance.set(distance.into());
96        self
97    }
98
99    /// Sets whether the tooltip should skip Y values that are `f64::NAN`.
100    pub fn skip_missing(self, skip_missing: impl Into<bool>) -> Self {
101        self.skip_missing.set(skip_missing.into());
102        self
103    }
104
105    /// Sets whether to show X ticks.
106    pub fn show_x_ticks(self, show_x_ticks: impl Into<bool>) -> Self {
107        self.show_x_ticks.set(show_x_ticks.into());
108        self
109    }
110}
111
112impl<X: Tick, Y: Tick> Default for Tooltip<X, Y> {
113    fn default() -> Self {
114        Self {
115            placement: RwSignal::default(),
116            sort_by: RwSignal::default(),
117            cursor_distance: RwSignal::new(TOOLTIP_CURSOR_DISTANCE),
118            skip_missing: RwSignal::new(false),
119            show_x_ticks: RwSignal::new(true),
120            x_ticks: TickLabels::default(),
121            y_ticks: TickLabels::default(),
122        }
123    }
124}
125
126impl TooltipSortBy {
127    fn to_ord<Y: Tick>(y: &Option<Y>) -> Option<F64Ord> {
128        y.as_ref().map(|y| F64Ord(y.position()))
129    }
130
131    fn sort_values<Y: Tick>(&self, values: &mut [(UseY, Option<Y>)]) {
132        match self {
133            TooltipSortBy::Lines => values.sort_by_key(|(line, _)| line.name.get()),
134            TooltipSortBy::Ascending => values.sort_by_key(|(_, y)| Self::to_ord(y)),
135            TooltipSortBy::Descending => values.sort_by_key(|(_, y)| Reverse(Self::to_ord(y))),
136        }
137    }
138}
139
140#[derive(Copy, Clone, PartialEq)]
141struct F64Ord(f64);
142
143impl PartialOrd for F64Ord {
144    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
145        Some(self.cmp(other))
146    }
147}
148
149impl Ord for F64Ord {
150    fn cmp(&self, other: &Self) -> Ordering {
151        self.0.total_cmp(&other.0)
152    }
153}
154
155impl Eq for F64Ord {}
156
157impl std::fmt::Display for TooltipPlacement {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        match self {
160            TooltipPlacement::Hide => write!(f, "Hide"),
161            TooltipPlacement::LeftCursor => write!(f, "Left cursor"),
162        }
163    }
164}
165
166impl std::str::FromStr for TooltipPlacement {
167    type Err = String;
168
169    fn from_str(s: &str) -> Result<Self, Self::Err> {
170        match s.to_lowercase().as_str() {
171            "hide" => Ok(TooltipPlacement::Hide),
172            "left cursor" => Ok(TooltipPlacement::LeftCursor),
173            _ => Err(format!("invalid TooltipPlacement: `{}`", s)),
174        }
175    }
176}
177
178impl std::fmt::Display for TooltipSortBy {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        match self {
181            TooltipSortBy::Lines => write!(f, "Lines"),
182            TooltipSortBy::Ascending => write!(f, "Ascending"),
183            TooltipSortBy::Descending => write!(f, "Descending"),
184        }
185    }
186}
187
188impl std::str::FromStr for TooltipSortBy {
189    type Err = String;
190
191    fn from_str(s: &str) -> Result<Self, Self::Err> {
192        match s.to_lowercase().as_str() {
193            "lines" => Ok(TooltipSortBy::Lines),
194            "ascending" => Ok(TooltipSortBy::Ascending),
195            "descending" => Ok(TooltipSortBy::Descending),
196            _ => Err(format!("invalid SortBy: `{}`", s)),
197        }
198    }
199}
200
201#[component]
202pub(crate) fn Tooltip<X: Tick, Y: Tick>(
203    tooltip: Tooltip<X, Y>,
204    state: State<X, Y>,
205) -> impl IntoView {
206    let Tooltip {
207        placement,
208        sort_by,
209        skip_missing,
210        cursor_distance,
211        show_x_ticks,
212        x_ticks,
213        y_ticks,
214    } = tooltip;
215    let debug = state.pre.debug;
216    let font_height = state.pre.font_height;
217    let font_width = state.pre.font_width;
218    let padding = state.pre.padding;
219    let inner = state.layout.inner;
220
221    let x_body = {
222        let nearest_data_x = state.pre.data.nearest_data_x(state.hover_position_x);
223        let x_format = x_ticks.format;
224        let avail_width = Signal::derive(move || inner.read().width());
225        let x_ticks = x_ticks.generate_x(&state.pre, avail_width);
226        move || {
227            // Hide ticks?
228            if !show_x_ticks.get() {
229                return "".to_string();
230            }
231            let x_format = x_format.get();
232            nearest_data_x.read().as_ref().map_or_else(
233                || "no data".to_string(),
234                |x_value| (x_format)(x_value, x_ticks.read().state.as_ref()),
235            )
236        }
237    };
238
239    let format_y_value = {
240        let avail_height = Signal::derive(move || inner.read().height());
241        let y_format = y_ticks.format;
242        let y_ticks = y_ticks.generate_y(&state.pre, avail_height);
243        move |y_value: Option<Y>| {
244            let y_format = y_format.get();
245            y_value.as_ref().map_or_else(
246                || "-".to_string(),
247                |y_value| (y_format)(y_value, y_ticks.read().state.as_ref()),
248            )
249        }
250    };
251
252    let nearest_y_values = {
253        let nearest_data_y = state.pre.data.nearest_data_y(state.hover_position_x);
254        Memo::new(move |_| {
255            let mut y_values = nearest_data_y.get();
256            // Skip missing?
257            if skip_missing.get() {
258                y_values = y_values
259                    .into_iter()
260                    .filter(|(_, y_value)| y_value.is_some())
261                    .collect::<Vec<_>>()
262            }
263            // Sort values
264            sort_by.get().sort_values(&mut y_values);
265            y_values
266        })
267    };
268
269    let nearest_data_y = move || {
270        nearest_y_values
271            .get()
272            .into_iter()
273            .map(|(line, y_value)| {
274                let y_value = format_y_value(y_value);
275                (line, y_value)
276            })
277            .collect::<Vec<_>>()
278    };
279
280    let series_tr = {
281        let state = state.clone();
282        move |(series, y_value): (UseY, String)| {
283            view! {
284                <tr>
285                    <td><Snippet series=series state=state.clone() /></td>
286                    <td
287                        style="white-space: pre; font-family: monospace; text-align: right;"
288                        style:padding-top=move || format!("{}px", font_height.get() / 4.0)
289                        style:padding-left=move || format!("{}px", font_width.get())>
290                        {y_value}
291                    </td>
292                </tr>
293            }
294            .into_any()
295        }
296    };
297
298    view! {
299        <Show when=move || state.hover_inner.get() && placement.get() != TooltipPlacement::Hide>
300            <DebugRect label="tooltip" debug=debug />
301            <aside
302                class="_chartistry_tooltip"
303                style="position: absolute; z-index: 1; width: max-content; height: max-content; transform: translateY(-50%); background-color: #fff; white-space: pre; font-family: monospace;"
304                style:border=format!("1px solid {}", AXIS_MARKER_COLOUR)
305                style:top=move || format!("calc({}px)", state.mouse_page.get().1)
306                style:right=move || format!("calc(100% - {}px + {}px)", state.mouse_page.get().0, cursor_distance.get())
307                style:padding=move || padding.get().to_css_style()>
308                <h2
309                    style="margin: 0; text-align: center;"
310                    style:font-size=move || format!("{}px", font_height.get())>
311                    {x_body}
312                </h2>
313                <table
314                    style="border-collapse: collapse; border-spacing: 0; margin: 0 0 0 auto; padding: 0;"
315                    style:font-size=move || format!("{}px", font_height.get())>
316                    <tbody>
317                        <For
318                            each=nearest_data_y
319                            key=|(series, y_value)| (series.id, y_value.to_owned())
320                            children=series_tr.clone()
321                        />
322                    </tbody>
323                </table>
324            </aside>
325        </Show>
326    }.into_any()
327}