mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 06:22:22 -04:00
tidy up graph code
This commit is contained in:
parent
48a693f861
commit
1d1ed5b241
4 changed files with 190 additions and 206 deletions
|
@ -1,23 +1,16 @@
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import {
|
import { defaultGraphBounds } from "./graphs";
|
||||||
gatherIntervalData,
|
|
||||||
intervalGraph,
|
import { gatherIntervalData, intervalGraph, IntervalRange } from "./intervals";
|
||||||
IntervalUpdateFn,
|
|
||||||
IntervalRange,
|
|
||||||
} from "./intervals";
|
|
||||||
import type { IntervalGraphData } from "./intervals";
|
import type { IntervalGraphData } from "./intervals";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import pb from "../backend/proto";
|
import pb from "../backend/proto";
|
||||||
|
|
||||||
|
const bounds = defaultGraphBounds();
|
||||||
|
|
||||||
export let data: pb.BackendProto.GraphsOut | null = null;
|
export let data: pb.BackendProto.GraphsOut | null = null;
|
||||||
|
|
||||||
let svg = null as HTMLElement | SVGElement | null;
|
let svg = null as HTMLElement | SVGElement | null;
|
||||||
let updater = null as IntervalUpdateFn | null;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updater = intervalGraph(svg as SVGElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
let range = IntervalRange.Percentile95;
|
let range = IntervalRange.Percentile95;
|
||||||
|
|
||||||
let intervalData: IntervalGraphData | null = null;
|
let intervalData: IntervalGraphData | null = null;
|
||||||
|
@ -26,8 +19,8 @@
|
||||||
intervalData = gatherIntervalData(data);
|
intervalData = gatherIntervalData(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (intervalData && updater) {
|
$: if (intervalData) {
|
||||||
updater(intervalData, range);
|
intervalGraph(svg as SVGElement, bounds, intervalData, range);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -60,7 +53,25 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<svg bind:this={svg}>
|
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
|
||||||
|
<g class="bars" />
|
||||||
|
<g class="hoverzone" />
|
||||||
<path class="area" />
|
<path class="area" />
|
||||||
|
<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)`} />
|
||||||
|
<text
|
||||||
|
class="axis-label"
|
||||||
|
transform={`translate(${bounds.width / 2}, ${bounds.height - 5})`}>
|
||||||
|
Interval (days)
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
class="axis-label y-axis-label"
|
||||||
|
transform={`translate(${bounds.marginLeft / 3}, ${(bounds.height - bounds.marginBottom) / 2 + bounds.marginTop}) rotate(-180)`}>
|
||||||
|
Number of cards
|
||||||
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -54,5 +54,20 @@
|
||||||
.intervals .area {
|
.intervals .area {
|
||||||
opacity: 0.05;
|
opacity: 0.05;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
stroke: black;
|
fill: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-label {
|
||||||
|
text-anchor: middle;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.y-axis-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
rotate: 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hoverzone rect {
|
||||||
|
fill: none;
|
||||||
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,3 +39,23 @@ export enum GraphRange {
|
||||||
Year = 2,
|
Year = 2,
|
||||||
All = 3,
|
All = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GraphBounds {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
marginLeft: number;
|
||||||
|
marginRight: number;
|
||||||
|
marginTop: number;
|
||||||
|
marginBottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultGraphBounds(): GraphBounds {
|
||||||
|
return {
|
||||||
|
width: 600,
|
||||||
|
height: 250,
|
||||||
|
marginLeft: 100,
|
||||||
|
marginRight: 20,
|
||||||
|
marginTop: 20,
|
||||||
|
marginBottom: 40,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -4,20 +4,19 @@
|
||||||
/* eslint
|
/* eslint
|
||||||
@typescript-eslint/no-non-null-assertion: "off",
|
@typescript-eslint/no-non-null-assertion: "off",
|
||||||
@typescript-eslint/no-explicit-any: "off",
|
@typescript-eslint/no-explicit-any: "off",
|
||||||
@typescript-eslint/ban-ts-ignore: "off",
|
*/
|
||||||
@typescript-eslint/explicit-function-return-type: "off" */
|
|
||||||
|
|
||||||
import { select, mouse, Selection } from "d3-selection";
|
import "d3-transition";
|
||||||
|
import pb from "../backend/proto";
|
||||||
|
import { select, mouse } from "d3-selection";
|
||||||
import { cumsum, extent, max, histogram, quantile } from "d3-array";
|
import { cumsum, extent, max, histogram, quantile } from "d3-array";
|
||||||
import { interpolateBlues } from "d3-scale-chromatic";
|
import { interpolateBlues } from "d3-scale-chromatic";
|
||||||
import { scaleLinear, scaleSequential } from "d3-scale";
|
import { scaleLinear, scaleSequential } from "d3-scale";
|
||||||
import { axisBottom, axisLeft } from "d3-axis";
|
import { axisBottom, axisLeft } from "d3-axis";
|
||||||
import { area } from "d3-shape";
|
import { area } from "d3-shape";
|
||||||
import "d3-transition";
|
|
||||||
import { CardQueue } from "../cards";
|
import { CardQueue } from "../cards";
|
||||||
import { showTooltip, hideTooltip } from "./tooltip";
|
import { showTooltip, hideTooltip } from "./tooltip";
|
||||||
import pb from "../backend/proto";
|
import { GraphBounds } from "./graphs";
|
||||||
import { assertUnreachable } from "../typing";
|
|
||||||
|
|
||||||
export interface IntervalGraphData {
|
export interface IntervalGraphData {
|
||||||
intervals: number[];
|
intervals: number[];
|
||||||
|
@ -38,64 +37,22 @@ export function gatherIntervalData(data: pb.BackendProto.GraphsOut): IntervalGra
|
||||||
return { intervals };
|
return { intervals };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntervalUpdateFn = (
|
export function intervalGraph(
|
||||||
arg0: IntervalGraphData,
|
svgElem: SVGElement,
|
||||||
|
bounds: GraphBounds,
|
||||||
|
graphData: IntervalGraphData,
|
||||||
maxDays: IntervalRange
|
maxDays: IntervalRange
|
||||||
) => void;
|
): void {
|
||||||
|
const svg = select(svgElem);
|
||||||
|
const trans = svg.transition().duration(600) as any;
|
||||||
|
|
||||||
/// Creates an interval graph, returning a function used to update it.
|
|
||||||
export function intervalGraph(svgElem: SVGElement): IntervalUpdateFn {
|
|
||||||
const margin = { top: 20, right: 20, bottom: 40, left: 100 };
|
|
||||||
const height = 250;
|
|
||||||
const width = 600;
|
|
||||||
const xTicks = 6;
|
|
||||||
|
|
||||||
// svg elements
|
|
||||||
const svg = select(svgElem).attr("viewBox", [0, 0, width, height].join(" "));
|
|
||||||
const barGroup = svg.append("g");
|
|
||||||
const hoverGroup = svg.append("g");
|
|
||||||
const areaPath = svg.select("path.area");
|
|
||||||
const xAxisGroup = svg.append("g").classed("no-domain-line", true);
|
|
||||||
const yAxisGroup = svg.append("g").classed("no-domain-line", true);
|
|
||||||
|
|
||||||
// x axis
|
|
||||||
const xScale = scaleLinear()
|
|
||||||
.range([margin.left, width - margin.right])
|
|
||||||
.domain([0, 0]);
|
|
||||||
svg.append("text")
|
|
||||||
.attr("transform", `translate(${width / 2}, ${height - 5})`)
|
|
||||||
.style("text-anchor", "middle")
|
|
||||||
.style("font-size", 10)
|
|
||||||
.text("Interval (days)");
|
|
||||||
|
|
||||||
// y axis
|
|
||||||
const yScale = scaleLinear()
|
|
||||||
.domain([0, 0])
|
|
||||||
.range([height - margin.bottom, margin.top]);
|
|
||||||
svg.append("text")
|
|
||||||
.attr(
|
|
||||||
"transform",
|
|
||||||
`translate(${margin.left / 3}, ${
|
|
||||||
(height - margin.bottom) / 2 + margin.top
|
|
||||||
}) rotate(-180)`
|
|
||||||
)
|
|
||||||
.style("text-anchor", "middle")
|
|
||||||
.style("writing-mode", "vertical-rl")
|
|
||||||
.style("rotate", "180")
|
|
||||||
.style("font-size", 10)
|
|
||||||
.text("Number of cards");
|
|
||||||
|
|
||||||
function update(graphData: IntervalGraphData, maxDays: IntervalRange) {
|
|
||||||
const allIntervals = graphData.intervals;
|
const allIntervals = graphData.intervals;
|
||||||
|
|
||||||
const [xMin, origXMax] = extent(allIntervals);
|
const [xMin, origXMax] = extent(allIntervals);
|
||||||
|
|
||||||
let desiredBars = 70;
|
|
||||||
let xMax = origXMax;
|
let xMax = origXMax;
|
||||||
switch (maxDays) {
|
switch (maxDays) {
|
||||||
case IntervalRange.Month:
|
case IntervalRange.Month:
|
||||||
xMax = Math.min(xMax!, 31);
|
xMax = Math.min(xMax!, 31);
|
||||||
desiredBars = 31;
|
|
||||||
break;
|
break;
|
||||||
case IntervalRange.Percentile50:
|
case IntervalRange.Percentile50:
|
||||||
xMax = quantile(allIntervals, 0.5);
|
xMax = quantile(allIntervals, 0.5);
|
||||||
|
@ -108,108 +65,94 @@ export function intervalGraph(svgElem: SVGElement): IntervalUpdateFn {
|
||||||
break;
|
break;
|
||||||
case IntervalRange.All:
|
case IntervalRange.All:
|
||||||
break;
|
break;
|
||||||
default:
|
|
||||||
assertUnreachable(maxDays);
|
|
||||||
}
|
}
|
||||||
|
const desiredBars = Math.min(70, xMax! - xMin!);
|
||||||
|
|
||||||
const x = xScale.copy().domain([xMin!, xMax!]);
|
// x scale & bins
|
||||||
// .nice();
|
|
||||||
|
|
||||||
|
const x = scaleLinear()
|
||||||
|
.range([bounds.marginLeft, bounds.width - bounds.marginRight])
|
||||||
|
.domain([xMin!, xMax!]);
|
||||||
const data = histogram()
|
const data = histogram()
|
||||||
.domain(x.domain() as any)
|
.domain(x.domain() as any)
|
||||||
.thresholds(x.ticks(desiredBars))(allIntervals);
|
.thresholds(x.ticks(desiredBars))(allIntervals);
|
||||||
|
svg.select<SVGGElement>(".x-ticks")
|
||||||
|
.transition(trans)
|
||||||
|
.call(axisBottom(x).ticks(6).tickSizeOuter(0));
|
||||||
|
|
||||||
|
// y scale
|
||||||
|
|
||||||
const yMax = max(data, (d) => d.length);
|
const yMax = max(data, (d) => d.length);
|
||||||
|
const y = scaleLinear()
|
||||||
const colourScale = scaleSequential(interpolateBlues).domain([
|
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
|
||||||
-20,
|
.domain([0, yMax!]);
|
||||||
data.length,
|
svg.select<SVGGElement>(".y-ticks")
|
||||||
]);
|
.transition(trans)
|
||||||
|
.call(
|
||||||
const y = yScale.copy().domain([0, yMax!]);
|
axisLeft(y)
|
||||||
|
.ticks(bounds.height / 80)
|
||||||
const t = svg.transition().duration(600);
|
|
||||||
|
|
||||||
const updateXAxis = (
|
|
||||||
g: Selection<SVGGElement, unknown, null, undefined>,
|
|
||||||
scale: any
|
|
||||||
) =>
|
|
||||||
g
|
|
||||||
.attr("transform", `translate(0,${height - margin.bottom})`)
|
|
||||||
.call(axisBottom(scale).ticks(xTicks).tickSizeOuter(0));
|
|
||||||
|
|
||||||
xAxisGroup.transition(t as any).call(updateXAxis as any, x);
|
|
||||||
|
|
||||||
const updateYAxis = (
|
|
||||||
g: Selection<SVGGElement, unknown, null, undefined>,
|
|
||||||
scale: any
|
|
||||||
) =>
|
|
||||||
g.attr("transform", `translate(${margin.left}, 0)`).call(
|
|
||||||
axisLeft(scale)
|
|
||||||
.ticks(height / 80)
|
|
||||||
.tickSizeOuter(0)
|
.tickSizeOuter(0)
|
||||||
);
|
);
|
||||||
|
|
||||||
yAxisGroup.transition(t as any).call(updateYAxis as any, y);
|
// x bars
|
||||||
|
|
||||||
function barWidth(d: any): number {
|
function barWidth(d: any): number {
|
||||||
const width = Math.max(0, x(d.x1) - x(d.x0) - 1);
|
const width = Math.max(0, x(d.x1) - x(d.x0) - 1);
|
||||||
return width ? width : 0;
|
return width ? width : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateBar = (sel: any) => {
|
const colour = scaleSequential(interpolateBlues).domain([-5, data.length]);
|
||||||
return sel.call((sel) =>
|
|
||||||
sel
|
const updateBar = (sel: any): any => {
|
||||||
.transition(t as any)
|
return sel
|
||||||
|
.transition(trans)
|
||||||
.attr("width", barWidth)
|
.attr("width", barWidth)
|
||||||
.attr("x", (d: any) => x(d.x0))
|
.attr("x", (d: any) => x(d.x0))
|
||||||
.attr("y", (d: any) => y(d.length)!)
|
.attr("y", (d: any) => y(d.length)!)
|
||||||
.attr("height", (d: any) => y(0) - y(d.length))
|
.attr("height", (d: any) => y(0) - y(d.length))
|
||||||
.attr("fill", (d, idx) => colourScale(idx))
|
.attr("fill", (d, idx) => colour(idx));
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
barGroup
|
svg.select("g.bars")
|
||||||
.selectAll("rect")
|
.selectAll("rect")
|
||||||
.data(data)
|
.data(data)
|
||||||
.join(
|
.join(
|
||||||
(enter) =>
|
(enter) =>
|
||||||
updateBar(
|
|
||||||
enter
|
enter
|
||||||
.append("rect")
|
.append("rect")
|
||||||
.attr("rx", 1)
|
.attr("rx", 1)
|
||||||
.attr("x", (d: any) => x(d.x0))
|
.attr("x", (d: any) => x(d.x0))
|
||||||
.attr("y", y(0))
|
.attr("y", y(0))
|
||||||
.attr("height", 0)
|
.attr("height", 0)
|
||||||
),
|
.call(updateBar),
|
||||||
(update) => updateBar(update),
|
(update) => update.call(updateBar),
|
||||||
(remove) =>
|
(remove) =>
|
||||||
remove.call((remove) =>
|
remove.call((remove) =>
|
||||||
remove
|
remove.transition(trans).attr("height", 0).attr("y", y(0))
|
||||||
.transition(t as any)
|
|
||||||
.attr("height", 0)
|
|
||||||
.attr("y", y(0))
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// cumulative area
|
||||||
|
|
||||||
const areaData = cumsum(data.map((d) => d.length));
|
const areaData = cumsum(data.map((d) => d.length));
|
||||||
const xAreaScale = x.copy().domain([0, areaData.length]);
|
const xAreaScale = x.copy().domain([0, areaData.length]);
|
||||||
const yAreaScale = y.copy().domain([0, allIntervals.length]);
|
const yAreaScale = y.copy().domain([0, allIntervals.length]);
|
||||||
|
|
||||||
areaPath
|
svg.select("path.area")
|
||||||
.datum(areaData as any)
|
.datum(areaData as any)
|
||||||
.attr("fill", "grey")
|
|
||||||
.attr(
|
.attr(
|
||||||
"d",
|
"d",
|
||||||
area()
|
area()
|
||||||
.x((d: any, idx) => {
|
.x((d, idx) => {
|
||||||
return xAreaScale(idx);
|
return xAreaScale(idx);
|
||||||
})
|
})
|
||||||
.y0(height - margin.bottom)
|
.y0(bounds.height - bounds.marginBottom)
|
||||||
.y1((d: any) => yAreaScale(d)) as any
|
.y1((d: any) => yAreaScale(d)) as any
|
||||||
);
|
);
|
||||||
|
|
||||||
hoverGroup
|
// hover/tooltip
|
||||||
|
|
||||||
|
svg.select("g.hoverzone")
|
||||||
.selectAll("rect")
|
.selectAll("rect")
|
||||||
.data(data)
|
.data(data)
|
||||||
.join("rect")
|
.join("rect")
|
||||||
|
@ -217,8 +160,6 @@ export function intervalGraph(svgElem: SVGElement): IntervalUpdateFn {
|
||||||
.attr("y", () => y(yMax!))
|
.attr("y", () => y(yMax!))
|
||||||
.attr("width", barWidth)
|
.attr("width", barWidth)
|
||||||
.attr("height", () => y(0) - y(yMax!))
|
.attr("height", () => y(0) - y(yMax!))
|
||||||
.attr("fill", "none")
|
|
||||||
.attr("pointer-events", "all")
|
|
||||||
.on("mousemove", function (this: any, d: any, idx) {
|
.on("mousemove", function (this: any, d: any, idx) {
|
||||||
const [x, y] = mouse(document.body);
|
const [x, y] = mouse(document.body);
|
||||||
const pct = ((areaData[idx] / allIntervals.length) * 100).toFixed(2);
|
const pct = ((areaData[idx] / allIntervals.length) * 100).toFixed(2);
|
||||||
|
@ -231,6 +172,3 @@ export function intervalGraph(svgElem: SVGElement): IntervalUpdateFn {
|
||||||
})
|
})
|
||||||
.on("mouseout", hideTooltip);
|
.on("mouseout", hideTooltip);
|
||||||
}
|
}
|
||||||
|
|
||||||
return update;
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue