diff --git a/ts/src/stats/IntervalsGraph.svelte b/ts/src/stats/IntervalsGraph.svelte index 5fedf49a6..90344aca6 100644 --- a/ts/src/stats/IntervalsGraph.svelte +++ b/ts/src/stats/IntervalsGraph.svelte @@ -1,23 +1,16 @@ @@ -60,7 +53,25 @@ - + + + + + + + Interval (days) + + + Number of cards + diff --git a/ts/src/stats/graphs.css b/ts/src/stats/graphs.css index c8e10b34f..02b0f64e9 100644 --- a/ts/src/stats/graphs.css +++ b/ts/src/stats/graphs.css @@ -54,5 +54,20 @@ .intervals .area { opacity: 0.05; pointer-events: none; - stroke: black; + fill: #555; +} + +.axis-label { + text-anchor: middle; + font-size: 10px; +} + +.y-axis-label { + writing-mode: vertical-rl; + rotate: 180; +} + +.hoverzone rect { + fill: none; + pointer-events: all; } diff --git a/ts/src/stats/graphs.ts b/ts/src/stats/graphs.ts index 1166a8ece..eb8e59267 100644 --- a/ts/src/stats/graphs.ts +++ b/ts/src/stats/graphs.ts @@ -39,3 +39,23 @@ export enum GraphRange { Year = 2, All = 3, } + +export interface GraphBounds { + width: number; + height: number; + marginLeft: number; + marginRight: number; + marginTop: number; + marginBottom: number; +} + +export function defaultGraphBounds(): GraphBounds { + return { + width: 600, + height: 250, + marginLeft: 100, + marginRight: 20, + marginTop: 20, + marginBottom: 40, + }; +} diff --git a/ts/src/stats/intervals.ts b/ts/src/stats/intervals.ts index f7a6f15a9..56127f733 100644 --- a/ts/src/stats/intervals.ts +++ b/ts/src/stats/intervals.ts @@ -4,20 +4,19 @@ /* 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 "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 "d3-transition"; import { CardQueue } from "../cards"; import { showTooltip, hideTooltip } from "./tooltip"; -import pb from "../backend/proto"; -import { assertUnreachable } from "../typing"; +import { GraphBounds } from "./graphs"; export interface IntervalGraphData { intervals: number[]; @@ -38,199 +37,138 @@ export function gatherIntervalData(data: pb.BackendProto.GraphsOut): IntervalGra return { intervals }; } -export type IntervalUpdateFn = ( - arg0: IntervalGraphData, +export function intervalGraph( + svgElem: SVGElement, + bounds: GraphBounds, + graphData: IntervalGraphData, maxDays: IntervalRange -) => void; +): void { + const svg = select(svgElem); + const trans = svg.transition().duration(600) as any; -/// 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; + const allIntervals = graphData.intervals; - // 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); + const [xMin, origXMax] = extent(allIntervals); + let xMax = origXMax; + switch (maxDays) { + case IntervalRange.Month: + xMax = Math.min(xMax!, 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; + } + const desiredBars = Math.min(70, xMax! - xMin!); - // 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)"); + // x scale & bins - // 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"); + 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)); - function update(graphData: IntervalGraphData, maxDays: IntervalRange) { - const allIntervals = graphData.intervals; + // y scale - const [xMin, origXMax] = extent(allIntervals); + 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) + ); - 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); - } + // x bars - 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); + function barWidth(d: any): number { + const width = Math.max(0, x(d.x1) - x(d.x0) - 1); + return width ? width : 0; } - return update; + 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 areaData = cumsum(data.map((d) => d.length)); + const xAreaScale = x.copy().domain([0, areaData.length]); + 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); }