From 894e824460a384fd6ac5daa72252883950b022b1 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 27 Jun 2020 12:35:13 +1000 Subject: [PATCH] basics of review graph --- ts/src/stats/GraphsPage.svelte | 2 + ts/src/stats/HourGraph.svelte | 2 +- ts/src/stats/ReviewsGraph.svelte | 68 ++++++++ ts/src/stats/hours.ts | 1 + ts/src/stats/reviews.ts | 283 +++++++++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 ts/src/stats/ReviewsGraph.svelte create mode 100644 ts/src/stats/reviews.ts diff --git a/ts/src/stats/GraphsPage.svelte b/ts/src/stats/GraphsPage.svelte index ed6379bda..d8333ace0 100644 --- a/ts/src/stats/GraphsPage.svelte +++ b/ts/src/stats/GraphsPage.svelte @@ -16,6 +16,7 @@ import CardCounts from "./CardCounts.svelte"; import HourGraph from "./HourGraph.svelte"; import FutureDue from "./FutureDue.svelte"; + import ReviewsGraph from "./ReviewsGraph.svelte"; let sourceData: pb.BackendProto.GraphsOut | null = null; @@ -122,6 +123,7 @@
+ diff --git a/ts/src/stats/HourGraph.svelte b/ts/src/stats/HourGraph.svelte index 83de82174..90d1e764d 100644 --- a/ts/src/stats/HourGraph.svelte +++ b/ts/src/stats/HourGraph.svelte @@ -23,8 +23,8 @@

Hours

- + diff --git a/ts/src/stats/ReviewsGraph.svelte b/ts/src/stats/ReviewsGraph.svelte new file mode 100644 index 000000000..a505843ad --- /dev/null +++ b/ts/src/stats/ReviewsGraph.svelte @@ -0,0 +1,68 @@ + + +
+

Reviews

+ +
+ + + + + + +
+ + + {#each [4, 3, 2, 1, 0] as i} + + {/each} + + + + + + +
diff --git a/ts/src/stats/hours.ts b/ts/src/stats/hours.ts index 818898506..823ee6551 100644 --- a/ts/src/stats/hours.ts +++ b/ts/src/stats/hours.ts @@ -116,6 +116,7 @@ export function renderHours( .attr("x", (d: Hour) => x(d.hour.toString())!) .attr("y", y(0)) .attr("height", 0) + .attr("opacity", 0.7) .call(updateBar), (update) => update.call(updateBar), (remove) => diff --git a/ts/src/stats/reviews.ts b/ts/src/stats/reviews.ts new file mode 100644 index 000000000..1c4ad1b4f --- /dev/null +++ b/ts/src/stats/reviews.ts @@ -0,0 +1,283 @@ +// 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 { + interpolateBlues, + interpolateGreens, + interpolateReds, + interpolateOranges, +} from "d3-scale-chromatic"; +import "d3-transition"; +import { select, mouse } from "d3-selection"; +import { scaleLinear, scaleSequential } from "d3-scale"; +import { axisBottom, axisLeft } from "d3-axis"; +import { showTooltip, hideTooltip } from "./tooltip"; +import { GraphBounds } from "./graphs"; +import { area, curveBasis } from "d3-shape"; +import { min, histogram, sum, max, Bin, cumsum } from "d3-array"; + +interface Reviews { + mature: number; + young: number; + learn: number; + relearn: number; + early: number; +} + +export interface GraphData { + // indexed by day, where day is relative to today + reviewCount: Map; + reviewTime: Map; +} + +export enum ReviewRange { + Month = 0, + Quarter = 1, + Year = 2, + AllTime = 3, +} + +const ReviewKind = pb.BackendProto.RevlogEntry.ReviewKind; +type BinType = Bin, number>; + +export function gatherData(data: pb.BackendProto.GraphsOut): GraphData { + const reviewCount = new Map(); + const reviewTime = new Map(); + const empty = { mature: 0, young: 0, learn: 0, relearn: 0, early: 0 }; + + for (const review of data.revlog as pb.BackendProto.RevlogEntry[]) { + const day = Math.ceil( + ((review.id as number) / 1000 - data.nextDayAtSecs) / 86400 + ); + const countEntry = + reviewCount.get(day) ?? reviewCount.set(day, { ...empty }).get(day)!; + const timeEntry = + reviewTime.get(day) ?? reviewTime.set(day, { ...empty }).get(day)!; + + switch (review.reviewKind) { + case ReviewKind.REVIEW: + if (review.interval < 21) { + countEntry.young += 1; + timeEntry.young += review.takenMillis; + } else { + countEntry.mature += 1; + timeEntry.mature += review.takenMillis; + } + break; + case ReviewKind.LEARNING: + countEntry.learn += 1; + timeEntry.learn += review.takenMillis; + break; + case ReviewKind.RELEARNING: + countEntry.relearn += 1; + timeEntry.relearn += review.takenMillis; + break; + case ReviewKind.EARLY_REVIEW: + countEntry.early += 1; + timeEntry.early += review.takenMillis; + break; + } + } + + return { reviewCount, reviewTime }; +} + +function totalsForBin(bin: BinType): number[] { + const total = [0, 0, 0, 0, 0]; + for (const entry of bin) { + total[0] += entry[1].mature; + total[1] += entry[1].young; + total[2] += entry[1].learn; + total[3] += entry[1].relearn; + total[4] += entry[1].early; + } + + return total; +} + +/// eg idx=0 is mature count, idx=1 is mature+young count, etc +function cumulativeBinValue(bin: BinType, idx: number): number { + return sum(totalsForBin(bin).slice(0, idx + 1)); +} + +function tooltipText(d: BinType, cumulative: number): string { + return `bin: ${JSON.stringify(totalsForBin(d))}
cumulative: ${cumulative}`; +} + +export function renderReviews( + svgElem: SVGElement, + bounds: GraphBounds, + sourceData: GraphData, + range: ReviewRange, + showTime: boolean +): void { + console.log(sourceData); + + const xMax = 0; + let xMin = 0; + // cap max to selected range + switch (range) { + case ReviewRange.Month: + xMin = -31; + break; + case ReviewRange.Quarter: + xMin = -90; + break; + case ReviewRange.Year: + xMin = -365; + break; + case ReviewRange.AllTime: + xMin = min(sourceData.reviewCount.keys())!; + break; + } + const desiredBars = Math.min(70, Math.abs(xMin!)); + console.log(`xmin ${xMin}`); + + const x = scaleLinear().domain([xMin!, xMax]); + const sourceMap = showTime ? sourceData.reviewTime : sourceData.reviewCount; + const bins = histogram() + .value((m) => { + return m[0]; + }) + .domain(x.domain() as any) + .thresholds(x.ticks(desiredBars))(sourceMap.entries() as any); + + const svg = select(svgElem); + const trans = svg.transition().duration(600) as any; + + x.range([bounds.marginLeft, bounds.width - bounds.marginRight]); + svg.select(".x-ticks") + .transition(trans) + .call(axisBottom(x).ticks(6).tickSizeOuter(0)); + + // y scale + + const yMax = max(bins, (b: Bin) => cumulativeBinValue(b, 4))!; + console.log(`ymax ${yMax}`); + const y = scaleLinear() + .range([bounds.height - bounds.marginBottom, bounds.marginTop]) + .domain([0, yMax]); + svg.select(".y-ticks") + .transition(trans) + .call( + axisLeft(y) + .ticks(bounds.height / 80) + .tickSizeOuter(0) + ); + + // x bars + + function barWidth(d: any): number { + const width = Math.max(0, x(d.x1) - x(d.x0) - 1); + return width ? width : 0; + } + + const cappedRange = scaleLinear().range([0.2, 0.5]); + const shiftedRange = scaleLinear().range([0.4, 0.7]); + const darkerGreens = scaleSequential((n) => + interpolateGreens(shiftedRange(n)) + ).domain(x.domain() as any); + const lighterGreens = scaleSequential((n) => + interpolateGreens(cappedRange(n)) + ).domain(x.domain() as any); + const blues = scaleSequential((n) => interpolateBlues(cappedRange(n))).domain( + x.domain() as any + ); + const reds = scaleSequential((n) => interpolateReds(cappedRange(n))).domain( + x.domain() as any + ); + const oranges = scaleSequential((n) => interpolateOranges(cappedRange(n))).domain( + x.domain() as any + ); + + const updateBar = (sel: any, idx: number): any => { + return sel + .attr("width", barWidth) + .transition(trans) + .attr("x", (d: any) => x(d.x0)) + .attr("y", (d: any) => y(cumulativeBinValue(d, idx))!) + .attr("height", (d: any) => y(0) - y(cumulativeBinValue(d, idx))) + .attr("fill", (d: any) => { + switch (idx) { + case 0: + return darkerGreens(d.x0); + case 1: + return lighterGreens(d.x0); + case 2: + return blues(d.x0); + case 3: + return reds(d.x0); + case 4: + return oranges(d.x0); + } + }); + }; + + for (const barNum of [0, 1, 2, 3, 4]) { + svg.select(`g.bars${barNum}`) + .selectAll("rect") + .data(bins) + .join( + (enter) => + enter + .append("rect") + .attr("rx", 1) + .attr("x", (d: any) => x(d.x0)) + .attr("y", y(0)) + .attr("height", 0) + .call((d) => updateBar(d, barNum)), + (update) => update.call((d) => updateBar(d, barNum)), + (remove) => + remove.call((remove) => + remove.transition(trans).attr("height", 0).attr("y", y(0)) + ) + ); + } + + // cumulative area + + const areaCounts = bins.map((d: any) => cumulativeBinValue(d, 4)); + areaCounts.unshift(0); + const areaData = cumsum(areaCounts); + const yAreaScale = y.copy().domain([0, areaData.slice(-1)[0]]); + + if (bins.length) { + svg.select("path.area") + .datum(areaData as any) + .attr( + "d", + area() + .curve(curveBasis) + .x((d, idx) => { + if (idx === 0) { + return x(bins[0].x0!); + } else { + return x(bins[idx - 1].x1!); + } + }) + .y0(bounds.height - bounds.marginBottom) + .y1((d: any) => yAreaScale(d)) as any + ); + } + + // // hover/tooltip + svg.select("g.hoverzone") + .selectAll("rect") + .data(bins) + .join("rect") + .attr("x", (d: any) => x(d.x0)) + .attr("y", () => y(yMax!)) + .attr("width", barWidth) + .attr("height", () => y(0) - y(yMax!)) + .on("mousemove", function (this: any, d: any, idx) { + const [x, y] = mouse(document.body); + showTooltip(tooltipText(d, areaData[idx + 1]), x, y); + }) + .on("mouseout", hideTooltip); +}