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",
|
|
|
|
*/
|
|
|
|
|
2021-01-22 14:56:41 +01:00
|
|
|
import pb from "anki/backend_proto";
|
2021-01-18 23:27:57 +01:00
|
|
|
import {
|
2021-01-30 01:13:47 +01:00
|
|
|
interpolateBlues,
|
|
|
|
select,
|
|
|
|
pointer,
|
|
|
|
scaleLinear,
|
|
|
|
scaleSequentialSqrt,
|
2021-01-18 23:27:57 +01:00
|
|
|
timeDay,
|
|
|
|
timeYear,
|
|
|
|
timeSunday,
|
|
|
|
timeMonday,
|
|
|
|
timeFriday,
|
|
|
|
timeSaturday,
|
2021-01-30 01:13:47 +01:00
|
|
|
} from "d3";
|
|
|
|
import type { CountableTimeInterval } from "d3";
|
|
|
|
import { showTooltip, hideTooltip } from "./tooltip";
|
|
|
|
import {
|
|
|
|
GraphBounds,
|
|
|
|
setDataAvailable,
|
|
|
|
RevlogRange,
|
|
|
|
SearchDispatch,
|
|
|
|
} from "./graph-helpers";
|
2020-11-05 02:01:30 +01:00
|
|
|
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;
|
2021-01-21 19:31:47 +01:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2021-01-23 11:47:45 +01:00
|
|
|
type WeekdayType = pb.BackendProto.GraphPreferences.Weekday;
|
|
|
|
const Weekday = pb.BackendProto.GraphPreferences.Weekday; /* enum */
|
2021-01-22 14:56:41 +01:00
|
|
|
|
|
|
|
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 =
|
2021-01-22 14:56:41 +01:00
|
|
|
firstDayOfWeek === Weekday.MONDAY
|
2021-01-18 23:27:57 +01:00
|
|
|
? timeMonday
|
2021-01-22 16:53:33 +01:00
|
|
|
: firstDayOfWeek === Weekday.FRIDAY
|
2021-01-18 23:27:57 +01:00
|
|
|
? timeFriday
|
2021-01-22 16:53:33 +01:00
|
|
|
: firstDayOfWeek === Weekday.SATURDAY
|
2021-01-18 23:27:57 +01:00
|
|
|
? timeSaturday
|
|
|
|
: timeSunday;
|
2021-01-18 23:23:55 +01:00
|
|
|
|
2021-01-21 19:31:47 +01:00
|
|
|
const weekdayLabels: number[] = [];
|
|
|
|
for (let i = 0; i < 7; i++) {
|
2021-01-22 14:56:41 +01:00
|
|
|
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,
|
2021-01-26 12:41:22 +01:00
|
|
|
dispatch: SearchDispatch,
|
2020-06-30 07:09:20 +02:00
|
|
|
targetYear: number,
|
2020-06-30 08:23:46 +02:00
|
|
|
i18n: I18n,
|
2020-08-21 05:41:34 +02:00
|
|
|
nightMode: boolean,
|
2021-01-22 17:51:15 +01:00
|
|
|
revlogRange: RevlogRange,
|
2021-01-22 18:03:58 +01:00
|
|
|
setFirstDayOfWeek: (d: number) => void
|
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);
|
2020-08-21 05:41:34 +02:00
|
|
|
const oneYearAgoFromNow = new Date(now);
|
|
|
|
oneYearAgoFromNow.setFullYear(now.getFullYear() - 1);
|
2020-08-21 05:40:50 +02:00
|
|
|
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;
|
|
|
|
}
|
2020-08-21 05:41:34 +02:00
|
|
|
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]);
|
2021-01-22 22:39:29 +01:00
|
|
|
const blues = scaleSequentialSqrt()
|
|
|
|
.domain([0, maxCount])
|
|
|
|
.interpolator((n) => interpolateBlues(cappedRange(n)!));
|
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")
|
2021-01-21 19:31:47 +01:00
|
|
|
.text((d: number) => i18n.weekdayLabel(d))
|
2021-01-20 21:17:36 +01:00
|
|
|
.attr("width", x(-1)! - 2)
|
|
|
|
.attr("height", height - 2)
|
2021-01-20 22:07:02 +01:00
|
|
|
.attr("x", x(1)! - 3)
|
2021-01-20 21:17:36 +01:00
|
|
|
.attr("y", (_d, index) => bounds.marginTop + index * height)
|
2021-01-22 19:02:05 +01:00
|
|
|
.attr("fill", nightMode ? "#ddd" : "black")
|
2021-01-20 21:17:36 +01:00
|
|
|
.attr("dominant-baseline", "hanging")
|
2021-01-20 22:07:02 +01:00
|
|
|
.attr("text-anchor", "end")
|
2021-01-20 21:17:36 +01:00
|
|
|
.attr("font-size", "small")
|
|
|
|
.attr("font-family", "monospace")
|
2021-01-22 17:51:15 +01:00
|
|
|
.style("user-select", "none")
|
|
|
|
.on("click", null)
|
2021-01-22 18:03:58 +01:00
|
|
|
.filter((d: number) =>
|
|
|
|
[Weekday.SUNDAY, Weekday.MONDAY, Weekday.FRIDAY, Weekday.SATURDAY].includes(
|
|
|
|
d
|
|
|
|
)
|
|
|
|
)
|
2021-01-22 17:51:15 +01:00
|
|
|
.on("click", setFirstDayOfWeek);
|
2021-01-20 21:17:36 +01:00
|
|
|
|
|
|
|
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)
|
2021-01-30 01:13:47 +01:00
|
|
|
.on("mousemove", (event: MouseEvent, d: DayDatum) => {
|
2021-01-30 02:35:33 +01:00
|
|
|
const [x, y] = pointer(event, document.body);
|
2020-06-30 07:09:20 +02:00
|
|
|
showTooltip(tooltipText(d), x, y);
|
|
|
|
})
|
|
|
|
.on("mouseout", hideTooltip)
|
2021-01-18 02:09:04 +01:00
|
|
|
.attr("class", (d: any): string => {
|
|
|
|
return d.count > 0 ? "clickable" : "";
|
|
|
|
})
|
2021-01-30 02:08:01 +01:00
|
|
|
.on("click", function (_event: MouseEvent, d: any) {
|
2021-01-18 02:09:04 +01:00
|
|
|
if (d.count > 0) {
|
|
|
|
dispatch("search", { query: `"prop:rated=${d.day}"` });
|
|
|
|
}
|
2021-01-18 01:24:32 +01:00
|
|
|
})
|
2020-06-30 08:39:30 +02:00
|
|
|
.transition()
|
|
|
|
.duration(800)
|
2021-01-22 18:03:58 +01:00
|
|
|
.attr("fill", (d) => (d.count === 0 ? emptyColour : blues(d.count)!));
|
2020-06-30 07:09:20 +02:00
|
|
|
}
|