konoma 0.28.5

Terminal file browser built for AI pair-programming — full-screen previews (Markdown, images, PDF, CSV), a git suite (jj/Jujutsu in preview), and an agent-watch mode that follows your AI's edits (macOS and Linux)
//! Drawing a `quadrantChart`.
//!
//! # The one thing this chart must get right
//!
//! A point at `[0.3, 0.6]` belongs in the top-left box, and **y runs up**: the source's y is a
//! fraction from the bottom, and SVG's y is a distance from the top, so [`place`] inverts it.
//! Getting that wrong produces a chart that is entirely plausible and says the opposite of what
//! the author wrote — which is why `tests` states it as "the point is inside the named quadrant"
//! rather than as a coordinate.
//!
//! # konoma does not paint the four boxes
//!
//! mermaid tints them four shades of near-white, which on a terminal is an opaque card over
//! whatever the user's background is (§1). Here the quadrants are made by the axes that divide
//! them, and each is named by the label in its corner — which is the information the tint was
//! standing in for.

use crate::preview::mermaid::chart::quadrant::{QuadrantChart, QuadrantPoint};
use crate::preview::mermaid::flowchart::Stroke;
use crate::preview::mermaid::layout::Point;

use super::super::{
    normalise, Diagram, Glyph, Label, PlacedNode, RenderError, ShapeStyle, Size, Theme,
};
use super::{add_title, label_node, rule, text_node, POINT_RADIUS, TICK_GAP};

/// Side of the square the four quadrants fill, before the words round it ask for more.
pub const SIDE: f64 = 340.0;

/// Blank space kept between two words that sit side by side along one edge.
pub const WORD_GAP: f64 = 8.0;

/// Blank space between a quadrant's corner and the label in it.
pub const CORNER_PAD: f64 = 8.0;

/// Radius of a plotted point.
pub const DOT_RADIUS: f64 = 4.5;

/// Reads a mermaid quadrant chart source and draws it.
///
/// **This is the entry point the golden tests go through** (§6).
pub fn render(code: &str, theme: &str) -> Result<String, RenderError> {
    let chart = crate::preview::mermaid::chart::quadrant::parse(code)?;
    let diagram = lay_out(&chart)?;
    Ok(super::super::svg::emit(&diagram, &Theme::named(theme)))
}

/// Where a `[x, y]` pair lands, in px.
///
/// `left`/`top` are the square's corner and `side` its length. **y is inverted**: `y = 1` is the
/// top of the square.
pub fn place(left: f64, top: f64, side: f64, x: f64, y: f64) -> Point {
    Point::new(left + x * side, top + (1.0 - y) * side)
}

/// How big the square has to be for the words along its edges not to run into each other.
///
/// The two x words sit at a quarter and three quarters across, and the two names in the top half
/// do the same — so **the words decide the size**, exactly as a category name decides how wide an
/// `xychart`'s plot is and a node name decides how wide a Sankey is. At the fixed 340 the two
/// halves of a long axis ran together into one string: `…on the leftAnd a long one on the right`
/// is not a chart with untidy labels, it is a chart with a word in it that nobody wrote.
///
/// Half the side separates the two centres, and each word claims half its own width, so the pair
/// needs `w₁ + w₂ + 2·WORD_GAP` of side between them.
fn square_side(chart: &QuadrantChart) -> f64 {
    let w = |t: &str| Label::measure(t).width;
    [
        w(&chart.x_left) + w(&chart.x_right),
        w(&chart.quadrant2) + w(&chart.quadrant1),
        w(&chart.quadrant3) + w(&chart.quadrant4),
    ]
    .into_iter()
    .fold(SIDE, |side, pair| side.max(pair + WORD_GAP * 2.0))
}

/// Works out where the axes, the quadrant names and every point go.
pub fn lay_out(chart: &QuadrantChart) -> Result<Diagram, RenderError> {
    if !crate::preview::mermaid::text_metrics::fonts_available() {
        return Err(RenderError::NoFonts);
    }
    let (left, top) = (0.0, 0.0);
    let side = square_side(chart);
    let (right, bottom) = (side, side);
    let mid_x = left + side / 2.0;
    let mid_y = top + side / 2.0;

    let mut nodes: Vec<PlacedNode> = Vec::new();
    let mut edges = Vec::new();

    nodes.push(PlacedNode {
        id: "frame".to_string(),
        shape: Glyph::PlotFrame,
        center: Point::new(mid_x, mid_y),
        size: Size::new(side, side),
        label: Label::measure(""),
        panel: None,
        series: None,
        mark: None,
        style: None,
    });
    // The two dividers. These are what make the four quadrants four quadrants, so they are drawn
    // solid rather than as a grid: a reader has to be able to tell which side of the middle a
    // point is on without measuring.
    edges.push(rule(
        Point::new(mid_x, top),
        Point::new(mid_x, bottom),
        Stroke::Normal,
        None,
    ));
    edges.push(rule(
        Point::new(left, mid_y),
        Point::new(right, mid_y),
        Stroke::Normal,
        None,
    ));

    // --- the four names, each in its own corner -----------------------------------------------
    //
    // `quadrant-1` is the top **right** — mermaid's numbering is the mathematical one, going
    // anticlockwise from the top right, and it is the one thing about this chart everybody gets
    // backwards.
    for (text, qx, qy) in [
        (&chart.quadrant1, 1.0, 1.0),
        (&chart.quadrant2, 0.0, 1.0),
        (&chart.quadrant3, 0.0, 0.0),
        (&chart.quadrant4, 1.0, 0.0),
    ] {
        let label = Label::measure(text);
        if label.is_blank() {
            continue;
        }
        // Centred across its own half, tucked under the top edge of it.
        let cx = left + side * 0.25 + qx * side * 0.5;
        let band_top = top + (1.0 - qy) * side * 0.5;
        let cy = band_top + CORNER_PAD + label.height / 2.0;
        if let Some(n) = label_node(
            format!(
                "quadrant#{}",
                if qy > 0.5 { qx as u8 } else { 2 + qx as u8 }
            ),
            label,
            Point::new(cx, cy),
            None,
        ) {
            nodes.push(n);
        }
    }

    // --- the points ---------------------------------------------------------------------------
    for (i, p) in chart.points.iter().enumerate() {
        let c = place(left, top, side, p.x, p.y);
        nodes.push(PlacedNode {
            id: format!("point#{i}"),
            shape: Glyph::ChartPoint,
            center: c.clone(),
            size: Size::new(DOT_RADIUS * 2.0, DOT_RADIUS * 2.0),
            label: Label::measure(""),
            panel: None,
            series: Some(i),
            mark: None,
            style: quadrant_style(&chart.class_defs, p),
        });
        let label = Label::measure(&p.label);
        if let Some(n) = label_node(
            format!("point#{i}#label"),
            label.clone(),
            Point::new(c.x, c.y + DOT_RADIUS + TICK_GAP + label.height / 2.0),
            None,
        ) {
            nodes.push(n);
        }
    }

    // --- the axis texts, one per side ---------------------------------------------------------
    let x_label_gap = POINT_RADIUS + TICK_GAP * 2.0;
    for (text, cx, below) in [
        (&chart.x_left, left + side * 0.25, true),
        (&chart.x_right, left + side * 0.75, true),
    ] {
        let label = Label::measure(text);
        if let Some(n) = label_node(
            format!(
                "xaxis#{}",
                if below && cx < mid_x { "left" } else { "right" }
            ),
            label.clone(),
            Point::new(cx, bottom + x_label_gap + label.height / 2.0),
            None,
        ) {
            nodes.push(n);
        }
    }
    // The y texts sit outside the left edge, upright — see `xychart`'s note on rotated text.
    for (text, cy, name) in [
        (&chart.y_bottom, top + side * 0.75, "bottom"),
        (&chart.y_top, top + side * 0.25, "top"),
    ] {
        let label = Label::measure(text);
        if let Some(n) = label_node(
            format!("yaxis#{name}"),
            label.clone(),
            Point::new(left - x_label_gap - label.width / 2.0, cy),
            None,
        ) {
            nodes.push(n);
        }
    }
    let _ = text_node("", "", Point::new(0.0, 0.0), None);

    let mut diagram = Diagram {
        nodes,
        edges,
        ..Diagram::default()
    };
    add_title(&mut diagram, &chart.preamble);
    normalise(&mut diagram);
    Ok(diagram)
}

/// Resolves one point's paint: `classDef default` → its own `:::class` → its own inline styles.
///
/// Not [`super::super::style::cascade`]: this grammar's declarations are not `fill`/`stroke` —
/// they are `radius`/`color`/`stroke-color`/`stroke-width`, mermaid's own `quadrantChart` point
/// vocabulary (`QuadrantPoint::styles`' doc comment, and the corpus case at
/// `chart::tests::CASES`'s `classDef hot radius: 8, color: #ff0000`). [`apply_quadrant_decl`]
/// translates that vocabulary onto [`ShapeStyle`] instead.
fn quadrant_style(
    class_defs: &[(String, Vec<String>)],
    point: &QuadrantPoint,
) -> Option<ShapeStyle> {
    let mut style = ShapeStyle::default();
    let apply_class = |style: &mut ShapeStyle, name: &str| {
        if let Some((_, decls)) = class_defs.iter().find(|(n, _)| n == name) {
            for d in decls {
                apply_quadrant_decl(style, d);
            }
        }
    };
    apply_class(&mut style, "default");
    if let Some(c) = &point.class {
        apply_class(&mut style, c);
    }
    for d in &point.styles {
        apply_quadrant_decl(&mut style, d);
    }
    if style.is_empty() {
        None
    } else {
        Some(style)
    }
}

/// One `radius: 8, color: #ff0000, stroke-color: #333, stroke-width: 2px` declaration, mapped onto
/// the field it actually means: `color` is the point's own fill (there is no text on a point for
/// it to colour, unlike `classDef`'s `color:` elsewhere), `stroke-color` is `stroke`, and
/// `stroke-width` needs no translation. `radius` resizes the point rather than colouring it, which
/// is out of this bug fix's scope (`docs/STATUS.md`'s flowchart entry, D3); it is recognised here
/// so it is not treated as an unknown declaration, and otherwise dropped. Reuses
/// [`ShapeStyle::apply`] for the actual parsing so the same colour validation
/// (`render::style`'s module docs) applies here too.
fn apply_quadrant_decl(style: &mut ShapeStyle, decl: &str) {
    let Some((key, value)) = decl.split_once(':') else {
        return;
    };
    match key.trim() {
        "color" => style.apply(&format!("fill:{}", value.trim())),
        "stroke-color" => style.apply(&format!("stroke:{}", value.trim())),
        "stroke-width" => style.apply(decl),
        _ => {}
    }
}