Skip to content

Commit

Permalink
Merge pull request #31 from mkobetic/balances-chart
Browse files Browse the repository at this point in the history
coin2html: add balances chart
  • Loading branch information
mkobetic authored Jan 3, 2025
2 parents a49534b + 835c8be commit be37302
Show file tree
Hide file tree
Showing 9 changed files with 23,146 additions and 1,160 deletions.
3 changes: 2 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
### coin2html

- update user docs in README (details, balances, screenshots ...)
- balance charts
- allow dropping subaccounts from aggregations (in both chart and register)
- filter subaccounts, payee, tag...
- brush to select date range
Expand All @@ -50,5 +49,7 @@
- commodity renames?
- language server?
- lots/costs
- https://beancount.github.io/docs/how_inventories_work.html
- https://beancount.github.io/docs/a_proposal_for_an_improvement_on_inventory_booking.html
- multiple commodities in single account?
- query language
1 change: 1 addition & 0 deletions cmd/coin2html/js/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ <h1>
<script src="src/utils.ts"></script>
<script src="src/views.ts"></script>
<script src="src/viewsBalances.ts"></script>
<script src="src/viewsBalancesChart.ts"></script>
<script src="src/viewsRegister.ts"></script>
<script src="src/viewsAggregatedRegisterChart.ts"></script>
<script src="src/ui.ts"></script>
1 change: 1 addition & 0 deletions cmd/coin2html/js/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export class Account {
const balance = this.balanceAt(date);
const total = Amount.clone(balance);
for (const child of this.children) {
if (!State.ShowClosedAccounts && child.isClosed(date)) continue;
const childBalances = child.withAllChildBalances(date);
balances = balances.concat(childBalances);
total.addIn(childBalances[0].total, date);
Expand Down
3 changes: 3 additions & 0 deletions cmd/coin2html/js/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
StartDateInput,
State,
updateAccounts,
updateAggregationForTimeRange,
updateView,
Views,
} from "./views";
Expand All @@ -42,6 +43,7 @@ function initializeUI() {
.on("change", (e) => {
const input = e.currentTarget as HTMLInputElement;
State.EndDate = new Date(input.value);
updateAggregationForTimeRange();
updateView();
});
select(StartDateInput)
Expand All @@ -51,6 +53,7 @@ function initializeUI() {
.on("change", (e) => {
const input = e.currentTarget as HTMLInputElement;
State.StartDate = new Date(input.value);
updateAggregationForTimeRange();
updateView();
});
type optionWithAccount = HTMLOptionElement & { __data__: Account };
Expand Down
32 changes: 26 additions & 6 deletions cmd/coin2html/js/src/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import {
renderPostingsWithSubAccounts,
viewRegister,
} from "./viewsRegister";
import { viewChartTotals } from "./viewsAggregatedRegisterChart";
import { viewAggregatedRegisterChart } from "./viewsAggregatedRegisterChart";
import { Account } from "./account";
import { PostingGroup, shortenAccountName, topN } from "./utils";
import { viewBalances } from "./viewsBalances";
import { viewBalancesChart } from "./viewsBalancesChart";

export const Aggregation = {
None: null,
Expand Down Expand Up @@ -45,18 +46,22 @@ export const State = {
},
};

// View types by account category.
// Available view types by account category.
// These define what is offered in the view drop-down.
// All types have Register.
export const Views = {
Assets: {
Balances: viewBalances,
Register: viewRegister,
Chart: viewChartTotals,
ChartBalances: viewBalancesChart,
ChartAggregatedRegister: viewAggregatedRegisterChart,
},
Liabilities: {
Balances: viewBalances,
Register: () => viewRegister({ negated: true }),
Chart: () => viewChartTotals({ negated: true }),
ChartBalances: () => viewBalancesChart({ negated: true }),
ChartAggregatedRegister: () =>
viewAggregatedRegisterChart({ negated: true }),
},
Income: {
Balances: viewBalances,
Expand All @@ -65,15 +70,18 @@ export const Views = {
negated: true,
aggregatedTotal: true,
}),
Chart: () => viewChartTotals({ negated: true }),
ChartBalances: () => viewBalancesChart({ negated: true }),
ChartAggregatedRegister: () =>
viewAggregatedRegisterChart({ negated: true }),
},
Expenses: {
Balances: viewBalances,
Register: () =>
viewRegister({
aggregatedTotal: true,
}),
Chart: viewChartTotals,
ChartBalances: viewBalancesChart,
ChartAggregatedRegister: viewAggregatedRegisterChart,
},
Equity: {
Balances: viewBalances,
Expand Down Expand Up @@ -193,6 +201,7 @@ export function addAggregateInput(
);
if (!opts.includeNone && State.View.Aggregate == "None") {
State.View.Aggregate = data[0] as keyof typeof Aggregation;
updateAggregationForTimeRange();
console.log("Aggregate = ", State.View.Aggregate);
}
aggregate
Expand Down Expand Up @@ -249,6 +258,17 @@ export function updateView() {
view();
}

export function updateAggregationForTimeRange() {
if (State.View.Aggregate == "None") return;
const days =
(State.EndDate.getTime() - State.StartDate.getTime()) /
(1000 * 60 * 60 * 24);
if (days < 180) State.View.Aggregate = "Weekly";
else if (days < 3 * 180) State.View.Aggregate = "Monthly";
else if (days < 5 * 365) State.View.Aggregate = "Quarterly";
else State.View.Aggregate = "Yearly";
}

export function updateAccount() {
const account = State.SelectedAccount;
const spans = select(AccountName)
Expand Down
19 changes: 9 additions & 10 deletions cmd/coin2html/js/src/viewsAggregatedRegisterChart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { scaleLinear, scaleOrdinal, scaleTime } from "d3-scale";
import { schemeCategory10 } from "d3-scale-chromatic";
import { select } from "d3-selection";

export function viewChartTotals(options?: {
export function viewAggregatedRegisterChart(options?: {
negated?: boolean; // is this negatively denominated account (e.g. Income/Liability)
}) {
const containerSelector = MainView;
Expand Down Expand Up @@ -48,16 +48,13 @@ export function viewChartTotals(options?: {
// compute offsets for each group left to right
// and max width for the x domain
let max = 0;
const amountFromGroup = (group: PostingGroup) =>
State.View.AggregationStyle == AggregationStyle.Flows
? group.sum
: group.balance;
const widthFromGroup = (group: PostingGroup) => {
let width = Math.trunc(
account.commodity
.convert(
State.View.AggregationStyle == AggregationStyle.Flows
? group.sum
: group.balance,
group.date
)
.toNumber()
account.commodity.convert(amountFromGroup(group), group.date).toNumber()
);
if (opts.negated) width = -width;
return width < 0 ? 0 : width;
Expand Down Expand Up @@ -117,7 +114,9 @@ export function viewChartTotals(options?: {
.attr("x", (d) => x(d.offset ?? 0))
.attr("width", (d) => x(d.width ?? 0))
.attr("height", rowHeight - 1)
.on("click", (e, d) => showDetails(d, !d.account));
.on("click", (e, d) => showDetails(d, !d.account))
.append("title")
.text((d) => `${d.account?.fullName ?? "Other"} ${amountFromGroup(d)}`);

// bar text
layer
Expand Down
3 changes: 0 additions & 3 deletions cmd/coin2html/js/src/viewsBalances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ export function viewBalances(options?: {
const labels = ["Balance", "Total", "Account"];
const table = addTableWithHeader(containerSelector, labels);
let balances = account.withAllChildBalances(State.EndDate);
if (!State.ShowClosedAccounts) {
balances = balances.filter((b) => !b.account.isClosed(State.EndDate));
}
balances = balances.filter(
(b) => b.account.depthFrom(account) <= State.View.BalanceDepth
);
Expand Down
141 changes: 141 additions & 0 deletions cmd/coin2html/js/src/viewsBalancesChart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { stratify, treemap } from "d3-hierarchy";
import { select } from "d3-selection";
import { group } from "d3-array";
import {
addBalanceDepthInput,
emptyElement,
MainView,
State,
updateAccount,
} from "./views";
import { scaleSequential } from "d3-scale";
import { interpolateBlues } from "d3-scale-chromatic";
import { AccountBalanceAndTotal } from "./utils";

function max(a: number, b: number) {
return a > b ? a : b;
}

// based on https://observablehq.com/@d3/nested-treemap
export function viewBalancesChart(options?: {
negated?: boolean; // is this negatively denominated account (e.g. Income/Liability)
}) {
const containerSelector = MainView;
const account = State.SelectedAccount;
const opts = { negated: false }; // defaults
Object.assign(opts, options);
emptyElement(containerSelector);
addBalanceDepthInput(containerSelector);
const date = State.EndDate;
// build the hierarchical data structure that d3 treemap expects
let root = stratify<AccountBalanceAndTotal>()
.id(({ account }) => account.fullName)
.parentId(({ account }) =>
account.parent && account.parent != State.SelectedAccount.parent
? account.parent.fullName
: undefined
)(account.withAllChildBalances(date));
// compute the individual node.value that drives the treemap layout
root = root.sum((a) =>
max(
(opts.negated ? -1 : 1) *
account.commodity.convert(a.balance, date).toNumber(),
0
)
);

const [width, height] = [1200, 800];
const tm = treemap<AccountBalanceAndTotal>()
.size([width, height])
.padding(4)
.paddingTop(20);
const nodes = tm(root);
const nodesByDepth = Array.from(group(nodes, (d) => d.depth))
.sort((a, b) => a[0] - b[0])
.map((d) => d[1])
.slice(0, State.View.BalanceDepth);

let uidCounter = 0;

const svg = select(containerSelector)
.append("svg")
.attr("id", "chart")
.attr("width", "100%")
.attr("height", height);
// .attr("viewBox", [0, 0, width, height]);
// .attr(
// "style",
// "max-width: 100%; height: auto; overflow: visible; font: 10px sans-serif;"
// );

const color = scaleSequential(
[0, nodesByDepth.length * 1.5],
interpolateBlues
);

const node = svg
.selectAll("g")
.data(nodesByDepth)
.join("g")
.selectAll("g")
.data((d) => d)
.join("g")
.attr("transform", (d) => `translate(${d.x0},${d.y0})`);

node
.append("title")
.text(({ data }) =>
data.account.children.length > 0 && !data.balance.isZero
? `${data.account.fullName} ${data.total} [ ${data.balance} ]`
: `${data.account.fullName} ${data.total}`
);

node
.append("rect")
.attr("id", (d: any) => (d.nodeUid = `node-${uidCounter++}`))
.attr("fill", (d) => color(d.depth))
.attr("width", (d) => d.x1 - d.x0)
.attr("height", (d) => d.y1 - d.y0)
.on("click", (e, { data }) => {
State.SelectedAccount = data.account;
updateAccount();
});

// add a clippath to clip the text to the rectangle
node
.append("clipPath")
.attr("id", (d: any) => (d.clipUid = `clip-${uidCounter++}`))
.append("use")
.attr("xlink:href", (d: any) => `#${d.nodeUid}`);

node
.append("text")
.attr("clip-path", (d: any) => `url(#${d.clipUid})`)
.on("click", (e, { data }) => {
State.SelectedAccount = data.account;
updateAccount();
})
.selectAll("tspan")
.data(({ data }) => {
const bits = [data.account.name, data.total.toString()];
if (data.account.children.length > 0 && !data.balance.isZero)
bits.push(data.balance.toString());
return bits;
})
.join("tspan")
.text((d) => d);

const narrowBoxLimit = 150;
// if the box is wide, put the spans on the same line
node
.filter((d: any) => d.x1 - d.x0 > narrowBoxLimit)
.selectAll("tspan")
.attr("dx", 10)
.attr("y", 15);
// if the box is narrow, put the spans on separate lines
node
.filter((d) => d.x1 - d.x0 <= narrowBoxLimit)
.selectAll("tspan")
.attr("x", 10)
.attr("dy", 15);
}
Loading

0 comments on commit be37302

Please sign in to comment.