From d2c48745717688dd660e50fd64ad99e304173192 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 26 Jun 2020 19:25:02 +1000 Subject: [PATCH] add future due / "forecast" graph --- ts/src/stats/AddedGraph.svelte | 4 +- ts/src/stats/FutureDue.svelte | 62 ++++++++++++++++ ts/src/stats/GraphsPage.svelte | 4 +- ts/src/stats/IntervalsGraph.svelte | 7 +- ts/src/stats/added.ts | 5 +- ts/src/stats/future-due.ts | 114 +++++++++++++++++++++++++++++ ts/src/stats/graphs.css | 2 +- ts/src/stats/histogram-graph.ts | 11 ++- 8 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 ts/src/stats/FutureDue.svelte create mode 100644 ts/src/stats/future-due.ts diff --git a/ts/src/stats/AddedGraph.svelte b/ts/src/stats/AddedGraph.svelte index 9b8b222c3..2b061ce26 100644 --- a/ts/src/stats/AddedGraph.svelte +++ b/ts/src/stats/AddedGraph.svelte @@ -1,6 +1,6 @@ diff --git a/ts/src/stats/FutureDue.svelte b/ts/src/stats/FutureDue.svelte new file mode 100644 index 000000000..aa3744475 --- /dev/null +++ b/ts/src/stats/FutureDue.svelte @@ -0,0 +1,62 @@ + + +{#if histogramData} + +
+

Future Due

+ +
+ + + + +
+ + + +
+{/if} diff --git a/ts/src/stats/GraphsPage.svelte b/ts/src/stats/GraphsPage.svelte index e935fe545..ed6379bda 100644 --- a/ts/src/stats/GraphsPage.svelte +++ b/ts/src/stats/GraphsPage.svelte @@ -15,6 +15,7 @@ import ButtonsGraph from "./ButtonsGraph.svelte"; import CardCounts from "./CardCounts.svelte"; import HourGraph from "./HourGraph.svelte"; + import FutureDue from "./FutureDue.svelte"; let sourceData: pb.BackendProto.GraphsOut | null = null; @@ -121,10 +122,11 @@
+ - + diff --git a/ts/src/stats/IntervalsGraph.svelte b/ts/src/stats/IntervalsGraph.svelte index e6ef075d2..40b39893c 100644 --- a/ts/src/stats/IntervalsGraph.svelte +++ b/ts/src/stats/IntervalsGraph.svelte @@ -11,11 +11,12 @@ export let sourceData: pb.BackendProto.GraphsOut | null = null; - let svg = null as HTMLElement | SVGElement | null; - let range = IntervalRange.Percentile95; + let intervalData: IntervalGraphData | null = 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) { console.log("gathering data"); intervalData = gatherIntervalData(sourceData); diff --git a/ts/src/stats/added.ts b/ts/src/stats/added.ts index 2dc2e54cb..111b56b65 100644 --- a/ts/src/stats/added.ts +++ b/ts/src/stats/added.ts @@ -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 const total = data.daysAdded.length; if (!total) { diff --git a/ts/src/stats/future-due.ts b/ts/src/stats/future-due.ts new file mode 100644 index 000000000..0ea76177f --- /dev/null +++ b/ts/src/stats/future-due.ts @@ -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; +} + +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, 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}. ` + + `
${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(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, + }; +} diff --git a/ts/src/stats/graphs.css b/ts/src/stats/graphs.css index 4fa20c420..55db1b93e 100644 --- a/ts/src/stats/graphs.css +++ b/ts/src/stats/graphs.css @@ -105,6 +105,6 @@ } .spin.active { - opacity: 1; + opacity: 0.5; transition: opacity 1s; } diff --git a/ts/src/stats/histogram-graph.ts b/ts/src/stats/histogram-graph.ts index bcdfb5e95..118c58e98 100644 --- a/ts/src/stats/histogram-graph.ts +++ b/ts/src/stats/histogram-graph.ts @@ -27,6 +27,7 @@ export interface HistogramData { ) => string; showArea: boolean; colourScale: ScaleSequential; + binValue?: (bin: Bin) => number; } export function histogramGraph( @@ -34,6 +35,8 @@ export function histogramGraph( bounds: GraphBounds, data: HistogramData ): void { + const binValue = data.binValue ?? ((bin: any) => bin.length as number); + const svg = select(svgElem); const trans = svg.transition().duration(600) as any; @@ -44,7 +47,7 @@ export function histogramGraph( // y scale - const yMax = max(data.bins, (d) => d.length)!; + const yMax = max(data.bins, (d) => binValue(d))!; const y = scaleLinear() .range([bounds.height - bounds.marginBottom, bounds.marginTop]) .domain([0, yMax]); @@ -68,8 +71,8 @@ export function histogramGraph( .attr("width", barWidth) .transition(trans) .attr("x", (d: any) => x(d.x0)) - .attr("y", (d: any) => y(d.length)!) - .attr("height", (d: any) => y(0) - y(d.length)) + .attr("y", (d: any) => y(binValue(d))!) + .attr("height", (d: any) => y(0) - y(binValue(d))) .attr("fill", (d) => data.colourScale(d.x1)); }; @@ -94,7 +97,7 @@ export function histogramGraph( // cumulative area - const areaCounts = data.bins.map((d) => d.length); + const areaCounts = data.bins.map((d) => binValue(d)); areaCounts.unshift(0); const areaData = cumsum(areaCounts); const yAreaScale = y.copy().domain([0, data.total]);