Skip to content

Commit

Permalink
coin2html: thousands separator, d3 slimming
Browse files Browse the repository at this point in the history
  • Loading branch information
mkobetic committed Dec 13, 2024
1 parent 57c112d commit f25ff30
Show file tree
Hide file tree
Showing 9 changed files with 33,538 additions and 229,904 deletions.
6 changes: 2 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,15 @@

### coin2html

- chart and register aggregation should show periodic balances not aggregated inflows for Assets/Liabilities
- show location info
- make commodity conversions more robust, they blow up too easily
- replace dateToString with d3.format
- thousands separator
- tooltips for columns, inputs and wherever useful
- show details of selected posting
- show details of selected posting group
- filter subaccounts, payee, tag...
- preserve view selection across root changes
- preserve UI state in history (make back/forward buttons work)
- trim to time range on export (need to recalc posting balances!)
- allow dropping a subaccount from aggregations (in both chart and register)
- balance charts
- show commodities and prices
- investment performance summary
Expand Down
2 changes: 1 addition & 1 deletion cmd/coin2html/js/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ module.exports = {
moduleDirectories: ["node_modules"],
transformIgnorePatterns: [`node_modules`],
moduleNameMapper: {
d3: "<rootDir>/node_modules/d3/dist/d3.min.js",
"^d3-(.*)$": "<rootDir>/node_modules/d3-$1/dist/d3-$1.min.js",
},
};
18 changes: 17 additions & 1 deletion cmd/coin2html/js/spec/commodity.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
import { Commodity } from "../src/commodity";
import { Amount, Commodity } from "../src/commodity";

test("create commodity", () =>
expect(new Commodity("CAD", "Canadian Dollar", 2, "")).toBeTruthy());

describe("amount", () => {
const CAD = new Commodity("CAD", "Canadian Dollar", 2, "");
test.each([
[0, "0.00 CAD"],
[1, "0.01 CAD"],
[-1, "-0.01 CAD"],
[200, "2.00 CAD"],
[-50, "-0.50 CAD"],
[123456789, "1,234,567.89 CAD"],
[-12345678, "-123,456.78 CAD"],
])(`%#: %i`, (i, expected) => {
const amt = new Amount(i, CAD);
expect(amt.toString()).toBe(expected);
});
});
18 changes: 10 additions & 8 deletions cmd/coin2html/js/src/chart.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as d3 from "d3";
import {
Aggregation,
State,
Expand All @@ -9,6 +8,10 @@ import {
} from "./views";
import { groupWithSubAccounts } from "./utils";
import { Account } from "./account";
import { axisLeft, axisTop } from "d3-axis";
import { scaleLinear, scaleOrdinal, scaleTime } from "d3-scale";
import { schemeCategory10 } from "d3-scale-chromatic";
import { select } from "d3-selection";

export function viewChart(options?: {
negated?: boolean; // is this negatively denominated account (e.g. Income/Liability)
Expand Down Expand Up @@ -54,8 +57,7 @@ export function viewChart(options?: {
height = dates.length * rowHeight + margin.top + margin.bottom,
textOffset = (rowHeight * 3) / 4;

const svg = d3
.select(containerSelector)
const svg = select(containerSelector)
.append("svg")
.attr("id", "chart")
.attr("width", "100%")
Expand All @@ -70,11 +72,11 @@ export function viewChart(options?: {
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");

var x = d3.scaleLinear([0, max], [0, width]).nice();
var y = d3.scaleTime([State.StartDate, State.EndDate], [0, height]);
var z = d3.scaleOrdinal([0, maxAccounts], d3.schemeCategory10);
var xAxis = d3.axisTop(x);
var yAxis = d3.axisLeft(y).ticks(groupKey, "%Y/%m/%d");
var x = scaleLinear([0, max], [0, width]).nice();
var y = scaleTime([State.StartDate, State.EndDate], [0, height]);
var z = scaleOrdinal([0, maxAccounts], schemeCategory10);
var xAxis = axisTop(x);
var yAxis = axisLeft(y).ticks(groupKey, "%Y/%m/%d");

// bar layers
var layer = chart
Expand Down
23 changes: 17 additions & 6 deletions cmd/coin2html/js/src/commodity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as d3 from "d3";
import { scaleTime } from "d3-scale";
import { timeWeek } from "d3-time";
import { dateToString, last } from "./utils";

// Commodity, Amount and Price
Expand All @@ -11,10 +12,10 @@ function newConversion(prices: Price[]): Conversion {
throw new Error("cannot create conversion from empty price list");
const from = prices[0].date;
const to = last(prices)!.date;
const dates = d3.timeWeek.range(from, to);
const dates = timeWeek.range(from, to);
if (dates.length == 0) return (d: Date) => prices[0].value;
// scale from dates to the number of weeks/price points
const scale = d3.scaleTime([from, to], [0, dates.length - 1]).clamp(true);
const scale = scaleTime([from, to], [0, dates.length - 1]).clamp(true);
// generate array of prices per week
let cpi = 0;
const weeks = dates.map((d) => {
Expand Down Expand Up @@ -93,20 +94,20 @@ export class Amount {
return new Amount(value, commodity);
}
toString(): string {
let str = this.value.toString();
let str = Math.abs(this.value).toString();
if (this.commodity.decimals > 0) {
if (str.length < this.commodity.decimals) {
str = "0".repeat(this.commodity.decimals - str.length + 1) + str;
}
str =
str.slice(0, -this.commodity.decimals) +
triplets(str.slice(0, -this.commodity.decimals)).join(",") +
"." +
str.slice(-this.commodity.decimals);
if (str[0] == ".") {
str = "0" + str;
}
}
return str + " " + this.commodity.id;
return (this.value < 0 ? "-" : "") + str + " " + this.commodity.id;
}
toNumber() {
return this.value / 10 ** this.commodity.decimals;
Expand Down Expand Up @@ -210,3 +211,13 @@ export function loadCommodities() {
}
}
}

function triplets(s: string): string[] {
const triplets = [];
for (let end = s.length; end > 0; end = end - 3) {
let start = end - 3;
if (start < 0) start = 0;
triplets.unshift(s.slice(start, end));
}
return triplets;
}
5 changes: 2 additions & 3 deletions cmd/coin2html/js/src/register.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as d3 from "d3";
import {
Aggregation,
State,
Expand All @@ -21,10 +20,10 @@ import {
trimToDateRange,
} from "./utils";
import { Amount } from "./commodity";
import { select } from "d3-selection";

function addTableWithHeader(containerSelector: string, labels: string[]) {
const table = d3
.select(containerSelector)
const table = select(containerSelector)
.append("table")
.attr("id", "register");
table
Expand Down
10 changes: 5 additions & 5 deletions cmd/coin2html/js/src/ui.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as d3 from "d3";
import {
Account,
Accounts,
Expand All @@ -19,6 +18,7 @@ import {
updateView,
Views,
} from "./views";
import { select } from "d3-selection";

function initializeUI() {
// Need to load before initializing the UI state.
Expand All @@ -32,7 +32,7 @@ function initializeUI() {

const minDate = dateToString(new Date(MinDate.getFullYear(), 1, 1));
const maxDate = dateToString(new Date(MaxDate.getFullYear() + 1, 1, 1));
d3.select(EndDateInput)
select(EndDateInput)
.property("valueAsDate", State.EndDate)
.property("min", minDate)
.property("max", maxDate)
Expand All @@ -41,7 +41,7 @@ function initializeUI() {
State.EndDate = new Date(input.value);
updateView();
});
d3.select(StartDateInput)
select(StartDateInput)
.property("valueAsDate", State.StartDate)
.property("min", minDate)
.property("max", maxDate)
Expand All @@ -51,7 +51,7 @@ function initializeUI() {
updateView();
});
type optionWithAccount = HTMLOptionElement & { __data__: Account };
d3.select(RootAccountSelect)
select(RootAccountSelect)
.on("change", (e: Event) => {
const select = e.currentTarget as HTMLSelectElement;
const account = (
Expand All @@ -65,7 +65,7 @@ function initializeUI() {
.join("option")
.property("selected", (d) => d == State.SelectedAccount)
.text((d) => d.fullName);
d3.select(ShowClosedAccounts)
select(ShowClosedAccounts)
.on("change", (e: Event) => {
const input = e.currentTarget as HTMLInputElement;
State.ShowClosedAccounts = input.checked;
Expand Down
34 changes: 17 additions & 17 deletions cmd/coin2html/js/src/views.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import * as d3 from "d3";
import { select } from "d3-selection";
import { timeMonth, timeWeek, timeYear } from "d3-time";
import { viewRegister } from "./register";
import { viewChart } from "./chart";
import { Account, Accounts, MaxDate, MinDate } from "./account";
import { Account } from "./account";

export const Aggregation = {
None: null,
Weekly: d3.timeWeek,
Monthly: d3.timeMonth,
Quarterly: d3.timeMonth.every(3),
Yearly: d3.timeYear,
Weekly: timeWeek,
Monthly: timeMonth,
Quarterly: timeMonth.every(3),
Yearly: timeYear,
};

export enum AggregationStyle {
Expand Down Expand Up @@ -73,7 +74,7 @@ export const Views = {
// View components

export function addIncludeSubAccountsInput(containerSelector: string) {
const container = d3.select(containerSelector);
const container = select(containerSelector);
container
.append("label")
.property("for", "includeSubAccounts")
Expand All @@ -91,7 +92,7 @@ export function addIncludeSubAccountsInput(containerSelector: string) {
}

export function addIncludeNotesInput(containerSelector: string) {
const container = d3.select(containerSelector);
const container = select(containerSelector);
container.append("label").property("for", "includeNotes").text("Show Notes");
container
.append("input")
Expand All @@ -106,7 +107,7 @@ export function addIncludeNotesInput(containerSelector: string) {
}

export function addShowLocationInput(containerSelector: string) {
const container = d3.select(containerSelector);
const container = select(containerSelector);
container
.append("label")
.property("for", "showLocation")
Expand All @@ -124,7 +125,7 @@ export function addShowLocationInput(containerSelector: string) {
}

export function addSubAccountMaxInput(containerSelector: string) {
const container = d3.select(containerSelector);
const container = select(containerSelector);
container
.append("label")
.property("for", "subAccountMax")
Expand All @@ -149,7 +150,7 @@ export function addAggregateInput(
) {
const opts = { includeNone: true }; // defaults
Object.assign(opts, options);
const container = d3.select(containerSelector);
const container = select(containerSelector);
container.append("label").property("for", "aggregate").text("Aggregate");
const aggregate = container.append("select").attr("id", "aggregate");
aggregate.on("change", (e, d) => {
Expand All @@ -175,7 +176,7 @@ export function addAggregateInput(
}

export function addAggregationStyleInput(containerSelector: string) {
const container = d3.select(containerSelector);
const container = select(containerSelector);
const aggregate = container.append("select").attr("id", "aggregationStyle");
aggregate.on("change", (e, d) => {
const select = e.currentTarget as HTMLSelectElement;
Expand Down Expand Up @@ -205,7 +206,7 @@ export const AccountOutput = "#main output#account";
export const MainView = "#main section#view";

export function emptyElement(selector: string) {
(d3.select(selector).node() as Element).replaceChildren();
(select(selector).node() as Element).replaceChildren();
}

// UI Events
Expand All @@ -220,8 +221,7 @@ export function updateView() {
export function updateAccount() {
const account = State.SelectedAccount;
// d3.select(AccountOutput).text(account.fullName);
const spans = d3
.select(AccountOutput)
const spans = select(AccountOutput)
.selectAll("span")
.data(account.withAllParents())
.join("span")
Expand All @@ -242,7 +242,7 @@ export function addViewSelect() {
const selectedViews = Object.keys(Views[account.name as keyof typeof Views]);
if (!selectedViews.includes(State.SelectedView))
State.SelectedView = selectedViews[0];
d3.select(ViewSelect)
select(ViewSelect)
.on("change", (e) => {
const select = e.currentTarget as HTMLSelectElement;
State.SelectedView = select.options[select.selectedIndex].value;
Expand All @@ -258,7 +258,7 @@ export function addViewSelect() {
type liWithAccount = HTMLLIElement & { __data__: Account };
export function addAccountList() {
const account = State.SelectedAccount;
d3.select(AccountList)
select(AccountList)
.selectAll("li")
.data(account.allChildren())
.join("li")
Expand Down
Loading

0 comments on commit f25ff30

Please sign in to comment.