mermaid_text/quadrant_chart.rs
1//! Data model for Mermaid `quadrantChart` diagrams.
2//!
3//! A quadrant chart is a 2x2 priority matrix with labeled quadrants and
4//! plotted points on a unit-square coordinate system ([0, 1] x [0, 1]).
5//! Quadrant numbering follows Mermaid convention: Q1 = top-right,
6//! Q2 = top-left, Q3 = bottom-left, Q4 = bottom-right.
7//!
8//! Example source:
9//!
10//! ```text
11//! quadrantChart
12//! title Reach and engagement of campaigns
13//! x-axis Low Reach --> High Reach
14//! y-axis Low Engagement --> High Engagement
15//! quadrant-1 We should expand
16//! quadrant-2 Need to promote
17//! quadrant-3 Re-evaluate
18//! quadrant-4 May be improved
19//! Campaign A: [0.3, 0.6]
20//! Campaign B: [0.45, 0.23]
21//! ```
22//!
23//! Constructed by [`crate::parser::quadrant_chart::parse`] and consumed by
24//! [`crate::render::quadrant_chart::render`].
25//!
26//! ## Phase 1 limitations
27//!
28//! - Custom point styling (colour, radius) is not supported; all points render
29//! as a `·` marker followed by the point name and coordinates.
30//! - Background quadrant colours/gradients are not rendered.
31//! - `accDescr` / `accTitle` accessibility metadata is silently ignored.
32//! - Points that are close together may overlap in the text output.
33
34/// Axis label pair for one dimension of the quadrant chart.
35///
36/// `low` is the label at the origin-side of the axis;
37/// `high` is the label at the far end.
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct AxisLabels {
40 pub low: String,
41 pub high: String,
42}
43
44/// Labels for each of the four quadrants.
45///
46/// Mermaid numbers quadrants starting from top-right and going
47/// counter-clockwise: Q1 = top-right, Q2 = top-left,
48/// Q3 = bottom-left, Q4 = bottom-right.
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50pub struct QuadrantLabels {
51 pub q1: Option<String>,
52 pub q2: Option<String>,
53 pub q3: Option<String>,
54 pub q4: Option<String>,
55}
56
57/// A single plotted data point in the quadrant chart.
58///
59/// Coordinates `x` and `y` must be in [0, 1]; the parser rejects values
60/// outside this range. Overlapping points are possible in Phase 1 when two
61/// points map to the same terminal cell.
62#[derive(Debug, Clone, PartialEq)]
63pub struct QuadrantPoint {
64 pub name: String,
65 /// Horizontal position in [0, 1]; 0 = left edge, 1 = right edge.
66 pub x: f64,
67 /// Vertical position in [0, 1]; 0 = bottom edge, 1 = top edge.
68 pub y: f64,
69}
70
71/// A parsed `quadrantChart` diagram.
72///
73/// Constructed by [`crate::parser::quadrant_chart::parse`] and consumed by
74/// [`crate::render::quadrant_chart::render`].
75#[derive(Debug, Clone, PartialEq, Default)]
76pub struct QuadrantChart {
77 /// Optional diagram title.
78 pub title: Option<String>,
79 /// Optional x-axis labels (low end and high end).
80 pub x_axis: Option<AxisLabels>,
81 /// Optional y-axis labels (low end and high end).
82 pub y_axis: Option<AxisLabels>,
83 /// Labels for each of the four quadrants (all optional).
84 pub quadrants: QuadrantLabels,
85 /// Data points to plot on the chart.
86 pub points: Vec<QuadrantPoint>,
87}
88
89impl QuadrantChart {
90 /// Total number of data points in the chart.
91 pub fn point_count(&self) -> usize {
92 self.points.len()
93 }
94}
95
96// ---------------------------------------------------------------------------
97// Tests
98// ---------------------------------------------------------------------------
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn default_chart_has_zero_points() {
106 let chart = QuadrantChart::default();
107 assert_eq!(chart.point_count(), 0);
108 assert!(chart.title.is_none());
109 assert!(chart.x_axis.is_none());
110 assert!(chart.y_axis.is_none());
111 assert!(chart.quadrants.q1.is_none());
112 assert!(chart.quadrants.q4.is_none());
113 assert!(chart.points.is_empty());
114 }
115
116 #[test]
117 fn point_count_reflects_number_of_points() {
118 let chart = QuadrantChart {
119 points: vec![
120 QuadrantPoint {
121 name: "A".to_string(),
122 x: 0.3,
123 y: 0.6,
124 },
125 QuadrantPoint {
126 name: "B".to_string(),
127 x: 0.7,
128 y: 0.2,
129 },
130 QuadrantPoint {
131 name: "C".to_string(),
132 x: 0.5,
133 y: 0.5,
134 },
135 ],
136 ..Default::default()
137 };
138 assert_eq!(chart.point_count(), 3);
139 }
140
141 #[test]
142 fn equality_holds_for_identical_charts() {
143 let a = QuadrantChart {
144 title: Some("My Chart".to_string()),
145 x_axis: Some(AxisLabels {
146 low: "Low".to_string(),
147 high: "High".to_string(),
148 }),
149 points: vec![QuadrantPoint {
150 name: "P".to_string(),
151 x: 0.5,
152 y: 0.5,
153 }],
154 ..Default::default()
155 };
156 let b = a.clone();
157 assert_eq!(a, b);
158
159 let c = QuadrantChart {
160 title: Some("Other".to_string()),
161 ..Default::default()
162 };
163 assert_ne!(a, c);
164 }
165
166 #[test]
167 fn partial_eq_works_for_f64_coordinates() {
168 let p1 = QuadrantPoint {
169 name: "X".to_string(),
170 x: 0.123_456_789,
171 y: 0.987_654_321,
172 };
173 let p2 = p1.clone();
174 assert_eq!(p1, p2);
175
176 let p3 = QuadrantPoint {
177 name: "X".to_string(),
178 x: 0.123_456_789,
179 y: 0.1,
180 };
181 assert_ne!(p1, p3);
182 }
183}