Skip to main content

makeover_webview/
chart.rs

1//! The stylesheet half of a chart: a run of magnitudes against one axis, drawn
2//! as bars.
3//!
4//! The markup is drawn by the quasi hosts (`quasi-webview` for this renderer),
5//! which own the vocabulary member. This module owns the classes that markup
6//! carries and the rules that draw them, so the generated stylesheet and the
7//! emitted markup cannot disagree about a name.
8//!
9//! [`meter`](crate::meter)'s neighbour and its opposite in one respect: a meter
10//! draws one proportion, and a chart draws a series whose markup computes
11//! nothing. Both numbers reach the markup as they were given, and the division
12//! happens in CSS.
13//!
14//! # Why the arithmetic is in the stylesheet
15//!
16//! `quasi-declare` derives a compiled template by rendering a screen with
17//! stand-in values and keeping the bytes that no request reaches; a number the
18//! description HANDS a renderer is found in that render and becomes a hole, and
19//! a number the renderer WORKS OUT from two of them is printed as its
20//! arithmetic, leaves no stand-in to find, and is baked into the template as a
21//! constant. `quasi_router::stage::number_at` says so in as many words.
22//!
23//! So `--value` and `--most` reach the markup as themselves, and `chart_rules`
24//! divides them where a browser can. Both ride in
25//! [`VARS_ATTR`](crate::VARS_ATTR) rather than in `style`, so a page whose
26//! policy refuses style attributes still draws its bars.
27
28use crate::{Depth, Emit, class, depth_rule, gated, hover_condition};
29use makeover_layout::{Intent as _, Tone};
30use std::fmt::Write as _;
31
32/// Every class a chart's markup can carry.
33///
34/// [`crate::facet::FACET_CLASSES`]' obligation, and the list is what keeps the
35/// scraped vocabulary true if a rule goes away.
36pub const CHART_CLASSES: &[&str] = &[
37    "chart",
38    "chart-bars",
39    "chart-bar-col",
40    "chart-bar",
41    "chart-bar-label",
42];
43
44/// What a chart looks like.
45///
46/// # What is emitted and what is deferred
47///
48/// `progress_rules`' rule, applied: the tones are emitted and the sizes are
49/// not. This crate names no magnitude, that is `makeover-geometry`'s, so
50/// every length here is a custom property with a default an adopter overrides
51/// once, exactly as `--awaiting-bar` is. How tall a chart stands is the app's:
52/// MNW's revenue chart is 200px and a sparkline beside a figure is 24px.
53///
54/// The height of a BAR is the one length that has to be here, and it is not a
55/// magnitude: it is the two numbers the markup carries, divided. That division
56/// is the half of the contract the markup cannot state on its own.
57///
58/// The column stretches to the chart's height and stacks from the bottom. It
59/// has to: a percentage height resolves only against a definite one, and a
60/// column left at `align-items: flex-end`'s content height gave every bar
61/// `auto`, which is the 2px floor and nothing more.
62///
63/// `max(var(--most), 1)` rather than a guard: an axis of zero is sayable, and
64/// dividing by it would make the whole declaration invalid at computed-value
65/// time. Clamping the divisor draws every bar at nothing, which is what an
66/// empty axis means. `min(…, 100%)` is the other end: a value past the axis
67/// fills its column and stops, as the vocabulary's own fraction and the other
68/// two renderers already clamp it, rather than pushing its label out of the chart.
69///
70/// The column holds the label too, one line by `nowrap`, so the bar's share is
71/// of the column less `1lh`: measured against the whole column a full bar and
72/// its label overflowed it, and flex shrank the tallest bars out of proportion
73/// with the rest. The first `height` is the same without the label's line, for
74/// an engine that does not know `lh` and drops the second.
75fn chart_rules(opts: &Emit) -> String {
76    let chart = class("chart", opts);
77    let bars = class("chart-bars", opts);
78    let col = class("chart-bar-col", opts);
79    let bar = class("chart-bar", opts);
80    let label = class("chart-bar-label", opts);
81
82    let mut css = depth_rule(&chart, Depth::Well);
83
84    let _ = writeln!(
85        css,
86        ".{bars} {{\n    display: flex;\n    align-items: flex-end;\n    \
87         gap: var(--chart-gap, 2px);\n    height: var(--chart-height, 200px);\n}}"
88    );
89    let _ = writeln!(
90        css,
91        ".{col} {{\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    \
92         justify-content: flex-end;\n    align-self: stretch;\n    \
93         align-items: center;\n    min-width: 0;\n    position: relative;\n}}"
94    );
95    let _ = writeln!(
96        css,
97        ".{bar} {{\n    width: 100%;\n    background: var(--action);\n    \
98         min-height: var(--chart-bar-least, 2px);\n    \
99         height: min(calc(var(--value, 0) * 100% / max(var(--most, 1), 1)), 100%);\n    \
100         height: min(calc(var(--value, 0) * (100% - 1lh) / max(var(--most, 1), 1)), 100% - 1lh);\n}}"
101    );
102    // A chart can be saying something, the same way a bar can. `progress_rules`
103    // emits the tones for that reason and this follows it.
104    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
105        let _ = writeln!(
106            css,
107            ".{chart}[data-tone=\"{0}\"] .{bar} {{\n    background: var(--{0});\n}}",
108            tone.token()
109        );
110    }
111    let _ = writeln!(
112        css,
113        ".{label} {{\n    color: var(--content-muted);\n    max-width: 100%;\n    \
114         white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}}"
115    );
116
117    // The readout, revealed from the attribute the markup already carries.
118    //
119    // Gated, because it is a hover state and this crate asks
120    // `Affordance` whether a hover state exists rather than assuming one.
121    // Keyed on the attribute rather than on a class, so a bar with nothing to
122    // say reveals no empty bubble.
123    //
124    // Centred with `inset-inline: 0` and an auto margin rather than with a
125    // half-width translate: the translate is the idiom and it names a
126    // magnitude, and this does the same job with three keywords.
127    css.push_str(&gated(
128        hover_condition(),
129        &format!(
130            ".{col}[data-tooltip]:hover::before {{\n    content: attr(data-tooltip);\n    \
131             position: absolute;\n    bottom: 100%;\n    inset-inline: 0;\n    \
132             margin-inline: auto;\n    width: max-content;\n    \
133             background: var(--surface-raised);\n    color: var(--content);\n    \
134             border: var(--border);\n    box-shadow: var(--elevation-overlay);\n    \
135             padding: var(--chart-readout-padding, 0.25em 0.5em);\n    \
136             white-space: nowrap;\n    pointer-events: none;\n}}\n"
137        ),
138    ));
139    css
140}
141
142/// The rules, for the stylesheet builder.
143#[must_use]
144pub fn rules(opts: &Emit) -> String {
145    chart_rules(opts)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    /// Every class the markup can carry has a rule, which is what
153    /// `CHART_CLASSES` exists to keep true.
154    #[test]
155    fn every_class_this_module_names_has_a_rule() {
156        let css = chart_rules(&Emit::default());
157        for name in CHART_CLASSES {
158            assert!(css.contains(&format!(".{name}")), "{name} has no rule");
159        }
160    }
161}