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//!
28//! Both ride in [`VARS_ATTR`] rather than in `style`, so a page whose policy
29//! refuses style attributes still draws its bars.
30
31use crate::form::escape_into;
32use crate::{Depth, Emit, VARS_ATTR, class, depth_rule, gated, hover_condition, push_class};
33use makeover_layout::{Bar, Chart, Intent, Tone};
34use std::fmt::Write as _;
35
36/// Every class this module can put in markup.
37///
38/// [`crate::facet::FACET_CLASSES`]' obligation, and the list is what keeps the
39/// scraped vocabulary true if a rule goes away.
40pub const CHART_CLASSES: &[&str] = &[
41    "chart",
42    "chart-bars",
43    "chart-bar-col",
44    "chart-bar",
45    "chart-bar-label",
46];
47
48/// What a bar says when a pointer rests on it, or nothing.
49///
50/// The reading and the note, in that order, joined the way the description did
51/// not: [`Bar::reading`] and [`Bar::note`] arrive worded separately so a
52/// terminal at one line and a tooltip can want different sentences, which is
53/// [`crate::meter::meter_text`]'s split exactly.
54///
55/// The place on the axis is deliberately not in here. It is drawn under the bar
56/// as its own label, so repeating it in the tooltip is the readout arguing with
57/// itself.
58#[must_use]
59pub fn bar_text(bar: &Bar<'_>) -> Option<String> {
60    match (bar.reading, bar.note) {
61        (Some(reading), Some(note)) => Some(format!("{reading} / {note}")),
62        (Some(only), None) | (None, Some(only)) => Some(only.to_string()),
63        (None, None) => None,
64    }
65}
66
67/// A chart as a run of bars, written into a buffer the caller already has.
68///
69/// The bars arrive as an iterator rather than a slice so a caller holding owned
70/// bars can map them through without building a second `Vec`, which is how
71/// `quasi-webview` holds a `Vec<screen::Bar>` and this wants
72/// [`makeover_layout::Bar`].
73///
74/// An empty axis draws its container and no bars. A chart over nothing is
75/// sayable on purpose, see [`Chart::is_empty`], and drawing the frame says
76/// so on screen, where dividing by the axis would put `NaN` in a length.
77pub fn chart_html_into<'a>(
78    chart: &Chart<'_>,
79    bars: impl IntoIterator<Item = Bar<'a>>,
80    opts: &Emit,
81    out: &mut String,
82) {
83    emit_chart(chart, bars, opts, out, None);
84}
85
86/// A chart, saying where each bar landed.
87///
88/// Byte-identical to [`chart_html_into`], and it appends one entry to `placed`
89/// per bar, in order: the offsets in `out` between which that bar's whole
90/// column was written. See [`crate::list::cells_html_placed`], which exists for
91/// the same reason and says it at length: a caller compiling this markup into a
92/// template has to know which bytes one bar produced, and the writer is the
93/// only source for that which cannot be wrong.
94pub fn chart_html_placed<'a>(
95    chart: &Chart<'_>,
96    bars: impl IntoIterator<Item = Bar<'a>>,
97    opts: &Emit,
98    out: &mut String,
99    placed: &mut Vec<core::ops::Range<usize>>,
100) {
101    emit_chart(chart, bars, opts, out, Some(placed));
102}
103
104fn emit_chart<'a>(
105    chart: &Chart<'_>,
106    bars: impl IntoIterator<Item = Bar<'a>>,
107    opts: &Emit,
108    out: &mut String,
109    mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
110) {
111    out.push_str("<div class=\"");
112    push_class(out, "chart", opts);
113    out.push('"');
114    // The axis, once, on the container the bars read it from. Stated here and
115    // not per bar because it is one fact about the chart, and a fact repeated
116    // per bar is one the copies can disagree about.
117    let _ = write!(out, " {VARS_ATTR}=\"--most: {}\"", chart.most);
118    if chart.tone != Tone::Neutral {
119        let _ = write!(out, " data-tone=\"{}\"", chart.tone.token());
120    }
121    // `role="img"` only where there is a name for it. The role tells a screen
122    // reader to announce this as one thing instead of reading the bars, and an
123    // unnamed one announces nothing at all, worse than the group of labelled
124    // readouts the markup already is. So the role and the name arrive together
125    // or neither does, and a description that wants the chart announced says
126    // what the magnitudes are.
127    if let Some(label) = chart.label {
128        out.push_str(" role=\"img\" aria-label=\"");
129        escape_into(label, out);
130        out.push('"');
131    }
132    out.push('>');
133
134    out.push_str("<div class=\"");
135    push_class(out, "chart-bars", opts);
136    out.push_str("\">");
137
138    // An axis running to zero is not an axis, and a bar read against it
139    // divides by zero in the stylesheet. The container and the axis fact are
140    // still drawn, the honest report on a chart over nothing, but no bar is:
141    // this is the empty-axis path the `chart_html_into` doc already promised.
142    if !chart.is_empty() {
143        for bar in bars {
144            let at = out.len();
145            bar_html_into(&bar, opts, out);
146            if let Some(placed) = placed.as_deref_mut() {
147                placed.push(at..out.len());
148            }
149        }
150    }
151
152    out.push_str("</div></div>");
153}
154
155/// One bar and its label.
156///
157/// Split out because the loop over bars is the loop a compiled template holds,
158/// so what one pass emits is worth being able to read on its own.
159fn bar_html_into(bar: &Bar<'_>, opts: &Emit, out: &mut String) {
160    out.push_str("<div class=\"");
161    push_class(out, "chart-bar-col", opts);
162    out.push('"');
163    if let Some(text) = bar_text(bar) {
164        out.push_str(" data-tooltip=\"");
165        escape_into(&text, out);
166        out.push('"');
167    }
168    out.push('>');
169
170    out.push_str("<div class=\"");
171    push_class(out, "chart-bar", opts);
172    // The magnitude as it was handed over. See the module header for why this
173    // is not a width.
174    let _ = write!(out, "\" {VARS_ATTR}=\"--value: {}\"></div>", bar.value);
175
176    out.push_str("<div class=\"");
177    push_class(out, "chart-bar-label", opts);
178    out.push_str("\">");
179    escape_into(bar.at, out);
180    out.push_str("</div></div>");
181}
182
183/// A chart as a returned string.
184#[must_use]
185pub fn chart_html<'a>(
186    chart: &Chart<'_>,
187    bars: impl IntoIterator<Item = Bar<'a>>,
188    opts: &Emit,
189) -> String {
190    let mut html = String::new();
191    chart_html_into(chart, bars, opts, &mut html);
192    html
193}
194
195/// What a chart looks like.
196///
197/// # What is emitted and what is deferred
198///
199/// `progress_rules`' rule, applied: the tones are emitted and the sizes are
200/// not. This crate names no magnitude, that is `makeover-geometry`'s, so
201/// every length here is a custom property with a default an adopter overrides
202/// once, exactly as `--awaiting-bar` is. How tall a chart stands is the app's:
203/// MNW's revenue chart is 200px and a sparkline beside a figure is 24px.
204///
205/// The height of a BAR is the one length that has to be here, and it is not a
206/// magnitude: it is the two numbers the markup carries, divided. That division
207/// is the half of the contract the markup cannot state on its own.
208///
209/// The column stretches to the chart's height and stacks from the bottom. It
210/// has to: a percentage height resolves only against a definite one, and a
211/// column left at `align-items: flex-end`'s content height gave every bar
212/// `auto`, which is the 2px floor and nothing more.
213///
214/// `max(var(--most), 1)` rather than a guard: an axis of zero is sayable, and
215/// dividing by it would make the whole declaration invalid at computed-value
216/// time. Clamping the divisor draws every bar at nothing, which is what an
217/// empty axis means. `min(…, 100%)` is the other end: a value past the axis
218/// fills its column and stops, as `Bar::fraction` and the other two renderers
219/// already clamp it, rather than pushing its label out of the chart.
220///
221/// The column holds the label too, one line by `nowrap`, so the bar's share is
222/// of the column less `1lh`: measured against the whole column a full bar and
223/// its label overflowed it, and flex shrank the tallest bars out of proportion
224/// with the rest. The first `height` is the same without the label's line, for
225/// an engine that does not know `lh` and drops the second.
226fn chart_rules(opts: &Emit) -> String {
227    let chart = class("chart", opts);
228    let bars = class("chart-bars", opts);
229    let col = class("chart-bar-col", opts);
230    let bar = class("chart-bar", opts);
231    let label = class("chart-bar-label", opts);
232
233    let mut css = depth_rule(&chart, Depth::Well);
234
235    let _ = writeln!(
236        css,
237        ".{bars} {{\n    display: flex;\n    align-items: flex-end;\n    \
238         gap: var(--chart-gap, 2px);\n    height: var(--chart-height, 200px);\n}}"
239    );
240    let _ = writeln!(
241        css,
242        ".{col} {{\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    \
243         justify-content: flex-end;\n    align-self: stretch;\n    \
244         align-items: center;\n    min-width: 0;\n    position: relative;\n}}"
245    );
246    let _ = writeln!(
247        css,
248        ".{bar} {{\n    width: 100%;\n    background: var(--action);\n    \
249         min-height: var(--chart-bar-least, 2px);\n    \
250         height: min(calc(var(--value, 0) * 100% / max(var(--most, 1), 1)), 100%);\n    \
251         height: min(calc(var(--value, 0) * (100% - 1lh) / max(var(--most, 1), 1)), 100% - 1lh);\n}}"
252    );
253    // A chart can be saying something, the same way a bar can. `progress_rules`
254    // emits the tones for that reason and this follows it.
255    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
256        let _ = writeln!(
257            css,
258            ".{chart}[data-tone=\"{0}\"] .{bar} {{\n    background: var(--{0});\n}}",
259            tone.token()
260        );
261    }
262    let _ = writeln!(
263        css,
264        ".{label} {{\n    color: var(--content-muted);\n    max-width: 100%;\n    \
265         white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}}"
266    );
267
268    // The readout, revealed from the attribute the markup already carries.
269    //
270    // Gated, because it is a hover state and this crate asks
271    // `makeover-touch` whether a hover state exists rather than assuming one.
272    // Keyed on the attribute rather than on a class, so a bar with nothing to
273    // say reveals no empty bubble.
274    //
275    // Centred with `inset-inline: 0` and an auto margin rather than with a
276    // half-width translate: the translate is the idiom and it names a
277    // magnitude, and this does the same job with three keywords.
278    css.push_str(&gated(
279        hover_condition(),
280        &format!(
281            ".{col}[data-tooltip]:hover::before {{\n    content: attr(data-tooltip);\n    \
282             position: absolute;\n    bottom: 100%;\n    inset-inline: 0;\n    \
283             margin-inline: auto;\n    width: max-content;\n    \
284             background: var(--surface-raised);\n    color: var(--content);\n    \
285             border: var(--border);\n    box-shadow: var(--elevation-overlay);\n    \
286             padding: var(--chart-readout-padding, 0.25em 0.5em);\n    \
287             white-space: nowrap;\n    pointer-events: none;\n}}\n"
288        ),
289    ));
290    css
291}
292
293/// The rules, for the stylesheet builder.
294#[must_use]
295pub fn rules(opts: &Emit) -> String {
296    chart_rules(opts)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn axis() -> Chart<'static> {
304        Chart::new(6740).label("revenue")
305    }
306
307    /// The two numbers reach the markup as themselves. This is the whole reason
308    /// the member is shaped the way it is, so it is asserted rather than
309    /// assumed: a width computed here would compile into a template as a
310    /// constant and serve one request's chart to everybody.
311    #[test]
312    fn both_numbers_are_printed_and_neither_is_divided() {
313        let html = chart_html(
314            &axis(),
315            [Bar::at("Mar 3").of(4210).reading("$42.10").note("3 sales")],
316            &Emit::default(),
317        );
318        assert!(html.contains("--most: 6740"), "{html}");
319        assert!(html.contains("--value: 4210"), "{html}");
320        assert!(
321            !html.contains('%'),
322            "a percentage reached the markup: {html}"
323        );
324    }
325
326    /// The role and the name arrive together or neither does. An unnamed
327    /// `role="img"` announces nothing, which is worse than the labelled
328    /// readouts the markup already is.
329    #[test]
330    fn an_unlabelled_chart_claims_no_role() {
331        let named = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
332        assert!(
333            named.contains(r#"role="img" aria-label="revenue""#),
334            "{named}"
335        );
336
337        let bare = chart_html(&Chart::new(10), [Bar::at("Mar 3").of(1)], &Emit::default());
338        assert!(!bare.contains("role="), "{bare}");
339        assert!(!bare.contains("aria-label"), "{bare}");
340    }
341
342    /// A bar says both facts or the one it has, and a bar with neither draws no
343    /// tooltip rather than an empty one.
344    #[test]
345    fn a_readout_is_what_the_bar_was_given() {
346        assert_eq!(
347            bar_text(&Bar::at("a").of(1).reading("$1").note("2 sales")),
348            Some("$1 / 2 sales".to_string())
349        );
350        assert_eq!(
351            bar_text(&Bar::at("a").of(1).reading("$1")),
352            Some("$1".to_string())
353        );
354        assert_eq!(
355            bar_text(&Bar::at("a").of(1).note("2 sales")),
356            Some("2 sales".to_string())
357        );
358        assert_eq!(bar_text(&Bar::at("a").of(1)), None);
359
360        let bare = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
361        assert!(!bare.contains("data-tooltip"), "{bare}");
362    }
363
364    /// Everything a request brings goes through the escaper, in the text and in
365    /// the attribute. A label reaching a chart from a database is why.
366    #[test]
367    fn a_label_and_a_readout_are_escaped() {
368        let html = chart_html(
369            &Chart::new(10).label("a & b"),
370            [Bar::at("<script>").of(5).reading("\"x\"")],
371            &Emit::default(),
372        );
373        assert!(!html.contains("<script>"), "{html}");
374        assert!(html.contains("&lt;script&gt;"), "{html}");
375        assert!(html.contains("a &amp; b"), "{html}");
376        assert!(html.contains("&quot;x&quot;"), "{html}");
377    }
378
379    /// No style attribute, because a `style-src` without `'unsafe-inline'`
380    /// refuses every one and a chart under it drew flat.
381    #[test]
382    fn the_numbers_ride_in_the_vars_attribute_and_not_in_style() {
383        let html = chart_html(&axis(), [Bar::at("Mar 3").of(4210)], &Emit::default());
384        assert!(!html.contains("style="), "{html}");
385        assert!(html.contains(r#"data-vars="--most: 6740""#), "{html}");
386        assert!(html.contains(r#"data-vars="--value: 4210""#), "{html}");
387    }
388
389    /// An axis of zero draws its frame and no bars. Drawing no frame would be a
390    /// screen that says nothing where it has nothing, which is the empty state's
391    /// job and not this one's; drawing a bar against a zero axis is a bar with
392    /// nothing to read against, and the `--most` fact still names the axis.
393    #[test]
394    fn an_empty_axis_draws_rather_than_dividing() {
395        let html = chart_html(&Chart::new(0), [Bar::at("Mar 3").of(0)], &Emit::default());
396        assert!(html.contains("--most: 0"), "{html}");
397        assert!(
398            !html.contains("--value:"),
399            "a bar was emitted against a zero axis: {html}"
400        );
401        assert!(
402            !html.contains("chart-bar-col"),
403            "a bar column was emitted against a zero axis: {html}"
404        );
405    }
406
407    /// The streamed form and the returned one are the same bytes, which is the
408    /// obligation every other emitter here carries.
409    #[test]
410    fn the_streamed_form_is_the_returned_one() {
411        let opts = Emit {
412            class_prefix: "mk-",
413            ..Emit::default()
414        };
415        let bars = [Bar::at("Mar 3").of(4210).reading("$42.10")];
416        let mut streamed = String::new();
417        chart_html_into(&axis(), bars, &opts, &mut streamed);
418        assert_eq!(streamed, chart_html(&axis(), bars, &opts));
419    }
420
421    /// Every class the emitter can write carries a rule, which is what
422    /// `CHART_CLASSES` exists to keep true.
423    #[test]
424    fn every_class_this_module_writes_has_a_rule() {
425        let css = chart_rules(&Emit::default());
426        for name in CHART_CLASSES {
427            assert!(css.contains(&format!(".{name}")), "{name} has no rule");
428        }
429    }
430}