chartlet 0.21.0

Compile structured data into deterministic, accessible static charts
Documentation
//! Node colors from values: the boxes of flow charts and trees filled from a sequential scale of
//! six blues, light for the smallest value and dark for the largest, with a key below the
//! diagram. The colors are custom properties, the same in the light and the dark theme, and every
//! step fixes its text color so that labels keep at least 4.5:1 against their fill.

use crate::{
    layout::{LABEL_SIZE, count, format_value},
    metrics::TextMetrics,
    scene::{Element, Rect, Scene, Text, TextAnchor},
    spec::ChartSpec,
    text,
};

pub(crate) const STEPS: usize = 6;

pub(crate) const STYLE: &str = ".chartlet-root{--chartlet-heat-0:#cde0f5;--chartlet-heat-1:#a7c8ea;--chartlet-heat-2:#81afdf;--chartlet-heat-3:#5b97d3;--chartlet-heat-4:#0f66ac;--chartlet-heat-5:#064f89}.chartlet-heat-0 .chartlet-flow-node,.chartlet-heat-key-0{fill:var(--chartlet-heat-0)}.chartlet-heat-1 .chartlet-flow-node,.chartlet-heat-key-1{fill:var(--chartlet-heat-1)}.chartlet-heat-2 .chartlet-flow-node,.chartlet-heat-key-2{fill:var(--chartlet-heat-2)}.chartlet-heat-3 .chartlet-flow-node,.chartlet-heat-key-3{fill:var(--chartlet-heat-3)}.chartlet-heat-4 .chartlet-flow-node,.chartlet-heat-key-4{fill:var(--chartlet-heat-4)}.chartlet-heat-5 .chartlet-flow-node,.chartlet-heat-key-5{fill:var(--chartlet-heat-5)}.chartlet-heat-0 text,.chartlet-heat-1 text,.chartlet-heat-2 text,.chartlet-heat-3 text{fill:#172033}.chartlet-heat-4 text,.chartlet-heat-5 text{fill:#fff}.chartlet-heat-key{stroke:var(--chartlet-grid);stroke-width:1}.chartlet-legend{font-size:12px;fill:var(--chartlet-muted)}";

const KEY_CLASSES: [&str; STEPS] = [
    "chartlet-heat-key chartlet-heat-key-0",
    "chartlet-heat-key chartlet-heat-key-1",
    "chartlet-heat-key chartlet-heat-key-2",
    "chartlet-heat-key chartlet-heat-key-3",
    "chartlet-heat-key chartlet-heat-key-4",
    "chartlet-heat-key chartlet-heat-key-5",
];
const SWATCH: (f64, f64) = (28.0, 12.0);
/// The height the key adds below a diagram.
const KEY_HEIGHT: f64 = 52.0;

/// The step of every node with a value, in proportion to where its value lies between the
/// smallest and the largest; the middle step when all values are equal.
pub(crate) fn steps(values: &[Option<f64>]) -> Vec<Option<usize>> {
    let Some((low, high)) = range(values) else {
        return vec![None; values.len()];
    };
    values
        .iter()
        .map(|value| {
            value.map(|value| {
                if high > low {
                    let share = (value - low) / (high - low);
                    // A share of 0 to 1 times the last step, rounded: a small whole number.
                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
                    let step = (share * count(STEPS - 1)).round() as usize;
                    step
                } else {
                    STEPS / 2
                }
            })
        })
        .collect()
}

/// The smallest and the largest of the values that are there.
pub(crate) fn range(values: &[Option<f64>]) -> Option<(f64, f64)> {
    let mut present = values.iter().flatten().copied();
    let first = present.next()?;
    Some(present.fold((first, first), |(low, high), value| {
        (low.min(value), high.max(value))
    }))
}

/// The class a node's group gains for its step.
pub(crate) fn class(step: usize) -> String {
    format!(" chartlet-heat-{step}")
}

/// What the values stand for: the diagram's `valueLabel`, or the word for a value.
pub(crate) fn name(spec: &ChartSpec, label: Option<&str>) -> String {
    label.map_or_else(|| spec.locale.words().value.to_owned(), str::to_owned)
}

/// The key below a diagram: its name, then the six steps from the smallest value to the largest
/// with both ends written under them. The canvas grows by its height.
pub(crate) fn push_key(
    scene: &mut Scene,
    spec: &ChartSpec,
    (label, values): (&str, &[Option<f64>]),
    margin: f64,
    metrics: &impl TextMetrics,
) {
    let Some((low, high)) = range(values) else {
        return;
    };
    let top = f64::from(scene.height) + 4.0;
    let style = spec.number_style();
    let name_width = metrics.width(label, LABEL_SIZE);
    scene.elements.push(Element::Text(Text {
        x: margin,
        y: top + 14.0,
        class: "chartlet-legend",
        anchor: TextAnchor::Start,
        content: label.to_owned(),
    }));
    let left = margin + name_width + 12.0;
    for (step, class) in KEY_CLASSES.iter().enumerate() {
        scene.elements.push(Element::Rect(Rect {
            x: left + SWATCH.0 * count(step),
            y: top + 4.0,
            width: SWATCH.0,
            height: SWATCH.1,
            class,
            series_index: None,
            style_index: None,
            tooltip: None,
        }));
    }
    for (x, value, anchor) in [
        (left, low, TextAnchor::Start),
        (left + SWATCH.0 * count(STEPS), high, TextAnchor::End),
    ] {
        scene.elements.push(Element::Text(Text {
            x,
            y: top + 32.0,
            class: "chartlet-legend",
            anchor,
            content: format_value(value, style),
        }));
    }
    let right = left + SWATCH.0 * count(STEPS) + margin;
    scene.width = scene.width.max(crate::diagram::pixels(right));
    scene.height = crate::diagram::pixels(f64::from(scene.height) + KEY_HEIGHT);
}

/// The sentence that says what the colors show and gives every value, nodes in reading order as
/// `(label, value)`.
pub(crate) fn sentence(spec: &ChartSpec, name: &str, nodes: &[(&str, f64)]) -> String {
    let style = spec.number_style();
    let values: Vec<Option<f64>> = nodes.iter().map(|(_, value)| Some(*value)).collect();
    let (low, high) = range(&values).unwrap_or((0.0, 0.0));
    let listed: Vec<String> = nodes
        .iter()
        .map(|(label, value)| format!("{label} {}", format_value(*value, style)))
        .collect();
    text::node_values(
        spec.locale,
        name,
        (&format_value(low, style), &format_value(high, style)),
        &listed,
    )
}

#[cfg(test)]
mod tests {
    use super::steps;

    #[test]
    fn values_spread_over_the_steps_in_proportion() {
        assert_eq!(
            steps(&[Some(0.0), Some(50.0), None, Some(100.0)]),
            [Some(0), Some(3), None, Some(5)]
        );
        assert_eq!(steps(&[Some(7.0), Some(7.0)]), [Some(3), Some(3)]);
        assert_eq!(steps(&[None]), [None]);
    }
}