diff --git a/ts/src/stats/histogram-graph.ts b/ts/src/stats/histogram-graph.ts new file mode 100644 index 000000000..e6919f604 --- /dev/null +++ b/ts/src/stats/histogram-graph.ts @@ -0,0 +1,134 @@ +// 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 "d3-transition"; +import { select, mouse } from "d3-selection"; +import { cumsum, max, Bin } from "d3-array"; +import { interpolateBlues } from "d3-scale-chromatic"; +import { scaleLinear, scaleSequential, ScaleLinear } from "d3-scale"; +import { axisBottom, axisLeft } from "d3-axis"; +import { area } from "d3-shape"; +import { showTooltip, hideTooltip } from "./tooltip"; +import { GraphBounds } from "./graphs"; + +export interface HistogramData { + scale: ScaleLinear; + bins: Bin[]; + total: number; +} + +export function histogramGraph( + svgElem: SVGElement, + bounds: GraphBounds, + data: HistogramData +): void { + const svg = select(svgElem); + const trans = svg.transition().duration(600) as any; + + const x = data.scale.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(data.bins, (d) => d.length)!; + 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 colour = scaleSequential(interpolateBlues).domain([-5, data.bins.length]); + + const updateBar = (sel: any): any => { + return sel + .transition(trans) + .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) => colour(idx)); + }; + + svg.select("g.bars") + .selectAll("rect") + .data(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(updateBar), + (update) => update.call(updateBar), + (remove) => + remove.call((remove) => + remove.transition(trans).attr("height", 0).attr("y", y(0)) + ) + ); + + // cumulative area + + const areaCounts = data.bins.map((d) => d.length); + areaCounts.unshift(0); + const areaData = cumsum(areaCounts); + const yAreaScale = y.copy().domain([0, data.total]); + + svg.select("path.area") + .datum(areaData as any) + .attr( + "d", + area() + .x((d, idx) => { + if (idx === 0) { + return x(data.bins[0].x0!); + } else { + return x(data.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(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); + const pct = ((areaData[idx] / data.total) * 100).toFixed(2); + showTooltip( + `${d.length} cards with interval ${d.x0}~${d.x1} days. ` + + `
${pct}% cards below this point.`, + x, + y + ); + }) + .on("mouseout", hideTooltip); +} diff --git a/ts/src/stats/intervals.ts b/ts/src/stats/intervals.ts index 7470b9843..24ed88a74 100644 --- a/ts/src/stats/intervals.ts +++ b/ts/src/stats/intervals.ts @@ -6,16 +6,11 @@ @typescript-eslint/no-explicit-any: "off", */ -import "d3-transition"; import pb from "../backend/proto"; -import { select, mouse } 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 { extent, histogram, quantile } from "d3-array"; +import { scaleLinear } from "d3-scale"; import { CardQueue } from "../cards"; -import { showTooltip, hideTooltip } from "./tooltip"; +import { HistogramData, histogramGraph } from "./histogram-graph"; import { GraphBounds } from "./graphs"; export interface IntervalGraphData { @@ -37,20 +32,18 @@ export function gatherIntervalData(data: pb.BackendProto.GraphsOut): IntervalGra return { intervals }; } -export function intervalGraph( - svgElem: SVGElement, - bounds: GraphBounds, - graphData: IntervalGraphData, - maxDays: IntervalRange -): void { - const svg = select(svgElem); - const trans = svg.transition().duration(600) as any; - - const allIntervals = graphData.intervals; - +function prepareIntervalData( + data: IntervalGraphData, + range: IntervalRange +): HistogramData { + // get min/max + const allIntervals = data.intervals; + const total = allIntervals.length; const [xMin, origXMax] = extent(allIntervals); let xMax = origXMax; - switch (maxDays) { + + // cap max to selected range + switch (range) { case IntervalRange.Month: xMax = Math.min(xMax!, 31); break; @@ -66,111 +59,24 @@ export function intervalGraph( case IntervalRange.All: break; } + + // cap bars to available range const desiredBars = Math.min(70, xMax! - xMin!); - // x scale & bins + const scale = scaleLinear().domain([xMin!, xMax!]); + const bins = histogram() + .domain(scale.domain() as any) + .thresholds(scale.ticks(desiredBars))(allIntervals); - const x = scaleLinear() - .range([bounds.marginLeft, bounds.width - bounds.marginRight]) - .domain([xMin!, xMax!]); - const data = histogram() - .domain(x.domain() as any) - .thresholds(x.ticks(desiredBars))(allIntervals); - svg.select(".x-ticks") - .transition(trans) - .call(axisBottom(x).ticks(6).tickSizeOuter(0)); - - // y scale - - const yMax = max(data, (d) => d.length); - 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 colour = scaleSequential(interpolateBlues).domain([-5, data.length]); - - const updateBar = (sel: any): any => { - return sel - .transition(trans) - .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) => colour(idx)); - }; - - svg.select("g.bars") - .selectAll("rect") - .data(data) - .join( - (enter) => - enter - .append("rect") - .attr("rx", 1) - .attr("x", (d: any) => x(d.x0)) - .attr("y", y(0)) - .attr("height", 0) - .call(updateBar), - (update) => update.call(updateBar), - (remove) => - remove.call((remove) => - remove.transition(trans).attr("height", 0).attr("y", y(0)) - ) - ); - - // cumulative area - - const areaCounts = data.map((d) => d.length); - areaCounts.unshift(0); - const areaData = cumsum(areaCounts); - const xAreaScale = x.copy().domain([0, areaData.length - 1]); - const yAreaScale = y.copy().domain([0, allIntervals.length]); - - svg.select("path.area") - .datum(areaData as any) - .attr( - "d", - area() - .x((d, idx) => { - return xAreaScale(idx); - }) - .y0(bounds.height - bounds.marginBottom) - .y1((d: any) => yAreaScale(d)) as any - ); - - // hover/tooltip - - svg.select("g.hoverzone") - .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!)) - .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 { scale, bins, total }; +} + +export function intervalGraph( + svgElem: SVGElement, + bounds: GraphBounds, + data: IntervalGraphData, + range: IntervalRange +): void { + const histogram = prepareIntervalData(data, range); + histogramGraph(svgElem, bounds, histogram); }