add future due / "forecast" graph

This commit is contained in:
Damien Elmes 2020-06-26 19:25:02 +10:00
parent 194a512820
commit d2c4874571
8 changed files with 197 additions and 12 deletions

View file

@ -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>

View 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}

View file

@ -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} />

View file

@ -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);

View file

@ -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
View 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,
};
}

View file

@ -105,6 +105,6 @@
} }
.spin.active { .spin.active {
opacity: 1; opacity: 0.5;
transition: opacity 1s; transition: opacity 1s;
} }

View file

@ -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]);