Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
183 lines
5.6 KiB
TypeScript
183 lines
5.6 KiB
TypeScript
import type { Bucket } from "@/lib/types";
|
|
|
|
export interface Rect {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface BarLayout {
|
|
bucket: Bucket;
|
|
/** queries - blocked - cached, clamped at 0. */
|
|
other: number;
|
|
slot: Rect;
|
|
segments: {
|
|
blocked: Rect;
|
|
cached: Rect;
|
|
other: Rect;
|
|
};
|
|
}
|
|
|
|
export interface ChartLayout {
|
|
width: number;
|
|
height: number;
|
|
plot: Rect;
|
|
scaleMax: number;
|
|
bars: BarLayout[];
|
|
yTicks: { value: number; y: number }[];
|
|
xTicks: { ts: number; x: number }[];
|
|
}
|
|
|
|
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
|
const BAR_GAP = 2;
|
|
const MIN_X_LABEL_PX = 90;
|
|
|
|
/** Tick values from 0 upward in a 1/2/5 step, extended until the last tick covers `max`. */
|
|
export function niceTicks(max: number, targetCount = 4): number[] {
|
|
if (max <= 0) return [0];
|
|
const rawStep = max / targetCount;
|
|
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
|
const normalized = rawStep / magnitude;
|
|
const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
|
|
const ticks: number[] = [];
|
|
for (let value = 0; ; value += step) {
|
|
ticks.push(value);
|
|
if (value >= max) break;
|
|
}
|
|
return ticks;
|
|
}
|
|
|
|
export function isEmptyTimeseries(buckets: Bucket[]): boolean {
|
|
return buckets.every((bucket) => bucket.queries === 0);
|
|
}
|
|
|
|
function plotRect(width: number, height: number): Rect {
|
|
return {
|
|
x: MARGIN.left,
|
|
y: MARGIN.top,
|
|
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
|
height: Math.max(0, height - MARGIN.top - MARGIN.bottom),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* How many columns share one x-axis label. A 30-day window is 30 columns and a
|
|
* 1-hour window is 60, so at narrow widths the labels have to thin out rather
|
|
* than overprint each other.
|
|
*/
|
|
function labelStepFor(count: number, plotWidth: number): number {
|
|
if (count === 0 || plotWidth <= 0) return 1;
|
|
return Math.max(1, Math.ceil((count * MIN_X_LABEL_PX) / plotWidth));
|
|
}
|
|
|
|
/** One stacked column: the series values in the order the caller stacks them. */
|
|
export interface StackedColumn {
|
|
ts: number;
|
|
total: number;
|
|
slot: Rect;
|
|
segments: Rect[];
|
|
}
|
|
|
|
export interface StackedLayout {
|
|
width: number;
|
|
height: number;
|
|
plot: Rect;
|
|
scaleMax: number;
|
|
columns: StackedColumn[];
|
|
yTicks: { value: number; y: number }[];
|
|
xTicks: { ts: number; x: number }[];
|
|
}
|
|
|
|
/**
|
|
* The same geometry as the query-volume chart, for an arbitrary number of
|
|
* series. The scale comes from the tallest column's own total, because every
|
|
* series here is a disjoint part of the whole rather than a highlighted subset
|
|
* of a separately reported total.
|
|
*/
|
|
export function layoutStacked(
|
|
columns: { ts: number; values: number[] }[],
|
|
width: number,
|
|
height: number,
|
|
): StackedLayout {
|
|
const plot = plotRect(width, height);
|
|
const totals = columns.map((column) => column.values.reduce((sum, value) => sum + value, 0));
|
|
const tickValues = niceTicks(Math.max(0, ...totals));
|
|
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
|
|
const baseline = plot.y + plot.height;
|
|
const toHeight = (value: number) => (value / scaleMax) * plot.height;
|
|
|
|
const slotWidth = columns.length > 0 ? plot.width / columns.length : 0;
|
|
const barWidth = Math.max(1, slotWidth - BAR_GAP);
|
|
|
|
const laidOut: StackedColumn[] = columns.map((column, i) => {
|
|
const slotX = plot.x + i * slotWidth;
|
|
const barX = slotX + (slotWidth - barWidth) / 2;
|
|
let top = baseline;
|
|
const segments = column.values.map((value) => {
|
|
const segmentHeight = toHeight(value);
|
|
top -= segmentHeight;
|
|
return { x: barX, y: top, width: barWidth, height: segmentHeight };
|
|
});
|
|
return {
|
|
ts: column.ts,
|
|
total: totals[i],
|
|
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
|
|
segments,
|
|
};
|
|
});
|
|
|
|
const step = labelStepFor(columns.length, plot.width);
|
|
return {
|
|
width,
|
|
height,
|
|
plot,
|
|
scaleMax,
|
|
columns: laidOut,
|
|
yTicks: tickValues.map((value) => ({ value, y: baseline - toHeight(value) })),
|
|
xTicks: laidOut
|
|
.filter((_, i) => i % step === 0)
|
|
.map((column) => ({ ts: column.ts, x: column.slot.x + column.slot.width / 2 })),
|
|
};
|
|
}
|
|
|
|
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
|
|
const plot = plotRect(width, height);
|
|
const maxQueries = buckets.reduce((max, bucket) => Math.max(max, bucket.queries), 0);
|
|
const tickValues = niceTicks(maxQueries);
|
|
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
|
|
const baseline = plot.y + plot.height;
|
|
const toHeight = (value: number) => (value / scaleMax) * plot.height;
|
|
|
|
const slotWidth = buckets.length > 0 ? plot.width / buckets.length : 0;
|
|
const barWidth = Math.max(1, slotWidth - BAR_GAP);
|
|
|
|
const bars: BarLayout[] = buckets.map((bucket, i) => {
|
|
const slotX = plot.x + i * slotWidth;
|
|
const barX = slotX + (slotWidth - barWidth) / 2;
|
|
const other = Math.max(0, bucket.queries - bucket.blocked - bucket.cached);
|
|
const blockedH = toHeight(bucket.blocked);
|
|
const cachedH = toHeight(bucket.cached);
|
|
const otherH = toHeight(other);
|
|
return {
|
|
bucket,
|
|
other,
|
|
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
|
|
segments: {
|
|
blocked: { x: barX, y: baseline - blockedH, width: barWidth, height: blockedH },
|
|
cached: { x: barX, y: baseline - blockedH - cachedH, width: barWidth, height: cachedH },
|
|
other: { x: barX, y: baseline - blockedH - cachedH - otherH, width: barWidth, height: otherH },
|
|
},
|
|
};
|
|
});
|
|
|
|
const yTicks = tickValues.map((value) => ({ value, y: baseline - toHeight(value) }));
|
|
|
|
const labelStep = labelStepFor(buckets.length, plot.width);
|
|
const xTicks = bars
|
|
.filter((_, i) => i % labelStep === 0)
|
|
.map((bar) => ({ ts: bar.bucket.ts, x: bar.slot.x + bar.slot.width / 2 }));
|
|
|
|
return { width, height, plot, scaleMax, bars, yTicks, xTicks };
|
|
}
|