1use 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
11const PANEL_WIDTH: f64 = 180.0;
13const PANEL_HEIGHT: f64 = 76.0;
14const GAP: f64 = 22.0;
15const TREE_WIDTH: f64 = 200.0;
17const FINAL_WIDTH: f64 = 260.0;
18const GUTTER: f64 = 44.0;
20const TOP: f64 = 64.0;
21const 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 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 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 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, ¬e);
127 Ok(svg.finish(width, height))
128 }
129
130 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 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 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 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 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 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
292fn terms(tree: &Node) -> Vec<String> {
294 let mut out = Vec::new();
295 tree.terms(&mut out);
296 out
297}
298
299fn 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
311fn 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
321fn 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#[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 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 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 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 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 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
484fn escape(text: &str) -> String {
486 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
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 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 < b & "c"");
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}