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); } export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout { const plot: Rect = { x: MARGIN.left, y: MARGIN.top, width: Math.max(0, width - MARGIN.left - MARGIN.right), height: Math.max(0, height - MARGIN.top - MARGIN.bottom), }; 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 = buckets.length > 0 && plot.width > 0 ? Math.max(1, Math.ceil((buckets.length * MIN_X_LABEL_PX) / plot.width)) : 1; 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 }; }