mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
46bcf4efa6
commit
1af3c58d40
12 changed files with 445 additions and 147 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -131,6 +131,7 @@ dependencies = [
|
|||
"prost-reflect",
|
||||
"pulldown-cmark 0.13.0",
|
||||
"rand 0.9.1",
|
||||
"rayon",
|
||||
"regex",
|
||||
"reqwest 0.12.20",
|
||||
"rusqlite",
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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 scheduler’s 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.
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,6 +359,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
{#if !workload}
|
||||
<SpinBoxFloatRow
|
||||
bind:value={simulateFsrsRequest.desiredRetention}
|
||||
defaultValue={$config.desiredRetention}
|
||||
|
@ -285,10 +367,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
max={0.99}
|
||||
percentage={true}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
|
||||
<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,10 +523,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
{/if}
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={simulateFsrs}
|
||||
on:click={workload ? simulateWorkload : simulateFsrs}
|
||||
>
|
||||
{tr.deckConfigSimulate()}
|
||||
</button>
|
||||
|
@ -440,25 +544,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
<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();
|
||||
}
|
||||
}}
|
||||
on:click={saveConfigToPreset}
|
||||
>
|
||||
{tr.deckConfigSaveOptionsToPreset()}
|
||||
</button>
|
||||
|
@ -466,10 +552,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
{#if processing}
|
||||
{tr.actionsProcessing()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Graph>
|
||||
<div class="radio-group">
|
||||
<InputBox>
|
||||
{#if !workload}
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
|
@ -494,6 +582,40 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
/>
|
||||
{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>
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue