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>) {
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 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 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 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 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
281fn terms(tree: &Node) -> Vec<String> {
283 let mut out = Vec::new();
284 tree.terms(&mut out);
285 out
286}
287
288fn 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
300fn 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#[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 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 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 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 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 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 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
471fn escape(text: &str) -> String {
473 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
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 < b & "c"");
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}