tidy up graph code

This commit is contained in:
Damien Elmes 2020-06-23 12:43:23 +09:00
parent 48a693f861
commit 1d1ed5b241
4 changed files with 190 additions and 206 deletions

View file

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

View file

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

View file

@ -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,
};
}

View file

@ -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);
@ -230,7 +171,4 @@ export function intervalGraph(svgElem: SVGElement): IntervalUpdateFn {
); );
}) })
.on("mouseout", hideTooltip); .on("mouseout", hideTooltip);
}
return update;
} }