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