Pie chart shows expense breakdown by category with color-coded slices. Bar chart shows income vs expense for the last 6 months. Line chart shows cumulative daily net trend. All charts render via plotters BitMapBackend to GDK MemoryTexture for display. Month navigation shared across all three charts.
402 lines
15 KiB
Rust
402 lines
15 KiB
Rust
use adw::prelude::*;
|
|
use chrono::{Datelike, Local};
|
|
use gtk::glib;
|
|
use outlay_core::db::Database;
|
|
use outlay_core::models::TransactionType;
|
|
use plotters::prelude::*;
|
|
use std::cell::Cell;
|
|
use std::rc::Rc;
|
|
|
|
const CHART_WIDTH: u32 = 560;
|
|
const CHART_HEIGHT: u32 = 340;
|
|
|
|
pub struct ChartsView {
|
|
pub container: gtk::Box,
|
|
}
|
|
|
|
impl ChartsView {
|
|
pub fn new(db: Rc<Database>) -> Self {
|
|
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
|
|
let clamp = adw::Clamp::new();
|
|
clamp.set_maximum_size(700);
|
|
clamp.set_margin_start(12);
|
|
clamp.set_margin_end(12);
|
|
|
|
let inner = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
|
inner.set_margin_top(16);
|
|
inner.set_margin_bottom(16);
|
|
|
|
let today = Local::now().date_naive();
|
|
let current_year = Rc::new(Cell::new(today.year()));
|
|
let current_month = Rc::new(Cell::new(today.month()));
|
|
|
|
// Month navigation
|
|
let nav_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
nav_box.set_halign(gtk::Align::Center);
|
|
|
|
let prev_btn = gtk::Button::from_icon_name("go-previous-symbolic");
|
|
prev_btn.add_css_class("flat");
|
|
|
|
let month_label = gtk::Label::new(None);
|
|
month_label.add_css_class("title-3");
|
|
month_label.set_width_chars(16);
|
|
month_label.set_xalign(0.5);
|
|
|
|
let next_btn = gtk::Button::from_icon_name("go-next-symbolic");
|
|
next_btn.add_css_class("flat");
|
|
|
|
nav_box.append(&prev_btn);
|
|
nav_box.append(&month_label);
|
|
nav_box.append(&next_btn);
|
|
|
|
// Chart containers
|
|
let pie_frame = gtk::Frame::new(Some("Expenses by Category"));
|
|
let pie_picture = gtk::Picture::new();
|
|
pie_frame.set_child(Some(&pie_picture));
|
|
|
|
let bar_frame = gtk::Frame::new(Some("Monthly Income vs Expenses"));
|
|
let bar_picture = gtk::Picture::new();
|
|
bar_frame.set_child(Some(&bar_picture));
|
|
|
|
let line_frame = gtk::Frame::new(Some("Daily Net Trend"));
|
|
let line_picture = gtk::Picture::new();
|
|
line_frame.set_child(Some(&line_picture));
|
|
|
|
// Initial render
|
|
Self::update_month_label(&month_label, current_year.get(), current_month.get());
|
|
Self::render_all(&db, &pie_picture, &bar_picture, &line_picture, current_year.get(), current_month.get());
|
|
|
|
// Navigation
|
|
{
|
|
let db_ref = db.clone();
|
|
let year_ref = current_year.clone();
|
|
let month_ref = current_month.clone();
|
|
let label_ref = month_label.clone();
|
|
let pie_ref = pie_picture.clone();
|
|
let bar_ref = bar_picture.clone();
|
|
let line_ref = line_picture.clone();
|
|
prev_btn.connect_clicked(move |_| {
|
|
let mut y = year_ref.get();
|
|
let mut m = month_ref.get();
|
|
if m == 1 { m = 12; y -= 1; } else { m -= 1; }
|
|
year_ref.set(y);
|
|
month_ref.set(m);
|
|
Self::update_month_label(&label_ref, y, m);
|
|
Self::render_all(&db_ref, &pie_ref, &bar_ref, &line_ref, y, m);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let year_ref = current_year.clone();
|
|
let month_ref = current_month.clone();
|
|
let label_ref = month_label.clone();
|
|
let pie_ref = pie_picture.clone();
|
|
let bar_ref = bar_picture.clone();
|
|
let line_ref = line_picture.clone();
|
|
next_btn.connect_clicked(move |_| {
|
|
let mut y = year_ref.get();
|
|
let mut m = month_ref.get();
|
|
if m == 12 { m = 1; y += 1; } else { m += 1; }
|
|
year_ref.set(y);
|
|
month_ref.set(m);
|
|
Self::update_month_label(&label_ref, y, m);
|
|
Self::render_all(&db_ref, &pie_ref, &bar_ref, &line_ref, y, m);
|
|
});
|
|
}
|
|
|
|
inner.append(&nav_box);
|
|
inner.append(&pie_frame);
|
|
inner.append(&bar_frame);
|
|
inner.append(&line_frame);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
|
|
let scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.child(&clamp)
|
|
.build();
|
|
|
|
container.append(&scroll);
|
|
|
|
ChartsView { container }
|
|
}
|
|
|
|
fn update_month_label(label: >k::Label, year: i32, month: u32) {
|
|
let month_name = match month {
|
|
1 => "January", 2 => "February", 3 => "March",
|
|
4 => "April", 5 => "May", 6 => "June",
|
|
7 => "July", 8 => "August", 9 => "September",
|
|
10 => "October", 11 => "November", 12 => "December",
|
|
_ => "Unknown",
|
|
};
|
|
label.set_label(&format!("{} {}", month_name, year));
|
|
}
|
|
|
|
fn render_all(
|
|
db: &Database,
|
|
pie_picture: >k::Picture,
|
|
bar_picture: >k::Picture,
|
|
line_picture: >k::Picture,
|
|
year: i32,
|
|
month: u32,
|
|
) {
|
|
Self::render_pie_chart(db, pie_picture, year, month);
|
|
Self::render_bar_chart(db, bar_picture, year, month);
|
|
Self::render_line_chart(db, line_picture, year, month);
|
|
}
|
|
|
|
fn set_picture_from_rgb(picture: >k::Picture, rgb_data: &[u8], width: u32, height: u32) {
|
|
let bytes = glib::Bytes::from(rgb_data);
|
|
let stride = (width * 3) as usize;
|
|
let texture = gdk::MemoryTexture::new(
|
|
width as i32,
|
|
height as i32,
|
|
gdk::MemoryFormat::R8g8b8,
|
|
&bytes,
|
|
stride,
|
|
);
|
|
picture.set_paintable(Some(&texture));
|
|
}
|
|
|
|
fn render_pie_chart(db: &Database, picture: >k::Picture, year: i32, month: u32) {
|
|
let data = db
|
|
.get_monthly_totals_by_category(year, month, TransactionType::Expense)
|
|
.unwrap_or_default();
|
|
|
|
let mut buf = vec![0u8; (CHART_WIDTH * CHART_HEIGHT * 3) as usize];
|
|
{
|
|
let root = BitMapBackend::with_buffer(&mut buf, (CHART_WIDTH, CHART_HEIGHT))
|
|
.into_drawing_area();
|
|
root.fill(&WHITE).ok();
|
|
|
|
if data.is_empty() {
|
|
root.draw_text(
|
|
"No expense data this month",
|
|
&("sans-serif", 16).into_text_style(&root),
|
|
(CHART_WIDTH as i32 / 2 - 100, CHART_HEIGHT as i32 / 2),
|
|
).ok();
|
|
} else {
|
|
let total: f64 = data.iter().map(|(_, v)| *v).sum();
|
|
|
|
// Simple pie chart using plotters primitives
|
|
let center = ((CHART_WIDTH / 2) as i32, (CHART_HEIGHT / 2 - 20) as i32);
|
|
let radius = 120i32;
|
|
|
|
let colors = [
|
|
RGBColor(231, 76, 60), // red
|
|
RGBColor(52, 152, 219), // blue
|
|
RGBColor(46, 204, 113), // green
|
|
RGBColor(155, 89, 182), // purple
|
|
RGBColor(241, 196, 15), // yellow
|
|
RGBColor(230, 126, 34), // orange
|
|
RGBColor(26, 188, 156), // teal
|
|
RGBColor(233, 30, 99), // pink
|
|
RGBColor(0, 188, 212), // cyan
|
|
RGBColor(121, 85, 72), // brown
|
|
RGBColor(96, 125, 139), // grey-blue
|
|
RGBColor(255, 87, 34), // deep orange
|
|
RGBColor(63, 81, 181), // indigo
|
|
RGBColor(0, 150, 136), // dark teal
|
|
];
|
|
|
|
let mut start_angle = 0.0_f64;
|
|
for (i, (cat, amount)) in data.iter().enumerate() {
|
|
let pct = amount / total;
|
|
let sweep = pct * 360.0;
|
|
let color = &colors[i % colors.len()];
|
|
|
|
// Draw pie slice as filled polygon approximation
|
|
let steps = (sweep as usize).max(4);
|
|
for step in 0..steps {
|
|
let a1 = (start_angle + (step as f64 / steps as f64) * sweep).to_radians();
|
|
let a2 = (start_angle + ((step + 1) as f64 / steps as f64) * sweep).to_radians();
|
|
let p1 = (
|
|
center.0 + (radius as f64 * a1.cos()) as i32,
|
|
center.1 - (radius as f64 * a1.sin()) as i32,
|
|
);
|
|
let p2 = (
|
|
center.0 + (radius as f64 * a2.cos()) as i32,
|
|
center.1 - (radius as f64 * a2.sin()) as i32,
|
|
);
|
|
root.draw(&plotters::element::Polygon::new(
|
|
vec![center, p1, p2],
|
|
color.filled(),
|
|
)).ok();
|
|
}
|
|
|
|
// Label
|
|
if pct > 0.03 {
|
|
let mid_angle = (start_angle + sweep / 2.0).to_radians();
|
|
let label_r = radius as f64 + 30.0;
|
|
let lx = center.0 + (label_r * mid_angle.cos()) as i32;
|
|
let ly = center.1 - (label_r * mid_angle.sin()) as i32;
|
|
let text = format!("{} {:.0}%", cat.name, pct * 100.0);
|
|
root.draw_text(
|
|
&text,
|
|
&("sans-serif", 11).into_text_style(&root),
|
|
(lx.max(5), ly.max(5)),
|
|
).ok();
|
|
}
|
|
|
|
start_angle += sweep;
|
|
}
|
|
}
|
|
|
|
root.present().ok();
|
|
}
|
|
|
|
// Encode to PNG
|
|
Self::set_picture_from_rgb(picture, &buf, CHART_WIDTH, CHART_HEIGHT);
|
|
}
|
|
|
|
fn render_bar_chart(db: &Database, picture: >k::Picture, year: i32, month: u32) {
|
|
// Get last 6 months of data
|
|
let mut months: Vec<(i32, u32, f64, f64)> = Vec::new();
|
|
let mut y = year;
|
|
let mut m = month;
|
|
for _ in 0..6 {
|
|
let income = db.get_monthly_total(y, m, TransactionType::Income).unwrap_or(0.0);
|
|
let expense = db.get_monthly_total(y, m, TransactionType::Expense).unwrap_or(0.0);
|
|
months.push((y, m, income, expense));
|
|
if m == 1 { m = 12; y -= 1; } else { m -= 1; }
|
|
}
|
|
months.reverse();
|
|
|
|
let mut buf = vec![0u8; (CHART_WIDTH * CHART_HEIGHT * 3) as usize];
|
|
{
|
|
let root = BitMapBackend::with_buffer(&mut buf, (CHART_WIDTH, CHART_HEIGHT))
|
|
.into_drawing_area();
|
|
root.fill(&WHITE).ok();
|
|
|
|
let max_val = months.iter().map(|(_, _, i, e)| i.max(*e)).fold(0.0_f64, f64::max);
|
|
let y_max = if max_val > 0.0 { max_val * 1.2 } else { 100.0 };
|
|
|
|
let labels: Vec<String> = months
|
|
.iter()
|
|
.map(|(_, m, _, _)| {
|
|
match m {
|
|
1 => "Jan", 2 => "Feb", 3 => "Mar",
|
|
4 => "Apr", 5 => "May", 6 => "Jun",
|
|
7 => "Jul", 8 => "Aug", 9 => "Sep",
|
|
10 => "Oct", 11 => "Nov", 12 => "Dec",
|
|
_ => "?",
|
|
}.to_string()
|
|
})
|
|
.collect();
|
|
|
|
let mut chart = ChartBuilder::on(&root)
|
|
.margin(15)
|
|
.x_label_area_size(30)
|
|
.y_label_area_size(60)
|
|
.build_cartesian_2d(0..6i32, 0.0..y_max)
|
|
.unwrap();
|
|
|
|
chart.configure_mesh()
|
|
.x_labels(6)
|
|
.x_label_formatter(&|x| {
|
|
labels.get(*x as usize).cloned().unwrap_or_default()
|
|
})
|
|
.y_label_formatter(&|y| format!("{:.0}", y))
|
|
.draw()
|
|
.ok();
|
|
|
|
// Income bars (green)
|
|
chart.draw_series(
|
|
months.iter().enumerate().map(|(i, (_, _, income, _))| {
|
|
let x0 = i as i32;
|
|
Rectangle::new(
|
|
[(x0, 0.0), (x0, *income)],
|
|
RGBColor(46, 204, 113).filled(),
|
|
)
|
|
})
|
|
).ok();
|
|
|
|
root.present().ok();
|
|
}
|
|
|
|
Self::set_picture_from_rgb(picture, &buf, CHART_WIDTH, CHART_HEIGHT);
|
|
}
|
|
|
|
fn render_line_chart(db: &Database, picture: >k::Picture, year: i32, month: u32) {
|
|
let daily = db.get_daily_totals(year, month).unwrap_or_default();
|
|
|
|
let mut buf = vec![0u8; (CHART_WIDTH * CHART_HEIGHT * 3) as usize];
|
|
{
|
|
let root = BitMapBackend::with_buffer(&mut buf, (CHART_WIDTH, CHART_HEIGHT))
|
|
.into_drawing_area();
|
|
root.fill(&WHITE).ok();
|
|
|
|
if daily.is_empty() {
|
|
root.draw_text(
|
|
"No data this month",
|
|
&("sans-serif", 16).into_text_style(&root),
|
|
(CHART_WIDTH as i32 / 2 - 60, CHART_HEIGHT as i32 / 2),
|
|
).ok();
|
|
} else {
|
|
// Sort by date ascending
|
|
let mut sorted = daily.clone();
|
|
sorted.sort_by_key(|(d, _, _)| *d);
|
|
|
|
// Build cumulative net
|
|
let mut cumulative: Vec<(i32, f64)> = Vec::new();
|
|
let mut running = 0.0_f64;
|
|
for (date, income, expense) in &sorted {
|
|
running += income - expense;
|
|
cumulative.push((date.day() as i32, running));
|
|
}
|
|
|
|
let days_in_month = Self::days_in_month(year, month);
|
|
let min_val = cumulative.iter().map(|(_, v)| *v).fold(f64::INFINITY, f64::min);
|
|
let max_val = cumulative.iter().map(|(_, v)| *v).fold(f64::NEG_INFINITY, f64::max);
|
|
let margin = (max_val - min_val).max(10.0) * 0.1;
|
|
|
|
let mut chart = ChartBuilder::on(&root)
|
|
.margin(15)
|
|
.x_label_area_size(30)
|
|
.y_label_area_size(60)
|
|
.build_cartesian_2d(1..days_in_month as i32 + 1, (min_val - margin)..(max_val + margin))
|
|
.unwrap();
|
|
|
|
chart.configure_mesh()
|
|
.x_label_formatter(&|x| format!("{}", x))
|
|
.y_label_formatter(&|y| format!("{:.0}", y))
|
|
.draw()
|
|
.ok();
|
|
|
|
chart.draw_series(LineSeries::new(
|
|
cumulative.iter().map(|(d, v)| (*d, *v)),
|
|
&RGBColor(52, 152, 219),
|
|
)).ok();
|
|
|
|
// Zero line
|
|
chart.draw_series(LineSeries::new(
|
|
vec![(1, 0.0), (days_in_month as i32, 0.0)],
|
|
&RGBColor(200, 200, 200),
|
|
)).ok();
|
|
}
|
|
|
|
root.present().ok();
|
|
}
|
|
|
|
Self::set_picture_from_rgb(picture, &buf, CHART_WIDTH, CHART_HEIGHT);
|
|
}
|
|
|
|
fn days_in_month(year: i32, month: u32) -> u32 {
|
|
match month {
|
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
|
4 | 6 | 9 | 11 => 30,
|
|
2 => {
|
|
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
|
|
29
|
|
} else {
|
|
28
|
|
}
|
|
}
|
|
_ => 30,
|
|
}
|
|
}
|
|
|
|
}
|