merge in Henrik's TS/Svelte refactor with some changes

- The previous commits moved the majority of the remaining global css
into components; move the remaining @emotion/css references into
ticks.scss and the styling of the Graph.svelte. This is not as elegant
as the emotion solution, but builds a whole lot faster, and most of
our styling can be scoped to a component anyway.
- Leave the .html files in ts/ for now. AnkiMobile uses them, and
AnkiDroid likely will in the future too. In the long run we'll likely
move to loading the JS into an existing page instead of loading a
separate page, but at that point we can just exclude the .html file from
copy_files_into_group() without affecting other clients.

Closes #1074
This commit is contained in:
Damien Elmes 2021-03-21 22:47:52 +10:00
parent 95ccfc1ed3
commit 7d8f19e6e4
34 changed files with 467 additions and 402 deletions

View File

@ -13,6 +13,7 @@ copy_files_into_group(
copy_files_into_group(
name = "congrats_page",
srcs = [
"congrats.css",
"congrats.html",
"congrats.js",
],

View File

@ -10,8 +10,8 @@ svelte(
)
ts_library(
name = "bootstrap",
srcs = ["bootstrap.ts"],
name = "index",
srcs = ["index.ts"],
deps = [
"CongratsPage",
"lib",
@ -38,17 +38,19 @@ esbuild(
"--global-name=anki",
"--inject:ts/protobuf-shim.js",
],
entry_point = "bootstrap.ts",
entry_point = "index.ts",
external = [
"protobufjs/light",
],
output_css = True,
visibility = ["//visibility:public"],
deps = [
"CongratsPage",
"bootstrap",
"index",
"//ts/lib",
"//ts/lib:backend_proto",
"//ts/lib:fluent_proto",
"//ts/sass:core_css",
],
)

View File

@ -1,4 +1,6 @@
<script lang="ts">
import "../sass/core.css";
import { I18n } from "anki/i18n";
import pb from "anki/backend_proto";
import { buildNextLearnMsg } from "./lib";

View File

@ -3,14 +3,15 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" id="viewport" content="width=device-width" />
<link href="../css/core.css" rel="stylesheet" />
<link href="congrats.css" rel="stylesheet" />
<script src="../js/vendor/protobuf.min.js"></script>
<script src="congrats.js"></script>
</head>
<body>
<div id="main"></div>
<script>
anki.congrats(document.getElementById("main"));
</script>
</body>
<script src="../js/vendor/protobuf.min.js"></script>
<script src="congrats.js"></script>
<script>
anki.congrats(document.getElementById("main"));
</script>
</html>

View File

@ -1,11 +1,12 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { setupI18n } from "anki/i18n";
import CongratsPage from "./CongratsPage.svelte";
import { getCongratsInfo } from "./lib";
import { setupI18n } from "anki/i18n";
import { checkNightMode } from "anki/nightmode";
import CongratsPage from "./CongratsPage.svelte";
export async function congrats(target: HTMLDivElement): Promise<void> {
checkNightMode();
const i18n = await setupI18n();

View File

@ -1,15 +1,19 @@
<script lang="typescript">
import { RevlogRange, GraphRange } from "./graph-helpers";
import type { TableDatum, SearchEventMap } from "./graph-helpers";
import type { I18n } from "anki/i18n";
import type { HistogramData } from "./histogram-graph";
import { gatherData, buildHistogram } from "./added";
import type { GraphData } from "./added";
import type pb from "anki/backend_proto";
import { createEventDispatcher } from "svelte";
import Graph from "./Graph.svelte";
import InputBox from "./InputBox.svelte";
import HistogramGraph from "./HistogramGraph.svelte";
import GraphRangeRadios from "./GraphRangeRadios.svelte";
import TableData from "./TableData.svelte";
import { createEventDispatcher } from "svelte";
import { RevlogRange, GraphRange } from "./graph-helpers";
import type { TableDatum, SearchEventMap } from "./graph-helpers";
import type { HistogramData } from "./histogram-graph";
import { gatherData, buildHistogram } from "./added";
import type { GraphData } from "./added";
import type { PreferenceStore } from "./preferences";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
@ -42,16 +46,12 @@
const subtitle = i18n.tr(i18n.TR.STATISTICS_ADDED_SUBTITLE);
</script>
<div class="graph" id="graph-added">
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
<div class="range-box-inner">
<Graph {title} {subtitle}>
<InputBox>
<GraphRangeRadios bind:graphRange {i18n} revlogRange={RevlogRange.All} />
</div>
</InputBox>
<HistogramGraph data={histogramData} {i18n} />
<TableData {i18n} {tableData} />
</div>
</Graph>

View File

@ -1,12 +1,15 @@
<script lang="typescript">
import type { GraphBounds } from "./graph-helpers";
export let bounds: GraphBounds;
</script>
<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)`} />
<g
class="y2-ticks no-domain-line"
transform={`translate(${bounds.width - bounds.marginRight}, 0)`} />
<style lang="scss">
g :global(.domain) {
opacity: 0.05;
}
</style>
<g class="x-ticks" transform={`translate(0, ${bounds.height - bounds.marginBottom})`} />
<g class="y-ticks" transform={`translate(${bounds.marginLeft}, 0)`} />
<g class="y2-ticks" transform={`translate(${bounds.width - bounds.marginRight}, 0)`} />

View File

@ -6,12 +6,8 @@ load("//ts:esbuild.bzl", "esbuild")
load("@io_bazel_rules_sass//:defs.bzl", "sass_binary")
sass_binary(
name = "graphs_shared",
src = "graphs_shared.scss",
visibility = ["//visibility:public"],
deps = [
"//ts/sass:core_lib",
],
name = "ticks",
src = "ticks.scss",
)
svelte_files = glob(["*.svelte"])
@ -24,8 +20,8 @@ compile_svelte(
)
ts_library(
name = "bootstrap",
srcs = ["bootstrap.ts"],
name = "index",
srcs = ["index.ts"],
deps = [
"GraphsPage",
"lib",
@ -39,7 +35,7 @@ ts_library(
name = "lib",
srcs = glob(
["*.ts"],
exclude = ["bootstrap.ts"],
exclude = ["index.ts"],
),
deps = [
"//ts/lib",
@ -62,7 +58,7 @@ esbuild(
"--global-name=anki",
"--inject:ts/protobuf-shim.js",
],
entry_point = "bootstrap.ts",
entry_point = "index.ts",
external = [
"protobufjs/light",
],
@ -72,8 +68,9 @@ esbuild(
"//ts/lib",
"//ts/lib:backend_proto",
"//ts/lib:fluent_proto",
"bootstrap",
"graphs_shared",
":index",
":ticks",
"//ts/sass:core_css",
] + svelte_names,
)

View File

@ -1,12 +1,15 @@
<script lang="typescript">
import { defaultGraphBounds, GraphRange, RevlogRange } from "./graph-helpers";
import AxisTicks from "./AxisTicks.svelte";
import { renderButtons } from "./buttons";
import type pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
import Graph from "./Graph.svelte";
import InputBox from "./InputBox.svelte";
import AxisTicks from "./AxisTicks.svelte";
import NoDataOverlay from "./NoDataOverlay.svelte";
import GraphRangeRadios from "./GraphRangeRadios.svelte";
import HoverColumns from "./HoverColumns.svelte";
import { renderButtons } from "./buttons";
import { defaultGraphBounds, GraphRange, RevlogRange } from "./graph-helpers";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let i18n: I18n;
@ -26,14 +29,10 @@
const subtitle = i18n.tr(i18n.TR.STATISTICS_ANSWER_BUTTONS_SUBTITLE);
</script>
<div class="graph" id="graph-buttons">
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
<div class="range-box-inner">
<Graph {title} {subtitle}>
<InputBox>
<GraphRangeRadios bind:graphRange {i18n} {revlogRange} followRevlog={true} />
</div>
</InputBox>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<g class="bars" />
@ -41,4 +40,4 @@
<AxisTicks {bounds} />
<NoDataOverlay {bounds} {i18n} />
</svg>
</div>
</Graph>

View File

@ -1,14 +1,18 @@
<script lang="typescript">
import type pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
import { createEventDispatcher } from "svelte";
import Graph from "./Graph.svelte";
import InputBox from "./InputBox.svelte";
import NoDataOverlay from "./NoDataOverlay.svelte";
import AxisTicks from "./AxisTicks.svelte";
import { defaultGraphBounds, RevlogRange } from "./graph-helpers";
import type { SearchEventMap } from "./graph-helpers";
import { gatherData, renderCalendar } from "./calendar";
import type { PreferenceStore } from "./preferences";
import type { GraphData } from "./calendar";
import type pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let preferences: PreferenceStore | null = null;
@ -65,10 +69,8 @@
const title = i18n.tr(i18n.TR.STATISTICS_CALENDAR_TITLE);
</script>
<div class="graph" id="graph-calendar">
<h1>{title}</h1>
<div class="range-box-inner">
<Graph {title}>
<InputBox>
<span>
<button on:click={() => targetYear--} disabled={minYear >= targetYear}>
@ -80,7 +82,7 @@
</button>
</span>
</div>
</InputBox>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<g class="weekdays" />
@ -88,4 +90,4 @@
<AxisTicks {bounds} />
<NoDataOverlay {bounds} {i18n} />
</svg>
</div>
</Graph>

View File

@ -1,12 +1,16 @@
<script lang="typescript">
import { createEventDispatcher } from "svelte";
import type pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
import Graph from "./Graph.svelte";
import InputBox from "./InputBox.svelte";
import { defaultGraphBounds } from "./graph-helpers";
import type { SearchEventMap } from "./graph-helpers";
import { gatherData, renderCards } from "./card-counts";
import type { GraphData, TableDatum } from "./card-counts";
import type { PreferenceStore } from "./preferences";
import type pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
export let sourceData: pb.BackendProto.GraphsOut;
export let i18n: I18n;
@ -68,15 +72,13 @@
}
</style>
<div class="graph" id="graph-card-counts">
<h1>{graphData.title}</h1>
<div class="range-box-inner">
<Graph title={graphData.title}>
<InputBox>
<label>
<input type="checkbox" bind:checked={$cardCountsSeparateInactive} />
{label}
</label>
</div>
</InputBox>
<div class="counts-outer">
<svg
@ -113,4 +115,4 @@
</table>
</div>
</div>
</div>
</Graph>

View File

@ -1,12 +1,15 @@
<script lang="typescript">
import type pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
import { createEventDispatcher } from "svelte";
import HistogramGraph from "./HistogramGraph.svelte";
import Graph from "./Graph.svelte";
import TableData from "./TableData.svelte";
import type { HistogramData } from "./histogram-graph";
import { gatherData, prepareData } from "./ease";
import type pb from "anki/backend_proto";
import HistogramGraph from "./HistogramGraph.svelte";
import type { I18n } from "anki/i18n";
import type { TableDatum, SearchEventMap } from "./graph-helpers";
import TableData from "./TableData.svelte";
import { createEventDispatcher } from "svelte";
import type { PreferenceStore } from "./preferences";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
@ -32,12 +35,8 @@
const subtitle = i18n.tr(i18n.TR.STATISTICS_CARD_EASE_SUBTITLE);
</script>
<div class="graph" id="graph-ease">
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
<Graph {title} {subtitle}>
<HistogramGraph data={histogramData} {i18n} />
<TableData {i18n} {tableData} />
</div>
</Graph>

View File

@ -1,15 +1,19 @@
<script lang="typescript">
import { createEventDispatcher } from "svelte";
import type { I18n } from "anki/i18n";
import type pb from "anki/backend_proto";
import Graph from "./Graph.svelte";
import InputBox from "./InputBox.svelte";
import HistogramGraph from "./HistogramGraph.svelte";
import GraphRangeRadios from "./GraphRangeRadios.svelte";
import TableData from "./TableData.svelte";
import type { HistogramData } from "./histogram-graph";
import { GraphRange, RevlogRange } from "./graph-helpers";
import type { TableDatum, SearchEventMap } from "./graph-helpers";
import { gatherData, buildHistogram } from "./future-due";
import type { GraphData } from "./future-due";
import type pb from "anki/backend_proto";
import HistogramGraph from "./HistogramGraph.svelte";
import GraphRangeRadios from "./GraphRangeRadios.svelte";
import TableData from "./TableData.svelte";
import { createEventDispatcher } from "svelte";
import type { PreferenceStore } from "./preferences";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
@ -44,12 +48,8 @@
const backlogLabel = i18n.tr(i18n.TR.STATISTICS_BACKLOG_CHECKBOX);
</script>
<div class="graph" id="graph-future-due">
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
<div class="range-box-inner">
<Graph {title} {subtitle}>
<InputBox>
{#if graphData && graphData.haveBacklog}
<label>
<input type="checkbox" bind:checked={$futureDueShowBacklog} />
@ -58,9 +58,9 @@
{/if}
<GraphRangeRadios bind:graphRange {i18n} revlogRange={RevlogRange.All} />
</div>
</InputBox>
<HistogramGraph data={histogramData} {i18n} />
<TableData {i18n} {tableData} />
</div>
</Graph>

44
ts/graphs/Graph.svelte Normal file
View File

@ -0,0 +1,44 @@
<script context="module">
// custom tick styling
import "./ticks.css";
// see graph-style.ts for constants referencing global styles
</script>
<script lang="typescript">
export let title: string;
export let subtitle: string | null = null;
</script>
<style lang="scss">
.graph {
margin-left: auto;
margin-right: auto;
max-width: 60em;
page-break-inside: avoid;
}
h1 {
text-align: center;
margin-bottom: 0.25em;
margin-top: 1.5em;
}
.subtitle {
text-align: center;
margin-bottom: 1em;
}
.graph :global(.graph-element-clickable) {
cursor: pointer;
}
</style>
<div class="graph">
<h1>{title}</h1>
{#if subtitle}
<div class="subtitle">{subtitle}</div>
{/if}
<slot />
</div>

View File

@ -1,5 +1,6 @@
<script lang="typescript">
import "./graphs_shared.css";
import "../sass/core.css";
import type { SvelteComponent } from "svelte/internal";
import type { I18n } from "anki/i18n";
import type { PreferenceStore } from "./preferences";
@ -52,27 +53,41 @@
};
</script>
{#if controller}
<svelte:component
this={controller}
{i18n}
{search}
{days}
{active}
on:update={refresh} />
{/if}
<style lang="scss">
@media only screen and (max-width: 600px) {
.base {
font-size: 12px;
}
}
{#if sourceData}
<div tabindex="-1" class="no-focus-outline">
{#each graphs as graph}
<svelte:component
this={graph}
{sourceData}
{preferences}
{revlogRange}
{i18n}
{nightMode}
on:search={browserSearch} />
{/each}
</div>
{/if}
.no-focus-outline:focus {
outline: 0;
}
</style>
<div class="base">
{#if controller}
<svelte:component
this={controller}
{i18n}
{search}
{days}
{active}
on:update={refresh} />
{/if}
{#if sourceData}
<div tabindex="-1" class="no-focus-outline">
{#each graphs as graph}
<svelte:component
this={graph}
{sourceData}
{preferences}
{revlogRange}
{i18n}
{nightMode}
on:search={browserSearch} />
{/each}
</div>
{/if}
</div>

View File

@ -1,13 +1,14 @@
<script lang="typescript">
import type { HistogramData } from "./histogram-graph";
import { histogramGraph } from "./histogram-graph";
import type { I18n } from "anki/i18n";
import AxisTicks from "./AxisTicks.svelte";
import { defaultGraphBounds } from "./graph-helpers";
import NoDataOverlay from "./NoDataOverlay.svelte";
import CumulativeOverlay from "./CumulativeOverlay.svelte";
import HoverColumns from "./HoverColumns.svelte";
import type { I18n } from "anki/i18n";
import type { HistogramData } from "./histogram-graph";
import { histogramGraph } from "./histogram-graph";
import { defaultGraphBounds } from "./graph-helpers";
export let data: HistogramData | null = null;
export let i18n: I18n;

View File

@ -1,13 +1,16 @@
<script lang="typescript">
import { defaultGraphBounds, RevlogRange, GraphRange } from "./graph-helpers";
import AxisTicks from "./AxisTicks.svelte";
import { renderHours } from "./hours";
import type pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
import Graph from "./Graph.svelte";
import InputBox from "./InputBox.svelte";
import AxisTicks from "./AxisTicks.svelte";
import NoDataOverlay from "./NoDataOverlay.svelte";
import GraphRangeRadios from "./GraphRangeRadios.svelte";
import CumulativeOverlay from "./CumulativeOverlay.svelte";
import HoverColumns from "./HoverColumns.svelte";
import { defaultGraphBounds, RevlogRange, GraphRange } from "./graph-helpers";
import { renderHours } from "./hours";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let i18n: I18n;
@ -26,14 +29,10 @@
const subtitle = i18n.tr(i18n.TR.STATISTICS_HOURS_SUBTITLE);
</script>
<div class="graph" id="graph-hour">
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
<div class="range-box-inner">
<Graph {title} {subtitle}>
<InputBox>
<GraphRangeRadios bind:graphRange {i18n} {revlogRange} followRevlog={true} />
</div>
</InputBox>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<g class="bars" />
@ -42,4 +41,4 @@
<AxisTicks {bounds} />
<NoDataOverlay {bounds} {i18n} />
</svg>
</div>
</Graph>

19
ts/graphs/InputBox.svelte Normal file
View File

@ -0,0 +1,19 @@
<style lang="scss">
div {
display: flex;
justify-content: center;
@media only screen and (max-device-width: 480px) and (orientation: portrait) {
font-size: smaller;
}
& > :global(*) {
padding-left: 0.5em;
padding-right: 0.5em;
}
}
</style>
<div>
<slot />
</div>

View File

@ -1,6 +1,14 @@
<script lang="typescript">
import { timeSpan, MONTH } from "anki/time";
import type { I18n } from "anki/i18n";
import type pb from "anki/backend_proto";
import { createEventDispatcher } from "svelte";
import Graph from "./Graph.svelte";
import InputBox from "./InputBox.svelte";
import HistogramGraph from "./HistogramGraph.svelte";
import TableData from "./TableData.svelte";
import type { HistogramData } from "./histogram-graph";
import {
gatherIntervalData,
@ -8,11 +16,7 @@
prepareIntervalData,
} from "./intervals";
import type { IntervalGraphData } from "./intervals";
import type pb from "anki/backend_proto";
import HistogramGraph from "./HistogramGraph.svelte";
import type { TableDatum, SearchEventMap } from "./graph-helpers";
import TableData from "./TableData.svelte";
import { createEventDispatcher } from "svelte";
import type { PreferenceStore } from "./preferences";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
@ -42,17 +46,13 @@
}
const title = i18n.tr(i18n.TR.STATISTICS_INTERVALS_TITLE);
const subtitle = i18n.tr(i18n.TR.STATISTICS_INTERVALS_SUBTITLE);
const month = timeSpan(i18n, 1 * MONTH);
const all = i18n.tr(i18n.TR.STATISTICS_RANGE_ALL_TIME);
const subtitle = i18n.tr(i18n.TR.STATISTICS_INTERVALS_SUBTITLE);
</script>
<div class="graph intervals" id="graph-intervals">
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
<div class="range-box-inner">
<Graph {title} {subtitle}>
<InputBox>
<label>
<input type="radio" bind:group={range} value={IntervalRange.Month} />
{month}
@ -69,9 +69,9 @@
<input type="radio" bind:group={range} value={IntervalRange.All} />
{all}
</label>
</div>
</InputBox>
<HistogramGraph data={histogramData} {i18n} />
<TableData {i18n} {tableData} />
</div>
</Graph>

View File

@ -6,6 +6,19 @@
const noData = i18n.tr(i18n.TR.STATISTICS_NO_DATA);
</script>
<style lang="scss">
.no-data {
rect {
fill: var(--window-bg);
}
text {
text-anchor: middle;
fill: grey;
}
}
</style>
<g class="no-data">
<rect x="0" y="0" width={bounds.width} height={bounds.height} />
<text x="{bounds.width / 2}," y={bounds.height / 2}>{noData}</text>

View File

@ -1,6 +1,8 @@
<script lang="typescript">
import { createEventDispatcher } from "svelte";
import InputBox from "./InputBox.svelte";
import type { I18n } from "anki/i18n";
import { RevlogRange, daysToRevlogRange } from "./graph-helpers";
@ -81,10 +83,55 @@
const all = i18n.tr(i18n.TR.STATISTICS_RANGE_ALL_HISTORY);
</script>
<style lang="scss">
.range-box {
position: fixed;
z-index: 1;
top: 0;
width: 100%;
color: var(--text-fg);
background: var(--window-bg);
padding: 0.5em;
@media print {
position: absolute;
}
}
@keyframes spin {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
.spin {
display: inline-block;
position: absolute;
font-size: 2em;
animation: spin;
animation-duration: 1s;
animation-iteration-count: infinite;
opacity: 0;
&.active {
opacity: 0.5;
transition: opacity 1s;
}
}
.range-box-pad {
height: 2em;
}
</style>
<div class="range-box">
<div class="spin" class:active></div>
<div class="range-box-inner">
<InputBox>
<label>
<input type="radio" bind:group={searchRange} value={SearchRange.Deck} />
{deck}
@ -105,9 +152,9 @@
searchRange = SearchRange.Custom;
}}
placeholder={searchLabel} />
</div>
</InputBox>
<div class="range-box-inner">
<InputBox>
<label>
<input type="radio" bind:group={revlogRange} value={RevlogRange.Year} />
{year}
@ -116,7 +163,7 @@
<input type="radio" bind:group={revlogRange} value={RevlogRange.All} />
{all}
</label>
</div>
</InputBox>
</div>
<div class="range-box-pad" />

View File

@ -1,17 +1,21 @@
<script lang="typescript">
import AxisTicks from "./AxisTicks.svelte";
import { defaultGraphBounds, RevlogRange, GraphRange } from "./graph-helpers";
import type { TableDatum } from "./graph-helpers";
import { gatherData, renderReviews } from "./reviews";
import type { GraphData } from "./reviews";
import type pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
import Graph from "./Graph.svelte";
import InputBox from "./InputBox.svelte";
import NoDataOverlay from "./NoDataOverlay.svelte";
import CumulativeOverlay from "./CumulativeOverlay.svelte";
import GraphRangeRadios from "./GraphRangeRadios.svelte";
import TableData from "./TableData.svelte";
import AxisTicks from "./AxisTicks.svelte";
import HoverColumns from "./HoverColumns.svelte";
import { defaultGraphBounds, RevlogRange, GraphRange } from "./graph-helpers";
import type { TableDatum } from "./graph-helpers";
import { gatherData, renderReviews } from "./reviews";
import type { GraphData } from "./reviews";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let revlogRange: RevlogRange;
export let i18n: I18n;
@ -50,16 +54,12 @@
}
</script>
<div class="graph" id="graph-reviews">
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
<div class="range-box-inner">
<Graph {title} {subtitle}>
<InputBox>
<label> <input type="checkbox" bind:checked={showTime} /> {time} </label>
<GraphRangeRadios bind:graphRange {i18n} {revlogRange} followRevlog={true} />
</div>
</InputBox>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
{#each [4, 3, 2, 1, 0] as i}
@ -72,4 +72,4 @@
</svg>
<TableData {i18n} {tableData} />
</div>
</Graph>

View File

@ -6,7 +6,22 @@
export let tableData: TableDatum[];
</script>
<div class="centered">
<style lang="scss">
div {
display: flex;
justify-content: center;
}
.align-end {
text-align: end;
}
.align-start {
text-align: start;
}
</style>
<div>
<table dir={i18n.direction()}>
{#each tableData as { label, value }}
<tr>

View File

@ -1,9 +1,12 @@
<script lang="typescript">
import { gatherData } from "./today";
import type { TodayData } from "./today";
import type pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
import Graph from "./Graph.svelte";
import type { TodayData } from "./today";
import { gatherData } from "./today";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let i18n: I18n;
@ -13,14 +16,18 @@
}
</script>
{#if todayData}
<div class="graph" id="graph-today-stats">
<h1>{todayData.title}</h1>
<style lang="scss">
.legend {
text-align: center;
}
</style>
<div class="legend-outer">
{#if todayData}
<Graph title={todayData.title}>
<div class="legend">
{#each todayData.lines as line}
<div>{line}</div>
{/each}
</div>
</div>
</Graph>
{/if}

View File

@ -7,6 +7,7 @@
*/
import pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
import {
interpolateRdYlGn,
select,
@ -25,7 +26,6 @@ import {
GraphRange,
millisecondCutoffForRange,
} from "./graph-helpers";
import type { I18n } from "anki/i18n";
type ButtonCounts = [number, number, number, number];
@ -153,9 +153,8 @@ export function renderButtons(
const xGroup = scaleBand()
.domain(["learning", "young", "mature"])
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
svg.select<SVGGElement>(".x-ticks")
.transition(trans)
.call(
svg.select<SVGGElement>(".x-ticks").call((selection) =>
selection.transition(trans).call(
axisBottom(xGroup)
.tickFormat(((d: GroupKind) => {
let kind: string;
@ -174,7 +173,8 @@ export function renderButtons(
return `${kind} \u200e(${totalCorrect(d).percent}%)`;
}) as any)
.tickSizeOuter(0)
);
)
);
const xButton = scaleBand()
.domain(["1", "2", "3", "4"])
@ -189,13 +189,13 @@ export function renderButtons(
const y = scaleLinear()
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
.domain([0, yMax]);
svg.select<SVGGElement>(".y-ticks")
.transition(trans)
.call(
svg.select<SVGGElement>(".y-ticks").call((selection) =>
selection.transition(trans).call(
axisLeft(y)
.ticks(bounds.height / 50)
.tickSizeOuter(0)
);
)
);
// x bars

View File

@ -3,9 +3,9 @@
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
@typescript-eslint/no-explicit-any: "off",
*/
import type { I18n } from "anki/i18n";
import pb from "anki/backend_proto";
import {
interpolateBlues,
@ -21,6 +21,7 @@ import {
timeSaturday,
} from "d3";
import type { CountableTimeInterval } from "d3";
import { showTooltip, hideTooltip } from "./tooltip";
import {
GraphBounds,
@ -28,7 +29,7 @@ import {
RevlogRange,
SearchDispatch,
} from "./graph-helpers";
import type { I18n } from "anki/i18n";
import { clickableClass } from "./graph-styles";
export interface GraphData {
// indexed by day, where day is relative to today
@ -203,24 +204,22 @@ export function renderCalendar(
.data(data)
.join("rect")
.attr("fill", emptyColour)
.attr("width", (d) => x(d.weekNumber + 1)! - x(d.weekNumber)! - 2)
.attr("width", (d: DayDatum) => x(d.weekNumber + 1)! - x(d.weekNumber)! - 2)
.attr("height", height - 2)
.attr("x", (d) => x(d.weekNumber + 1)!)
.attr("y", (d) => bounds.marginTop + d.weekDay * height)
.attr("x", (d: DayDatum) => x(d.weekNumber + 1)!)
.attr("y", (d: DayDatum) => bounds.marginTop + d.weekDay * height)
.on("mousemove", (event: MouseEvent, d: DayDatum) => {
const [x, y] = pointer(event, document.body);
showTooltip(tooltipText(d), x, y);
})
.on("mouseout", hideTooltip)
.attr("class", (d: any): string => {
return d.count > 0 ? "clickable" : "";
})
.on("click", function (_event: MouseEvent, d: any) {
.attr("class", (d: DayDatum): string => (d.count > 0 ? clickableClass : ""))
.on("click", function (_event: MouseEvent, d: DayDatum) {
if (d.count > 0) {
dispatch("search", { query: `"prop:rated=${d.day}"` });
}
})
.transition()
.duration(800)
.attr("fill", (d) => (d.count === 0 ? emptyColour : blues(d.count)!));
.attr("fill", (d: DayDatum) => (d.count === 0 ? emptyColour : blues(d.count)!));
}

13
ts/graphs/graph-styles.ts Normal file
View File

@ -0,0 +1,13 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
// Global css classes used by subcomponents
// Graph.svelte
export const oddTickClass = "tick-odd";
export const clickableClass = "graph-element-clickable";
// It would be nice to define these in the svelte file that declares them,
// but currently this trips the tooling up:
// https://github.com/sveltejs/svelte/issues/5817
// export { oddTickClass, clickableClass } from "./Graph.svelte";

View File

@ -1,155 +0,0 @@
/* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
@use '../sass/core';
* {
overscroll-behavior: none;
}
.graph {
margin-left: auto;
margin-right: auto;
max-width: 60em;
page-break-inside: avoid;
}
.graph h1 {
text-align: center;
margin-bottom: 0.25em;
margin-top: 1.5em;
}
.no-domain-line .domain {
opacity: 0.05;
}
.tick {
line {
opacity: 0.1;
}
text {
opacity: 0.5;
font-size: 10px;
}
}
@media only screen and (max-width: 800px) {
.tick text {
font-size: 13px;
}
}
@media only screen and (max-width: 600px) {
body {
font-size: 12px;
}
.tick text {
font-size: 16px;
}
.tick-odd {
display: none;
}
}
.range-box {
position: fixed;
z-index: 1;
top: 0;
width: 100%;
color: var(--text-fg);
background: var(--window-bg);
padding: 0.5em;
}
@media print {
.range-box {
position: absolute;
}
}
.range-box-pad {
height: 2em;
}
.range-box-inner {
display: flex;
justify-content: center;
}
@media only screen and (max-device-width: 480px) and (orientation: portrait) {
.range-box-inner {
font-size: smaller;
}
}
.range-box-inner > * {
padding-left: 0.5em;
padding-right: 0.5em;
}
@keyframes spin {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
.spin {
position: absolute;
animation: spin;
animation-duration: 1s;
animation-iteration-count: infinite;
display: inline-block;
font-size: 2em;
opacity: 0;
}
.spin.active {
opacity: 0.5;
transition: opacity 1s;
}
.legend-outer {
text-align: center;
}
.subtitle {
text-align: center;
margin-bottom: 1em;
}
.no-data {
text {
text-anchor: middle;
fill: grey;
}
rect {
fill: var(--window-bg);
}
}
.centered {
display: flex;
justify-content: center;
}
.align-end {
text-align: end;
}
.align-start {
text-align: start;
}
.no-focus-outline:focus {
outline: 0;
}
.clickable {
cursor: pointer;
}

View File

@ -22,6 +22,7 @@ import {
import type { ScaleLinear, ScaleSequential, Bin } from "d3";
import { showTooltip, hideTooltip } from "./tooltip";
import { GraphBounds, setDataAvailable } from "./graph-helpers";
import { clickableClass } from "./graph-styles";
export interface HistogramData {
scale: ScaleLinear<number, number>;
@ -57,14 +58,14 @@ export function histogramGraph(
const binValue = data.binValue ?? ((bin: any): number => bin.length as number);
const x = data.scale.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
svg.select<SVGGElement>(".x-ticks")
.transition(trans)
.call(
svg.select<SVGGElement>(".x-ticks").call((selection) =>
selection.transition(trans).call(
axisBottom(x)
.ticks(7)
.tickSizeOuter(0)
.tickFormat((data.xTickFormat ?? null) as any)
);
)
);
// y scale
@ -73,13 +74,13 @@ export function histogramGraph(
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
.domain([0, yMax])
.nice();
svg.select<SVGGElement>(".y-ticks")
.transition(trans)
.call(
svg.select<SVGGElement>(".y-ticks").call((selection) =>
selection.transition(trans).call(
axisLeft(y)
.ticks(bounds.height / 50)
.tickSizeOuter(0)
);
)
);
// x bars
@ -125,15 +126,15 @@ export function histogramGraph(
const yAreaScale = y.copy().domain([0, data.total]).nice();
if (data.showArea && data.bins.length && areaData.slice(-1)[0]) {
svg.select<SVGGElement>(".y2-ticks")
.transition(trans)
.call(
svg.select<SVGGElement>(".y2-ticks").call((selection) =>
selection.transition(trans).call(
axisRight(yAreaScale)
.ticks(bounds.height / 50)
.tickSizeOuter(0)
);
)
);
svg.select("path.cumulative-overlay")
svg.select("path.area")
.datum(areaData as any)
.attr(
"d",
@ -179,7 +180,7 @@ export function histogramGraph(
if (data.onClick) {
hoverzone
.filter(([bin]) => bin.length > 0)
.attr("class", "clickable")
.attr("class", clickableClass)
.on("click", (_event, [bin]) => data.onClick!(bin));
}
}

View File

@ -6,6 +6,7 @@
@typescript-eslint/no-explicit-any: "off",
*/
import type { I18n } from "anki/i18n";
import pb from "anki/backend_proto";
import {
interpolateBlues,
@ -20,6 +21,7 @@ import {
area,
curveBasis,
} from "d3";
import { showTooltip, hideTooltip } from "./tooltip";
import {
GraphBounds,
@ -27,9 +29,7 @@ import {
GraphRange,
millisecondCutoffForRange,
} from "./graph-helpers";
import type { I18n } from "anki/i18n";
type ButtonCounts = [number, number, number, number];
import { oddTickClass } from "./graph-styles";
interface Hour {
hour: number;
@ -97,16 +97,12 @@ export function renderHours(
.range([bounds.marginLeft, bounds.width - bounds.marginRight])
.paddingInner(0.1);
svg.select<SVGGElement>(".x-ticks")
.transition(trans)
.call(axisBottom(x).tickSizeOuter(0))
.call((selection) =>
selection.transition(trans).call(axisBottom(x).tickSizeOuter(0))
)
.selectAll(".tick")
.selectAll("text")
.attr("class", (n: any) => {
if (n % 2 != 0) {
return "tick-odd";
} else {
return "";
}
});
.classed(oddTickClass, (d: any): boolean => d % 2 != 0);
const cappedRange = scaleLinear().range([0.1, 0.8]);
const colour = scaleSequential((n) => interpolateBlues(cappedRange(n)!)).domain([
@ -120,13 +116,13 @@ export function renderHours(
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
.domain([0, yMax])
.nice();
svg.select<SVGGElement>(".y-ticks")
.transition(trans)
.call(
svg.select<SVGGElement>(".y-ticks").call((selection) =>
selection.transition(trans).call(
axisLeft(y)
.ticks(bounds.height / 50)
.tickSizeOuter(0)
);
)
);
const yArea = y.copy().domain([0, 1]);
@ -162,14 +158,14 @@ export function renderHours(
)
);
svg.select<SVGGElement>(".y2-ticks")
.transition(trans)
.call(
svg.select<SVGGElement>(".y2-ticks").call((selection) =>
selection.transition(trans).call(
axisRight(yArea)
.ticks(bounds.height / 50)
.tickFormat((n: any) => `${Math.round(n * 100)}%`)
.tickSizeOuter(0)
);
)
);
svg.select("path.cumulative-overlay")
.datum(data)

View File

@ -1,16 +1,13 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-explicit-any: "off",
*/
import type { SvelteComponent } from "svelte/internal";
import { setupI18n } from "anki/i18n";
import GraphsPage from "./GraphsPage.svelte";
import { checkNightMode } from "anki/nightmode";
import GraphsPage from "./GraphsPage.svelte";
export { default as RangeBox } from "./RangeBox.svelte";
export { default as IntervalsGraph } from "./IntervalsGraph.svelte";
@ -28,7 +25,11 @@ export { RevlogRange } from "./graph-helpers";
export function graphs(
target: HTMLDivElement,
graphs: SvelteComponent[],
{ search = "deck:current", days = 365, controller = null as any } = {}
{
search = "deck:current",
days = 365,
controller = null as SvelteComponent | null,
} = {}
): void {
const nightMode = checkNightMode();

View File

@ -7,6 +7,8 @@
*/
import pb from "anki/backend_proto";
import type { I18n } from "anki/i18n";
import { timeSpan, dayLabel } from "anki/time";
import {
interpolateGreens,
interpolateReds,
@ -29,11 +31,9 @@ import {
} from "d3";
import type { Bin } from "d3";
import { showTooltip, hideTooltip } from "./tooltip";
import { GraphBounds, setDataAvailable, GraphRange } from "./graph-helpers";
import type { TableDatum } from "./graph-helpers";
import { timeSpan, dayLabel } from "anki/time";
import type { I18n } from "anki/i18n";
import { GraphBounds, setDataAvailable, GraphRange } from "./graph-helpers";
import { showTooltip, hideTooltip } from "./tooltip";
interface Reviews {
learn: number;
@ -167,9 +167,9 @@ export function renderReviews(
}
x.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
svg.select<SVGGElement>(".x-ticks")
.transition(trans)
.call(axisBottom(x).ticks(7).tickSizeOuter(0));
svg.select<SVGGElement>(".x-ticks").call((selection) =>
selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0))
);
// y scale
@ -190,14 +190,14 @@ export function renderReviews(
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
.domain([0, yMax])
.nice();
svg.select<SVGGElement>(".y-ticks")
.transition(trans)
.call(
svg.select<SVGGElement>(".y-ticks").call((selection) =>
selection.transition(trans).call(
axisLeft(y)
.ticks(bounds.height / 50)
.tickSizeOuter(0)
.tickFormat(yTickFormat as any)
);
)
);
// x bars
@ -331,14 +331,14 @@ export function renderReviews(
const yAreaScale = y.copy().domain([0, yCumMax]).nice();
if (yCumMax) {
svg.select<SVGGElement>(".y2-ticks")
.transition(trans)
.call(
svg.select<SVGGElement>(".y2-ticks").call((selection) =>
selection.transition(trans).call(
axisRight(yAreaScale)
.ticks(bounds.height / 50)
.tickFormat(yTickFormat as any)
.tickSizeOuter(0)
);
)
);
svg.select("path.cumulative-overlay")
.datum(areaData as any)

40
ts/graphs/ticks.scss Normal file
View File

@ -0,0 +1,40 @@
/* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
// Customizing the standard x and y tick markers and text on the graphs. The `tick`
// class is automatically added by d3. We apply our custom ticks only to ticks
// that are nested under a Graph component.
.graph {
.tick {
line {
opacity: 0.1;
}
text {
opacity: 0.5;
font-size: 10px;
}
}
@media only screen and (max-width: 800px) {
.tick {
text {
font-size: 13px;
}
}
}
@media only screen and (max-width: 600px) {
.tick {
text {
font-size: 16px;
// on small screens, hide every second row on graphs that have
// marked the ticks as odd
&.tick-odd {
display: none;
}
}
}
}
}

View File

@ -13,6 +13,7 @@ body {
background: var(--window-bg);
margin: 1em;
transition: opacity 0.5s ease-out;
overscroll-behavior: none;
}
a {