mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
add future due / "forecast" graph
This commit is contained in:
parent
194a512820
commit
d2c4874571
8 changed files with 197 additions and 12 deletions
|
@ -1,6 +1,6 @@
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import { HistogramData } from "./histogram-graph";
|
import { HistogramData } from "./histogram-graph";
|
||||||
import { gatherData, prepareData, GraphData, AddedRange } from "./added";
|
import { gatherData, buildHistogram, GraphData, AddedRange } from "./added";
|
||||||
import pb from "../backend/proto";
|
import pb from "../backend/proto";
|
||||||
import HistogramGraph from "./HistogramGraph.svelte";
|
import HistogramGraph from "./HistogramGraph.svelte";
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
$: if (addedData) {
|
$: if (addedData) {
|
||||||
console.log("preparing data");
|
console.log("preparing data");
|
||||||
histogramData = prepareData(addedData, range);
|
histogramData = buildHistogram(addedData, range);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
62
ts/src/stats/FutureDue.svelte
Normal file
62
ts/src/stats/FutureDue.svelte
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
<script lang="typescript">
|
||||||
|
import { HistogramData } from "./histogram-graph";
|
||||||
|
import { defaultGraphBounds } from "./graphs";
|
||||||
|
import {
|
||||||
|
gatherData,
|
||||||
|
renderFutureDue,
|
||||||
|
GraphData,
|
||||||
|
FutureDueRange,
|
||||||
|
buildHistogram,
|
||||||
|
} from "./future-due";
|
||||||
|
import pb from "../backend/proto";
|
||||||
|
import HistogramGraph from "./HistogramGraph.svelte";
|
||||||
|
|
||||||
|
export let sourceData: pb.BackendProto.GraphsOut | null = null;
|
||||||
|
let graphData = null as GraphData | null;
|
||||||
|
let histogramData = null as HistogramData | null;
|
||||||
|
|
||||||
|
let svg = null as HTMLElement | SVGElement | null;
|
||||||
|
let range = FutureDueRange.Month;
|
||||||
|
|
||||||
|
$: if (sourceData) {
|
||||||
|
console.log("gathering data");
|
||||||
|
graphData = gatherData(sourceData);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (graphData) {
|
||||||
|
console.log("preparing data");
|
||||||
|
histogramData = buildHistogram(graphData, range);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if histogramData}
|
||||||
|
|
||||||
|
<div class="graph">
|
||||||
|
<h1>Future Due</h1>
|
||||||
|
|
||||||
|
<div class="range-box-inner">
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={range} value={FutureDueRange.Month} />
|
||||||
|
Month
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={range} value={FutureDueRange.Quarter} />
|
||||||
|
3 months
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={range} value={FutureDueRange.Year} />
|
||||||
|
Year
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={range} value={FutureDueRange.AllTime} />
|
||||||
|
All time
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HistogramGraph
|
||||||
|
data={histogramData}
|
||||||
|
xText="Days from now"
|
||||||
|
yText="Number of cards" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
|
@ -15,6 +15,7 @@
|
||||||
import ButtonsGraph from "./ButtonsGraph.svelte";
|
import ButtonsGraph from "./ButtonsGraph.svelte";
|
||||||
import CardCounts from "./CardCounts.svelte";
|
import CardCounts from "./CardCounts.svelte";
|
||||||
import HourGraph from "./HourGraph.svelte";
|
import HourGraph from "./HourGraph.svelte";
|
||||||
|
import FutureDue from "./FutureDue.svelte";
|
||||||
|
|
||||||
let sourceData: pb.BackendProto.GraphsOut | null = null;
|
let sourceData: pb.BackendProto.GraphsOut | null = null;
|
||||||
|
|
||||||
|
@ -121,10 +122,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="range-box-pad" />
|
<div class="range-box-pad" />
|
||||||
|
|
||||||
|
<FutureDue {sourceData} />
|
||||||
<TodayStats {sourceData} />
|
<TodayStats {sourceData} />
|
||||||
<CardCounts {sourceData} />
|
<CardCounts {sourceData} />
|
||||||
<AddedGraph {sourceData} />
|
|
||||||
<IntervalsGraph {sourceData} />
|
<IntervalsGraph {sourceData} />
|
||||||
<EaseGraph {sourceData} />
|
<EaseGraph {sourceData} />
|
||||||
<ButtonsGraph {sourceData} />
|
<ButtonsGraph {sourceData} />
|
||||||
<HourGraph {sourceData} />
|
<HourGraph {sourceData} />
|
||||||
|
<AddedGraph {sourceData} />
|
||||||
|
|
|
@ -11,11 +11,12 @@
|
||||||
|
|
||||||
export let sourceData: pb.BackendProto.GraphsOut | null = null;
|
export let sourceData: pb.BackendProto.GraphsOut | null = null;
|
||||||
|
|
||||||
let svg = null as HTMLElement | SVGElement | null;
|
let intervalData: IntervalGraphData | null = null;
|
||||||
let range = IntervalRange.Percentile95;
|
|
||||||
let histogramData = null as HistogramData | null;
|
let histogramData = null as HistogramData | null;
|
||||||
|
|
||||||
let intervalData: IntervalGraphData | null = null;
|
let svg = null as HTMLElement | SVGElement | null;
|
||||||
|
let range = IntervalRange.Percentile95;
|
||||||
|
|
||||||
$: if (sourceData) {
|
$: if (sourceData) {
|
||||||
console.log("gathering data");
|
console.log("gathering data");
|
||||||
intervalData = gatherIntervalData(sourceData);
|
intervalData = gatherIntervalData(sourceData);
|
||||||
|
|
|
@ -44,7 +44,10 @@ function hoverText(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareData(data: GraphData, range: AddedRange): HistogramData | null {
|
export function buildHistogram(
|
||||||
|
data: GraphData,
|
||||||
|
range: AddedRange
|
||||||
|
): HistogramData | null {
|
||||||
// get min/max
|
// get min/max
|
||||||
const total = data.daysAdded.length;
|
const total = data.daysAdded.length;
|
||||||
if (!total) {
|
if (!total) {
|
||||||
|
|
114
ts/src/stats/future-due.ts
Normal file
114
ts/src/stats/future-due.ts
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
/* eslint
|
||||||
|
@typescript-eslint/no-non-null-assertion: "off",
|
||||||
|
@typescript-eslint/no-explicit-any: "off",
|
||||||
|
*/
|
||||||
|
|
||||||
|
import pb from "../backend/proto";
|
||||||
|
import { extent, histogram, rollup, max, sum, Bin } from "d3-array";
|
||||||
|
import { scaleLinear, scaleSequential } from "d3-scale";
|
||||||
|
import { CardQueue } from "../cards";
|
||||||
|
import { HistogramData } from "./histogram-graph";
|
||||||
|
import { interpolateGreens } from "d3-scale-chromatic";
|
||||||
|
|
||||||
|
export interface GraphData {
|
||||||
|
dueCounts: Map<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FutureDueRange {
|
||||||
|
Month = 0,
|
||||||
|
Quarter = 1,
|
||||||
|
Year = 2,
|
||||||
|
AllTime = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gatherData(data: pb.BackendProto.GraphsOut): GraphData {
|
||||||
|
const due = (data.cards as pb.BackendProto.Card[])
|
||||||
|
.filter((c) => c.queue == CardQueue.Review) // && c.due >= data.daysElapsed)
|
||||||
|
.map((c) => c.due - data.daysElapsed);
|
||||||
|
const dueCounts = rollup(
|
||||||
|
due,
|
||||||
|
(v) => v.length,
|
||||||
|
(d) => d
|
||||||
|
);
|
||||||
|
return { dueCounts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function binValue(d: Bin<Map<number, number>, number>): number {
|
||||||
|
return sum(d, (d) => d[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoverText(
|
||||||
|
data: HistogramData,
|
||||||
|
binIdx: number,
|
||||||
|
cumulative: number,
|
||||||
|
_percent: number
|
||||||
|
): string {
|
||||||
|
const bin = data.bins[binIdx];
|
||||||
|
const interval =
|
||||||
|
bin.x1! - bin.x0! === 1 ? `${bin.x0} days` : `${bin.x0}~${bin.x1} days`;
|
||||||
|
return (
|
||||||
|
`${binValue(data.bins[binIdx] as any)} cards due in ${interval}. ` +
|
||||||
|
`<br>${cumulative} cards at or before this point.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHistogram(
|
||||||
|
sourceData: GraphData,
|
||||||
|
range: FutureDueRange
|
||||||
|
): HistogramData | null {
|
||||||
|
// get min/max
|
||||||
|
const data = sourceData.dueCounts;
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [xMinOrig, origXMax] = extent<number>(data.keys());
|
||||||
|
const xMin = 0;
|
||||||
|
let xMax = origXMax;
|
||||||
|
|
||||||
|
// cap max to selected range
|
||||||
|
switch (range) {
|
||||||
|
case FutureDueRange.Month:
|
||||||
|
xMax = 31;
|
||||||
|
break;
|
||||||
|
case FutureDueRange.Quarter:
|
||||||
|
xMax = 90;
|
||||||
|
break;
|
||||||
|
case FutureDueRange.Year:
|
||||||
|
xMax = 365;
|
||||||
|
break;
|
||||||
|
case FutureDueRange.AllTime:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
xMax = xMax! + 1;
|
||||||
|
|
||||||
|
// cap bars to available range
|
||||||
|
const desiredBars = Math.min(70, xMax! - xMin!);
|
||||||
|
|
||||||
|
const x = scaleLinear().domain([xMin!, xMax!]).nice();
|
||||||
|
const bins = histogram()
|
||||||
|
.value((m) => {
|
||||||
|
return m[0];
|
||||||
|
})
|
||||||
|
.domain(x.domain() as any)
|
||||||
|
.thresholds(x.ticks(desiredBars))(data.entries() as any);
|
||||||
|
|
||||||
|
// start slightly darker
|
||||||
|
const shiftedMin = xMin! - Math.round((xMax - xMin!) / 10);
|
||||||
|
const colourScale = scaleSequential(interpolateGreens).domain([shiftedMin, xMax]);
|
||||||
|
|
||||||
|
const total = sum(bins as any, binValue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scale: x,
|
||||||
|
bins,
|
||||||
|
total,
|
||||||
|
hoverText,
|
||||||
|
showArea: true,
|
||||||
|
colourScale,
|
||||||
|
binValue,
|
||||||
|
};
|
||||||
|
}
|
|
@ -105,6 +105,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin.active {
|
.spin.active {
|
||||||
opacity: 1;
|
opacity: 0.5;
|
||||||
transition: opacity 1s;
|
transition: opacity 1s;
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ export interface HistogramData {
|
||||||
) => string;
|
) => string;
|
||||||
showArea: boolean;
|
showArea: boolean;
|
||||||
colourScale: ScaleSequential<string>;
|
colourScale: ScaleSequential<string>;
|
||||||
|
binValue?: (bin: Bin<any, any>) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function histogramGraph(
|
export function histogramGraph(
|
||||||
|
@ -34,6 +35,8 @@ export function histogramGraph(
|
||||||
bounds: GraphBounds,
|
bounds: GraphBounds,
|
||||||
data: HistogramData
|
data: HistogramData
|
||||||
): void {
|
): void {
|
||||||
|
const binValue = data.binValue ?? ((bin: any) => bin.length as number);
|
||||||
|
|
||||||
const svg = select(svgElem);
|
const svg = select(svgElem);
|
||||||
const trans = svg.transition().duration(600) as any;
|
const trans = svg.transition().duration(600) as any;
|
||||||
|
|
||||||
|
@ -44,7 +47,7 @@ export function histogramGraph(
|
||||||
|
|
||||||
// y scale
|
// y scale
|
||||||
|
|
||||||
const yMax = max(data.bins, (d) => d.length)!;
|
const yMax = max(data.bins, (d) => binValue(d))!;
|
||||||
const y = scaleLinear()
|
const y = scaleLinear()
|
||||||
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
|
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
|
||||||
.domain([0, yMax]);
|
.domain([0, yMax]);
|
||||||
|
@ -68,8 +71,8 @@ export function histogramGraph(
|
||||||
.attr("width", barWidth)
|
.attr("width", barWidth)
|
||||||
.transition(trans)
|
.transition(trans)
|
||||||
.attr("x", (d: any) => x(d.x0))
|
.attr("x", (d: any) => x(d.x0))
|
||||||
.attr("y", (d: any) => y(d.length)!)
|
.attr("y", (d: any) => y(binValue(d))!)
|
||||||
.attr("height", (d: any) => y(0) - y(d.length))
|
.attr("height", (d: any) => y(0) - y(binValue(d)))
|
||||||
.attr("fill", (d) => data.colourScale(d.x1));
|
.attr("fill", (d) => data.colourScale(d.x1));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -94,7 +97,7 @@ export function histogramGraph(
|
||||||
|
|
||||||
// cumulative area
|
// cumulative area
|
||||||
|
|
||||||
const areaCounts = data.bins.map((d) => d.length);
|
const areaCounts = data.bins.map((d) => binValue(d));
|
||||||
areaCounts.unshift(0);
|
areaCounts.unshift(0);
|
||||||
const areaData = cumsum(areaCounts);
|
const areaData = cumsum(areaCounts);
|
||||||
const yAreaScale = y.copy().domain([0, data.total]);
|
const yAreaScale = y.copy().domain([0, data.total]);
|
||||||
|
|
Loading…
Reference in a new issue