From 7da11b5880de0a079e2975912878da329721b217 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Tue, 25 Jun 2024 16:41:43 +0800 Subject: [PATCH 01/15] test using existed cards --- qt/aqt/mediasrv.py | 1 + rslib/src/scheduler/fsrs/simulator.rs | 17 ++-- ts/routes/deck-options/FsrsOptions.svelte | 102 +++++++++++++++++++++- 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index b3ab7f04205..2c9c050bc6e 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -625,6 +625,7 @@ def handle_on_main() -> None: "set_wants_abort", "evaluate_weights", "get_optimal_retention_parameters", + "simulate_fsrs_review", ] diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index fd039f7b94a..3a52215fa27 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -7,6 +7,7 @@ use fsrs::simulate; use fsrs::SimulatorConfig; use itertools::Itertools; +use crate::card::CardQueue; use crate::prelude::*; use crate::search::SortMode; @@ -22,9 +23,15 @@ impl Collection { .get_revlog_entries_for_searched_cards_in_card_order()?; let cards = guard.col.storage.all_searched_cards()?; drop(guard); + let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; + let converted_cards = cards + .into_iter() + .filter(|c| c.queue == CardQueue::Review) + .filter_map(|c| Card::convert(c, days_elapsed)) + .collect_vec(); let p = self.get_optimal_retention_parameters(revlogs)?; let config = SimulatorConfig { - deck_size: req.deck_size as usize, + deck_size: req.deck_size as usize + converted_cards.len(), learn_span: req.days_to_simulate as usize, max_cost_perday: f64::MAX, max_ivl: req.max_interval as f64, @@ -46,7 +53,6 @@ impl Collection { learn_limit: req.new_limit as usize, review_limit: req.review_limit as usize, }; - let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; let ( accumulated_knowledge_acquisition, daily_review_count, @@ -57,12 +63,7 @@ impl Collection { &req.weights.iter().map(|w| *w as f64).collect_vec(), req.desired_retention as f64, None, - Some( - cards - .into_iter() - .filter_map(|c| Card::convert(c, days_elapsed)) - .collect_vec(), - ), + Some(converted_cards), ); Ok(SimulateFsrsReviewResponse { accumulated_knowledge_acquisition: accumulated_knowledge_acquisition diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index e836e1dc8b4..9c4675dfc10 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -7,10 +7,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ComputeRetentionProgress, type ComputeWeightsProgress, } from "@generated/anki/collection_pb"; - import { ComputeOptimalRetentionRequest } from "@generated/anki/scheduler_pb"; + import { ComputeOptimalRetentionRequest, SimulateFsrsReviewRequest } from "@generated/anki/scheduler_pb"; import { computeFsrsWeights, computeOptimalRetention, + simulateFsrsReview, evaluateWeights, setWantsAbort, } from "@generated/backend"; @@ -68,6 +69,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html optimalRetentionRequest.daysToSimulate = 3650; } + const simulateFsrsRequest = new SimulateFsrsReviewRequest({ + weights: $config.fsrsWeights, + desiredRetention: $config.desiredRetention, + deckSize: 1000, + daysToSimulate: 365, + newLimit: $config.newPerDay, + reviewLimit: $config.reviewsPerDay, + maxInterval: $config.maximumReviewInterval, + search: `preset:"${state.getCurrentName()}" -is:suspended`, + }); + function getRetentionWarning(retention: number): string { const decay = -0.5; const factor = 0.9 ** (1 / decay) - 1; @@ -256,6 +268,25 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } return tr.deckConfigPredictedOptimalRetention({ num: retention.toFixed(2) }); } + + async function simulateFsrs(): Promise { + let result; + try { + await runWithBackendProgress( + async () => { + simulateFsrsRequest.weights = $config.fsrsWeights; + simulateFsrsRequest.desiredRetention = $config.desiredRetention; + simulateFsrsRequest.search = `preset:"${state.getCurrentName()}" -is:suspended`; + console.log(simulateFsrsRequest); + const resp = await simulateFsrsReview(simulateFsrsRequest); + result = resp; + }, + () => {}, + ); + } finally { + console.log(result); + } + } +
+
+ FSRS simulator (experimental) + + + openHelpModal("simulateFsrsReview")}> + Days to simulate + + + + + openHelpModal("simulateFsrsReview")}> + Deck size + + + + + openHelpModal("simulateFsrsReview")}> + New cards per day + + + + + openHelpModal("simulateFsrsReview")}> + Reviews per day + + + + + openHelpModal("simulateFsrsReview")}> + Maximum review interval + + + + +
+
+ From 8d2b199c5e304d66b5f18c9b7f0004901bfba36e Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Fri, 28 Jun 2024 14:51:03 +0800 Subject: [PATCH 02/15] plot new and review --- ts/routes/deck-options/FsrsOptions.svelte | 45 ++++- ts/routes/graphs/simulator.ts | 200 ++++++++++++++++++++++ 2 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 ts/routes/graphs/simulator.ts diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 9c4675dfc10..4fea496a531 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -7,7 +7,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ComputeRetentionProgress, type ComputeWeightsProgress, } from "@generated/anki/collection_pb"; - import { ComputeOptimalRetentionRequest, SimulateFsrsReviewRequest } from "@generated/anki/scheduler_pb"; + import { ComputeOptimalRetentionRequest, SimulateFsrsReviewRequest, SimulateFsrsReviewResponse } from "@generated/anki/scheduler_pb"; import { computeFsrsWeights, computeOptimalRetention, @@ -29,6 +29,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Warning from "./Warning.svelte"; import WeightsInputRow from "./WeightsInputRow.svelte"; import WeightsSearchRow from "./WeightsSearchRow.svelte"; + import { renderSimulationChart } from "../graphs/simulator"; + import Graph from "../graphs/Graph.svelte"; + import HoverColumns from "../graphs/HoverColumns.svelte"; + import CumulativeOverlay from "../graphs/CumulativeOverlay.svelte"; + import AxisTicks from "../graphs/AxisTicks.svelte"; + import NoDataOverlay from "../graphs/NoDataOverlay.svelte"; + import TableData from "../graphs/TableData.svelte"; + import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; @@ -269,8 +277,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html return tr.deckConfigPredictedOptimalRetention({ num: retention.toFixed(2) }); } + let tableData: TableDatum[] = [] as any; + const bounds = defaultGraphBounds(); + let svg = null as HTMLElement | SVGElement | null; + const title = tr.statisticsReviewsTitle(); + let subtitle = ""; + async function simulateFsrs(): Promise { - let result; + let resp: SimulateFsrsReviewResponse | undefined; try { await runWithBackendProgress( async () => { @@ -278,15 +292,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html simulateFsrsRequest.desiredRetention = $config.desiredRetention; simulateFsrsRequest.search = `preset:"${state.getCurrentName()}" -is:suspended`; console.log(simulateFsrsRequest); - const resp = await simulateFsrsReview(simulateFsrsRequest); - result = resp; + resp = await simulateFsrsReview(simulateFsrsRequest); }, () => {}, ); } finally { - console.log(result); + if (resp) { + tableData = renderSimulationChart(svg as SVGElement, bounds, resp); + } } } + + + {"Simulate"} + + + + + + {#each [1, 0] as i} + + {/each} + + + + + + + + diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts new file mode 100644 index 00000000000..340a6b8c27f --- /dev/null +++ b/ts/routes/graphs/simulator.ts @@ -0,0 +1,200 @@ +import type { SimulateFsrsReviewResponse } from "@generated/anki/scheduler_pb"; +import * as tr from "@generated/ftl"; +import { localizedNumber } from "@tslib/i18n"; +import { timeSpan } from "@tslib/time"; +import { + area, + axisBottom, + axisLeft, + axisRight, + bin, + cumsum, + curveBasis, + interpolateBlues, + interpolateGreens, + interpolateOranges, + interpolatePurples, + interpolateReds, + max, + pointer, + scaleLinear, + scaleSequential, + select, + sum, + type Bin, + type ScaleSequential, +} from "d3"; + +import type { GraphBounds, TableDatum } from "./graph-helpers"; +import { setDataAvailable } from "./graph-helpers"; +import { hideTooltip, showTooltip } from "./tooltip"; + +interface Reviews { + new: number; + review: number; + acquisition: number; + cost: number; +} + +function convertSimulateResponseToMap(data: SimulateFsrsReviewResponse): Map { + const result = new Map(); + + // 假设所有数组的长度相同 + const length = data.accumulatedKnowledgeAcquisition.length; + + for (let i = 0; i < length; i++) { + result.set(i, { + new: data.dailyNewCount[i], + review: data.dailyReviewCount[i], + acquisition: data.accumulatedKnowledgeAcquisition[i], + cost: data.dailyTimeCost[i] + }); + } + + return result; +} + +enum BinIndex { + New = 0, + Review = 1, +} + +type BinType = Bin, number>; + +function totalsForBin(bin: BinType): number[] { + const total = [0, 0]; + for (const entry of bin) { + total[BinIndex.New] += entry[1].new; + total[BinIndex.Review] += entry[1].review; + } + + return total; +} + + +function cumulativeBinValue(bin: BinType, idx: number): number { + return sum(totalsForBin(bin).slice(0, idx + 1)); +} + +export function renderSimulationChart( + svgElem: SVGElement, + bounds: GraphBounds, + data: SimulateFsrsReviewResponse, +): TableDatum[] { + // Prepare data + const sourceMap = convertSimulateResponseToMap(data); + + const svg = select(svgElem); + const trans = svg.transition().duration(600) as any; + + const xMax = max(sourceMap.keys()); + const xMin = 0; + const desiredBars = Math.min(70, Math.abs(xMax!)); + + const x = scaleLinear().domain([xMin, xMax!]); + x.nice(desiredBars); + + + const bins = bin() + .value((m) => { + return m[0]; + }) + .domain(x.domain() as any) + .thresholds(x.ticks(desiredBars))(sourceMap.entries() as any); + + // empty graph? + const totalDays = sum(bins, (bin) => bin.length); + if (!totalDays) { + setDataAvailable(svg, false); + return []; + } else { + setDataAvailable(svg, true); + } + + + x.range([bounds.marginLeft, bounds.width - bounds.marginRight]); + svg.select(".x-ticks") + .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0))) + .attr("direction", "ltr"); + + // y scale + + const yTickFormat = (n: number): string => { + if (Math.round(n) != n) { + return ""; + } else { + return localizedNumber(n); + } + }; + + const yMax = max(bins, (b: Bin) => cumulativeBinValue(b, 2))!; + const y = scaleLinear() + .range([bounds.height - bounds.marginBottom, bounds.marginTop]) + .domain([0, yMax]) + .nice(); + svg.select(".y-ticks") + .call((selection) => + selection.transition(trans).call( + axisLeft(y) + .ticks(bounds.height / 50) + .tickSizeOuter(0) + .tickFormat(yTickFormat as any), + ) + ) + .attr("direction", "ltr"); + + // x bars + + function barWidth(d: Bin): number { + const width = Math.max(0, x(d.x1!) - x(d.x0!) - 1); + return width ?? 0; + } + + const adjustedRange = scaleLinear().range([0.7, 0.3]); + const cappedRange = scaleLinear().range([0.3, 0.5]); + const blues = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain(x.domain() as any); + const greens = scaleSequential((n) => interpolateGreens(cappedRange(n)!)).domain(x.domain() as any); + + function binColor(idx: BinIndex): ScaleSequential { + switch (idx) { + case BinIndex.New: + return blues; + case BinIndex.Review: + return greens; + } + } + + const updateBar = (sel: any, idx: number): any => { + return sel + .attr("width", barWidth) + .transition(trans) + .attr("x", (d: any) => x(d.x0)) + .attr("y", (d: any) => y(cumulativeBinValue(d, idx))!) + .attr("height", (d: any) => y(0)! - y(cumulativeBinValue(d, idx))!) + .attr("fill", (d: any) => binColor(idx)(d.x0)); + }; + + for (const barNum of [0, 1]) { + svg.select(`g.bars${barNum}`) + .selectAll("rect") + .data(bins) + .join( + (enter) => + enter + .append("rect") + .attr("rx", 1) + .attr("x", (d: any) => x(d.x0)!) + .attr("y", y(0)!) + .attr("height", 0) + .call((d) => updateBar(d, barNum)), + (update) => update.call((d) => updateBar(d, barNum)), + (remove) => remove.call((remove) => remove.transition(trans).attr("height", 0).attr("y", y(0)!)), + ); + } + + + const tableData: TableDatum[] = [ + ]; + + return tableData; +} From da13f6ee7ceec02497925d16f3cbd06726802f25 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Sat, 29 Jun 2024 12:16:41 +0800 Subject: [PATCH 03/15] convert learning cards & use line chart --- rslib/src/scheduler/fsrs/simulator.rs | 51 +++++-- ts/routes/deck-options/FsrsOptions.svelte | 28 ++-- ts/routes/graphs/simulator.ts | 170 +++------------------- 3 files changed, 74 insertions(+), 175 deletions(-) diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index 3a52215fa27..dcb5dc88737 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -26,8 +26,8 @@ impl Collection { let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; let converted_cards = cards .into_iter() - .filter(|c| c.queue == CardQueue::Review) - .filter_map(|c| Card::convert(c, days_elapsed)) + .filter(|c| c.queue != CardQueue::Suspended && c.queue != CardQueue::PreviewRepeat) + .filter_map(|c| Card::convert(c, days_elapsed, req.days_to_simulate)) .collect_vec(); let p = self.get_optimal_retention_parameters(revlogs)?; let config = SimulatorConfig { @@ -78,19 +78,42 @@ impl Collection { } impl Card { - fn convert(card: Card, days_elapsed: i32) -> Option { + fn convert(card: Card, days_elapsed: i32, day_to_simulate: u32) -> Option { match card.memory_state { - Some(state) => { - let due = card.original_or_current_due(); - let relative_due = due - days_elapsed; - Some(fsrs::Card { - difficulty: state.difficulty as f64, - stability: state.stability as f64, - last_date: (relative_due - card.interval as i32) as f64, - due: relative_due as f64, - }) - } - None => None, + Some(state) => match card.queue { + CardQueue::DayLearn | CardQueue::Review => { + let due = card.original_or_current_due(); + let relative_due = due - days_elapsed; + Some(fsrs::Card { + difficulty: state.difficulty as f64, + stability: state.stability as f64, + last_date: (relative_due - card.interval as i32) as f64, + due: relative_due as f64, + }) + } + CardQueue::New => Some(fsrs::Card { + difficulty: 1e-10, + stability: 1e-10, + last_date: 0.0, + due: day_to_simulate as f64, + }), + CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => { + Some(fsrs::Card { + difficulty: state.difficulty as f64, + stability: state.stability as f64, + last_date: 0.0, + due: 0.0, + }) + } + CardQueue::PreviewRepeat => None, + CardQueue::Suspended => None, + }, + None => Some(fsrs::Card { + difficulty: 1e-10, + stability: 1e-10, + last_date: 0.0, + due: day_to_simulate as f64, + }), } } } diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 4fea496a531..13d9c6337ab 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -7,7 +7,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ComputeRetentionProgress, type ComputeWeightsProgress, } from "@generated/anki/collection_pb"; - import { ComputeOptimalRetentionRequest, SimulateFsrsReviewRequest, SimulateFsrsReviewResponse } from "@generated/anki/scheduler_pb"; + import { + ComputeOptimalRetentionRequest, + SimulateFsrsReviewRequest, + SimulateFsrsReviewResponse, + } from "@generated/anki/scheduler_pb"; import { computeFsrsWeights, computeOptimalRetention, @@ -302,9 +306,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } } } - - - openHelpModal("simulateFsrsReview")}> - Deck size + Additional new cards @@ -459,7 +460,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html max={1000} > openHelpModal("simulateFsrsReview")}> - New cards per day + New cards/day @@ -470,7 +471,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html max={1000} > openHelpModal("simulateFsrsReview")}> - Reviews per day + Maximum reviews/day @@ -481,7 +482,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html max={36500} > openHelpModal("simulateFsrsReview")}> - Maximum review interval + Maximum interval @@ -494,21 +495,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - - {#each [1, 0] as i} - + {/each} - + + - + - diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index 340a6b8c27f..5d3046e527c 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -1,124 +1,34 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { SimulateFsrsReviewResponse } from "@generated/anki/scheduler_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; -import { timeSpan } from "@tslib/time"; -import { - area, - axisBottom, - axisLeft, - axisRight, - bin, - cumsum, - curveBasis, - interpolateBlues, - interpolateGreens, - interpolateOranges, - interpolatePurples, - interpolateReds, - max, - pointer, - scaleLinear, - scaleSequential, - select, - sum, - type Bin, - type ScaleSequential, -} from "d3"; +import { axisBottom, axisLeft, line, max, scaleLinear, select } from "d3"; import type { GraphBounds, TableDatum } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; import { hideTooltip, showTooltip } from "./tooltip"; -interface Reviews { - new: number; - review: number; - acquisition: number; - cost: number; -} - -function convertSimulateResponseToMap(data: SimulateFsrsReviewResponse): Map { - const result = new Map(); - - // 假设所有数组的长度相同 - const length = data.accumulatedKnowledgeAcquisition.length; - - for (let i = 0; i < length; i++) { - result.set(i, { - new: data.dailyNewCount[i], - review: data.dailyReviewCount[i], - acquisition: data.accumulatedKnowledgeAcquisition[i], - cost: data.dailyTimeCost[i] - }); - } - - return result; -} - -enum BinIndex { - New = 0, - Review = 1, -} - -type BinType = Bin, number>; - -function totalsForBin(bin: BinType): number[] { - const total = [0, 0]; - for (const entry of bin) { - total[BinIndex.New] += entry[1].new; - total[BinIndex.Review] += entry[1].review; - } - - return total; -} - - -function cumulativeBinValue(bin: BinType, idx: number): number { - return sum(totalsForBin(bin).slice(0, idx + 1)); -} - export function renderSimulationChart( svgElem: SVGElement, bounds: GraphBounds, data: SimulateFsrsReviewResponse, ): TableDatum[] { // Prepare data - const sourceMap = convertSimulateResponseToMap(data); const svg = select(svgElem); const trans = svg.transition().duration(600) as any; - const xMax = max(sourceMap.keys()); + const xMax = data.dailyReviewCount.length - 1; const xMin = 0; - const desiredBars = Math.min(70, Math.abs(xMax!)); - - const x = scaleLinear().domain([xMin, xMax!]); - x.nice(desiredBars); - - - const bins = bin() - .value((m) => { - return m[0]; - }) - .domain(x.domain() as any) - .thresholds(x.ticks(desiredBars))(sourceMap.entries() as any); - - // empty graph? - const totalDays = sum(bins, (bin) => bin.length); - if (!totalDays) { - setDataAvailable(svg, false); - return []; - } else { - setDataAvailable(svg, true); - } - - x.range([bounds.marginLeft, bounds.width - bounds.marginRight]); + const x = scaleLinear().domain([xMin, xMax!]).range([bounds.marginLeft, bounds.width - bounds.marginRight]); svg.select(".x-ticks") .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0))) .attr("direction", "ltr"); // y scale - + const yTickFormat = (n: number): string => { if (Math.round(n) != n) { return ""; @@ -127,7 +37,7 @@ export function renderSimulationChart( } }; - const yMax = max(bins, (b: Bin) => cumulativeBinValue(b, 2))!; + const yMax = max(data.dailyReviewCount)!; const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]) @@ -143,58 +53,26 @@ export function renderSimulationChart( ) .attr("direction", "ltr"); - // x bars - - function barWidth(d: Bin): number { - const width = Math.max(0, x(d.x1!) - x(d.x0!) - 1); - return width ?? 0; - } - - const adjustedRange = scaleLinear().range([0.7, 0.3]); - const cappedRange = scaleLinear().range([0.3, 0.5]); - const blues = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain(x.domain() as any); - const greens = scaleSequential((n) => interpolateGreens(cappedRange(n)!)).domain(x.domain() as any); - - function binColor(idx: BinIndex): ScaleSequential { - switch (idx) { - case BinIndex.New: - return blues; - case BinIndex.Review: - return greens; - } - } - - const updateBar = (sel: any, idx: number): any => { - return sel - .attr("width", barWidth) - .transition(trans) - .attr("x", (d: any) => x(d.x0)) - .attr("y", (d: any) => y(cumulativeBinValue(d, idx))!) - .attr("height", (d: any) => y(0)! - y(cumulativeBinValue(d, idx))!) - .attr("fill", (d: any) => binColor(idx)(d.x0)); - }; - - for (const barNum of [0, 1]) { - svg.select(`g.bars${barNum}`) - .selectAll("rect") - .data(bins) - .join( - (enter) => - enter - .append("rect") - .attr("rx", 1) - .attr("x", (d: any) => x(d.x0)!) - .attr("y", y(0)!) - .attr("height", 0) - .call((d) => updateBar(d, barNum)), - (update) => update.call((d) => updateBar(d, barNum)), - (remove) => remove.call((remove) => remove.transition(trans).attr("height", 0).attr("y", y(0)!)), + // x lines + + svg.selectAll("path").remove(); + + svg.select("g.lines0") + .append("path") + .datum(data.dailyReviewCount) + .attr("fill", "none") + .attr("stroke", "steelblue") + .attr("stroke-width", 1.5) + .attr( + "d", + line() + .x((d, i) => x(i)) + .y(d => y(d)), ); - } + setDataAvailable(svg, true); - const tableData: TableDatum[] = [ - ]; + const tableData: TableDatum[] = []; return tableData; } From fce6912a09bdf8f02c3a963d359da5ae06e185e7 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Thu, 1 Aug 2024 13:50:58 +0800 Subject: [PATCH 04/15] allow draw multiple simulations in the same chart --- ts/routes/deck-options/FsrsOptions.svelte | 37 +++++++++++++++++---- ts/routes/graphs/simulator.ts | 39 ++++++++++++++--------- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 13d9c6337ab..a0157464ce8 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -33,7 +33,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Warning from "./Warning.svelte"; import WeightsInputRow from "./WeightsInputRow.svelte"; import WeightsSearchRow from "./WeightsSearchRow.svelte"; - import { renderSimulationChart } from "../graphs/simulator"; + import { renderSimulationChart, type Point } from "../graphs/simulator"; import Graph from "../graphs/Graph.svelte"; import HoverColumns from "../graphs/HoverColumns.svelte"; import CumulativeOverlay from "../graphs/CumulativeOverlay.svelte"; @@ -286,23 +286,51 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let svg = null as HTMLElement | SVGElement | null; const title = tr.statisticsReviewsTitle(); let subtitle = ""; + let simulationNumber = 0; + + let points: Point[] = []; + + function movingAverage(y: number[], windowSize: number): number[] { + const result: number[] = []; + for (let i = 0; i < y.length; i++) { + let sum = 0; + let count = 0; + for (let j = Math.max(0, i - windowSize + 1); j <= i; j++) { + sum += y[j]; + count++; + } + result.push(sum / count); + } + return result; + } async function simulateFsrs(): Promise { let resp: SimulateFsrsReviewResponse | undefined; + simulationNumber += 1; try { await runWithBackendProgress( async () => { simulateFsrsRequest.weights = $config.fsrsWeights; simulateFsrsRequest.desiredRetention = $config.desiredRetention; simulateFsrsRequest.search = `preset:"${state.getCurrentName()}" -is:suspended`; - console.log(simulateFsrsRequest); resp = await simulateFsrsReview(simulateFsrsRequest); }, () => {}, ); } finally { if (resp) { - tableData = renderSimulationChart(svg as SVGElement, bounds, resp); + let dailyTimeCost = movingAverage( + resp.dailyTimeCost, + Math.round(simulateFsrsRequest.daysToSimulate / 50), + ); + points = points.concat( + dailyTimeCost.map((v, i) => ({ + x: i, + y: v, + label: simulationNumber, + })), + ); + tableData = renderSimulationChart(svg as SVGElement, bounds, points); } } } @@ -496,9 +524,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - {#each [1, 0] as i} - - {/each} diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index 5d3046e527c..dfa404e8627 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -1,26 +1,32 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import type { SimulateFsrsReviewResponse } from "@generated/anki/scheduler_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; -import { axisBottom, axisLeft, line, max, scaleLinear, select } from "d3"; +import { axisBottom, axisLeft, line, max, rollup, scaleLinear, select } from "d3"; import type { GraphBounds, TableDatum } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; import { hideTooltip, showTooltip } from "./tooltip"; +export interface Point { + x: number; + y: number; + label: number; +} + export function renderSimulationChart( svgElem: SVGElement, bounds: GraphBounds, - data: SimulateFsrsReviewResponse, + data: Point[], ): TableDatum[] { // Prepare data const svg = select(svgElem); const trans = svg.transition().duration(600) as any; - const xMax = data.dailyReviewCount.length - 1; const xMin = 0; + const xMax = max(data, d => d.x); + console.log(xMax); const x = scaleLinear().domain([xMin, xMax!]).range([bounds.marginLeft, bounds.width - bounds.marginRight]); svg.select(".x-ticks") @@ -37,7 +43,8 @@ export function renderSimulationChart( } }; - const yMax = max(data.dailyReviewCount)!; + const yMax = max(data, d => d.y)!; + console.log(yMax); const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]) @@ -57,18 +64,20 @@ export function renderSimulationChart( svg.selectAll("path").remove(); - svg.select("g.lines0") - .append("path") - .datum(data.dailyReviewCount) + const points = 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]); + + svg.append("g") .attr("fill", "none") - .attr("stroke", "steelblue") + .attr("stroke", "green") .attr("stroke-width", 1.5) - .attr( - "d", - line() - .x((d, i) => x(i)) - .y(d => y(d)), - ); + .attr("stroke-linejoin", "round") + .attr("stroke-linecap", "round") + .selectAll("path") + .data(groups.values()) + .join("path") + .style("mix-blend-mode", "multiply") + .attr("d", line()); setDataAvailable(svg, true); From 3b1fdbf6b6dca63e6199291eb6c4770fb9dc3f2c Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Thu, 1 Aug 2024 14:15:46 +0800 Subject: [PATCH 05/15] support hide simulation --- ts/routes/deck-options/FsrsOptions.svelte | 3 ++ ts/routes/graphs/simulator.ts | 54 +++++++++++++++++++---- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index a0157464ce8..c575ccbe4f9 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -536,4 +536,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index dfa404e8627..cc9e1e55a8c 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -2,7 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; -import { axisBottom, axisLeft, line, max, rollup, scaleLinear, select } from "d3"; +import { axisBottom, axisLeft, line, max, rollup, scaleLinear, schemeCategory10, select } from "d3"; import type { GraphBounds, TableDatum } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; @@ -26,7 +26,6 @@ export function renderSimulationChart( const xMin = 0; const xMax = max(data, d => d.x); - console.log(xMax); const x = scaleLinear().domain([xMin, xMax!]).range([bounds.marginLeft, bounds.width - bounds.marginRight]); svg.select(".x-ticks") @@ -44,7 +43,6 @@ export function renderSimulationChart( }; const yMax = max(data, d => d.y)!; - console.log(yMax); const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]) @@ -61,23 +59,61 @@ export function renderSimulationChart( .attr("direction", "ltr"); // x lines - - svg.selectAll("path").remove(); - const points = 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; + + svg.selectAll("path").remove(); + svg.append("g") .attr("fill", "none") - .attr("stroke", "green") .attr("stroke-width", 1.5) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") .selectAll("path") - .data(groups.values()) + .data(Array.from(groups.entries())) .join("path") .style("mix-blend-mode", "multiply") - .attr("d", line()); + .attr("stroke", (d, i) => color[i % color.length]) + .attr("d", d => line()(d[1].map(p => [p[0], p[1]]))) + .attr("data-group", d => d[0]); + + const legend = svg.append("g") + .attr("font-family", "sans-serif") + .attr("font-size", 10) + .attr("text-anchor", "start") + .selectAll("g") + .data(Array.from(groups.keys())) + .join("g") + .attr("transform", (d, i) => `translate(0,${i * 20})`) + .attr("cursor", "pointer") + .on("click", (event, d) => toggleGroup(event, d)); + + legend.append("rect") + .attr("x", bounds.width - bounds.marginRight + 10) + .attr("width", 19) + .attr("height", 19) + .attr("fill", (d, i) => color[i % color.length]); + + legend.append("text") + .attr("x", bounds.width - bounds.marginRight + 34) + .attr("y", 9.5) + .attr("dy", "0.32em") + .text(d => `Group ${d}`); + + const toggleGroup = (event: MouseEvent, d: number) => { + const group = d; + const path = svg.select(`path[data-group="${group}"]`); + const hidden = path.classed("hidden"); + const target = event.currentTarget as HTMLElement; + + path.classed("hidden", !hidden); + path.style("display", () => hidden ? null : "none"); + + select(target).select("rect") + .style("opacity", hidden ? 1 : 0.5); + }; setDataAvailable(svg, true); From fb7689a4ca93d4955ce3b57df1b0952e7bd650c3 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Thu, 1 Aug 2024 14:21:48 +0800 Subject: [PATCH 06/15] convert x axis to Date --- ts/routes/graphs/simulator.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index cc9e1e55a8c..4bc9ac3789d 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -2,7 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; -import { axisBottom, axisLeft, line, max, rollup, scaleLinear, schemeCategory10, select } from "d3"; +import { axisBottom, axisLeft, line, max, rollup, scaleLinear, scaleTime, schemeCategory10, select, timeFormat } from "d3"; import type { GraphBounds, TableDatum } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; @@ -24,14 +24,27 @@ export function renderSimulationChart( const svg = select(svgElem); const trans = svg.transition().duration(600) as any; - const xMin = 0; - const xMax = max(data, d => d.x); + const today = new Date(); + const dateData = data.map(d => ({ + ...d, + date: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000) + })); + const xMin = today; + const xMax = max(dateData, d => d.date); + + const x = scaleTime() + .domain([xMin, xMax!]) + .range([bounds.marginLeft, bounds.width - bounds.marginRight]); + const formatDate = timeFormat("%Y-%m-%d"); - const x = scaleLinear().domain([xMin, xMax!]).range([bounds.marginLeft, bounds.width - bounds.marginRight]); svg.select(".x-ticks") - .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0))) + .call((selection) => selection.transition(trans).call( + axisBottom(x) + .ticks(7) + .tickFormat((d: any) => formatDate(d)) + .tickSizeOuter(0) + )) .attr("direction", "ltr"); - // y scale const yTickFormat = (n: number): string => { @@ -59,7 +72,7 @@ export function renderSimulationChart( .attr("direction", "ltr"); // x lines - const points = data.map((d) => [x(d.x), y(d.y), d.label]); + const points = dateData.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 color = schemeCategory10; From 0e3e03ff5f47652860d2b2b2f391120ede87d4b7 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Thu, 1 Aug 2024 14:33:56 +0800 Subject: [PATCH 07/15] convert y from second to minute --- ts/routes/graphs/simulator.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index 4bc9ac3789d..fa6074ddcfa 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -25,12 +25,13 @@ export function renderSimulationChart( const trans = svg.transition().duration(600) as any; const today = new Date(); - const dateData = data.map(d => ({ + const convertedData = data.map(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 xMax = max(dateData, d => d.date); + const xMax = max(convertedData, d => d.date); const x = scaleTime() .domain([xMin, xMax!]) @@ -55,7 +56,7 @@ export function renderSimulationChart( } }; - const yMax = max(data, d => d.y)!; + const yMax = max(convertedData, d => d.yMinutes)!; const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]) @@ -71,8 +72,19 @@ export function renderSimulationChart( ) .attr("direction", "ltr"); + svg.select(".y-ticks") + .append("text") + .attr("class", "y-axis-title") + .attr("transform", "rotate(-90)") + .attr("y", 0 - bounds.marginLeft) + .attr("x", 0 - (bounds.height / 2)) + .attr("dy", "1em") + .attr("fill", "currentColor") + .style("text-anchor", "middle") + .text("Review Time (minutes) per day"); + // x lines - const points = dateData.map((d) => [x(d.date), y(d.y), d.label]); + const points = convertedData.map((d) => [x(d.date), y(d.yMinutes), d.label]); const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]); const color = schemeCategory10; From e7d03011c8c352a50ecfa7b731111942cb937241 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Thu, 1 Aug 2024 14:54:40 +0800 Subject: [PATCH 08/15] support clear last simulation --- ts/routes/deck-options/FsrsOptions.svelte | 15 ++++++++ ts/routes/graphs/simulator.ts | 44 ++++++++++++++++------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index c575ccbe4f9..3a2c63975ad 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -41,6 +41,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import NoDataOverlay from "../graphs/NoDataOverlay.svelte"; import TableData from "../graphs/TableData.svelte"; import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers"; + import { p } from "$lib/domlib/surround/test-utils"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; @@ -334,6 +335,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } } } + + function clearSimulation(): void { + points = points.filter((p) => p.label !== simulationNumber); + simulationNumber = Math.max(0, simulationNumber - 1); + tableData = renderSimulationChart(svg as SVGElement, bounds, points); + } + + diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index fa6074ddcfa..bfa080c8f83 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -2,7 +2,18 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; -import { axisBottom, axisLeft, line, max, rollup, scaleLinear, scaleTime, schemeCategory10, select, timeFormat } from "d3"; +import { + axisBottom, + axisLeft, + line, + max, + rollup, + scaleLinear, + scaleTime, + schemeCategory10, + select, + timeFormat, +} from "d3"; import type { GraphBounds, TableDatum } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; @@ -19,16 +30,21 @@ export function renderSimulationChart( bounds: GraphBounds, data: Point[], ): TableDatum[] { - // Prepare data - const svg = select(svgElem); + svg.selectAll(".lines").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), - yMinutes: d.y / 60 + yMinutes: d.y / 60, })); const xMin = today; const xMax = max(convertedData, d => d.date); @@ -39,12 +55,14 @@ export function renderSimulationChart( const formatDate = timeFormat("%Y-%m-%d"); svg.select(".x-ticks") - .call((selection) => selection.transition(trans).call( - axisBottom(x) - .ticks(7) - .tickFormat((d: any) => formatDate(d)) - .tickSizeOuter(0) - )) + .call((selection) => + selection.transition(trans).call( + axisBottom(x) + .ticks(7) + .tickFormat((d: any) => formatDate(d)) + .tickSizeOuter(0), + ) + ) .attr("direction", "ltr"); // y scale @@ -81,7 +99,7 @@ export function renderSimulationChart( .attr("dy", "1em") .attr("fill", "currentColor") .style("text-anchor", "middle") - .text("Review Time (minutes) per day"); + .text("Review Time per day (minutes)"); // x lines const points = convertedData.map((d) => [x(d.date), y(d.yMinutes), d.label]); @@ -89,9 +107,8 @@ export function renderSimulationChart( const color = schemeCategory10; - svg.selectAll("path").remove(); - svg.append("g") + .attr("class", "lines") .attr("fill", "none") .attr("stroke-width", 1.5) .attr("stroke-linejoin", "round") @@ -105,6 +122,7 @@ export function renderSimulationChart( .attr("data-group", d => d[0]); const legend = svg.append("g") + .attr("class", "legend") .attr("font-family", "sans-serif") .attr("font-size", 10) .attr("text-anchor", "start") From f810b7d23ee4fd12f9a67bb157bb5a7262ff6fb5 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Thu, 1 Aug 2024 15:10:33 +0800 Subject: [PATCH 09/15] remove unused import --- rslib/src/scheduler/fsrs/simulator.rs | 8 ++++---- ts/routes/deck-options/FsrsOptions.svelte | 11 +++-------- ts/routes/graphs/simulator.ts | 2 -- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index 578b08f60f5..e1bf9b03b02 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -76,8 +76,8 @@ impl Card { let due = card.original_or_current_due(); let relative_due = due - days_elapsed; Some(fsrs::Card { - difficulty: state.difficulty as f32, - stability: state.stability as f32, + difficulty: state.difficulty, + stability: state.stability, last_date: (relative_due - card.interval as i32) as f32, due: relative_due as f32, }) @@ -90,8 +90,8 @@ impl Card { }), CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => { Some(fsrs::Card { - difficulty: state.difficulty as f32, - stability: state.stability as f32, + difficulty: state.difficulty, + stability: state.stability, last_date: 0.0, due: 0.0, }) diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 3a2c63975ad..a2867c64ebc 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { ComputeOptimalRetentionRequest, SimulateFsrsReviewRequest, - SimulateFsrsReviewResponse, + type SimulateFsrsReviewResponse, } from "@generated/anki/scheduler_pb"; import { computeFsrsWeights, @@ -41,7 +41,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import NoDataOverlay from "../graphs/NoDataOverlay.svelte"; import TableData from "../graphs/TableData.svelte"; import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers"; - import { p } from "$lib/domlib/surround/test-utils"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; @@ -286,7 +285,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const bounds = defaultGraphBounds(); let svg = null as HTMLElement | SVGElement | null; const title = tr.statisticsReviewsTitle(); - let subtitle = ""; let simulationNumber = 0; let points: Point[] = []; @@ -320,7 +318,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ); } finally { if (resp) { - let dailyTimeCost = movingAverage( + const dailyTimeCost = movingAverage( resp.dailyTimeCost, Math.round(simulateFsrsRequest.daysToSimulate / 50), ); @@ -537,7 +535,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {"Clear last simulation"} - + @@ -551,7 +549,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index bfa080c8f83..665dbdef2dd 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -1,6 +1,5 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { axisBottom, @@ -17,7 +16,6 @@ import { import type { GraphBounds, TableDatum } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; -import { hideTooltip, showTooltip } from "./tooltip"; export interface Point { x: number; From b5c7284fe42df255dbac73ed1d7b908d0fbeca1c Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Fri, 2 Aug 2024 10:05:28 +0800 Subject: [PATCH 10/15] rename --- ts/routes/deck-options/FsrsOptions.svelte | 4 ++-- ts/routes/graphs/simulator.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index a2867c64ebc..d5ddfa026de 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -84,7 +84,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const simulateFsrsRequest = new SimulateFsrsReviewRequest({ weights: $config.fsrsWeights, desiredRetention: $config.desiredRetention, - deckSize: 1000, + deckSize: 0, daysToSimulate: 365, newLimit: $config.newPerDay, reviewLimit: $config.reviewsPerDay, @@ -482,7 +482,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html max={10000} > openHelpModal("simulateFsrsReview")}> - Additional new cards + Additional new cards to simulate diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index 665dbdef2dd..7f8a9b7500c 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -141,7 +141,7 @@ export function renderSimulationChart( .attr("x", bounds.width - bounds.marginRight + 34) .attr("y", 9.5) .attr("dy", "0.32em") - .text(d => `Group ${d}`); + .text(d => `Simulation ${d}`); const toggleGroup = (event: MouseEvent, d: number) => { const group = d; From a0d9da1822427c27e4fb6bff8c48b90d24860b56 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Wed, 14 Aug 2024 17:39:06 +0800 Subject: [PATCH 11/15] add hover/tooltip --- ts/routes/graphs/simulator.ts | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index 7f8a9b7500c..52c7917c633 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -4,8 +4,10 @@ import { localizedNumber } from "@tslib/i18n"; import { axisBottom, axisLeft, + bisector, line, max, + pointer, rollup, scaleLinear, scaleTime, @@ -16,6 +18,7 @@ import { import type { GraphBounds, TableDatum } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; +import { hideTooltip, showTooltip } from "./tooltip"; export interface Point { x: number; @@ -30,6 +33,8 @@ export function renderSimulationChart( ): 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); @@ -119,6 +124,57 @@ export function renderSimulationChart( .attr("d", d => line()(d[1].map(p => [p[0], p[1]]))) .attr("data-group", d => d[0]); + const focusLine = svg.append("line") + .attr("class", "focus-line") + .attr("y1", bounds.marginTop) + .attr("y2", bounds.height - bounds.marginBottom) + .attr("stroke", "black") + .attr("stroke-width", 1) + .style("opacity", 0); + + const LongestGroupData = Array.from(groups.values()).reduce((a, b) => a.length > b.length ? a : b); + const barWidth = bounds.width / LongestGroupData.length; + + // hover/tooltip + svg.append("g") + .attr("class", "hover-columns") + .selectAll("rect") + .data(LongestGroupData) + .join("rect") + .attr("x", d => d[0] - barWidth / 2) + .attr("y", bounds.marginTop) + .attr("width", barWidth) + .attr("height", bounds.height - bounds.marginTop - bounds.marginBottom) + .attr("fill", "transparent") + .on("mousemove", mousemove) + .on("mouseout", hideTooltip); + + function mousemove(event: MouseEvent, d: any): void { + pointer(event, document.body); + const date = x.invert(d[0]); + + const groupData: { [key: string]: number } = {}; + + groups.forEach((groupPoints, key) => { + const bisect = bisector((d: number[]) => x.invert(d[0])).left; + const index = bisect(groupPoints, date); + const dataPoint = groupPoints[index - 1] || groupPoints[index]; + + if (dataPoint) { + groupData[key] = y.invert(dataPoint[1]); + } + }); + + focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1); + + let tooltipContent = `Date: ${timeFormat("%Y-%m-%d")(date)}
`; + for (const [key, value] of Object.entries(groupData)) { + tooltipContent += `Simulation ${key}: ${value.toFixed(2)} minutes
`; + } + + showTooltip(tooltipContent, event.pageX, event.pageY); + } + const legend = svg.append("g") .attr("class", "legend") .attr("font-family", "sans-serif") From db0187b5ab33485e6f74b3e8b4c56e8ff962d9e7 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Sat, 17 Aug 2024 14:32:34 +0800 Subject: [PATCH 12/15] fallback to default parameters --- rslib/src/scheduler/fsrs/simulator.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index e1bf9b03b02..b41d98d76eb 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -5,6 +5,7 @@ use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewResponse; use fsrs::simulate; use fsrs::SimulatorConfig; +use fsrs::DEFAULT_PARAMETERS; use itertools::Itertools; use crate::card::CardQueue; @@ -47,6 +48,19 @@ impl Collection { learn_limit: req.new_limit as usize, review_limit: req.review_limit as usize, }; + let parameters = if req.weights.is_empty() { + DEFAULT_PARAMETERS.to_vec() + } else if req.weights.len() != 19 { + if req.weights.len() == 17 { + let mut parameters = req.weights.to_vec(); + parameters.extend_from_slice(&[0.0, 0.0]); + parameters + } else { + return Err(AnkiError::FsrsWeightsInvalid); + } + } else { + req.weights.to_vec() + }; let ( accumulated_knowledge_acquisition, daily_review_count, @@ -54,7 +68,7 @@ impl Collection { daily_time_cost, ) = simulate( &config, - &req.weights, + ¶meters, req.desired_retention, None, Some(converted_cards), From 605e90725a70c4b6085f5be182b3b8b8994c319a Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Sat, 17 Aug 2024 14:40:55 +0800 Subject: [PATCH 13/15] update default value and maximum of deckSize --- ts/routes/deck-options/FsrsOptions.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index d5ddfa026de..22fcb84b651 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -477,9 +477,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html openHelpModal("simulateFsrsReview")}> Additional new cards to simulate From 42e977f01be19975b1a3d413c029af0b3d412c18 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Sat, 17 Aug 2024 14:53:10 +0800 Subject: [PATCH 14/15] add "processing..." --- ts/routes/deck-options/FsrsOptions.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 22fcb84b651..0c1c99404dc 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -303,6 +303,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html return result; } + $: simulateProgressString = ""; + async function simulateFsrs(): Promise { let resp: SimulateFsrsReviewResponse | undefined; simulationNumber += 1; @@ -312,12 +314,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html simulateFsrsRequest.weights = $config.fsrsWeights; simulateFsrsRequest.desiredRetention = $config.desiredRetention; simulateFsrsRequest.search = `preset:"${state.getCurrentName()}" -is:suspended`; + simulateProgressString = "processing..."; resp = await simulateFsrsReview(simulateFsrsRequest); }, () => {}, ); } finally { if (resp) { + simulateProgressString = ""; const dailyTimeCost = movingAverage( resp.dailyTimeCost, Math.round(simulateFsrsRequest.daysToSimulate / 50), @@ -456,7 +460,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /> {/if} {/if} -
{computeRetentionProgressString}
+
{simulateProgressString}
@@ -534,6 +538,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html > {"Clear last simulation"} +
{simulateProgressString}
From dc0d1ad7e006b20d6925ac12c5ea5f0c18eadfb9 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Sat, 17 Aug 2024 15:11:46 +0800 Subject: [PATCH 15/15] fix mistake --- ts/routes/deck-options/FsrsOptions.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 0c1c99404dc..569dee9ddc9 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -460,7 +460,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /> {/if} {/if} -
{simulateProgressString}
+
{computeRetentionProgressString}