yew_chart/
axis.rs

1/// A Axis represents a range of f32 values. The tick interval of that range is expressed
2/// as a step. The axis also has an orientation describing which side of the axis should be used
3/// to convey its optional title.
4///
5/// The component takes a "name" property field so that it may be easily referenced when styled.
6///
7/// The following styling properties are available:
8///
9/// * axis - the axis as a whole
10/// * line - the axis line
11/// * tick - the axis tick line
12/// * text - the axis text
13use std::{marker::PhantomData, rc::Rc};
14
15use gloo_events::EventListener;
16use wasm_bindgen::JsCast;
17use web_sys::{Element, SvgElement};
18use yew::prelude::*;
19
20use crate::series::Scalar;
21
22/// Axis scaled value, expected to be between 0 and 1
23/// except in the case where the value is outside of the axis range
24#[derive(Debug, PartialEq)]
25pub struct NormalisedValue(pub f32);
26
27/// Specifies a generic scale on which axes and data can be rendered
28pub trait Scale {
29    type Scalar: Scalar;
30
31    /// Provides the list of [ticks](AxisTick) that should be rendered along the axis
32    fn ticks(&self) -> Vec<Tick>;
33
34    /// Normalises a value within the axis scale to a number between 0 and 1,
35    /// where 0 represents the minimum value of the scale, and 1 the maximum
36    ///
37    /// For example, for a linear scale between 50 and 100:
38    /// - normalise(50)  -> 0
39    /// - normalise(60)  -> 0.2
40    /// - normalise(75)  -> 0.5
41    /// - normalise(100) -> 1
42    fn normalise(&self, value: Self::Scalar) -> NormalisedValue;
43}
44
45/// An axis tick, specifying a label to be displayed at some normalised
46/// position along the axis
47#[derive(Debug, PartialEq)]
48pub struct Tick {
49    /// normalised location between zero and one along the axis specifying
50    /// the position at which the tick should be rendered
51    pub location: NormalisedValue,
52
53    /// An optional label that should be rendered alongside the tick
54    pub label: Option<String>,
55}
56
57pub enum Msg {
58    Resize,
59}
60
61#[derive(Clone, PartialEq)]
62pub enum Orientation {
63    Left,
64    Right,
65    Bottom,
66    Top,
67}
68
69#[derive(Properties, Clone)]
70pub struct Props<S: Scalar> {
71    /// A name given to the axis that will be used for CSS classes
72    pub name: String,
73    /// How the axis will be positioned in relation to other elements
74    pub orientation: Orientation,
75    /// The start position
76    pub x1: f32,
77    /// The start position
78    pub y1: f32,
79    /// The target position as x or y depending on orientation - x for left
80    /// and right, y for bottom and top
81    pub xy2: f32,
82    /// The length of ticks
83    pub tick_len: f32,
84    /// Any title to be drawn and associated with the axis
85    #[prop_or_default]
86    pub title: Option<String>,
87    /// The scaling conversion to be used with the axis
88    pub scale: Rc<dyn Scale<Scalar = S>>,
89}
90
91impl<S: Scalar> PartialEq for Props<S> {
92    fn eq(&self, other: &Self) -> bool {
93        self.name == other.name
94            && self.orientation == other.orientation
95            && self.x1 == other.x1
96            && self.y1 == other.y1
97            && self.xy2 == other.xy2
98            && self.tick_len == other.tick_len
99            && self.title == other.title
100            && std::ptr::eq(
101                // test reference equality, avoiding issues with vtables discussed in
102                // https://github.com/rust-lang/rust/issues/46139
103                &*self.scale as *const _ as *const u8,
104                &*other.scale as *const _ as *const u8,
105            )
106    }
107}
108
109pub struct Axis<S: Scalar> {
110    phantom: PhantomData<S>,
111    _resize_listener: EventListener,
112    svg: NodeRef,
113}
114
115impl<S: Scalar + 'static> Component for Axis<S> {
116    type Message = Msg;
117
118    type Properties = Props<S>;
119
120    fn create(ctx: &Context<Self>) -> Self {
121        let on_resize = ctx.link().callback(|_: Event| Msg::Resize);
122        Axis {
123            phantom: PhantomData,
124            _resize_listener: EventListener::new(&gloo_utils::window(), "resize", move |e| {
125                on_resize.emit(e.clone())
126            }),
127            svg: NodeRef::default(),
128        }
129    }
130
131    fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
132        match msg {
133            Msg::Resize => true,
134        }
135    }
136
137    fn changed(&mut self, _ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
138        true
139    }
140
141    fn view(&self, ctx: &Context<Self>) -> Html {
142        let p = ctx.props();
143
144        fn title(x: f32, y: f32, baseline: &str, title: &str) -> Html {
145            html! {
146                <text
147                    x={x.to_string()} y={y.to_string()}
148                    dominant-baseline={baseline.to_string()}
149                    text-anchor={"middle"}
150                    transform-origin={format!("{} {}", x, y)}
151                    class="title" >
152                    {title}
153                </text>
154            }
155        }
156
157        let class = match p.orientation {
158            Orientation::Left => "left",
159            Orientation::Right => "right",
160            Orientation::Bottom => "bottom",
161            Orientation::Top => "top",
162        };
163
164        if p.orientation == Orientation::Left || p.orientation == Orientation::Right {
165            let scale = p.xy2 - p.y1;
166            let x = p.x1;
167            let to_x = if p.orientation == Orientation::Left {
168                x - p.tick_len
169            } else {
170                x + p.tick_len
171            };
172
173            html! {
174                <svg ref={self.svg.clone()} class={classes!("axis", class, p.name.to_owned())}>
175                    <line x1={p.x1.to_string()} y1={p.y1.to_string()} x2={p.x1.to_string()} y2={p.xy2.to_string()} class="line" />
176                    { for (p.scale.ticks().iter()).map(|Tick { location: NormalisedValue(normalised_location), label }| {
177                        let y = (p.xy2 - (normalised_location * scale)) as u32;
178                        html! {
179                        <>
180                            <line x1={x.to_string()} y1={y.to_string()} x2={to_x.to_string()} y2={y.to_string()} class="tick" />
181                            if let Some(l) = label {
182                                <text x={to_x.to_string()} y={y.to_string()} text-anchor={if p.orientation == Orientation::Left {"end"} else {"start"}} class="text">{l.to_string()}</text>
183                            }
184                        </>
185                        }
186                    }) }
187                    { for p.title.as_ref().map(|t| {
188                        let title_distance = p.tick_len * 2.0;
189                        let x = if p.orientation == Orientation::Left {
190                            p.x1 - title_distance
191                        } else {
192                            p.x1 + title_distance
193                        };
194                        let y = p.y1 + ((p.xy2 - p.y1) * 0.5);
195                        title(x, y, "auto",t)
196                    })}
197                </svg>
198            }
199        } else {
200            let scale = p.xy2 - p.x1;
201            let y = p.y1;
202            let (to_y, baseline) = if p.orientation == Orientation::Top {
203                (y - p.tick_len, "auto")
204            } else {
205                (y + p.tick_len, "hanging")
206            };
207
208            html! {
209                <svg ref={self.svg.clone()} class={classes!("axis", class, p.name.to_owned())}>
210                    <line x1={p.x1.to_string()} y1={p.y1.to_string()} x2={p.xy2.to_string()} y2={p.y1.to_string()} class="line" />
211                    { for(p.scale.ticks().iter()).map(|Tick { location: NormalisedValue(normalised_location), label }| {
212                        let x = p.x1 + normalised_location * scale;
213                        html! {
214                        <>
215                            <line x1={x.to_string()} y1={y.to_string()} x2={x.to_string()} y2={to_y.to_string()} class="tick" />
216                            if let Some(l) = label {
217                                <text x={x.to_string()} y={to_y.to_string()} text-anchor="middle" transform-origin={format!("{} {}", x, to_y)} dominant-baseline={baseline.to_string()} class="text">{l.to_string()}</text>
218                            }
219                        </>
220                        }
221                    }) }
222                    { for p.title.as_ref().map(|t| {
223                        let title_distance = p.tick_len * 2.0;
224                        let y = if p.orientation == Orientation::Top {
225                            p.y1 - title_distance
226                        } else {
227                            p.y1 + title_distance
228                        };
229                        let x = p.x1 + ((p.xy2 - p.x1) * 0.5);
230                        title(x, y, baseline, t)
231                    })}
232                </svg>
233            }
234        }
235    }
236
237    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
238        let p = ctx.props();
239
240        let element = self.svg.cast::<Element>().unwrap();
241        if let Some(svg_element) = element
242            .first_child()
243            .and_then(|n| n.dyn_into::<SvgElement>().ok())
244        {
245            let bounding_rect = svg_element.get_bounding_client_rect();
246            let scale = if p.orientation == Orientation::Left || p.orientation == Orientation::Right
247            {
248                let height = bounding_rect.height() as f32;
249                (p.xy2 - p.y1) / height
250            } else {
251                let width = bounding_rect.width() as f32;
252                (p.xy2 - p.x1) / width
253            };
254            let font_size = scale * 100.0;
255            let _ = element.set_attribute("font-size", &format!("{}%", &font_size));
256            let _ = element.set_attribute("style", &format!("stroke-width: {}", scale));
257        }
258    }
259}