chartlet 0.20.0

Compile structured data into deterministic, accessible static charts
Documentation
//! Roles of the nodes and edges of a diagram: what something means beyond its kind, such as
//! critical, planned or deprecated, drawn alike in every diagram. A role keeps the shape and the
//! fill of its kind and changes the outline or the line: its color, taken from the role colors of
//! the theme, and a sign that does not depend on color, a stronger or patterned line and a badge
//! on the corner of a box. One legend below the diagram names the roles it uses; the description
//! and the table name the role of everything that has one.

use crate::{
    diagram::BADGE,
    layout::{LABEL_SIZE, count},
    metrics::TextMetrics,
    scene::{Circle, Element, Line, Scene, Text, TextAnchor},
    spec::{ChartType, DiagramRole, Locale},
    text,
};

/// The roles drawn on the outlines of boxes, on lines and their heads, and on the badges. They
/// come after the main path, so that a role recolors a main edge, and before the widths of
/// weighted edges, so that a weight keeps its width.
pub(crate) const STYLE: &str = ".chartlet-flow-role-critical .chartlet-flow-node{stroke:var(--chartlet-role-red);stroke-width:3}.chartlet-flow-role-planned .chartlet-flow-node{stroke:var(--chartlet-role-violet);stroke-dasharray:6 3}.chartlet-flow-role-deprecated .chartlet-flow-node{stroke:var(--chartlet-role-gray);stroke-dasharray:1.5 3}.chartlet-flow-role-critical .chartlet-flow-edge{stroke:var(--chartlet-role-red);stroke-width:2.6}.chartlet-flow-role-planned .chartlet-flow-edge{stroke:var(--chartlet-role-violet);stroke-dasharray:6 4}.chartlet-flow-role-deprecated .chartlet-flow-edge{stroke:var(--chartlet-role-gray);stroke-dasharray:1 4;stroke-linecap:round}.chartlet-flow-role-critical .chartlet-flow-head,.chartlet-flow-role-critical .chartlet-diagram-badge{fill:var(--chartlet-role-red);stroke:var(--chartlet-role-red)}.chartlet-flow-role-planned .chartlet-flow-head,.chartlet-flow-role-planned .chartlet-diagram-badge{fill:var(--chartlet-role-violet);stroke:var(--chartlet-role-violet)}.chartlet-flow-role-deprecated .chartlet-flow-head,.chartlet-flow-role-deprecated .chartlet-diagram-badge{fill:var(--chartlet-role-gray);stroke:var(--chartlet-role-gray)}.chartlet-legend{font-size:12px;fill:var(--chartlet-muted)}";

/// In class diagrams, the hollow marks of a relation stay hollow under a role, and the open
/// arrowhead of a dependency or an association takes the role's color like a filled head.
const CLASSES_STYLE: &str = ".chartlet-flow-role-critical .chartlet-flow-hollow,.chartlet-flow-role-planned .chartlet-flow-hollow,.chartlet-flow-role-deprecated .chartlet-flow-hollow{fill:var(--chartlet-background)}.chartlet-flow-role-critical .chartlet-flow-open{stroke:var(--chartlet-role-red)}.chartlet-flow-role-planned .chartlet-flow-open{stroke:var(--chartlet-role-violet)}.chartlet-flow-role-deprecated .chartlet-flow-open{stroke:var(--chartlet-role-gray)}";

/// In networks, the ring a role draws around a dot, which keeps the color of its group.
const NETWORK_STYLE: &str = ".chartlet-net-role{fill:none}";

/// The style of the roles of a diagram of `chart_type`.
pub(crate) fn style(chart_type: ChartType) -> String {
    match chart_type {
        ChartType::Classes => [STYLE, CLASSES_STYLE].concat(),
        ChartType::Network => [STYLE, NETWORK_STYLE].concat(),
        _ => STYLE.to_owned(),
    }
}

/// The groups of the shared stylesheet for the roles, each with the chart types it serves.
pub(crate) const STYLE_GROUPS: [(&str, &[ChartType]); 3] = [
    (
        STYLE,
        &[
            ChartType::Flow,
            ChartType::Architecture,
            ChartType::State,
            ChartType::Network,
            ChartType::Tree,
            ChartType::Classes,
        ],
    ),
    (CLASSES_STYLE, &[ChartType::Classes]),
    (NETWORK_STYLE, &[ChartType::Network]),
];

/// How far the ring of a role stands from the dot of a network node, which wears a halo of the
/// background on its edge.
pub(crate) const RING_GAP: f64 = 2.5;

const LEGEND_ROW: f64 = 22.0;
/// The length of the line that stands for an edge in the legend.
const SAMPLE: f64 = 22.0;

/// The class a node's or an edge's group gains for its role.
pub(crate) const fn class(role: DiagramRole) -> &'static str {
    match role {
        DiagramRole::Critical => " chartlet-flow-role-critical",
        DiagramRole::Planned => " chartlet-flow-role-planned",
        DiagramRole::Deprecated => " chartlet-flow-role-deprecated",
    }
}

/// The sign on a role's badge.
const fn sign(role: DiagramRole) -> &'static str {
    match role {
        DiagramRole::Critical => "!",
        DiagramRole::Planned => "+",
        DiagramRole::Deprecated => "−",
    }
}

/// The badge of a role centred on `(cx, cy)`: on a box, its top left corner, away from the labels
/// of the edges that leave it on its right or bottom; in the legend, beside the role's name.
pub(crate) fn push_badge(role: DiagramRole, (cx, cy): (f64, f64), elements: &mut Vec<Element>) {
    elements.push(Element::Circle(Circle {
        cx,
        cy,
        radius: BADGE,
        class: "chartlet-diagram-badge",
        topic: None,
        series_index: None,
        style_index: None,
        tooltip: None,
    }));
    elements.push(Element::Text(Text {
        x: cx,
        y: cy + 3.5,
        class: "chartlet-diagram-badge-text",
        anchor: TextAnchor::Middle,
        content: sign(role).to_owned(),
    }));
}

/// The ring of a role around the dot of a network node centred on `(cx, cy)`: its pattern says the
/// role without color, as the outline of a box does; a dot is too small for a badge.
pub(crate) fn push_ring((cx, cy): (f64, f64), radius: f64, elements: &mut Vec<Element>) {
    elements.push(Element::Circle(Circle {
        cx,
        cy,
        radius,
        class: "chartlet-flow-node chartlet-net-role",
        topic: None,
        series_index: None,
        style_index: None,
        tooltip: None,
    }));
}

/// The roles a diagram uses, in their fixed order, each with whether a node and whether an edge
/// has it.
pub(crate) fn used(
    nodes: impl Iterator<Item = Option<DiagramRole>>,
    edges: impl Iterator<Item = Option<DiagramRole>>,
) -> Vec<(DiagramRole, bool, bool)> {
    let (nodes, edges): (Vec<DiagramRole>, Vec<DiagramRole>) =
        (nodes.flatten().collect(), edges.flatten().collect());
    [
        DiagramRole::Critical,
        DiagramRole::Planned,
        DiagramRole::Deprecated,
    ]
    .into_iter()
    .map(|role| (role, nodes.contains(&role), edges.contains(&role)))
    .filter(|(_, on_nodes, on_edges)| *on_nodes || *on_edges)
    .collect()
}

/// What draws the sign of a node with a role centred on a point: a badge, or a ring in networks.
pub(crate) type NodeSign = fn(DiagramRole, (f64, f64), &mut Vec<Element>);

/// The legend of the roles below a diagram: for every role used, the sign of a node with it
/// where a node has it (drawn by `node_sign` centred on a point), a piece of line where an edge
/// has it, and its name, in rows that fit. The canvas grows by its height.
pub(crate) fn push_legend(
    scene: &mut Scene,
    locale: Locale,
    used: &[(DiagramRole, bool, bool)],
    (margin, node_sign): (f64, NodeSign),
    metrics: &impl TextMetrics,
) {
    if used.is_empty() {
        return;
    }
    let room = f64::from(scene.width) - 2.0 * margin;
    let top = f64::from(scene.height) + 4.0;
    let (mut x, mut row) = (0.0, 0);
    for &(role, on_nodes, on_edges) in used {
        let name = capitalized(text::role(locale, role));
        let samples = if on_nodes { 2.0 * BADGE + 6.0 } else { 0.0 }
            + if on_edges { SAMPLE + 6.0 } else { 0.0 };
        let width = samples + metrics.width(&name, LABEL_SIZE) + 20.0;
        if x > 0.0 && x + width > room {
            x = 0.0;
            row += 1;
        }
        let y = top + 14.0 + LEGEND_ROW * count(row);
        let mut at = margin + x;
        scene
            .elements
            .push(Element::Group(class(role).trim_start().to_owned()));
        if on_nodes {
            node_sign(role, (at + BADGE, y - 4.0), &mut scene.elements);
            at += 2.0 * BADGE + 6.0;
        }
        if on_edges {
            scene.elements.push(Element::Line(Line {
                x1: at,
                y1: y - 4.0,
                x2: at + SAMPLE,
                y2: y - 4.0,
                class: "chartlet-flow-edge",
            }));
            at += SAMPLE + 6.0;
        }
        scene.elements.push(Element::GroupEnd);
        scene.elements.push(Element::Text(Text {
            x: at,
            y,
            class: "chartlet-legend",
            anchor: TextAnchor::Start,
            content: name,
        }));
        x += width;
    }
    scene.height = crate::diagram::pixels(top + LEGEND_ROW * count(row + 1) + 6.0);
}

fn capitalized(word: &str) -> String {
    let mut chars = word.chars();
    chars.next().map_or_else(String::new, |first| {
        first.to_uppercase().chain(chars).collect()
    })
}

/// The sentence with what has each role, as `(role, name)` pairs of nodes and edges, in their
/// order in the description; `None` when nothing has a role.
pub(crate) fn sentence(locale: Locale, named: &[(DiagramRole, String)]) -> Option<String> {
    let mut roles: Vec<DiagramRole> = named.iter().map(|(role, _)| *role).collect();
    roles.sort_unstable();
    roles.dedup();
    if roles.is_empty() {
        return None;
    }
    let entries: Vec<String> = roles
        .into_iter()
        .map(|role| {
            let members: Vec<&str> = named
                .iter()
                .filter(|(other, _)| *other == role)
                .map(|(_, name)| name.as_str())
                .collect();
            format!("{}: {}", text::role(locale, role), members.join(", "))
        })
        .collect();
    Some(text::roles(locale, &entries))
}