Compare commits
39 Commits
1dab0d748f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a218bb88 | |||
| 7c75bd3c37 | |||
| 1e4efa0c5b | |||
| 99a7920118 | |||
| 8d660ea8c2 | |||
| be3f3b604e | |||
| 6a497c8575 | |||
| ab1a1c3aad | |||
| 050f5f0ae4 | |||
| 393f654a57 | |||
| 29aaa9066f | |||
| 8c2fa9296d | |||
| 0fdaff0fa2 | |||
| ba6b6c2e4a | |||
| f4b73064ff | |||
| e2bf90d4d8 | |||
| 3dad927bf7 | |||
| 7c72e59a99 | |||
| d8f571bf31 | |||
| ebe5857d37 | |||
| 97e770ab0d | |||
| 0816ebd26b | |||
| 8a0d7f748f | |||
| 14e830ead0 | |||
| d4211b6744 | |||
| c72885258b | |||
| fb3a54d430 | |||
| 37bc515712 | |||
| a31f754cfd | |||
| 90024635eb | |||
| 9ff0a2b434 | |||
| 1bf13e77e3 | |||
| 5e78751921 | |||
| cc8e974589 | |||
| 0c21d671a9 | |||
| 1116da8251 | |||
| 802bf3e837 | |||
| c745d23ab8 | |||
| dd9e44eace |
21
README.md
21
README.md
@@ -1,3 +1,22 @@
|
||||
# banking-breakdown
|
||||
|
||||
Visualize banking statements.
|
||||
Visualize bank statements.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Assign categories to bank statement entries:
|
||||
```bash
|
||||
$ python -m banking_breakdown categorize -i [bank_statement.csv] -f [regex_file]
|
||||
```
|
||||
2. Generate report from categorized data:
|
||||
```bash
|
||||
$ python -m banking_breakdown report -i [categorized.csv]
|
||||
```
|
||||
|
||||
## Other
|
||||
|
||||
### Generate GUI from `*.ui` file
|
||||
|
||||
```bash
|
||||
$ pyuic6 res/main_window.ui -o banking_breakdown/ui/main_window.py
|
||||
```
|
||||
@@ -1,35 +1,61 @@
|
||||
import pandas as pd
|
||||
from banking_breakdown import document_builder
|
||||
import subprocess
|
||||
import os
|
||||
import shutil
|
||||
from banking_breakdown import ui, regex_categorizer, statement_parser, \
|
||||
document_builder
|
||||
|
||||
import pandas as pd
|
||||
import argparse
|
||||
|
||||
from banking_breakdown import types
|
||||
import numpy as np
|
||||
|
||||
def categorize_func(args):
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_csv(args.i, delimiter=args.d)
|
||||
|
||||
if args.f is not None:
|
||||
df = regex_categorizer.assign_categories(df, args.f)
|
||||
|
||||
import signal
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
|
||||
ui.show_main_window(df=df)
|
||||
|
||||
|
||||
def report_func(args):
|
||||
report_data = statement_parser.parse_statement(args.i)
|
||||
document_builder.build_document(report_data)
|
||||
|
||||
|
||||
#
|
||||
# Define CLI
|
||||
#
|
||||
|
||||
|
||||
def main():
|
||||
categories = ["A", "B", "C", "D", "E", "F", "G"]
|
||||
values = np.array([10, 12, 53, 12, 90, 23, 32])
|
||||
values = values / values.sum() * 100
|
||||
parser = argparse.ArgumentParser(prog='banking_breakdown',
|
||||
description='Visualize bank statements')
|
||||
|
||||
total_value = np.random.normal(size=10) + 4
|
||||
net_income = np.diff(total_value)
|
||||
subparsers = parser.add_subparsers()
|
||||
|
||||
category_overview_df = pd.DataFrame(
|
||||
{"category": categories, "value": values.astype('int32')})
|
||||
t = np.linspace(0, total_value.size, total_value.size)
|
||||
total_value_df = pd.DataFrame({"t": t, "value": total_value})
|
||||
t = np.linspace(0, net_income.size, net_income.size)
|
||||
net_income_df = pd.DataFrame({"t": t, "value": net_income})
|
||||
categorize_parser = subparsers.add_parser("categorize")
|
||||
categorize_parser.set_defaults(func=categorize_func)
|
||||
categorize_parser.add_argument('-i', required=True,
|
||||
help="Bank statement CSV")
|
||||
categorize_parser.add_argument('-f', required=False,
|
||||
help="JSON file containing regexes to"
|
||||
" pre-categorize statement entries")
|
||||
categorize_parser.add_argument('-d', required=False,
|
||||
help="Delimiter to use when reading the"
|
||||
" bank statement", default=',')
|
||||
|
||||
report_data = types.ReportData(category_overview=category_overview_df,
|
||||
net_income=net_income_df,
|
||||
total_value=total_value_df)
|
||||
report_parser = subparsers.add_parser("report")
|
||||
report_parser.set_defaults(func=report_func)
|
||||
report_parser.add_argument('-i', required=True,
|
||||
help="CSV output file from categorization step")
|
||||
|
||||
document_builder.build_document(report_data)
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(args, 'func'):
|
||||
args.func(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -14,7 +14,11 @@ def _serialize_report_data(report_data: types.ReportData):
|
||||
report_data.net_income.to_csv('build/net_income.csv', index=False)
|
||||
report_data.category_overview.to_csv('build/category_overview.csv',
|
||||
index=False)
|
||||
report_data.expenses_by_category.to_csv('build/expenses_by_category.csv',
|
||||
index=False)
|
||||
report_data.total_value.to_csv('build/total_value.csv', index=False)
|
||||
report_data.detailed_balance.to_csv('build/detailed_balance.csv',
|
||||
index=False)
|
||||
|
||||
|
||||
def _compile_document():
|
||||
|
||||
54
banking_breakdown/regex_categorizer.py
Normal file
54
banking_breakdown/regex_categorizer.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import pandas as pd
|
||||
import json
|
||||
|
||||
|
||||
def _is_str_column(s: pd.Series):
|
||||
"""Check if the type of a pandas DataFrame column is str.
|
||||
|
||||
Taken from https://stackoverflow.com/a/67001213/3433817.
|
||||
"""
|
||||
if isinstance(s.dtype, pd.StringDtype):
|
||||
# The series was explicitly created as a string series (Pandas>=1.0.0)
|
||||
return True
|
||||
elif s.dtype == 'object':
|
||||
# Object series, check each value
|
||||
return all((v is None) or isinstance(v, str) for v in s)
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def _read_regex_dict(regex_file: str):
|
||||
with open(regex_file, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def assign_categories(df: pd.DataFrame, regex_file: str) -> pd.DataFrame:
|
||||
if 'category' not in df.columns:
|
||||
df['category'] = [' '] * len(df.index)
|
||||
|
||||
regex_dict = _read_regex_dict(regex_file)
|
||||
|
||||
df = df.fillna('')
|
||||
|
||||
for column in df.columns:
|
||||
if not _is_str_column(df[column]):
|
||||
continue
|
||||
|
||||
for category in regex_dict:
|
||||
for regex in regex_dict[category]:
|
||||
matched = df[column].str.contains(regex, regex=True)
|
||||
df.loc[matched, 'category'] = category
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def main():
|
||||
df = pd.read_csv('../res/bank_statement_2023_categorized.csv')
|
||||
|
||||
df = assign_categories(df, regex_file='../res/regexes.json')
|
||||
|
||||
print(df['category'])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
122
banking_breakdown/statement_parser.py
Normal file
122
banking_breakdown/statement_parser.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import typing
|
||||
import pandas as pd
|
||||
from banking_breakdown import types
|
||||
import json
|
||||
import re
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _escape_string(to_escape: str):
|
||||
return to_escape.translate(str.maketrans({"&": r"\&"}))
|
||||
|
||||
|
||||
def _compute_total_balance(df: pd.DataFrame) -> pd.DataFrame:
|
||||
stripped_df = pd.DataFrame(
|
||||
{'t': df["t"], 'value': df["balance"]})
|
||||
|
||||
stripped_df.index = stripped_df['t']
|
||||
gb = stripped_df.groupby(pd.Grouper(freq='M'))
|
||||
|
||||
result = gb.tail(1)['value'].reset_index()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _compute_net_income(df: pd.DataFrame) -> pd.DataFrame:
|
||||
df.index = df['t']
|
||||
income_df = df.loc[df['value'] > 0]
|
||||
expenses_df = df.loc[df['value'] < 0]
|
||||
|
||||
income_df = income_df.groupby(pd.Grouper(freq='M'))[
|
||||
'value'].sum().reset_index().round(decimals=2)
|
||||
expenses_df = expenses_df.groupby(pd.Grouper(freq='M'))[
|
||||
'value'].sum().reset_index().round(decimals=2)
|
||||
|
||||
t = income_df['t']
|
||||
income = income_df['value'].round(decimals=2)
|
||||
expenses = expenses_df['value'].round(decimals=2)
|
||||
net = (income + expenses).round(decimals=2)
|
||||
|
||||
result_df = pd.DataFrame(
|
||||
{'t': t, 'income': income, 'expenses': expenses, 'net': net})
|
||||
|
||||
return result_df
|
||||
|
||||
|
||||
def _compute_category_overview(df: pd.DataFrame) -> pd.DataFrame:
|
||||
df = df.loc[df['value'] < 0]
|
||||
df = df.drop('t', axis=1)
|
||||
df = df.groupby(['category']).sum().reset_index()
|
||||
values = (df['value'] / df['value'].sum() * 100).to_numpy()
|
||||
values[-1] += 100 - np.sum(values)
|
||||
values = np.round(values, decimals=1)
|
||||
categories = [_escape_string(category) for category in df['category']]
|
||||
|
||||
category_overview_df = pd.DataFrame(
|
||||
{"category": categories, "value": values})
|
||||
|
||||
category_overview_df = category_overview_df.sort_values('value',
|
||||
ascending=False)
|
||||
|
||||
return category_overview_df
|
||||
|
||||
|
||||
def _compute_expenses_by_category(complete_df: pd.DataFrame) -> pd.DataFrame:
|
||||
complete_df = complete_df.loc[complete_df['value'] < 0].copy()
|
||||
complete_df['value'] = -complete_df['value']
|
||||
complete_df.index = complete_df['t']
|
||||
|
||||
complete_gb = complete_df.groupby(pd.Grouper(freq='M'))
|
||||
|
||||
categories = complete_df['category'].unique()
|
||||
data_dict = {category: [] for category in categories}
|
||||
|
||||
for (month_date, month_df) in complete_gb:
|
||||
month_df = month_df.drop('t', axis=1).reset_index().drop('t', axis=1)
|
||||
|
||||
category_df = month_df.groupby(['category']).sum().reset_index()
|
||||
|
||||
for _, row in category_df.iterrows():
|
||||
data_dict[row['category']].append(row['value'])
|
||||
|
||||
non_listed = list(set(categories) - set(category_df['category']))
|
||||
for category in non_listed:
|
||||
data_dict[category].append(0)
|
||||
|
||||
result = pd.DataFrame(data_dict)
|
||||
result = result.reindex(result.mean().sort_values(ascending=False).index,
|
||||
axis=1)
|
||||
result = result.round(decimals=2)
|
||||
result['t'] = complete_gb.tail(1).drop('t', axis=1).reset_index()['t']
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _compute_detailed_balance(df: pd.DataFrame) -> pd.DataFrame:
|
||||
return pd.DataFrame({'t': df["t"],
|
||||
'value': df["balance"]})
|
||||
|
||||
|
||||
def parse_statement(filename: str) -> types.ReportData:
|
||||
df = pd.read_csv(filename)
|
||||
df["t"] = pd.to_datetime(df["t"], format='%Y-%m-%d')
|
||||
|
||||
category_overview_df = _compute_category_overview(df)
|
||||
total_balance_df = _compute_total_balance(df)
|
||||
net_income_df = _compute_net_income(df)
|
||||
detailed_balance_df = _compute_detailed_balance(df)
|
||||
expenses_by_category_df = _compute_expenses_by_category(df)
|
||||
|
||||
return types.ReportData(category_overview_df,
|
||||
expenses_by_category_df,
|
||||
net_income_df,
|
||||
total_balance_df,
|
||||
detailed_balance_df, )
|
||||
|
||||
|
||||
def main():
|
||||
report_data = parse_statement("../res/bank_statement_2023_categorized.csv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
banking_breakdown/test.json
Normal file
5
banking_breakdown/test.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"asdf": [
|
||||
"Kinemic"
|
||||
]
|
||||
}
|
||||
@@ -5,5 +5,7 @@ import pandas as pd
|
||||
@dataclass
|
||||
class ReportData:
|
||||
category_overview: pd.DataFrame
|
||||
expenses_by_category: pd.DataFrame
|
||||
net_income: pd.DataFrame
|
||||
total_value: pd.DataFrame
|
||||
detailed_balance: pd.DataFrame
|
||||
|
||||
17
banking_breakdown/ui/__init__.py
Normal file
17
banking_breakdown/ui/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import sys
|
||||
|
||||
import pandas as pd
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from banking_breakdown.ui.main_window import MainWindow
|
||||
|
||||
|
||||
def show_main_window(df: pd.DataFrame = None):
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
|
||||
if df is not None:
|
||||
window.set_statement_data(df)
|
||||
|
||||
window.show()
|
||||
app.exec()
|
||||
161
banking_breakdown/ui/custom_ui_items.py
Normal file
161
banking_breakdown/ui/custom_ui_items.py
Normal file
@@ -0,0 +1,161 @@
|
||||
import pandas as pd
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QPixmap, QAction
|
||||
from PyQt6.QtWidgets import QHBoxLayout, QLabel, QMenu, QInputDialog, \
|
||||
QMessageBox
|
||||
|
||||
from banking_breakdown.ui.pandas_model import PandasModel
|
||||
|
||||
|
||||
class WarningItem(QHBoxLayout):
|
||||
"""Item appearing at top of Window with warning icon."""
|
||||
|
||||
def __init__(self, text: str, parent=None):
|
||||
super(WarningItem, self).__init__()
|
||||
|
||||
self._warningIcon = QLabel()
|
||||
pixmap = QPixmap("res/warning.png")
|
||||
self._warningIcon.setPixmap(pixmap)
|
||||
|
||||
self._label = QLabel(text)
|
||||
self._label.setWordWrap(True)
|
||||
|
||||
self.addWidget(self._warningIcon)
|
||||
self.addWidget(self._label)
|
||||
|
||||
self.setStretch(0, 0)
|
||||
self.setStretch(1, 1)
|
||||
|
||||
def hide(self):
|
||||
self._label.hide()
|
||||
self._warningIcon.hide()
|
||||
|
||||
|
||||
class HeaderContextMenu(QMenu):
|
||||
"""Context menu appearing when right-clicking the header of the QTableView.
|
||||
"""
|
||||
|
||||
def __init__(self, column_index, pandas_model: PandasModel, callback=None,
|
||||
parent=None):
|
||||
super(HeaderContextMenu, self).__init__()
|
||||
|
||||
self._pandas_model = pandas_model
|
||||
self._callback = callback
|
||||
|
||||
self._column_index = column_index
|
||||
self._column_text \
|
||||
= self._pandas_model.headerData(self._column_index,
|
||||
Qt.Orientation.Horizontal)
|
||||
|
||||
# Define assign action
|
||||
|
||||
assign_menu = QMenu("Assign type", self)
|
||||
assign_date_action = QAction("date", self)
|
||||
assign_float_action = QAction("float", self)
|
||||
|
||||
assign_menu.addAction(assign_date_action)
|
||||
assign_menu.addAction(assign_float_action)
|
||||
|
||||
assign_date_action.triggered.connect(self._assign_date_handler)
|
||||
assign_float_action.triggered.connect(self._assign_float_handler)
|
||||
|
||||
# Define other actions
|
||||
|
||||
rename_action = QAction("Rename", self)
|
||||
delete_action = QAction("Delete", self)
|
||||
switch_action = QAction("Switch position", self)
|
||||
|
||||
rename_action.triggered.connect(self._rename_handler)
|
||||
delete_action.triggered.connect(self._delete_handler)
|
||||
switch_action.triggered.connect(self._switch_handler)
|
||||
|
||||
# Add actions to menu
|
||||
|
||||
self.addAction(rename_action)
|
||||
self.addAction(delete_action)
|
||||
self.addAction(switch_action)
|
||||
self.addAction(assign_menu.menuAction())
|
||||
|
||||
def _rename_handler(self):
|
||||
new_name, flag = QInputDialog.getText(self, "Rename column",
|
||||
"New name:",
|
||||
text=self._column_text)
|
||||
|
||||
if not flag:
|
||||
return
|
||||
|
||||
if (new_name != self._column_text) and (new_name != ''):
|
||||
try:
|
||||
self._pandas_model.rename_column(self._column_text, new_name)
|
||||
except:
|
||||
QMessageBox.warning(self, "No action performed",
|
||||
"An error occurred.")
|
||||
|
||||
if self._callback:
|
||||
self._callback()
|
||||
|
||||
def _delete_handler(self):
|
||||
button = QMessageBox.question(self, "Delete column",
|
||||
f"Are you sure you want to delete"
|
||||
f" column '{self._column_text}'?")
|
||||
|
||||
if button == QMessageBox.StandardButton.Yes:
|
||||
self._pandas_model.delete_column_by_index(self._column_index)
|
||||
|
||||
if self._callback:
|
||||
self._callback()
|
||||
|
||||
def _switch_handler(self):
|
||||
df = self._pandas_model.get_dataframe()
|
||||
columns = [column for column in df.columns
|
||||
if column != self._column_text]
|
||||
|
||||
other_name, flag = QInputDialog.getItem(self, "Switch column position",
|
||||
f"Switch position of colum "
|
||||
f"'{self._column_text}' with:",
|
||||
columns, editable=False)
|
||||
|
||||
if not flag:
|
||||
return
|
||||
|
||||
self._pandas_model.switch_columns(self._column_text, other_name)
|
||||
|
||||
if self._callback:
|
||||
self._callback()
|
||||
|
||||
def _assign_date_handler(self):
|
||||
date_format, flag = QInputDialog.getText(self, "Format",
|
||||
"Format:",
|
||||
text="%d.%m.%Y")
|
||||
|
||||
if not flag:
|
||||
return
|
||||
|
||||
try:
|
||||
self._pandas_model.assign_date_column(self._column_text,
|
||||
date_format)
|
||||
except:
|
||||
QMessageBox.warning(self, "No action performed",
|
||||
"An error occurred.")
|
||||
|
||||
if self._callback:
|
||||
self._callback()
|
||||
|
||||
def _assign_float_handler(self):
|
||||
chars = ['.', ',']
|
||||
decimal_sep, flag = QInputDialog.getItem(self, "Decimal separator",
|
||||
"Decimal separator:",
|
||||
chars, editable=False)
|
||||
|
||||
if not flag:
|
||||
return
|
||||
|
||||
try:
|
||||
self._pandas_model.assign_float_column(self._column_text,
|
||||
decimal_sep)
|
||||
except:
|
||||
QMessageBox.warning(self, "No action performed",
|
||||
"An error occurred.")
|
||||
|
||||
if self._callback:
|
||||
self._callback()
|
||||
245
banking_breakdown/ui/main_window.py
Normal file
245
banking_breakdown/ui/main_window.py
Normal file
@@ -0,0 +1,245 @@
|
||||
import typing
|
||||
import pandas as pd
|
||||
from PyQt6 import uic
|
||||
from PyQt6.QtCore import Qt, QSortFilterProxyModel
|
||||
from PyQt6.QtGui import QAction
|
||||
from PyQt6.QtWidgets import QMainWindow, QPushButton, QVBoxLayout, \
|
||||
QTableView, QInputDialog, QMessageBox, QFileDialog, QListWidget
|
||||
|
||||
from banking_breakdown.ui.pandas_model import PandasModel
|
||||
from banking_breakdown.ui.custom_ui_items import WarningItem, HeaderContextMenu
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super(MainWindow, self).__init__()
|
||||
|
||||
uic.loadUi("res/main_window.ui", self)
|
||||
self.setContentsMargins(9, 9, 9, 9)
|
||||
|
||||
self._warnings = []
|
||||
|
||||
# Extract elements
|
||||
|
||||
self._warning_layout \
|
||||
= self.findChild(QVBoxLayout, "warningLayout")
|
||||
self._create_button \
|
||||
= self.findChild(QPushButton, "createCategoryButton")
|
||||
self._delete_button \
|
||||
= self.findChild(QPushButton, "deleteCategoryButton")
|
||||
self._apply_button \
|
||||
= self.findChild(QPushButton, "applyCategoryButton")
|
||||
self._clear_button \
|
||||
= self.findChild(QPushButton, "clearCategoryButton")
|
||||
self._list_widget \
|
||||
= self.findChild(QListWidget, "categoryListWidget")
|
||||
self._table_view \
|
||||
= self.findChild(QTableView, "transactionTableView")
|
||||
self._action_save \
|
||||
= self.findChild(QAction, "actionSave")
|
||||
|
||||
# Set up QTableView model
|
||||
|
||||
self._pandas_model = PandasModel(self)
|
||||
self._proxy_model = QSortFilterProxyModel(self)
|
||||
self._proxy_model.setSourceModel(self._pandas_model)
|
||||
self._table_view.setModel(self._proxy_model)
|
||||
self._proxy_model.setSortRole(Qt.ItemDataRole.EditRole)
|
||||
self._proxy_model.setDynamicSortFilter(False)
|
||||
|
||||
# Set event handlers
|
||||
|
||||
self._create_button.clicked.connect(self._handle_create_click)
|
||||
self._delete_button.clicked.connect(self._handle_delete_click)
|
||||
self._clear_button.clicked.connect(self._handle_clear_click)
|
||||
self._apply_button.clicked.connect(self._handle_apply_click)
|
||||
self._list_widget.itemActivated.connect(self._handle_item_double_click)
|
||||
|
||||
self._action_save.triggered.connect(self._handle_save)
|
||||
|
||||
header = self._table_view.horizontalHeader()
|
||||
header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
header.customContextMenuRequested.connect(
|
||||
self._handle_header_right_click)
|
||||
|
||||
self._list_widget.itemSelectionChanged.connect(
|
||||
self._handle_list_selection_changed)
|
||||
|
||||
self._table_view.selectionModel().selectionChanged.connect(
|
||||
self._handle_table_selection_changed)
|
||||
|
||||
#
|
||||
# Table data updates
|
||||
#
|
||||
|
||||
def set_statement_data(self, df: pd.DataFrame):
|
||||
self._pandas_model.set_dataframe(df)
|
||||
self._dataframe_update_callback()
|
||||
self._resize_table_columns_to_content()
|
||||
|
||||
def get_statement_data(self) -> pd.DataFrame:
|
||||
return self._pandas_model.get_dataframe()
|
||||
|
||||
def _dataframe_update_callback(self):
|
||||
self._show_warnings()
|
||||
self._update_categories_from_dataframe()
|
||||
|
||||
def _resize_table_columns_to_content(self):
|
||||
df = self._pandas_model.get_dataframe()
|
||||
|
||||
# Resize columns
|
||||
|
||||
if len(df.columns) < 10: # Experimentally determined threshold
|
||||
# Properly resize columns (takes longer)
|
||||
self._table_view.resizeColumnsToContents()
|
||||
else:
|
||||
# Quickly approximate sizes
|
||||
for i, col in enumerate(df.columns):
|
||||
max_char = max(max([len(str(x)) for x in df[col].values]),
|
||||
len(col))
|
||||
self._table_view.setColumnWidth(i, max_char * 10)
|
||||
|
||||
def _assign_category_to_selected_transactions(self, category: str):
|
||||
indexes = self._table_view.selectionModel().selectedRows()
|
||||
row_indices = [self._table_view.model().mapToSource(index).row()
|
||||
for index in indexes]
|
||||
|
||||
self._pandas_model.assign_category(category, row_indices)
|
||||
|
||||
#
|
||||
# List data updates
|
||||
#
|
||||
|
||||
def _add_categories(self, categories: typing.Sequence[str]):
|
||||
for category in categories:
|
||||
self._list_widget.addItem(category)
|
||||
|
||||
def _update_categories_from_dataframe(self):
|
||||
df_categories = self._pandas_model.get_categories()
|
||||
current_categories = [self._list_widget.item(x).text() for x
|
||||
in range(self._list_widget.count())]
|
||||
missing = list(set(df_categories) - set(current_categories))
|
||||
|
||||
self._add_categories([category for category
|
||||
in missing if category != ' '])
|
||||
|
||||
#
|
||||
# Warnings
|
||||
#
|
||||
|
||||
def _add_warning_item(self, text: str):
|
||||
warning_item = WarningItem(text=text, parent=self)
|
||||
|
||||
self._warning_layout.addLayout(warning_item)
|
||||
self._warnings.append(warning_item)
|
||||
|
||||
def _show_warnings(self):
|
||||
for warning_item in self._warnings:
|
||||
warning_item.hide()
|
||||
self._warning_layout.removeItem(warning_item)
|
||||
|
||||
columns = self._pandas_model.get_columns()
|
||||
|
||||
if 't' not in columns:
|
||||
self._add_warning_item(
|
||||
"The column 't' does not exist. Please rename the column"
|
||||
" containing the dates of the transactions to 't'.")
|
||||
|
||||
if 'value' not in columns:
|
||||
self._add_warning_item(
|
||||
"The column 'value' does not exist. Please rename the column"
|
||||
" containing the values of the transactions to 'value'.")
|
||||
|
||||
if 'balance' not in columns:
|
||||
self._add_warning_item(
|
||||
"The column 'balance' does not exist. Please rename the column"
|
||||
" containing the balance after each transaction to 'balance'")
|
||||
|
||||
#
|
||||
# Event handlers
|
||||
#
|
||||
|
||||
def _handle_header_right_click(self, pos):
|
||||
column = self._table_view.horizontalHeader().logicalIndexAt(pos)
|
||||
|
||||
context = HeaderContextMenu(parent=self, column_index=column,
|
||||
pandas_model=self._pandas_model,
|
||||
callback=self._dataframe_update_callback)
|
||||
context.exec(self.sender().mapToGlobal(pos))
|
||||
|
||||
def _handle_create_click(self):
|
||||
new_name, flag = QInputDialog.getText(self, "Create category",
|
||||
"New category:",
|
||||
text="Category")
|
||||
if not flag:
|
||||
return
|
||||
|
||||
current_items = [self._list_widget.item(x).text() for x
|
||||
in range(self._list_widget.count())]
|
||||
|
||||
if new_name not in current_items:
|
||||
self._add_categories([new_name])
|
||||
else:
|
||||
QMessageBox.warning(self, "No action performed",
|
||||
f"Category '{new_name}' already exists.")
|
||||
|
||||
def _handle_delete_click(self):
|
||||
selected_item = self._list_widget.selectedItems()[0]
|
||||
|
||||
button = QMessageBox.question(self, "Delete category",
|
||||
f"Are you sure you want to delete"
|
||||
f" category '{selected_item.text()}'?")
|
||||
|
||||
if button == QMessageBox.StandardButton.Yes:
|
||||
self._pandas_model.delete_category(selected_item.text())
|
||||
self._list_widget.takeItem(self._list_widget.row(selected_item))
|
||||
|
||||
def _handle_clear_click(self):
|
||||
self._assign_category_to_selected_transactions(' ')
|
||||
|
||||
def _handle_apply_click(self):
|
||||
category = self._list_widget.selectedItems()[0].text()
|
||||
self._assign_category_to_selected_transactions(category)
|
||||
|
||||
def _handle_item_double_click(self, item):
|
||||
self._assign_category_to_selected_transactions(item.text())
|
||||
|
||||
def _handle_save(self):
|
||||
filename, _ = QFileDialog.getSaveFileName(self, 'Save File')
|
||||
|
||||
if filename == '':
|
||||
return
|
||||
|
||||
df = self.get_statement_data()
|
||||
df.to_csv(filename, index=False)
|
||||
|
||||
#
|
||||
# Enable / Disable buttons
|
||||
#
|
||||
|
||||
def _check_enable_delete_button(self):
|
||||
if len(self._list_widget.selectedItems()) > 0:
|
||||
self._delete_button.setEnabled(True)
|
||||
else:
|
||||
self._delete_button.setEnabled(False)
|
||||
|
||||
def _check_enable_clear_button(self):
|
||||
if len(self._table_view.selectionModel().selectedRows()) > 0:
|
||||
self._clear_button.setEnabled(True)
|
||||
else:
|
||||
self._clear_button.setEnabled(False)
|
||||
|
||||
def _check_enable_apply_button(self):
|
||||
if ((len(self._table_view.selectionModel().selectedRows()) > 0)
|
||||
and (len(self._list_widget.selectedItems()) > 0)):
|
||||
self._apply_button.setEnabled(True)
|
||||
else:
|
||||
self._apply_button.setEnabled(False)
|
||||
|
||||
def _handle_list_selection_changed(self):
|
||||
self._check_enable_delete_button()
|
||||
self._check_enable_apply_button()
|
||||
|
||||
def _handle_table_selection_changed(self):
|
||||
self._check_enable_clear_button()
|
||||
self._check_enable_apply_button()
|
||||
172
banking_breakdown/ui/pandas_model.py
Normal file
172
banking_breakdown/ui/pandas_model.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import typing
|
||||
|
||||
import numpy
|
||||
import pandas as pd
|
||||
from PyQt6 import QtCore
|
||||
from PyQt6.QtCore import Qt, QModelIndex, QSortFilterProxyModel
|
||||
|
||||
|
||||
def _get_str_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Return a given dataframe with all values turned into strings.
|
||||
|
||||
When the data given to the PandasModel class contains non-strings,
|
||||
an attached QTableView seems to respond rather slowly. This function
|
||||
turns all data in the DataFrame into strings, yielding a better experience.
|
||||
"""
|
||||
return df.astype(str)
|
||||
|
||||
|
||||
class PandasModel(QtCore.QAbstractTableModel):
|
||||
def __init__(self, parent=None):
|
||||
QtCore.QAbstractTableModel.__init__(self, parent)
|
||||
|
||||
self._data = pd.DataFrame()
|
||||
self._data_str = pd.DataFrame()
|
||||
|
||||
#
|
||||
# Overloaded functions
|
||||
#
|
||||
|
||||
def rowCount(self, parent=None):
|
||||
return len(self._data_str.values)
|
||||
|
||||
def columnCount(self, parent=None):
|
||||
return self._data_str.columns.size
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid():
|
||||
return QtCore.QVariant()
|
||||
|
||||
if (role != Qt.ItemDataRole.DisplayRole) and (
|
||||
role != Qt.ItemDataRole.EditRole):
|
||||
return QtCore.QVariant()
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
item = self._data_str.iloc[index.row(), index.column()]
|
||||
return QtCore.QVariant(item)
|
||||
elif role == Qt.ItemDataRole.EditRole:
|
||||
item = self._data.iloc[index.row(), index.column()]
|
||||
if type(item) is numpy.float64:
|
||||
return QtCore.QVariant(float(item))
|
||||
else:
|
||||
return QtCore.QVariant(item)
|
||||
|
||||
def headerData(self, section, orientation,
|
||||
role=Qt.ItemDataRole.DisplayRole):
|
||||
|
||||
if not ((orientation == Qt.Orientation.Horizontal)
|
||||
and (role == Qt.ItemDataRole.DisplayRole)):
|
||||
return super().headerData(section, orientation, role)
|
||||
|
||||
return self._data_str.columns[section]
|
||||
|
||||
#
|
||||
# Manipulate categories
|
||||
#
|
||||
|
||||
def assign_category(self, category, row_indices):
|
||||
if 'category' not in self._data.columns:
|
||||
self.create_column('category')
|
||||
|
||||
self._data.loc[row_indices, 'category'] = category
|
||||
self._data_str = _get_str_dataframe(self._data)
|
||||
|
||||
for row_index in row_indices:
|
||||
start_index = self.index(row_index, 0)
|
||||
stop_index = self.index(row_index, len(self._data.columns) - 1)
|
||||
self.dataChanged.emit(start_index, stop_index)
|
||||
|
||||
def delete_category(self, category):
|
||||
if 'category' not in self._data.columns:
|
||||
self.create_column('category')
|
||||
|
||||
row_indices = self._data.loc[self._data['category'] == category].index
|
||||
self.assign_category(' ', row_indices)
|
||||
|
||||
def get_categories(self) -> typing.List[str]:
|
||||
if 'category' not in self._data.columns:
|
||||
self.create_column('category')
|
||||
|
||||
return self._data['category'].unique()
|
||||
|
||||
#
|
||||
# Manipulate columns
|
||||
#
|
||||
|
||||
def create_column(self, column, initial_value=' '):
|
||||
self._data[column] = [initial_value] * len(self._data.index)
|
||||
self._data_str = _get_str_dataframe(self._data)
|
||||
self.layoutAboutToBeChanged.emit()
|
||||
self.layoutChanged.emit()
|
||||
|
||||
def delete_column_by_index(self, column_index):
|
||||
self._data \
|
||||
= self._data.iloc[:, [j for j, c in enumerate(self._data.columns)
|
||||
if j != column_index]]
|
||||
self._data_str = _get_str_dataframe(self._data)
|
||||
|
||||
self.layoutAboutToBeChanged.emit()
|
||||
self.layoutChanged.emit()
|
||||
|
||||
def rename_column(self, old_name, new_name):
|
||||
if new_name in self._data.columns:
|
||||
raise Exception(
|
||||
f"A column with the name '{new_name}' already exists.")
|
||||
|
||||
self._data = self._data.rename(columns={old_name: new_name})
|
||||
self._data_str = _get_str_dataframe(self._data)
|
||||
|
||||
column_index = self._data.columns.get_loc(new_name)
|
||||
self.headerDataChanged.emit(Qt.Orientation.Horizontal,
|
||||
column_index, column_index)
|
||||
|
||||
def switch_columns(self, column1, column2):
|
||||
column_titles = list(self._data.columns)
|
||||
|
||||
index1, index2 \
|
||||
= column_titles.index(column1), column_titles.index(column2)
|
||||
|
||||
column_titles[index1], column_titles[index2] \
|
||||
= column_titles[index2], column_titles[index1]
|
||||
|
||||
self._data = self._data.reindex(columns=column_titles)
|
||||
self._data_str = _get_str_dataframe(self._data)
|
||||
|
||||
self.layoutAboutToBeChanged.emit()
|
||||
self.layoutChanged.emit()
|
||||
|
||||
def get_columns(self) -> typing.List[str]:
|
||||
return list(self._data.columns)
|
||||
|
||||
def assign_float_column(self, column, decimal_sep):
|
||||
if decimal_sep == ',':
|
||||
self._data[column] \
|
||||
= self._data[column].str.replace(',', '.').astype(float)
|
||||
else:
|
||||
self._data[column] = self._data[column].astype(float)
|
||||
|
||||
self._data_str = _get_str_dataframe(self._data)
|
||||
|
||||
column_index = self._data.columns.get_loc(column)
|
||||
start_index = self.index(0, column_index)
|
||||
stop_index = self.index(len(self._data.index), column_index)
|
||||
self.dataChanged.emit(start_index, stop_index)
|
||||
|
||||
def assign_date_column(self, column, date_format):
|
||||
self._data[column] \
|
||||
= pd.to_datetime(self._data[column], format=date_format)
|
||||
self._data_str = _get_str_dataframe(self._data)
|
||||
|
||||
#
|
||||
# Directly access dataframe
|
||||
#
|
||||
|
||||
def set_dataframe(self, df):
|
||||
self._data = df
|
||||
self._data_str = _get_str_dataframe(df)
|
||||
|
||||
self.layoutAboutToBeChanged.emit()
|
||||
self.layoutChanged.emit()
|
||||
|
||||
def get_dataframe(self) -> pd.DataFrame:
|
||||
return self._data
|
||||
176
res/main_window.ui
Normal file
176
res/main_window.ui
Normal file
@@ -0,0 +1,176 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1300</width>
|
||||
<height>731</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_6">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget" native="true">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>600</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="warningLayout">
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3" stretch="0,1">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>Categories</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="createCategoryButton">
|
||||
<property name="toolTip">
|
||||
<string>Create new category</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Create</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="deleteCategoryButton">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Delete selected category</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Delete</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QListWidget" name="categoryListWidget">
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QAbstractScrollArea::AdjustToContents</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QPushButton" name="clearCategoryButton">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Clear category field of selected transactions</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Clear</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="applyCategoryButton">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Apply selected category to selected transactions</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Apply</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Transactions</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QTableView" name="transactionTableView">
|
||||
<property name="selectionBehavior">
|
||||
<enum>QAbstractItemView::SelectRows</enum>
|
||||
</property>
|
||||
<property name="sortingEnabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
|
||||
<bool>true</bool>
|
||||
</attribute>
|
||||
<attribute name="horizontalHeaderStretchLastSection">
|
||||
<bool>true</bool>
|
||||
</attribute>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1300</width>
|
||||
<height>23</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>File</string>
|
||||
</property>
|
||||
<addaction name="actionSave"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<action name="actionSave">
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+S</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
160
res/report.tex
160
res/report.tex
@@ -4,6 +4,7 @@
|
||||
\usepackage{amsmath}
|
||||
\usepackage{pgfplots}
|
||||
\pgfplotsset{compat=newest}
|
||||
\usetikzlibrary{pgfplots.dateplot}
|
||||
|
||||
% Other packages
|
||||
\usepackage[a4paper, total={12cm, 25cm}]{geometry}
|
||||
@@ -30,6 +31,10 @@
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||
|
||||
|
||||
\makeatletter
|
||||
\newcommand*\shortyear[1]{\expandafter\@gobbletwo\number\numexpr#1\relax}
|
||||
\makeatother
|
||||
|
||||
\newcommand{\slice}[6]{
|
||||
\pgfmathparse{0.5*#1+0.5*#2}
|
||||
\let\midangle\pgfmathresult
|
||||
@@ -136,16 +141,41 @@
|
||||
|
||||
\begin{tikzpicture}
|
||||
\begin{axis}[
|
||||
date coordinates in=x,
|
||||
width=\textwidth,
|
||||
height=0.375\textwidth,
|
||||
ylabel={Net income in €},
|
||||
y label style={at={(-0.1,0.5)},anchor=south},
|
||||
xticklabel=\month.\shortyear{\year},
|
||||
xtick=data,
|
||||
xticklabel style={
|
||||
rotate=60,
|
||||
anchor=near xticklabel,
|
||||
},
|
||||
grid,
|
||||
enlarge x limits=0.03,
|
||||
]
|
||||
\addplot+[ybar, color=scol2, fill=scol2, line width=1pt]
|
||||
table[col sep=comma, x=t, y=value, discard if lt={value}{0}]
|
||||
% Dummy plot to set x axis ticks
|
||||
\addplot[draw=none]
|
||||
table[col sep=comma, x=t, y=net]
|
||||
{net_income.csv};
|
||||
\addplot+[ybar, color=scol0, fill=scol0, line width=1pt]
|
||||
table[col sep=comma, x=t, y=value, discard if gt={value}{0}]
|
||||
|
||||
% Dummy plot to set x axis scale
|
||||
\addplot[draw=none]
|
||||
table[col sep=comma, x=t, y expr=0]
|
||||
{detailed_balance.csv};
|
||||
|
||||
\addplot[ybar, bar width=0.4cm, draw=none, fill=scol2!30, line width=1pt]
|
||||
table[col sep=comma, x=t, y=income]
|
||||
{net_income.csv};
|
||||
\addplot[ybar, bar width=0.4cm, draw=none, fill=scol0!30, line width=1pt]
|
||||
table[col sep=comma, x=t, y=expenses]
|
||||
{net_income.csv};
|
||||
\addplot[ybar, bar width=0.3cm, draw=none, fill=scol0, line width=1pt]
|
||||
table[col sep=comma, x=t, y=net, discard if gt={net}{0}]
|
||||
{net_income.csv};
|
||||
\addplot[ybar, bar width=0.3cm, draw=none, fill=scol2, line width=1pt]
|
||||
table[col sep=comma, x=t, y=net, discard if lt={net}{0}]
|
||||
{net_income.csv};
|
||||
\end{axis}
|
||||
\end{tikzpicture}
|
||||
@@ -155,15 +185,31 @@
|
||||
|
||||
\begin{tikzpicture}
|
||||
\begin{axis}[
|
||||
date coordinates in=x,
|
||||
width=\textwidth,
|
||||
height=0.375\textwidth,
|
||||
area style,
|
||||
ylabel={Total balance in €},
|
||||
y label style={at={(-0.1,0.5)},anchor=south},
|
||||
xticklabel=\month.\shortyear{\year},
|
||||
xtick=data,
|
||||
enlarge x limits=0.03,
|
||||
xticklabel style={
|
||||
rotate=60,
|
||||
anchor=near xticklabel,
|
||||
},
|
||||
grid,
|
||||
]
|
||||
\addplot+[mark=none, color=scol1, line width=1pt]
|
||||
% Dummy plot to set x axis ticks
|
||||
\addplot[draw=none]
|
||||
table[col sep=comma, x=t, y=value]
|
||||
{total_value.csv} \closedcycle;
|
||||
{total_value.csv};
|
||||
|
||||
\addplot[scol3, mark=none, line width=1pt]
|
||||
table[col sep=comma, x=t, y=value]
|
||||
{detailed_balance.csv};
|
||||
\addplot[scol1, mark=none, line width=1pt]
|
||||
table[col sep=comma, x=t, y=value]
|
||||
{total_value.csv};
|
||||
\end{axis}
|
||||
\end{tikzpicture}
|
||||
\end{subfigure}
|
||||
@@ -171,5 +217,105 @@
|
||||
\caption{Development of account balance over time}
|
||||
\end{figure}
|
||||
|
||||
\begin{figure}
|
||||
\centering
|
||||
|
||||
|
||||
\csvautotabular{net_income.csv}
|
||||
\end{figure}
|
||||
|
||||
|
||||
\begin{figure}[H]
|
||||
\centering
|
||||
|
||||
% Read table
|
||||
\pgfplotstableread[col sep=comma]{expenses_by_category.csv}\expbycattable
|
||||
\pgfplotstablegetcolsof{\expbycattable}
|
||||
\pgfmathtruncatemacro\NumCols{\pgfplotsretval-1}
|
||||
|
||||
\begin{subfigure}[c]{\textwidth}
|
||||
\centering
|
||||
|
||||
\begin{tikzpicture}
|
||||
\begin{axis}[
|
||||
stack plots=y,
|
||||
area style,
|
||||
date coordinates in=x,
|
||||
width=\textwidth,
|
||||
height=0.375\textwidth,
|
||||
xticklabel=\month.\shortyear{\year},
|
||||
xtick=data,
|
||||
enlargelimits=false,
|
||||
xticklabel style={
|
||||
rotate=60,
|
||||
anchor=near xticklabel,
|
||||
},
|
||||
legend columns=5,
|
||||
legend style={at={(0.5,-0.6)},anchor=south},
|
||||
ylabel={Expenses in €},
|
||||
ymin=0,
|
||||
]
|
||||
% For each
|
||||
\pgfplotsinvokeforeach{0,...,\NumCols/2 -1}{
|
||||
% Define color
|
||||
\pgfmathparse{1000 / (\NumCols/2 -1) * #1}
|
||||
\extractcolormapcolor{tempcol#1}{\pgfmathresult}
|
||||
|
||||
% Add plot
|
||||
\addplot+[tempcol#1]
|
||||
table[col sep=comma, x=t, y index=#1]
|
||||
{\expbycattable} \closedcycle;
|
||||
|
||||
% Add legend entry (https://tex.stackexchange.com/a/405018)
|
||||
\pgfplotstablegetcolumnnamebyindex{#1}\of{\expbycattable}\to\pgfplotsretval
|
||||
\expandafter\addlegendentry\expandafter{\pgfplotsretval}
|
||||
}
|
||||
\end{axis}
|
||||
\end{tikzpicture}
|
||||
\end{subfigure}\\[1em]
|
||||
\begin{subfigure}[c]{\textwidth}
|
||||
\centering
|
||||
|
||||
\begin{tikzpicture}
|
||||
\begin{axis}[
|
||||
stack plots=y,
|
||||
area style,
|
||||
date coordinates in=x,
|
||||
width=\textwidth,
|
||||
height=0.375\textwidth,
|
||||
xticklabel=\month.\shortyear{\year},
|
||||
xtick=data,
|
||||
enlargelimits=false,
|
||||
xticklabel style={
|
||||
rotate=60,
|
||||
anchor=near xticklabel,
|
||||
},
|
||||
legend columns=5,
|
||||
legend style={at={(0.5,-0.6)},anchor=south},
|
||||
ylabel={Expenses in €},
|
||||
ymin=0,
|
||||
]
|
||||
% For each
|
||||
\pgfplotsinvokeforeach{\NumCols/2,...,\NumCols-1}{
|
||||
% Define color
|
||||
\pgfmathparse{1000 * (#1 - \NumCols/2) / (\NumCols-1 - \NumCols/2)}
|
||||
\extractcolormapcolor{tempcol#1}{\pgfmathresult}
|
||||
|
||||
% Add plot
|
||||
\addplot+[tempcol#1]
|
||||
table[col sep=comma, x=t, y index=#1]
|
||||
{\expbycattable} \closedcycle;
|
||||
|
||||
% Add legend entry (https://tex.stackexchange.com/a/405018)
|
||||
\pgfplotstablegetcolumnnamebyindex{#1}\of{\expbycattable}\to\pgfplotsretval
|
||||
\expandafter\addlegendentry\expandafter{\pgfplotsretval}
|
||||
}
|
||||
\end{axis}
|
||||
\end{tikzpicture}
|
||||
\end{subfigure}
|
||||
|
||||
\caption{Expenses by category}
|
||||
\end{figure}
|
||||
|
||||
\end{document}
|
||||
|
||||
|
||||
BIN
res/warning.png
Normal file
BIN
res/warning.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 634 B |
Reference in New Issue
Block a user