use bevy_app::{App, Update};
use bevy_ecs::change_detection::{DetectChanges, DetectChangesMut};
use bevy_ecs::hierarchy::ChildOf;
use bevy_ecs::prelude::{Component, On, Query, Ref, Res, ResMut};
use plurimus::core::ratatui_core::buffer::Buffer;
use plurimus::core::ratatui_core::layout::{Position, Rect};
use plurimus::core::ratatui_core::style::{Color, Style};
use plurimus::core::ratatui_core::symbols::Marker;
use plurimus::core::ratatui_core::widgets::Widget;
use plurimus::core::{UiArea, UiWidget};
use plurimus::term::CursorCell;
use plurimus::ui::{ComputedWidgetArea, Hovered, PointerPress, PressFocusDisabled};
use plurimus::widgets::ratatui_widgets::chart::{Axis, Chart, Dataset, GraphType};
use retiretui_engine::plan::Dollars;
use retiretui_engine::project::{Projection, YearRow};
use crate::commands::table::basis_amount;
mod marks;
mod shades;
pub use marks::Mark;
pub use shades::Shade;
use super::layout::placed;
use super::pane::Framed;
use super::present::compact_dollars;
use super::session::YearCursor;
const AXIS_ROWS: u16 = 2;
const HEADROOM: f64 = 1.05;
pub fn plugin(app: &mut App) {
app.add_systems(Update, (draw_charts, read_out));
app.add_observer(handle_press);
}
#[derive(Component, Clone, Default)]
#[require(UiWidget, UiArea = placed(), Hovered, PressFocusDisabled)]
pub struct SeriesChart {
pub series: Vec<Series>,
pub x_bounds: [f64; 2],
pub y_bounds: [f64; 2],
pub x_labels: Vec<String>,
pub y_labels: Vec<String>,
pub axis: Style,
pub marks: Vec<Mark>,
pub shades: Vec<Shade>,
pub is_legend_hidden: bool,
}
#[derive(Clone)]
pub struct Series {
pub label: String,
pub color: Color,
pub points: Vec<(f64, f64)>,
}
impl Series {
pub fn of(
label: impl Into<String>,
color: Color,
projection: &Projection,
nominal: bool,
value: impl Fn(&YearRow) -> Dollars,
) -> Self {
let points = projection
.years
.iter()
.map(|row| {
let amount = basis_amount(value(row), row.deflator, nominal);
(f64::from(row.year), amount as f64)
})
.collect();
Self {
label: label.into(),
color,
points,
}
}
}
impl SeriesChart {
pub fn of(series: Vec<Series>, axis: Style) -> Self {
let (mut first, mut last) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut low, mut peak) = (0.0_f64, 0.0_f64);
for &(year, amount) in series.iter().flat_map(|series| &series.points) {
first = first.min(year);
last = last.max(year);
low = low.min(amount);
peak = peak.max(amount);
}
let (first, last) = if first.is_finite() {
(first, last)
} else {
(0.0, 1.0)
};
let x_bounds = if last > first {
[first, last]
} else {
[first - 0.5, first + 0.5]
};
let bottom = low * HEADROOM;
let top = match (peak > 0.0, bottom < 0.0) {
(true, _) => peak * HEADROOM,
(false, true) => 0.0,
(false, false) => 1.0,
};
let bottom_label = if bottom < 0.0 {
compact_dollars(bottom as Dollars)
} else {
"0".to_owned()
};
Self {
x_labels: vec![format!("{}", first as i64), format!("{}", last as i64)],
y_labels: vec![bottom_label, compact_dollars(top as Dollars)],
x_bounds,
y_bounds: [bottom, top],
series,
axis,
marks: Vec::new(),
shades: Vec::new(),
is_legend_hidden: false,
}
}
#[must_use]
pub fn shaded(mut self, shades: Vec<Shade>) -> Self {
let high = shades
.iter()
.flat_map(|shade| &shade.points)
.fold(0.0_f64, |high, &(_, _, point)| high.max(point));
if high * HEADROOM > self.y_bounds[1] {
self.y_bounds[1] = high * HEADROOM;
self.y_labels[1] = compact_dollars(self.y_bounds[1] as Dollars);
}
self.shades = shades;
self
}
fn graph(&self, area: Rect) -> Option<Rect> {
let y_labels = self
.y_labels
.iter()
.map(|label| label.chars().count())
.max();
let x_label = self.x_labels.first().map(|label| label.chars().count() - 1);
let inset = (y_labels.unwrap_or(0).max(x_label.unwrap_or(0)) as u16).min(area.width / 3);
let left = area.x + inset + 1;
let width = area.right().checked_sub(left)?;
let height = area.height.saturating_sub(AXIS_ROWS);
(width >= 2).then_some(Rect::new(left, area.y, width, height))
}
pub fn year_at(&self, area: Rect, cell: Position) -> Option<i16> {
let graph = self.graph(area)?;
if !area.contains(cell) || cell.x < graph.x {
return None;
}
Some(self.year_along(graph, cell.x).round() as i16)
}
fn year_along(&self, graph: Rect, column: u16) -> f64 {
let [first, last] = self.x_bounds;
let along = f64::from(column - graph.x) / f64::from(graph.width - 1);
first + along * (last - first)
}
pub fn column_of(&self, area: Rect, year: i16) -> Option<u16> {
self.column_in(self.graph(area)?, year)
}
fn column_in(&self, graph: Rect, year: i16) -> Option<u16> {
let [first, last] = self.x_bounds;
let along = (f64::from(year) - first) / (last - first);
(0.0..=1.0)
.contains(&along)
.then(|| graph.x + (along * f64::from(graph.width - 1)).round() as u16)
}
pub fn read_at(&self, area: Rect, cell: Position) -> Option<String> {
let year = self.year_at(area, cell)?;
let values: Vec<String> = self
.series
.iter()
.map(|series| {
series
.points
.iter()
.find(|&&(at, _)| (at - f64::from(year)).abs() < 0.5)
.map_or_else(
|| "-".to_owned(),
|&(_, amount)| compact_dollars(amount as Dollars),
)
})
.collect();
Some(format!("{year} · {}", values.join(" / ")))
}
}
impl Widget for &SeriesChart {
fn render(self, area: Rect, buf: &mut Buffer) {
shades::draw_shades(self, area, buf);
marks::draw_zero(self, area, buf);
marks::draw_rules(self, area, buf);
let datasets = self
.series
.iter()
.map(|series| {
Dataset::default()
.name(series.label.as_str())
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::new().fg(series.color))
.data(&series.points)
})
.collect();
let chart = Chart::new(datasets)
.x_axis(
Axis::default()
.style(self.axis)
.bounds(self.x_bounds)
.labels(self.x_labels.iter().map(String::as_str)),
)
.y_axis(
Axis::default()
.style(self.axis)
.bounds(self.y_bounds)
.labels(self.y_labels.iter().map(String::as_str)),
);
if self.is_legend_hidden {
chart.legend_position(None).render(area, buf);
} else {
chart.render(area, buf);
}
marks::draw_labels(self, area, buf);
}
}
fn draw_charts(mut charts: Query<(Ref<SeriesChart>, &mut UiWidget)>) {
for (chart, mut widget) in &mut charts {
if chart.is_changed() {
*widget = UiWidget::new(SeriesChart::clone(&chart));
}
}
}
fn read_out(
cursor: Res<CursorCell>,
charts: Query<(Ref<SeriesChart>, &ComputedWidgetArea, &ChildOf)>,
mut frames: Query<&mut Framed>,
) {
if !cursor.is_changed() && !charts.iter().any(|(chart, ..)| chart.is_changed()) {
return;
}
for (chart, area, parent) in &charts {
let note = cursor
.0
.and_then(|cell| chart.read_at(area.0, cell))
.unwrap_or_default();
if let Ok(mut framed) = frames.get_mut(parent.parent()) {
Framed::renote(&mut framed, ¬e);
}
}
}
fn handle_press(
press: On<PointerPress>,
charts: Query<(&SeriesChart, &ComputedWidgetArea)>,
mut cursor: ResMut<YearCursor>,
) {
if let Ok((chart, area)) = charts.get(press.entity)
&& let Some(year) = chart.year_at(area.0, press.position)
{
cursor.set_if_neq(YearCursor(Some(year)));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::tui::support::{
SIZE, click, composed_frame, headless_app, hover, overview_chart,
};
fn flat(first: i16, last: i16) -> SeriesChart {
let points = (first..=last).map(|year| (f64::from(year), 1.0)).collect();
let series = Series {
label: "flat".to_owned(),
color: Color::Reset,
points,
};
SeriesChart::of(vec![series], Style::new())
}
#[test]
fn a_column_reads_as_a_year_between_the_ends() {
let chart = flat(2026, 2050);
let area = Rect::new(1, 2, 38, 10);
assert_eq!(chart.year_at(area, Position::new(5, 3)), Some(2026));
assert_eq!(chart.year_at(area, Position::new(38, 3)), Some(2050));
assert_eq!(chart.year_at(area, Position::new(21, 3)), Some(2038));
assert_eq!(chart.year_at(area, Position::new(4, 3)), None, "the axis");
assert_eq!(chart.year_at(area, Position::new(5, 12)), None, "below");
assert_eq!(chart.year_at(area, Position::new(39, 3)), None, "beside");
assert_eq!(
chart.read_at(area, Position::new(5, 3)).as_deref(),
Some("2026 · 1")
);
let narrow = Rect::new(1, 2, 3, 10);
assert_eq!(
chart.year_at(narrow, Position::new(3, 3)),
None,
"too narrow"
);
}
#[test]
fn hovering_a_chart_names_the_year_in_its_pane_and_a_press_sets_the_cursor() {
let mut app = headless_app(SIZE);
app.update();
let chart = overview_chart(&mut app);
let area = app.world().get::<ComputedWidgetArea>(chart).unwrap().0;
let drawn = app.world().get::<SeriesChart>(chart).unwrap().clone();
let column = |year| drawn.column_of(area, year).unwrap();
let row = area.y + 1;
hover(&mut app, column(2026), row);
let frame = composed_frame(&app);
assert!(frame.contains("dollars · 2026 · "), "{frame}");
hover(&mut app, column(2050), row);
let frame = composed_frame(&app);
assert!(frame.contains("dollars · 2050 · "), "{frame}");
hover(&mut app, 0, 0);
let frame = composed_frame(&app);
assert!(
frame.contains("╭ Balances by tax treatment · today's dollars ─"),
"{frame}"
);
click(&mut app, column(2038), row);
assert_eq!(app.world().resource::<YearCursor>().0, Some(2038));
app.update();
let frame = composed_frame(&app);
assert!(
frame.contains("2038 · to do"),
"the To do pane follows: {frame}"
);
}
}