mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
tidy up graph code
This commit is contained in:
parent
48a693f861
commit
1d1ed5b241
4 changed files with 190 additions and 206 deletions
|
@ -1,23 +1,16 @@
|
|||
<script lang="typescript">
|
||||
import {
|
||||
gatherIntervalData,
|
||||
intervalGraph,
|
||||
IntervalUpdateFn,
|
||||
IntervalRange,
|
||||
} from "./intervals";
|
||||
import { defaultGraphBounds } from "./graphs";
|
||||
|
||||
import { gatherIntervalData, intervalGraph, IntervalRange } from "./intervals";
|
||||
import type { IntervalGraphData } from "./intervals";
|
||||
import { onMount } from "svelte";
|
||||
import pb from "../backend/proto";
|
||||
|
||||
const bounds = defaultGraphBounds();
|
||||
|
||||
export let data: pb.BackendProto.GraphsOut | null = null;
|
||||
|
||||
let svg = null as HTMLElement | SVGElement | null;
|
||||
let updater = null as IntervalUpdateFn | null;
|
||||
|
||||
onMount(() => {
|
||||
updater = intervalGraph(svg as SVGElement);
|
||||
});
|
||||
|
||||
let range = IntervalRange.Percentile95;
|
||||
|
||||
let intervalData: IntervalGraphData | null = null;
|
||||
|
@ -26,8 +19,8 @@
|
|||
intervalData = gatherIntervalData(data);
|
||||
}
|
||||
|
||||
$: if (intervalData && updater) {
|
||||
updater(intervalData, range);
|
||||
$: if (intervalData) {
|
||||
intervalGraph(svg as SVGElement, bounds, intervalData, range);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -60,7 +53,25 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<svg bind:this={svg}>
|
||||
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
|
||||
<g class="bars" />
|
||||
<g class="hoverzone" />
|
||||
<path class="area" />
|
||||
<g
|
||||
class="x-ticks no-domain-line"
|
||||
transform={`translate(0,${bounds.height - bounds.marginBottom})`} />
|
||||
<g
|
||||
class="y-ticks no-domain-line"
|
||||
transform={`translate(${bounds.marginLeft}, 0)`} />
|
||||
<text
|
||||
class="axis-label"
|
||||
transform={`translate(${bounds.width / 2}, ${bounds.height - 5})`}>
|
||||
Interval (days)
|
||||
</text>
|
||||
<text
|
||||
class="axis-label y-axis-label"
|
||||
transform={`translate(${bounds.marginLeft / 3}, ${(bounds.height - bounds.marginBottom) / 2 + bounds.marginTop}) rotate(-180)`}>
|
||||
Number of cards
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<SVGGElement>(".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<SVGGElement>(".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<SVGGElement, unknown, null, undefined>,
|
||||
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<SVGGElement, unknown, null, undefined>,
|
||||
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. ` +
|
||||
`<br>${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. ` +
|
||||
`<br>${pct}% cards below this point.`,
|
||||
x,
|
||||
y
|
||||
);
|
||||
})
|
||||
.on("mouseout", hideTooltip);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue