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( copy_files_into_group(
name = "congrats_page", name = "congrats_page",
srcs = [ srcs = [
"congrats.css",
"congrats.html", "congrats.html",
"congrats.js", "congrats.js",
], ],

View file

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

View file

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

View file

@ -3,14 +3,15 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" id="viewport" content="width=device-width" /> <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> </head>
<body> <body>
<div id="main"></div> <div id="main"></div>
</body>
<script src="../js/vendor/protobuf.min.js"></script>
<script src="congrats.js"></script>
<script> <script>
anki.congrats(document.getElementById("main")); anki.congrats(document.getElementById("main"));
</script> </script>
</body>
</html> </html>

View file

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

View file

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

View file

@ -1,12 +1,15 @@
<script lang="typescript"> <script lang="typescript">
import type { GraphBounds } from "./graph-helpers"; import type { GraphBounds } from "./graph-helpers";
export let bounds: GraphBounds; export let bounds: GraphBounds;
</script> </script>
<g <style lang="scss">
class="x-ticks no-domain-line" g :global(.domain) {
transform={`translate(0,${bounds.height - bounds.marginBottom})`} /> opacity: 0.05;
<g class="y-ticks no-domain-line" transform={`translate(${bounds.marginLeft}, 0)`} /> }
<g </style>
class="y2-ticks no-domain-line"
transform={`translate(${bounds.width - bounds.marginRight}, 0)`} /> <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") load("@io_bazel_rules_sass//:defs.bzl", "sass_binary")
sass_binary( sass_binary(
name = "graphs_shared", name = "ticks",
src = "graphs_shared.scss", src = "ticks.scss",
visibility = ["//visibility:public"],
deps = [
"//ts/sass:core_lib",
],
) )
svelte_files = glob(["*.svelte"]) svelte_files = glob(["*.svelte"])
@ -24,8 +20,8 @@ compile_svelte(
) )
ts_library( ts_library(
name = "bootstrap", name = "index",
srcs = ["bootstrap.ts"], srcs = ["index.ts"],
deps = [ deps = [
"GraphsPage", "GraphsPage",
"lib", "lib",
@ -39,7 +35,7 @@ ts_library(
name = "lib", name = "lib",
srcs = glob( srcs = glob(
["*.ts"], ["*.ts"],
exclude = ["bootstrap.ts"], exclude = ["index.ts"],
), ),
deps = [ deps = [
"//ts/lib", "//ts/lib",
@ -62,7 +58,7 @@ esbuild(
"--global-name=anki", "--global-name=anki",
"--inject:ts/protobuf-shim.js", "--inject:ts/protobuf-shim.js",
], ],
entry_point = "bootstrap.ts", entry_point = "index.ts",
external = [ external = [
"protobufjs/light", "protobufjs/light",
], ],
@ -72,8 +68,9 @@ esbuild(
"//ts/lib", "//ts/lib",
"//ts/lib:backend_proto", "//ts/lib:backend_proto",
"//ts/lib:fluent_proto", "//ts/lib:fluent_proto",
"bootstrap", ":index",
"graphs_shared", ":ticks",
"//ts/sass:core_css",
] + svelte_names, ] + svelte_names,
) )

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,19 @@
<script lang="typescript"> <script lang="typescript">
import { createEventDispatcher } from "svelte";
import type { I18n } from "anki/i18n"; 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 type { HistogramData } from "./histogram-graph";
import { GraphRange, RevlogRange } from "./graph-helpers"; import { GraphRange, RevlogRange } from "./graph-helpers";
import type { TableDatum, SearchEventMap } from "./graph-helpers"; import type { TableDatum, SearchEventMap } from "./graph-helpers";
import { gatherData, buildHistogram } from "./future-due"; import { gatherData, buildHistogram } from "./future-due";
import type { GraphData } 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"; import type { PreferenceStore } from "./preferences";
export let sourceData: pb.BackendProto.GraphsOut | null = null; export let sourceData: pb.BackendProto.GraphsOut | null = null;
@ -44,12 +48,8 @@
const backlogLabel = i18n.tr(i18n.TR.STATISTICS_BACKLOG_CHECKBOX); const backlogLabel = i18n.tr(i18n.TR.STATISTICS_BACKLOG_CHECKBOX);
</script> </script>
<div class="graph" id="graph-future-due"> <Graph {title} {subtitle}>
<h1>{title}</h1> <InputBox>
<div class="subtitle">{subtitle}</div>
<div class="range-box-inner">
{#if graphData && graphData.haveBacklog} {#if graphData && graphData.haveBacklog}
<label> <label>
<input type="checkbox" bind:checked={$futureDueShowBacklog} /> <input type="checkbox" bind:checked={$futureDueShowBacklog} />
@ -58,9 +58,9 @@
{/if} {/if}
<GraphRangeRadios bind:graphRange {i18n} revlogRange={RevlogRange.All} /> <GraphRangeRadios bind:graphRange {i18n} revlogRange={RevlogRange.All} />
</div> </InputBox>
<HistogramGraph data={histogramData} {i18n} /> <HistogramGraph data={histogramData} {i18n} />
<TableData {i18n} {tableData} /> <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"> <script lang="typescript">
import "./graphs_shared.css"; import "../sass/core.css";
import type { SvelteComponent } from "svelte/internal"; import type { SvelteComponent } from "svelte/internal";
import type { I18n } from "anki/i18n"; import type { I18n } from "anki/i18n";
import type { PreferenceStore } from "./preferences"; import type { PreferenceStore } from "./preferences";
@ -52,7 +53,20 @@
}; };
</script> </script>
{#if controller} <style lang="scss">
@media only screen and (max-width: 600px) {
.base {
font-size: 12px;
}
}
.no-focus-outline:focus {
outline: 0;
}
</style>
<div class="base">
{#if controller}
<svelte:component <svelte:component
this={controller} this={controller}
{i18n} {i18n}
@ -60,9 +74,9 @@
{days} {days}
{active} {active}
on:update={refresh} /> on:update={refresh} />
{/if} {/if}
{#if sourceData} {#if sourceData}
<div tabindex="-1" class="no-focus-outline"> <div tabindex="-1" class="no-focus-outline">
{#each graphs as graph} {#each graphs as graph}
<svelte:component <svelte:component
@ -75,4 +89,5 @@
on:search={browserSearch} /> on:search={browserSearch} />
{/each} {/each}
</div> </div>
{/if} {/if}
</div>

View file

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

View file

@ -1,13 +1,16 @@
<script lang="typescript"> <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 pb from "anki/backend_proto";
import type { I18n } from "anki/i18n"; 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 NoDataOverlay from "./NoDataOverlay.svelte";
import GraphRangeRadios from "./GraphRangeRadios.svelte"; import GraphRangeRadios from "./GraphRangeRadios.svelte";
import CumulativeOverlay from "./CumulativeOverlay.svelte"; import CumulativeOverlay from "./CumulativeOverlay.svelte";
import HoverColumns from "./HoverColumns.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 sourceData: pb.BackendProto.GraphsOut | null = null;
export let i18n: I18n; export let i18n: I18n;
@ -26,14 +29,10 @@
const subtitle = i18n.tr(i18n.TR.STATISTICS_HOURS_SUBTITLE); const subtitle = i18n.tr(i18n.TR.STATISTICS_HOURS_SUBTITLE);
</script> </script>
<div class="graph" id="graph-hour"> <Graph {title} {subtitle}>
<h1>{title}</h1> <InputBox>
<div class="subtitle">{subtitle}</div>
<div class="range-box-inner">
<GraphRangeRadios bind:graphRange {i18n} {revlogRange} followRevlog={true} /> <GraphRangeRadios bind:graphRange {i18n} {revlogRange} followRevlog={true} />
</div> </InputBox>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}> <svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<g class="bars" /> <g class="bars" />
@ -42,4 +41,4 @@
<AxisTicks {bounds} /> <AxisTicks {bounds} />
<NoDataOverlay {bounds} {i18n} /> <NoDataOverlay {bounds} {i18n} />
</svg> </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"> <script lang="typescript">
import { timeSpan, MONTH } from "anki/time"; import { timeSpan, MONTH } from "anki/time";
import type { I18n } from "anki/i18n"; 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 type { HistogramData } from "./histogram-graph";
import { import {
gatherIntervalData, gatherIntervalData,
@ -8,11 +16,7 @@
prepareIntervalData, prepareIntervalData,
} from "./intervals"; } from "./intervals";
import type { IntervalGraphData } 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 type { TableDatum, SearchEventMap } from "./graph-helpers";
import TableData from "./TableData.svelte";
import { createEventDispatcher } from "svelte";
import type { PreferenceStore } from "./preferences"; import type { PreferenceStore } from "./preferences";
export let sourceData: pb.BackendProto.GraphsOut | null = null; export let sourceData: pb.BackendProto.GraphsOut | null = null;
@ -42,17 +46,13 @@
} }
const title = i18n.tr(i18n.TR.STATISTICS_INTERVALS_TITLE); 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 month = timeSpan(i18n, 1 * MONTH);
const all = i18n.tr(i18n.TR.STATISTICS_RANGE_ALL_TIME); const all = i18n.tr(i18n.TR.STATISTICS_RANGE_ALL_TIME);
const subtitle = i18n.tr(i18n.TR.STATISTICS_INTERVALS_SUBTITLE);
</script> </script>
<div class="graph intervals" id="graph-intervals"> <Graph {title} {subtitle}>
<h1>{title}</h1> <InputBox>
<div class="subtitle">{subtitle}</div>
<div class="range-box-inner">
<label> <label>
<input type="radio" bind:group={range} value={IntervalRange.Month} /> <input type="radio" bind:group={range} value={IntervalRange.Month} />
{month} {month}
@ -69,9 +69,9 @@
<input type="radio" bind:group={range} value={IntervalRange.All} /> <input type="radio" bind:group={range} value={IntervalRange.All} />
{all} {all}
</label> </label>
</div> </InputBox>
<HistogramGraph data={histogramData} {i18n} /> <HistogramGraph data={histogramData} {i18n} />
<TableData {i18n} {tableData} /> <TableData {i18n} {tableData} />
</div> </Graph>

View file

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

View file

@ -1,6 +1,8 @@
<script lang="typescript"> <script lang="typescript">
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import InputBox from "./InputBox.svelte";
import type { I18n } from "anki/i18n"; import type { I18n } from "anki/i18n";
import { RevlogRange, daysToRevlogRange } from "./graph-helpers"; import { RevlogRange, daysToRevlogRange } from "./graph-helpers";
@ -81,10 +83,55 @@
const all = i18n.tr(i18n.TR.STATISTICS_RANGE_ALL_HISTORY); const all = i18n.tr(i18n.TR.STATISTICS_RANGE_ALL_HISTORY);
</script> </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="range-box">
<div class="spin" class:active></div> <div class="spin" class:active></div>
<div class="range-box-inner"> <InputBox>
<label> <label>
<input type="radio" bind:group={searchRange} value={SearchRange.Deck} /> <input type="radio" bind:group={searchRange} value={SearchRange.Deck} />
{deck} {deck}
@ -105,9 +152,9 @@
searchRange = SearchRange.Custom; searchRange = SearchRange.Custom;
}} }}
placeholder={searchLabel} /> placeholder={searchLabel} />
</div> </InputBox>
<div class="range-box-inner"> <InputBox>
<label> <label>
<input type="radio" bind:group={revlogRange} value={RevlogRange.Year} /> <input type="radio" bind:group={revlogRange} value={RevlogRange.Year} />
{year} {year}
@ -116,7 +163,7 @@
<input type="radio" bind:group={revlogRange} value={RevlogRange.All} /> <input type="radio" bind:group={revlogRange} value={RevlogRange.All} />
{all} {all}
</label> </label>
</div> </InputBox>
</div> </div>
<div class="range-box-pad" /> <div class="range-box-pad" />

View file

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

View file

@ -6,7 +6,22 @@
export let tableData: TableDatum[]; export let tableData: TableDatum[];
</script> </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()}> <table dir={i18n.direction()}>
{#each tableData as { label, value }} {#each tableData as { label, value }}
<tr> <tr>

View file

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

View file

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

View file

@ -3,9 +3,9 @@
/* eslint /* eslint
@typescript-eslint/no-non-null-assertion: "off", @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 pb from "anki/backend_proto";
import { import {
interpolateBlues, interpolateBlues,
@ -21,6 +21,7 @@ import {
timeSaturday, timeSaturday,
} from "d3"; } from "d3";
import type { CountableTimeInterval } from "d3"; import type { CountableTimeInterval } from "d3";
import { showTooltip, hideTooltip } from "./tooltip"; import { showTooltip, hideTooltip } from "./tooltip";
import { import {
GraphBounds, GraphBounds,
@ -28,7 +29,7 @@ import {
RevlogRange, RevlogRange,
SearchDispatch, SearchDispatch,
} from "./graph-helpers"; } from "./graph-helpers";
import type { I18n } from "anki/i18n"; import { clickableClass } from "./graph-styles";
export interface GraphData { export interface GraphData {
// indexed by day, where day is relative to today // indexed by day, where day is relative to today
@ -203,24 +204,22 @@ export function renderCalendar(
.data(data) .data(data)
.join("rect") .join("rect")
.attr("fill", emptyColour) .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("height", height - 2)
.attr("x", (d) => x(d.weekNumber + 1)!) .attr("x", (d: DayDatum) => x(d.weekNumber + 1)!)
.attr("y", (d) => bounds.marginTop + d.weekDay * height) .attr("y", (d: DayDatum) => bounds.marginTop + d.weekDay * height)
.on("mousemove", (event: MouseEvent, d: DayDatum) => { .on("mousemove", (event: MouseEvent, d: DayDatum) => {
const [x, y] = pointer(event, document.body); const [x, y] = pointer(event, document.body);
showTooltip(tooltipText(d), x, y); showTooltip(tooltipText(d), x, y);
}) })
.on("mouseout", hideTooltip) .on("mouseout", hideTooltip)
.attr("class", (d: any): string => { .attr("class", (d: DayDatum): string => (d.count > 0 ? clickableClass : ""))
return d.count > 0 ? "clickable" : ""; .on("click", function (_event: MouseEvent, d: DayDatum) {
})
.on("click", function (_event: MouseEvent, d: any) {
if (d.count > 0) { if (d.count > 0) {
dispatch("search", { query: `"prop:rated=${d.day}"` }); dispatch("search", { query: `"prop:rated=${d.day}"` });
} }
}) })
.transition() .transition()
.duration(800) .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 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";
import { clickableClass } from "./graph-styles";
export interface HistogramData { export interface HistogramData {
scale: ScaleLinear<number, number>; scale: ScaleLinear<number, number>;
@ -57,13 +58,13 @@ export function histogramGraph(
const binValue = data.binValue ?? ((bin: any): number => bin.length as number); const binValue = data.binValue ?? ((bin: any): number => bin.length as number);
const x = data.scale.range([bounds.marginLeft, bounds.width - bounds.marginRight]); const x = data.scale.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
svg.select<SVGGElement>(".x-ticks") svg.select<SVGGElement>(".x-ticks").call((selection) =>
.transition(trans) selection.transition(trans).call(
.call(
axisBottom(x) axisBottom(x)
.ticks(7) .ticks(7)
.tickSizeOuter(0) .tickSizeOuter(0)
.tickFormat((data.xTickFormat ?? null) as any) .tickFormat((data.xTickFormat ?? null) as any)
)
); );
// y scale // y scale
@ -73,12 +74,12 @@ export function histogramGraph(
.range([bounds.height - bounds.marginBottom, bounds.marginTop]) .range([bounds.height - bounds.marginBottom, bounds.marginTop])
.domain([0, yMax]) .domain([0, yMax])
.nice(); .nice();
svg.select<SVGGElement>(".y-ticks") svg.select<SVGGElement>(".y-ticks").call((selection) =>
.transition(trans) selection.transition(trans).call(
.call(
axisLeft(y) axisLeft(y)
.ticks(bounds.height / 50) .ticks(bounds.height / 50)
.tickSizeOuter(0) .tickSizeOuter(0)
)
); );
// x bars // x bars
@ -125,15 +126,15 @@ export function histogramGraph(
const yAreaScale = y.copy().domain([0, data.total]).nice(); const yAreaScale = y.copy().domain([0, data.total]).nice();
if (data.showArea && data.bins.length && areaData.slice(-1)[0]) { if (data.showArea && data.bins.length && areaData.slice(-1)[0]) {
svg.select<SVGGElement>(".y2-ticks") svg.select<SVGGElement>(".y2-ticks").call((selection) =>
.transition(trans) selection.transition(trans).call(
.call(
axisRight(yAreaScale) axisRight(yAreaScale)
.ticks(bounds.height / 50) .ticks(bounds.height / 50)
.tickSizeOuter(0) .tickSizeOuter(0)
)
); );
svg.select("path.cumulative-overlay") svg.select("path.area")
.datum(areaData as any) .datum(areaData as any)
.attr( .attr(
"d", "d",
@ -179,7 +180,7 @@ export function histogramGraph(
if (data.onClick) { if (data.onClick) {
hoverzone hoverzone
.filter(([bin]) => bin.length > 0) .filter(([bin]) => bin.length > 0)
.attr("class", "clickable") .attr("class", clickableClass)
.on("click", (_event, [bin]) => data.onClick!(bin)); .on("click", (_event, [bin]) => data.onClick!(bin));
} }
} }

View file

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

View file

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

View file

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

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); background: var(--window-bg);
margin: 1em; margin: 1em;
transition: opacity 0.5s ease-out; transition: opacity 0.5s ease-out;
overscroll-behavior: none;
} }
a { a {