Anki/ts/routes/graphs/card-counts.ts
2025-01-12 20:05:05 +11:00

188 lines
5.4 KiB
TypeScript

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-explicit-any: "off",
*/
import type { GraphsResponse } from "@generated/anki/stats_pb";
import * as tr from "@generated/ftl";
import { localizedNumber } from "@tslib/i18n";
import {
arc,
cumsum,
interpolate,
pie,
scaleLinear,
schemeBlues,
schemeGreens,
schemeOranges,
schemeReds,
select,
sum,
} from "d3";
import type { GraphBounds } from "./graph-helpers";
type Count = [string, number, boolean, string];
export interface GraphData {
title: string;
counts: Count[];
totalCards: string;
}
const barColours = [
schemeBlues[5][2], /* new */
schemeOranges[5][2], /* learn */
schemeReds[5][2], /* relearn */
schemeGreens[5][2], /* young */
schemeGreens[5][3], /* mature */
"#FFDC41", /* suspended */
"grey", /* buried */
];
function countCards(data: GraphsResponse, separateInactive: boolean): Count[] {
const countData = separateInactive ? data.cardCounts!.excludingInactive! : data.cardCounts!.includingInactive!;
const extraQuery = separateInactive ? "AND -(\"is:buried\" OR \"is:suspended\")" : "";
const counts: Count[] = [
[tr.statisticsCountsNewCards(), countData.newCards, true, `"is:new"${extraQuery}`],
[
tr.statisticsCountsLearningCards(),
countData.learn,
true,
`(-"is:review" AND "is:learn")${extraQuery}`,
],
[
tr.statisticsCountsRelearningCards(),
countData.relearn,
true,
`("is:review" AND "is:learn")${extraQuery}`,
],
[
tr.statisticsCountsYoungCards(),
countData.young,
true,
`("is:review" AND -"is:learn") AND "prop:ivl<21"${extraQuery}`,
],
[
tr.statisticsCountsMatureCards(),
countData.mature,
true,
`("is:review" -"is:learn") AND "prop:ivl>=21"${extraQuery}`,
],
[
tr.statisticsCountsSuspendedCards(),
countData.suspended,
separateInactive,
"\"is:suspended\"",
],
[tr.statisticsCountsBuriedCards(), countData.buried, separateInactive, "\"is:buried\""],
];
return counts;
}
export function gatherData(
data: GraphsResponse,
separateInactive: boolean,
): GraphData {
const counts = countCards(data, separateInactive);
const totalCards = localizedNumber(sum(counts, e => e[1]));
return {
title: tr.statisticsCountsTitle(),
counts,
totalCards,
};
}
export interface SummedDatum {
label: string;
// count of this particular item
count: number;
// show up in the table
show: boolean;
query: string;
// running total
total: number;
}
export interface TableDatum {
label: string;
count: string;
query: string;
percent: string;
colour: string;
}
export function renderCards(
svgElem: SVGElement,
bounds: GraphBounds,
sourceData: GraphData,
): TableDatum[] {
const summed = cumsum(sourceData.counts, (d: Count) => d[1]);
const data = Array.from(summed).map((n, idx) => {
const count = sourceData.counts[idx];
return {
label: count[0],
count: count[1],
show: count[2],
query: count[3],
total: n,
} satisfies SummedDatum;
});
// ensuring a non-zero range makes the percentages not break
// in an empty collection
const xMax = Math.max(1, summed.slice(-1)[0]);
const x = scaleLinear().domain([0, xMax]);
const svg = select(svgElem);
const paths = svg.select(".counts");
const pieData = pie()(sourceData.counts.map((d: Count) => d[1]));
const radius = bounds.height / 2 - bounds.marginTop - bounds.marginBottom;
const arcGen = arc().innerRadius(0).outerRadius(radius);
const trans = svg.transition().duration(600) as any;
paths
.attr("transform", `translate(${radius},${radius + bounds.marginTop})`)
.selectAll("path")
.data(pieData)
.join(
(enter) =>
enter
.append("path")
.attr("fill", (_d, idx) => {
return barColours[idx];
})
.attr("d", arcGen as any),
function(update) {
return update.call((d) =>
d.transition(trans).attrTween("d", (d) => {
const interpolator = interpolate(
{ startAngle: 0, endAngle: 0 },
d,
);
return (t): string => arcGen(interpolator(t) as any) as string;
})
);
},
);
x.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
const tableData = data.flatMap((d: SummedDatum, idx: number) => {
const percent = localizedNumber((d.count / xMax) * 100, 2);
return d.show
? ({
label: d.label,
count: localizedNumber(d.count),
percent: `${percent}%`,
colour: barColours[idx],
query: d.query,
} satisfies TableDatum)
: [];
});
return tableData;
}