anki/ts/graphs/calendar.ts

210 lines
6.4 KiB
TypeScript
Raw Normal View History

2020-06-30 07:09:20 +02:00
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
@typescript-eslint/no-explicit-any: "off",
*/
import pb from "anki/backend_proto";
2020-06-30 07:09:20 +02:00
import { interpolateBlues } from "d3-scale-chromatic";
import "d3-transition";
import { select, mouse } from "d3-selection";
import { scaleLinear, scaleSequential } from "d3-scale";
import { showTooltip, hideTooltip } from "./tooltip";
import { GraphBounds, setDataAvailable, RevlogRange } from "./graph-helpers";
2021-01-18 23:27:57 +01:00
import {
timeDay,
timeYear,
timeSunday,
timeMonday,
timeFriday,
timeSaturday,
} from "d3-time";
2021-01-18 23:23:55 +01:00
import type { CountableTimeInterval } from "d3-time";
import type { I18n } from "anki/i18n";
2020-06-30 07:09:20 +02:00
export interface GraphData {
// indexed by day, where day is relative to today
reviewCount: Map<number, number>;
2021-01-18 23:27:57 +01:00
timeFunction: CountableTimeInterval;
weekdayLabels: number[];
2020-06-30 07:09:20 +02:00
}
interface DayDatum {
day: number;
count: number;
// 0-51
weekNumber: number;
// 0-6
weekDay: number;
date: Date;
}
type WeekdayType = pb.BackendProto.GraphsPreferencesOut.Weekday;
const Weekday = pb.BackendProto.GraphsPreferencesOut.Weekday; /* enum */
export function gatherData(
data: pb.BackendProto.GraphsOut,
firstDayOfWeek: WeekdayType
): GraphData {
2020-06-30 07:09:20 +02:00
const reviewCount = new Map<number, number>();
for (const review of data.revlog as pb.BackendProto.RevlogEntry[]) {
if (review.buttonChosen == 0) {
continue;
}
const day = Math.ceil(
((review.id as number) / 1000 - data.nextDayAtSecs) / 86400
);
const count = reviewCount.get(day) ?? 0;
reviewCount.set(day, count + 1);
}
2021-01-18 23:27:57 +01:00
const timeFunction =
firstDayOfWeek === Weekday.MONDAY
2021-01-18 23:27:57 +01:00
? timeMonday
: data.firstWeekday === Weekday.FRIDAY
2021-01-18 23:27:57 +01:00
? timeFriday
: data.firstWeekday === Weekday.SATURDAY
2021-01-18 23:27:57 +01:00
? timeSaturday
: timeSunday;
2021-01-18 23:23:55 +01:00
const weekdayLabels: number[] = [];
for (let i = 0; i < 7; i++) {
weekdayLabels.push((firstDayOfWeek + i) % 7);
2021-01-20 21:17:36 +01:00
}
return { reviewCount, timeFunction, weekdayLabels };
2020-06-30 07:09:20 +02:00
}
export function renderCalendar(
svgElem: SVGElement,
bounds: GraphBounds,
sourceData: GraphData,
targetYear: number,
2020-06-30 08:23:46 +02:00
i18n: I18n,
nightMode: boolean,
revlogRange: RevlogRange
2020-06-30 07:09:20 +02:00
): void {
const svg = select(svgElem);
const now = new Date();
const nowForYear = new Date();
nowForYear.setFullYear(targetYear);
const x = scaleLinear()
.range([bounds.marginLeft, bounds.width - bounds.marginRight])
2021-01-20 21:17:36 +01:00
.domain([-1, 53]);
2020-06-30 07:09:20 +02:00
// map of 0-365 -> day
const dayMap: Map<number, DayDatum> = new Map();
let maxCount = 0;
for (const [day, count] of sourceData.reviewCount.entries()) {
const date = new Date(now.getTime() + day * 86400 * 1000);
if (date.getFullYear() != targetYear) {
continue;
}
2021-01-18 23:23:55 +01:00
const weekNumber = sourceData.timeFunction.count(timeYear(date), date);
const weekDay = timeDay.count(sourceData.timeFunction(date), date);
2020-06-30 07:09:20 +02:00
const yearDay = timeDay.count(timeYear(date), date);
dayMap.set(yearDay, { day, count, weekNumber, weekDay, date } as DayDatum);
if (count > maxCount) {
maxCount = count;
}
}
2020-07-06 06:01:49 +02:00
if (!maxCount) {
setDataAvailable(svg, false);
return;
} else {
setDataAvailable(svg, true);
}
2020-06-30 07:09:20 +02:00
// fill in any blanks
const startDate = timeYear(nowForYear);
const oneYearAgoFromNow = new Date(now);
oneYearAgoFromNow.setFullYear(now.getFullYear() - 1);
for (let i = 0; i < 365; i++) {
2020-06-30 07:09:20 +02:00
const date = new Date(startDate.getTime() + i * 86400 * 1000);
if (date > now) {
// don't fill out future dates
continue;
}
if (revlogRange == RevlogRange.Year && date < oneYearAgoFromNow) {
// don't fill out dates older than a year
continue;
}
2020-06-30 07:09:20 +02:00
const yearDay = timeDay.count(timeYear(date), date);
if (!dayMap.has(yearDay)) {
2021-01-18 23:23:55 +01:00
const weekNumber = sourceData.timeFunction.count(timeYear(date), date);
const weekDay = timeDay.count(sourceData.timeFunction(date), date);
2020-06-30 07:09:20 +02:00
dayMap.set(yearDay, {
day: yearDay,
count: 0,
weekNumber,
weekDay,
date,
} as DayDatum);
}
}
const data = Array.from(dayMap.values());
2020-06-30 08:23:46 +02:00
const cappedRange = scaleLinear().range([0.2, nightMode ? 0.8 : 1]);
const blues = scaleSequential((n) => interpolateBlues(cappedRange(n)!)).domain([
2020-06-30 08:23:46 +02:00
0,
maxCount,
]);
2020-06-30 07:09:20 +02:00
function tooltipText(d: DayDatum): string {
const date = d.date.toLocaleString(i18n.langs, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
2020-06-30 08:50:23 +02:00
const cards = i18n.tr(i18n.TR.STATISTICS_REVIEWS, { reviews: d.count });
2020-06-30 07:09:20 +02:00
return `${date}<br>${cards}`;
}
const height = bounds.height / 10;
2021-01-20 21:17:36 +01:00
const emptyColour = nightMode ? "#333" : "#ddd";
svg.select("g.weekdays")
.selectAll("text")
.data(sourceData.weekdayLabels)
.join("text")
.text((d: number) => i18n.weekdayLabel(d))
2021-01-20 21:17:36 +01:00
.attr("width", x(-1)! - 2)
.attr("height", height - 2)
.attr("x", x(1)! - 3)
2021-01-20 21:17:36 +01:00
.attr("y", (_d, index) => bounds.marginTop + index * height)
.attr("dominant-baseline", "hanging")
.attr("text-anchor", "end")
2021-01-20 21:17:36 +01:00
.attr("font-size", "small")
.attr("font-family", "monospace")
.style("user-select", "none");
svg.select("g.days")
2020-06-30 07:09:20 +02:00
.selectAll("rect")
.data(data)
.join("rect")
2020-06-30 08:39:30 +02:00
.attr("fill", emptyColour)
2021-01-20 21:17:36 +01:00
.attr("width", (d) => x(d.weekNumber + 1)! - x(d.weekNumber)! - 2)
2020-06-30 07:09:20 +02:00
.attr("height", height - 2)
2021-01-20 21:17:36 +01:00
.attr("x", (d) => x(d.weekNumber + 1)!)
2020-06-30 07:09:20 +02:00
.attr("y", (d) => bounds.marginTop + d.weekDay * height)
.on("mousemove", function (this: any, d: any) {
const [x, y] = mouse(document.body);
showTooltip(tooltipText(d), x, y);
})
.on("mouseout", hideTooltip)
2020-06-30 08:39:30 +02:00
.transition()
.duration(800)
2020-06-30 07:09:20 +02:00
.attr("fill", (d) => {
if (d.count === 0) {
2020-06-30 08:23:46 +02:00
return emptyColour;
2020-06-30 07:09:20 +02:00
} else {
return blues(d.count)!;
2020-06-30 07:09:20 +02:00
}
});
}