mirror of
https://github.com/ankitects/anki.git
synced 2025-09-25 01:06:35 -04:00
polish graphs of simulator, true retention table and forgetting curve (#3448)
* polish graphs of simulator and forgetting curve * True Retention: decrease precision of percentages * apply uniform sampling rate to forgetting curve * don't display time, only date when maxDays >= 365 * don't floor the totalDaysSinceLastReview * correct cramming condition * improve code-style * polish ticks & tooltip of simulator * remove unused import * fix minor error of daysSinceFirstLearn * filter out revlog entries from before the reset https://forums.ankiweb.net/t/anki-24-10-beta/49989/63?u=l.m.sherlock * use Math.ceil for windowSize * fill currentColor for legend text * remove mix-blend-mode: multiply * tune the position of legend
This commit is contained in:
parent
fdc69505e9
commit
59969f62f5
8 changed files with 165 additions and 121 deletions
|
@ -50,51 +50,32 @@ impl GraphsContext {
|
||||||
.map(|(name, _, _)| (*name, TrueRetention::default()))
|
.map(|(name, _, _)| (*name, TrueRetention::default()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for review in &self.revlog {
|
self.revlog
|
||||||
|
.iter()
|
||||||
|
.filter(|review| {
|
||||||
|
// not manually rescheduled
|
||||||
|
review.button_chosen > 0
|
||||||
|
// not cramming
|
||||||
|
&& (review.review_kind != RevlogReviewKind::Filtered || review.ease_factor != 0)
|
||||||
|
// cards with an interval ≥ 1 day
|
||||||
|
&& (review.review_kind == RevlogReviewKind::Review
|
||||||
|
|| review.last_interval <= -86400
|
||||||
|
|| review.last_interval >= 1)
|
||||||
|
})
|
||||||
|
.for_each(|review| {
|
||||||
for (period_name, start, end) in &periods {
|
for (period_name, start, end) in &periods {
|
||||||
if review.id.as_secs() >= *start && review.id.as_secs() < *end {
|
if review.id.as_secs() >= *start && review.id.as_secs() < *end {
|
||||||
let period_stat = period_stats.get_mut(period_name).unwrap();
|
let period_stat = period_stats.get_mut(period_name).unwrap();
|
||||||
const MATURE_IVL: i32 = 21; // mature interval is 21 days
|
const MATURE_IVL: i32 = 21; // mature interval is 21 days
|
||||||
|
match (review.last_interval < MATURE_IVL, review.button_chosen) {
|
||||||
match review.review_kind {
|
(true, 1) => period_stat.young_failed += 1,
|
||||||
RevlogReviewKind::Learning
|
(true, _) => period_stat.young_passed += 1,
|
||||||
| RevlogReviewKind::Review
|
(false, 1) => period_stat.mature_failed += 1,
|
||||||
| RevlogReviewKind::Relearning => {
|
(false, _) => period_stat.mature_passed += 1,
|
||||||
if review.last_interval < MATURE_IVL
|
|
||||||
&& review.button_chosen == 1
|
|
||||||
&& (review.review_kind == RevlogReviewKind::Review
|
|
||||||
|| review.last_interval <= -86400
|
|
||||||
|| review.last_interval >= 1)
|
|
||||||
{
|
|
||||||
period_stat.young_failed += 1;
|
|
||||||
} else if review.last_interval < MATURE_IVL
|
|
||||||
&& review.button_chosen > 1
|
|
||||||
&& (review.review_kind == RevlogReviewKind::Review
|
|
||||||
|| review.last_interval <= -86400
|
|
||||||
|| review.last_interval >= 1)
|
|
||||||
{
|
|
||||||
period_stat.young_passed += 1;
|
|
||||||
} else if review.last_interval >= MATURE_IVL
|
|
||||||
&& review.button_chosen == 1
|
|
||||||
&& (review.review_kind == RevlogReviewKind::Review
|
|
||||||
|| review.last_interval <= -86400
|
|
||||||
|| review.last_interval >= 1)
|
|
||||||
{
|
|
||||||
period_stat.mature_failed += 1;
|
|
||||||
} else if review.last_interval >= MATURE_IVL
|
|
||||||
&& review.button_chosen > 1
|
|
||||||
&& (review.review_kind == RevlogReviewKind::Review
|
|
||||||
|| review.last_interval <= -86400
|
|
||||||
|| review.last_interval >= 1)
|
|
||||||
{
|
|
||||||
period_stat.mature_passed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RevlogReviewKind::Filtered | RevlogReviewKind::Manual => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
stats.today = Some(period_stats["today"].clone());
|
stats.today = Some(period_stats["today"].clone());
|
||||||
stats.yesterday = Some(period_stats["yesterday"].clone());
|
stats.yesterday = Some(period_stats["yesterday"].clone());
|
||||||
|
|
|
@ -10,32 +10,58 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import AxisTicks from "../graphs/AxisTicks.svelte";
|
import AxisTicks from "../graphs/AxisTicks.svelte";
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import InputBox from "../graphs/InputBox.svelte";
|
import InputBox from "../graphs/InputBox.svelte";
|
||||||
import { prepareData, renderForgettingCurve, TimeRange } from "./forgetting-curve";
|
import {
|
||||||
|
renderForgettingCurve,
|
||||||
|
TimeRange,
|
||||||
|
calculateMaxDays,
|
||||||
|
filterRevlog,
|
||||||
|
} from "./forgetting-curve";
|
||||||
import { defaultGraphBounds } from "../graphs/graph-helpers";
|
import { defaultGraphBounds } from "../graphs/graph-helpers";
|
||||||
import HoverColumns from "../graphs/HoverColumns.svelte";
|
import HoverColumns from "../graphs/HoverColumns.svelte";
|
||||||
|
|
||||||
export let revlog: RevlogEntry[];
|
export let revlog: RevlogEntry[];
|
||||||
let svg = null as HTMLElement | SVGElement | null;
|
let svg = null as HTMLElement | SVGElement | null;
|
||||||
const bounds = defaultGraphBounds();
|
const bounds = defaultGraphBounds();
|
||||||
const timeRange = writable(TimeRange.AllTime);
|
|
||||||
const title = tr.cardStatsFsrsForgettingCurveTitle();
|
const title = tr.cardStatsFsrsForgettingCurveTitle();
|
||||||
const data = prepareData(revlog, TimeRange.AllTime);
|
const filteredRevlog = filterRevlog(revlog);
|
||||||
|
const maxDays = calculateMaxDays(filteredRevlog, TimeRange.AllTime);
|
||||||
|
let defaultTimeRange = TimeRange.Week;
|
||||||
|
if (maxDays > 365) {
|
||||||
|
defaultTimeRange = TimeRange.AllTime;
|
||||||
|
} else if (maxDays > 30) {
|
||||||
|
defaultTimeRange = TimeRange.Year;
|
||||||
|
} else if (maxDays > 7) {
|
||||||
|
defaultTimeRange = TimeRange.Month;
|
||||||
|
}
|
||||||
|
const timeRange = writable(defaultTimeRange);
|
||||||
|
|
||||||
$: renderForgettingCurve(revlog, $timeRange, svg as SVGElement, bounds);
|
$: renderForgettingCurve(filteredRevlog, $timeRange, svg as SVGElement, bounds);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="forgetting-curve">
|
<div class="forgetting-curve">
|
||||||
<InputBox>
|
<InputBox>
|
||||||
<div class="time-range-selector">
|
<div class="time-range-selector">
|
||||||
|
{#if maxDays > 0}
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" bind:group={$timeRange} value={TimeRange.Week} />
|
<input
|
||||||
|
type="radio"
|
||||||
|
bind:group={$timeRange}
|
||||||
|
value={TimeRange.Week}
|
||||||
|
/>
|
||||||
{tr.cardStatsFsrsForgettingCurveFirstWeek()}
|
{tr.cardStatsFsrsForgettingCurveFirstWeek()}
|
||||||
</label>
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if maxDays > 7}
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" bind:group={$timeRange} value={TimeRange.Month} />
|
<input
|
||||||
|
type="radio"
|
||||||
|
bind:group={$timeRange}
|
||||||
|
value={TimeRange.Month}
|
||||||
|
/>
|
||||||
{tr.cardStatsFsrsForgettingCurveFirstMonth()}
|
{tr.cardStatsFsrsForgettingCurveFirstMonth()}
|
||||||
</label>
|
</label>
|
||||||
{#if data.length > 0 && data.some((point) => point.daysSinceFirstLearn > 365)}
|
{/if}
|
||||||
|
{#if maxDays > 30}
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
@ -45,10 +71,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
{tr.cardStatsFsrsForgettingCurveFirstYear()}
|
{tr.cardStatsFsrsForgettingCurveFirstYear()}
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if maxDays > 365}
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" bind:group={$timeRange} value={TimeRange.AllTime} />
|
<input
|
||||||
|
type="radio"
|
||||||
|
bind:group={$timeRange}
|
||||||
|
value={TimeRange.AllTime}
|
||||||
|
/>
|
||||||
{tr.cardStatsFsrsForgettingCurveAllTime()}
|
{tr.cardStatsFsrsForgettingCurveAllTime()}
|
||||||
</label>
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</InputBox>
|
</InputBox>
|
||||||
<Graph {title}>
|
<Graph {title}>
|
||||||
|
|
|
@ -7,7 +7,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import { RevlogEntry_ReviewKind as ReviewKind } from "@generated/anki/stats_pb";
|
import { RevlogEntry_ReviewKind as ReviewKind } from "@generated/anki/stats_pb";
|
||||||
import * as tr2 from "@generated/ftl";
|
import * as tr2 from "@generated/ftl";
|
||||||
import { timeSpan, Timestamp } from "@tslib/time";
|
import { timeSpan, Timestamp } from "@tslib/time";
|
||||||
import { filterRevlogByReviewKind } from "./forgetting-curve";
|
import { filterRevlogEntryByReviewKind } from "./forgetting-curve";
|
||||||
|
|
||||||
export let revlog: RevlogEntry[];
|
export let revlog: RevlogEntry[];
|
||||||
export let fsrsEnabled: boolean = false;
|
export let fsrsEnabled: boolean = false;
|
||||||
|
@ -84,7 +84,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let prevValidEntry: RevlogEntry | undefined;
|
let prevValidEntry: RevlogEntry | undefined;
|
||||||
let i = index + 1;
|
let i = index + 1;
|
||||||
while (i < revlog.length) {
|
while (i < revlog.length) {
|
||||||
if (filterRevlogByReviewKind(revlog[i])) {
|
if (filterRevlogEntryByReviewKind(revlog[i])) {
|
||||||
prevValidEntry = revlog[i];
|
prevValidEntry = revlog[i];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -92,7 +92,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
|
|
||||||
let elapsedTime = "N/A";
|
let elapsedTime = "N/A";
|
||||||
if (filterRevlogByReviewKind(entry)) {
|
if (filterRevlogEntryByReviewKind(entry)) {
|
||||||
elapsedTime = prevValidEntry
|
elapsedTime = prevValidEntry
|
||||||
? timeSpan(Number(entry.time) - Number(prevValidEntry.time))
|
? timeSpan(Number(entry.time) - Number(prevValidEntry.time))
|
||||||
: "0";
|
: "0";
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { hideTooltip, showTooltip } from "../graphs/tooltip-utils.svelte";
|
||||||
|
|
||||||
const FACTOR = 19 / 81;
|
const FACTOR = 19 / 81;
|
||||||
const DECAY = -0.5;
|
const DECAY = -0.5;
|
||||||
const MIN_POINTS = 100;
|
const MIN_POINTS = 1000;
|
||||||
|
|
||||||
function forgettingCurve(stability: number, daysElapsed: number): number {
|
function forgettingCurve(stability: number, daysElapsed: number): number {
|
||||||
return Math.pow((daysElapsed / stability) * FACTOR + 1.0, DECAY);
|
return Math.pow((daysElapsed / stability) * FACTOR + 1.0, DECAY);
|
||||||
|
@ -34,31 +34,44 @@ export enum TimeRange {
|
||||||
AllTime,
|
AllTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterDataByTimeRange(data: DataPoint[], range: TimeRange): DataPoint[] {
|
const MAX_DAYS = {
|
||||||
const maxDays = {
|
|
||||||
[TimeRange.Week]: 7,
|
[TimeRange.Week]: 7,
|
||||||
[TimeRange.Month]: 30,
|
[TimeRange.Month]: 30,
|
||||||
[TimeRange.Year]: 365,
|
[TimeRange.Year]: 365,
|
||||||
[TimeRange.AllTime]: Infinity,
|
[TimeRange.AllTime]: Infinity,
|
||||||
}[range];
|
};
|
||||||
|
|
||||||
|
function filterDataByTimeRange(data: DataPoint[], maxDays: number): DataPoint[] {
|
||||||
return data.filter((point) => point.daysSinceFirstLearn <= maxDays);
|
return data.filter((point) => point.daysSinceFirstLearn <= maxDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterRevlogByReviewKind(entry: RevlogEntry): boolean {
|
export function filterRevlogEntryByReviewKind(entry: RevlogEntry): boolean {
|
||||||
return (
|
return (
|
||||||
entry.reviewKind !== RevlogEntry_ReviewKind.MANUAL
|
entry.reviewKind !== RevlogEntry_ReviewKind.MANUAL
|
||||||
&& (entry.reviewKind !== RevlogEntry_ReviewKind.FILTERED || entry.ease !== 0)
|
&& (entry.reviewKind !== RevlogEntry_ReviewKind.FILTERED || entry.ease !== 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
|
export function filterRevlog(revlog: RevlogEntry[]): RevlogEntry[] {
|
||||||
|
const result: RevlogEntry[] = [];
|
||||||
|
for (const entry of revlog) {
|
||||||
|
if (entry.reviewKind === RevlogEntry_ReviewKind.MANUAL && entry.ease === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.filter((entry) => filterRevlogEntryByReviewKind(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareData(revlog: RevlogEntry[], maxDays: number) {
|
||||||
const data: DataPoint[] = [];
|
const data: DataPoint[] = [];
|
||||||
let lastReviewTime = 0;
|
let lastReviewTime = 0;
|
||||||
let lastStability = 0;
|
let lastStability = 0;
|
||||||
|
const step = Math.min(maxDays / MIN_POINTS, 1);
|
||||||
|
let daysSinceFirstLearn = 0;
|
||||||
|
|
||||||
revlog
|
revlog
|
||||||
.filter((entry) => filterRevlogByReviewKind(entry))
|
|
||||||
.toReversed()
|
.toReversed()
|
||||||
.forEach((entry, index) => {
|
.forEach((entry, index) => {
|
||||||
const reviewTime = Number(entry.time);
|
const reviewTime = Number(entry.time);
|
||||||
|
@ -76,9 +89,9 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalDaysElapsed = (reviewTime - lastReviewTime) / (24 * 60 * 60);
|
const totalDaysElapsed = (reviewTime - lastReviewTime) / (24 * 60 * 60);
|
||||||
const step = Math.min(1, totalDaysElapsed / MIN_POINTS);
|
let elapsedDays = 0;
|
||||||
for (let i = 0; i < Math.max(MIN_POINTS, totalDaysElapsed); i++) {
|
while (elapsedDays < totalDaysElapsed - step) {
|
||||||
const elapsedDays = (i + 1) * step;
|
elapsedDays += step;
|
||||||
const retrievability = forgettingCurve(lastStability, elapsedDays);
|
const retrievability = forgettingCurve(lastStability, elapsedDays);
|
||||||
data.push({
|
data.push({
|
||||||
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
|
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
|
||||||
|
@ -88,10 +101,10 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
|
||||||
stability: lastStability,
|
stability: lastStability,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
daysSinceFirstLearn += totalDaysElapsed;
|
||||||
data.push({
|
data.push({
|
||||||
date: new Date((lastReviewTime + totalDaysElapsed * 86400) * 1000),
|
date: new Date((lastReviewTime + totalDaysElapsed * 86400) * 1000),
|
||||||
daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn,
|
daysSinceFirstLearn: daysSinceFirstLearn,
|
||||||
retrievability: 100,
|
retrievability: 100,
|
||||||
elapsedDaysSinceLastReview: 0,
|
elapsedDaysSinceLastReview: 0,
|
||||||
stability: lastStability,
|
stability: lastStability,
|
||||||
|
@ -106,10 +119,10 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
const totalDaysSinceLastReview = Math.floor((now - lastReviewTime) / (24 * 60 * 60));
|
const totalDaysSinceLastReview = (now - lastReviewTime) / (24 * 60 * 60);
|
||||||
const step = Math.min(1, totalDaysSinceLastReview / MIN_POINTS);
|
let elapsedDays = 0;
|
||||||
for (let i = 0; i < Math.max(MIN_POINTS, totalDaysSinceLastReview); i++) {
|
while (elapsedDays < totalDaysSinceLastReview - step) {
|
||||||
const elapsedDays = (i + 1) * step;
|
elapsedDays += step;
|
||||||
const retrievability = forgettingCurve(lastStability, elapsedDays);
|
const retrievability = forgettingCurve(lastStability, elapsedDays);
|
||||||
data.push({
|
data.push({
|
||||||
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
|
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
|
||||||
|
@ -119,19 +132,44 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
|
||||||
stability: lastStability,
|
stability: lastStability,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const filteredData = filterDataByTimeRange(data, timeRange);
|
daysSinceFirstLearn += totalDaysSinceLastReview;
|
||||||
|
const retrievability = forgettingCurve(lastStability, totalDaysSinceLastReview);
|
||||||
|
data.push({
|
||||||
|
date: new Date(now * 1000),
|
||||||
|
daysSinceFirstLearn: daysSinceFirstLearn,
|
||||||
|
elapsedDaysSinceLastReview: totalDaysSinceLastReview,
|
||||||
|
retrievability: retrievability * 100,
|
||||||
|
stability: lastStability,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredData = filterDataByTimeRange(data, maxDays);
|
||||||
return filteredData;
|
return filteredData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function calculateMaxDays(filteredRevlog: RevlogEntry[], timeRange: TimeRange): number {
|
||||||
|
if (filteredRevlog.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const daysSinceFirstLearn = (Date.now() / 1000 - Number(filteredRevlog[filteredRevlog.length - 1].time))
|
||||||
|
/ (24 * 60 * 60);
|
||||||
|
return Math.min(daysSinceFirstLearn, MAX_DAYS[timeRange]);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderForgettingCurve(
|
export function renderForgettingCurve(
|
||||||
revlog: RevlogEntry[],
|
filteredRevlog: RevlogEntry[],
|
||||||
timeRange: TimeRange,
|
timeRange: TimeRange,
|
||||||
svgElem: SVGElement,
|
svgElem: SVGElement,
|
||||||
bounds: GraphBounds,
|
bounds: GraphBounds,
|
||||||
) {
|
) {
|
||||||
const data = prepareData(revlog, timeRange);
|
|
||||||
const svg = select(svgElem);
|
const svg = select(svgElem);
|
||||||
const trans = svg.transition().duration(600) as any;
|
const trans = svg.transition().duration(600) as any;
|
||||||
|
if (filteredRevlog.length === 0) {
|
||||||
|
setDataAvailable(svg, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxDays = calculateMaxDays(filteredRevlog, timeRange);
|
||||||
|
|
||||||
|
const data = prepareData(filteredRevlog, maxDays);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
setDataAvailable(svg, false);
|
setDataAvailable(svg, false);
|
||||||
|
@ -186,7 +224,9 @@ export function renderForgettingCurve(
|
||||||
.style("opacity", 0);
|
.style("opacity", 0);
|
||||||
|
|
||||||
function tooltipText(d: DataPoint): string {
|
function tooltipText(d: DataPoint): string {
|
||||||
return `Date: ${d.date.toLocaleString()}<br>
|
return `${maxDays >= 365 ? "Date" : "Date Time"}: ${
|
||||||
|
maxDays >= 365 ? d.date.toLocaleDateString() : d.date.toLocaleString()
|
||||||
|
}<br>
|
||||||
${tr.cardStatsReviewLogElapsedTime()}: ${
|
${tr.cardStatsReviewLogElapsedTime()}: ${
|
||||||
timeSpan(d.elapsedDaysSinceLastReview * 86400)
|
timeSpan(d.elapsedDaysSinceLastReview * 86400)
|
||||||
}<br>${tr.cardStatsFsrsRetrievability()}: ${d.retrievability.toFixed(2)}%<br>${tr.cardStatsFsrsStability()}: ${
|
}<br>${tr.cardStatsFsrsRetrievability()}: ${d.retrievability.toFixed(2)}%<br>${tr.cardStatsFsrsStability()}: ${
|
||||||
|
|
|
@ -324,7 +324,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
if (resp) {
|
if (resp) {
|
||||||
const dailyTimeCost = movingAverage(
|
const dailyTimeCost = movingAverage(
|
||||||
resp.dailyTimeCost,
|
resp.dailyTimeCost,
|
||||||
Math.round(simulateFsrsRequest.daysToSimulate / 50),
|
Math.ceil(simulateFsrsRequest.daysToSimulate / 50),
|
||||||
);
|
);
|
||||||
points = points.concat(
|
points = points.concat(
|
||||||
dailyTimeCost.map((v, i) => ({
|
dailyTimeCost.map((v, i) => ({
|
||||||
|
|
|
@ -36,6 +36,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
import { localizedNumber } from "@tslib/i18n";
|
import { localizedDate } from "@tslib/i18n";
|
||||||
import {
|
import {
|
||||||
axisBottom,
|
axisBottom,
|
||||||
axisLeft,
|
axisLeft,
|
||||||
|
@ -14,9 +14,9 @@ import {
|
||||||
scaleTime,
|
scaleTime,
|
||||||
schemeCategory10,
|
schemeCategory10,
|
||||||
select,
|
select,
|
||||||
timeFormat,
|
|
||||||
} from "d3";
|
} from "d3";
|
||||||
|
|
||||||
|
import { timeSpan } from "@tslib/time";
|
||||||
import type { GraphBounds, TableDatum } from "./graph-helpers";
|
import type { GraphBounds, TableDatum } from "./graph-helpers";
|
||||||
import { setDataAvailable } from "./graph-helpers";
|
import { setDataAvailable } from "./graph-helpers";
|
||||||
import { hideTooltip, showTooltip } from "./tooltip-utils.svelte";
|
import { hideTooltip, showTooltip } from "./tooltip-utils.svelte";
|
||||||
|
@ -48,7 +48,6 @@ export function renderSimulationChart(
|
||||||
const convertedData = data.map(d => ({
|
const convertedData = data.map(d => ({
|
||||||
...d,
|
...d,
|
||||||
date: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000),
|
date: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000),
|
||||||
yMinutes: d.y / 60,
|
|
||||||
}));
|
}));
|
||||||
const xMin = today;
|
const xMin = today;
|
||||||
const xMax = max(convertedData, d => d.date);
|
const xMax = max(convertedData, d => d.date);
|
||||||
|
@ -56,29 +55,17 @@ export function renderSimulationChart(
|
||||||
const x = scaleTime()
|
const x = scaleTime()
|
||||||
.domain([xMin, xMax!])
|
.domain([xMin, xMax!])
|
||||||
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
|
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
|
||||||
const formatDate = timeFormat("%Y-%m-%d");
|
|
||||||
|
|
||||||
svg.select<SVGGElement>(".x-ticks")
|
svg.select<SVGGElement>(".x-ticks")
|
||||||
.call((selection) =>
|
.call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0)))
|
||||||
selection.transition(trans).call(
|
|
||||||
axisBottom(x)
|
|
||||||
.ticks(7)
|
|
||||||
.tickFormat((d: any) => formatDate(d))
|
|
||||||
.tickSizeOuter(0),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.attr("direction", "ltr");
|
.attr("direction", "ltr");
|
||||||
// y scale
|
// y scale
|
||||||
|
|
||||||
const yTickFormat = (n: number): string => {
|
const yTickFormat = (n: number): string => {
|
||||||
if (Math.round(n) != n) {
|
return timeSpan(n, true);
|
||||||
return "";
|
|
||||||
} else {
|
|
||||||
return localizedNumber(n);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const yMax = max(convertedData, d => d.yMinutes)!;
|
const yMax = max(convertedData, d => d.y)!;
|
||||||
const y = scaleLinear()
|
const y = scaleLinear()
|
||||||
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
|
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
|
||||||
.domain([0, yMax])
|
.domain([0, yMax])
|
||||||
|
@ -103,10 +90,10 @@ export function renderSimulationChart(
|
||||||
.attr("dy", "1em")
|
.attr("dy", "1em")
|
||||||
.attr("fill", "currentColor")
|
.attr("fill", "currentColor")
|
||||||
.style("text-anchor", "middle")
|
.style("text-anchor", "middle")
|
||||||
.text("Review Time per day (minutes)");
|
.text("Review Time per day");
|
||||||
|
|
||||||
// x lines
|
// x lines
|
||||||
const points = convertedData.map((d) => [x(d.date), y(d.yMinutes), d.label]);
|
const points = convertedData.map((d) => [x(d.date), y(d.y), d.label]);
|
||||||
const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]);
|
const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]);
|
||||||
|
|
||||||
const color = schemeCategory10;
|
const color = schemeCategory10;
|
||||||
|
@ -120,7 +107,6 @@ export function renderSimulationChart(
|
||||||
.selectAll("path")
|
.selectAll("path")
|
||||||
.data(Array.from(groups.entries()))
|
.data(Array.from(groups.entries()))
|
||||||
.join("path")
|
.join("path")
|
||||||
.style("mix-blend-mode", "multiply")
|
|
||||||
.attr("stroke", (d, i) => color[i % color.length])
|
.attr("stroke", (d, i) => color[i % color.length])
|
||||||
.attr("d", d => line()(d[1].map(p => [p[0], p[1]])))
|
.attr("d", d => line()(d[1].map(p => [p[0], p[1]])))
|
||||||
.attr("data-group", d => d[0]);
|
.attr("data-group", d => d[0]);
|
||||||
|
@ -148,7 +134,10 @@ export function renderSimulationChart(
|
||||||
.attr("height", bounds.height - bounds.marginTop - bounds.marginBottom)
|
.attr("height", bounds.height - bounds.marginTop - bounds.marginBottom)
|
||||||
.attr("fill", "transparent")
|
.attr("fill", "transparent")
|
||||||
.on("mousemove", mousemove)
|
.on("mousemove", mousemove)
|
||||||
.on("mouseout", hideTooltip);
|
.on("mouseout", () => {
|
||||||
|
focusLine.style("opacity", 0);
|
||||||
|
hideTooltip();
|
||||||
|
});
|
||||||
|
|
||||||
function mousemove(event: MouseEvent, d: any): void {
|
function mousemove(event: MouseEvent, d: any): void {
|
||||||
pointer(event, document.body);
|
pointer(event, document.body);
|
||||||
|
@ -168,9 +157,10 @@ export function renderSimulationChart(
|
||||||
|
|
||||||
focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1);
|
focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1);
|
||||||
|
|
||||||
let tooltipContent = `Date: ${timeFormat("%Y-%m-%d")(date)}<br>`;
|
const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed();
|
||||||
|
let tooltipContent = `Date: ${localizedDate(date)}<br>In ${days} Days<br>`;
|
||||||
for (const [key, value] of Object.entries(groupData)) {
|
for (const [key, value] of Object.entries(groupData)) {
|
||||||
tooltipContent += `Simulation ${key}: ${value.toFixed(2)} minutes<br>`;
|
tooltipContent += `#${key}: ${timeSpan(value)}<br>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
showTooltip(tooltipContent, event.pageX, event.pageY);
|
showTooltip(tooltipContent, event.pageX, event.pageY);
|
||||||
|
@ -189,16 +179,17 @@ export function renderSimulationChart(
|
||||||
.on("click", (event, d) => toggleGroup(event, d));
|
.on("click", (event, d) => toggleGroup(event, d));
|
||||||
|
|
||||||
legend.append("rect")
|
legend.append("rect")
|
||||||
.attr("x", bounds.width - bounds.marginRight + 10)
|
.attr("x", bounds.width - bounds.marginRight + 36)
|
||||||
.attr("width", 19)
|
.attr("width", 12)
|
||||||
.attr("height", 19)
|
.attr("height", 12)
|
||||||
.attr("fill", (d, i) => color[i % color.length]);
|
.attr("fill", (d, i) => color[i % color.length]);
|
||||||
|
|
||||||
legend.append("text")
|
legend.append("text")
|
||||||
.attr("x", bounds.width - bounds.marginRight + 34)
|
.attr("x", bounds.width - bounds.marginRight + 52)
|
||||||
.attr("y", 9.5)
|
.attr("y", 7)
|
||||||
.attr("dy", "0.32em")
|
.attr("dy", "0.3em")
|
||||||
.text(d => `Simulation ${d}`);
|
.attr("fill", "currentColor")
|
||||||
|
.text(d => `#${d}`);
|
||||||
|
|
||||||
const toggleGroup = (event: MouseEvent, d: number) => {
|
const toggleGroup = (event: MouseEvent, d: number) => {
|
||||||
const group = d;
|
const group = d;
|
||||||
|
|
|
@ -17,7 +17,7 @@ function calculateRetention(passed: number, failed: number): string {
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return "0%";
|
return "0%";
|
||||||
}
|
}
|
||||||
return localizedNumber((passed / total) * 100) + "%";
|
return localizedNumber((passed / total) * 100, 1) + "%";
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStatsRow(name: string, data: TrueRetentionData): string {
|
function createStatsRow(name: string, data: TrueRetentionData): string {
|
||||||
|
@ -50,8 +50,9 @@ export function renderTrueRetention(data: GraphsResponse, revlogRange: RevlogRan
|
||||||
td.trl { border: 1px solid; text-align: left; padding: 5px; }
|
td.trl { border: 1px solid; text-align: left; padding: 5px; }
|
||||||
td.trr { border: 1px solid; text-align: right; padding: 5px; }
|
td.trr { border: 1px solid; text-align: right; padding: 5px; }
|
||||||
td.trc { border: 1px solid; text-align: center; padding: 5px; }
|
td.trc { border: 1px solid; text-align: center; padding: 5px; }
|
||||||
|
table { width: 100%; margin: 0px 25px 20px 25px; }
|
||||||
</style>
|
</style>
|
||||||
<table style="border-collapse: collapse;" cellspacing="0" cellpadding="2">
|
<table cellspacing="0" cellpadding="2">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="trl" rowspan=3><b>${tr.statisticsTrueRetentionRange()}</b></td>
|
<td class="trl" rowspan=3><b>${tr.statisticsTrueRetentionRange()}</b></td>
|
||||||
<td class="trc" colspan=9><b>${tr.statisticsReviewsTitle()}</b></td>
|
<td class="trc" colspan=9><b>${tr.statisticsReviewsTitle()}</b></td>
|
||||||
|
|
Loading…
Reference in a new issue