mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
Minor changes to graphs (#1566)
* Add thousands comma separator for card counts graph * Fix Answer Buttons graph's tooltip Changes to the "times pressed" heading * Shows the percent of that button out of all the presses * Comma separates total on thousands * Update CONTRIBUTERS * Wider spacing for graph tables * Switch to locale-based stats numbers * Update CONTRIBUTORS Wrong email? * Fix counts graph on narrow devices Graph and table now align in a column when the device's screen is narrow. Columns widths are bounded to not get too wide * Rename toLocaleXXX functions * toLocaleNumber -> localizedNumber * toLocaleString -> localizedDate Also cleans up sketchy "card counts" table formatting * Localize more numbers Uses locale-based rounding for more numbers now * Localize graph axis ticks * Fix future-due graph tooltip * avoid div by zero (dae) Ignoring NaN in localizedNumber() could potentially mask a mistake in the future - better to explicitly handle the invalid case at the source instead.
This commit is contained in:
parent
972c9da12e
commit
2df3698e8c
16 changed files with 113 additions and 47 deletions
|
@ -88,6 +88,7 @@ Ren Tatsumoto <tatsu@autistici.org>
|
|||
lolilolicon <lolilolicon@gmail.com>
|
||||
Gesa Stupperich <gesa.stupperich@gmail.com>
|
||||
git9527 <github.com/git9527>
|
||||
Vova Selin <vselin12@gmail.com>
|
||||
|
||||
********************
|
||||
|
||||
|
|
|
@ -4,13 +4,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
-->
|
||||
<script lang="ts">
|
||||
import { pageTheme } from "../sveltelib/theme";
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
|
||||
export let value: number;
|
||||
export let min = 1;
|
||||
export let max = 9999;
|
||||
|
||||
let stringValue: string;
|
||||
$: stringValue = value.toFixed(2);
|
||||
$: stringValue = localizedNumber(value, 2);
|
||||
|
||||
function update(this: HTMLInputElement): void {
|
||||
value = Math.min(max, Math.max(min, parseFloat(this.value)));
|
||||
|
|
|
@ -48,15 +48,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</InputBox>
|
||||
|
||||
<div class="counts-outer">
|
||||
<svg
|
||||
bind:this={svg}
|
||||
viewBox={`0 0 ${bounds.width} ${bounds.height}`}
|
||||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
style="opacity: {graphData.totalCards ? 1 : 0}"
|
||||
>
|
||||
<g class="counts" />
|
||||
</svg>
|
||||
<div class="svg-container" width={bounds.width} height={bounds.height}>
|
||||
<svg
|
||||
bind:this={svg}
|
||||
viewBox={`0 0 ${bounds.width} ${bounds.height}`}
|
||||
style="opacity: {graphData.totalCards ? 1 : 0}"
|
||||
>
|
||||
<g class="counts" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="counts-table">
|
||||
<table>
|
||||
{#each tableData as d, _idx}
|
||||
|
@ -93,24 +93,52 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
.counts-outer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
margin: 0 4vw;
|
||||
|
||||
.counts-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
.svg-container {
|
||||
width: 225px;
|
||||
}
|
||||
|
||||
td {
|
||||
white-space: nowrap;
|
||||
.counts-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
table {
|
||||
border-spacing: 1em 0;
|
||||
padding-left: 4vw;
|
||||
|
||||
td {
|
||||
white-space: nowrap;
|
||||
padding: 0 min(4vw, 40px);
|
||||
|
||||
&.right {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
border-spacing: 1em 0;
|
||||
}
|
||||
/* On narrow devices, stack graph and table in a column */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.counts-outer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
.svg-container {
|
||||
width: 180px;
|
||||
|
||||
svg {
|
||||
margin-left: 4vw;
|
||||
}
|
||||
}
|
||||
|
||||
.counts-table table td {
|
||||
padding: 0 min(6vw, 30px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-link:hover {
|
||||
|
|
|
@ -15,7 +15,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
:global(rect:hover) {
|
||||
fill: grey;
|
||||
opacity: 0.05;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -25,6 +25,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.align-end {
|
||||
text-align: end;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
axisLeft,
|
||||
sum,
|
||||
} from "d3";
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
import { Stats } from "../lib/proto";
|
||||
import * as tr from "../lib/ftl";
|
||||
import { showTooltip, hideTooltip } from "./tooltip";
|
||||
|
@ -130,10 +131,20 @@ export function renderButtons(
|
|||
groupData.filter((d) => d.buttonNum > 1),
|
||||
(d) => d.count,
|
||||
);
|
||||
const percent = total ? ((correct / total) * 100).toFixed(2) : "0";
|
||||
const percent = total ? localizedNumber((correct / total) * 100) : "0";
|
||||
return { total, correct, percent };
|
||||
};
|
||||
|
||||
const totalPressedStr = (data: Datum): string => {
|
||||
const groupTotal = totalCorrect(data.group).total;
|
||||
const buttonTotal = data.count;
|
||||
const percent = groupTotal
|
||||
? localizedNumber((buttonTotal / groupTotal) * 100)
|
||||
: "0";
|
||||
|
||||
return `${localizedNumber(buttonTotal)} (${percent}%)`;
|
||||
};
|
||||
|
||||
const yMax = Math.max(...data.map((d) => d.count));
|
||||
|
||||
const svg = select(svgElem);
|
||||
|
@ -183,6 +194,7 @@ export function renderButtons(
|
|||
const colour = scaleSequential(interpolateRdYlGn).domain([1, 4]);
|
||||
|
||||
// y scale
|
||||
const yTickFormat = (n: number): string => localizedNumber(n);
|
||||
|
||||
const y = scaleLinear()
|
||||
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
|
||||
|
@ -192,7 +204,8 @@ export function renderButtons(
|
|||
selection.transition(trans).call(
|
||||
axisLeft(y)
|
||||
.ticks(bounds.height / 50)
|
||||
.tickSizeOuter(0),
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat(yTickFormat as any),
|
||||
),
|
||||
)
|
||||
.attr("direction", "ltr");
|
||||
|
@ -242,7 +255,8 @@ export function renderButtons(
|
|||
const button = tr.statisticsAnswerButtonsButtonNumber();
|
||||
const timesPressed = tr.statisticsAnswerButtonsButtonPressed();
|
||||
const correctStr = tr.statisticsHoursCorrect(totalCorrect(d.group));
|
||||
return `${button}: ${d.buttonNum}<br>${timesPressed}: ${d.count}<br>${correctStr}`;
|
||||
const pressedStr = `${timesPressed}: ${totalPressedStr(d)}`;
|
||||
return `${button}: ${d.buttonNum}<br>${pressedStr}<br>${correctStr}`;
|
||||
}
|
||||
|
||||
svg.select("g.hover-columns")
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
SearchDispatch,
|
||||
} from "./graph-helpers";
|
||||
import { clickableClass } from "./graph-styles";
|
||||
import { weekdayLabel, toLocaleString } from "../lib/i18n";
|
||||
import { weekdayLabel, localizedDate } from "../lib/i18n";
|
||||
import * as tr from "../lib/ftl";
|
||||
|
||||
export interface GraphData {
|
||||
|
@ -151,7 +151,7 @@ export function renderCalendar(
|
|||
.interpolator((n) => interpolateBlues(cappedRange(n)!));
|
||||
|
||||
function tooltipText(d: DayDatum): string {
|
||||
const date = toLocaleString(d.date, {
|
||||
const date = localizedDate(d.date, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
|
|
|
@ -20,13 +20,14 @@ import {
|
|||
interpolate,
|
||||
cumsum,
|
||||
} from "d3";
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
import type { GraphBounds } from "./graph-helpers";
|
||||
|
||||
type Count = [string, number, boolean, string];
|
||||
export interface GraphData {
|
||||
title: string;
|
||||
counts: Count[];
|
||||
totalCards: number;
|
||||
totalCards: string;
|
||||
}
|
||||
|
||||
const barColours = [
|
||||
|
@ -125,7 +126,7 @@ export function gatherData(
|
|||
data: Stats.GraphsResponse,
|
||||
separateInactive: boolean,
|
||||
): GraphData {
|
||||
const totalCards = data.cards.length;
|
||||
const totalCards = localizedNumber(data.cards.length);
|
||||
const counts = countCards(data.cards, separateInactive);
|
||||
|
||||
return {
|
||||
|
@ -148,7 +149,7 @@ export interface SummedDatum {
|
|||
|
||||
export interface TableDatum {
|
||||
label: string;
|
||||
count: number;
|
||||
count: string;
|
||||
query: string;
|
||||
percent: string;
|
||||
colour: string;
|
||||
|
@ -210,11 +211,11 @@ export function renderCards(
|
|||
x.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
|
||||
|
||||
const tableData = data.flatMap((d: SummedDatum, idx: number) => {
|
||||
const percent = ((d.count / xMax) * 100).toFixed(1);
|
||||
const percent = localizedNumber((d.count / xMax) * 100, 2);
|
||||
return d.show
|
||||
? ({
|
||||
label: d.label,
|
||||
count: d.count,
|
||||
count: localizedNumber(d.count),
|
||||
percent: `${percent}%`,
|
||||
colour: barColours[idx],
|
||||
query: d.query,
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
scaleSequential,
|
||||
interpolateRdYlGn,
|
||||
} from "d3";
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
import type { Bin, ScaleLinear } from "d3";
|
||||
import * as tr from "../lib/ftl";
|
||||
import { CardType } from "../lib/cards";
|
||||
|
@ -107,7 +108,7 @@ export function prepareData(
|
|||
dispatch("search", { query });
|
||||
}
|
||||
|
||||
const xTickFormat = (num: number): string => `${num.toFixed(0)}%`;
|
||||
const xTickFormat = (num: number): string => localizedNumber(num, 0) + "%";
|
||||
const tableData = [
|
||||
{
|
||||
label: tr.statisticsAverageEase(),
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
interpolateGreens,
|
||||
} from "d3";
|
||||
import type { Bin } from "d3";
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
import * as tr from "../lib/ftl";
|
||||
import { CardQueue } from "../lib/cards";
|
||||
import type { HistogramData } from "./histogram-graph";
|
||||
|
@ -140,6 +141,7 @@ export function buildHistogram(
|
|||
return output;
|
||||
}
|
||||
|
||||
const xTickFormat = (n: number): string => localizedNumber(n);
|
||||
const adjustedRange = scaleLinear().range([0.7, 0.3]);
|
||||
const colourScale = scaleSequential((n) =>
|
||||
interpolateGreens(adjustedRange(n)!),
|
||||
|
@ -158,7 +160,7 @@ export function buildHistogram(
|
|||
});
|
||||
const totalLabel = tr.statisticsRunningTotal();
|
||||
|
||||
return `${days}:<br>${cards}<br>${totalLabel}: ${cumulative}`;
|
||||
return `${days}:<br>${cards}<br>${totalLabel}: ${localizedNumber(cumulative)}`;
|
||||
}
|
||||
|
||||
function onClick(bin: Bin<number, number>): void {
|
||||
|
@ -198,6 +200,7 @@ export function buildHistogram(
|
|||
showArea: true,
|
||||
colourScale,
|
||||
binValue,
|
||||
xTickFormat,
|
||||
},
|
||||
tableData,
|
||||
};
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
area,
|
||||
curveBasis,
|
||||
} from "d3";
|
||||
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
import type { ScaleLinear, ScaleSequential, Bin } from "d3";
|
||||
import { showTooltip, hideTooltip } from "./tooltip";
|
||||
import { GraphBounds, setDataAvailable } from "./graph-helpers";
|
||||
|
@ -46,6 +46,7 @@ export function histogramGraph(
|
|||
): void {
|
||||
const svg = select(svgElem);
|
||||
const trans = svg.transition().duration(600) as any;
|
||||
const axisTickFormat = (n: number): string => localizedNumber(n);
|
||||
|
||||
if (!data) {
|
||||
setDataAvailable(svg, false);
|
||||
|
@ -63,7 +64,7 @@ export function histogramGraph(
|
|||
axisBottom(x)
|
||||
.ticks(7)
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat((data.xTickFormat ?? null) as any),
|
||||
.tickFormat((data.xTickFormat ?? axisTickFormat) as any),
|
||||
),
|
||||
)
|
||||
.attr("direction", "ltr");
|
||||
|
@ -80,7 +81,8 @@ export function histogramGraph(
|
|||
selection.transition(trans).call(
|
||||
axisLeft(y)
|
||||
.ticks(bounds.height / 50)
|
||||
.tickSizeOuter(0),
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat(axisTickFormat as any),
|
||||
),
|
||||
)
|
||||
.attr("direction", "ltr");
|
||||
|
@ -134,7 +136,8 @@ export function histogramGraph(
|
|||
selection.transition(trans).call(
|
||||
axisRight(yAreaScale)
|
||||
.ticks(bounds.height / 50)
|
||||
.tickSizeOuter(0),
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat(axisTickFormat as any),
|
||||
),
|
||||
)
|
||||
.attr("direction", "ltr");
|
||||
|
|
|
@ -19,7 +19,7 @@ import {
|
|||
area,
|
||||
curveBasis,
|
||||
} from "d3";
|
||||
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
import * as tr from "../lib/ftl";
|
||||
import { showTooltip, hideTooltip } from "./tooltip";
|
||||
import {
|
||||
|
@ -110,6 +110,7 @@ export function renderHours(
|
|||
]);
|
||||
|
||||
// y scale
|
||||
const yTickFormat = (n: number): string => localizedNumber(n);
|
||||
|
||||
const y = scaleLinear()
|
||||
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
|
||||
|
@ -120,7 +121,8 @@ export function renderHours(
|
|||
selection.transition(trans).call(
|
||||
axisLeft(y)
|
||||
.ticks(bounds.height / 50)
|
||||
.tickSizeOuter(0),
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat(yTickFormat as any),
|
||||
),
|
||||
)
|
||||
.attr("direction", "ltr");
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
scaleSequential,
|
||||
interpolateBlues,
|
||||
} from "d3";
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
import type { Bin } from "d3";
|
||||
import * as tr from "../lib/ftl";
|
||||
import { timeSpan } from "../lib/time";
|
||||
|
@ -147,7 +148,7 @@ export function prepareIntervalData(
|
|||
// const day = dayLabel(bin.x0!, bin.x1!);
|
||||
const interval = intervalLabel(bin.x0!, bin.x1!, bin.length);
|
||||
const total = tr.statisticsRunningTotal();
|
||||
return `${interval}<br>${total}: \u200e${percent.toFixed(1)}%`;
|
||||
return `${interval}<br>${total}: \u200e${localizedNumber(percent, 1)}%`;
|
||||
}
|
||||
|
||||
function onClick(bin: Bin<number, number>): void {
|
||||
|
|
|
@ -30,7 +30,7 @@ import {
|
|||
ScaleSequential,
|
||||
} from "d3";
|
||||
import type { Bin } from "d3";
|
||||
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
import * as tr from "../lib/ftl";
|
||||
import type { TableDatum } from "./graph-helpers";
|
||||
import { GraphBounds, setDataAvailable, GraphRange } from "./graph-helpers";
|
||||
|
@ -190,7 +190,7 @@ export function renderReviews(
|
|||
if (Math.round(n) != n) {
|
||||
return "";
|
||||
} else {
|
||||
return n.toLocaleString();
|
||||
return localizedNumber(n);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { Stats } from "../lib/proto";
|
||||
import { studiedToday } from "../lib/time";
|
||||
import { localizedNumber } from "../lib/i18n";
|
||||
import * as tr from "../lib/ftl";
|
||||
|
||||
export interface TodayData {
|
||||
|
@ -73,8 +74,8 @@ export function gatherData(data: Stats.GraphsResponse): TodayData {
|
|||
const studiedTodayText = studiedToday(answerCount, answerMillis / 1000);
|
||||
const againCount = answerCount - correctCount;
|
||||
let againCountText = tr.statisticsTodayAgainCount();
|
||||
againCountText += ` ${againCount} (${((againCount / answerCount) * 100).toFixed(
|
||||
2,
|
||||
againCountText += ` ${againCount} (${localizedNumber(
|
||||
(againCount / answerCount) * 100,
|
||||
)}%)`;
|
||||
const typeCounts = tr.statisticsTodayTypeCounts({
|
||||
learnCount,
|
||||
|
|
|
@ -41,13 +41,19 @@ export function weekdayLabel(n: number): string {
|
|||
|
||||
let langs: string[] = [];
|
||||
|
||||
export function toLocaleString(
|
||||
export function localizedDate(
|
||||
date: Date,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string {
|
||||
return date.toLocaleDateString(langs, options);
|
||||
}
|
||||
|
||||
export function localizedNumber(n: number, precision = 2): string {
|
||||
const round = Math.pow(10, precision);
|
||||
const rounded = Math.round(n * round) / round;
|
||||
return rounded.toLocaleString(langs);
|
||||
}
|
||||
|
||||
export function localeCompare(
|
||||
first: string,
|
||||
second: string,
|
||||
|
|
Loading…
Reference in a new issue