Feat/Desired retention info graphs (#4199)

* backend part

* split memorised and cost

* slapdash frontend

* extract some simulator logic

* Add zoomed version of graph

* ./check

* Fix: Tooltip

* Fix: Simulator/workload transition

* remove "time"

* Update ts/routes/graphs/simulator.ts

Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com>

* Added: Mode toggle

* Disable Dr in workload mode

* keep button order consistant between modes

* dont clear points on mode swap

* add review count graph

* Revert "dont clear points on mode swap"

This reverts commit fc89efb1d9.

* "Help me pick" button

* unrelated title case change

* Add translation strings

* fix: missing translation string

* Fix: Layout shift

* Add: Experimental

* Fix Time / Memorized

* per day values

* set review limit to 9999 on open

* keep default at currently set value

* Do DR calculation in parallel (dae)

Approx 5x faster on my machine

---------

Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com>
Co-authored-by: Damien Elmes <gpg@ankiweb.net>
This commit is contained in:
Luc Mcgrady 2025-07-28 09:55:08 +01:00 committed by GitHub
parent 46bcf4efa6
commit 1af3c58d40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 445 additions and 147 deletions

1
Cargo.lock generated
View file

@ -131,6 +131,7 @@ dependencies = [
"prost-reflect",
"pulldown-cmark 0.13.0",
"rand 0.9.1",
"rayon",
"regex",
"reqwest 0.12.20",
"rusqlite",

View file

@ -110,6 +110,7 @@ prost-types = "0.13"
pulldown-cmark = "0.13.0"
pyo3 = { version = "0.25.1", features = ["extension-module", "abi3", "abi3-py39"] }
rand = "0.9.1"
rayon = "1.10.0"
regex = "1.11.1"
reqwest = { version = "0.12.20", default-features = false, features = ["json", "socks", "stream", "multipart"] }
rusqlite = { version = "0.36.0", features = ["trace", "functions", "collation", "bundled"] }

View file

@ -505,7 +505,9 @@ deck-config-desired-retention-below-optimal = Your desired retention is below op
# Description of the y axis in the FSRS simulation
# diagram (Deck options -> FSRS) showing the total number of
# cards that can be recalled or retrieved on a specific date.
deck-config-fsrs-simulator-experimental = FSRS simulator (experimental)
deck-config-fsrs-simulator-experimental = FSRS Simulator (Experimental)
deck-config-fsrs-simulate-desired-retention-experimental = FSRS Desired Retention Simulator (Experimental)
deck-config-fsrs-desired-retention-help-me-decide-experimental = Help Me Decide (Experimental)
deck-config-additional-new-cards-to-simulate = Additional new cards to simulate
deck-config-simulate = Simulate
deck-config-clear-last-simulate = Clear Last Simulation
@ -515,10 +517,14 @@ deck-config-smooth-graph = Smooth graph
deck-config-suspend-leeches = Suspend leeches
deck-config-save-options-to-preset = Save Changes to Preset
deck-config-save-options-to-preset-confirm = Overwrite the options in your current preset with the options that are currently set in the simulator?
deck-config-plotted-on-x-axis = (Plotted on the X-axis)
# Radio button in the FSRS simulation diagram (Deck options -> FSRS) selecting
# to show the total number of cards that can be recalled or retrieved on a
# specific date.
deck-config-fsrs-simulator-radio-memorized = Memorized
deck-config-fsrs-simulator-radio-ratio = Time / Memorized Ratio
# $time here is pre-formatted e.g. "10 Seconds"
deck-config-fsrs-simulator-ratio-tooltip = { $time } per memorized card
## Messages related to the FSRS schedulers health check. The health check determines whether the correlation between FSRS predictions and your memory is good or bad. It can be optionally triggered as part of the "Optimize" function.

View file

@ -55,6 +55,8 @@ service SchedulerService {
returns (ComputeOptimalRetentionResponse);
rpc SimulateFsrsReview(SimulateFsrsReviewRequest)
returns (SimulateFsrsReviewResponse);
rpc SimulateFsrsWorkload(SimulateFsrsReviewRequest)
returns (SimulateFsrsWorkloadResponse);
rpc EvaluateParams(EvaluateParamsRequest) returns (EvaluateParamsResponse);
rpc EvaluateParamsLegacy(EvaluateParamsLegacyRequest)
returns (EvaluateParamsResponse);
@ -414,6 +416,12 @@ message SimulateFsrsReviewResponse {
repeated float daily_time_cost = 4;
}
message SimulateFsrsWorkloadResponse {
map<uint32, float> cost = 1;
map<uint32, float> memorized = 2;
map<uint32, uint32> review_count = 3;
}
message ComputeOptimalRetentionResponse {
float optimal_retention = 1;
}

View file

@ -654,6 +654,7 @@ exposed_backend_list = [
"evaluate_params_legacy",
"get_optimal_retention_parameters",
"simulate_fsrs_review",
"simulate_fsrs_workload",
# DeckConfigService
"get_ignored_before_count",
"get_retention_workload",

View file

@ -81,6 +81,7 @@ pin-project.workspace = true
prost.workspace = true
pulldown-cmark.workspace = true
rand.workspace = true
rayon.workspace = true
regex.workspace = true
reqwest.workspace = true
rusqlite.workspace = true

View file

@ -1,11 +1,13 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::collections::HashMap;
use std::sync::Arc;
use anki_proto::deck_config::deck_config::config::ReviewCardOrder;
use anki_proto::deck_config::deck_config::config::ReviewCardOrder::*;
use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse;
use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
use fsrs::simulate;
use fsrs::PostSchedulingFn;
use fsrs::ReviewPriorityFn;
@ -14,6 +16,8 @@ use fsrs::FSRS;
use itertools::Itertools;
use rand::rngs::StdRng;
use rand::Rng;
use rayon::iter::IntoParallelIterator;
use rayon::iter::ParallelIterator;
use crate::card::CardQueue;
use crate::card::CardType;
@ -267,6 +271,38 @@ impl Collection {
daily_time_cost: result.cost_per_day,
})
}
pub fn simulate_workload(
&mut self,
req: SimulateFsrsReviewRequest,
) -> Result<SimulateFsrsWorkloadResponse> {
let (config, cards) = self.simulate_request_to_config(&req)?;
let dr_workload = (70u32..=99u32)
.into_par_iter()
.map(|dr| {
let result = simulate(
&config,
&req.params,
dr as f32 / 100.,
None,
Some(cards.clone()),
)?;
Ok((
dr,
(
*result.memorized_cnt_per_day.last().unwrap_or(&0.),
result.cost_per_day.iter().sum::<f32>(),
result.review_cnt_per_day.iter().sum::<usize>() as u32,
),
))
})
.collect::<Result<HashMap<_, _>>>()?;
Ok(SimulateFsrsWorkloadResponse {
memorized: dr_workload.iter().map(|(k, v)| (*k, v.0)).collect(),
cost: dr_workload.iter().map(|(k, v)| (*k, v.1)).collect(),
review_count: dr_workload.iter().map(|(k, v)| (*k, v.2)).collect(),
})
}
}
impl Card {

View file

@ -16,6 +16,7 @@ use anki_proto::scheduler::FuzzDeltaResponse;
use anki_proto::scheduler::GetOptimalRetentionParametersResponse;
use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse;
use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
use fsrs::ComputeParametersInput;
use fsrs::FSRSItem;
use fsrs::FSRSReview;
@ -283,6 +284,13 @@ impl crate::services::SchedulerService for Collection {
self.simulate_review(input)
}
fn simulate_fsrs_workload(
&mut self,
input: SimulateFsrsReviewRequest,
) -> Result<SimulateFsrsWorkloadResponse> {
self.simulate_workload(input)
}
fn compute_optimal_retention(
&mut self,
input: SimulateFsrsReviewRequest,

View file

@ -93,6 +93,10 @@ impl TimestampMillis {
pub fn adding_secs(self, secs: i64) -> Self {
Self(self.0 + secs * 1000)
}
pub fn elapsed_millis(self) -> u64 {
(Self::now().0 - self.0).max(0) as u64
}
}
fn elapsed() -> time::Duration {

View file

@ -325,6 +325,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
let simulatorModal: Modal;
let workloadModal: Modal;
</script>
<DynamicallySlottable slotHost={Item} api={{}}>
@ -349,6 +350,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Item>
</DynamicallySlottable>
<button
class="btn btn-primary"
on:click={() => {
simulateFsrsRequest.reviewLimit = 9999;
workloadModal?.show();
}}
>
{tr.deckConfigFsrsDesiredRetentionHelpMeDecideExperimental()}
</button>
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
@ -444,6 +455,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{onPresetChange}
/>
<SimulatorModal
bind:modal={workloadModal}
workload
{state}
{simulateFsrsRequest}
{computing}
{openHelpModal}
{onPresetChange}
/>
<style>
.btn {
margin-bottom: 0.375rem;

View file

@ -13,15 +13,25 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import TableData from "../graphs/TableData.svelte";
import InputBox from "../graphs/InputBox.svelte";
import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers";
import { SimulateSubgraph, type Point } from "../graphs/simulator";
import {
SimulateSubgraph,
SimulateWorkloadSubgraph,
type Point,
type WorkloadPoint,
} from "../graphs/simulator";
import * as tr from "@generated/ftl";
import { renderSimulationChart } from "../graphs/simulator";
import { computeOptimalRetention, simulateFsrsReview } from "@generated/backend";
import { renderSimulationChart, renderWorkloadChart } from "../graphs/simulator";
import {
computeOptimalRetention,
simulateFsrsReview,
simulateFsrsWorkload,
} from "@generated/backend";
import { runWithBackendProgress } from "@tslib/progress";
import type {
ComputeOptimalRetentionResponse,
SimulateFsrsReviewRequest,
SimulateFsrsReviewResponse,
SimulateFsrsWorkloadResponse,
} from "@generated/anki/scheduler_pb";
import type { DeckOptionsState } from "./lib";
import SwitchRow from "$lib/components/SwitchRow.svelte";
@ -34,15 +44,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Warning from "./Warning.svelte";
import type { ComputeRetentionProgress } from "@generated/anki/collection_pb";
import Modal from "bootstrap/js/dist/modal";
import Row from "$lib/components/Row.svelte";
import Col from "$lib/components/Col.svelte";
export let state: DeckOptionsState;
export let simulateFsrsRequest: SimulateFsrsReviewRequest;
export let computing: boolean;
export let openHelpModal: (key: string) => void;
export let onPresetChange: () => void;
/** Do not modify this once set */
export let workload: boolean = false;
const config = state.currentConfig;
let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count;
let simulateWorkloadSubgraph: SimulateWorkloadSubgraph =
SimulateWorkloadSubgraph.ratio;
let tableData: TableDatum[] = [];
let simulating: boolean = false;
const fsrs = state.fsrs;
@ -50,7 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let svg: HTMLElement | SVGElement | null = null;
let simulationNumber = 0;
let points: Point[] = [];
let points: (WorkloadPoint | Point)[] = [];
const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
let smooth = true;
let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND;
@ -177,6 +193,43 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
async function simulateWorkload(): Promise<void> {
let resp: SimulateFsrsWorkloadResponse | undefined;
updateRequest();
try {
await runWithBackendProgress(
async () => {
simulating = true;
resp = await simulateFsrsWorkload(simulateFsrsRequest);
},
() => {},
);
} finally {
simulating = false;
if (resp) {
simulationNumber += 1;
points = points.concat(
Object.entries(resp.memorized).map(([dr, v]) => ({
x: parseInt(dr),
timeCost: resp!.cost[dr],
memorized: v,
count: resp!.reviewCount[dr],
label: simulationNumber,
learnSpan: simulateFsrsRequest.daysToSimulate,
})),
);
tableData = renderWorkloadChart(
svg as SVGElement,
bounds,
points as WorkloadPoint[],
simulateWorkloadSubgraph,
);
}
}
}
function clearSimulation() {
points = points.filter((p) => p.label !== simulationNumber);
simulationNumber = Math.max(0, simulationNumber - 1);
@ -188,6 +241,25 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
);
}
function saveConfigToPreset() {
if (confirm(tr.deckConfigSaveOptionsToPresetConfirm())) {
$config.newPerDay = simulateFsrsRequest.newLimit;
$config.reviewsPerDay = simulateFsrsRequest.reviewLimit;
$config.maximumReviewInterval = simulateFsrsRequest.maxInterval;
if (!workload) {
$config.desiredRetention = simulateFsrsRequest.desiredRetention;
}
$newCardsIgnoreReviewLimit = simulateFsrsRequest.newCardsIgnoreReviewLimit;
$config.reviewOrder = simulateFsrsRequest.reviewOrder;
$config.leechAction = suspendLeeches
? DeckConfig_Config_LeechAction.SUSPEND
: DeckConfig_Config_LeechAction.TAG_ONLY;
$config.leechThreshold = leechThreshold;
$config.easyDaysPercentages = [...easyDayPercentages];
onPresetChange();
}
}
$: if (svg) {
let pointsToRender = points;
if (smooth) {
@ -225,11 +297,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
});
}
tableData = renderSimulationChart(
const render_function = workload ? renderWorkloadChart : renderSimulationChart;
tableData = render_function(
svg as SVGElement,
bounds,
pointsToRender,
simulateSubgraph,
// This cast shouldn't matter because we aren't switching between modes in the same modal
pointsToRender as WorkloadPoint[],
(workload ? simulateWorkloadSubgraph : simulateSubgraph) as any as never,
);
}
@ -252,7 +327,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{tr.deckConfigFsrsSimulatorExperimental()}</h5>
<h5 class="modal-title">
{#if workload}
{tr.deckConfigFsrsSimulateDesiredRetentionExperimental()}
{:else}
{tr.deckConfigFsrsSimulatorExperimental()}
{/if}
</h5>
<button
type="button"
class="btn-close"
@ -278,17 +359,38 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</SettingTitle>
</SpinBoxRow>
<SpinBoxFloatRow
bind:value={simulateFsrsRequest.desiredRetention}
defaultValue={$config.desiredRetention}
min={0.7}
max={0.99}
percentage={true}
>
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
{#if !workload}
<SpinBoxFloatRow
bind:value={simulateFsrsRequest.desiredRetention}
defaultValue={$config.desiredRetention}
min={0.7}
max={0.99}
percentage={true}
>
<SettingTitle
on:click={() => openHelpModal("desiredRetention")}
>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
{:else}
<Row --cols={13}>
<Col --col-size={7} breakpoint="xs">
<SettingTitle
on:click={() => openHelpModal("desiredRetention")}
>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</Col>
<Col --col-size={6} breakpoint="xs">
<input
type="text"
disabled
value={tr.deckConfigPlottedOnXAxis()}
/>
</Col>
</Row>
{/if}
<SpinBoxRow
bind:value={simulateFsrsRequest.newLimit}
@ -421,79 +523,99 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{/if}
</details>
</div>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={simulateFsrs}
>
{tr.deckConfigSimulate()}
</button>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={clearSimulation}
>
{tr.deckConfigClearLastSimulate()}
</button>
<div>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={workload ? simulateWorkload : simulateFsrs}
>
{tr.deckConfigSimulate()}
</button>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={() => {
if (confirm(tr.deckConfigSaveOptionsToPresetConfirm())) {
$config.newPerDay = simulateFsrsRequest.newLimit;
$config.reviewsPerDay = simulateFsrsRequest.reviewLimit;
$config.maximumReviewInterval =
simulateFsrsRequest.maxInterval;
$config.desiredRetention =
simulateFsrsRequest.desiredRetention;
$newCardsIgnoreReviewLimit =
simulateFsrsRequest.newCardsIgnoreReviewLimit;
$config.reviewOrder = simulateFsrsRequest.reviewOrder;
$config.leechAction = suspendLeeches
? DeckConfig_Config_LeechAction.SUSPEND
: DeckConfig_Config_LeechAction.TAG_ONLY;
$config.leechThreshold = leechThreshold;
$config.easyDaysPercentages = [...easyDayPercentages];
onPresetChange();
}
}}
>
{tr.deckConfigSaveOptionsToPreset()}
</button>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={clearSimulation}
>
{tr.deckConfigClearLastSimulate()}
</button>
{#if processing}
{tr.actionsProcessing()}
{/if}
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={saveConfigToPreset}
>
{tr.deckConfigSaveOptionsToPreset()}
</button>
{#if processing}
{tr.actionsProcessing()}
{/if}
</div>
<Graph>
<div class="radio-group">
<InputBox>
<label>
<input
type="radio"
value={SimulateSubgraph.count}
bind:group={simulateSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioCount()}
</label>
<label>
<input
type="radio"
value={SimulateSubgraph.time}
bind:group={simulateSubgraph}
/>
{tr.statisticsReviewsTimeCheckbox()}
</label>
<label>
<input
type="radio"
value={SimulateSubgraph.memorized}
bind:group={simulateSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioMemorized()}
</label>
{#if !workload}
<label>
<input
type="radio"
value={SimulateSubgraph.count}
bind:group={simulateSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioCount()}
</label>
<label>
<input
type="radio"
value={SimulateSubgraph.time}
bind:group={simulateSubgraph}
/>
{tr.statisticsReviewsTimeCheckbox()}
</label>
<label>
<input
type="radio"
value={SimulateSubgraph.memorized}
bind:group={simulateSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioMemorized()}
</label>
{:else}
<label>
<input
type="radio"
value={SimulateWorkloadSubgraph.ratio}
bind:group={simulateWorkloadSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioRatio()}
</label>
<label>
<input
type="radio"
value={SimulateWorkloadSubgraph.count}
bind:group={simulateWorkloadSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioCount()}
</label>
<label>
<input
type="radio"
value={SimulateWorkloadSubgraph.time}
bind:group={simulateWorkloadSubgraph}
/>
{tr.statisticsReviewsTimeCheckbox()}
</label>
<label>
<input
type="radio"
value={SimulateWorkloadSubgraph.memorized}
bind:group={simulateWorkloadSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioMemorized()}
</label>
{/if}
</InputBox>
</div>

View file

@ -31,50 +31,86 @@ export interface Point {
label: number;
}
export type WorkloadPoint = Point & {
learnSpan: number;
};
export enum SimulateSubgraph {
time,
count,
memorized,
}
export enum SimulateWorkloadSubgraph {
ratio,
time,
count,
memorized,
}
export function renderWorkloadChart(
svgElem: SVGElement,
bounds: GraphBounds,
data: WorkloadPoint[],
subgraph: SimulateWorkloadSubgraph,
) {
const xMin = 70;
const xMax = 99;
const x = scaleLinear()
.domain([xMin, xMax])
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
const subgraph_data = ({
[SimulateWorkloadSubgraph.ratio]: data.map(d => ({ ...d, y: d.timeCost / d.memorized })),
[SimulateWorkloadSubgraph.time]: data.map(d => ({ ...d, y: d.timeCost / d.learnSpan })),
[SimulateWorkloadSubgraph.count]: data.map(d => ({ ...d, y: d.count / d.learnSpan })),
[SimulateWorkloadSubgraph.memorized]: data.map(d => ({ ...d, y: d.memorized })),
})[subgraph];
const yTickFormat = (n: number): string => {
return subgraph == SimulateWorkloadSubgraph.time || subgraph == SimulateWorkloadSubgraph.ratio
? timeSpan(n, true)
: n.toString();
};
const formatY: (value: number) => string = ({
[SimulateWorkloadSubgraph.ratio]: (value: number) =>
tr.deckConfigFsrsSimulatorRatioTooltip({ time: timeSpan(value) }),
[SimulateWorkloadSubgraph.time]: (value: number) =>
tr.statisticsMinutesPerDay({ count: parseFloat((value / 60).toPrecision(2)) }),
[SimulateWorkloadSubgraph.count]: (value: number) => tr.statisticsReviewsPerDay({ count: Math.round(value) }),
[SimulateWorkloadSubgraph.memorized]: (value: number) =>
tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),
})[subgraph];
function formatX(dr: number) {
return `Desired Retention: ${dr}%<br>`;
}
return _renderSimulationChart(
svgElem,
bounds,
subgraph_data,
x,
yTickFormat,
formatY,
formatX,
(_e: MouseEvent, _d: number) => undefined,
);
}
export function renderSimulationChart(
svgElem: SVGElement,
bounds: GraphBounds,
data: Point[],
subgraph: SimulateSubgraph,
): TableDatum[] {
const svg = select(svgElem);
svg.selectAll(".lines").remove();
svg.selectAll(".hover-columns").remove();
svg.selectAll(".focus-line").remove();
svg.selectAll(".legend").remove();
if (data.length == 0) {
setDataAvailable(svg, false);
return [];
}
const trans = svg.transition().duration(600) as any;
// Prepare data
const today = new Date();
const convertedData = data.map(d => ({
...d,
date: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000),
x: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000),
}));
const xMin = today;
const xMax = max(convertedData, d => d.date);
const x = scaleTime()
.domain([xMin, xMax!])
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
svg.select<SVGGElement>(".x-ticks")
.call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0)))
.attr("direction", "ltr");
// y scale
const yTickFormat = (n: number): string => {
return subgraph == SimulateSubgraph.time ? timeSpan(n, true) : n.toString();
};
const subgraph_data = ({
[SimulateSubgraph.count]: convertedData.map(d => ({ ...d, y: d.count })),
@ -82,6 +118,90 @@ export function renderSimulationChart(
[SimulateSubgraph.memorized]: convertedData.map(d => ({ ...d, y: d.memorized })),
})[subgraph];
const xMin = today;
const xMax = max(subgraph_data, d => d.x);
const x = scaleTime()
.domain([xMin, xMax!])
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
const yTickFormat = (n: number): string => {
return subgraph == SimulateSubgraph.time ? timeSpan(n, true) : n.toString();
};
const formatY: (value: number) => string = ({
[SimulateSubgraph.time]: timeSpan,
[SimulateSubgraph.count]: (value: number) => tr.statisticsReviews({ reviews: Math.round(value) }),
[SimulateSubgraph.memorized]: (value: number) =>
tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),
})[subgraph];
const perDay = ({
[SimulateSubgraph.count]: tr.statisticsReviewsPerDay,
[SimulateSubgraph.time]: ({ count }: { count: number }) => timeSpan(count),
[SimulateSubgraph.memorized]: tr.statisticsCardsPerDay,
})[subgraph];
function legendMouseMove(e: MouseEvent, d: number) {
const data = subgraph_data.filter(datum => datum.label == d);
const total = subgraph == SimulateSubgraph.memorized
? data[data.length - 1].memorized - data[0].memorized
: sumBy(data, d => d.y);
const average = total / (data?.length || 1);
showTooltip(
`#${d}:<br/>
${tr.statisticsAverage()}: ${perDay({ count: average })}<br/>
${tr.statisticsTotal()}: ${formatY(total)}`,
e.pageX,
e.pageY,
);
}
function formatX(date: Date) {
const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed();
return `Date: ${localizedDate(date)}<br>In ${days} Days<br>`;
}
return _renderSimulationChart(
svgElem,
bounds,
subgraph_data,
x,
yTickFormat,
formatY,
formatX,
legendMouseMove,
);
}
function _renderSimulationChart<T extends { x: any; y: any; label: number }>(
svgElem: SVGElement,
bounds: GraphBounds,
subgraph_data: T[],
x: any,
yTickFormat: (n: number) => string,
formatY: (n: T["y"]) => string,
formatX: (n: T["x"]) => string,
legendMouseMove: (e: MouseEvent, d: number) => void,
): TableDatum[] {
const svg = select(svgElem);
svg.selectAll(".lines").remove();
svg.selectAll(".hover-columns").remove();
svg.selectAll(".focus-line").remove();
svg.selectAll(".legend").remove();
if (subgraph_data.length == 0) {
setDataAvailable(svg, false);
return [];
}
const trans = svg.transition().duration(600) as any;
svg.select<SVGGElement>(".x-ticks")
.call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0)))
.attr("direction", "ltr");
// y scale
const yMax = max(subgraph_data, d => d.y)!;
const y = scaleLinear()
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
@ -110,7 +230,7 @@ export function renderSimulationChart(
.attr("fill", "currentColor");
// x lines
const points = subgraph_data.map((d) => [x(d.date), y(d.y), d.label]);
const points = subgraph_data.map((d) => [x(d.x), y(d.y), d.label]);
const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]);
const color = schemeCategory10;
@ -157,13 +277,6 @@ export function renderSimulationChart(
hideTooltip();
});
const formatY: (value: number) => string = ({
[SimulateSubgraph.time]: timeSpan,
[SimulateSubgraph.count]: (value: number) => tr.statisticsReviews({ reviews: Math.round(value) }),
[SimulateSubgraph.memorized]: (value: number) =>
tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),
})[subgraph];
function mousemove(event: MouseEvent, d: any): void {
pointer(event, document.body);
const date = x.invert(d[0]);
@ -182,8 +295,7 @@ export function renderSimulationChart(
focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1);
const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed();
let tooltipContent = `Date: ${localizedDate(date)}<br>In ${days} Days<br>`;
let tooltipContent = formatX(date);
for (const [key, value] of Object.entries(groupData)) {
const path = svg.select(`path[data-group="${key}"]`);
const hidden = path.classed("hidden");
@ -212,29 +324,6 @@ export function renderSimulationChart(
.on("mousemove", legendMouseMove)
.on("mouseout", hideTooltip);
const perDay = ({
[SimulateSubgraph.count]: tr.statisticsReviewsPerDay,
[SimulateSubgraph.time]: ({ count }: { count: number }) => timeSpan(count),
[SimulateSubgraph.memorized]: tr.statisticsCardsPerDay,
})[subgraph];
function legendMouseMove(e: MouseEvent, d: number) {
const data = subgraph_data.filter(datum => datum.label == d);
const total = subgraph == SimulateSubgraph.memorized
? data[data.length - 1].memorized - data[0].memorized
: sumBy(data, d => d.y);
const average = total / (data?.length || 1);
showTooltip(
`#${d}:<br/>
${tr.statisticsAverage()}: ${perDay({ count: average })}<br/>
${tr.statisticsTotal()}: ${formatY(total)}`,
e.pageX,
e.pageY,
);
}
legend.append("rect")
.attr("x", bounds.width - bounds.marginRight + 36)
.attr("width", 12)