// The geometry of a BarChart, apart from its markup: where each bar sits, how tall it is, where
// the grid lines fall. Pure, so the server render and a hydrated one cannot draw two charts.
export interface ChartPoint {
/** The bar's name — a day, a bucket, a label. Read by `
` and the axis. */
readonly key: string;
readonly value: number;
/**
* A second series, stacked on `value` — failed on top of done. Absent or 0 draws nothing, so a
* one-series chart is exactly the chart it was.
*/
readonly secondary?: number;
}
export interface BarRect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
/**
* 600 × 108, nearly 6:1, not 3:1: at dashboard width a taller box pushed the table — the thing
* people came for — below the fold. `plot` is where bars live; `top` and the matching strip under
* it only keep the outer grid hairlines off the edge. The axis labels are HTML OUTSIDE the svg
* (issue 494) — inside a viewBox they scaled with it — so the box spends no strip on them.
*/
export const BAR_CHART = { width: 600, top: 2, plot: 104, height: 108, gap: 3 } as const;
/** Quarter lines. Four is what a reader can use to judge a bar's height without a y-axis. */
export const GRID_STEPS = [0.25, 0.5, 0.75, 1] as const;
const finite = (value: number | undefined): number =>
value !== undefined && Number.isFinite(value) ? Math.max(0, value) : 0;
/** The busiest point — its whole stack — and never below 1, so an all-zero series still divides. */
export function maxOf(points: readonly ChartPoint[]): number {
let max = 1;
for (const point of points) {
const total = finite(point.value) + finite(point.secondary);
if (total > max) max = total;
}
return max;
}
/** The y of a grid line at `step` of the plot height (1 = the top line = the busiest point). */
export function gridY(step: number): number {
return BAR_CHART.top + BAR_CHART.plot - step * BAR_CHART.plot;
}
/** One rect per point, oldest first. At least 2px tall so a zero still draws a visible bar. */
export function barRects(points: readonly ChartPoint[]): readonly BarRect[] {
const count = Math.max(points.length, 1);
// The gap gives way before the bars do: at most half of each bar's slot, so past the 201 points
// where 3px gaps alone fill the width, every bar is still a positive rect and the row still ends
// at the right edge.
const gap = Math.min(BAR_CHART.gap, BAR_CHART.width / count / 2);
const width = (BAR_CHART.width - gap * (count - 1)) / count;
const max = maxOf(points);
return points.map((point, index) => {
const value = Number.isFinite(point.value) ? Math.max(0, point.value) : 0;
const height = Math.max(2, (value / max) * BAR_CHART.plot);
return {
x: index * (width + gap),
y: BAR_CHART.top + BAR_CHART.plot - height,
width,
height,
};
});
}
/**
* The second series' rect per point, stacked on that point's primary bar; `null` where it is 0.
* Same scale as `barRects`, so a stack is read against the same grid lines.
*/
export function secondaryRects(points: readonly ChartPoint[]): readonly (BarRect | null)[] {
const primary = barRects(points);
const max = maxOf(points);
return points.map((point, index) => {
const value = finite(point.secondary);
const below = primary[index];
if (value === 0 || below === undefined) return null;
const height = (value / max) * BAR_CHART.plot;
return { x: below.x, y: below.y - height, width: below.width, height };
});
}