use crate::form::escape_into;
use crate::{Depth, Emit, class, depth_rule, gated, hover_condition, push_class};
use makeover_layout::{Bar, Chart, Intent, Tone};
use std::fmt::Write as _;
pub const CHART_CLASSES: &[&str] = &[
"chart",
"chart-bars",
"chart-bar-col",
"chart-bar",
"chart-bar-label",
];
#[must_use]
pub fn bar_text(bar: &Bar<'_>) -> Option<String> {
match (bar.reading, bar.note) {
(Some(reading), Some(note)) => Some(format!("{reading} / {note}")),
(Some(only), None) | (None, Some(only)) => Some(only.to_string()),
(None, None) => None,
}
}
pub fn chart_html_into<'a>(
chart: &Chart<'_>,
bars: impl IntoIterator<Item = Bar<'a>>,
opts: &Emit,
out: &mut String,
) {
emit_chart(chart, bars, opts, out, None);
}
pub fn chart_html_placed<'a>(
chart: &Chart<'_>,
bars: impl IntoIterator<Item = Bar<'a>>,
opts: &Emit,
out: &mut String,
placed: &mut Vec<core::ops::Range<usize>>,
) {
emit_chart(chart, bars, opts, out, Some(placed));
}
fn emit_chart<'a>(
chart: &Chart<'_>,
bars: impl IntoIterator<Item = Bar<'a>>,
opts: &Emit,
out: &mut String,
mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
) {
out.push_str("<div class=\"");
push_class(out, "chart", opts);
out.push('"');
let _ = write!(out, " style=\"--most: {}\"", chart.most);
if chart.tone != Tone::Neutral {
let _ = write!(out, " data-tone=\"{}\"", chart.tone.token());
}
if let Some(label) = chart.label {
out.push_str(" role=\"img\" aria-label=\"");
escape_into(label, out);
out.push('"');
}
out.push('>');
out.push_str("<div class=\"");
push_class(out, "chart-bars", opts);
out.push_str("\">");
for bar in bars {
let at = out.len();
bar_html_into(&bar, opts, out);
if let Some(placed) = placed.as_deref_mut() {
placed.push(at..out.len());
}
}
out.push_str("</div></div>");
}
fn bar_html_into(bar: &Bar<'_>, opts: &Emit, out: &mut String) {
out.push_str("<div class=\"");
push_class(out, "chart-bar-col", opts);
out.push('"');
if let Some(text) = bar_text(bar) {
out.push_str(" data-tooltip=\"");
escape_into(&text, out);
out.push('"');
}
out.push('>');
out.push_str("<div class=\"");
push_class(out, "chart-bar", opts);
let _ = write!(out, "\" style=\"--value: {}\"></div>", bar.value);
out.push_str("<div class=\"");
push_class(out, "chart-bar-label", opts);
out.push_str("\">");
escape_into(bar.at, out);
out.push_str("</div></div>");
}
#[must_use]
pub fn chart_html<'a>(
chart: &Chart<'_>,
bars: impl IntoIterator<Item = Bar<'a>>,
opts: &Emit,
) -> String {
let mut html = String::new();
chart_html_into(chart, bars, opts, &mut html);
html
}
fn chart_rules(opts: &Emit) -> String {
let chart = class("chart", opts);
let bars = class("chart-bars", opts);
let col = class("chart-bar-col", opts);
let bar = class("chart-bar", opts);
let label = class("chart-bar-label", opts);
let mut css = depth_rule(&chart, Depth::Well);
let _ = writeln!(
css,
".{bars} {{\n display: flex;\n align-items: flex-end;\n \
gap: var(--chart-gap, 2px);\n height: var(--chart-height, 200px);\n}}"
);
let _ = writeln!(
css,
".{col} {{\n flex: 1;\n display: flex;\n flex-direction: column;\n \
align-items: center;\n min-width: 0;\n position: relative;\n}}"
);
let _ = writeln!(
css,
".{bar} {{\n width: 100%;\n background: var(--action);\n \
min-height: var(--chart-bar-least, 2px);\n \
height: calc(var(--value, 0) * 100% / max(var(--most, 1), 1));\n}}"
);
for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
let _ = writeln!(
css,
".{chart}[data-tone=\"{0}\"] .{bar} {{\n background: var(--{0});\n}}",
tone.token()
);
}
let _ = writeln!(
css,
".{label} {{\n color: var(--content-muted);\n max-width: 100%;\n \
white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}}"
);
css.push_str(&gated(
hover_condition(),
&format!(
".{col}[data-tooltip]:hover::before {{\n content: attr(data-tooltip);\n \
position: absolute;\n bottom: 100%;\n inset-inline: 0;\n \
margin-inline: auto;\n width: max-content;\n \
background: var(--surface-raised);\n color: var(--content);\n \
border: var(--border);\n box-shadow: var(--elevation-overlay);\n \
padding: var(--chart-readout-padding, 0.25em 0.5em);\n \
white-space: nowrap;\n pointer-events: none;\n}}\n"
),
));
css
}
#[must_use]
pub fn rules(opts: &Emit) -> String {
chart_rules(opts)
}
#[cfg(test)]
mod tests {
use super::*;
fn axis() -> Chart<'static> {
Chart::new(6740).label("revenue")
}
#[test]
fn both_numbers_are_printed_and_neither_is_divided() {
let html = chart_html(
&axis(),
[Bar::at("Mar 3").of(4210).reading("$42.10").note("3 sales")],
&Emit::default(),
);
assert!(html.contains("--most: 6740"), "{html}");
assert!(html.contains("--value: 4210"), "{html}");
assert!(
!html.contains('%'),
"a percentage reached the markup: {html}"
);
}
#[test]
fn an_unlabelled_chart_claims_no_role() {
let named = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
assert!(
named.contains(r#"role="img" aria-label="revenue""#),
"{named}"
);
let bare = chart_html(&Chart::new(10), [Bar::at("Mar 3").of(1)], &Emit::default());
assert!(!bare.contains("role="), "{bare}");
assert!(!bare.contains("aria-label"), "{bare}");
}
#[test]
fn a_readout_is_what_the_bar_was_given() {
assert_eq!(
bar_text(&Bar::at("a").of(1).reading("$1").note("2 sales")),
Some("$1 / 2 sales".to_string())
);
assert_eq!(
bar_text(&Bar::at("a").of(1).reading("$1")),
Some("$1".to_string())
);
assert_eq!(
bar_text(&Bar::at("a").of(1).note("2 sales")),
Some("2 sales".to_string())
);
assert_eq!(bar_text(&Bar::at("a").of(1)), None);
let bare = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
assert!(!bare.contains("data-tooltip"), "{bare}");
}
#[test]
fn a_label_and_a_readout_are_escaped() {
let html = chart_html(
&Chart::new(10).label("a & b"),
[Bar::at("<script>").of(5).reading("\"x\"")],
&Emit::default(),
);
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("<script>"), "{html}");
assert!(html.contains("a & b"), "{html}");
assert!(html.contains(""x""), "{html}");
}
#[test]
fn an_empty_axis_draws_rather_than_dividing() {
let html = chart_html(&Chart::new(0), [Bar::at("Mar 3").of(0)], &Emit::default());
assert!(html.contains("--most: 0"), "{html}");
assert!(html.contains("--value: 0"), "{html}");
}
#[test]
fn the_streamed_form_is_the_returned_one() {
let opts = Emit {
class_prefix: "mk-",
..Emit::default()
};
let bars = [Bar::at("Mar 3").of(4210).reading("$42.10")];
let mut streamed = String::new();
chart_html_into(&axis(), bars, &opts, &mut streamed);
assert_eq!(streamed, chart_html(&axis(), bars, &opts));
}
#[test]
fn every_class_this_module_writes_has_a_rule() {
let css = chart_rules(&Emit::default());
for name in CHART_CLASSES {
assert!(css.contains(&format!(".{name}")), "{name} has no rule");
}
}
}