Skip to content

Commit

Permalink
coin2html: add balances chart
Browse files Browse the repository at this point in the history
  • Loading branch information
mkobetic committed Jan 3, 2025
1 parent a49534b commit 7f4c84e
Show file tree
Hide file tree
Showing 8 changed files with 22,832 additions and 1,100 deletions.
3 changes: 3 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

- update user docs in README (details, balances, screenshots ...)
- balance charts
- default aggregation granularity based on selected time range
- allow dropping subaccounts from aggregations (in both chart and register)
- filter subaccounts, payee, tag...
- brush to select date range
Expand All @@ -50,5 +51,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
20 changes: 14 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
2 changes: 1 addition & 1 deletion 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
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
126 changes: 126 additions & 0 deletions cmd/coin2html/js/src/viewsBalancesChart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { stratify, treemap } from "d3-hierarchy";
import { select } from "d3-selection";
import { group } from "d3-array";
import { addBalanceDepthInput, emptyElement, MainView, State } 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);

// 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})`)
.selectAll("tspan")
.data(({ data }) => {
const bits = [data.account.name, data.total.toString()];
if (data.account.children.length > 0) 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 7f4c84e

Please sign in to comment.