chartlet 0.22.0

Compile structured data into deterministic, accessible static charts
Documentation
//! The HTML figure around one or more SVGs: the series filter, zoom steps, focus
//! of a diagram and mobile variant, without a script, and the page of all views of a model.

use std::fmt::Write;

use super::{escape, number, table::render_data_table};
use crate::{TableMode, spec::ChartSpec};

/// CSS rules for radio-selectable zoom panels. Only the panel whose radio is checked shows;
/// the first radio carries `checked`, so the first step is the default view.
const ZOOM_STYLE: &str = ".chartlet-zoom{border:none;padding:0;margin:0 0 12px 0}.chartlet-zoom legend{font-size:14px;font-weight:650;margin-bottom:4px}.chartlet-zoom label{display:inline-flex;align-items:center;min-height:44px;font-size:13px;margin-right:14px;cursor:pointer;white-space:nowrap}.chartlet-zoom input{margin-right:4px}.chartlet-zoom input:focus-visible{outline:2px solid #2563eb;outline-offset:2px}.chartlet-panel{display:none}.chartlet-wrapper:has(.chartlet-zoom input.zoom-0:checked) .chartlet-panel-0{display:block}.chartlet-wrapper:has(.chartlet-zoom input.zoom-1:checked) .chartlet-panel-1{display:block}.chartlet-wrapper:has(.chartlet-zoom input.zoom-2:checked) .chartlet-panel-2{display:block}.chartlet-wrapper:has(.chartlet-zoom input.zoom-3:checked) .chartlet-panel-3{display:block}";

/// One chart of the HTML figure: a zoom step, or the only chart. `mobile` is its mobile variant,
/// when the specification has one.
pub(crate) struct Panel {
    pub label: String,
    pub svg: String,
    pub mobile: Option<String>,
    /// For a diagram, where its nodes stand in each layout, desktop first: the HTML profile lays
    /// a focus link over every node. Empty for every other chart.
    pub stages: Vec<Stage>,
}

/// The nodes of one laid-out diagram: the SVG's root ID, its size, and where each node stands.
#[derive(Debug, Clone)]
pub(crate) struct Stage {
    pub root: String,
    pub width: u32,
    pub height: u32,
    pub hotspots: Vec<crate::scene::Hotspot>,
}

pub(crate) fn html(
    panel: Panel,
    spec: &ChartSpec,
    table_mode: TableMode,
    id_prefix: &str,
    hooks: bool,
) -> String {
    html_document(&[panel], spec, table_mode, id_prefix, false, hooks)
}

pub(crate) fn html_zoom(
    panels: &[Panel],
    spec: &ChartSpec,
    table_mode: TableMode,
    id_prefix: &str,
    hooks: bool,
) -> String {
    html_document(panels, spec, table_mode, id_prefix, true, hooks)
}

/// One view of an architecture model on the page of all its views: the ID prefix of its figure,
/// which its section's ID extends, its title and its HTML figure.
pub(crate) struct ViewSection {
    pub id: String,
    pub title: String,
    pub content: String,
}

/// The page of all views: links stay visible and large enough to hit, a link target keeps a
/// margin above it, and in print every view after the first starts a page of its own.
const VIEWS_STYLE: &str = ".chartlet-views-nav ul{margin:0 0 16px 0;padding-left:20px}.chartlet-views-nav li{margin:4px 0}.chartlet-views-nav a{display:inline-block;min-height:24px;padding:2px 0}.chartlet-views-nav a:focus-visible{outline:2px solid #2563eb;outline-offset:2px}.chartlet-view{margin:0 0 32px 0;scroll-margin-top:16px}@media print{.chartlet-view+.chartlet-view{break-before:page}}";

/// Every view of a model on one page, without a script: the model's title, a navigation of links
/// to the views in the order of the page, and each view as a section headed by its title, with
/// its figure.
pub(crate) fn html_views(sections: &[ViewSection], spec: &ChartSpec, id_prefix: &str) -> String {
    let words = spec.locale.words();
    let mut output = String::new();
    write!(
        output,
        "<div class=\"chartlet-views\" id=\"{id_prefix}\"><style>{VIEWS_STYLE}</style><h2 id=\"{id_prefix}-title\">{}</h2><nav class=\"chartlet-views-nav\" aria-label=\"{}\"><ul>",
        escape(&spec.title),
        words.views
    )
    .expect("write");
    for section in sections {
        write!(
            output,
            "<li><a href=\"#{}-view\">{}</a></li>",
            section.id,
            escape(&section.title)
        )
        .expect("write");
    }
    output.push_str("</ul></nav>");
    for section in sections {
        write!(
            output,
            "<section class=\"chartlet-view\" id=\"{id}-view\" aria-labelledby=\"{id}-view-title\"><h3 id=\"{id}-view-title\">{}</h3>{}</section>",
            escape(&section.title),
            section.content,
            id = section.id,
        )
        .expect("write");
    }
    output.push_str("</div>");
    output
}

/// Switches between the chart and its mobile variant by the width of the wrapper. The mobile
/// variant is hidden unless the container query applies, so a browser without container queries
/// keeps showing the chart at its full size. `display:none` also takes the hidden variant out of
/// the accessibility tree. The rules carry the breakpoint in their selector, so charts with
/// different breakpoints on one page do not switch each other.
fn responsive_style(breakpoint: u32) -> String {
    format!(
        ".chartlet-wrapper.chartlet-responsive{{display:block;width:100%;container-type:inline-size}}.chartlet-variant-mobile{{display:none}}@container (max-width:{}px){{.chartlet-bp-{breakpoint} .chartlet-variant-desktop{{display:none}}.chartlet-bp-{breakpoint} .chartlet-variant-mobile{{display:block}}}}",
        breakpoint - 1
    )
}

/// Builds the HTML figure. With one panel and no zoom name this is the plain figure; with
/// several panels it adds a radio group that switches between pre-computed variants.
fn html_document(
    panels: &[Panel],
    spec: &ChartSpec,
    table_mode: TableMode,
    id_prefix: &str,
    zoomable: bool,
    hooks: bool,
) -> String {
    let mut output = String::new();
    // A stack shows every series as part of a whole; hiding one would leave a gap in it.
    let has_series = spec.series.len() > 1 && spec.stack.is_none();
    let zoom = zoomable && panels.len() > 1;
    let breakpoint = spec.mobile.as_ref().map(|mobile| mobile.breakpoint);
    let focus = !zoom && panels.first().is_some_and(|panel| !panel.stages.is_empty());
    let wrapped = has_series || zoom || breakpoint.is_some() || focus;
    if let Some(breakpoint) = breakpoint {
        write!(
            output,
            "<div class=\"chartlet-wrapper chartlet-responsive chartlet-bp-{breakpoint}\"><style>{}</style>",
            responsive_style(breakpoint)
        )
        .expect("write");
    } else if wrapped {
        output.push_str("<div class=\"chartlet-wrapper\">");
    }
    if focus {
        emit_focus_controls(&panels[0], spec, id_prefix, &mut output);
    }
    if has_series {
        write!(
            output,
            "<fieldset class=\"chartlet-filter\"><legend>{}</legend>",
            spec.locale.words().series
        )
        .expect("write");
        for (i, series) in spec.series.iter().enumerate() {
            write!(
                output,
                "<label><input type=\"checkbox\" class=\"series-{i}\" checked> {}</label>",
                escape(&series.name)
            )
            .expect("write");
        }
        output.push_str("</fieldset>");
    }
    if zoom {
        write!(
            output,
            "<style>{ZOOM_STYLE}</style><fieldset class=\"chartlet-zoom\"><legend>{}</legend>",
            spec.locale.words().view
        )
        .expect("write");
        for (i, Panel { label, .. }) in panels.iter().enumerate() {
            let checked = if i == 0 { " checked" } else { "" };
            write!(
                output,
                "<label><input type=\"radio\" name=\"zoom-{id_prefix}\" class=\"zoom-{i}\"{checked}> {}</label>",
                escape(label)
            )
            .expect("write");
        }
        output.push_str("</fieldset>");
    }
    // `data-viz` and `data-viz-text` follow the display-mode convention of barrierlab: a chart
    // figure whose text layer is its data table.
    output.push_str("<figure class=\"chartlet-figure\" data-viz=\"chart\">");
    write!(output, "<figcaption>{}</figcaption>", escape(&spec.title)).expect("write");
    emit_panels(panels, zoom, id_prefix, &mut output);
    if let Some(source) = &spec.source {
        write!(
            output,
            "<p class=\"chartlet-source\">{}: {}</p>",
            spec.locale.words().source,
            escape(source)
        )
        .expect("write");
    }
    render_data_table(&mut output, spec, table_mode, hooks.then_some(id_prefix));
    output.push_str("</figure>");
    if wrapped {
        output.push_str("</div>");
    }
    output
}

/// The charts of the figure: one per zoom step, each with its mobile variant beside it.
fn emit_panels(panels: &[Panel], zoom: bool, id_prefix: &str, output: &mut String) {
    for (i, panel) in panels.iter().enumerate() {
        let desktop = staged(&panel.svg, panel.stages.first(), id_prefix);
        let variants = match &panel.mobile {
            Some(mobile) => format!(
                "<div class=\"chartlet-variant-desktop\">{desktop}</div><div class=\"chartlet-variant-mobile\">{}</div>",
                staged(mobile, panel.stages.get(1), id_prefix)
            ),
            None => desktop,
        };
        if zoom {
            write!(
                output,
                "<div class=\"chartlet-panel chartlet-panel-{i}\">{variants}</div>"
            )
            .expect("write");
        } else {
            output.push_str(&variants);
        }
    }
}

/// The focus of a diagram, without a script: a group of radio buttons, one per node and one for
/// all of them, in a disclosure before the figure, and over every node a label of its button, so
/// that a click on the node checks it. While a node is checked, CSS keeps it, its neighbours and
/// the edges between them as they are and fades the rest. The buttons are the controls, with
/// their names and the usual keyboard; the labels over the nodes are only a larger place to click
/// them. The SVG keeps its `role="img"`: the labels are HTML beside it, not content of it.
const FOCUS_STYLE: &str = ".chartlet-stage{position:relative;display:inline-block;max-width:100%;vertical-align:top}.chartlet-stage>svg{display:block}.chartlet-hotspot{position:absolute;border-radius:8px;cursor:pointer}.chartlet-focus{margin:0 0 12px 0;font-size:13px}.chartlet-focus summary{cursor:pointer;min-height:24px}.chartlet-focus fieldset{border:none;padding:0;margin:4px 0 0 0}.chartlet-focus label{display:inline-flex;align-items:center;min-height:44px;margin-right:14px;cursor:pointer;white-space:nowrap}.chartlet-focus input{margin-right:4px}.chartlet-focus input:focus-visible{outline:2px solid #2563eb;outline-offset:2px}";

/// The stylesheet and the radio buttons of the focus, before the figure.
fn emit_focus_controls(panel: &Panel, spec: &ChartSpec, id_prefix: &str, output: &mut String) {
    let words = spec.locale.words();
    let mut style = String::from(FOCUS_STYLE);
    let nodes = &panel.stages[0].hotspots;
    for stage in &panel.stages {
        let root = &stage.root;
        write!(
            style,
            ".chartlet-wrapper:has(.chartlet-focus-node:checked) #{root} .chartlet-f{{opacity:.18}}"
        )
        .expect("write");
        for hotspot in &stage.hotspots {
            write!(
                style,
                "#{}{{left:{}%;top:{}%;width:{}%;height:{}%}}",
                hotspot_id(root, hotspot.node),
                number(hotspot.x / f64::from(stage.width) * 100.0),
                number(hotspot.y / f64::from(stage.height) * 100.0),
                number(hotspot.width / f64::from(stage.width) * 100.0),
                number(hotspot.height / f64::from(stage.height) * 100.0),
            )
            .expect("write");
        }
        for hotspot in nodes {
            let node = hotspot.node;
            write!(
                style,
                ".chartlet-wrapper:has(#{id_prefix}-focus-{node}:checked) #{root} .chartlet-f-{node}{{opacity:1}}.chartlet-wrapper:has(#{id_prefix}-focus-{node}:checked) #{root} .chartlet-n-{node} .chartlet-flow-node,.chartlet-wrapper:has(#{id_prefix}-focus-{node}:checked) #{root} .chartlet-n-{node} .chartlet-seq-box{{stroke-width:3}}"
            )
            .expect("write");
        }
    }
    write!(
        output,
        "<style>{style}</style><details class=\"chartlet-focus\"><summary>{}</summary><fieldset><label><input type=\"radio\" name=\"{id_prefix}-focus\" checked> {}</label>",
        words.focus, words.show_all
    )
    .expect("write");
    for hotspot in nodes {
        write!(
            output,
            "<label><input type=\"radio\" name=\"{id_prefix}-focus\" class=\"chartlet-focus-node\" id=\"{id_prefix}-focus-{}\"> {}</label>",
            hotspot.node,
            escape(&hotspot.label)
        )
        .expect("write");
    }
    output.push_str("</fieldset></details>");
}

fn hotspot_id(root: &str, node: usize) -> String {
    format!("{root}-hs-{node}")
}

/// An SVG with, over every node of its stage, a label of that node's focus button; or the SVG
/// alone without a stage.
fn staged(svg: &str, stage: Option<&Stage>, id_prefix: &str) -> String {
    let Some(stage) = stage else {
        return svg.to_owned();
    };
    let mut output = format!("<div class=\"chartlet-stage\">{svg}");
    for hotspot in &stage.hotspots {
        write!(
            output,
            "<label class=\"chartlet-hotspot\" id=\"{}\" for=\"{id_prefix}-focus-{}\"></label>",
            hotspot_id(&stage.root, hotspot.node),
            hotspot.node,
        )
        .expect("write");
    }
    output.push_str("</div>");
    output
}