From 58040c6ede5dc959bd42e4f86428af76c676df21 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Sat, 8 Nov 2025 13:41:24 +0000 Subject: [PATCH 01/12] Added: Difficulty zoom --- rslib/src/stats/graphs/eases.rs | 20 ++++++------ rslib/src/stats/graphs/retrievability.rs | 2 +- ts/routes/graphs/DifficultyGraph.svelte | 40 ++++++++++++++++++++++-- ts/routes/graphs/difficulty.ts | 13 ++++++-- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/rslib/src/stats/graphs/eases.rs b/rslib/src/stats/graphs/eases.rs index 9b8e703e6..80c1caccc 100644 --- a/rslib/src/stats/graphs/eases.rs +++ b/rslib/src/stats/graphs/eases.rs @@ -17,7 +17,7 @@ impl GraphsContext { if let Some(state) = card.memory_state { *difficulty .eases - .entry(percent_to_bin(state.difficulty() * 100.0)) + .entry(percent_to_bin(state.difficulty() * 100.0, 1)) .or_insert_with(Default::default) += 1; difficulty_values.push(state.difficulty()); } else if matches!(card.ctype, CardType::Review | CardType::Relearn) { @@ -51,11 +51,11 @@ fn median(data: &mut [f32]) -> f32 { } /// Bins the number into a bin of 0, 5, .. 95 -pub(super) fn percent_to_bin(x: f32) -> u32 { +pub(super) fn percent_to_bin(x: f32, bin_size: u32) -> u32 { if x == 100.0 { - 95 + 100 - bin_size } else { - ((x / 5.0).floor() * 5.0) as u32 + ((x / bin_size as f32).floor() * bin_size as f32) as u32 } } @@ -65,11 +65,11 @@ mod tests { #[test] fn bins() { - assert_eq!(percent_to_bin(0.0), 0); - assert_eq!(percent_to_bin(4.9), 0); - assert_eq!(percent_to_bin(5.0), 5); - assert_eq!(percent_to_bin(9.9), 5); - assert_eq!(percent_to_bin(99.9), 95); - assert_eq!(percent_to_bin(100.0), 95); + assert_eq!(percent_to_bin(0.0, 5), 0); + assert_eq!(percent_to_bin(4.9, 5), 0); + assert_eq!(percent_to_bin(5.0, 5), 5); + assert_eq!(percent_to_bin(9.9, 5), 5); + assert_eq!(percent_to_bin(99.9, 5), 95); + assert_eq!(percent_to_bin(100.0, 5), 95); } } diff --git a/rslib/src/stats/graphs/retrievability.rs b/rslib/src/stats/graphs/retrievability.rs index 6881a6062..69ac4d450 100644 --- a/rslib/src/stats/graphs/retrievability.rs +++ b/rslib/src/stats/graphs/retrievability.rs @@ -39,7 +39,7 @@ impl GraphsContext { *retrievability .retrievability - .entry(percent_to_bin(r * 100.0)) + .entry(percent_to_bin(r * 100.0, 1)) .or_insert_with(Default::default) += 1; retrievability.sum_by_card += r; card_with_retrievability_count += 1; diff --git a/ts/routes/graphs/DifficultyGraph.svelte b/ts/routes/graphs/DifficultyGraph.svelte index 4eea03727..5d05dc6fe 100644 --- a/ts/routes/graphs/DifficultyGraph.svelte +++ b/ts/routes/graphs/DifficultyGraph.svelte @@ -7,13 +7,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { createEventDispatcher } from "svelte"; - import { gatherData, prepareData } from "./difficulty"; + import { DifficultyRange, gatherData, prepareData } from "./difficulty"; import Graph from "./Graph.svelte"; import type { GraphPrefs } from "./graph-helpers"; import type { SearchEventMap, TableDatum } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; import HistogramGraph from "./HistogramGraph.svelte"; import TableData from "./TableData.svelte"; + import InputBox from "./InputBox.svelte"; export let sourceData: GraphsResponse | null = null; export let prefs: GraphPrefs; @@ -22,12 +23,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let histogramData: HistogramData | null = null; let tableData: TableDatum[] = []; + let range = DifficultyRange.All; + + $: percentile = { + [DifficultyRange.Percentile50]: 0.5, + [DifficultyRange.Percentile95]: 0.95, + [DifficultyRange.All]: 1, + }[range]; $: if (sourceData) { + const data = gatherData(sourceData); + + console.log(data.eases); + [histogramData, tableData] = prepareData( - gatherData(sourceData), + data, dispatch, $prefs.browserLinksSupported, + 1 - percentile, ); } @@ -37,6 +50,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {#if sourceData?.fsrs} + + + + + + diff --git a/ts/routes/graphs/difficulty.ts b/ts/routes/graphs/difficulty.ts index 1778e2513..96aff05f9 100644 --- a/ts/routes/graphs/difficulty.ts +++ b/ts/routes/graphs/difficulty.ts @@ -9,12 +9,18 @@ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import type { Bin, ScaleLinear } from "d3"; -import { bin, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3"; +import { bin, interpolateRdYlGn, quantile, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; +export enum DifficultyRange { + All = 0, + Percentile50 = 1, + Percentile95 = 2, +} + export interface GraphData { eases: Map; average: number; @@ -61,14 +67,15 @@ export function prepareData( data: GraphData, dispatch: SearchDispatch, browserLinksSupported: boolean, + lowerQuantile: number = 0 ): [HistogramData | null, TableDatum[]] { // get min/max const allEases = data.eases; if (!allEases.size) { return [null, []]; } - const xMin = 0; - const xMax = 100; + const xMin = quantile(Array.from(allEases.keys()), lowerQuantile) ?? 0; + const xMax = 100 const desiredBars = 20; const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars); From c3f29fad0bb591f4284e0fffaf18ed3f6905838a Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Sat, 8 Nov 2025 15:35:44 +0000 Subject: [PATCH 02/12] Fix: quantiles, Extract: PercentageRange component (I accidentally staged the second commit before comiting the first) --- ts/routes/graphs/DifficultyGraph.svelte | 38 ++++------------------ ts/routes/graphs/PercentageRange.svelte | 42 +++++++++++++++++++++++++ ts/routes/graphs/difficulty.ts | 24 ++++++++------ ts/routes/graphs/percentageRange.ts | 17 ++++++++++ 4 files changed, 79 insertions(+), 42 deletions(-) create mode 100644 ts/routes/graphs/PercentageRange.svelte create mode 100644 ts/routes/graphs/percentageRange.ts diff --git a/ts/routes/graphs/DifficultyGraph.svelte b/ts/routes/graphs/DifficultyGraph.svelte index 5d05dc6fe..f71ae794a 100644 --- a/ts/routes/graphs/DifficultyGraph.svelte +++ b/ts/routes/graphs/DifficultyGraph.svelte @@ -7,14 +7,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import * as tr from "@generated/ftl"; import { createEventDispatcher } from "svelte"; - import { DifficultyRange, gatherData, prepareData } from "./difficulty"; + import { gatherData, prepareData } from "./difficulty"; import Graph from "./Graph.svelte"; import type { GraphPrefs } from "./graph-helpers"; import type { SearchEventMap, TableDatum } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; import HistogramGraph from "./HistogramGraph.svelte"; import TableData from "./TableData.svelte"; - import InputBox from "./InputBox.svelte"; + import PercentageRange from "./PercentageRange.svelte"; + import { PercentageRangeEnum, PercentageRangeToQuantile } from "./percentageRange"; export let sourceData: GraphsResponse | null = null; export let prefs: GraphPrefs; @@ -23,13 +24,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let histogramData: HistogramData | null = null; let tableData: TableDatum[] = []; - let range = DifficultyRange.All; - - $: percentile = { - [DifficultyRange.Percentile50]: 0.5, - [DifficultyRange.Percentile95]: 0.95, - [DifficultyRange.All]: 1, - }[range]; + let range = PercentageRangeEnum.All; $: if (sourceData) { const data = gatherData(sourceData); @@ -40,7 +35,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html data, dispatch, $prefs.browserLinksSupported, - 1 - percentile, + PercentageRangeToQuantile(range), ); } @@ -50,28 +45,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {#if sourceData?.fsrs} - - - - - + diff --git a/ts/routes/graphs/PercentageRange.svelte b/ts/routes/graphs/PercentageRange.svelte new file mode 100644 index 000000000..250d45d99 --- /dev/null +++ b/ts/routes/graphs/PercentageRange.svelte @@ -0,0 +1,42 @@ + + + + + + + + + diff --git a/ts/routes/graphs/difficulty.ts b/ts/routes/graphs/difficulty.ts index 96aff05f9..aefeb9bd2 100644 --- a/ts/routes/graphs/difficulty.ts +++ b/ts/routes/graphs/difficulty.ts @@ -9,18 +9,12 @@ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import type { Bin, ScaleLinear } from "d3"; -import { bin, interpolateRdYlGn, quantile, scaleLinear, scaleSequential, sum } from "d3"; +import { bin, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; -export enum DifficultyRange { - All = 0, - Percentile50 = 1, - Percentile95 = 2, -} - export interface GraphData { eases: Map; average: number; @@ -63,19 +57,29 @@ function getAdjustedScaleAndTicks( ]; } +export function easeQuantile(data: Map, quantile: number) { + let count = sum(data.values()) * quantile; + for (const [key, value] of data.entries()) { + count -= value; + if (count <= 0) { + return key; + } + } +} + export function prepareData( data: GraphData, dispatch: SearchDispatch, browserLinksSupported: boolean, - lowerQuantile: number = 0 + quantile?: number, ): [HistogramData | null, TableDatum[]] { // get min/max const allEases = data.eases; if (!allEases.size) { return [null, []]; } - const xMin = quantile(Array.from(allEases.keys()), lowerQuantile) ?? 0; - const xMax = 100 + const xMin = quantile ? easeQuantile(allEases, 1 - quantile) ?? 0 : 0; + const xMax = quantile ? easeQuantile(allEases, quantile) ?? 0 : 100; const desiredBars = 20; const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars); diff --git a/ts/routes/graphs/percentageRange.ts b/ts/routes/graphs/percentageRange.ts new file mode 100644 index 000000000..38ebc0dfd --- /dev/null +++ b/ts/routes/graphs/percentageRange.ts @@ -0,0 +1,17 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +export enum PercentageRangeEnum { + All = 0, + Percentile100 = 1, + Percentile95 = 2, + Percentile50 = 3, +} + +export function PercentageRangeToQuantile(range: PercentageRangeEnum) { + return ({ + [PercentageRangeEnum.Percentile100]: 1, + [PercentageRangeEnum.Percentile95]: 0.95, + [PercentageRangeEnum.Percentile50]: 0.5, + [PercentageRangeEnum.All]: undefined, + })[range]; +} From 13caf85f2609ddaa8672f81f5ed30f442473cbd3 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Sat, 8 Nov 2025 15:42:58 +0000 Subject: [PATCH 03/12] Add to retrievability graph --- ts/routes/graphs/RetrievabilityGraph.svelte | 6 ++++++ ts/routes/graphs/difficulty.ts | 14 ++------------ ts/routes/graphs/percentageRange.ts | 20 ++++++++++++++++++++ ts/routes/graphs/retrievability.ts | 5 +++-- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/ts/routes/graphs/RetrievabilityGraph.svelte b/ts/routes/graphs/RetrievabilityGraph.svelte index 348c5239a..b6c2f69f7 100644 --- a/ts/routes/graphs/RetrievabilityGraph.svelte +++ b/ts/routes/graphs/RetrievabilityGraph.svelte @@ -14,6 +14,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import HistogramGraph from "./HistogramGraph.svelte"; import { gatherData, prepareData } from "./retrievability"; import TableData from "./TableData.svelte"; + import PercentageRange from "./PercentageRange.svelte"; + import { PercentageRangeEnum, PercentageRangeToQuantile } from "./percentageRange"; export let sourceData: GraphsResponse | null = null; export let prefs: GraphPrefs; @@ -22,12 +24,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let histogramData: HistogramData | null = null; let tableData: TableDatum[] = []; + let range = PercentageRangeEnum.All; $: if (sourceData) { [histogramData, tableData] = prepareData( gatherData(sourceData), dispatch, $prefs.browserLinksSupported, + PercentageRangeToQuantile(range), ); } @@ -37,6 +41,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {#if sourceData?.fsrs} + + diff --git a/ts/routes/graphs/difficulty.ts b/ts/routes/graphs/difficulty.ts index aefeb9bd2..9ab1d82bc 100644 --- a/ts/routes/graphs/difficulty.ts +++ b/ts/routes/graphs/difficulty.ts @@ -14,6 +14,7 @@ import { bin, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; +import { percentageRangeMinMax } from "./percentageRange"; export interface GraphData { eases: Map; @@ -57,16 +58,6 @@ function getAdjustedScaleAndTicks( ]; } -export function easeQuantile(data: Map, quantile: number) { - let count = sum(data.values()) * quantile; - for (const [key, value] of data.entries()) { - count -= value; - if (count <= 0) { - return key; - } - } -} - export function prepareData( data: GraphData, dispatch: SearchDispatch, @@ -78,8 +69,7 @@ export function prepareData( if (!allEases.size) { return [null, []]; } - const xMin = quantile ? easeQuantile(allEases, 1 - quantile) ?? 0 : 0; - const xMax = quantile ? easeQuantile(allEases, quantile) ?? 0 : 100; + const [xMin, xMax] = percentageRangeMinMax(allEases, quantile); const desiredBars = 20; const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars); diff --git a/ts/routes/graphs/percentageRange.ts b/ts/routes/graphs/percentageRange.ts index 38ebc0dfd..060fa15c0 100644 --- a/ts/routes/graphs/percentageRange.ts +++ b/ts/routes/graphs/percentageRange.ts @@ -1,4 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors + +import { sum } from "d3"; + // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export enum PercentageRangeEnum { All = 0, @@ -15,3 +18,20 @@ export function PercentageRangeToQuantile(range: PercentageRangeEnum) { [PercentageRangeEnum.All]: undefined, })[range]; } + +export function easeQuantile(data: Map, quantile: number) { + let count = sum(data.values()) * quantile; + for (const [key, value] of data.entries()) { + count -= value; + if (count <= 0) { + return key; + } + } +} + +export function percentageRangeMinMax(data: Map, range: number | undefined) { + const xMin = range ? easeQuantile(data, 1 - range) ?? 0 : 0; + const xMax = range ? easeQuantile(data, range) ?? 0 : 100; + + return [xMin, xMax]; +} diff --git a/ts/routes/graphs/retrievability.ts b/ts/routes/graphs/retrievability.ts index dd54c2b8d..48d7ad6c9 100644 --- a/ts/routes/graphs/retrievability.ts +++ b/ts/routes/graphs/retrievability.ts @@ -14,6 +14,7 @@ import { bin, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; +import { percentageRangeMinMax } from "./percentageRange"; export interface GraphData { retrievability: Map; @@ -68,14 +69,14 @@ export function prepareData( data: GraphData, dispatch: SearchDispatch, browserLinksSupported: boolean, + quantile?: number, ): [HistogramData | null, TableDatum[]] { // get min/max const allEases = data.retrievability; if (!allEases.size) { return [null, []]; } - const xMin = 0; - const xMax = 100; + const [xMin, xMax] = percentageRangeMinMax(allEases, quantile); const desiredBars = 20; const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars); From 2d790920d389b33dd3f5e92bf2e01700d2cad8f7 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Sat, 8 Nov 2025 15:46:22 +0000 Subject: [PATCH 04/12] Fix: Typo --- ts/routes/graphs/PercentageRange.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/routes/graphs/PercentageRange.svelte b/ts/routes/graphs/PercentageRange.svelte index 250d45d99..7f65bcec6 100644 --- a/ts/routes/graphs/PercentageRange.svelte +++ b/ts/routes/graphs/PercentageRange.svelte @@ -15,7 +15,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 50% From 4c8b150353241a0613c09182fd1c325efbbb7d12 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Sat, 8 Nov 2025 15:50:08 +0000 Subject: [PATCH 05/12] Fix: Half the percentiles --- ts/routes/graphs/percentageRange.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ts/routes/graphs/percentageRange.ts b/ts/routes/graphs/percentageRange.ts index 060fa15c0..2ef785810 100644 --- a/ts/routes/graphs/percentageRange.ts +++ b/ts/routes/graphs/percentageRange.ts @@ -11,10 +11,11 @@ export enum PercentageRangeEnum { } export function PercentageRangeToQuantile(range: PercentageRangeEnum) { + // These are halved because the quantiles are in both directions return ({ [PercentageRangeEnum.Percentile100]: 1, - [PercentageRangeEnum.Percentile95]: 0.95, - [PercentageRangeEnum.Percentile50]: 0.5, + [PercentageRangeEnum.Percentile95]: 0.975, + [PercentageRangeEnum.Percentile50]: 0.75, [PercentageRangeEnum.All]: undefined, })[range]; } From 372d15578fe6e4c547f6207ab45fda76990dc830 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Sat, 8 Nov 2025 15:52:30 +0000 Subject: [PATCH 06/12] Remove 50% option --- ts/routes/graphs/PercentageRange.svelte | 8 -------- ts/routes/graphs/percentageRange.ts | 5 +---- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/ts/routes/graphs/PercentageRange.svelte b/ts/routes/graphs/PercentageRange.svelte index 7f65bcec6..0b9514675 100644 --- a/ts/routes/graphs/PercentageRange.svelte +++ b/ts/routes/graphs/PercentageRange.svelte @@ -11,14 +11,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -