use std::marker::PhantomData;
use bevy_app::{App, Update};
use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::hierarchy::ChildOf;
use bevy_ecs::prelude::{
Commands, Component, Entity, IntoScheduleConfigs, Query, Res, ResMut, Resource,
};
use bevy_ui::{Display, Node};
use plurimus::core::UiWidget;
use plurimus::core::ratatui_core::layout::Direction;
use plurimus::core::ratatui_core::style::Style;
use plurimus::ui::ScrollArea;
use plurimus::widgets::WidgetSystems;
use plurimus::widgets::ratatui_widgets::barchart::{Bar, BarChart};
use retiretui_engine::market::{BAND_PERCENTILES, Runs};
use retiretui_engine::plan::Dollars;
use super::{MarketTool, count_text};
use crate::commands::tui::chart::{Series, SeriesChart};
use crate::commands::tui::command::Outcome;
use crate::commands::tui::edit::table_bundle;
use crate::commands::tui::layout::{self, filling, placed};
use crate::commands::tui::nav::Page;
use crate::commands::tui::present::{compact_dollars, rate};
use crate::commands::tui::tabulate;
use crate::commands::tui::theme::Theme;
use crate::commands::tui::tools::Tool;
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub(crate) enum View {
#[default]
Bands,
ByYear,
StillFunded,
Endings,
}
impl View {
fn next(self, has_by_year: bool) -> Self {
match self {
Self::Bands if has_by_year => Self::ByYear,
Self::Bands | Self::ByYear => Self::StillFunded,
Self::StillFunded => Self::Endings,
Self::Endings => Self::Bands,
}
}
}
#[derive(Resource)]
pub(crate) struct ViewOf<R>(pub View, PhantomData<R>);
impl<R> Default for ViewOf<R> {
fn default() -> Self {
Self(View::default(), PhantomData)
}
}
#[derive(Component)]
pub(super) struct ViewPart(pub Page, pub View);
pub(super) const BANDS_TITLE: &str = "Net Worth";
const BY_YEAR_TITLE: &str = "By Year · today's dollars";
const FUNDED_TITLE: &str = "Still Funded · share of runs";
const FUNDED_SERIES: usize = 0;
const PERCENT: f64 = 100.0;
const SHORT: &str = "short";
const ENDING_BREAKS: [Dollars; 4] = [1_000_000, 2_000_000, 4_000_000, 8_000_000];
const GAP: u16 = 1;
pub(super) fn install<R: MarketTool>(app: &mut App) {
app.init_resource::<ViewOf<R>>();
app.add_systems(
Update,
(show_view::<R>, refresh_views::<R>).before(WidgetSystems::Layout),
);
}
pub(super) fn spawn_parts(commands: &mut Commands, framed: Entity, page: Page) {
let hidden = || Node {
display: Display::None,
..filling()
};
commands.spawn((
table_bundle(),
ViewPart(page, View::ByYear),
layout::Rests,
hidden(),
placed(),
ChildOf(framed),
));
commands.spawn((
SeriesChart::default(),
ViewPart(page, View::StillFunded),
hidden(),
ChildOf(framed),
));
commands.spawn((
UiWidget::default(),
ViewPart(page, View::Endings),
hidden(),
placed(),
ChildOf(framed),
));
}
pub(crate) fn cycle_view<R: MarketTool>(mut view: ResMut<ViewOf<R>>) -> Outcome {
view.0 = view.0.next(R::HAS_BY_YEAR);
Outcome::Done
}
fn show_view<R: MarketTool>(view: Res<ViewOf<R>>, mut parts: Query<(&ViewPart, &mut Node)>) {
if !view.is_changed() {
return;
}
for (part, mut node) in &mut parts {
if part.0 != R::PAGE {
continue;
}
layout::set_display(&mut node, part.1 == view.0);
}
}
pub(super) fn title(view: View, runs: &Runs, label: &str) -> String {
match view {
View::Bands => format!("{BANDS_TITLE} · {label}"),
View::ByYear => BY_YEAR_TITLE.to_owned(),
View::StillFunded => FUNDED_TITLE.to_owned(),
View::Endings => format!("Ends With · {} runs", count_text(runs.runs.len())),
}
}
fn refresh_views<R: MarketTool>(
(tool, theme, view): (Res<Tool<R>>, Res<Theme>, Res<ViewOf<R>>),
mut parts: Query<(Entity, &ViewPart, Option<&mut ScrollArea>)>,
mut funded: Query<&mut SeriesChart>,
mut endings: Query<&mut UiWidget>,
mut commands: Commands,
) {
if !tool.is_changed() && !theme.is_changed() && !view.is_changed() {
return;
}
let Some(found) = tool.found() else {
return;
};
let runs = found.runs();
for (part, shown, scroll) in &mut parts {
if shown.0 != R::PAGE || shown.1 != view.0 {
continue;
}
match (shown.1, scroll) {
(View::ByYear, Some(mut scroll)) => {
fill_by_year(&mut commands, (part, &mut scroll), runs);
}
(View::StillFunded, _) => {
if let Ok(mut chart) = funded.get_mut(part) {
*chart = still_funded(runs, &theme);
}
}
(View::Endings, _) => {
if let Ok(mut widget) = endings.get_mut(part) {
*widget = UiWidget::new(endings_chart(runs, &theme));
}
}
_ => {}
}
}
}
fn fill_by_year(commands: &mut Commands, (table, scroll): (Entity, &mut ScrollArea), runs: &Runs) {
let mut header = vec!["Year".to_owned()];
header.extend(
BAND_PERCENTILES
.iter()
.map(|percentile| format!("{percentile}th")),
);
header.push("Funded".to_owned());
let rows: Vec<Vec<String>> = runs
.bands
.iter()
.map(|band| {
let mut cells = vec![band.year.to_string()];
cells.extend(band.net_worth.iter().map(|&worth| compact_dollars(worth)));
cells.push(rate(band.funded));
cells
})
.collect();
let columns = tabulate::columns((&header, &rows), GAP);
commands.entity(table).insert(columns);
tabulate::refill(commands, (table, scroll), (&header, &rows), &[0]);
}
fn still_funded(runs: &Runs, theme: &Theme) -> SeriesChart {
let points = runs
.bands
.iter()
.map(|band| (f64::from(band.year), band.funded * PERCENT))
.collect();
let series = Series {
label: "still funded".to_owned(),
color: theme.series(FUNDED_SERIES),
points,
};
let mut chart = SeriesChart::of(vec![series], theme.dimmed());
chart.y_bounds = [0.0, PERCENT];
chart.y_labels = vec!["0%".to_owned(), "100%".to_owned()];
chart
}
fn endings_chart(runs: &Runs, theme: &Theme) -> BarChart<'static> {
let mut counts = vec![0_u64; ENDING_BREAKS.len() + 2];
for run in &runs.runs {
let at = if run.first_short.is_some() {
0
} else {
1 + ENDING_BREAKS
.iter()
.filter(|&&edge| run.ending >= edge)
.count()
};
counts[at] += 1;
}
let bars: Vec<Bar<'static>> = counts
.iter()
.enumerate()
.map(|(at, &count)| {
let bar = Bar::with_label(bucket_label(at), count).text_value(count.to_string());
if at == 0 {
bar.style(theme.exceeded())
} else {
bar.style(Style::new().fg(theme.series(FUNDED_SERIES)))
}
})
.collect();
BarChart::horizontal(bars)
.direction(Direction::Horizontal)
.bar_width(1)
.bar_gap(0)
.label_style(theme.dimmed())
}
fn bucket_label(at: usize) -> String {
match at {
0 => SHORT.to_owned(),
1 => format!("<{}", compact_dollars(ENDING_BREAKS[0])),
_ if at > ENDING_BREAKS.len() => format!(
"{}+",
compact_dollars(ENDING_BREAKS[ENDING_BREAKS.len() - 1])
),
_ => format!(
"{}–{}",
compact_dollars(ENDING_BREAKS[at - 2]),
compact_dollars(ENDING_BREAKS[at - 1])
),
}
}