diff --git a/rslib/ftl/statistics.ftl b/rslib/ftl/statistics.ftl index 8f20ce765..8fc0c04ac 100644 --- a/rslib/ftl/statistics.ftl +++ b/rslib/ftl/statistics.ftl @@ -157,3 +157,5 @@ statistics-hours-range = From { $hourStart }:00~{ $hourEnd }:00 statistics-hours-correct = { $correct }/{ $total } correct ({ $percent }%) statistics-hours-title = Hourly Breakdown statistics-hours-subtitle = Review success rate for each hour of the day. +# shown when graph is empty +statistics-no-data = NO DATA diff --git a/ts/src/stats/AddedGraph.svelte b/ts/src/stats/AddedGraph.svelte index 4c7ce7f17..6188b6518 100644 --- a/ts/src/stats/AddedGraph.svelte +++ b/ts/src/stats/AddedGraph.svelte @@ -44,31 +44,29 @@ const subtitle = i18n.tr(i18n.TR.STATISTICS_ADDED_SUBTITLE); -{#if histogramData} -
-

{title}

+
+

{title}

-
- - - - -
- -
{subtitle}
- - +
+ + + +
-{/if} + +
{subtitle}
+ + +
diff --git a/ts/src/stats/ButtonsGraph.svelte b/ts/src/stats/ButtonsGraph.svelte index f4a053d4c..9ea4c1fb3 100644 --- a/ts/src/stats/ButtonsGraph.svelte +++ b/ts/src/stats/ButtonsGraph.svelte @@ -4,6 +4,7 @@ import { gatherData, GraphData, renderButtons } from "./buttons"; import pb from "../backend/proto"; import { I18n } from "../i18n"; + import NoDataOverlay from "./NoDataOverlay.svelte"; export let sourceData: pb.BackendProto.GraphsOut | null = null; export let i18n: I18n; @@ -29,5 +30,6 @@ +
diff --git a/ts/src/stats/CalendarGraph.svelte b/ts/src/stats/CalendarGraph.svelte index 41f2f6f29..fd2b3508c 100644 --- a/ts/src/stats/CalendarGraph.svelte +++ b/ts/src/stats/CalendarGraph.svelte @@ -1,9 +1,8 @@ -{#if graphData} -
-

{graphData.title}

+ - - - +
+

{graphData.title}

-
{total}: {graphData.totalCards}
+ + + -
-{/if} +
{total}: {graphData.totalCards}
+ +
diff --git a/ts/src/stats/EaseGraph.svelte b/ts/src/stats/EaseGraph.svelte index 8b44e39ee..38dbe4536 100644 --- a/ts/src/stats/EaseGraph.svelte +++ b/ts/src/stats/EaseGraph.svelte @@ -19,12 +19,10 @@ const subtitle = i18n.tr(i18n.TR.STATISTICS_CARD_EASE_SUBTITLE); -{#if histogramData} -
-

{title}

+
+

{title}

-
{subtitle}
+
{subtitle}
- -
-{/if} + +
diff --git a/ts/src/stats/FutureDue.svelte b/ts/src/stats/FutureDue.svelte index 8c9e0e93b..d9214bdb9 100644 --- a/ts/src/stats/FutureDue.svelte +++ b/ts/src/stats/FutureDue.svelte @@ -52,38 +52,35 @@ const backlogLabel = i18n.tr(i18n.TR.STATISTICS_BACKLOG_CHECKBOX); -{#if histogramData} +
+

{title}

-
-

{title}

- -
- - - - - - -
- -
{subtitle}
- - +
+ + + + +
-{/if} + +
{subtitle}
+ + + +
diff --git a/ts/src/stats/GraphsPage.svelte b/ts/src/stats/GraphsPage.svelte index 4aba44c70..6d1b93455 100644 --- a/ts/src/stats/GraphsPage.svelte +++ b/ts/src/stats/GraphsPage.svelte @@ -141,13 +141,15 @@
- - - - - - - - - - +{#if sourceData} + + + + + + + + + + +{/if} diff --git a/ts/src/stats/HistogramGraph.svelte b/ts/src/stats/HistogramGraph.svelte index ac9c635a1..062bf8e1f 100644 --- a/ts/src/stats/HistogramGraph.svelte +++ b/ts/src/stats/HistogramGraph.svelte @@ -2,15 +2,16 @@ import { HistogramData, histogramGraph } from "./histogram-graph"; import AxisTicks from "./AxisTicks.svelte"; import { defaultGraphBounds } from "./graphs"; + import NoDataOverlay from "./NoDataOverlay.svelte"; + import { I18n } from "../i18n"; export let data: HistogramData | null = null; + export let i18n: I18n; let bounds = defaultGraphBounds(); let svg = null as HTMLElement | SVGElement | null; - $: if (data) { - histogramGraph(svg as SVGElement, bounds, data); - } + $: histogramGraph(svg as SVGElement, bounds, data); @@ -18,4 +19,5 @@ + diff --git a/ts/src/stats/HourGraph.svelte b/ts/src/stats/HourGraph.svelte index 34031e1fc..f86a4be17 100644 --- a/ts/src/stats/HourGraph.svelte +++ b/ts/src/stats/HourGraph.svelte @@ -4,6 +4,7 @@ import { gatherData, GraphData, renderHours } from "./hours"; import pb from "../backend/proto"; import { I18n } from "../i18n"; + import NoDataOverlay from "./NoDataOverlay.svelte"; export let sourceData: pb.BackendProto.GraphsOut | null = null; export let i18n: I18n; @@ -30,5 +31,6 @@ +
diff --git a/ts/src/stats/IntervalsGraph.svelte b/ts/src/stats/IntervalsGraph.svelte index c9a20d724..9b83026c9 100644 --- a/ts/src/stats/IntervalsGraph.svelte +++ b/ts/src/stats/IntervalsGraph.svelte @@ -34,44 +34,36 @@ const subtitle = i18n.tr(i18n.TR.STATISTICS_INTERVALS_SUBTITLE); -{#if histogramData} -
-

{title}

+
+

{title}

-
- - - - - -
- -
{subtitle}
- - +
+ + + + +
-{/if} + +
{subtitle}
+ + +
diff --git a/ts/src/stats/NoDataOverlay.svelte b/ts/src/stats/NoDataOverlay.svelte new file mode 100644 index 000000000..76d6b0613 --- /dev/null +++ b/ts/src/stats/NoDataOverlay.svelte @@ -0,0 +1,14 @@ + + + + + + {noData} + + diff --git a/ts/src/stats/ReviewsGraph.svelte b/ts/src/stats/ReviewsGraph.svelte index 211c8d964..f8e1b679b 100644 --- a/ts/src/stats/ReviewsGraph.svelte +++ b/ts/src/stats/ReviewsGraph.svelte @@ -6,6 +6,7 @@ import pb from "../backend/proto"; import { timeSpan, MONTH, YEAR } from "../time"; import { I18n } from "../i18n"; + import NoDataOverlay from "./NoDataOverlay.svelte"; export let sourceData: pb.BackendProto.GraphsOut | null = null; export let revlogRange: RevlogRange = RevlogRange.Month; @@ -93,6 +94,7 @@ +
diff --git a/ts/src/stats/added.ts b/ts/src/stats/added.ts index c8c9c55bf..555d3092b 100644 --- a/ts/src/stats/added.ts +++ b/ts/src/stats/added.ts @@ -7,7 +7,7 @@ */ import pb from "../backend/proto"; -import { extent, histogram } from "d3-array"; +import { extent, histogram, sum } from "d3-array"; import { scaleLinear, scaleSequential } from "d3-scale"; import { HistogramData } from "./histogram-graph"; import { interpolateBlues } from "d3-scale-chromatic"; @@ -69,6 +69,11 @@ export function buildHistogram( .domain(scale.domain() as any) .thresholds(scale.ticks(desiredBars))(data.daysAdded); + // empty graph? + if (!sum(bins, (bin) => bin.length)) { + return null; + } + const colourScale = scaleSequential(interpolateBlues).domain([xMin!, xMax]); function hoverText( diff --git a/ts/src/stats/buttons.ts b/ts/src/stats/buttons.ts index e7ce57d90..e0bc18444 100644 --- a/ts/src/stats/buttons.ts +++ b/ts/src/stats/buttons.ts @@ -13,7 +13,7 @@ import { select, mouse } from "d3-selection"; import { scaleLinear, scaleBand, scaleSequential } from "d3-scale"; import { axisBottom, axisLeft } from "d3-axis"; import { showTooltip, hideTooltip } from "./tooltip"; -import { GraphBounds } from "./graphs"; +import { GraphBounds, setDataAvailable } from "./graphs"; import { I18n } from "../i18n"; type ButtonCounts = [number, number, number, number]; @@ -103,6 +103,13 @@ export function renderButtons( const svg = select(svgElem); const trans = svg.transition().duration(600) as any; + if (!yMax) { + setDataAvailable(svg, false); + return; + } else { + setDataAvailable(svg, true); + } + const xGroup = scaleBand() .domain(["learning", "young", "mature"]) .range([bounds.marginLeft, bounds.width - bounds.marginRight]); diff --git a/ts/src/stats/calendar.ts b/ts/src/stats/calendar.ts index 52c985b1c..7b6e4d7f6 100644 --- a/ts/src/stats/calendar.ts +++ b/ts/src/stats/calendar.ts @@ -12,7 +12,7 @@ import "d3-transition"; import { select, mouse } from "d3-selection"; import { scaleLinear, scaleSequential } from "d3-scale"; import { showTooltip, hideTooltip } from "./tooltip"; -import { GraphBounds } from "./graphs"; +import { GraphBounds, setDataAvailable } from "./graphs"; import { timeDay, timeYear, timeWeek } from "d3-time"; import { I18n } from "../i18n"; @@ -80,6 +80,14 @@ export function renderCalendar( maxCount = count; } } + + if (!maxCount) { + setDataAvailable(svg, false); + return; + } else { + setDataAvailable(svg, true); + } + // fill in any blanks const startDate = timeYear(nowForYear); for (let i = 0; i < 366; i++) { diff --git a/ts/src/stats/card-counts.ts b/ts/src/stats/card-counts.ts index 1926674dd..ed4ed0055 100644 --- a/ts/src/stats/card-counts.ts +++ b/ts/src/stats/card-counts.ts @@ -94,12 +94,14 @@ export function renderCards( total: n, }; }); - const xMax = summed.slice(-1)[0]; + // ensuring a non-zero range makes a better animation + // in the empty data case + const xMax = Math.max(1, summed.slice(-1)[0]); const x = scaleLinear().domain([0, xMax]); const svg = select(svgElem); const trans = svg.transition().duration(600) as any; - x.range([bounds.marginLeft, bounds.width - bounds.marginRight]); + x.range([bounds.marginLeft, bounds.width - bounds.marginRight - bounds.marginLeft]); const tooltipText = (d: any): string => { const pct = ((d.count[1] / xMax) * 100).toFixed(2); @@ -113,7 +115,7 @@ export function renderCards( showTooltip(tooltipText(d), x, y); }) .transition(trans) - .attr("width", (d) => x(d.total) - bounds.marginLeft); + .attr("width", (d) => x(d.total)); }; data.reverse(); diff --git a/ts/src/stats/future-due.ts b/ts/src/stats/future-due.ts index 58a18dbac..2e501bf3c 100644 --- a/ts/src/stats/future-due.ts +++ b/ts/src/stats/future-due.ts @@ -88,6 +88,11 @@ export function buildHistogram( .domain(x.domain() as any) .thresholds(x.ticks(desiredBars))(data.entries() as any); + // empty graph? + if (!sum(bins, (bin) => bin.length)) { + return null; + } + const adjustedRange = scaleLinear().range([0.8, 0.3]); const colourScale = scaleSequential((n) => interpolateGreens(adjustedRange(n)) diff --git a/ts/src/stats/graphs.scss b/ts/src/stats/graphs.scss index 8a95c9b64..8b7fb9362 100644 --- a/ts/src/stats/graphs.scss +++ b/ts/src/stats/graphs.scss @@ -126,6 +126,16 @@ body.night-mode { color: $night-fg; } +.no-data { + text { + text-anchor: middle; + fill: grey; + } + rect { + fill: $day-bg; + } +} + .night-mode { .graph-tooltip { background: $night-bg; @@ -139,6 +149,9 @@ body.night-mode { fill: $night-fg; opacity: 0.1; } + .no-data rect { + fill: $night-bg; + } } .centered { diff --git a/ts/src/stats/graphs.ts b/ts/src/stats/graphs.ts index 9a8896b28..5b7258b0e 100644 --- a/ts/src/stats/graphs.ts +++ b/ts/src/stats/graphs.ts @@ -7,6 +7,7 @@ @typescript-eslint/ban-ts-ignore: "off" */ import pb from "../backend/proto"; +import { Selection } from "d3-selection"; async function fetchData(search: string, days: number): Promise { const resp = await fetch("/_anki/graphData", { @@ -66,3 +67,14 @@ export function defaultGraphBounds(): GraphBounds { marginBottom: 40, }; } + +export function setDataAvailable( + svg: Selection, + available: boolean +): void { + svg.select(".no-data") + .attr("pointer-events", available ? "none" : "all") + .transition() + .duration(600) + .attr("opacity", available ? 0 : 1); +} diff --git a/ts/src/stats/histogram-graph.ts b/ts/src/stats/histogram-graph.ts index 86806df44..9cfeb0632 100644 --- a/ts/src/stats/histogram-graph.ts +++ b/ts/src/stats/histogram-graph.ts @@ -13,7 +13,7 @@ import { scaleLinear, ScaleLinear, ScaleSequential } from "d3-scale"; import { axisBottom, axisLeft } from "d3-axis"; import { area, curveBasis } from "d3-shape"; import { showTooltip, hideTooltip } from "./tooltip"; -import { GraphBounds } from "./graphs"; +import { GraphBounds, setDataAvailable } from "./graphs"; export interface HistogramData { scale: ScaleLinear; @@ -33,13 +33,20 @@ export interface HistogramData { export function histogramGraph( svgElem: SVGElement, bounds: GraphBounds, - data: HistogramData + data: HistogramData | null ): void { - const binValue = data.binValue ?? ((bin: any): number => bin.length as number); - const svg = select(svgElem); const trans = svg.transition().duration(600) as any; + if (!data) { + setDataAvailable(svg, false); + return; + } else { + setDataAvailable(svg, true); + } + + const binValue = data.binValue ?? ((bin: any): number => bin.length as number); + const x = data.scale.range([bounds.marginLeft, bounds.width - bounds.marginRight]); svg.select(".x-ticks") .transition(trans) diff --git a/ts/src/stats/hours.ts b/ts/src/stats/hours.ts index e2b2d903d..0fcb61c45 100644 --- a/ts/src/stats/hours.ts +++ b/ts/src/stats/hours.ts @@ -13,7 +13,7 @@ import { select, mouse } from "d3-selection"; import { scaleLinear, scaleBand, scaleSequential } from "d3-scale"; import { axisBottom, axisLeft } from "d3-axis"; import { showTooltip, hideTooltip } from "./tooltip"; -import { GraphBounds } from "./graphs"; +import { GraphBounds, setDataAvailable } from "./graphs"; import { area, curveBasis } from "d3-shape"; import { I18n } from "../i18n"; @@ -66,6 +66,13 @@ export function renderHours( const svg = select(svgElem); const trans = svg.transition().duration(600) as any; + if (!yMax) { + setDataAvailable(svg, false); + return; + } else { + setDataAvailable(svg, true); + } + const x = scaleBand() .domain(data.map((d) => d.hour.toString())) .range([bounds.marginLeft, bounds.width - bounds.marginRight]) diff --git a/ts/src/stats/intervals.ts b/ts/src/stats/intervals.ts index 8ec794a00..9c8d54ba7 100644 --- a/ts/src/stats/intervals.ts +++ b/ts/src/stats/intervals.ts @@ -7,7 +7,7 @@ */ import pb from "../backend/proto"; -import { extent, histogram, quantile } from "d3-array"; +import { extent, histogram, quantile, sum } from "d3-array"; import { scaleLinear, scaleSequential } from "d3-scale"; import { CardQueue } from "../cards"; import { HistogramData } from "./histogram-graph"; @@ -98,6 +98,11 @@ export function prepareIntervalData( .domain(scale.domain() as any) .thresholds(scale.ticks(desiredBars))(allIntervals); + // empty graph? + if (!sum(bins, (bin) => bin.length)) { + return null; + } + // start slightly darker const shiftedMin = xMin! - Math.round((xMax - xMin!) / 10); const colourScale = scaleSequential(interpolateBlues).domain([shiftedMin, xMax]); diff --git a/ts/src/stats/reviews.ts b/ts/src/stats/reviews.ts index 6ce9bc790..fdfb67818 100644 --- a/ts/src/stats/reviews.ts +++ b/ts/src/stats/reviews.ts @@ -18,7 +18,7 @@ import { select, mouse } from "d3-selection"; import { scaleLinear, scaleSequential } from "d3-scale"; import { axisBottom, axisLeft } from "d3-axis"; import { showTooltip, hideTooltip } from "./tooltip"; -import { GraphBounds } from "./graphs"; +import { GraphBounds, setDataAvailable } from "./graphs"; import { area, curveBasis } from "d3-shape"; import { min, histogram, sum, max, Bin, cumsum } from "d3-array"; import { timeSpan, dayLabel } from "../time"; @@ -116,6 +116,9 @@ export function renderReviews( showTime: boolean, i18n: I18n ): void { + const svg = select(svgElem); + const trans = svg.transition().duration(600) as any; + const xMax = 1; let xMin = 0; // cap max to selected range @@ -144,8 +147,13 @@ export function renderReviews( .domain(x.domain() as any) .thresholds(x.ticks(desiredBars))(sourceMap.entries() as any); - const svg = select(svgElem); - const trans = svg.transition().duration(600) as any; + // empty graph? + if (!sum(bins, (bin) => bin.length)) { + setDataAvailable(svg, false); + return; + } else { + setDataAvailable(svg, true); + } x.range([bounds.marginLeft, bounds.width - bounds.marginRight]); svg.select(".x-ticks")