From 1b37398503a75817c001953d9bbbf554824a4237 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 30 Jun 2020 15:09:20 +1000 Subject: [PATCH] add basic calendar graph --- ts/package.json | 1 + ts/src/i18n.ts | 2 + ts/src/stats/CalendarGraph.svelte | 73 ++++++++++++++++ ts/src/stats/GraphsPage.svelte | 2 + ts/src/stats/calendar.ts | 140 ++++++++++++++++++++++++++++++ ts/webpack.config.js | 5 ++ 6 files changed, 223 insertions(+) create mode 100644 ts/src/stats/CalendarGraph.svelte create mode 100644 ts/src/stats/calendar.ts diff --git a/ts/package.json b/ts/package.json index 703f14c63..91ed8c38f 100644 --- a/ts/package.json +++ b/ts/package.json @@ -55,6 +55,7 @@ "d3-scale-chromatic": "^1.5.0", "d3-selection": "^1.4.1", "d3-shape": "^1.3.7", + "d3-time": "^1.1.0", "d3-transition": "^1.3.2", "intl-pluralrules": "^1.2.0", "lodash.debounce": "^4.0.8", diff --git a/ts/src/i18n.ts b/ts/src/i18n.ts index ea0e7f9dc..d21226506 100644 --- a/ts/src/i18n.ts +++ b/ts/src/i18n.ts @@ -22,6 +22,7 @@ function formatNumbers(args?: Record): void { export class I18n { bundles: FluentBundle[] = []; + langs: string[] = []; TR = pb.BackendProto.FluentString; tr(id: pb.BackendProto.FluentString, args?: Record): string { @@ -65,6 +66,7 @@ export async function setupI18n(): Promise { bundle.addResource(resource); i18n.bundles.push(bundle); } + i18n.langs = json.langs; return i18n; } diff --git a/ts/src/stats/CalendarGraph.svelte b/ts/src/stats/CalendarGraph.svelte new file mode 100644 index 000000000..b75e01822 --- /dev/null +++ b/ts/src/stats/CalendarGraph.svelte @@ -0,0 +1,73 @@ + + +
+

{title}

+ +
+ + + + {targetYear} + + + +
+ + + + + + +
diff --git a/ts/src/stats/GraphsPage.svelte b/ts/src/stats/GraphsPage.svelte index a77fb2c39..f8a42f4b5 100644 --- a/ts/src/stats/GraphsPage.svelte +++ b/ts/src/stats/GraphsPage.svelte @@ -17,6 +17,7 @@ import HourGraph from "./HourGraph.svelte"; import FutureDue from "./FutureDue.svelte"; import ReviewsGraph from "./ReviewsGraph.svelte"; + import CalendarGraph from "./CalendarGraph.svelte"; export let i18n: I18n; @@ -141,6 +142,7 @@ + diff --git a/ts/src/stats/calendar.ts b/ts/src/stats/calendar.ts new file mode 100644 index 000000000..e9ba26f18 --- /dev/null +++ b/ts/src/stats/calendar.ts @@ -0,0 +1,140 @@ +// 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 "../backend/proto"; +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 } from "./graphs"; +import { timeDay, timeYear, timeWeek } from "d3-time"; +import { I18n } from "../i18n"; + +export interface GraphData { + // indexed by day, where day is relative to today + reviewCount: Map; +} + +interface DayDatum { + day: number; + count: number; + // 0-51 + weekNumber: number; + // 0-6 + weekDay: number; + date: Date; +} + +export function gatherData(data: pb.BackendProto.GraphsOut): GraphData { + const reviewCount = new Map(); + + 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); + } + + return { reviewCount }; +} + +export function renderCalendar( + svgElem: SVGElement, + bounds: GraphBounds, + sourceData: GraphData, + targetYear: number, + i18n: I18n +): 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]) + .domain([0, 53]); + // map of 0-365 -> day + const dayMap: Map = 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; + } + const weekNumber = timeWeek.count(timeYear(date), date); + const weekDay = timeDay.count(timeWeek(date), date); + const yearDay = timeDay.count(timeYear(date), date); + dayMap.set(yearDay, { day, count, weekNumber, weekDay, date } as DayDatum); + if (count > maxCount) { + maxCount = count; + } + } + // fill in any blanks + const startDate = timeYear(nowForYear); + for (let i = 0; i < 366; i++) { + const date = new Date(startDate.getTime() + i * 86400 * 1000); + if (date > now) { + // don't fill out future dates + continue; + } + const yearDay = timeDay.count(timeYear(date), date); + if (!dayMap.has(yearDay)) { + const weekNumber = timeWeek.count(timeYear(date), date); + const weekDay = timeDay.count(timeWeek(date), date); + dayMap.set(yearDay, { + day: yearDay, + count: 0, + weekNumber, + weekDay, + date, + } as DayDatum); + } + } + const data = Array.from(dayMap.values()); + const blues = scaleSequential(interpolateBlues).domain([0, maxCount]); + + function tooltipText(d: DayDatum): string { + const date = d.date.toLocaleString(i18n.langs, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + const cards = i18n.tr(i18n.TR.STATISTICS_CARDS, { cards: d.count }); + return `${date}
${cards}`; + } + + const height = bounds.height / 10; + svg.select(`g.days`) + .selectAll("rect") + .data(data) + .join("rect") + .attr("width", (d) => { + return x(d.weekNumber + 1) - x(d.weekNumber) - 2; + }) + .attr("height", height - 2) + .attr("x", (d) => x(d.weekNumber)) + .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) + .attr("fill", (d) => { + if (d.count === 0) { + return "#eee"; + } else { + return blues(d.count); + } + }); +} diff --git a/ts/webpack.config.js b/ts/webpack.config.js index 313ccdd08..a86b7063e 100644 --- a/ts/webpack.config.js +++ b/ts/webpack.config.js @@ -86,4 +86,9 @@ module.exports = { // chunks: "all", }, }, + performance: { + hints: false, + maxEntrypointSize: 512000, + maxAssetSize: 512000, + }, };