diff --git a/package-lock.json b/package-lock.json index d28e90f..befec3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@types/lodash": "^4.14.181", "@types/sprintf-js": "^1.1.2", "bulma": "^0.9.4", + "chroma-js": "^2.4.2", "clusterize.js": "^0.19.0", "d3": "^7.4.0", "d3-svg-legend": "^2.25.6", @@ -22,6 +23,7 @@ "tippy.js": "^6.3.7" }, "devDependencies": { + "@types/chroma-js": "^2.1.4", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", "eslint": "^8.15.0", @@ -173,6 +175,12 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@types/chroma-js": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.1.4.tgz", + "integrity": "sha512-l9hWzP7cp7yleJUI7P2acmpllTJNYf5uU6wh50JzSIZt3fFHe+w2FM6w9oZGBTYzjjm2qHdnQvI+fF/JF/E5jQ==", + "dev": true + }, "node_modules/@types/clusterize.js": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/@types/clusterize.js/-/clusterize.js-0.18.1.tgz", @@ -950,6 +958,11 @@ "fsevents": "~2.3.2" } }, + "node_modules/chroma-js": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", + "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" + }, "node_modules/clusterize.js": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/clusterize.js/-/clusterize.js-0.19.0.tgz", @@ -3188,6 +3201,12 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==" }, + "@types/chroma-js": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.1.4.tgz", + "integrity": "sha512-l9hWzP7cp7yleJUI7P2acmpllTJNYf5uU6wh50JzSIZt3fFHe+w2FM6w9oZGBTYzjjm2qHdnQvI+fF/JF/E5jQ==", + "dev": true + }, "@types/clusterize.js": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/@types/clusterize.js/-/clusterize.js-0.18.1.tgz", @@ -3783,6 +3802,11 @@ "readdirp": "~3.6.0" } }, + "chroma-js": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", + "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" + }, "clusterize.js": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/clusterize.js/-/clusterize.js-0.19.0.tgz", diff --git a/package.json b/package.json index 27d7a99..a280b85 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "@types/jquery": "^3.5.14", "@types/lodash": "^4.14.181", "@types/sprintf-js": "^1.1.2", + "bulma": "^0.9.4", + "chroma-js": "^2.4.2", "clusterize.js": "^0.19.0", "d3": "^7.4.0", "d3-svg-legend": "^2.25.6", @@ -13,10 +15,10 @@ "jquery": "^3.6.0", "lodash": "^4.17.21", "sprintf-js": "^1.1.2", - "tippy.js": "^6.3.7", - "bulma": "^0.9.4" + "tippy.js": "^6.3.7" }, "devDependencies": { + "@types/chroma-js": "^2.1.4", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", "eslint": "^8.15.0", diff --git a/web/src/allocation.ts b/web/src/allocation.ts index 32b5ede..513ab33 100644 --- a/web/src/allocation.ts +++ b/web/src/allocation.ts @@ -15,8 +15,10 @@ import { secondName, textColor, tooltip, - skipTicks + skipTicks, + generateColorScheme } from "./utils"; +import COLORS from "./colors"; export default async function () { const { @@ -55,7 +57,7 @@ function renderAllocationTarget(allocationTargets: AllocationTarget[]) { const keys = ["target", "current"]; const colorKeys = ["target", "current", "diff"]; - const colors = ["#1f77b4", "#17becf", "#4a4a4a"]; + const colors = [COLORS.primary, COLORS.secondary, COLORS.diff]; const y = d3.scaleBand().range([0, height]).paddingInner(0).paddingOuter(0); y.domain(allocationTargets.map((t) => t.name)); @@ -244,7 +246,7 @@ function renderPartition(element: HTMLElement, aggregates, hierarchy) { return formatFloat((d.value / root.value) * 100) + "%"; }; - const color = rainbowScale(_.keys(aggregates)); + const color = generateColorScheme(_.keys(aggregates)); const stratify = d3 .stratify() @@ -373,7 +375,7 @@ function renderAllocationTimeline( 0, d3.max(d3.map(points, (p) => d3.max(_.values(_.omit(p, "date"))))) ]), - z = d3.scaleOrdinal(d3.schemeCategory10).domain(assets); + z = generateColorScheme(assets); const line = (group) => d3 diff --git a/web/src/colors.ts b/web/src/colors.ts new file mode 100644 index 0000000..d2f6eae --- /dev/null +++ b/web/src/colors.ts @@ -0,0 +1,11 @@ +const COLORS = { + gain: "#b2df8a", + gainText: "#48c78e", + loss: "#fb9a99", + lossText: "#f14668", + primary: "#1f77b4", + secondary: "#17becf", + tertiary: "#ff7f0e", + diff: "#4a4a4a" +}; +export default COLORS; diff --git a/web/src/expense.ts b/web/src/expense.ts index 4276a8e..912224f 100644 --- a/web/src/expense.ts +++ b/web/src/expense.ts @@ -1,7 +1,7 @@ import * as d3 from "d3"; import legend from "d3-svg-legend"; -import { sprintf } from "sprintf-js"; import dayjs, { Dayjs } from "dayjs"; +import chroma from "chroma-js"; import _ from "lodash"; import { ajax, @@ -14,8 +14,10 @@ import { secondName, setHtml, skipTicks, - tooltip + tooltip, + generateColorScheme } from "./utils"; +import COLORS from "./colors"; export default async function () { const { @@ -82,10 +84,10 @@ function renderSelectedMonth( investments: Posting[] ) { renderer(expenses); - setHtml("current-month-income", sum(incomes, -1)); - setHtml("current-month-tax", sum(taxes)); - setHtml("current-month-expenses", sum(expenses)); - setHtml("current-month-investment", sum(investments)); + setHtml("current-month-income", sum(incomes, -1), COLORS.gainText); + setHtml("current-month-tax", sum(taxes), COLORS.lossText); + setHtml("current-month-expenses", sum(expenses), COLORS.lossText); + setHtml("current-month-investment", sum(investments), COLORS.secondary); } function sum(postings: Posting[], sign = 1) { @@ -159,7 +161,7 @@ function renderMonthlyExpensesTimeline( x.domain(points.map((p) => p.month)); y.domain([0, d3.max(points, sum)]); - const z = d3.scaleOrdinal().range(d3.schemeCategory10); + const z = generateColorScheme(groups); g.append("g") .attr("class", "axis x") @@ -401,8 +403,8 @@ function renderCurrentExpensesBreakdown( .style("white-space", "pre") .style("font-size", "13px") .style("font-weight", "bold") - .attr("fill", function (d) { - return z(d.category); + .style("fill", function (d) { + return chroma(z(d.category)).darken(0.8).hex(); }) .attr("class", "is-family-monospace") .text( diff --git a/web/src/gain.ts b/web/src/gain.ts index 916bb35..5642adb 100644 --- a/web/src/gain.ts +++ b/web/src/gain.ts @@ -1,7 +1,9 @@ +import chroma from "chroma-js"; import * as d3 from "d3"; import legend from "d3-svg-legend"; import dayjs from "dayjs"; import _ from "lodash"; +import COLORS from "./colors"; import { ajax, formatCurrency, @@ -26,13 +28,13 @@ export default async function () { } const areaKeys = ["gain", "loss"]; -const colors = ["#b2df8a", "#fb9a99"]; +const colors = [COLORS.gain, COLORS.loss]; const areaScale = d3.scaleOrdinal().domain(areaKeys).range(colors); const lineKeys = ["balance", "investment", "withdrawal"]; const lineScale = d3 .scaleOrdinal() .domain(lineKeys) - .range(["#1f77b4", "#17becf", "#ff7f0e"]); + .range([COLORS.primary, COLORS.secondary, COLORS.tertiary]); function renderTable(gain: Gain) { const tbody = d3.select(this); @@ -95,7 +97,13 @@ function renderOverview(gains: Gain[]) { .paddingOuter(0.1); const keys = ["balance", "investment", "withdrawal", "gain", "loss"]; - const colors = ["#1f77b4", "#17becf", "#ff7f0e", "#b2df8a", "#fb9a99"]; + const colors = [ + COLORS.primary, + COLORS.secondary, + COLORS.tertiary, + COLORS.gain, + COLORS.loss + ]; const z = d3.scaleOrdinal(colors).domain(keys); const getInvestmentAmount = (g: Gain) => @@ -203,7 +211,9 @@ function renderOverview(gains: Gain[]) { .text((g) => formatCurrency(getGainAmount(g))) .attr("alignment-baseline", "hanging") .attr("text-anchor", "end") - .style("fill", (g) => (getGainAmount(g) > 0 ? z("gain") : "none")) + .style("fill", (g) => + getGainAmount(g) > 0 ? chroma(z("gain")).darken().hex() : "none" + ) .attr("dx", "-3") .attr("dy", "3") .attr("x", textGroupZero + (textGroupWidth * 2) / 3) @@ -223,7 +233,9 @@ function renderOverview(gains: Gain[]) { .append("text") .text((g) => formatCurrency(getGainAmount(g))) .attr("text-anchor", "end") - .style("fill", (g) => (getGainAmount(g) < 0 ? z("loss") : "none")) + .style("fill", (g) => + getGainAmount(g) < 0 ? chroma(z("loss")).darken().hex() : "none" + ) .attr("dx", "-3") .attr("dy", "-3") .attr("x", textGroupZero + (textGroupWidth * 2) / 3) @@ -254,7 +266,11 @@ function renderOverview(gains: Gain[]) { .text((g) => formatFloat(g.xirr)) .attr("text-anchor", "end") .attr("alignment-baseline", "middle") - .style("fill", (g) => (g.xirr < 0 ? z("loss") : z("gain"))) + .style("fill", (g) => + g.xirr < 0 + ? chroma(z("loss")).darken().hex() + : chroma(z("gain")).darken().hex() + ) .attr("x", xirrWidth + xirrTextWidth) .attr("y", (g) => y(restName(g.account)) + y.bandwidth() / 2); diff --git a/web/src/income.ts b/web/src/income.ts index 4f77726..d08960f 100644 --- a/web/src/income.ts +++ b/web/src/income.ts @@ -2,10 +2,12 @@ import * as d3 from "d3"; import legend from "d3-svg-legend"; import dayjs from "dayjs"; import _ from "lodash"; +import COLORS from "./colors"; import { ajax, formatCurrency, formatCurrencyCrude, + generateColorScheme, Income, Posting, restName, @@ -27,8 +29,8 @@ export default async function () { const netTax = _.sumBy(taxes, (t) => _.sumBy(t.postings, (p) => p.amount)); - setHtml("gross-income", formatCurrency(grossIncome)); - setHtml("net-tax", formatCurrency(netTax)); + setHtml("gross-income", formatCurrency(grossIncome), COLORS.gainText); + setHtml("net-tax", formatCurrency(netTax), COLORS.lossText); } function renderMonthlyInvestmentTimeline(incomes: Income[]) { @@ -119,7 +121,7 @@ function renderIncomeTimeline( ) ]); - const z = d3.scaleOrdinal().range(d3.schemeCategory10); + const z = generateColorScheme(groupKeys); g.append("g") .attr("class", "axis x") diff --git a/web/src/investment.ts b/web/src/investment.ts index 104406a..f5c9bbd 100644 --- a/web/src/investment.ts +++ b/web/src/investment.ts @@ -8,6 +8,7 @@ import { formatCurrency, formatCurrencyCrude, formatFloat, + generateColorScheme, Posting, secondName, skipTicks, @@ -132,7 +133,7 @@ function renderMonthlyInvestmentTimeline(postings: Posting[]) { ) ]); - const z = d3.scaleOrdinal().range(d3.schemeCategory10); + const z = generateColorScheme(groups); g.append("g") .attr("class", "axis x") @@ -312,7 +313,7 @@ function renderYearlyInvestmentTimeline(yearlyCards: YearlyCard[]) { ) ]); - const z = d3.scaleOrdinal().range(d3.schemeCategory10); + const z = generateColorScheme(groups); g.append("g") .attr("class", "axis y") diff --git a/web/src/overview.ts b/web/src/overview.ts index 483b821..33bc358 100644 --- a/web/src/overview.ts +++ b/web/src/overview.ts @@ -2,6 +2,7 @@ import * as d3 from "d3"; import legend from "d3-svg-legend"; import dayjs from "dayjs"; import _ from "lodash"; +import COLORS from "./colors"; import { ajax, formatCurrency, @@ -22,13 +23,19 @@ export default async function () { current.investment_amount + current.gain_amount - current.withdrawal_amount - ) + ), + COLORS.primary ); setHtml( "investment", - formatCurrency(current.investment_amount - current.withdrawal_amount) + formatCurrency(current.investment_amount - current.withdrawal_amount), + COLORS.secondary + ); + setHtml( + "gains", + formatCurrency(current.gain_amount), + current.gain_amount >= 0 ? COLORS.gainText : COLORS.lossText ); - setHtml("gains", formatCurrency(current.gain_amount)); setHtml("xirr", formatFloat(xirr)); renderOverview(points, document.getElementById("d3-overview-timeline")); @@ -47,7 +54,7 @@ function renderOverview(points: Overview[], element: Element) { .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); const areaKeys = ["gain", "loss"]; - const colors = ["#b2df8a", "#fb9a99"]; + const colors = [COLORS.gain, COLORS.loss]; const areaScale = d3.scaleOrdinal().domain(areaKeys).range(colors); const lineKeys = ["networth", "investment"]; @@ -55,7 +62,7 @@ function renderOverview(points: Overview[], element: Element) { const lineScale = d3 .scaleOrdinal() .domain(lineKeys) - .range(["#1f77b4", "#17becf", "#ff7f0e"]); + .range([COLORS.primary, COLORS.secondary]); const positions = _.flatMap(points, (p) => [ p.gain_amount + p.investment_amount - p.withdrawal_amount, diff --git a/web/src/utils.ts b/web/src/utils.ts index d900f99..6e87003 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -1,3 +1,4 @@ +import chroma from "chroma-js"; import dayjs from "dayjs"; import { sprintf } from "sprintf-js"; import _ from "lodash"; @@ -243,8 +244,106 @@ export function skipTicks( }; } -export function setHtml(selector: string, value: string) { - const node = document.querySelector(".d3-" + selector); +export function generateColorScheme(domain: string[]) { + let colors: string[]; + + const n = domain.length; + if (n <= 12) { + colors = { + 1: ["#7570b3"], + 2: ["#7fc97f", "#fdc086"], + 3: ["#66c2a5", "#fc8d62", "#8da0cb"], + 4: ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3"], + 5: ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854"], + 6: ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f"], + 7: [ + "#8dd3c7", + "#ffed6f", + "#bebada", + "#fb8072", + "#80b1d3", + "#fdb462", + "#b3de69" + ], + 8: [ + "#8dd3c7", + "#ffed6f", + "#bebada", + "#fb8072", + "#80b1d3", + "#fdb462", + "#b3de69", + "#fccde5" + ], + 9: [ + "#8dd3c7", + "#ffed6f", + "#bebada", + "#fb8072", + "#80b1d3", + "#fdb462", + "#b3de69", + "#fccde5", + "#d9d9d9" + ], + 10: [ + "#8dd3c7", + "#ffed6f", + "#bebada", + "#fb8072", + "#80b1d3", + "#fdb462", + "#b3de69", + "#fccde5", + "#d9d9d9", + "#bc80bd" + ], + 11: [ + "#8dd3c7", + "#ffed6f", + "#bebada", + "#fb8072", + "#80b1d3", + "#fdb462", + "#b3de69", + "#fccde5", + "#d9d9d9", + "#bc80bd", + "#ccebc5" + ], + 12: [ + "#8dd3c7", + "#ffed6f", + "#bebada", + "#fb8072", + "#80b1d3", + "#fdb462", + "#b3de69", + "#fccde5", + "#d9d9d9", + "#bc80bd", + "#ccebc5", + "#ffed6f" + ] + }[n]; + } else { + const z = d3 + .scaleSequential() + .domain([0, n - 1]) + .interpolator(d3.interpolateSinebow); + colors = _.map(_.range(0, n), (n) => chroma(z(n)).desaturate(1.5).hex()); + } + + return d3.scaleOrdinal().domain(domain).range(colors); +} + +export function setHtml(selector: string, value: string, color?: string) { + const node: HTMLElement = document.querySelector(".d3-" + selector); + if (color) { + node.style.backgroundColor = color; + node.style.padding = "5px"; + node.style.color = "white"; + } node.innerHTML = value; } diff --git a/web/static/index.html b/web/static/index.html index 9ca61b7..1e7b860 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -58,7 +58,7 @@
-

Gain

+

Gain / Loss

@@ -363,10 +363,9 @@
-
-
- -

+
+
+
@@ -389,13 +388,13 @@

Net Investment

-

+

Expenses

-

+