// 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", @typescript-eslint/ban-ts-ignore: "off", @typescript-eslint/explicit-function-return-type: "off" */ import { select, mouse, Selection } from "d3-selection"; import { cumsum, extent, max, histogram, quantile } from "d3-array"; import { interpolateBlues } from "d3-scale-chromatic"; import { scaleLinear, scaleSequential } from "d3-scale"; import { axisBottom, axisLeft } from "d3-axis"; import { area } from "d3-shape"; import "d3-transition"; import { CardQueue } from "../cards"; import { showTooltip, hideTooltip } from "./tooltip"; import pb from "../backend/proto"; import { assertUnreachable } from "../typing"; export interface IntervalGraphData { intervals: number[]; } export enum IntervalRange { Month = 0, Percentile50 = 1, Percentile95 = 2, Percentile999 = 3, All = 4, } export function gatherIntervalData(data: pb.BackendProto.GraphsOut): IntervalGraphData { const intervals = (data.cards2 as pb.BackendProto.Card[]) .filter((c) => c.queue == CardQueue.Review) .map((c) => c.ivl); return { intervals }; } export type IntervalUpdateFn = ( arg0: IntervalGraphData, maxDays: IntervalRange ) => void; /// Creates an interval graph, returning a function used to update it. export function intervalGraph(svgElem: SVGElement): IntervalUpdateFn { const margin = { top: 20, right: 20, bottom: 40, left: 100 }; const height = 250; const width = 600; const xTicks = 6; // svg elements const svg = select(svgElem).attr("viewBox", [0, 0, width, height].join(" ")); const barGroup = svg.append("g"); const hoverGroup = svg.append("g"); const areaPath = svg.select("path.area"); const xAxisGroup = svg.append("g").classed("no-domain-line", true); const yAxisGroup = svg.append("g").classed("no-domain-line", true); // x axis const xScale = scaleLinear() .range([margin.left, width - margin.right]) .domain([0, 0]); svg.append("text") .attr("transform", `translate(${width / 2}, ${height - 5})`) .style("text-anchor", "middle") .style("font-size", 10) .text("Interval (days)"); // y axis const yScale = scaleLinear() .domain([0, 0]) .range([height - margin.bottom, margin.top]); svg.append("text") .attr( "transform", `translate(${margin.left / 3}, ${ (height - margin.bottom) / 2 + margin.top }) rotate(-180)` ) .style("text-anchor", "middle") .style("writing-mode", "vertical-rl") .style("rotate", "180") .style("font-size", 10) .text("Number of cards"); function update(graphData: IntervalGraphData, maxDays: IntervalRange) { const allIntervals = graphData.intervals; const [xMin, origXMax] = extent(allIntervals); let desiredBars = 70; let xMax = origXMax; switch (maxDays) { case IntervalRange.Month: xMax = Math.min(xMax!, 31); desiredBars = 31; break; case IntervalRange.Percentile50: xMax = quantile(allIntervals, 0.5); break; case IntervalRange.Percentile95: xMax = quantile(allIntervals, 0.95); break; case IntervalRange.Percentile999: xMax = quantile(allIntervals, 0.999); break; case IntervalRange.All: break; default: assertUnreachable(maxDays); } const x = xScale.copy().domain([xMin!, xMax!]); // .nice(); const data = histogram() .domain(x.domain() as any) .thresholds(x.ticks(desiredBars))(allIntervals); const yMax = max(data, (d) => d.length); const colourScale = scaleSequential(interpolateBlues).domain([ -20, data.length, ]); const y = yScale.copy().domain([0, yMax!]); const t = svg.transition().duration(600); const updateXAxis = ( g: Selection, scale: any ) => g .attr("transform", `translate(0,${height - margin.bottom})`) .call(axisBottom(scale).ticks(xTicks).tickSizeOuter(0)); xAxisGroup.transition(t as any).call(updateXAxis as any, x); const updateYAxis = ( g: Selection, scale: any ) => g.attr("transform", `translate(${margin.left}, 0)`).call( axisLeft(scale) .ticks(height / 80) .tickSizeOuter(0) ); yAxisGroup.transition(t as any).call(updateYAxis as any, y); function barWidth(d: any): number { const width = Math.max(0, x(d.x1) - x(d.x0) - 1); return width ? width : 0; } const updateBar = (sel: any) => { return sel.call((sel) => sel .transition(t as any) .attr("width", barWidth) .attr("x", (d: any) => x(d.x0)) .attr("y", (d: any) => y(d.length)!) .attr("height", (d: any) => y(0) - y(d.length)) .attr("fill", (d, idx) => colourScale(idx)) ); }; barGroup .selectAll("rect") .data(data) .join( (enter) => updateBar( enter .append("rect") .attr("rx", 1) .attr("x", (d: any) => x(d.x0)) .attr("y", y(0)) .attr("height", 0) ), (update) => updateBar(update), (remove) => remove.call((remove) => remove .transition(t as any) .attr("height", 0) .attr("y", y(0)) ) ); const areaData = cumsum(data.map((d) => d.length)); const xAreaScale = x.copy().domain([0, areaData.length]); const yAreaScale = y.copy().domain([0, allIntervals.length]); areaPath .datum(areaData as any) .attr("fill", "grey") .attr( "d", area() .x((d: any, idx) => { return xAreaScale(idx); }) .y0(height - margin.bottom) .y1((d: any) => yAreaScale(d)) as any ); hoverGroup .selectAll("rect") .data(data) .join("rect") .attr("x", (d: any) => x(d.x0)) .attr("y", () => y(yMax!)) .attr("width", barWidth) .attr("height", () => y(0) - y(yMax!)) .attr("fill", "none") .attr("pointer-events", "all") .on("mousemove", function (this: any, d: any, idx) { const [x, y] = mouse(document.body); const pct = ((areaData[idx] / allIntervals.length) * 100).toFixed(2); showTooltip( `${d.length} cards with interval ${d.x0}~${d.x1} days. ` + `
${pct}% cards below this point.`, x, y ); }) .on("mouseout", hideTooltip); } return update; }