use std::collections::BTreeMap;
use crate::{
error::ChartWarning,
spec::{ChartSpec, ChartType, Theme},
theme::bundled_tokens,
};
const TEXT: f64 = 4.5;
const MARK: f64 = 3.0;
const ROLES: [&str; 7] = ["blue", "green", "amber", "teal", "violet", "gray", "red"];
struct Pair {
front: String,
back: String,
what: &'static str,
needs: f64,
}
fn pair(front: &str, back: &str, what: &'static str, needs: f64) -> Pair {
Pair {
front: front.to_owned(),
back: back.to_owned(),
what,
needs,
}
}
pub(crate) fn warnings(spec: &ChartSpec) -> Vec<ChartWarning> {
check(spec, &pairs(spec))
}
pub(crate) fn role_warnings(spec: &ChartSpec, svg: &str) -> Vec<ChartWarning> {
if spec.tokens.is_empty() {
return Vec::new();
}
let mut pairs = Vec::new();
for (color, kind) in drawn_roles(svg) {
let (stroke, fill) = (format!("role-{color}"), format!("role-{color}-fill"));
match kind {
RoleUse::Kind => {
pairs.push(Pair {
front: "text".into(),
back: fill.clone(),
what: "labels on a box",
needs: TEXT,
});
pairs.push(Pair {
front: stroke.clone(),
back: fill,
what: "outlines",
needs: MARK,
});
pairs.push(Pair {
front: stroke,
back: "background".into(),
what: "outlines and lines",
needs: MARK,
});
}
RoleUse::Mark => {
pairs.push(Pair {
front: stroke,
back: "background".into(),
what: "outlines and lines",
needs: MARK,
});
}
}
}
check(spec, &pairs)
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum RoleUse {
Kind,
Mark,
}
fn drawn_roles(svg: &str) -> Vec<(&'static str, RoleUse)> {
let mut found: Vec<(&'static str, RoleUse)> = Vec::new();
for (start, _) in svg.match_indices("class=\"") {
let rest = &svg[start + 7..];
let Some(end) = rest.find('"') else { continue };
for class in rest[..end].split(' ') {
let used = ROLES
.iter()
.find(|color| class.strip_prefix("chartlet-role-") == Some(**color))
.map(|color| (*color, RoleUse::Kind))
.or(match class {
"chartlet-flow-role-critical" => Some(("red", RoleUse::Mark)),
"chartlet-flow-role-planned" => Some(("violet", RoleUse::Mark)),
"chartlet-flow-role-deprecated" => Some(("gray", RoleUse::Mark)),
_ => None,
});
if let Some(used) = used
&& !found.contains(&used)
{
found.push(used);
}
}
}
found
}
fn check(spec: &ChartSpec, pairs: &[Pair]) -> Vec<ChartWarning> {
if spec.tokens.is_empty() {
return Vec::new();
}
let mut palette = bundled_tokens(match spec.theme {
Theme::Light => "light",
Theme::Dark => "dark",
Theme::Blueprint => "blueprint",
});
palette.extend(spec.tokens.clone());
pairs
.iter()
.filter_map(|pair| {
let path = [&pair.front, &pair.back]
.into_iter()
.find(|name| spec.tokens.contains_key(*name))?;
let ratio = ratio(&palette, &pair.front, &pair.back)?;
(ratio < pair.needs).then(|| {
ChartWarning::new(
"low_contrast",
format!("/tokens/{path}"),
format!(
"{} ({}) on {} ({}) has a contrast of {ratio:.1}:1; {} need {:.1}:1",
pair.front,
palette[&pair.front],
pair.back,
palette[&pair.back],
pair.what,
pair.needs
),
)
})
})
.collect()
}
fn pairs(spec: &ChartSpec) -> Vec<Pair> {
let mut pairs = vec![
pair("text", "background", "text and values", TEXT),
pair("muted", "background", "axis labels and legends", TEXT),
];
match spec.chart_type {
ChartType::Bar | ChartType::Line => {
let series = spec.series.len().max(spec.data_groups().len());
if series > 1 {
pairs.extend(series_pairs(series));
} else {
pairs.push(pair("accent", "background", "bars and lines", MARK));
}
}
ChartType::Time | ChartType::Multiples => {
let used = spec
.data_layers()
.filter(|entry| entry.layer.resolved_color().is_none())
.map(|entry| spec.palette_index(entry.layer) + 1)
.max()
.unwrap_or(0);
pairs.extend(series_pairs(used));
}
_ => {}
}
pairs
}
fn series_pairs(count: usize) -> impl Iterator<Item = Pair> {
(1..=count.min(4)).map(|number| Pair {
front: format!("color-{number}"),
back: "background".to_owned(),
what: "series marks",
needs: MARK,
})
}
fn ratio(palette: &BTreeMap<String, String>, front: &str, back: &str) -> Option<f64> {
let back = color(palette.get(back)?)?;
let front = color(palette.get(front)?)?;
let front = blend(front, back);
let (light, dark) = {
let (a, b) = (luminance(front), luminance(back));
if a > b { (a, b) } else { (b, a) }
};
Some((light + 0.05) / (dark + 0.05))
}
type Rgba = [f64; 4];
fn color(value: &str) -> Option<Rgba> {
if let Some(hex) = value.strip_prefix('#') {
let digit = |index: usize| {
u8::from_str_radix(hex.get(index..=index)?, 16)
.ok()
.map(f64::from)
};
let pair = |index: usize| {
u8::from_str_radix(hex.get(index..index + 2)?, 16)
.ok()
.map(f64::from)
};
return match hex.len() {
3 => Some([digit(0)? * 17.0, digit(1)? * 17.0, digit(2)? * 17.0, 1.0]),
6 => Some([pair(0)?, pair(2)?, pair(4)?, 1.0]),
8 => Some([pair(0)?, pair(2)?, pair(4)?, pair(6)? / 255.0]),
_ => None,
};
}
let inner = value
.strip_prefix("rgba(")
.or_else(|| value.strip_prefix("rgb("))?
.strip_suffix(')')?;
let numbers: Vec<f64> = inner
.split([',', ' '])
.filter(|part| !part.is_empty())
.map(|part| match part.strip_suffix('%') {
Some(percent) => percent.parse::<f64>().ok().map(|value| value / 100.0),
None => part.parse::<f64>().ok(),
})
.collect::<Option<_>>()?;
let channel = |part: &str, number: f64| {
if part.ends_with('%') {
number * 255.0
} else {
number
}
};
let parts: Vec<&str> = inner
.split([',', ' '])
.filter(|part| !part.is_empty())
.collect();
match numbers.len() {
3 | 4 => Some([
channel(parts[0], numbers[0]),
channel(parts[1], numbers[1]),
channel(parts[2], numbers[2]),
numbers.get(3).copied().unwrap_or(1.0),
]),
_ => None,
}
}
fn blend(front: Rgba, back: Rgba) -> Rgba {
let alpha = front[3].clamp(0.0, 1.0);
[
front[0] * alpha + back[0] * (1.0 - alpha),
front[1] * alpha + back[1] * (1.0 - alpha),
front[2] * alpha + back[2] * (1.0 - alpha),
1.0,
]
}
fn luminance(color: Rgba) -> f64 {
let linear = |channel: f64| {
let channel = (channel / 255.0).clamp(0.0, 1.0);
if channel <= 0.039_28 {
channel / 12.92
} else {
((channel + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(color[0]) + 0.7152 * linear(color[1]) + 0.0722 * linear(color[2])
}
#[cfg(test)]
mod tests {
use crate::{RenderFormat, RenderOptions, render_json};
fn spec(extra: &str) -> String {
format!(
r#"{{"schemaVersion":1,"type":"bar","title":"Revenue",{extra}"data":[{{"label":"A","value":1}},{{"label":"B","value":2}}]}}"#
)
}
fn warnings(extra: &str) -> Vec<(String, String)> {
let rendered = render_json(&spec(extra), RenderFormat::Svg, &RenderOptions::default())
.expect("renders");
rendered
.warnings
.into_iter()
.filter(|warning| warning.code == "low_contrast")
.map(|warning| (warning.path, warning.message))
.collect()
}
#[test]
fn a_chart_without_tokens_is_not_measured() {
assert_eq!(warnings("").len(), 0);
assert_eq!(warnings(r#""theme":"dark","#).len(), 0);
}
#[test]
fn text_that_is_too_pale_for_its_ground_is_reported_at_the_token() {
let found = warnings(r##""tokens":{"text":"#aaaaaa"},"##);
assert_eq!(found.len(), 1);
assert_eq!(found[0].0, "/tokens/text");
assert!(
found[0].1.contains("text (#aaaaaa) on background (#fff)"),
"{}",
found[0].1
);
}
#[test]
fn a_background_is_measured_against_the_text_it_carries() {
let found = warnings(r##""tokens":{"background":"#222222"},"##);
assert!(
found.iter().any(|(path, _)| path == "/tokens/background"),
"{found:?}"
);
}
#[test]
fn only_the_colors_a_chart_draws_are_measured() {
let pale = r##""tokens":{"accent":"#eeeeee","color-3":"#eeeeee","role-red":"#eeeeee"},"##;
let found = warnings(pale);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].0, "/tokens/accent");
let two = r##""tokens":{"accent":"#eeeeee","color-2":"#eeeeee","color-3":"#eeeeee"},"categories":["A","B"],"series":[{"name":"x","values":[1,2]},{"name":"y","values":[2,3]}],"##;
let source = format!(r#"{{"schemaVersion":1,"type":"bar","title":"T",{two}"data":[]}}"#);
let rendered =
render_json(&source, RenderFormat::Svg, &RenderOptions::default()).expect("renders");
let paths: Vec<_> = rendered
.warnings
.iter()
.filter(|warning| warning.code == "low_contrast")
.map(|warning| warning.path.as_str())
.collect();
assert_eq!(paths, ["/tokens/color-2"]);
}
#[test]
fn a_diagram_measures_the_outline_and_the_labels_of_its_roles() {
let flow = std::fs::read_to_string("examples/release-flow.json").expect("example");
let themed =
crate::apply_theme(&flow, r##"{"tokens":{"role-green":"#dddddd"}}"##).expect("applies");
let rendered =
render_json(&themed, RenderFormat::Svg, &RenderOptions::default()).expect("renders");
let found: Vec<_> = rendered
.warnings
.iter()
.filter(|warning| warning.code == "low_contrast")
.collect();
assert!(
found
.iter()
.all(|warning| warning.path == "/tokens/role-green"),
"{found:?}"
);
assert!(
found
.iter()
.any(|warning| warning.message.contains("outlines")),
"{found:?}"
);
}
#[test]
fn a_diagram_measures_only_the_roles_it_draws() {
let flow = std::fs::read_to_string("examples/release-flow.json").expect("example");
let plain =
render_json(&flow, RenderFormat::Svg, &RenderOptions::default()).expect("renders");
let drawn: Vec<&str> = super::drawn_roles(&plain.content)
.iter()
.map(|(color, _)| *color)
.collect();
assert!(
drawn.contains(&"green") && drawn.len() < super::ROLES.len(),
"{drawn:?}"
);
for color in super::ROLES {
let token = format!("role-{color}");
let themed =
crate::apply_theme(&flow, &format!(r##"{{"tokens":{{"{token}":"#eeeeee"}}}}"##))
.expect("applies");
let rendered = render_json(&themed, RenderFormat::Svg, &RenderOptions::default())
.expect("renders");
let warned = rendered.warnings.iter().any(|warning| {
warning.code == "low_contrast" && warning.path == format!("/tokens/{token}")
});
assert_eq!(warned, drawn.contains(&color), "{color}");
}
}
#[test]
fn translucent_and_rgb_colors_are_read() {
let found = warnings(r#""tokens":{"text":"rgba(0, 0, 0, 0.2)"},"#);
assert_eq!(found.len(), 1);
assert_eq!(warnings(r#""tokens":{"text":"rgb(10 10 10)"},"#).len(), 0);
}
#[test]
fn the_brand_theme_of_the_examples_passes() {
let theme = std::fs::read_to_string("examples/themes/brand.json").expect("brand theme");
for example in ["monthly-revenue", "release-flow", "revenue-by-channel"] {
let source =
std::fs::read_to_string(format!("examples/{example}.json")).expect("example");
let themed = crate::apply_theme(&source, &theme).expect("applies");
let rendered = render_json(&themed, RenderFormat::Svg, &RenderOptions::default())
.expect("renders");
assert!(
rendered
.warnings
.iter()
.all(|warning| warning.code != "low_contrast"),
"{example}: {:?}",
rendered.warnings
);
}
}
}