mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00

* backend part
* split memorised and cost
* slapdash frontend
* extract some simulator logic
* Add zoomed version of graph
* ./check
* Fix: Tooltip
* Fix: Simulator/workload transition
* remove "time"
* Update ts/routes/graphs/simulator.ts
Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com>
* Added: Mode toggle
* Disable Dr in workload mode
* keep button order consistant between modes
* dont clear points on mode swap
* add review count graph
* Revert "dont clear points on mode swap"
This reverts commit fc89efb1d9
.
* "Help me pick" button
* unrelated title case change
* Add translation strings
* fix: missing translation string
* Fix: Layout shift
* Add: Experimental
* Fix Time / Memorized
* per day values
* set review limit to 9999 on open
* keep default at currently set value
* Do DR calculation in parallel (dae)
Approx 5x faster on my machine
---------
Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com>
Co-authored-by: Damien Elmes <gpg@ankiweb.net>
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
// Copyright: Ankitects Pty Ltd and contributors
|
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
import { localizedDate } from "@tslib/i18n";
|
|
import {
|
|
axisBottom,
|
|
axisLeft,
|
|
bisector,
|
|
line,
|
|
max,
|
|
pointer,
|
|
rollup,
|
|
scaleLinear,
|
|
scaleTime,
|
|
schemeCategory10,
|
|
select,
|
|
} from "d3";
|
|
|
|
import * as tr from "@generated/ftl";
|
|
import { timeSpan } from "@tslib/time";
|
|
import { sumBy } from "lodash-es";
|
|
import type { GraphBounds, TableDatum } from "./graph-helpers";
|
|
import { setDataAvailable } from "./graph-helpers";
|
|
import { hideTooltip, showTooltip } from "./tooltip-utils.svelte";
|
|
|
|
export interface Point {
|
|
x: number;
|
|
timeCost: number;
|
|
count: number;
|
|
memorized: number;
|
|
label: number;
|
|
}
|
|
|
|
export type WorkloadPoint = Point & {
|
|
learnSpan: number;
|
|
};
|
|
|
|
export enum SimulateSubgraph {
|
|
time,
|
|
count,
|
|
memorized,
|
|
}
|
|
|
|
export enum SimulateWorkloadSubgraph {
|
|
ratio,
|
|
time,
|
|
count,
|
|
memorized,
|
|
}
|
|
|
|
export function renderWorkloadChart(
|
|
svgElem: SVGElement,
|
|
bounds: GraphBounds,
|
|
data: WorkloadPoint[],
|
|
subgraph: SimulateWorkloadSubgraph,
|
|
) {
|
|
const xMin = 70;
|
|
const xMax = 99;
|
|
|
|
const x = scaleLinear()
|
|
.domain([xMin, xMax])
|
|
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
|
|
|
|
const subgraph_data = ({
|
|
[SimulateWorkloadSubgraph.ratio]: data.map(d => ({ ...d, y: d.timeCost / d.memorized })),
|
|
[SimulateWorkloadSubgraph.time]: data.map(d => ({ ...d, y: d.timeCost / d.learnSpan })),
|
|
[SimulateWorkloadSubgraph.count]: data.map(d => ({ ...d, y: d.count / d.learnSpan })),
|
|
[SimulateWorkloadSubgraph.memorized]: data.map(d => ({ ...d, y: d.memorized })),
|
|
})[subgraph];
|
|
|
|
const yTickFormat = (n: number): string => {
|
|
return subgraph == SimulateWorkloadSubgraph.time || subgraph == SimulateWorkloadSubgraph.ratio
|
|
? timeSpan(n, true)
|
|
: n.toString();
|
|
};
|
|
|
|
const formatY: (value: number) => string = ({
|
|
[SimulateWorkloadSubgraph.ratio]: (value: number) =>
|
|
tr.deckConfigFsrsSimulatorRatioTooltip({ time: timeSpan(value) }),
|
|
[SimulateWorkloadSubgraph.time]: (value: number) =>
|
|
tr.statisticsMinutesPerDay({ count: parseFloat((value / 60).toPrecision(2)) }),
|
|
[SimulateWorkloadSubgraph.count]: (value: number) => tr.statisticsReviewsPerDay({ count: Math.round(value) }),
|
|
[SimulateWorkloadSubgraph.memorized]: (value: number) =>
|
|
tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),
|
|
})[subgraph];
|
|
|
|
function formatX(dr: number) {
|
|
return `Desired Retention: ${dr}%<br>`;
|
|
}
|
|
|
|
return _renderSimulationChart(
|
|
svgElem,
|
|
bounds,
|
|
subgraph_data,
|
|
x,
|
|
yTickFormat,
|
|
formatY,
|
|
formatX,
|
|
(_e: MouseEvent, _d: number) => undefined,
|
|
);
|
|
}
|
|
|
|
export function renderSimulationChart(
|
|
svgElem: SVGElement,
|
|
bounds: GraphBounds,
|
|
data: Point[],
|
|
subgraph: SimulateSubgraph,
|
|
): TableDatum[] {
|
|
const today = new Date();
|
|
const convertedData = data.map(d => ({
|
|
...d,
|
|
x: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000),
|
|
}));
|
|
|
|
const subgraph_data = ({
|
|
[SimulateSubgraph.count]: convertedData.map(d => ({ ...d, y: d.count })),
|
|
[SimulateSubgraph.time]: convertedData.map(d => ({ ...d, y: d.timeCost })),
|
|
[SimulateSubgraph.memorized]: convertedData.map(d => ({ ...d, y: d.memorized })),
|
|
})[subgraph];
|
|
|
|
const xMin = today;
|
|
const xMax = max(subgraph_data, d => d.x);
|
|
|
|
const x = scaleTime()
|
|
.domain([xMin, xMax!])
|
|
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
|
|
|
|
const yTickFormat = (n: number): string => {
|
|
return subgraph == SimulateSubgraph.time ? timeSpan(n, true) : n.toString();
|
|
};
|
|
|
|
const formatY: (value: number) => string = ({
|
|
[SimulateSubgraph.time]: timeSpan,
|
|
[SimulateSubgraph.count]: (value: number) => tr.statisticsReviews({ reviews: Math.round(value) }),
|
|
[SimulateSubgraph.memorized]: (value: number) =>
|
|
tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),
|
|
})[subgraph];
|
|
|
|
const perDay = ({
|
|
[SimulateSubgraph.count]: tr.statisticsReviewsPerDay,
|
|
[SimulateSubgraph.time]: ({ count }: { count: number }) => timeSpan(count),
|
|
[SimulateSubgraph.memorized]: tr.statisticsCardsPerDay,
|
|
})[subgraph];
|
|
|
|
function legendMouseMove(e: MouseEvent, d: number) {
|
|
const data = subgraph_data.filter(datum => datum.label == d);
|
|
|
|
const total = subgraph == SimulateSubgraph.memorized
|
|
? data[data.length - 1].memorized - data[0].memorized
|
|
: sumBy(data, d => d.y);
|
|
const average = total / (data?.length || 1);
|
|
|
|
showTooltip(
|
|
`#${d}:<br/>
|
|
${tr.statisticsAverage()}: ${perDay({ count: average })}<br/>
|
|
${tr.statisticsTotal()}: ${formatY(total)}`,
|
|
e.pageX,
|
|
e.pageY,
|
|
);
|
|
}
|
|
|
|
function formatX(date: Date) {
|
|
const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed();
|
|
return `Date: ${localizedDate(date)}<br>In ${days} Days<br>`;
|
|
}
|
|
|
|
return _renderSimulationChart(
|
|
svgElem,
|
|
bounds,
|
|
subgraph_data,
|
|
x,
|
|
yTickFormat,
|
|
formatY,
|
|
formatX,
|
|
legendMouseMove,
|
|
);
|
|
}
|
|
|
|
function _renderSimulationChart<T extends { x: any; y: any; label: number }>(
|
|
svgElem: SVGElement,
|
|
bounds: GraphBounds,
|
|
subgraph_data: T[],
|
|
x: any,
|
|
yTickFormat: (n: number) => string,
|
|
formatY: (n: T["y"]) => string,
|
|
formatX: (n: T["x"]) => string,
|
|
legendMouseMove: (e: MouseEvent, d: number) => void,
|
|
): TableDatum[] {
|
|
const svg = select(svgElem);
|
|
svg.selectAll(".lines").remove();
|
|
svg.selectAll(".hover-columns").remove();
|
|
svg.selectAll(".focus-line").remove();
|
|
svg.selectAll(".legend").remove();
|
|
if (subgraph_data.length == 0) {
|
|
setDataAvailable(svg, false);
|
|
return [];
|
|
}
|
|
const trans = svg.transition().duration(600) as any;
|
|
|
|
svg.select<SVGGElement>(".x-ticks")
|
|
.call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0)))
|
|
.attr("direction", "ltr");
|
|
// y scale
|
|
|
|
const yMax = max(subgraph_data, d => d.y)!;
|
|
const y = scaleLinear()
|
|
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
|
|
.domain([0, yMax])
|
|
.nice();
|
|
svg.select<SVGGElement>(".y-ticks")
|
|
.call((selection) =>
|
|
selection.transition(trans).call(
|
|
axisLeft(y)
|
|
.ticks(bounds.height / 50)
|
|
.tickSizeOuter(0)
|
|
.tickFormat(yTickFormat as any),
|
|
)
|
|
)
|
|
.attr("direction", "ltr");
|
|
|
|
svg.select(".y-ticks .y-axis-title").remove();
|
|
svg.select(".y-ticks")
|
|
.append("text")
|
|
.attr("class", "y-axis-title")
|
|
.attr("transform", "rotate(-90)")
|
|
.attr("y", 0 - bounds.marginLeft)
|
|
.attr("x", 0 - (bounds.height / 2))
|
|
.attr("font-size", "1rem")
|
|
.attr("dy", "1.1em")
|
|
.attr("fill", "currentColor");
|
|
|
|
// x lines
|
|
const points = subgraph_data.map((d) => [x(d.x), y(d.y), d.label]);
|
|
const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]);
|
|
|
|
const color = schemeCategory10;
|
|
|
|
svg.append("g")
|
|
.attr("class", "lines")
|
|
.attr("fill", "none")
|
|
.attr("stroke-width", 1.5)
|
|
.attr("stroke-linejoin", "round")
|
|
.attr("stroke-linecap", "round")
|
|
.selectAll("path")
|
|
.data(Array.from(groups.entries()))
|
|
.join("path")
|
|
.attr("vector-effect", "non-scaling-stroke")
|
|
.attr("stroke", (d, i) => color[i % color.length])
|
|
.attr("d", d => line()(d[1].map(p => [p[0], p[1]])))
|
|
.attr("data-group", d => d[0]);
|
|
|
|
const focusLine = svg.append("line")
|
|
.attr("class", "focus-line")
|
|
.attr("y1", bounds.marginTop)
|
|
.attr("y2", bounds.height - bounds.marginBottom)
|
|
.attr("stroke", "black")
|
|
.attr("stroke-width", 1)
|
|
.style("opacity", 0);
|
|
|
|
const LongestGroupData = Array.from(groups.values()).reduce((a, b) => a.length > b.length ? a : b);
|
|
const barWidth = bounds.width / LongestGroupData.length;
|
|
|
|
// hover/tooltip
|
|
svg.append("g")
|
|
.attr("class", "hover-columns")
|
|
.selectAll("rect")
|
|
.data(LongestGroupData)
|
|
.join("rect")
|
|
.attr("x", d => d[0] - barWidth / 2)
|
|
.attr("y", bounds.marginTop)
|
|
.attr("width", barWidth)
|
|
.attr("height", bounds.height - bounds.marginTop - bounds.marginBottom)
|
|
.attr("fill", "transparent")
|
|
.on("mousemove", mousemove)
|
|
.on("mouseout", () => {
|
|
focusLine.style("opacity", 0);
|
|
hideTooltip();
|
|
});
|
|
|
|
function mousemove(event: MouseEvent, d: any): void {
|
|
pointer(event, document.body);
|
|
const date = x.invert(d[0]);
|
|
|
|
const groupData: { [key: string]: number } = {};
|
|
|
|
groups.forEach((groupPoints, key) => {
|
|
const bisect = bisector((d: number[]) => x.invert(d[0])).left;
|
|
const index = bisect(groupPoints, date);
|
|
const dataPoint = groupPoints[index];
|
|
|
|
if (dataPoint) {
|
|
groupData[key] = y.invert(dataPoint[1]);
|
|
}
|
|
});
|
|
|
|
focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1);
|
|
|
|
let tooltipContent = formatX(date);
|
|
for (const [key, value] of Object.entries(groupData)) {
|
|
const path = svg.select(`path[data-group="${key}"]`);
|
|
const hidden = path.classed("hidden");
|
|
|
|
if (!hidden) {
|
|
tooltipContent += `<span style="color:${color[(parseInt(key) - 1) % color.length]}">■</span> #${key}: ${
|
|
formatY(value)
|
|
}<br>`;
|
|
}
|
|
}
|
|
|
|
showTooltip(tooltipContent, event.pageX, event.pageY);
|
|
}
|
|
|
|
const legend = svg.append("g")
|
|
.attr("class", "legend")
|
|
.attr("font-family", "sans-serif")
|
|
.attr("font-size", 10)
|
|
.attr("text-anchor", "start")
|
|
.selectAll("g")
|
|
.data(Array.from(groups.keys()))
|
|
.join("g")
|
|
.attr("transform", (d, i) => `translate(0,${i * 20})`)
|
|
.attr("cursor", "pointer")
|
|
.on("click", (event, d) => toggleGroup(event, d))
|
|
.on("mousemove", legendMouseMove)
|
|
.on("mouseout", hideTooltip);
|
|
|
|
legend.append("rect")
|
|
.attr("x", bounds.width - bounds.marginRight + 36)
|
|
.attr("width", 12)
|
|
.attr("height", 12)
|
|
.attr("fill", (d, i) => color[i % color.length]);
|
|
|
|
legend.append("text")
|
|
.attr("x", bounds.width - bounds.marginRight + 52)
|
|
.attr("y", 7)
|
|
.attr("dy", "0.3em")
|
|
.attr("fill", "currentColor")
|
|
.text(d => `#${d}`);
|
|
|
|
const toggleGroup = (event: MouseEvent, d: number) => {
|
|
const group = d;
|
|
const path = svg.select(`path[data-group="${group}"]`);
|
|
const hidden = path.classed("hidden");
|
|
const target = event.currentTarget as HTMLElement;
|
|
|
|
path.classed("hidden", !hidden);
|
|
path.style("display", () => hidden ? null : "none");
|
|
|
|
select(target).select("rect")
|
|
.style("opacity", hidden ? 1 : 0.5);
|
|
};
|
|
|
|
setDataAvailable(svg, true);
|
|
|
|
const tableData: TableDatum[] = [];
|
|
|
|
return tableData;
|
|
}
|