import { useMemo } from "react"; // At 11px tabular-nums, 1 char ≈ 6.5px, so character count is a width proxy (see useYAxisWidth). const PX_PER_CH = 6.5; // Minimum gap between adjacent labels. const LABEL_GAP_PX = 16; // Floor so very short labels still get some space. const MIN_LABEL_PX = 24; /** Pick `count` indices evenly spaced across [0, n), always including the first and last. */ export function selectEvenlySpacedIndices(n: number, count: number): number[] { if (n <= 0) return []; if (count <= 1) return [0]; if (count >= n) return Array.from({ length: n }, (_, i) => i); if (count === 2) return [0, n - 1]; const step = (n - 1) / (count - 1); const out: number[] = []; const seen = new Set(); for (let i = 0; i < count; i++) { const idx = Math.round(i * step); if (!seen.has(idx)) { seen.add(idx); out.push(idx); } } // Rounding can drop the final index; force it in. if (!seen.has(n - 1)) out.push(n - 1); return out; } /** Pick `maxLabels` values evenly spaced across `values`, always including the first and last. */ export function selectEvenlySpacedTicks(values: T[], maxLabels: number): T[] { return selectEvenlySpacedIndices(values.length, maxLabels).map((i) => values[i]); } /** How many labels of `maxLabelChars` width fit in `width` pixels. */ export function estimateMaxLabels(width: number, maxLabelChars: number): number { if (!width || width <= 0) return 0; const labelPx = Math.max(MIN_LABEL_PX, maxLabelChars * PX_PER_CH) + LABEL_GAP_PX; return Math.max(1, Math.floor(width / labelPx)); } /** * From `indices` (into `labels`/`values`), return the tick values with adjacent * duplicate labels dropped. The final index is always kept: if it repeats the * previous label it replaces that tick instead of being skipped, so the * end-of-range label never disappears (honoring the "first + last" contract). */ export function dedupeTicksByLabel(indices: number[], labels: string[], values: T[]): T[] { const lastIndex = values.length - 1; const ticks: T[] = []; let lastLabel: string | null = null; for (const idx of indices) { if (labels[idx] === lastLabel) { if (idx === lastIndex && ticks.length > 0) ticks[ticks.length - 1] = values[idx]; continue; } lastLabel = labels[idx]; ticks.push(values[idx]); } return ticks; } /** * Explicit x-axis tick values: evenly spaced across the plot, including first + * last, bounded by how many fit in `plotWidth` and by the count of distinct * labels (so nothing overlaps or repeats). `plotWidth` excludes the y-axis and * margins. Returns `undefined` until a width is known (first paint). */ export function useXAxisTicks( data: Array>, dataKey: string, plotWidth: number | undefined, tickFormatter?: (value: any, index: number) => string ): any[] | undefined { return useMemo(() => { if (!data?.length || !plotWidth || plotWidth <= 0) return undefined; const n = data.length; const fmt = tickFormatter ?? ((v: any) => String(v)); const labels = data.map((d, i) => fmt(d[dataKey], i) ?? ""); let maxChars = 0; for (const label of labels) { if (label.length > maxChars) maxChars = label.length; } // Cap at distinct labels: laying out N evenly-spaced labels (vs one-per-period) // keeps spacing even when the first/last period is partial. const fit = estimateMaxLabels(plotWidth, maxChars); const distinct = new Set(labels).size; const target = Math.min(fit, distinct, n); // Evenly spaced on screen, then drop any that repeat the previous label. const values = data.map((d) => d[dataKey]); return dedupeTicksByLabel(selectEvenlySpacedIndices(n, target), labels, values); }, [data, dataKey, plotWidth, tickFormatter]); }