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:
Jarrett Ye 2024-09-30 22:22:30 +08:00 committed by GitHub
parent fdc69505e9
commit 59969f62f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 165 additions and 121 deletions

View file

@ -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
for (period_name, start, end) in &periods { .iter()
if review.id.as_secs() >= *start && review.id.as_secs() < *end { .filter(|review| {
let period_stat = period_stats.get_mut(period_name).unwrap(); // not manually rescheduled
const MATURE_IVL: i32 = 21; // mature interval is 21 days review.button_chosen > 0
// not cramming
match review.review_kind { && (review.review_kind != RevlogReviewKind::Filtered || review.ease_factor != 0)
RevlogReviewKind::Learning // cards with an interval ≥ 1 day
| RevlogReviewKind::Review && (review.review_kind == RevlogReviewKind::Review
| RevlogReviewKind::Relearning => { || review.last_interval <= -86400
if review.last_interval < MATURE_IVL || review.last_interval >= 1)
&& review.button_chosen == 1 })
&& (review.review_kind == RevlogReviewKind::Review .for_each(|review| {
|| review.last_interval <= -86400 for (period_name, start, end) in &periods {
|| review.last_interval >= 1) if review.id.as_secs() >= *start && review.id.as_secs() < *end {
{ let period_stat = period_stats.get_mut(period_name).unwrap();
period_stat.young_failed += 1; const MATURE_IVL: i32 = 21; // mature interval is 21 days
} else if review.last_interval < MATURE_IVL match (review.last_interval < MATURE_IVL, review.button_chosen) {
&& review.button_chosen > 1 (true, 1) => period_stat.young_failed += 1,
&& (review.review_kind == RevlogReviewKind::Review (true, _) => period_stat.young_passed += 1,
|| review.last_interval <= -86400 (false, 1) => period_stat.mature_failed += 1,
|| review.last_interval >= 1) (false, _) => period_stat.mature_passed += 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());

View file

@ -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">
<label> {#if maxDays > 0}
<input type="radio" bind:group={$timeRange} value={TimeRange.Week} /> <label>
{tr.cardStatsFsrsForgettingCurveFirstWeek()} <input
</label> type="radio"
<label> bind:group={$timeRange}
<input type="radio" bind:group={$timeRange} value={TimeRange.Month} /> value={TimeRange.Week}
{tr.cardStatsFsrsForgettingCurveFirstMonth()} />
</label> {tr.cardStatsFsrsForgettingCurveFirstWeek()}
{#if data.length > 0 && data.some((point) => point.daysSinceFirstLearn > 365)} </label>
{/if}
{#if maxDays > 7}
<label>
<input
type="radio"
bind:group={$timeRange}
value={TimeRange.Month}
/>
{tr.cardStatsFsrsForgettingCurveFirstMonth()}
</label>
{/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}
<label> {#if maxDays > 365}
<input type="radio" bind:group={$timeRange} value={TimeRange.AllTime} /> <label>
{tr.cardStatsFsrsForgettingCurveAllTime()} <input
</label> type="radio"
bind:group={$timeRange}
value={TimeRange.AllTime}
/>
{tr.cardStatsFsrsForgettingCurveAllTime()}
</label>
{/if}
</div> </div>
</InputBox> </InputBox>
<Graph {title}> <Graph {title}>

View file

@ -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";

View file

@ -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()}: ${

View file

@ -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) => ({

View file

@ -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>

View file

@ -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;

View file

@ -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>