add 'no data' overlay when graph empty

This commit is contained in:
Damien Elmes 2020-07-06 14:01:49 +10:00
parent 19541c4a9d
commit 0d287330c3
24 changed files with 244 additions and 150 deletions

View file

@ -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

View file

@ -44,31 +44,29 @@
const subtitle = i18n.tr(i18n.TR.STATISTICS_ADDED_SUBTITLE);
</script>
{#if histogramData}
<div class="graph">
<h1>{title}</h1>
<div class="graph">
<h1>{title}</h1>
<div class="range-box-inner">
<label>
<input type="radio" bind:group={range} value={AddedRange.Month} />
{month}
</label>
<label>
<input type="radio" bind:group={range} value={AddedRange.Quarter} />
{month3}
</label>
<label>
<input type="radio" bind:group={range} value={AddedRange.Year} />
{year}
</label>
<label>
<input type="radio" bind:group={range} value={AddedRange.AllTime} />
{all}
</label>
</div>
<div class="subtitle">{subtitle}</div>
<HistogramGraph data={histogramData} />
<div class="range-box-inner">
<label>
<input type="radio" bind:group={range} value={AddedRange.Month} />
{month}
</label>
<label>
<input type="radio" bind:group={range} value={AddedRange.Quarter} />
{month3}
</label>
<label>
<input type="radio" bind:group={range} value={AddedRange.Year} />
{year}
</label>
<label>
<input type="radio" bind:group={range} value={AddedRange.AllTime} />
{all}
</label>
</div>
{/if}
<div class="subtitle">{subtitle}</div>
<HistogramGraph data={histogramData} {i18n} />
</div>

View file

@ -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 @@
<g class="bars" />
<g class="hoverzone" />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} {i18n} />
</svg>
</div>

View file

@ -1,9 +1,8 @@
<script lang="typescript">
import ReviewsGraph from "./ReviewsGraph.svelte";
import NoDataOverlay from "./NoDataOverlay.svelte";
import AxisTicks from "./AxisTicks.svelte";
import { defaultGraphBounds, RevlogRange } from "./graphs";
import { GraphData, gatherData, renderCalendar, ReviewRange } from "./calendar";
import { GraphData, gatherData, renderCalendar } from "./calendar";
import pb from "../backend/proto";
import { timeSpan, MONTH, YEAR } from "../time";
import { I18n } from "../i18n";
@ -76,6 +75,7 @@
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<g class="days" />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} {i18n} />
</svg>
</div>

View file

@ -4,7 +4,7 @@
import pb from "../backend/proto";
import { I18n } from "../i18n";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let sourceData: pb.BackendProto.GraphsOut;
export let i18n: I18n;
let svg = null as HTMLElement | SVGElement | null;
@ -15,27 +15,31 @@
bounds.marginRight = 20;
bounds.marginTop = 0;
let graphData: GraphData | null = null;
$: if (sourceData) {
let graphData: GraphData;
$: {
graphData = gatherData(sourceData, i18n);
}
$: if (graphData) {
renderCards(svg as any, bounds, graphData);
}
const total = i18n.tr(i18n.TR.STATISTICS_COUNTS_TOTAL_CARDS);
</script>
{#if graphData}
<div class="graph">
<h1>{graphData.title}</h1>
<style>
svg {
transition: opacity 1s;
}
</style>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<g class="days" />
</svg>
<div class="graph">
<h1>{graphData.title}</h1>
<div class="centered">{total}: {graphData.totalCards}</div>
<svg
bind:this={svg}
viewBox={`0 0 ${bounds.width} ${bounds.height}`}
style="opacity: {graphData.totalCards ? 1 : 0}">
<g class="days" />
</svg>
</div>
{/if}
<div class="centered">{total}: {graphData.totalCards}</div>
</div>

View file

@ -19,12 +19,10 @@
const subtitle = i18n.tr(i18n.TR.STATISTICS_CARD_EASE_SUBTITLE);
</script>
{#if histogramData}
<div class="graph">
<h1>{title}</h1>
<div class="graph">
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
<div class="subtitle">{subtitle}</div>
<HistogramGraph data={histogramData} />
</div>
{/if}
<HistogramGraph data={histogramData} {i18n} />
</div>

View file

@ -52,38 +52,35 @@
const backlogLabel = i18n.tr(i18n.TR.STATISTICS_BACKLOG_CHECKBOX);
</script>
{#if histogramData}
<div class="graph">
<h1>{title}</h1>
<div class="graph">
<h1>{title}</h1>
<div class="range-box-inner">
<label>
<input type="checkbox" bind:checked={backlog} />
{backlogLabel}
</label>
<label>
<input type="radio" bind:group={range} value={FutureDueRange.Month} />
{month}
</label>
<label>
<input type="radio" bind:group={range} value={FutureDueRange.Quarter} />
{month3}
</label>
<label>
<input type="radio" bind:group={range} value={FutureDueRange.Year} />
{year}
</label>
<label>
<input type="radio" bind:group={range} value={FutureDueRange.AllTime} />
{all}
</label>
</div>
<div class="subtitle">{subtitle}</div>
<HistogramGraph data={histogramData} />
<div class="range-box-inner">
<label>
<input type="checkbox" bind:checked={backlog} />
{backlogLabel}
</label>
<label>
<input type="radio" bind:group={range} value={FutureDueRange.Month} />
{month}
</label>
<label>
<input type="radio" bind:group={range} value={FutureDueRange.Quarter} />
{month3}
</label>
<label>
<input type="radio" bind:group={range} value={FutureDueRange.Year} />
{year}
</label>
<label>
<input type="radio" bind:group={range} value={FutureDueRange.AllTime} />
{all}
</label>
</div>
{/if}
<div class="subtitle">{subtitle}</div>
<HistogramGraph data={histogramData} {i18n} />
</div>

View file

@ -141,13 +141,15 @@
</div>
<div class="range-box-pad" />
<TodayStats {sourceData} {i18n} />
<CardCounts {sourceData} {i18n} />
<CalendarGraph {sourceData} {revlogRange} {i18n} {nightMode} />
<FutureDue {sourceData} {revlogRange} {i18n} />
<ReviewsGraph {sourceData} {revlogRange} {i18n} />
<IntervalsGraph {sourceData} {i18n} />
<EaseGraph {sourceData} {i18n} />
<HourGraph {sourceData} {i18n} />
<ButtonsGraph {sourceData} {i18n} />
<AddedGraph {sourceData} {revlogRange} {i18n} />
{#if sourceData}
<TodayStats {sourceData} {i18n} />
<CardCounts {sourceData} {i18n} />
<CalendarGraph {sourceData} {revlogRange} {i18n} {nightMode} />
<FutureDue {sourceData} {revlogRange} {i18n} />
<ReviewsGraph {sourceData} {revlogRange} {i18n} />
<IntervalsGraph {sourceData} {i18n} />
<EaseGraph {sourceData} {i18n} />
<HourGraph {sourceData} {i18n} />
<ButtonsGraph {sourceData} {i18n} />
<AddedGraph {sourceData} {revlogRange} {i18n} />
{/if}

View file

@ -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);
</script>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
@ -18,4 +19,5 @@
<g class="hoverzone" />
<path class="area" />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} {i18n} />
</svg>

View file

@ -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 @@
<path class="area" />
<g class="hoverzone" />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} {i18n} />
</svg>
</div>

View file

@ -34,44 +34,36 @@
const subtitle = i18n.tr(i18n.TR.STATISTICS_INTERVALS_SUBTITLE);
</script>
{#if histogramData}
<div class="graph intervals">
<h1>{title}</h1>
<div class="graph intervals">
<h1>{title}</h1>
<div class="range-box-inner">
<label>
<input type="radio" bind:group={range} value={IntervalRange.Month} />
{month}
</label>
<label>
<input
type="radio"
bind:group={range}
value={IntervalRange.Percentile50} />
50%
</label>
<label>
<input
type="radio"
bind:group={range}
value={IntervalRange.Percentile95} />
95%
</label>
<label>
<input
type="radio"
bind:group={range}
value={IntervalRange.Percentile999} />
99.9%
</label>
<label>
<input type="radio" bind:group={range} value={IntervalRange.All} />
{all}
</label>
</div>
<div class="subtitle">{subtitle}</div>
<HistogramGraph data={histogramData} />
<div class="range-box-inner">
<label>
<input type="radio" bind:group={range} value={IntervalRange.Month} />
{month}
</label>
<label>
<input type="radio" bind:group={range} value={IntervalRange.Percentile50} />
50%
</label>
<label>
<input type="radio" bind:group={range} value={IntervalRange.Percentile95} />
95%
</label>
<label>
<input
type="radio"
bind:group={range}
value={IntervalRange.Percentile999} />
99.9%
</label>
<label>
<input type="radio" bind:group={range} value={IntervalRange.All} />
{all}
</label>
</div>
{/if}
<div class="subtitle">{subtitle}</div>
<HistogramGraph data={histogramData} {i18n} />
</div>

View file

@ -0,0 +1,14 @@
<script lang="typescript">
import { I18n } from "../i18n";
import { GraphBounds } from "./graphs";
export let bounds: GraphBounds;
export let i18n: I18n;
const noData = i18n.tr(i18n.TR.STATISTICS_NO_DATA);
</script>
<g class="no-data">
<rect x="0" y="0" width={bounds.width} height={bounds.height} />
<text x="{bounds.width / 2}," y={bounds.height / 2} letter-spacing="3">
{noData}
</text>
</g>

View file

@ -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 @@
<path class="area" />
<g class="hoverzone" />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} {i18n} />
</svg>
</div>

View file

@ -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(

View file

@ -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]);

View file

@ -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++) {

View file

@ -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();

View file

@ -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))

View file

@ -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 {

View file

@ -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<Uint8Array> {
const resp = await fetch("/_anki/graphData", {
@ -66,3 +67,14 @@ export function defaultGraphBounds(): GraphBounds {
marginBottom: 40,
};
}
export function setDataAvailable(
svg: Selection<SVGElement, any, any, any>,
available: boolean
): void {
svg.select(".no-data")
.attr("pointer-events", available ? "none" : "all")
.transition()
.duration(600)
.attr("opacity", available ? 0 : 1);
}

View file

@ -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<number, number>;
@ -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<SVGGElement>(".x-ticks")
.transition(trans)

View file

@ -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])

View file

@ -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]);

View file

@ -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<SVGGElement>(".x-ticks")