Add charts view with pie, bar, and line charts
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.
This commit is contained in:
401
outlay-gtk/src/charts_view.rs
Normal file
401
outlay-gtk/src/charts_view.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user