mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
add basic calendar graph
This commit is contained in:
parent
41f75c00a7
commit
1b37398503
6 changed files with 223 additions and 0 deletions
|
@ -55,6 +55,7 @@
|
||||||
"d3-scale-chromatic": "^1.5.0",
|
"d3-scale-chromatic": "^1.5.0",
|
||||||
"d3-selection": "^1.4.1",
|
"d3-selection": "^1.4.1",
|
||||||
"d3-shape": "^1.3.7",
|
"d3-shape": "^1.3.7",
|
||||||
|
"d3-time": "^1.1.0",
|
||||||
"d3-transition": "^1.3.2",
|
"d3-transition": "^1.3.2",
|
||||||
"intl-pluralrules": "^1.2.0",
|
"intl-pluralrules": "^1.2.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
|
|
|
@ -22,6 +22,7 @@ function formatNumbers(args?: Record<string, RecordVal>): void {
|
||||||
|
|
||||||
export class I18n {
|
export class I18n {
|
||||||
bundles: FluentBundle[] = [];
|
bundles: FluentBundle[] = [];
|
||||||
|
langs: string[] = [];
|
||||||
TR = pb.BackendProto.FluentString;
|
TR = pb.BackendProto.FluentString;
|
||||||
|
|
||||||
tr(id: pb.BackendProto.FluentString, args?: Record<string, RecordVal>): string {
|
tr(id: pb.BackendProto.FluentString, args?: Record<string, RecordVal>): string {
|
||||||
|
@ -65,6 +66,7 @@ export async function setupI18n(): Promise<I18n> {
|
||||||
bundle.addResource(resource);
|
bundle.addResource(resource);
|
||||||
i18n.bundles.push(bundle);
|
i18n.bundles.push(bundle);
|
||||||
}
|
}
|
||||||
|
i18n.langs = json.langs;
|
||||||
|
|
||||||
return i18n;
|
return i18n;
|
||||||
}
|
}
|
||||||
|
|
73
ts/src/stats/CalendarGraph.svelte
Normal file
73
ts/src/stats/CalendarGraph.svelte
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="typescript">
|
||||||
|
import ReviewsGraph from "./ReviewsGraph.svelte";
|
||||||
|
|
||||||
|
import AxisTicks from "./AxisTicks.svelte";
|
||||||
|
import { defaultGraphBounds, RevlogRange } from "./graphs";
|
||||||
|
import { GraphData, gatherData, renderCalendar, ReviewRange } from "./calendar";
|
||||||
|
import pb from "../backend/proto";
|
||||||
|
import { timeSpan, MONTH, YEAR } from "../time";
|
||||||
|
import { I18n } from "../i18n";
|
||||||
|
|
||||||
|
export let sourceData: pb.BackendProto.GraphsOut | null = null;
|
||||||
|
export let revlogRange: RevlogRange = RevlogRange.Month;
|
||||||
|
export let i18n: I18n;
|
||||||
|
|
||||||
|
let graphData: GraphData | null = null;
|
||||||
|
|
||||||
|
let bounds = defaultGraphBounds();
|
||||||
|
bounds.height = 120;
|
||||||
|
bounds.marginLeft = 20;
|
||||||
|
bounds.marginRight = 20;
|
||||||
|
|
||||||
|
let svg = null as HTMLElement | SVGElement | null;
|
||||||
|
let maxYear = new Date().getFullYear();
|
||||||
|
let minYear;
|
||||||
|
let targetYear = maxYear;
|
||||||
|
|
||||||
|
$: if (sourceData) {
|
||||||
|
graphData = gatherData(sourceData);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (revlogRange < RevlogRange.Year) {
|
||||||
|
minYear = maxYear;
|
||||||
|
} else if ((revlogRange as RevlogRange) === RevlogRange.Year) {
|
||||||
|
minYear = maxYear - 1;
|
||||||
|
} else {
|
||||||
|
minYear = 2000;
|
||||||
|
}
|
||||||
|
if (targetYear < minYear) {
|
||||||
|
targetYear = minYear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (graphData) {
|
||||||
|
renderCalendar(svg as SVGElement, bounds, graphData, targetYear, i18n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = i18n.tr(i18n.TR.STATISTICS_REVIEWS_TITLE);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="graph">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
|
||||||
|
<div class="range-box-inner">
|
||||||
|
<span>
|
||||||
|
<button on:click={() => targetYear--} disabled={minYear >= targetYear}>
|
||||||
|
◄
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span>{targetYear}</span>
|
||||||
|
<span>
|
||||||
|
<button on:click={() => targetYear++} disabled={targetYear >= maxYear}>
|
||||||
|
►
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
|
||||||
|
<g class="days" />
|
||||||
|
<AxisTicks {bounds} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</div>
|
|
@ -17,6 +17,7 @@
|
||||||
import HourGraph from "./HourGraph.svelte";
|
import HourGraph from "./HourGraph.svelte";
|
||||||
import FutureDue from "./FutureDue.svelte";
|
import FutureDue from "./FutureDue.svelte";
|
||||||
import ReviewsGraph from "./ReviewsGraph.svelte";
|
import ReviewsGraph from "./ReviewsGraph.svelte";
|
||||||
|
import CalendarGraph from "./CalendarGraph.svelte";
|
||||||
|
|
||||||
export let i18n: I18n;
|
export let i18n: I18n;
|
||||||
|
|
||||||
|
@ -141,6 +142,7 @@
|
||||||
|
|
||||||
<TodayStats {sourceData} {i18n} />
|
<TodayStats {sourceData} {i18n} />
|
||||||
<CardCounts {sourceData} {i18n} />
|
<CardCounts {sourceData} {i18n} />
|
||||||
|
<CalendarGraph {sourceData} {revlogRange} {i18n} />
|
||||||
<FutureDue {sourceData} {revlogRange} {i18n} />
|
<FutureDue {sourceData} {revlogRange} {i18n} />
|
||||||
<ReviewsGraph {sourceData} {revlogRange} {i18n} />
|
<ReviewsGraph {sourceData} {revlogRange} {i18n} />
|
||||||
<IntervalsGraph {sourceData} {i18n} />
|
<IntervalsGraph {sourceData} {i18n} />
|
||||||
|
|
140
ts/src/stats/calendar.ts
Normal file
140
ts/src/stats/calendar.ts
Normal file
|
@ -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<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
|
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}<br>${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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -86,4 +86,9 @@ module.exports = {
|
||||||
// chunks: "all",
|
// chunks: "all",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
performance: {
|
||||||
|
hints: false,
|
||||||
|
maxEntrypointSize: 512000,
|
||||||
|
maxAssetSize: 512000,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue