1use maud::{html, Markup, PreEscaped};
4
5#[derive(Debug, Clone)]
7pub struct DataPoint {
8 pub label: String,
9 pub value: f64,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ChartType {
15 Bar,
16 Line,
17}
18
19#[derive(Debug, Clone)]
21pub struct Props {
22 pub id: String,
24 pub chart_type: ChartType,
26 pub data: Vec<DataPoint>,
28 pub title: Option<String>,
30 pub width: u32,
32 pub height: u32,
34 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
52const PAD_LEFT: f64 = 48.0;
54const PAD_TOP: f64 = 12.0;
55const PAD_RIGHT: f64 = 12.0;
56const PAD_BOTTOM: f64 = 32.0;
57
58fn 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
67fn 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 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 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 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 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 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 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
158fn 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 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 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 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 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 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 if points.len() >= 2 {
242 let mut area = String::from("<polygon points=\"");
243 area.push_str(&format!("{},{} ", points[0].0, baseline_y));
245 for (x, y) in &points {
246 area.push_str(&format!("{x},{y} "));
247 }
248 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 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 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 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
288pub 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
310fn 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("&"),
316 '<' => out.push_str("<"),
317 '>' => out.push_str(">"),
318 '"' => out.push_str("""),
319 '\'' => out.push_str("'"),
320 _ => out.push(c),
321 }
322 }
323 out
324}
325
326pub 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}