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:
Vova Selin 2021-12-28 22:04:15 -07:00 committed by GitHub
parent 972c9da12e
commit 2df3698e8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 113 additions and 47 deletions

View file

@ -88,6 +88,7 @@ Ren Tatsumoto <tatsu@autistici.org>
lolilolicon <lolilolicon@gmail.com> lolilolicon <lolilolicon@gmail.com>
Gesa Stupperich <gesa.stupperich@gmail.com> Gesa Stupperich <gesa.stupperich@gmail.com>
git9527 <github.com/git9527> git9527 <github.com/git9527>
Vova Selin <vselin12@gmail.com>
******************** ********************

View file

@ -4,13 +4,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import { pageTheme } from "../sveltelib/theme"; import { pageTheme } from "../sveltelib/theme";
import { localizedNumber } from "../lib/i18n";
export let value: number; export let value: number;
export let min = 1; export let min = 1;
export let max = 9999; export let max = 9999;
let stringValue: string; let stringValue: string;
$: stringValue = value.toFixed(2); $: stringValue = localizedNumber(value, 2);
function update(this: HTMLInputElement): void { function update(this: HTMLInputElement): void {
value = Math.min(max, Math.max(min, parseFloat(this.value))); value = Math.min(max, Math.max(min, parseFloat(this.value)));

View file

@ -48,15 +48,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</InputBox> </InputBox>
<div class="counts-outer"> <div class="counts-outer">
<div class="svg-container" width={bounds.width} height={bounds.height}>
<svg <svg
bind:this={svg} bind:this={svg}
viewBox={`0 0 ${bounds.width} ${bounds.height}`} viewBox={`0 0 ${bounds.width} ${bounds.height}`}
width={bounds.width}
height={bounds.height}
style="opacity: {graphData.totalCards ? 1 : 0}" style="opacity: {graphData.totalCards ? 1 : 0}"
> >
<g class="counts" /> <g class="counts" />
</svg> </svg>
</div>
<div class="counts-table"> <div class="counts-table">
<table> <table>
{#each tableData as d, _idx} {#each tableData as d, _idx}
@ -93,6 +93,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.counts-outer { .counts-outer {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 0 4vw;
.svg-container {
width: 225px;
} }
.counts-table { .counts-table {
@ -100,17 +104,41 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
td {
white-space: nowrap;
}
}
table { table {
border-spacing: 1em 0; border-spacing: 1em 0;
padding-left: 4vw;
td {
white-space: nowrap;
padding: 0 min(4vw, 40px);
&.right {
text-align: right;
}
}
}
}
} }
.right { /* On narrow devices, stack graph and table in a column */
text-align: right; @media only screen and (max-width: 600px) {
.counts-outer {
display: flex;
flex-direction: column;
align-items: center;
.svg-container {
width: 180px;
svg {
margin-left: 4vw;
}
}
.counts-table table td {
padding: 0 min(6vw, 30px);
}
}
} }
.search-link:hover { .search-link:hover {

View file

@ -15,7 +15,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
:global(rect:hover) { :global(rect:hover) {
fill: grey; fill: grey;
opacity: 0.05; opacity: 0.2;
} }
} }
</style> </style>

View file

@ -25,6 +25,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
justify-content: center; justify-content: center;
} }
td {
padding: 3px;
}
.align-end { .align-end {
text-align: end; text-align: end;
} }

View file

@ -16,6 +16,7 @@ import {
axisLeft, axisLeft,
sum, sum,
} from "d3"; } from "d3";
import { localizedNumber } from "../lib/i18n";
import { Stats } from "../lib/proto"; import { Stats } from "../lib/proto";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { showTooltip, hideTooltip } from "./tooltip"; import { showTooltip, hideTooltip } from "./tooltip";
@ -130,10 +131,20 @@ export function renderButtons(
groupData.filter((d) => d.buttonNum > 1), groupData.filter((d) => d.buttonNum > 1),
(d) => d.count, (d) => d.count,
); );
const percent = total ? ((correct / total) * 100).toFixed(2) : "0"; const percent = total ? localizedNumber((correct / total) * 100) : "0";
return { total, correct, percent }; 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 yMax = Math.max(...data.map((d) => d.count));
const svg = select(svgElem); const svg = select(svgElem);
@ -183,6 +194,7 @@ export function renderButtons(
const colour = scaleSequential(interpolateRdYlGn).domain([1, 4]); const colour = scaleSequential(interpolateRdYlGn).domain([1, 4]);
// y scale // y scale
const yTickFormat = (n: number): string => localizedNumber(n);
const y = scaleLinear() const y = scaleLinear()
.range([bounds.height - bounds.marginBottom, bounds.marginTop]) .range([bounds.height - bounds.marginBottom, bounds.marginTop])
@ -192,7 +204,8 @@ export function renderButtons(
selection.transition(trans).call( selection.transition(trans).call(
axisLeft(y) axisLeft(y)
.ticks(bounds.height / 50) .ticks(bounds.height / 50)
.tickSizeOuter(0), .tickSizeOuter(0)
.tickFormat(yTickFormat as any),
), ),
) )
.attr("direction", "ltr"); .attr("direction", "ltr");
@ -242,7 +255,8 @@ export function renderButtons(
const button = tr.statisticsAnswerButtonsButtonNumber(); const button = tr.statisticsAnswerButtonsButtonNumber();
const timesPressed = tr.statisticsAnswerButtonsButtonPressed(); const timesPressed = tr.statisticsAnswerButtonsButtonPressed();
const correctStr = tr.statisticsHoursCorrect(totalCorrect(d.group)); 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") svg.select("g.hover-columns")

View file

@ -25,7 +25,7 @@ import {
SearchDispatch, SearchDispatch,
} from "./graph-helpers"; } from "./graph-helpers";
import { clickableClass } from "./graph-styles"; import { clickableClass } from "./graph-styles";
import { weekdayLabel, toLocaleString } from "../lib/i18n"; import { weekdayLabel, localizedDate } from "../lib/i18n";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
export interface GraphData { export interface GraphData {
@ -151,7 +151,7 @@ export function renderCalendar(
.interpolator((n) => interpolateBlues(cappedRange(n)!)); .interpolator((n) => interpolateBlues(cappedRange(n)!));
function tooltipText(d: DayDatum): string { function tooltipText(d: DayDatum): string {
const date = toLocaleString(d.date, { const date = localizedDate(d.date, {
weekday: "long", weekday: "long",
year: "numeric", year: "numeric",
month: "long", month: "long",

View file

@ -20,13 +20,14 @@ import {
interpolate, interpolate,
cumsum, cumsum,
} from "d3"; } from "d3";
import { localizedNumber } from "../lib/i18n";
import type { GraphBounds } from "./graph-helpers"; import type { GraphBounds } from "./graph-helpers";
type Count = [string, number, boolean, string]; type Count = [string, number, boolean, string];
export interface GraphData { export interface GraphData {
title: string; title: string;
counts: Count[]; counts: Count[];
totalCards: number; totalCards: string;
} }
const barColours = [ const barColours = [
@ -125,7 +126,7 @@ export function gatherData(
data: Stats.GraphsResponse, data: Stats.GraphsResponse,
separateInactive: boolean, separateInactive: boolean,
): GraphData { ): GraphData {
const totalCards = data.cards.length; const totalCards = localizedNumber(data.cards.length);
const counts = countCards(data.cards, separateInactive); const counts = countCards(data.cards, separateInactive);
return { return {
@ -148,7 +149,7 @@ export interface SummedDatum {
export interface TableDatum { export interface TableDatum {
label: string; label: string;
count: number; count: string;
query: string; query: string;
percent: string; percent: string;
colour: string; colour: string;
@ -210,11 +211,11 @@ export function renderCards(
x.range([bounds.marginLeft, bounds.width - bounds.marginRight]); x.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
const tableData = data.flatMap((d: SummedDatum, idx: number) => { 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 return d.show
? ({ ? ({
label: d.label, label: d.label,
count: d.count, count: localizedNumber(d.count),
percent: `${percent}%`, percent: `${percent}%`,
colour: barColours[idx], colour: barColours[idx],
query: d.query, query: d.query,

View file

@ -14,6 +14,7 @@ import {
scaleSequential, scaleSequential,
interpolateRdYlGn, interpolateRdYlGn,
} from "d3"; } from "d3";
import { localizedNumber } from "../lib/i18n";
import type { Bin, ScaleLinear } from "d3"; import type { Bin, ScaleLinear } from "d3";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { CardType } from "../lib/cards"; import { CardType } from "../lib/cards";
@ -107,7 +108,7 @@ export function prepareData(
dispatch("search", { query }); dispatch("search", { query });
} }
const xTickFormat = (num: number): string => `${num.toFixed(0)}%`; const xTickFormat = (num: number): string => localizedNumber(num, 0) + "%";
const tableData = [ const tableData = [
{ {
label: tr.statisticsAverageEase(), label: tr.statisticsAverageEase(),

View file

@ -16,6 +16,7 @@ import {
interpolateGreens, interpolateGreens,
} from "d3"; } from "d3";
import type { Bin } from "d3"; import type { Bin } from "d3";
import { localizedNumber } from "../lib/i18n";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { CardQueue } from "../lib/cards"; import { CardQueue } from "../lib/cards";
import type { HistogramData } from "./histogram-graph"; import type { HistogramData } from "./histogram-graph";
@ -140,6 +141,7 @@ export function buildHistogram(
return output; return output;
} }
const xTickFormat = (n: number): string => localizedNumber(n);
const adjustedRange = scaleLinear().range([0.7, 0.3]); const adjustedRange = scaleLinear().range([0.7, 0.3]);
const colourScale = scaleSequential((n) => const colourScale = scaleSequential((n) =>
interpolateGreens(adjustedRange(n)!), interpolateGreens(adjustedRange(n)!),
@ -158,7 +160,7 @@ export function buildHistogram(
}); });
const totalLabel = tr.statisticsRunningTotal(); 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 { function onClick(bin: Bin<number, number>): void {
@ -198,6 +200,7 @@ export function buildHistogram(
showArea: true, showArea: true,
colourScale, colourScale,
binValue, binValue,
xTickFormat,
}, },
tableData, tableData,
}; };

View file

@ -17,7 +17,7 @@ import {
area, area,
curveBasis, curveBasis,
} from "d3"; } from "d3";
import { localizedNumber } from "../lib/i18n";
import type { ScaleLinear, ScaleSequential, Bin } from "d3"; import type { ScaleLinear, ScaleSequential, Bin } from "d3";
import { showTooltip, hideTooltip } from "./tooltip"; import { showTooltip, hideTooltip } from "./tooltip";
import { GraphBounds, setDataAvailable } from "./graph-helpers"; import { GraphBounds, setDataAvailable } from "./graph-helpers";
@ -46,6 +46,7 @@ export function histogramGraph(
): void { ): void {
const svg = select(svgElem); const svg = select(svgElem);
const trans = svg.transition().duration(600) as any; const trans = svg.transition().duration(600) as any;
const axisTickFormat = (n: number): string => localizedNumber(n);
if (!data) { if (!data) {
setDataAvailable(svg, false); setDataAvailable(svg, false);
@ -63,7 +64,7 @@ export function histogramGraph(
axisBottom(x) axisBottom(x)
.ticks(7) .ticks(7)
.tickSizeOuter(0) .tickSizeOuter(0)
.tickFormat((data.xTickFormat ?? null) as any), .tickFormat((data.xTickFormat ?? axisTickFormat) as any),
), ),
) )
.attr("direction", "ltr"); .attr("direction", "ltr");
@ -80,7 +81,8 @@ export function histogramGraph(
selection.transition(trans).call( selection.transition(trans).call(
axisLeft(y) axisLeft(y)
.ticks(bounds.height / 50) .ticks(bounds.height / 50)
.tickSizeOuter(0), .tickSizeOuter(0)
.tickFormat(axisTickFormat as any),
), ),
) )
.attr("direction", "ltr"); .attr("direction", "ltr");
@ -134,7 +136,8 @@ export function histogramGraph(
selection.transition(trans).call( selection.transition(trans).call(
axisRight(yAreaScale) axisRight(yAreaScale)
.ticks(bounds.height / 50) .ticks(bounds.height / 50)
.tickSizeOuter(0), .tickSizeOuter(0)
.tickFormat(axisTickFormat as any),
), ),
) )
.attr("direction", "ltr"); .attr("direction", "ltr");

View file

@ -19,7 +19,7 @@ import {
area, area,
curveBasis, curveBasis,
} from "d3"; } from "d3";
import { localizedNumber } from "../lib/i18n";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { showTooltip, hideTooltip } from "./tooltip"; import { showTooltip, hideTooltip } from "./tooltip";
import { import {
@ -110,6 +110,7 @@ export function renderHours(
]); ]);
// y scale // y scale
const yTickFormat = (n: number): string => localizedNumber(n);
const y = scaleLinear() const y = scaleLinear()
.range([bounds.height - bounds.marginBottom, bounds.marginTop]) .range([bounds.height - bounds.marginBottom, bounds.marginTop])
@ -120,7 +121,8 @@ export function renderHours(
selection.transition(trans).call( selection.transition(trans).call(
axisLeft(y) axisLeft(y)
.ticks(bounds.height / 50) .ticks(bounds.height / 50)
.tickSizeOuter(0), .tickSizeOuter(0)
.tickFormat(yTickFormat as any),
), ),
) )
.attr("direction", "ltr"); .attr("direction", "ltr");

View file

@ -16,6 +16,7 @@ import {
scaleSequential, scaleSequential,
interpolateBlues, interpolateBlues,
} from "d3"; } from "d3";
import { localizedNumber } from "../lib/i18n";
import type { Bin } from "d3"; import type { Bin } from "d3";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { timeSpan } from "../lib/time"; import { timeSpan } from "../lib/time";
@ -147,7 +148,7 @@ export function prepareIntervalData(
// const day = dayLabel(bin.x0!, bin.x1!); // const day = dayLabel(bin.x0!, bin.x1!);
const interval = intervalLabel(bin.x0!, bin.x1!, bin.length); const interval = intervalLabel(bin.x0!, bin.x1!, bin.length);
const total = tr.statisticsRunningTotal(); 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 { function onClick(bin: Bin<number, number>): void {

View file

@ -30,7 +30,7 @@ import {
ScaleSequential, ScaleSequential,
} from "d3"; } from "d3";
import type { Bin } from "d3"; import type { Bin } from "d3";
import { localizedNumber } from "../lib/i18n";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import type { TableDatum } from "./graph-helpers"; import type { TableDatum } from "./graph-helpers";
import { GraphBounds, setDataAvailable, GraphRange } from "./graph-helpers"; import { GraphBounds, setDataAvailable, GraphRange } from "./graph-helpers";
@ -190,7 +190,7 @@ export function renderReviews(
if (Math.round(n) != n) { if (Math.round(n) != n) {
return ""; return "";
} else { } else {
return n.toLocaleString(); return localizedNumber(n);
} }
} }
}; };

View file

@ -3,6 +3,7 @@
import { Stats } from "../lib/proto"; import { Stats } from "../lib/proto";
import { studiedToday } from "../lib/time"; import { studiedToday } from "../lib/time";
import { localizedNumber } from "../lib/i18n";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
export interface TodayData { export interface TodayData {
@ -73,8 +74,8 @@ export function gatherData(data: Stats.GraphsResponse): TodayData {
const studiedTodayText = studiedToday(answerCount, answerMillis / 1000); const studiedTodayText = studiedToday(answerCount, answerMillis / 1000);
const againCount = answerCount - correctCount; const againCount = answerCount - correctCount;
let againCountText = tr.statisticsTodayAgainCount(); let againCountText = tr.statisticsTodayAgainCount();
againCountText += ` ${againCount} (${((againCount / answerCount) * 100).toFixed( againCountText += ` ${againCount} (${localizedNumber(
2, (againCount / answerCount) * 100,
)}%)`; )}%)`;
const typeCounts = tr.statisticsTodayTypeCounts({ const typeCounts = tr.statisticsTodayTypeCounts({
learnCount, learnCount,

View file

@ -41,13 +41,19 @@ export function weekdayLabel(n: number): string {
let langs: string[] = []; let langs: string[] = [];
export function toLocaleString( export function localizedDate(
date: Date, date: Date,
options?: Intl.DateTimeFormatOptions, options?: Intl.DateTimeFormatOptions,
): string { ): string {
return date.toLocaleDateString(langs, options); 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( export function localeCompare(
first: string, first: string,
second: string, second: string,