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/// The value is clamped to the axis, `min(var(--value), var(--most))`, so an
64/// axis of zero draws every bar at nothing, which is what an empty axis means,
65/// and a value past the axis fills its column and stops rather than pushing its
66/// label out of the chart. `max(var(--most), 1)` rather than a bare
67/// `var(--most)` keeps the divisor nonzero, since dividing by it would make the
68/// whole declaration invalid at computed-value time and drop the height to
69/// `auto`; the vocabulary's own fraction and the other two renderers make the
70/// same clamp.
71///
72/// The column holds the label too, one line by `nowrap`, so the bar's share is
73/// of the column less `1lh`: measured against the whole column a full bar and
74/// its label overflowed it, and flex shrank the tallest bars out of proportion
75/// with the rest. The first `height` is the same without the label's line, for
76/// an engine that does not know `lh` and drops the second.
77fn chart_rules(opts: &Emit) -> String {
78 let chart = class("chart", opts);
79 let bars = class("chart-bars", opts);
80 let col = class("chart-bar-col", opts);
81 let bar = class("chart-bar", opts);
82 let label = class("chart-bar-label", opts);
83
84 let mut css = depth_rule(&chart, Depth::Well);
85
86 let _ = writeln!(
87 css,
88 ".{bars} {{\n display: flex;\n align-items: flex-end;\n \
89 gap: var(--chart-gap, 2px);\n height: var(--chart-height, 200px);\n}}"
90 );
91 let _ = writeln!(
92 css,
93 ".{col} {{\n flex: 1;\n display: flex;\n flex-direction: column;\n \
94 justify-content: flex-end;\n align-self: stretch;\n \
95 align-items: center;\n min-width: 0;\n position: relative;\n}}"
96 );
97 let _ = writeln!(
98 css,
99 ".{bar} {{\n width: 100%;\n background: var(--action);\n \
100 min-height: var(--chart-bar-least, 2px);\n \
101 height: min(calc(min(var(--value, 0), var(--most, 0)) * 100% / max(var(--most, 1), 1)), 100%);\n \
102 height: min(calc(min(var(--value, 0), var(--most, 0)) * (100% - 1lh) / max(var(--most, 1), 1)), 100% - 1lh);\n}}"
103 );
104 // A chart can be saying something, the same way a bar can. `progress_rules`
105 // emits the tones for that reason and this follows it.
106 for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
107 let _ = writeln!(
108 css,
109 ".{chart}[data-tone=\"{0}\"] .{bar} {{\n background: var(--{0});\n}}",
110 tone.token()
111 );
112 }
113 let _ = writeln!(
114 css,
115 ".{label} {{\n color: var(--content-muted);\n max-width: 100%;\n \
116 white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}}"
117 );
118 // A label every bar is a label no bar can hold. Thirty days drew thirty
119 // `Sep …` stubs, each cut to its column, and not one date read. Past a
120 // dozen bars only every seventh is labelled, a week apart on a daily
121 // axis, and that label runs on over the six blank neighbours after it
122 // rather than being cut to its own column. Past a hundred and twenty,
123 // every thirtieth. Counted with `:has` and `:nth-child`, so the markup
124 // says nothing it does not already say. A kept label near the end can run
125 // past the last bar, so the bars clip across and only across: the readout
126 // rises above them and must not be cut.
127 let _ = writeln!(css, ".{bars} {{\n overflow-x: clip;\n}}");
128 for (from, every) in [(13, 7), (121, 30)] {
129 let many = format!(".{bars}:has(> .{col}:nth-child({from}))");
130 let _ = writeln!(
131 css,
132 "{many} > .{col} > .{label} {{\n visibility: hidden;\n}}\n\
133 {many} > .{col}:nth-child({every}n + 1) > .{label} {{\n visibility: visible;\n \
134 max-width: none;\n overflow: visible;\n align-self: flex-start;\n}}"
135 );
136 }
137
138 // The readout, revealed from the attribute the markup already carries.
139 //
140 // Gated, because it is a hover state and this crate asks
141 // `Affordance` whether a hover state exists rather than assuming one.
142 // Keyed on the attribute rather than on a class, so a bar with nothing to
143 // say reveals no empty bubble.
144 //
145 // Centred with `inset-inline: 0` and an auto margin rather than with a
146 // half-width translate: the translate is the idiom and it names a
147 // magnitude, and this does the same job with three keywords.
148 css.push_str(&gated(
149 hover_condition(),
150 &format!(
151 ".{col}[data-tooltip]:hover::before {{\n content: attr(data-tooltip);\n \
152 position: absolute;\n bottom: 100%;\n inset-inline: 0;\n \
153 margin-inline: auto;\n width: max-content;\n \
154 background: var(--surface-raised);\n color: var(--content);\n \
155 border: var(--border);\n box-shadow: var(--elevation-overlay);\n \
156 padding: var(--chart-readout-padding, 0.25em 0.5em);\n \
157 white-space: nowrap;\n pointer-events: none;\n}}\n"
158 ),
159 ));
160 css
161}
162
163/// The rules, for the stylesheet builder.
164#[must_use]
165pub fn rules(opts: &Emit) -> String {
166 chart_rules(opts)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 /// Every class the markup can carry has a rule, which is what
174 /// `CHART_CLASSES` exists to keep true.
175 #[test]
176 fn every_class_this_module_names_has_a_rule() {
177 let css = chart_rules(&Emit::default());
178 for name in CHART_CLASSES {
179 assert!(css.contains(&format!(".{name}")), "{name} has no rule");
180 }
181 }
182
183 /// A zero axis draws every bar at nothing rather than at full height.
184 ///
185 /// The value is clamped to the axis before the division, so five on an
186 /// axis of zero is `min(5, 0)`, not `min(5 * 100% / 1, 100%)`, which was
187 /// the whole column. The divisor guard stays, so the declaration survives
188 /// a zero axis instead of falling to `auto`.
189 #[test]
190 fn a_zero_axis_clamps_every_bar_to_nothing() {
191 let css = chart_rules(&Emit::default());
192 assert!(
193 css.contains(
194 "height: min(calc(min(var(--value, 0), var(--most, 0)) * 100% \
195 / max(var(--most, 1), 1)), 100%);"
196 ),
197 "{css}"
198 );
199 assert!(
200 css.contains(
201 "height: min(calc(min(var(--value, 0), var(--most, 0)) \
202 * (100% - 1lh) / max(var(--most, 1), 1)), 100% - 1lh);"
203 ),
204 "{css}"
205 );
206 }
207}