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, the term's own in bold, cut at the degree Jev gave it), 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 Score's levels as curves along its scale, or a Choice's options and a
131    /// Noul's no and yes as bars. `targets` are the terms of this rule that read it, drawn in bold,
132    /// and with a reply each is cut at its degree.
133    fn premise(&self, svg: &mut Svg, frame: &Frame, targets: &[&Target], reply: Option<&DecisionResponse>) {
134        svg.frame(frame);
135        let target = targets[0];
136        let selected: Vec<usize> = targets.iter().map(|target| target.selected).collect();
137        let probabilities = reply.and_then(|reply| target.probabilities(reply));
138        let count = target.labels.len();
139        match target.kind {
140            Kind::Score => {
141                let frame = frame.domain(-0.5, count as f64 - 0.5);
142                for (at, label) in target.labels.iter().enumerate() {
143                    let set = Set { name: label.clone(), points: level(at, count) };
144                    let bold = selected.contains(&at);
145                    if bold {
146                        if let Some(probabilities) = &probabilities {
147                            svg.cut(&frame, &set, probabilities[at]);
148                        }
149                    }
150                    svg.shape(&frame, &set, bold);
151                    svg.label(&frame, at as f64, label, count, bold);
152                }
153                // Where the expected level falls: the reading the curves turn into degrees.
154                if let Some(score) = reply.and_then(|reply| reply.score(&target.id).ok()) {
155                    svg.marker(&frame, score.score, &format!("score {:.2}", score.score));
156                }
157            }
158            Kind::Choice | Kind::Noul => {
159                let slot = frame.width / count as f64;
160                for (at, label) in target.labels.iter().enumerate() {
161                    let bold = selected.contains(&at);
162                    let x = frame.x + at as f64 * slot + 4.0;
163                    let bar = slot - 8.0;
164                    if let Some(probabilities) = &probabilities {
165                        let height = probabilities[at] * (frame.height - 10.0);
166                        let fill = if bold { FILL } else { FRAME };
167                        svg.rect(x, frame.bottom() - height, bar, height, fill, "none", 0.0);
168                        if bold {
169                            // Inside the bar's top when it is tall enough, over it when it isn't.
170                            let y = if height > 16.0 { frame.bottom() - height + 12.0 } else { frame.bottom() - height - 3.0 };
171                            svg.text(x + bar / 2.0, y, 10.0, "middle", "bold", INK, &format!("{:.2}", probabilities[at]));
172                        }
173                    }
174                    let (stroke, width) = if bold { (INK, 2.0) } else { (FAINT, 1.0) };
175                    svg.rect(x, frame.y + 10.0, bar, frame.height - 10.0, "none", stroke, width);
176                    let frame_x = frame.domain(-0.5, count as f64 - 0.5);
177                    svg.label(&frame_x, at as f64, label, count, bold);
178                }
179            }
180        }
181    }
182
183    /// A rule's `if` as a tree of its operators, each with its value when there is one.
184    fn tree(&self, svg: &mut Svg, row: &Row, x: f64, y: f64) {
185        svg.rect(x, y, TREE_WIDTH, PANEL_HEIGHT, "#f6f8fa", FRAME, 1.0);
186        let mut lines = Vec::new();
187        tree_lines(&row.tree, "", "", &mut lines);
188        if row.weight < 1.0 {
189            lines.push(format!("× weight {}", row.weight));
190        }
191        let most = ((PANEL_HEIGHT - 8.0) / 13.0) as usize;
192        if lines.len() > most {
193            lines.truncate(most - 1);
194            lines.push("…".to_owned());
195        }
196        for (at, line) in lines.iter().enumerate() {
197            svg.mono(x + 8.0, y + 16.0 + at as f64 * 13.0, line);
198        }
199    }
200
201    /// A rule's conclusion: an output's sets with the one it concludes in bold and clipped at the
202    /// rule's score, or the item's bar filled to it.
203    fn conclusion(&self, svg: &mut Svg, frame: &Frame, row: &Row) {
204        svg.frame(frame);
205        match &row.then {
206            Then::Output { output, set: concluded } => {
207                let output = &self.outputs[*output];
208                let frame = frame.domain(output.range.0, output.range.1);
209                for (at, set) in output.sets.iter().enumerate() {
210                    let bold = at == *concluded;
211                    if let (true, Some(score)) = (bold, row.score) {
212                        svg.cut(&frame, set, score);
213                    }
214                    svg.shape(&frame, set, bold);
215                    svg.label(&frame, set.middle(), &set.name, output.sets.len(), bold);
216                }
217            }
218            Then::Item(name) => {
219                let y = frame.y + frame.height / 2.0 - 6.0;
220                let (x, width) = (frame.x + 6.0, frame.width - 12.0);
221                svg.text(x, frame.y + 16.0, 12.0, "start", "bold", INK, &clip(name, 20));
222                if let Some(score) = row.score {
223                    let colour = if score >= self.threshold { YES } else { FILL };
224                    svg.rect(x, y, width * score, 22.0, colour, "none", 0.0);
225                    svg.text(x + width, frame.y + 16.0, 12.0, "end", "normal", INK, &format!("{score:.2}"));
226                }
227                svg.rect(x, y, width, 22.0, "none", INK, 1.2);
228                let threshold = x + width * self.threshold;
229                svg.dashed(threshold, y - 5.0, threshold, y + 27.0, MARK);
230            }
231        }
232    }
233
234    /// The final column: each output's merged shape and its centre, then every item against the
235    /// threshold. Returns where it ends.
236    fn finals(&self, svg: &mut Svg, rows: &[Row], x: f64, reply: Option<&DecisionResponse>) -> f64 {
237        let scores: Option<Vec<f64>> = rows.iter().map(|row| row.score).collect();
238        let mut y = TOP;
239        for (at, output) in self.outputs.iter().enumerate() {
240            let clipped = scores.as_ref().map(|scores| self.clipped(at, scores));
241            let value = clipped.as_ref().and_then(|clipped| output.centroid(clipped, self.logic.or));
242            let caption = match (&clipped, value) {
243                (Some(_), Some(value)) => format!("{} = {value:.2}", output.name),
244                (Some(_), None) => format!("{}: no rule fired", output.name),
245                (None, _) => output.name.clone(),
246            };
247            svg.text(x, y + 13.0, 12.0, "start", "bold", INK, &caption);
248            let frame = Frame { x, y: y + CAPTION, width: FINAL_WIDTH, height: PANEL_HEIGHT, low: output.range.0, high: output.range.1 };
249            svg.frame(&frame);
250            if let Some(clipped) = &clipped {
251                let points: Vec<(f64, f64)> = output.samples(241).map(|at| (at, output.merged(clipped, self.logic.or, at))).collect();
252                svg.area(&frame, &points);
253            }
254            for set in &output.sets {
255                svg.shape(&frame, set, false);
256                svg.label(&frame, set.middle(), &set.name, output.sets.len(), false);
257            }
258            if let Some(value) = value {
259                svg.marker(&frame, value, &format!("{value:.2}"));
260            }
261            svg.text(frame.x, frame.bottom() + 28.0, 10.0, "start", "normal", FAINT, &trim_number(output.range.0));
262            svg.text(frame.x + frame.width, frame.bottom() + 28.0, 10.0, "end", "normal", FAINT, &trim_number(output.range.1));
263            y += ROW + 12.0;
264        }
265
266        let items = self.item_names();
267        if items.is_empty() {
268            return y;
269        }
270        let outcome = reply.and_then(|reply| self.evaluate(reply).ok());
271        svg.text(x, y + 13.0, 12.0, "start", "bold", INK, &format!("items (threshold {:.2})", self.threshold));
272        let top = y + CAPTION;
273        let (label, bar) = (100.0, FINAL_WIDTH - 140.0);
274        for (at, name) in items.iter().enumerate() {
275            let line = top + 8.0 + at as f64 * 22.0;
276            svg.text(x, line + 12.0, 11.0, "start", "normal", INK, &clip(name, 16));
277            if let Some(item) = outcome.as_ref().and_then(|outcome| outcome.items.iter().find(|item| &item.item == name)) {
278                let colour = if item.yes { YES } else { FILL };
279                svg.rect(x + label, line, bar * item.score, 16.0, colour, "none", 0.0);
280                let verdict = if item.yes { format!("{:.2} yes", item.score) } else { format!("{:.2}", item.score) };
281                svg.text(x + label + bar + 6.0, line + 12.0, 11.0, "start", if item.yes { "bold" } else { "normal" }, INK, &verdict);
282            }
283            svg.rect(x + label, line, bar, 16.0, "none", FAINT, 1.0);
284        }
285        let bottom = top + 8.0 + items.len() as f64 * 22.0;
286        let threshold = x + label + bar * self.threshold;
287        svg.dashed(threshold, top + 2.0, threshold, bottom, MARK);
288        bottom + 10.0
289    }
290}
291
292/// The terms a rule's tree reads, in the order written.
293fn terms(tree: &Node) -> Vec<String> {
294    let mut out = Vec::new();
295    tree.terms(&mut out);
296    out
297}
298
299/// A tree's lines, drawn with box lines: `AND (min) 0.96`, `├ raining 0.96`, …
300fn tree_lines(node: &Node, first: &str, rest: &str, out: &mut Vec<String>) {
301    let how = if node.how.is_empty() { String::new() } else { format!(" ({})", node.how) };
302    let value = node.value.map(|value| format!("  {value:.2}")).unwrap_or_default();
303    out.push(format!("{first}{}{how}{value}", node.label));
304    for (at, child) in node.children.iter().enumerate() {
305        let last = at + 1 == node.children.len();
306        let (branch, under) = if last { ("└ ", "  ") } else { ("├ ", "│ ") };
307        tree_lines(child, &format!("{rest}{branch}"), &format!("{rest}{under}"), out);
308    }
309}
310
311/// Level `at` of `count` as a set along the scale from -0.5 to `count - 0.5`: a triangle peaking
312/// at its own number, with the lowest and highest held up to the ends, as the outer sets of a fuzzy
313/// partition are.
314fn level(at: usize, count: usize) -> [f64; 4] {
315    let (at, last) = (at as f64, count as f64 - 1.0);
316    let (a, b) = if at == 0.0 { (-0.5, -0.5) } else { (at - 1.0, at) };
317    let (c, d) = if at == last { (last + 0.5, last + 0.5) } else { (at, at + 1.0) };
318    [a, b, c, d]
319}
320
321/// `text` cut to `most` characters, with `…` when it was longer.
322fn clip(text: &str, most: usize) -> String {
323    match text.chars().count() > most {
324        true => format!("{}…", text.chars().take(most.saturating_sub(1)).collect::<String>()),
325        false => text.to_owned(),
326    }
327}
328
329/// A plot: where it is, and the domain its x axis covers. Heights run from 0 at the bottom to 1
330/// ten pixels under the top, which leaves room for a value's label.
331#[derive(Clone, Copy)]
332struct Frame {
333    x: f64,
334    y: f64,
335    width: f64,
336    height: f64,
337    low: f64,
338    high: f64,
339}
340
341impl Frame {
342    fn new(x: f64, y: f64) -> Frame {
343        Frame { x, y, width: PANEL_WIDTH, height: PANEL_HEIGHT, low: 0.0, high: 1.0 }
344    }
345
346    fn domain(&self, low: f64, high: f64) -> Frame {
347        Frame { low, high, ..*self }
348    }
349
350    fn bottom(&self) -> f64 {
351        self.y + self.height
352    }
353
354    fn px(&self, x: f64) -> f64 {
355        self.x + (x - self.low) / (self.high - self.low) * self.width
356    }
357
358    fn py(&self, degree: f64) -> f64 {
359        self.bottom() - degree.clamp(0.0, 1.0) * (self.height - 10.0)
360    }
361}
362
363#[derive(Default)]
364struct Svg {
365    body: String,
366}
367
368impl Svg {
369    fn finish(self, width: f64, height: f64) -> String {
370        format!(
371            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" viewBox=\"0 0 {width:.0} {height:.0}\" \
372             font-family=\"-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif\">\n\
373             <rect width=\"100%\" height=\"100%\" fill=\"#ffffff\"/>\n{}</svg>\n",
374            self.body
375        )
376    }
377
378    #[allow(clippy::too_many_arguments)]
379    fn text(&mut self, x: f64, y: f64, size: f64, anchor: &str, weight: &str, fill: &str, text: &str) {
380        let _ = writeln!(
381            self.body,
382            "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"{size}\" text-anchor=\"{anchor}\" font-weight=\"{weight}\" fill=\"{fill}\">{}</text>",
383            escape(text)
384        );
385    }
386
387    fn mono(&mut self, x: f64, y: f64, text: &str) {
388        let _ = writeln!(
389            self.body,
390            "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"11\" font-family=\"ui-monospace, Menlo, Consolas, monospace\" fill=\"{INK}\" \
391             xml:space=\"preserve\">{}</text>",
392            escape(text)
393        );
394    }
395
396    #[allow(clippy::too_many_arguments)]
397    fn rect(&mut self, x: f64, y: f64, width: f64, height: f64, fill: &str, stroke: &str, stroke_width: f64) {
398        let _ = writeln!(
399            self.body,
400            "<rect x=\"{x:.1}\" y=\"{y:.1}\" width=\"{:.1}\" height=\"{:.1}\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{stroke_width}\"/>",
401            width.max(0.0),
402            height.max(0.0)
403        );
404    }
405
406    fn frame(&mut self, frame: &Frame) {
407        self.rect(frame.x, frame.y, frame.width, frame.height, "none", FRAME, 1.0);
408    }
409
410    fn dashed(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, stroke: &str) {
411        let _ = writeln!(
412            self.body,
413            "<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" stroke=\"{stroke}\" stroke-width=\"1.2\" stroke-dasharray=\"4 3\"/>"
414        );
415    }
416
417    fn arrow(&mut self, from: f64, to: f64, y: f64) {
418        let _ = writeln!(
419            self.body,
420            "<line x1=\"{from:.1}\" y1=\"{y:.1}\" x2=\"{:.1}\" y2=\"{y:.1}\" stroke=\"{INK}\" stroke-width=\"1.2\"/>\n\
421             <path d=\"M{to:.1},{y:.1} l-8,-4 v8 z\" fill=\"{INK}\"/>",
422            to - 6.0
423        );
424    }
425
426    /// A set's outline, bold when it is the one a rule reads or concludes.
427    fn shape(&mut self, frame: &Frame, set: &Set, bold: bool) {
428        let [a, b, c, d] = set.points;
429        let points = [(a, 0.0), (b, 1.0), (c, 1.0), (d, 0.0)];
430        let (stroke, width) = if bold { (INK, 2.2) } else { (FAINT, 1.0) };
431        let path: Vec<String> = points.iter().map(|(x, degree)| format!("{:.1},{:.1}", frame.px(*x), frame.py(*degree))).collect();
432        let _ = writeln!(
433            self.body,
434            "<polyline points=\"{}\" fill=\"none\" stroke=\"{stroke}\" stroke-width=\"{width}\" stroke-linejoin=\"round\"/>",
435            path.join(" ")
436        );
437    }
438
439    /// The part of `set` under `degree`, shaded, with the cut drawn across the panel and its value.
440    fn cut(&mut self, frame: &Frame, set: &Set, degree: f64) {
441        let [a, b, c, d] = set.points;
442        let rising = a + (b - a) * degree;
443        let falling = d - (d - c) * degree;
444        let corners = [(a, 0.0), (rising, degree), (falling, degree), (d, 0.0)];
445        let path: Vec<String> =
446            corners.iter().map(|(x, y)| format!("{:.1},{:.1}", frame.px(x.clamp(frame.low, frame.high)), frame.py(*y))).collect();
447        let _ = writeln!(self.body, "<polygon points=\"{}\" fill=\"{FILL}\" stroke=\"none\"/>", path.join(" "));
448        let y = frame.py(degree);
449        self.dashed(frame.px(falling.clamp(frame.low, frame.high)), y, frame.x + frame.width, y, INK);
450        self.text(frame.x + frame.width - 2.0, y - 3.0, 10.0, "end", "bold", INK, &format!("{degree:.2}"));
451    }
452
453    /// A shape given as points along the axis, filled.
454    fn area(&mut self, frame: &Frame, points: &[(f64, f64)]) {
455        let mut path = vec![format!("{:.1},{:.1}", frame.px(frame.low), frame.bottom())];
456        path.extend(points.iter().map(|(x, degree)| format!("{:.1},{:.1}", frame.px(*x), frame.py(*degree))));
457        path.push(format!("{:.1},{:.1}", frame.px(frame.high), frame.bottom()));
458        let _ = writeln!(self.body, "<polygon points=\"{}\" fill=\"{FILL}\" stroke=\"{INK}\" stroke-width=\"1\"/>", path.join(" "));
459    }
460
461    /// A set's or level's name under the axis, at `x`, cut to fit its share of the panel.
462    fn label(&mut self, frame: &Frame, x: f64, name: &str, count: usize, bold: bool) {
463        let room = ((frame.width / count as f64) / 6.5).max(3.0) as usize;
464        let weight = if bold { "bold" } else { "normal" };
465        let fill = if bold { INK } else { FAINT };
466        self.text(frame.px(x), frame.bottom() + 12.0, 10.0, "middle", weight, fill, &clip(name, room));
467    }
468
469    /// An arrow up to the axis at `x`: the expected level of a Score, or an output's centre.
470    fn marker(&mut self, frame: &Frame, x: f64, label: &str) {
471        let at = frame.px(x.clamp(frame.low, frame.high));
472        let _ = writeln!(
473            self.body,
474            "<line x1=\"{at:.1}\" y1=\"{:.1}\" x2=\"{at:.1}\" y2=\"{:.1}\" stroke=\"{MARK}\" stroke-width=\"1.6\"/>\n\
475             <path d=\"M{at:.1},{:.1} l-4,7 h8 z\" fill=\"{MARK}\"/>",
476            frame.y + 6.0,
477            frame.bottom(),
478            frame.bottom() + 14.0
479        );
480        self.text(at, frame.bottom() + 30.0, 10.0, "middle", "bold", MARK, label);
481    }
482}
483
484/// Text made safe to put inside an SVG element.
485fn escape(text: &str) -> String {
486    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn levels_make_a_partition_with_shoulders_at_the_ends() {
495        assert_eq!(level(0, 3), [-0.5, -0.5, 0.0, 1.0]);
496        assert_eq!(level(1, 3), [0.0, 1.0, 1.0, 2.0]);
497        assert_eq!(level(2, 3), [1.0, 2.0, 2.5, 2.5]);
498        // Between two levels, their degrees add up to one.
499        let (low, high) = (Set { name: String::new(), points: level(0, 3) }, Set { name: String::new(), points: level(1, 3) });
500        assert!((low.membership(0.3) + high.membership(0.3) - 1.0).abs() < 1e-9);
501    }
502
503    #[test]
504    fn escapes_text() {
505        assert_eq!(escape("a < b & \"c\""), "a &lt; b &amp; &quot;c&quot;");
506    }
507
508    #[test]
509    fn clips_long_labels() {
510        assert_eq!(clip("Very angry", 6), "Very …");
511        assert_eq!(clip("Hot", 6), "Hot");
512    }
513}