leptos_chartistry/layout/
tick_labels.rs

1use super::{UseLayout, UseVerticalLayout};
2use crate::{
3    bounds::Bounds,
4    debug::DebugRect,
5    edge::Edge,
6    state::{PreState, State},
7    ticks::{
8        AlignedFloats, GeneratedTicks, HorizontalSpan, TickFormat, TickFormatFn, TickGen,
9        Timestamps, VerticalSpan,
10    },
11    Tick,
12};
13use chrono::prelude::*;
14use leptos::prelude::*;
15use std::sync::Arc;
16
17/// Builds tick labels for an axis.
18///
19/// Note that ticks lack an identity resulting in generators and labels not being reactive.
20#[derive(Debug, PartialEq)]
21#[non_exhaustive]
22pub struct TickLabels<XY: Tick> {
23    /// Minimum number of characters to display for each tick label.
24    ///
25    /// Helpful for giving a fixed width to labels e.g., if your graph can display 0-100 then it might show a shorter label on "0" or "42" to "100". Needed to have the same inner chart ratio when using an outer chart ratio. Can also be useful for aligning a list of charts.
26    pub min_chars: RwSignal<usize>,
27    /// Format function for the tick labels. See [TickLabels::with_format] for details.
28    pub format: RwSignal<Arc<TickFormatFn<XY>>>,
29    /// Tick generator for the labels.
30    pub generator: RwSignal<Arc<dyn TickGen<Tick = XY> + Send + Sync>>,
31}
32
33#[derive(Clone)]
34pub struct UseTickLabels {
35    ticks: Signal<Vec<(f64, String)>>,
36}
37
38impl<XY: Tick> Clone for TickLabels<XY> {
39    fn clone(&self) -> Self {
40        Self {
41            min_chars: self.min_chars,
42            format: self.format,
43            generator: self.generator,
44        }
45    }
46}
47
48impl<XY: Tick> Default for TickLabels<XY> {
49    fn default() -> Self {
50        Self::from_generator(XY::tick_label_generator())
51    }
52}
53
54impl TickLabels<f64> {
55    /// Creates a new tick label generator for floating point numbers. See [AlignedFloats] for details.
56    pub fn aligned_floats() -> Self {
57        Self::from_generator(AlignedFloats::default())
58    }
59}
60
61impl<Tz> TickLabels<DateTime<Tz>>
62where
63    Tz: TimeZone + Send + Sync + 'static,
64    Tz::Offset: std::fmt::Display + Send + Sync,
65{
66    /// Creates a new tick label generator for timestamps. See [Timestamps] for details.
67    pub fn timestamps() -> Self {
68        Self::from_generator(Timestamps::default())
69    }
70}
71
72impl<XY: Tick> TickLabels<XY> {
73    /// Creates a new tick label generator from a tick generator. Use [AlignedFloats] or [Timestamps] for available generators.
74    pub fn from_generator(gen: impl TickGen<Tick = XY> + 'static) -> Self {
75        Self {
76            min_chars: RwSignal::default(),
77            format: RwSignal::new(HorizontalSpan::identity_format()),
78            generator: RwSignal::new(Arc::new(gen)),
79        }
80    }
81
82    /// Sets the minimum number of characters to display for each tick label.
83    pub fn with_min_chars(self, min_chars: usize) -> Self {
84        self.min_chars.set(min_chars);
85        self
86    }
87
88    /// Sets the format function for the tick labels.
89    ///
90    /// This is a function that takes a `Tick` and a formatter and returns a `String`. It gives an opportunity to customise tick label format. The formatter is the resulting state of the tick generator and does the default aciton. For example if aligned floats decides to use "1000s" then the formatter will use that.
91    pub fn with_format(
92        self,
93        format: impl Fn(&XY, &dyn TickFormat<Tick = XY>) -> String + Send + Sync + 'static,
94    ) -> Self {
95        self.format.set(Arc::new(format));
96        self
97    }
98
99    fn map_ticks(&self, gen: Memo<GeneratedTicks<XY>>) -> Signal<Vec<(f64, String)>> {
100        let format = self.format;
101        Signal::derive(move || {
102            let format = format.get();
103            gen.with(|GeneratedTicks { ticks, state }| {
104                ticks
105                    .iter()
106                    .map(|tick| (tick.position(), (format)(tick, state.as_ref())))
107                    .collect()
108            })
109        })
110    }
111}
112
113impl<Gen, XY> From<Gen> for TickLabels<XY>
114where
115    Gen: TickGen<Tick = XY> + 'static,
116    XY: Tick,
117{
118    fn from(gen: Gen) -> Self {
119        Self::from_generator(gen)
120    }
121}
122
123impl<X: Tick> TickLabels<X> {
124    pub(crate) fn generate_x<Y: Tick>(
125        &self,
126        state: &PreState<X, Y>,
127        avail_width: Signal<f64>,
128    ) -> Memo<GeneratedTicks<X>> {
129        let font_width = state.font_width;
130        let padding = state.padding;
131        let range_x = state.data.range_x;
132        let TickLabels {
133            min_chars,
134            format,
135            generator,
136        } = self.clone();
137        Memo::new(move |_| {
138            range_x
139                .get()
140                .range()
141                .map(|(first, last)| {
142                    let span = HorizontalSpan::new(
143                        font_width.get(),
144                        min_chars.get(),
145                        padding.get().width(),
146                        avail_width.get(),
147                        format.get(),
148                    );
149                    generator.get().generate(first, last, &span)
150                })
151                .unwrap_or_else(GeneratedTicks::none)
152        })
153    }
154
155    pub(super) fn fixed_height<Y: Tick>(&self, state: &PreState<X, Y>) -> Signal<f64> {
156        let font_height = state.font_height;
157        let padding = state.padding;
158        Signal::derive(move || font_height.get() + padding.get().height())
159    }
160
161    pub(super) fn to_horizontal_use<Y: Tick>(
162        &self,
163        state: &PreState<X, Y>,
164        avail_width: Memo<f64>,
165    ) -> UseLayout {
166        UseLayout::TickLabels(UseTickLabels {
167            ticks: self.map_ticks(self.generate_x(state, avail_width.into())),
168        })
169    }
170}
171
172impl<Y: Tick> TickLabels<Y> {
173    pub(crate) fn generate_y<X: Tick>(
174        &self,
175        state: &PreState<X, Y>,
176        avail_height: Signal<f64>,
177    ) -> Memo<GeneratedTicks<Y>> {
178        let font_height = state.font_height;
179        let padding = state.padding;
180        let range_y = state.data.range_y;
181        let generator = self.generator;
182        Memo::new(move |_| {
183            range_y
184                .get()
185                .range()
186                .map(|(first, last)| {
187                    let span = VerticalSpan::new(
188                        font_height.get() + padding.get().height(),
189                        avail_height.get(),
190                    );
191                    generator.get().generate(first, last, &span)
192                })
193                .unwrap_or_else(GeneratedTicks::none)
194        })
195    }
196
197    pub(super) fn to_vertical_use<X: Tick>(
198        &self,
199        state: &PreState<X, Y>,
200        avail_height: Memo<f64>,
201    ) -> UseVerticalLayout {
202        let ticks = self.map_ticks(self.generate_y(state, avail_height.into()));
203        UseVerticalLayout {
204            width: mk_width(self.min_chars, state, ticks),
205            layout: UseLayout::TickLabels(UseTickLabels { ticks }),
206        }
207    }
208}
209
210fn mk_width<X: Tick, Y: Tick>(
211    min_chars: RwSignal<usize>,
212    state: &PreState<X, Y>,
213    ticks: Signal<Vec<(f64, String)>>,
214) -> Signal<f64> {
215    let font_width = state.font_width;
216    let padding = state.padding;
217    Signal::derive(move || {
218        let longest_chars = ticks.with(|ticks| {
219            ticks
220                .iter()
221                .map(|(_, label)| label.len())
222                .max()
223                .unwrap_or_default()
224                .max(min_chars.get())
225        }) as f64;
226        font_width.get() * longest_chars + padding.get().width()
227    })
228}
229
230fn align_tick_labels(labels: Vec<String>) -> Vec<String> {
231    // Find longest label length
232    let min_label = labels
233        .iter()
234        .map(|label| label.len())
235        .max()
236        .unwrap_or_default();
237    // Pad labels to same length
238    labels
239        .into_iter()
240        .map(|mut label| {
241            let spaces = " ".repeat(min_label.saturating_sub(label.len()));
242            label.insert_str(0, &spaces);
243            label
244        })
245        .collect::<Vec<_>>()
246}
247
248#[component]
249pub(super) fn TickLabels<X: Tick, Y: Tick>(
250    ticks: UseTickLabels,
251    edge: Edge,
252    bounds: Memo<Bounds>,
253    state: State<X, Y>,
254) -> impl IntoView {
255    let ticks = move || {
256        // Align vertical labels
257        let ticks = ticks.ticks.get();
258        let ticks = if edge.is_vertical() {
259            let (pos, labels): (Vec<f64>, Vec<String>) = ticks.into_iter().unzip();
260            let labels = align_tick_labels(labels);
261            pos.into_iter().zip(labels).collect::<Vec<_>>()
262        } else {
263            ticks
264        };
265        ticks
266            .into_iter()
267            .map(|tick| {
268                view! {
269                    <TickLabel edge=edge outer=bounds state=state.clone() tick=tick />
270                }
271            })
272            .collect_view()
273    };
274    view! {
275        <g class="_chartistry_tick_labels">
276            {ticks}
277        </g>
278    }
279}
280
281#[component]
282fn TickLabel<X: Tick, Y: Tick>(
283    edge: Edge,
284    outer: Memo<Bounds>,
285    state: State<X, Y>,
286    tick: (f64, String),
287) -> impl IntoView {
288    let debug = state.pre.debug;
289    let font_height = state.pre.font_height;
290    let font_width = state.pre.font_width;
291    let padding = state.pre.padding;
292    let projection = state.projection;
293
294    let (position, label) = tick;
295    let label_len = label.len();
296    // Calculate positioning Bounds. Note: tick w / h includes padding
297    let bounds = Signal::derive(move || {
298        let padding = padding.get();
299        let width = font_width.get() * label_len as f64 + padding.width();
300        let height = font_height.get() + padding.height();
301
302        let proj = projection.get();
303        let outer = outer.get();
304        match edge {
305            Edge::Top | Edge::Bottom => {
306                let (x, _) = proj.position_to_svg(position, 0.0);
307                let x = x - width / 2.0;
308                Bounds::from_points(x, outer.top_y(), x + width, outer.bottom_y())
309            }
310
311            Edge::Left | Edge::Right => {
312                let (_, y) = proj.position_to_svg(0.0, position);
313                let y = y - height / 2.0;
314                Bounds::from_points(outer.left_x(), y, outer.right_x(), y + height)
315            }
316        }
317    });
318    let content = Memo::new(move |_| padding.get().apply(bounds.get()));
319
320    // Determine text position
321    let text_position = Memo::new(move |_| {
322        let content = content.get();
323        match edge {
324            Edge::Top | Edge::Bottom => ("middle", content.centre_x()),
325
326            Edge::Left | Edge::Right => {
327                let (x, anchor) = if edge == Edge::Left {
328                    (content.right_x(), "end")
329                } else {
330                    (content.left_x(), "start")
331                };
332                (anchor, x)
333            }
334        }
335    });
336
337    view! {
338        <g
339            class="_chartistry_tick_label"
340            font-family="monospace">
341            <DebugRect label="tick" debug=debug bounds=vec![bounds, content.into()] />
342            <text
343                x=move || text_position.get().1
344                y=move || content.get().centre_y()
345                style="white-space: pre;"
346                font-size=move || font_height.get()
347                dominant-baseline="middle"
348                text-anchor=move || text_position.get().0>
349                {label.clone()}
350            </text>
351        </g>
352    }
353}