use maud::{html, Markup, PreEscaped};
#[derive(Debug, Clone)]
pub struct DataPoint {
pub label: String,
pub value: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChartType {
Bar,
Line,
}
#[derive(Debug, Clone)]
pub struct Props {
pub id: String,
pub chart_type: ChartType,
pub data: Vec<DataPoint>,
pub title: Option<String>,
pub width: u32,
pub height: u32,
pub color: Option<String>,
}
impl Default for Props {
fn default() -> Self {
Self {
id: "chart".into(),
chart_type: ChartType::Bar,
data: Vec::new(),
title: None,
width: 400,
height: 200,
color: None,
}
}
}
const PAD_LEFT: f64 = 48.0;
const PAD_TOP: f64 = 12.0;
const PAD_RIGHT: f64 = 12.0;
const PAD_BOTTOM: f64 = 32.0;
fn scale_y(value: f64, max_value: f64, height: u32) -> f64 {
let plot_h = height as f64 - PAD_TOP - PAD_BOTTOM;
if max_value <= 0.0 {
return height as f64 - PAD_BOTTOM;
}
height as f64 - PAD_BOTTOM - (value / max_value) * plot_h
}
fn render_bar(props: &Props, color: &str) -> String {
let w = props.width;
let h = props.height;
let n = props.data.len();
if n == 0 {
return format!(
r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg"></svg>"#
);
}
let max_value = props
.data
.iter()
.map(|d| d.value)
.fold(f64::NEG_INFINITY, f64::max)
.max(0.0);
let plot_w = w as f64 - PAD_LEFT - PAD_RIGHT;
let slot_w = plot_w / n as f64;
let gap = (slot_w * 0.2).max(2.0);
let bar_w = slot_w - gap;
let baseline_y = h as f64 - PAD_BOTTOM;
let mut svg = format!(
r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg">"#
);
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
PAD_LEFT, PAD_TOP, PAD_LEFT, baseline_y
));
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
PAD_LEFT,
baseline_y,
w as f64 - PAD_RIGHT,
baseline_y
));
for i in 0..=4 {
let frac = i as f64 / 4.0;
let val = max_value * frac;
let y = scale_y(val, max_value, h);
svg.push_str(&format!(
r#"<line x1="{}" y1="{y}" x2="{}" y2="{y}" stroke="currentColor" stroke-opacity="0.1" />"#,
PAD_LEFT,
w as f64 - PAD_RIGHT,
));
let label = if val >= 1000.0 {
format!("{:.0}k", val / 1000.0)
} else if val == val.floor() {
format!("{:.0}", val)
} else {
format!("{:.1}", val)
};
svg.push_str(&format!(
r#"<text x="{}" y="{}" text-anchor="end" class="mui-chart__value">{}</text>"#,
PAD_LEFT - 4.0,
y + 3.0,
label
));
}
for i in 0..n {
let dp = &props.data[i];
let bar_x = PAD_LEFT + (i as f64 * slot_w) + gap / 2.0;
let bar_y = scale_y(dp.value, max_value, h);
let bar_h = baseline_y - bar_y;
let center_x = bar_x + bar_w / 2.0;
svg.push_str(&format!(
r#"<rect x="{bar_x}" y="{bar_y}" width="{bar_w}" height="{bar_h}" rx="3" fill="{color}" opacity="0.85" />"#
));
svg.push_str(&format!(
r#"<text x="{center_x}" y="{}" text-anchor="middle" class="mui-chart__label">{}</text>"#,
h as f64 - 10.0,
html_escape(&dp.label)
));
}
svg.push_str("</svg>");
svg
}
fn render_line(props: &Props, color: &str) -> String {
let w = props.width;
let h = props.height;
let n = props.data.len();
if n == 0 {
return format!(
r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg"></svg>"#
);
}
let max_value = props
.data
.iter()
.map(|d| d.value)
.fold(f64::NEG_INFINITY, f64::max)
.max(0.0);
let plot_w = w as f64 - PAD_LEFT - PAD_RIGHT;
let baseline_y = h as f64 - PAD_BOTTOM;
let mut svg = format!(
r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg">"#
);
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
PAD_LEFT, PAD_TOP, PAD_LEFT, baseline_y
));
svg.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
PAD_LEFT,
baseline_y,
w as f64 - PAD_RIGHT,
baseline_y
));
for i in 0..=4 {
let frac = i as f64 / 4.0;
let val = max_value * frac;
let y = scale_y(val, max_value, h);
svg.push_str(&format!(
r#"<line x1="{}" y1="{y}" x2="{}" y2="{y}" stroke="currentColor" stroke-opacity="0.1" />"#,
PAD_LEFT,
w as f64 - PAD_RIGHT,
));
let label = if val >= 1000.0 {
format!("{:.0}k", val / 1000.0)
} else if val == val.floor() {
format!("{:.0}", val)
} else {
format!("{:.1}", val)
};
svg.push_str(&format!(
r#"<text x="{}" y="{}" text-anchor="end" class="mui-chart__value">{}</text>"#,
PAD_LEFT - 4.0,
y + 3.0,
label
));
}
let mut points: Vec<(f64, f64)> = Vec::with_capacity(n);
for i in 0..n {
let x = if n == 1 {
PAD_LEFT + plot_w / 2.0
} else {
PAD_LEFT + (i as f64 / (n - 1) as f64) * plot_w
};
let y = scale_y(props.data[i].value, max_value, h);
points.push((x, y));
}
let grad_id = format!("{}-area-grad", props.id);
svg.push_str(&format!(
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>"#
));
if points.len() >= 2 {
let mut area = String::from("<polygon points=\"");
area.push_str(&format!("{},{} ", points[0].0, baseline_y));
for (x, y) in &points {
area.push_str(&format!("{x},{y} "));
}
area.push_str(&format!(
"{},{}",
points[points.len() - 1].0,
baseline_y
));
area.push_str(&format!(
r#"" fill="url(#{grad_id})" />"#
));
svg.push_str(&area);
}
let pts: Vec<String> = points.iter().map(|(x, y)| format!("{x},{y}")).collect();
svg.push_str(&format!(
r#"<polyline points="{}" fill="none" stroke="{color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />"#,
pts.join(" ")
));
for (x, y) in &points {
svg.push_str(&format!(
r#"<circle cx="{x}" cy="{y}" r="3" fill="{color}" />"#
));
}
for i in 0..n {
let x = points[i].0;
svg.push_str(&format!(
r#"<text x="{x}" y="{}" text-anchor="middle" class="mui-chart__label">{}</text>"#,
h as f64 - 10.0,
html_escape(&props.data[i].label)
));
}
svg.push_str("</svg>");
svg
}
pub fn render(props: Props) -> Markup {
let color = props
.color
.clone()
.unwrap_or_else(|| "var(--mui-accent)".into());
let svg = match props.chart_type {
ChartType::Bar => render_bar(&props, &color),
ChartType::Line => render_line(&props, &color),
};
html! {
div.mui-chart id=(props.id) {
@if let Some(ref title) = props.title {
p.mui-chart__title { (title) }
}
(PreEscaped(svg))
}
}
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
pub fn showcase() -> Markup {
let monthly_data = vec![
DataPoint { label: "Jan".into(), value: 186.0 },
DataPoint { label: "Feb".into(), value: 305.0 },
DataPoint { label: "Mar".into(), value: 237.0 },
DataPoint { label: "Apr".into(), value: 73.0 },
DataPoint { label: "May".into(), value: 209.0 },
DataPoint { label: "Jun".into(), value: 214.0 },
];
html! {
div.mui-showcase__grid {
div {
p.mui-showcase__caption { "Bar chart" }
(render(Props {
id: "chart-bar-demo".into(),
chart_type: ChartType::Bar,
data: monthly_data.clone(),
title: Some("Monthly Revenue (USD)".into()),
..Default::default()
}))
}
div {
p.mui-showcase__caption { "Line chart with area fill" }
(render(Props {
id: "chart-line-demo".into(),
chart_type: ChartType::Line,
data: monthly_data.clone(),
title: Some("Active Users Over Time".into()),
..Default::default()
}))
}
div {
p.mui-showcase__caption { "Custom color" }
(render(Props {
id: "chart-custom-color".into(),
chart_type: ChartType::Bar,
data: monthly_data.clone(),
title: Some("Conversion Rate by Month".into()),
color: Some("var(--mui-success)".into()),
..Default::default()
}))
}
div {
p.mui-showcase__caption { "Wide line chart" }
(render(Props {
id: "chart-line-wide".into(),
chart_type: ChartType::Line,
data: monthly_data,
title: Some("Pageviews Trend (6 months)".into()),
width: 600,
height: 250,
color: Some("var(--mui-warning)".into()),
}))
}
}
}
}