Skip to main content

jev/rules/
svg.rs

1//! The rules as an SVG image, laid out as a fuzzy rule base is drawn: premises, then each rule's
2//! operators, then its conclusion, a row per rule, and a final column. See [`super::graph`].
3
4use std::fmt::Write as _;
5
6use super::graph::{trim_number, Node, Row};
7use super::output::Set;
8use super::{Kind, Rules, Target, Then};
9use crate::DecisionResponse;
10
11/// A panel's plot, and the gaps around it.
12const PANEL_WIDTH: f64 = 180.0;
13const PANEL_HEIGHT: f64 = 76.0;
14const GAP: f64 = 22.0;
15/// The column of operator trees, and the final column, which holds labels as well as a plot.
16const TREE_WIDTH: f64 = 200.0;
17const FINAL_WIDTH: f64 = 260.0;
18/// Room for "R1" to the left of the rows, and for the column titles above them.
19const GUTTER: f64 = 44.0;
20const TOP: f64 = 64.0;
21/// A row: its caption, its panel, and under the panel's axis the labels and a marker's value.
22const CAPTION: f64 = 20.0;
23const UNDER: f64 = 34.0;
24const ROW: f64 = CAPTION + PANEL_HEIGHT + UNDER + 12.0;
25
26const INK: &str = "#1f2328";
27const FAINT: &str = "#9aa0a6";
28const FRAME: &str = "#d0d4d9";
29const FILL: &str = "#b8c4d0";
30const YES: &str = "#2f7d4a";
31const MARK: &str = "#c2410c";
32
33impl Rules {
34    /// The rules as an SVG image: a row per rule with its premises (each question's levels or
35    /// options as bars of their probabilities, the term's own in bold), the tree of operators that
36    /// combine them, and its conclusion (an item's bar, or an output's set clipped at the rule's
37    /// score); then a final column with every item against the threshold and each output's merged
38    /// shape and its centre. Without `reply`, the structure alone.
39    pub fn graph_svg(&self, reply: Option<&DecisionResponse>) -> crate::Result<String> {
40        let degrees = reply.map(|reply| self.degrees(reply)).transpose()?;
41        let rows = self.rows(degrees.as_ref());
42
43        // The premise columns: one per question, in the order the rules first use them.
44        let mut questions: Vec<String> = Vec::new();
45        for row in &rows {
46            for term in terms(&row.tree) {
47                let id = &self.terms[&term].id;
48                if !questions.contains(id) {
49                    questions.push(id.clone());
50                }
51            }
52        }
53        // The conclusion columns: each output a rule concludes in, then the items.
54        let mut conclusions: Vec<Option<usize>> = Vec::new();
55        for row in &rows {
56            let column = match row.then {
57                Then::Output { output, .. } => Some(output),
58                Then::Item(_) => None,
59            };
60            if !conclusions.contains(&column) {
61                conclusions.push(column);
62            }
63        }
64        conclusions.sort_by_key(|column| column.unwrap_or(usize::MAX));
65
66        let premise_x = |at: usize| GUTTER + at as f64 * (PANEL_WIDTH + GAP);
67        let tree_x = premise_x(questions.len());
68        let conclusion_x = |at: usize| tree_x + TREE_WIDTH + GAP + at as f64 * (PANEL_WIDTH + GAP);
69        let final_x = conclusion_x(conclusions.len()) + GAP;
70        let width = final_x + FINAL_WIDTH + GAP;
71
72        let mut svg = Svg::default();
73        let span = |from: f64, to: f64| (from + to) / 2.0;
74        if !questions.is_empty() {
75            svg.text(span(premise_x(0), premise_x(questions.len()) - GAP), 22.0, 14.0, "middle", "bold", INK, "Premises");
76        }
77        svg.text(tree_x + TREE_WIDTH / 2.0, 22.0, 14.0, "middle", "bold", INK, "Rules");
78        svg.text(span(conclusion_x(0), conclusion_x(conclusions.len()) - GAP), 22.0, 14.0, "middle", "bold", INK, "Conclusions");
79        svg.text(final_x + FINAL_WIDTH / 2.0, 22.0, 14.0, "middle", "bold", INK, "Final");
80        for (at, id) in questions.iter().enumerate() {
81            svg.text(premise_x(at) + PANEL_WIDTH / 2.0, 42.0, 12.0, "middle", "normal", FAINT, id);
82        }
83        for (at, column) in conclusions.iter().enumerate() {
84            let title = column.map_or("items", |output| self.outputs[output].name.as_str());
85            svg.text(conclusion_x(at) + PANEL_WIDTH / 2.0, 42.0, 12.0, "middle", "normal", FAINT, title);
86        }
87
88        for (at, row) in rows.iter().enumerate() {
89            let top = TOP + at as f64 * ROW;
90            let plot_top = top + CAPTION;
91            svg.text(8.0, plot_top + PANEL_HEIGHT / 2.0 + 5.0, 15.0, "start", "bold", INK, &format!("R{}", row.number));
92            let caption = format!("if {}  ⇒  {}", row.text, self.then_text(&row.then));
93            svg.text(GUTTER, top + 13.0, 12.0, "start", "normal", INK, &caption);
94
95            let used = terms(&row.tree);
96            for (column, id) in questions.iter().enumerate() {
97                let targets: Vec<&Target> = used.iter().map(|term| &self.terms[term]).filter(|target| &target.id == id).collect();
98                if targets.is_empty() {
99                    continue;
100                }
101                let frame = Frame::new(premise_x(column), plot_top);
102                self.premise(&mut svg, &frame, &targets, reply);
103            }
104
105            if !questions.is_empty() {
106                svg.arrow(tree_x - GAP + 4.0, tree_x - 4.0, plot_top + PANEL_HEIGHT / 2.0);
107            }
108            self.tree(&mut svg, row, tree_x, plot_top);
109            let conclusion = match row.then {
110                Then::Output { output, .. } => conclusions.iter().position(|column| *column == Some(output)),
111                Then::Item(_) => conclusions.iter().position(Option::is_none),
112            };
113            if let Some(column) = conclusion {
114                let frame = Frame::new(conclusion_x(column), plot_top);
115                svg.arrow(tree_x + TREE_WIDTH + 4.0, frame.x - 4.0, plot_top + PANEL_HEIGHT / 2.0);
116                self.conclusion(&mut svg, &frame, row);
117            }
118        }
119
120        let bottom = self.finals(&mut svg, &rows, final_x, reply);
121        let height = (TOP + rows.len() as f64 * ROW).max(bottom) + 28.0;
122        let note = match reply {
123            Some(reply) => format!("{} · AND = {}, OR = {}", reply.model, self.logic.and.describe(), self.logic.or.describe()),
124            None => "Structure only: give a state to see Jev's degrees.".to_owned(),
125        };
126        svg.text(GUTTER, height - 10.0, 11.0, "start", "normal", FAINT, &note);
127        Ok(svg.finish(width, height))
128    }
129
130    /// A question's panel: a bar per level, option, or no and yes, in order, filled to the
131    /// probability Jev gave it. `targets` are the terms of this rule that read it, drawn in bold with
132    /// their number. A Score's expected level is a marker of its own: it isn't a probability, and two
133    /// different spreads can have the same one. A probability the reply leaves out is marked as
134    /// missing rather than drawn as zero.
135    fn premise(&self, svg: &mut Svg, frame: &Frame, targets: &[&Target], reply: Option<&DecisionResponse>) {
136        svg.frame(frame);
137        let target = targets[0];
138        let selected: Vec<usize> = targets.iter().map(|target| target.selected).collect();
139        let probabilities = reply.and_then(|reply| target.probabilities(reply));
140        let count = target.labels.len();
141        let slot = frame.width / count as f64;
142        let levels = frame.domain(-0.5, count as f64 - 0.5);
143        for (at, label) in target.labels.iter().enumerate() {
144            let bold = selected.contains(&at);
145            let x = frame.x + at as f64 * slot + 4.0;
146            let bar = slot - 8.0;
147            match probabilities.as_ref().map(|probabilities| probabilities[at]) {
148                Some(Some(probability)) => {
149                    let height = probability * (frame.height - 10.0);
150                    let fill = if bold { FILL } else { FRAME };
151                    svg.rect(x, frame.bottom() - height, bar, height, fill, "none", 0.0);
152                    if bold {
153                        // Inside the bar's top when it is tall enough, over it when it isn't.
154                        let y = if height > 16.0 { frame.bottom() - height + 12.0 } else { frame.bottom() - height - 3.0 };
155                        svg.text(x + bar / 2.0, y, 10.0, "middle", "bold", INK, &format!("{probability:.2}"));
156                    }
157                }
158                Some(None) => svg.text(x + bar / 2.0, frame.bottom() - 4.0, 9.0, "middle", "normal", MARK, "missing"),
159                None => {}
160            }
161            let (stroke, width) = if bold { (INK, 2.0) } else { (FAINT, 1.0) };
162            svg.rect(x, frame.y + 10.0, bar, frame.height - 10.0, "none", stroke, width);
163            svg.label(&levels, at as f64, label, count, bold);
164        }
165        if target.kind == Kind::Score {
166            if let Some(score) = reply.and_then(|reply| reply.score(&target.id).ok()) {
167                svg.tick(&levels, score.score, &format!("expected {:.2}", score.score));
168            }
169        }
170    }
171
172    /// A rule's `if` as a tree of its operators, each with its value when there is one.
173    fn tree(&self, svg: &mut Svg, row: &Row, x: f64, y: f64) {
174        svg.rect(x, y, TREE_WIDTH, PANEL_HEIGHT, "#f6f8fa", FRAME, 1.0);
175        let mut lines = Vec::new();
176        tree_lines(&row.tree, "", "", &mut lines);
177        if row.weight < 1.0 {
178            lines.push(format!("× weight {}", row.weight));
179        }
180        let most = ((PANEL_HEIGHT - 8.0) / 13.0) as usize;
181        if lines.len() > most {
182            lines.truncate(most - 1);
183            lines.push("…".to_owned());
184        }
185        for (at, line) in lines.iter().enumerate() {
186            svg.mono(x + 8.0, y + 16.0 + at as f64 * 13.0, line);
187        }
188    }
189
190    /// A rule's conclusion: an output's sets with the one it concludes in bold and clipped at the
191    /// rule's score, or the item's bar filled to it.
192    fn conclusion(&self, svg: &mut Svg, frame: &Frame, row: &Row) {
193        svg.frame(frame);
194        match &row.then {
195            Then::Output { output, set: concluded } => {
196                let output = &self.outputs[*output];
197                let frame = frame.domain(output.range.0, output.range.1);
198                for (at, set) in output.sets.iter().enumerate() {
199                    let bold = at == *concluded;
200                    if let (true, Some(score)) = (bold, row.score) {
201                        svg.cut(&frame, set, score);
202                    }
203                    svg.shape(&frame, set, bold);
204                    svg.label(&frame, set.middle(), &set.name, output.sets.len(), bold);
205                }
206            }
207            Then::Item(name) => {
208                let y = frame.y + frame.height / 2.0 - 6.0;
209                let (x, width) = (frame.x + 6.0, frame.width - 12.0);
210                svg.text(x, frame.y + 16.0, 12.0, "start", "bold", INK, &clip(name, 20));
211                if let Some(score) = row.score {
212                    let colour = if score >= self.threshold { YES } else { FILL };
213                    svg.rect(x, y, width * score, 22.0, colour, "none", 0.0);
214                    svg.text(x + width, frame.y + 16.0, 12.0, "end", "normal", INK, &format!("{score:.2}"));
215                }
216                svg.rect(x, y, width, 22.0, "none", INK, 1.2);
217                let threshold = x + width * self.threshold;
218                svg.dashed(threshold, y - 5.0, threshold, y + 27.0, MARK);
219            }
220        }
221    }
222
223    /// The final column: each output's merged shape and its centre, then every item against the
224    /// threshold. Returns where it ends.
225    fn finals(&self, svg: &mut Svg, rows: &[Row], x: f64, reply: Option<&DecisionResponse>) -> f64 {
226        let scores: Option<Vec<f64>> = rows.iter().map(|row| row.score).collect();
227        let mut y = TOP;
228        for (at, output) in self.outputs.iter().enumerate() {
229            let clipped = scores.as_ref().map(|scores| self.set_scores(at, scores));
230            let value = clipped.as_ref().and_then(|clipped| output.centroid(clipped, self.logic.or));
231            let caption = match (&clipped, value) {
232                (Some(_), Some(value)) => format!("{} = {value:.2}", output.name),
233                (Some(_), None) => format!("{}: no rule fired", output.name),
234                (None, _) => output.name.clone(),
235            };
236            svg.text(x, y + 13.0, 12.0, "start", "bold", INK, &caption);
237            let frame = Frame { x, y: y + CAPTION, width: FINAL_WIDTH, height: PANEL_HEIGHT, low: output.range.0, high: output.range.1 };
238            svg.frame(&frame);
239            if let Some(clipped) = &clipped {
240                let points: Vec<(f64, f64)> = output.samples(241).map(|at| (at, output.merged(clipped, self.logic.or, at))).collect();
241                svg.area(&frame, &points);
242            }
243            for set in &output.sets {
244                svg.shape(&frame, set, false);
245                svg.label(&frame, set.middle(), &set.name, output.sets.len(), false);
246            }
247            if let Some(value) = value {
248                svg.marker(&frame, value, &format!("{value:.2}"));
249            }
250            svg.text(frame.x, frame.bottom() + 28.0, 10.0, "start", "normal", FAINT, &trim_number(output.range.0));
251            svg.text(frame.x + frame.width, frame.bottom() + 28.0, 10.0, "end", "normal", FAINT, &trim_number(output.range.1));
252            y += ROW + 12.0;
253        }
254
255        let items = self.item_names();
256        if items.is_empty() {
257            return y;
258        }
259        let outcome = reply.and_then(|reply| self.evaluate(reply).ok());
260        svg.text(x, y + 13.0, 12.0, "start", "bold", INK, &format!("items (threshold {:.2})", self.threshold));
261        let top = y + CAPTION;
262        let (label, bar) = (100.0, FINAL_WIDTH - 140.0);
263        for (at, name) in items.iter().enumerate() {
264            let line = top + 8.0 + at as f64 * 22.0;
265            svg.text(x, line + 12.0, 11.0, "start", "normal", INK, &clip(name, 16));
266            if let Some(item) = outcome.as_ref().and_then(|outcome| outcome.items.iter().find(|item| &item.item == name)) {
267                let colour = if item.yes { YES } else { FILL };
268                svg.rect(x + label, line, bar * item.score, 16.0, colour, "none", 0.0);
269                let verdict = if item.yes { format!("{:.2} yes", item.score) } else { format!("{:.2}", item.score) };
270                svg.text(x + label + bar + 6.0, line + 12.0, 11.0, "start", if item.yes { "bold" } else { "normal" }, INK, &verdict);
271            }
272            svg.rect(x + label, line, bar, 16.0, "none", FAINT, 1.0);
273        }
274        let bottom = top + 8.0 + items.len() as f64 * 22.0;
275        let threshold = x + label + bar * self.threshold;
276        svg.dashed(threshold, top + 2.0, threshold, bottom, MARK);
277        bottom + 10.0
278    }
279}
280
281/// The terms a rule's tree reads, in the order written.
282fn terms(tree: &Node) -> Vec<String> {
283    let mut out = Vec::new();
284    tree.terms(&mut out);
285    out
286}
287
288/// A tree's lines, drawn with box lines: `AND (min) 0.96`, `├ raining 0.96`, …
289fn tree_lines(node: &Node, first: &str, rest: &str, out: &mut Vec<String>) {
290    let how = if node.how.is_empty() { String::new() } else { format!(" ({})", node.how) };
291    let value = node.value.map(|value| format!("  {value:.2}")).unwrap_or_default();
292    out.push(format!("{first}{}{how}{value}", node.label));
293    for (at, child) in node.children.iter().enumerate() {
294        let last = at + 1 == node.children.len();
295        let (branch, under) = if last { ("└ ", "  ") } else { ("├ ", "│ ") };
296        tree_lines(child, &format!("{rest}{branch}"), &format!("{rest}{under}"), out);
297    }
298}
299
300/// `text` cut to `most` characters, with `…` when it was longer.
301fn clip(text: &str, most: usize) -> String {
302    match text.chars().count() > most {
303        true => format!("{}…", text.chars().take(most.saturating_sub(1)).collect::<String>()),
304        false => text.to_owned(),
305    }
306}
307
308/// A plot: where it is, and the domain its x axis covers. Heights run from 0 at the bottom to 1
309/// ten pixels under the top, which leaves room for a value's label.
310#[derive(Clone, Copy)]
311struct Frame {
312    x: f64,
313    y: f64,
314    width: f64,
315    height: f64,
316    low: f64,
317    high: f64,
318}
319
320impl Frame {
321    fn new(x: f64, y: f64) -> Frame {
322        Frame { x, y, width: PANEL_WIDTH, height: PANEL_HEIGHT, low: 0.0, high: 1.0 }
323    }
324
325    fn domain(&self, low: f64, high: f64) -> Frame {
326        Frame { low, high, ..*self }
327    }
328
329    fn bottom(&self) -> f64 {
330        self.y + self.height
331    }
332
333    fn px(&self, x: f64) -> f64 {
334        self.x + (x - self.low) / (self.high - self.low) * self.width
335    }
336
337    fn py(&self, degree: f64) -> f64 {
338        self.bottom() - degree.clamp(0.0, 1.0) * (self.height - 10.0)
339    }
340}
341
342#[derive(Default)]
343struct Svg {
344    body: String,
345}
346
347impl Svg {
348    fn finish(self, width: f64, height: f64) -> String {
349        format!(
350            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" viewBox=\"0 0 {width:.0} {height:.0}\" \
351             font-family=\"-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif\">\n\
352             <rect width=\"100%\" height=\"100%\" fill=\"#ffffff\"/>\n{}</svg>\n",
353            self.body
354        )
355    }
356
357    #[allow(clippy::too_many_arguments)]
358    fn text(&mut self, x: f64, y: f64, size: f64, anchor: &str, weight: &str, fill: &str, text: &str) {
359        let _ = writeln!(
360            self.body,
361            "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"{size}\" text-anchor=\"{anchor}\" font-weight=\"{weight}\" fill=\"{fill}\">{}</text>",
362            escape(text)
363        );
364    }
365
366    fn mono(&mut self, x: f64, y: f64, text: &str) {
367        let _ = writeln!(
368            self.body,
369            "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"11\" font-family=\"ui-monospace, Menlo, Consolas, monospace\" fill=\"{INK}\" \
370             xml:space=\"preserve\">{}</text>",
371            escape(text)
372        );
373    }
374
375    #[allow(clippy::too_many_arguments)]
376    fn rect(&mut self, x: f64, y: f64, width: f64, height: f64, fill: &str, stroke: &str, stroke_width: f64) {
377        let _ = writeln!(
378            self.body,
379            "<rect x=\"{x:.1}\" y=\"{y:.1}\" width=\"{:.1}\" height=\"{:.1}\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{stroke_width}\"/>",
380            width.max(0.0),
381            height.max(0.0)
382        );
383    }
384
385    fn frame(&mut self, frame: &Frame) {
386        self.rect(frame.x, frame.y, frame.width, frame.height, "none", FRAME, 1.0);
387    }
388
389    fn dashed(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, stroke: &str) {
390        let _ = writeln!(
391            self.body,
392            "<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" stroke=\"{stroke}\" stroke-width=\"1.2\" stroke-dasharray=\"4 3\"/>"
393        );
394    }
395
396    fn arrow(&mut self, from: f64, to: f64, y: f64) {
397        let _ = writeln!(
398            self.body,
399            "<line x1=\"{from:.1}\" y1=\"{y:.1}\" x2=\"{:.1}\" y2=\"{y:.1}\" stroke=\"{INK}\" stroke-width=\"1.2\"/>\n\
400             <path d=\"M{to:.1},{y:.1} l-8,-4 v8 z\" fill=\"{INK}\"/>",
401            to - 6.0
402        );
403    }
404
405    /// A set's outline, bold when it is the one a rule reads or concludes.
406    fn shape(&mut self, frame: &Frame, set: &Set, bold: bool) {
407        let [a, b, c, d] = set.points;
408        let points = [(a, 0.0), (b, 1.0), (c, 1.0), (d, 0.0)];
409        let (stroke, width) = if bold { (INK, 2.2) } else { (FAINT, 1.0) };
410        let path: Vec<String> = points.iter().map(|(x, degree)| format!("{:.1},{:.1}", frame.px(*x), frame.py(*degree))).collect();
411        let _ = writeln!(
412            self.body,
413            "<polyline points=\"{}\" fill=\"none\" stroke=\"{stroke}\" stroke-width=\"{width}\" stroke-linejoin=\"round\"/>",
414            path.join(" ")
415        );
416    }
417
418    /// The part of `set` under `degree`, shaded, with the cut drawn across the panel and its value.
419    fn cut(&mut self, frame: &Frame, set: &Set, degree: f64) {
420        let [a, b, c, d] = set.points;
421        let rising = a + (b - a) * degree;
422        let falling = d - (d - c) * degree;
423        let corners = [(a, 0.0), (rising, degree), (falling, degree), (d, 0.0)];
424        let path: Vec<String> =
425            corners.iter().map(|(x, y)| format!("{:.1},{:.1}", frame.px(x.clamp(frame.low, frame.high)), frame.py(*y))).collect();
426        let _ = writeln!(self.body, "<polygon points=\"{}\" fill=\"{FILL}\" stroke=\"none\"/>", path.join(" "));
427        let y = frame.py(degree);
428        self.dashed(frame.px(falling.clamp(frame.low, frame.high)), y, frame.x + frame.width, y, INK);
429        self.text(frame.x + frame.width - 2.0, y - 3.0, 10.0, "end", "bold", INK, &format!("{degree:.2}"));
430    }
431
432    /// A shape given as points along the axis, filled.
433    fn area(&mut self, frame: &Frame, points: &[(f64, f64)]) {
434        let mut path = vec![format!("{:.1},{:.1}", frame.px(frame.low), frame.bottom())];
435        path.extend(points.iter().map(|(x, degree)| format!("{:.1},{:.1}", frame.px(*x), frame.py(*degree))));
436        path.push(format!("{:.1},{:.1}", frame.px(frame.high), frame.bottom()));
437        let _ = writeln!(self.body, "<polygon points=\"{}\" fill=\"{FILL}\" stroke=\"{INK}\" stroke-width=\"1\"/>", path.join(" "));
438    }
439
440    /// A set's or level's name under the axis, at `x`, cut to fit its share of the panel.
441    fn label(&mut self, frame: &Frame, x: f64, name: &str, count: usize, bold: bool) {
442        let room = ((frame.width / count as f64) / 6.5).max(3.0) as usize;
443        let weight = if bold { "bold" } else { "normal" };
444        let fill = if bold { INK } else { FAINT };
445        self.text(frame.px(x), frame.bottom() + 12.0, 10.0, "middle", weight, fill, &clip(name, room));
446    }
447
448    /// An arrowhead under the axis at `x`, with no line up through the plot: a Score's expected
449    /// level, which would otherwise cross the bars and their numbers.
450    fn tick(&mut self, frame: &Frame, x: f64, label: &str) {
451        let at = frame.px(x.clamp(frame.low, frame.high));
452        let _ = writeln!(self.body, "<path d=\"M{at:.1},{:.1} l-4,7 h8 z\" fill=\"{MARK}\"/>", frame.bottom() + 14.0);
453        self.text(at, frame.bottom() + 30.0, 10.0, "middle", "bold", MARK, label);
454    }
455
456    /// An arrow up to the axis at `x`: an output's centre.
457    fn marker(&mut self, frame: &Frame, x: f64, label: &str) {
458        let at = frame.px(x.clamp(frame.low, frame.high));
459        let _ = writeln!(
460            self.body,
461            "<line x1=\"{at:.1}\" y1=\"{:.1}\" x2=\"{at:.1}\" y2=\"{:.1}\" stroke=\"{MARK}\" stroke-width=\"1.6\"/>\n\
462             <path d=\"M{at:.1},{:.1} l-4,7 h8 z\" fill=\"{MARK}\"/>",
463            frame.y + 6.0,
464            frame.bottom(),
465            frame.bottom() + 14.0
466        );
467        self.text(at, frame.bottom() + 30.0, 10.0, "middle", "bold", MARK, label);
468    }
469}
470
471/// Text made safe to put inside an SVG element.
472fn escape(text: &str) -> String {
473    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn escapes_text() {
482        assert_eq!(escape("a < b & \"c\""), "a &lt; b &amp; &quot;c&quot;");
483    }
484
485    #[test]
486    fn clips_long_labels() {
487        assert_eq!(clip("Very angry", 6), "Very …");
488        assert_eq!(clip("Hot", 6), "Hot");
489    }
490}