//! A run of magnitudes against one axis, rendered as bars. //! //! [`meter`](crate::meter)'s neighbour and its opposite in one respect: a meter //! draws one proportion and computes the width from the pair it is handed, and //! this draws a series and computes nothing. Both numbers reach the markup as //! they were given, and the division happens in CSS. //! //! # Why the arithmetic is in the stylesheet //! //! Not taste, and not an optimisation. `quasi-declare` derives a compiled //! template by rendering a screen with stand-in values and keeping the bytes //! that no request reaches; a number the description HANDS a renderer is found //! in that render and becomes a hole, and a number the renderer WORKS OUT from //! two of them is printed as its arithmetic, leaves no stand-in to find, and is //! baked into the template as a constant. `quasi_router::stage::number_at` says //! so in as many words. //! //! So a chart drawn from a width this crate computed could be described and //! could not be compiled, which for MNW's revenue chart is the difference //! between a screen on the seam and the one screen left off it. //! `--value` and `--most` are printed with `{}` and reach the markup as //! themselves, and `chart_rules` divides them where a browser can. //! //! It costs nothing and reads better: the DOM carries the two real numbers //! rather than a percentage with nothing behind it, which is //! [`makeover_layout::Chart`]'s own argument for carrying the pair. use crate::form::escape_into; use crate::{Depth, Emit, class, depth_rule, gated, hover_condition, push_class}; use makeover_layout::{Bar, Chart, Intent, Tone}; use std::fmt::Write as _; /// Every class this module can put in markup. /// /// [`crate::facet::FACET_CLASSES`]' obligation, and the list is what keeps the /// scraped vocabulary true if a rule goes away. pub const CHART_CLASSES: &[&str] = &[ "chart", "chart-bars", "chart-bar-col", "chart-bar", "chart-bar-label", ]; /// What a bar says when a pointer rests on it, or nothing. /// /// The reading and the note, in that order, joined the way the description did /// not: [`Bar::reading`] and [`Bar::note`] arrive worded separately so a /// terminal at one line and a tooltip can want different sentences, which is /// [`crate::meter::meter_text`]'s split exactly. /// /// The place on the axis is deliberately not in here. It is drawn under the bar /// as its own label, so repeating it in the tooltip is the readout arguing with /// itself. #[must_use] pub fn bar_text(bar: &Bar<'_>) -> Option { match (bar.reading, bar.note) { (Some(reading), Some(note)) => Some(format!("{reading} / {note}")), (Some(only), None) | (None, Some(only)) => Some(only.to_string()), (None, None) => None, } } /// A chart as a run of bars, written into a buffer the caller already has. /// /// The bars arrive as an iterator rather than a slice so a caller holding owned /// bars can map them through without building a second `Vec`, which is how /// `quasi-webview` holds a `Vec` and this wants /// [`makeover_layout::Bar`]. /// /// An empty axis draws its container and no bars. A chart over nothing is /// sayable on purpose -- see [`Chart::is_empty`] -- and drawing the frame says /// so on screen, where dividing by the axis would put `NaN` in a length. pub fn chart_html_into<'a>( chart: &Chart<'_>, bars: impl IntoIterator>, opts: &Emit, out: &mut String, ) { emit_chart(chart, bars, opts, out, None); } /// A chart, saying where each bar landed. /// /// Byte-identical to [`chart_html_into`], and it appends one entry to `placed` /// per bar, in order: the offsets in `out` between which that bar's whole /// column was written. See [`crate::list::cells_html_placed`], which exists for /// the same reason and says it at length: a caller compiling this markup into a /// template has to know which bytes one bar produced, and the writer is the /// only source for that which cannot be wrong. pub fn chart_html_placed<'a>( chart: &Chart<'_>, bars: impl IntoIterator>, opts: &Emit, out: &mut String, placed: &mut Vec>, ) { emit_chart(chart, bars, opts, out, Some(placed)); } fn emit_chart<'a>( chart: &Chart<'_>, bars: impl IntoIterator>, opts: &Emit, out: &mut String, mut placed: Option<&mut Vec>>, ) { out.push_str("
'); out.push_str("
"); for bar in bars { let at = out.len(); bar_html_into(&bar, opts, out); if let Some(placed) = placed.as_deref_mut() { placed.push(at..out.len()); } } out.push_str("
"); } /// One bar and its label. /// /// Split out because the loop over bars is the loop a compiled template holds, /// so what one pass emits is worth being able to read on its own. fn bar_html_into(bar: &Bar<'_>, opts: &Emit, out: &mut String) { out.push_str("
'); out.push_str("
", bar.value); out.push_str("
"); escape_into(bar.at, out); out.push_str("
"); } /// A chart as a returned string. #[must_use] pub fn chart_html<'a>( chart: &Chart<'_>, bars: impl IntoIterator>, opts: &Emit, ) -> String { let mut html = String::new(); chart_html_into(chart, bars, opts, &mut html); html } /// What a chart looks like. /// /// # What is emitted and what is deferred /// /// `progress_rules`' rule, applied: the tones are emitted and the sizes are /// not. This crate names no magnitude -- that is `makeover-geometry`'s -- so /// every length here is a custom property with a default an adopter overrides /// once, exactly as `--awaiting-bar` is. How tall a chart stands is the app's: /// MNW's revenue chart is 200px and a sparkline beside a figure is 24px. /// /// The height of a BAR is the one length that has to be here, and it is not a /// magnitude: it is the two numbers the markup carries, divided. That division /// is the half of the contract the markup cannot state on its own. /// /// `max(var(--most), 1)` rather than a guard: an axis of zero is sayable, and /// dividing by it makes the whole declaration invalid at computed-value time, /// which drops the height to `auto` -- in a flex column, a bar of full height. /// Clamping the divisor draws every bar at nothing, which is what an empty axis /// means. fn chart_rules(opts: &Emit) -> String { let chart = class("chart", opts); let bars = class("chart-bars", opts); let col = class("chart-bar-col", opts); let bar = class("chart-bar", opts); let label = class("chart-bar-label", opts); let mut css = depth_rule(&chart, Depth::Well); let _ = writeln!( css, ".{bars} {{\n display: flex;\n align-items: flex-end;\n \ gap: var(--chart-gap, 2px);\n height: var(--chart-height, 200px);\n}}" ); let _ = writeln!( css, ".{col} {{\n flex: 1;\n display: flex;\n flex-direction: column;\n \ align-items: center;\n min-width: 0;\n position: relative;\n}}" ); let _ = writeln!( css, ".{bar} {{\n width: 100%;\n background: var(--action);\n \ min-height: var(--chart-bar-least, 2px);\n \ height: calc(var(--value, 0) * 100% / max(var(--most, 1), 1));\n}}" ); // A chart can be saying something, the same way a bar can. `progress_rules` // emits the tones for that reason and this follows it. for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] { let _ = writeln!( css, ".{chart}[data-tone=\"{0}\"] .{bar} {{\n background: var(--{0});\n}}", tone.token() ); } let _ = writeln!( css, ".{label} {{\n color: var(--content-muted);\n max-width: 100%;\n \ white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}}" ); // The readout, revealed from the attribute the markup already carries. // // Gated, because it is a hover state and this crate asks // `makeover-touch` whether a hover state exists rather than assuming one. // Keyed on the attribute rather than on a class, so a bar with nothing to // say reveals no empty bubble. // // Centred with `inset-inline: 0` and an auto margin rather than with a // half-width translate: the translate is the idiom and it names a // magnitude, and this does the same job with three keywords. css.push_str(&gated( hover_condition(), &format!( ".{col}[data-tooltip]:hover::before {{\n content: attr(data-tooltip);\n \ position: absolute;\n bottom: 100%;\n inset-inline: 0;\n \ margin-inline: auto;\n width: max-content;\n \ background: var(--surface-raised);\n color: var(--content);\n \ border: var(--border);\n box-shadow: var(--elevation-overlay);\n \ padding: var(--chart-readout-padding, 0.25em 0.5em);\n \ white-space: nowrap;\n pointer-events: none;\n}}\n" ), )); css } /// The rules, for the stylesheet builder. #[must_use] pub fn rules(opts: &Emit) -> String { chart_rules(opts) } #[cfg(test)] mod tests { use super::*; fn axis() -> Chart<'static> { Chart::new(6740).label("revenue") } /// The two numbers reach the markup as themselves. This is the whole reason /// the member is shaped the way it is, so it is asserted rather than /// assumed: a width computed here would compile into a template as a /// constant and serve one request's chart to everybody. #[test] fn both_numbers_are_printed_and_neither_is_divided() { let html = chart_html( &axis(), [Bar::at("Mar 3").of(4210).reading("$42.10").note("3 sales")], &Emit::default(), ); assert!(html.contains("--most: 6740"), "{html}"); assert!(html.contains("--value: 4210"), "{html}"); assert!( !html.contains('%'), "a percentage reached the markup: {html}" ); } /// The role and the name arrive together or neither does. An unnamed /// `role="img"` announces nothing, which is worse than the labelled /// readouts the markup already is. #[test] fn an_unlabelled_chart_claims_no_role() { let named = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default()); assert!( named.contains(r#"role="img" aria-label="revenue""#), "{named}" ); let bare = chart_html(&Chart::new(10), [Bar::at("Mar 3").of(1)], &Emit::default()); assert!(!bare.contains("role="), "{bare}"); assert!(!bare.contains("aria-label"), "{bare}"); } /// A bar says both facts or the one it has, and a bar with neither draws no /// tooltip rather than an empty one. #[test] fn a_readout_is_what_the_bar_was_given() { assert_eq!( bar_text(&Bar::at("a").of(1).reading("$1").note("2 sales")), Some("$1 / 2 sales".to_string()) ); assert_eq!( bar_text(&Bar::at("a").of(1).reading("$1")), Some("$1".to_string()) ); assert_eq!( bar_text(&Bar::at("a").of(1).note("2 sales")), Some("2 sales".to_string()) ); assert_eq!(bar_text(&Bar::at("a").of(1)), None); let bare = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default()); assert!(!bare.contains("data-tooltip"), "{bare}"); } /// Everything a request brings goes through the escaper, in the text and in /// the attribute. A label reaching a chart from a database is why. #[test] fn a_label_and_a_readout_are_escaped() { let html = chart_html( &Chart::new(10).label("a & b"), [Bar::at("