use ratatui::style::Style;
use ratatui::text::{Line, Span};
use typesafe::{Answer, Question};
use crate::evaluate::two;
use crate::format::{bold, color_for, dim};
use crate::headless::Answered;
use crate::session::{Session, turns_to_json};
pub fn prefixes(session: &Session) -> Vec<Session> {
let Some(turns) = session.turns() else {
return Vec::new();
};
(1..=turns.len())
.map(|n| {
let mut so_far = session.clone();
so_far.state = turns_to_json(&turns[..n]);
so_far
})
.collect()
}
#[derive(Debug, Clone, PartialEq)]
pub struct Series {
pub name: String,
pub kind: &'static str,
pub values: Vec<f64>,
pub top: f64,
pub readings: Vec<String>,
pub label: Option<String>,
}
fn kind_of(question: &Question) -> &'static str {
match question {
Question::Noul(_) => "noul",
Question::Choice(_) => "choice",
Question::Score(_) => "score",
_ => "raw",
}
}
pub fn series(session: &Session, per_turn: &[Vec<Answered>], threshold: f64) -> Vec<Series> {
session
.questions
.iter()
.map(|(name, question)| {
let kind = kind_of(question);
let missing = Series {
name: name.clone(),
kind,
values: Vec::new(),
top: 1.0,
readings: Vec::new(),
label: None,
};
let answers: Option<Vec<&Answer>> = per_turn
.iter()
.map(|answered| {
answered
.iter()
.find(|(n, _)| n == name)
.and_then(|(_, a)| a.as_ref())
.filter(|a| a.kind() == kind)
})
.collect();
let Some(all) = answers.filter(|all| !all.is_empty()) else {
return missing;
};
match all[all.len() - 1] {
Answer::Noul(_) => {
let at = session.threshold_of(name, threshold);
let values: Vec<f64> = all
.iter()
.map(|a| match a {
Answer::Noul(a) => a.noul,
_ => 0.0,
})
.collect();
let readings = values
.iter()
.map(|p| if *p >= at { "yes" } else { "no" }.to_owned())
.collect();
Series {
values,
readings,
..missing
}
}
Answer::Choice(last) => {
let label = last.choice.clone();
Series {
values: all
.iter()
.map(|a| match a {
Answer::Choice(a) => a.probability(&label).unwrap_or(0.0),
_ => 0.0,
})
.collect(),
readings: all
.iter()
.map(|a| match a {
Answer::Choice(a) => a.choice.clone(),
_ => String::new(),
})
.collect(),
label: Some(label),
..missing
}
}
Answer::Score(last) => Series {
values: all
.iter()
.map(|a| match a {
Answer::Score(a) => a.score,
_ => 0.0,
})
.collect(),
top: last.legend.len().saturating_sub(1) as f64,
readings: all
.iter()
.map(|a| match a {
Answer::Score(a) => format!("level {}", a.rounded_level()),
_ => String::new(),
})
.collect(),
..missing
},
_ => missing,
}
})
.collect()
}
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
pub fn sparkline(values: &[f64], top: f64) -> String {
values
.iter()
.map(|v| {
let share = if top <= 0.0 {
0.0
} else {
(v / top).clamp(0.0, 1.0)
};
BLOCKS[(share * 7.0).round() as usize]
})
.collect()
}
pub fn changes(readings: &[String]) -> String {
let out: Vec<String> = readings
.windows(2)
.enumerate()
.filter(|(_, pair)| pair[0] != pair[1])
.map(|(i, pair)| format!("turn {} {}", i + 2, pair[1]))
.collect();
if out.is_empty() {
format!(
"{} throughout",
readings.first().map(String::as_str).unwrap_or("")
)
} else {
out.join(" · ")
}
}
fn summary(one: &Series) -> String {
let first = two(one.values.first().copied().unwrap_or(0.0));
let last = two(one.values.last().copied().unwrap_or(0.0));
match &one.label {
Some(label) => format!("{label} {first} → {last}"),
None if one.kind == "score" => format!("{first} → {last} of {}", one.top),
None => format!("{first} → {last}"),
}
}
pub fn trend_lines(all: &[Series]) -> Vec<Line<'static>> {
let width = all
.iter()
.map(|one| one.name.chars().count())
.max()
.unwrap_or(0);
let summaries: Vec<String> = all
.iter()
.map(|one| {
if one.values.is_empty() {
String::new()
} else {
summary(one)
}
})
.collect();
let summary_width = summaries
.iter()
.map(|text| text.chars().count())
.max()
.unwrap_or(0);
all.iter()
.zip(&summaries)
.map(|(one, summary)| {
let color = Style::new().fg(color_for(one.kind));
let mut spans = vec![
Span::raw(" "),
bold(pad_end(&one.name, width)),
Span::raw(" "),
Span::styled(pad_end(one.kind, 8), color),
];
if one.values.is_empty() {
spans.push(dim("no answer to follow"));
return Line::from(spans);
}
spans.extend([
Span::styled(sparkline(&one.values, one.top), color),
Span::raw(" "),
Span::raw(pad_end(summary, summary_width)),
Span::raw(" "),
dim(changes(&one.readings)),
]);
Line::from(spans)
})
.collect()
}
fn pad_end(text: &str, width: usize) -> String {
let length = text.chars().count();
if length >= width {
text.to_owned()
} else {
format!("{text}{}", " ".repeat(width - length))
}
}