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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1586,6 +1586,7 @@ name = "outlay-gtk"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"gdk4",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"outlay-core",
|
"outlay-core",
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ outlay-core = { path = "../outlay-core" }
|
|||||||
gtk = { package = "gtk4", version = "0.11" }
|
gtk = { package = "gtk4", version = "0.11" }
|
||||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] }
|
adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
gdk = { package = "gdk4", version = "0.11" }
|
||||||
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "line_series", "area_series"] }
|
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "line_series", "area_series"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod charts_view;
|
||||||
mod history_view;
|
mod history_view;
|
||||||
mod log_view;
|
mod log_view;
|
||||||
mod window;
|
mod window;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use adw::prelude::*;
|
|||||||
use outlay_core::db::Database;
|
use outlay_core::db::Database;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::charts_view::ChartsView;
|
||||||
use crate::history_view::HistoryView;
|
use crate::history_view::HistoryView;
|
||||||
use crate::log_view::LogView;
|
use crate::log_view::LogView;
|
||||||
|
|
||||||
@@ -48,8 +49,12 @@ impl MainWindow {
|
|||||||
.build();
|
.build();
|
||||||
content_stack.add_named(&history_scroll, Some("history"));
|
content_stack.add_named(&history_scroll, Some("history"));
|
||||||
|
|
||||||
|
// Charts view
|
||||||
|
let charts_view = ChartsView::new(db.clone());
|
||||||
|
content_stack.add_named(&charts_view.container, Some("charts"));
|
||||||
|
|
||||||
// Remaining pages are placeholders for now
|
// Remaining pages are placeholders for now
|
||||||
for item in &SIDEBAR_ITEMS[2..] {
|
for item in &SIDEBAR_ITEMS[3..] {
|
||||||
let page = adw::StatusPage::builder()
|
let page = adw::StatusPage::builder()
|
||||||
.title(item.label)
|
.title(item.label)
|
||||||
.icon_name(item.icon)
|
.icon_name(item.icon)
|
||||||
|
|||||||
Reference in New Issue
Block a user