1use ratatui::style::Style;
10use ratatui::text::{Line, Span};
11use typesafe::{Answer, Question};
12
13use crate::evaluate::two;
14use crate::format::{bold, color_for, dim};
15use crate::headless::Answered;
16use crate::session::{Session, turns_to_json};
17
18pub fn prefixes(session: &Session) -> Vec<Session> {
24 let Some(turns) = session.turns() else {
25 return Vec::new();
26 };
27 (1..=turns.len())
28 .map(|n| {
29 let mut so_far = session.clone();
30 so_far.state = turns_to_json(&turns[..n]);
31 so_far
32 })
33 .collect()
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub struct Series {
39 pub name: String,
40 pub kind: &'static str,
41 pub values: Vec<f64>,
44 pub top: f64,
46 pub readings: Vec<String>,
48 pub label: Option<String>,
50}
51
52fn kind_of(question: &Question) -> &'static str {
54 match question {
55 Question::Noul(_) => "noul",
56 Question::Choice(_) => "choice",
57 Question::Score(_) => "score",
58 _ => "raw",
59 }
60}
61
62pub fn series(session: &Session, per_turn: &[Vec<Answered>], threshold: f64) -> Vec<Series> {
68 session
69 .questions
70 .iter()
71 .map(|(name, question)| {
72 let kind = kind_of(question);
73 let missing = Series {
74 name: name.clone(),
75 kind,
76 values: Vec::new(),
77 top: 1.0,
78 readings: Vec::new(),
79 label: None,
80 };
81 let answers: Option<Vec<&Answer>> = per_turn
82 .iter()
83 .map(|answered| {
84 answered
85 .iter()
86 .find(|(n, _)| n == name)
87 .and_then(|(_, a)| a.as_ref())
88 .filter(|a| a.kind() == kind)
89 })
90 .collect();
91 let Some(all) = answers.filter(|all| !all.is_empty()) else {
92 return missing;
93 };
94 match all[all.len() - 1] {
95 Answer::Noul(_) => {
96 let at = session.threshold_of(name, threshold);
97 let values: Vec<f64> = all
98 .iter()
99 .map(|a| match a {
100 Answer::Noul(a) => a.noul,
101 _ => 0.0,
102 })
103 .collect();
104 let readings = values
105 .iter()
106 .map(|p| if *p >= at { "yes" } else { "no" }.to_owned())
107 .collect();
108 Series {
109 values,
110 readings,
111 ..missing
112 }
113 }
114 Answer::Choice(last) => {
115 let label = last.choice.clone();
116 Series {
117 values: all
118 .iter()
119 .map(|a| match a {
120 Answer::Choice(a) => a.probability(&label).unwrap_or(0.0),
121 _ => 0.0,
122 })
123 .collect(),
124 readings: all
125 .iter()
126 .map(|a| match a {
127 Answer::Choice(a) => a.choice.clone(),
128 _ => String::new(),
129 })
130 .collect(),
131 label: Some(label),
132 ..missing
133 }
134 }
135 Answer::Score(last) => Series {
136 values: all
137 .iter()
138 .map(|a| match a {
139 Answer::Score(a) => a.score,
140 _ => 0.0,
141 })
142 .collect(),
143 top: last.legend.len().saturating_sub(1) as f64,
144 readings: all
145 .iter()
146 .map(|a| match a {
147 Answer::Score(a) => format!("level {}", a.rounded_level()),
148 _ => String::new(),
149 })
150 .collect(),
151 ..missing
152 },
153 _ => missing,
154 }
155 })
156 .collect()
157}
158
159const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
160
161pub fn sparkline(values: &[f64], top: f64) -> String {
164 values
165 .iter()
166 .map(|v| {
167 let share = if top <= 0.0 {
168 0.0
169 } else {
170 (v / top).clamp(0.0, 1.0)
171 };
172 BLOCKS[(share * 7.0).round() as usize]
173 })
174 .collect()
175}
176
177pub fn changes(readings: &[String]) -> String {
179 let out: Vec<String> = readings
180 .windows(2)
181 .enumerate()
182 .filter(|(_, pair)| pair[0] != pair[1])
183 .map(|(i, pair)| format!("turn {} {}", i + 2, pair[1]))
184 .collect();
185 if out.is_empty() {
186 format!(
187 "{} throughout",
188 readings.first().map(String::as_str).unwrap_or("")
189 )
190 } else {
191 out.join(" · ")
192 }
193}
194
195fn summary(one: &Series) -> String {
197 let first = two(one.values.first().copied().unwrap_or(0.0));
198 let last = two(one.values.last().copied().unwrap_or(0.0));
199 match &one.label {
200 Some(label) => format!("{label} {first} → {last}"),
201 None if one.kind == "score" => format!("{first} → {last} of {}", one.top),
202 None => format!("{first} → {last}"),
203 }
204}
205
206pub fn trend_lines(all: &[Series]) -> Vec<Line<'static>> {
208 let width = all
209 .iter()
210 .map(|one| one.name.chars().count())
211 .max()
212 .unwrap_or(0);
213 let summaries: Vec<String> = all
214 .iter()
215 .map(|one| {
216 if one.values.is_empty() {
217 String::new()
218 } else {
219 summary(one)
220 }
221 })
222 .collect();
223 let summary_width = summaries
224 .iter()
225 .map(|text| text.chars().count())
226 .max()
227 .unwrap_or(0);
228 all.iter()
229 .zip(&summaries)
230 .map(|(one, summary)| {
231 let color = Style::new().fg(color_for(one.kind));
232 let mut spans = vec![
233 Span::raw(" "),
234 bold(pad_end(&one.name, width)),
235 Span::raw(" "),
236 Span::styled(pad_end(one.kind, 8), color),
237 ];
238 if one.values.is_empty() {
239 spans.push(dim("no answer to follow"));
240 return Line::from(spans);
241 }
242 spans.extend([
243 Span::styled(sparkline(&one.values, one.top), color),
244 Span::raw(" "),
245 Span::raw(pad_end(summary, summary_width)),
246 Span::raw(" "),
247 dim(changes(&one.readings)),
248 ]);
249 Line::from(spans)
250 })
251 .collect()
252}
253
254fn pad_end(text: &str, width: usize) -> String {
255 let length = text.chars().count();
256 if length >= width {
257 text.to_owned()
258 } else {
259 format!("{text}{}", " ".repeat(width - length))
260 }
261}