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