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..fecb94edc 100644 --- a/ts/routes/graphs/DifficultyGraph.svelte +++ b/ts/routes/graphs/DifficultyGraph.svelte @@ -14,6 +14,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { HistogramData } from "./histogram-graph"; import HistogramGraph from "./HistogramGraph.svelte"; 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/PercentageRange.svelte b/ts/routes/graphs/PercentageRange.svelte new file mode 100644 index 000000000..7f65bcec6 --- /dev/null +++ b/ts/routes/graphs/PercentageRange.svelte @@ -0,0 +1,42 @@ + + + + + + + + + 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 1778e2513..4f36ecdcf 100644 --- a/ts/routes/graphs/difficulty.ts +++ b/ts/routes/graphs/difficulty.ts @@ -8,12 +8,13 @@ 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 type { Bin } from "d3"; +import { bin, interpolateRdYlGn, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; +import { getAdjustedScaleAndTicks, percentageRangeMinMax } from "./percentageRange"; export interface GraphData { eases: Map; @@ -33,42 +34,18 @@ function makeQuery(start: number, end: number): string { return `${fromQuery} AND ${tillQuery}`; } -function getAdjustedScaleAndTicks( - min: number, - max: number, - desiredBars: number, -): [ScaleLinear, number[]] { - const prescale = scaleLinear().domain([min, max]).nice(); - const ticks = prescale.ticks(desiredBars); - - const predomain = prescale.domain() as [number, number]; - - const minOffset = min - predomain[0]; - const tickSize = ticks[1] - ticks[0]; - - if (minOffset === 0 || (minOffset % tickSize !== 0 && tickSize % minOffset !== 0)) { - return [prescale, ticks]; - } - - const add = (n: number): number => n + minOffset; - return [ - scaleLinear().domain(predomain.map(add) as [number, number]), - ticks.map(add), - ]; -} - export function prepareData( data: GraphData, dispatch: SearchDispatch, browserLinksSupported: boolean, + quantile?: number, ): [HistogramData | null, TableDatum[]] { // get min/max const allEases = data.eases; 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); diff --git a/ts/routes/graphs/percentageRange.ts b/ts/routes/graphs/percentageRange.ts new file mode 100644 index 000000000..6094ef2e0 --- /dev/null +++ b/ts/routes/graphs/percentageRange.ts @@ -0,0 +1,68 @@ +// Copyright: Ankitects Pty Ltd and contributors + +import { range, type ScaleLinear, scaleLinear, sum } from "d3"; + +// 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) { + // These are halved because the quantiles are in both directions + return ({ + [PercentageRangeEnum.Percentile100]: 1, + [PercentageRangeEnum.Percentile95]: 0.975, + [PercentageRangeEnum.Percentile50]: 0.75, + [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]; +} + +export function getAdjustedScaleAndTicks( + min: number, + max: number, + desiredBars: number, +): [ScaleLinear, number[]] { + const prescale = scaleLinear().domain([min, max]).nice(); + let ticks = prescale.ticks(desiredBars); + + const predomain = prescale.domain() as [number, number]; + + const minOffset = min - predomain[0]; + let tickSize = ticks[1] - ticks[0]; + + const minBinSize = 1; + if (tickSize < minBinSize) { + ticks = range(min, max, minBinSize); + tickSize = minBinSize; + } + + if (minOffset === 0 || (minOffset % tickSize !== 0 && tickSize % minOffset !== 0)) { + return [prescale, ticks]; + } + + const add = (n: number): number => n + minOffset; + return [ + scaleLinear().domain(predomain.map(add) as [number, number]), + ticks.map(add), + ]; +} diff --git a/ts/routes/graphs/retrievability.ts b/ts/routes/graphs/retrievability.ts index dd54c2b8d..6a1a391d2 100644 --- a/ts/routes/graphs/retrievability.ts +++ b/ts/routes/graphs/retrievability.ts @@ -8,12 +8,13 @@ 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 type { Bin } from "d3"; +import { bin, interpolateRdYlGn, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; import type { HistogramData } from "./histogram-graph"; +import { getAdjustedScaleAndTicks, percentageRangeMinMax } from "./percentageRange"; export interface GraphData { retrievability: Map; @@ -40,42 +41,18 @@ function makeQuery(start: number, end: number): string { return `${fromQuery} AND ${tillQuery}`; } -function getAdjustedScaleAndTicks( - min: number, - max: number, - desiredBars: number, -): [ScaleLinear, number[]] { - const prescale = scaleLinear().domain([min, max]).nice(); - const ticks = prescale.ticks(desiredBars); - - const predomain = prescale.domain() as [number, number]; - - const minOffset = min - predomain[0]; - const tickSize = ticks[1] - ticks[0]; - - if (minOffset === 0 || (minOffset % tickSize !== 0 && tickSize % minOffset !== 0)) { - return [prescale, ticks]; - } - - const add = (n: number): number => n + minOffset; - return [ - scaleLinear().domain(predomain.map(add) as [number, number]), - ticks.map(add), - ]; -} - 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);