Skip to main content

maud_ui/primitives/
chart.rs

1//! Chart component — lightweight inline SVG bar and line charts, no JS needed.
2
3use maud::{html, Markup, PreEscaped};
4
5/// A single data point with a label and numeric value.
6#[derive(Debug, Clone)]
7pub struct DataPoint {
8    pub label: String,
9    pub value: f64,
10}
11
12/// Chart type: bar or line.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ChartType {
15    Bar,
16    Line,
17}
18
19/// Chart rendering properties.
20#[derive(Debug, Clone)]
21pub struct Props {
22    /// Unique ID for the chart container
23    pub id: String,
24    /// Bar or Line
25    pub chart_type: ChartType,
26    /// Data points to plot
27    pub data: Vec<DataPoint>,
28    /// Optional title above the chart
29    pub title: Option<String>,
30    /// SVG width in px (default 400)
31    pub width: u32,
32    /// SVG height in px (default 200)
33    pub height: u32,
34    /// CSS color for bars/line/dots; defaults to var(--mui-accent)
35    pub color: Option<String>,
36}
37
38impl Default for Props {
39    fn default() -> Self {
40        Self {
41            id: "chart".into(),
42            chart_type: ChartType::Bar,
43            data: Vec::new(),
44            title: None,
45            width: 400,
46            height: 200,
47            color: None,
48        }
49    }
50}
51
52// Layout constants — padding for axis labels and breathing room
53const PAD_LEFT: f64 = 48.0;
54const PAD_TOP: f64 = 12.0;
55const PAD_RIGHT: f64 = 12.0;
56const PAD_BOTTOM: f64 = 32.0;
57
58/// Scale a value into the drawable area (y-axis is flipped in SVG).
59fn scale_y(value: f64, max_value: f64, height: u32) -> f64 {
60    let plot_h = height as f64 - PAD_TOP - PAD_BOTTOM;
61    if max_value <= 0.0 {
62        return height as f64 - PAD_BOTTOM;
63    }
64    height as f64 - PAD_BOTTOM - (value / max_value) * plot_h
65}
66
67/// Build SVG markup for a bar chart.
68fn render_bar(props: &Props, color: &str) -> String {
69    let w = props.width;
70    let h = props.height;
71    let n = props.data.len();
72    if n == 0 {
73        return format!(
74            r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg"></svg>"#
75        );
76    }
77
78    let max_value = props
79        .data
80        .iter()
81        .map(|d| d.value)
82        .fold(f64::NEG_INFINITY, f64::max)
83        .max(0.0);
84
85    let plot_w = w as f64 - PAD_LEFT - PAD_RIGHT;
86    let slot_w = plot_w / n as f64;
87    let gap = (slot_w * 0.2).max(2.0);
88    let bar_w = slot_w - gap;
89    let baseline_y = h as f64 - PAD_BOTTOM;
90
91    let mut svg = format!(
92        r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg">"#
93    );
94
95    // Y-axis line
96    svg.push_str(&format!(
97        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
98        PAD_LEFT, PAD_TOP, PAD_LEFT, baseline_y
99    ));
100    // X-axis line
101    svg.push_str(&format!(
102        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
103        PAD_LEFT,
104        baseline_y,
105        w as f64 - PAD_RIGHT,
106        baseline_y
107    ));
108
109    // Y-axis tick labels (0, 25%, 50%, 75%, max)
110    for i in 0..=4 {
111        let frac = i as f64 / 4.0;
112        let val = max_value * frac;
113        let y = scale_y(val, max_value, h);
114        // Grid line
115        svg.push_str(&format!(
116            r#"<line x1="{}" y1="{y}" x2="{}" y2="{y}" stroke="currentColor" stroke-opacity="0.1" />"#,
117            PAD_LEFT,
118            w as f64 - PAD_RIGHT,
119        ));
120        // Value label
121        let label = if val >= 1000.0 {
122            format!("{:.0}k", val / 1000.0)
123        } else if val == val.floor() {
124            format!("{:.0}", val)
125        } else {
126            format!("{:.1}", val)
127        };
128        svg.push_str(&format!(
129            r#"<text x="{}" y="{}" text-anchor="end" class="mui-chart__value">{}</text>"#,
130            PAD_LEFT - 4.0,
131            y + 3.0,
132            label
133        ));
134    }
135
136    // Bars + X labels
137    for i in 0..n {
138        let dp = &props.data[i];
139        let bar_x = PAD_LEFT + (i as f64 * slot_w) + gap / 2.0;
140        let bar_y = scale_y(dp.value, max_value, h);
141        let bar_h = baseline_y - bar_y;
142        let center_x = bar_x + bar_w / 2.0;
143
144        svg.push_str(&format!(
145            r#"<rect x="{bar_x}" y="{bar_y}" width="{bar_w}" height="{bar_h}" rx="3" fill="{color}" opacity="0.85" />"#
146        ));
147        svg.push_str(&format!(
148            r#"<text x="{center_x}" y="{}" text-anchor="middle" class="mui-chart__label">{}</text>"#,
149            h as f64 - 10.0,
150            html_escape(&dp.label)
151        ));
152    }
153
154    svg.push_str("</svg>");
155    svg
156}
157
158/// Build SVG markup for a line chart.
159fn render_line(props: &Props, color: &str) -> String {
160    let w = props.width;
161    let h = props.height;
162    let n = props.data.len();
163    if n == 0 {
164        return format!(
165            r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg"></svg>"#
166        );
167    }
168
169    let max_value = props
170        .data
171        .iter()
172        .map(|d| d.value)
173        .fold(f64::NEG_INFINITY, f64::max)
174        .max(0.0);
175
176    let plot_w = w as f64 - PAD_LEFT - PAD_RIGHT;
177    let baseline_y = h as f64 - PAD_BOTTOM;
178
179    let mut svg = format!(
180        r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg">"#
181    );
182
183    // Y-axis line
184    svg.push_str(&format!(
185        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
186        PAD_LEFT, PAD_TOP, PAD_LEFT, baseline_y
187    ));
188    // X-axis line
189    svg.push_str(&format!(
190        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
191        PAD_LEFT,
192        baseline_y,
193        w as f64 - PAD_RIGHT,
194        baseline_y
195    ));
196
197    // Y-axis tick labels
198    for i in 0..=4 {
199        let frac = i as f64 / 4.0;
200        let val = max_value * frac;
201        let y = scale_y(val, max_value, h);
202        svg.push_str(&format!(
203            r#"<line x1="{}" y1="{y}" x2="{}" y2="{y}" stroke="currentColor" stroke-opacity="0.1" />"#,
204            PAD_LEFT,
205            w as f64 - PAD_RIGHT,
206        ));
207        let label = if val >= 1000.0 {
208            format!("{:.0}k", val / 1000.0)
209        } else if val == val.floor() {
210            format!("{:.0}", val)
211        } else {
212            format!("{:.1}", val)
213        };
214        svg.push_str(&format!(
215            r#"<text x="{}" y="{}" text-anchor="end" class="mui-chart__value">{}</text>"#,
216            PAD_LEFT - 4.0,
217            y + 3.0,
218            label
219        ));
220    }
221
222    // Compute (x, y) for each data point
223    let mut points: Vec<(f64, f64)> = Vec::with_capacity(n);
224    for i in 0..n {
225        let x = if n == 1 {
226            PAD_LEFT + plot_w / 2.0
227        } else {
228            PAD_LEFT + (i as f64 / (n - 1) as f64) * plot_w
229        };
230        let y = scale_y(props.data[i].value, max_value, h);
231        points.push((x, y));
232    }
233
234    // Gradient definition for area fill
235    let grad_id = format!("{}-area-grad", props.id);
236    svg.push_str(&format!(
237        r#"<defs><linearGradient id="{grad_id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="{color}" stop-opacity="0.25"/><stop offset="100%" stop-color="{color}" stop-opacity="0.03"/></linearGradient></defs>"#
238    ));
239
240    // Area fill (smooth gradient)
241    if points.len() >= 2 {
242        let mut area = String::from("<polygon points=\"");
243        // Start at baseline under first point
244        area.push_str(&format!("{},{} ", points[0].0, baseline_y));
245        for (x, y) in &points {
246            area.push_str(&format!("{x},{y} "));
247        }
248        // Close back to baseline under last point
249        area.push_str(&format!(
250            "{},{}",
251            points[points.len() - 1].0,
252            baseline_y
253        ));
254        area.push_str(&format!(
255            r#"" fill="url(#{grad_id})" />"#
256        ));
257        svg.push_str(&area);
258    }
259
260    // Polyline
261    let pts: Vec<String> = points.iter().map(|(x, y)| format!("{x},{y}")).collect();
262    svg.push_str(&format!(
263        r#"<polyline points="{}" fill="none" stroke="{color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />"#,
264        pts.join(" ")
265    ));
266
267    // Data-point circles
268    for (x, y) in &points {
269        svg.push_str(&format!(
270            r#"<circle cx="{x}" cy="{y}" r="3" fill="{color}" />"#
271        ));
272    }
273
274    // X labels
275    for i in 0..n {
276        let x = points[i].0;
277        svg.push_str(&format!(
278            r#"<text x="{x}" y="{}" text-anchor="middle" class="mui-chart__label">{}</text>"#,
279            h as f64 - 10.0,
280            html_escape(&props.data[i].label)
281        ));
282    }
283
284    svg.push_str("</svg>");
285    svg
286}
287
288/// Render a chart with the given properties.
289pub fn render(props: Props) -> Markup {
290    let color = props
291        .color
292        .clone()
293        .unwrap_or_else(|| "var(--mui-accent)".into());
294
295    let svg = match props.chart_type {
296        ChartType::Bar => render_bar(&props, &color),
297        ChartType::Line => render_line(&props, &color),
298    };
299
300    html! {
301        div.mui-chart id=(props.id) {
302            @if let Some(ref title) = props.title {
303                p.mui-chart__title { (title) }
304            }
305            (PreEscaped(svg))
306        }
307    }
308}
309
310/// Minimal HTML entity escaping for label text inside SVG.
311fn html_escape(s: &str) -> String {
312    let mut out = String::with_capacity(s.len());
313    for c in s.chars() {
314        match c {
315            '&' => out.push_str("&amp;"),
316            '<' => out.push_str("&lt;"),
317            '>' => out.push_str("&gt;"),
318            '"' => out.push_str("&quot;"),
319            '\'' => out.push_str("&#39;"),
320            _ => out.push(c),
321        }
322    }
323    out
324}
325
326/// Showcase bar and line charts with sample data.
327pub fn showcase() -> Markup {
328    let monthly_data = vec![
329        DataPoint { label: "Jan".into(), value: 186.0 },
330        DataPoint { label: "Feb".into(), value: 305.0 },
331        DataPoint { label: "Mar".into(), value: 237.0 },
332        DataPoint { label: "Apr".into(), value: 73.0 },
333        DataPoint { label: "May".into(), value: 209.0 },
334        DataPoint { label: "Jun".into(), value: 214.0 },
335    ];
336
337    html! {
338        div.mui-showcase__grid {
339            div {
340                p.mui-showcase__caption { "Bar chart" }
341                (render(Props {
342                    id: "chart-bar-demo".into(),
343                    chart_type: ChartType::Bar,
344                    data: monthly_data.clone(),
345                    title: Some("Monthly Revenue (USD)".into()),
346                    ..Default::default()
347                }))
348            }
349            div {
350                p.mui-showcase__caption { "Line chart with area fill" }
351                (render(Props {
352                    id: "chart-line-demo".into(),
353                    chart_type: ChartType::Line,
354                    data: monthly_data.clone(),
355                    title: Some("Active Users Over Time".into()),
356                    ..Default::default()
357                }))
358            }
359            div {
360                p.mui-showcase__caption { "Custom color" }
361                (render(Props {
362                    id: "chart-custom-color".into(),
363                    chart_type: ChartType::Bar,
364                    data: monthly_data.clone(),
365                    title: Some("Conversion Rate by Month".into()),
366                    color: Some("var(--mui-success)".into()),
367                    ..Default::default()
368                }))
369            }
370            div {
371                p.mui-showcase__caption { "Wide line chart" }
372                (render(Props {
373                    id: "chart-line-wide".into(),
374                    chart_type: ChartType::Line,
375                    data: monthly_data,
376                    title: Some("Pageviews Trend (6 months)".into()),
377                    width: 600,
378                    height: 250,
379                    color: Some("var(--mui-warning)".into()),
380                }))
381            }
382        }
383    }
384}