Skip to main content

makeover_webview/
chart.rs

1//! A run of magnitudes against one axis, rendered as bars.
2//!
3//! [`meter`](crate::meter)'s neighbour and its opposite in one respect: a meter
4//! draws one proportion and computes the width from the pair it is handed, and
5//! this draws a series and computes nothing. Both numbers reach the markup as
6//! they were given, and the division happens in CSS.
7//!
8//! # Why the arithmetic is in the stylesheet
9//!
10//! Not taste, and not an optimisation. `quasi-declare` derives a compiled
11//! template by rendering a screen with stand-in values and keeping the bytes
12//! that no request reaches; a number the description HANDS a renderer is found
13//! in that render and becomes a hole, and a number the renderer WORKS OUT from
14//! two of them is printed as its arithmetic, leaves no stand-in to find, and is
15//! baked into the template as a constant. `quasi_router::stage::number_at` says
16//! so in as many words.
17//!
18//! So a chart drawn from a width this crate computed could be described and
19//! could not be compiled, which for MNW's revenue chart is the difference
20//! between a screen on the seam and the one screen left off it.
21//! `--value` and `--most` are printed with `{}` and reach the markup as
22//! themselves, and `chart_rules` divides them where a browser can.
23//!
24//! It costs nothing and reads better: the DOM carries the two real numbers
25//! rather than a percentage with nothing behind it, which is
26//! [`makeover_layout::Chart`]'s own argument for carrying the pair.
27
28use crate::form::escape_into;
29use crate::{Depth, Emit, class, depth_rule, gated, hover_condition, push_class};
30use makeover_layout::{Bar, Chart, Intent, Tone};
31use std::fmt::Write as _;
32
33/// Every class this module can put in markup.
34///
35/// [`crate::facet::FACET_CLASSES`]' obligation, and the list is what keeps the
36/// scraped vocabulary true if a rule goes away.
37pub const CHART_CLASSES: &[&str] = &[
38    "chart",
39    "chart-bars",
40    "chart-bar-col",
41    "chart-bar",
42    "chart-bar-label",
43];
44
45/// What a bar says when a pointer rests on it, or nothing.
46///
47/// The reading and the note, in that order, joined the way the description did
48/// not: [`Bar::reading`] and [`Bar::note`] arrive worded separately so a
49/// terminal at one line and a tooltip can want different sentences, which is
50/// [`crate::meter::meter_text`]'s split exactly.
51///
52/// The place on the axis is deliberately not in here. It is drawn under the bar
53/// as its own label, so repeating it in the tooltip is the readout arguing with
54/// itself.
55#[must_use]
56pub fn bar_text(bar: &Bar<'_>) -> Option<String> {
57    match (bar.reading, bar.note) {
58        (Some(reading), Some(note)) => Some(format!("{reading} / {note}")),
59        (Some(only), None) | (None, Some(only)) => Some(only.to_string()),
60        (None, None) => None,
61    }
62}
63
64/// A chart as a run of bars, written into a buffer the caller already has.
65///
66/// The bars arrive as an iterator rather than a slice so a caller holding owned
67/// bars can map them through without building a second `Vec`, which is how
68/// `quasi-webview` holds a `Vec<screen::Bar>` and this wants
69/// [`makeover_layout::Bar`].
70///
71/// An empty axis draws its container and no bars. A chart over nothing is
72/// sayable on purpose -- see [`Chart::is_empty`] -- and drawing the frame says
73/// so on screen, where dividing by the axis would put `NaN` in a length.
74pub fn chart_html_into<'a>(
75    chart: &Chart<'_>,
76    bars: impl IntoIterator<Item = Bar<'a>>,
77    opts: &Emit,
78    out: &mut String,
79) {
80    out.push_str("<div class=\"");
81    push_class(out, "chart", opts);
82    out.push('"');
83    // The axis, once, on the container the bars read it from. Stated here and
84    // not per bar because it is one fact about the chart, and a fact repeated
85    // per bar is one the copies can disagree about.
86    let _ = write!(out, " style=\"--most: {}\"", chart.most);
87    if chart.tone != Tone::Neutral {
88        let _ = write!(out, " data-tone=\"{}\"", chart.tone.token());
89    }
90    // `role="img"` only where there is a name for it. The role tells a screen
91    // reader to announce this as one thing instead of reading the bars, and an
92    // unnamed one announces nothing at all -- worse than the group of labelled
93    // readouts the markup already is. So the role and the name arrive together
94    // or neither does, and a description that wants the chart announced says
95    // what the magnitudes are.
96    if let Some(label) = chart.label {
97        out.push_str(" role=\"img\" aria-label=\"");
98        escape_into(label, out);
99        out.push('"');
100    }
101    out.push('>');
102
103    out.push_str("<div class=\"");
104    push_class(out, "chart-bars", opts);
105    out.push_str("\">");
106
107    for bar in bars {
108        bar_html_into(&bar, opts, out);
109    }
110
111    out.push_str("</div></div>");
112}
113
114/// One bar and its label.
115///
116/// Split out because the loop over bars is the loop a compiled template holds,
117/// so what one pass emits is worth being able to read on its own.
118fn bar_html_into(bar: &Bar<'_>, opts: &Emit, out: &mut String) {
119    out.push_str("<div class=\"");
120    push_class(out, "chart-bar-col", opts);
121    out.push('"');
122    if let Some(text) = bar_text(bar) {
123        out.push_str(" data-tooltip=\"");
124        escape_into(&text, out);
125        out.push('"');
126    }
127    out.push('>');
128
129    out.push_str("<div class=\"");
130    push_class(out, "chart-bar", opts);
131    // The magnitude as it was handed over. See the module header for why this
132    // is not a width.
133    let _ = write!(out, "\" style=\"--value: {}\"></div>", bar.value);
134
135    out.push_str("<div class=\"");
136    push_class(out, "chart-bar-label", opts);
137    out.push_str("\">");
138    escape_into(bar.at, out);
139    out.push_str("</div></div>");
140}
141
142/// A chart as a returned string.
143#[must_use]
144pub fn chart_html<'a>(
145    chart: &Chart<'_>,
146    bars: impl IntoIterator<Item = Bar<'a>>,
147    opts: &Emit,
148) -> String {
149    let mut html = String::new();
150    chart_html_into(chart, bars, opts, &mut html);
151    html
152}
153
154/// What a chart looks like.
155///
156/// # What is emitted and what is deferred
157///
158/// `progress_rules`' rule, applied: the tones are emitted and the sizes are
159/// not. This crate names no magnitude -- that is `makeover-geometry`'s -- so
160/// every length here is a custom property with a default an adopter overrides
161/// once, exactly as `--awaiting-bar` is. How tall a chart stands is the app's:
162/// MNW's revenue chart is 200px and a sparkline beside a figure is 24px.
163///
164/// The height of a BAR is the one length that has to be here, and it is not a
165/// magnitude: it is the two numbers the markup carries, divided. That division
166/// is the half of the contract the markup cannot state on its own.
167///
168/// `max(var(--most), 1)` rather than a guard: an axis of zero is sayable, and
169/// dividing by it makes the whole declaration invalid at computed-value time,
170/// which drops the height to `auto` -- in a flex column, a bar of full height.
171/// Clamping the divisor draws every bar at nothing, which is what an empty axis
172/// means.
173fn chart_rules(opts: &Emit) -> String {
174    let chart = class("chart", opts);
175    let bars = class("chart-bars", opts);
176    let col = class("chart-bar-col", opts);
177    let bar = class("chart-bar", opts);
178    let label = class("chart-bar-label", opts);
179
180    let mut css = depth_rule(&chart, Depth::Well);
181
182    let _ = writeln!(
183        css,
184        ".{bars} {{\n    display: flex;\n    align-items: flex-end;\n    \
185         gap: var(--chart-gap, 2px);\n    height: var(--chart-height, 200px);\n}}"
186    );
187    let _ = writeln!(
188        css,
189        ".{col} {{\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    \
190         align-items: center;\n    min-width: 0;\n    position: relative;\n}}"
191    );
192    let _ = writeln!(
193        css,
194        ".{bar} {{\n    width: 100%;\n    background: var(--action);\n    \
195         min-height: var(--chart-bar-least, 2px);\n    \
196         height: calc(var(--value, 0) * 100% / max(var(--most, 1), 1));\n}}"
197    );
198    // A chart can be saying something, the same way a bar can. `progress_rules`
199    // emits the tones for that reason and this follows it.
200    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
201        let _ = writeln!(
202            css,
203            ".{chart}[data-tone=\"{0}\"] .{bar} {{\n    background: var(--{0});\n}}",
204            tone.token()
205        );
206    }
207    let _ = writeln!(
208        css,
209        ".{label} {{\n    color: var(--content-muted);\n    max-width: 100%;\n    \
210         white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}}"
211    );
212
213    // The readout, revealed from the attribute the markup already carries.
214    //
215    // Gated, because it is a hover state and this crate asks
216    // `makeover-touch` whether a hover state exists rather than assuming one.
217    // Keyed on the attribute rather than on a class, so a bar with nothing to
218    // say reveals no empty bubble.
219    //
220    // Centred with `inset-inline: 0` and an auto margin rather than with a
221    // half-width translate: the translate is the idiom and it names a
222    // magnitude, and this does the same job with three keywords.
223    css.push_str(&gated(
224        hover_condition(),
225        &format!(
226            ".{col}[data-tooltip]:hover::before {{\n    content: attr(data-tooltip);\n    \
227             position: absolute;\n    bottom: 100%;\n    inset-inline: 0;\n    \
228             margin-inline: auto;\n    width: max-content;\n    \
229             background: var(--surface-raised);\n    color: var(--content);\n    \
230             border: var(--border);\n    box-shadow: var(--elevation-overlay);\n    \
231             padding: var(--chart-readout-padding, 0.25em 0.5em);\n    \
232             white-space: nowrap;\n    pointer-events: none;\n}}\n"
233        ),
234    ));
235    css
236}
237
238/// The rules, for the stylesheet builder.
239#[must_use]
240pub fn rules(opts: &Emit) -> String {
241    chart_rules(opts)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn axis() -> Chart<'static> {
249        Chart::new(6740).label("revenue")
250    }
251
252    /// The two numbers reach the markup as themselves. This is the whole reason
253    /// the member is shaped the way it is, so it is asserted rather than
254    /// assumed: a width computed here would compile into a template as a
255    /// constant and serve one request's chart to everybody.
256    #[test]
257    fn both_numbers_are_printed_and_neither_is_divided() {
258        let html = chart_html(
259            &axis(),
260            [Bar::at("Mar 3").of(4210).reading("$42.10").note("3 sales")],
261            &Emit::default(),
262        );
263        assert!(html.contains("--most: 6740"), "{html}");
264        assert!(html.contains("--value: 4210"), "{html}");
265        assert!(
266            !html.contains('%'),
267            "a percentage reached the markup: {html}"
268        );
269    }
270
271    /// The role and the name arrive together or neither does. An unnamed
272    /// `role="img"` announces nothing, which is worse than the labelled
273    /// readouts the markup already is.
274    #[test]
275    fn an_unlabelled_chart_claims_no_role() {
276        let named = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
277        assert!(
278            named.contains(r#"role="img" aria-label="revenue""#),
279            "{named}"
280        );
281
282        let bare = chart_html(&Chart::new(10), [Bar::at("Mar 3").of(1)], &Emit::default());
283        assert!(!bare.contains("role="), "{bare}");
284        assert!(!bare.contains("aria-label"), "{bare}");
285    }
286
287    /// A bar says both facts or the one it has, and a bar with neither draws no
288    /// tooltip rather than an empty one.
289    #[test]
290    fn a_readout_is_what_the_bar_was_given() {
291        assert_eq!(
292            bar_text(&Bar::at("a").of(1).reading("$1").note("2 sales")),
293            Some("$1 / 2 sales".to_string())
294        );
295        assert_eq!(
296            bar_text(&Bar::at("a").of(1).reading("$1")),
297            Some("$1".to_string())
298        );
299        assert_eq!(
300            bar_text(&Bar::at("a").of(1).note("2 sales")),
301            Some("2 sales".to_string())
302        );
303        assert_eq!(bar_text(&Bar::at("a").of(1)), None);
304
305        let bare = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
306        assert!(!bare.contains("data-tooltip"), "{bare}");
307    }
308
309    /// Everything a request brings goes through the escaper, in the text and in
310    /// the attribute. A label reaching a chart from a database is why.
311    #[test]
312    fn a_label_and_a_readout_are_escaped() {
313        let html = chart_html(
314            &Chart::new(10).label("a & b"),
315            [Bar::at("<script>").of(5).reading("\"x\"")],
316            &Emit::default(),
317        );
318        assert!(!html.contains("<script>"), "{html}");
319        assert!(html.contains("&lt;script&gt;"), "{html}");
320        assert!(html.contains("a &amp; b"), "{html}");
321        assert!(html.contains("&quot;x&quot;"), "{html}");
322    }
323
324    /// An axis of zero draws its frame and its bars, and the stylesheet is what
325    /// keeps them at nothing. Drawing no frame would be a screen that says
326    /// nothing where it has nothing, which is the empty state's job and not
327    /// this one's.
328    #[test]
329    fn an_empty_axis_draws_rather_than_dividing() {
330        let html = chart_html(&Chart::new(0), [Bar::at("Mar 3").of(0)], &Emit::default());
331        assert!(html.contains("--most: 0"), "{html}");
332        assert!(html.contains("--value: 0"), "{html}");
333    }
334
335    /// The streamed form and the returned one are the same bytes, which is the
336    /// obligation every other emitter here carries.
337    #[test]
338    fn the_streamed_form_is_the_returned_one() {
339        let opts = Emit {
340            class_prefix: "mk-",
341            ..Emit::default()
342        };
343        let bars = [Bar::at("Mar 3").of(4210).reading("$42.10")];
344        let mut streamed = String::new();
345        chart_html_into(&axis(), bars, &opts, &mut streamed);
346        assert_eq!(streamed, chart_html(&axis(), bars, &opts));
347    }
348
349    /// Every class the emitter can write carries a rule, which is what
350    /// `CHART_CLASSES` exists to keep true.
351    #[test]
352    fn every_class_this_module_writes_has_a_rule() {
353        let css = chart_rules(&Emit::default());
354        for name in CHART_CLASSES {
355            assert!(css.contains(&format!(".{name}")), "{name} has no rule");
356        }
357    }
358}