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