Files
2026-07-13 13:32:57 +08:00

100 lines
3.7 KiB
TypeScript

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<number>();
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<T>(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<T>(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<Record<string, any>>,
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]);
}