From 1867be3f582dc0dd2330fae635433c8b294bcb6b Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 15:52:42 -0600 Subject: [PATCH] fix: keep line chart time labels from overlapping --- src/ui/components/charts/LineChart.tsx | 34 +-------- src/ui/components/charts/x-ticks.ts | 74 +++++++++++++++++++ .../charts/line-chart-ticks.test.ts | 46 ++++++++++++ 3 files changed, 122 insertions(+), 32 deletions(-) create mode 100644 src/ui/components/charts/x-ticks.ts create mode 100644 src/ui/tests/components/charts/line-chart-ticks.test.ts diff --git a/src/ui/components/charts/LineChart.tsx b/src/ui/components/charts/LineChart.tsx index 1079c5a53..f618e9159 100644 --- a/src/ui/components/charts/LineChart.tsx +++ b/src/ui/components/charts/LineChart.tsx @@ -1,6 +1,7 @@ import { useRef, useState, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { parseServerTime } from "@/lib/server-time"; +import { formatTimestamp, pickXTicks } from "./x-ticks"; export interface LineChartSeries { key: string; @@ -24,26 +25,6 @@ const X_LABEL_H = 20; const TOP_PAD = 6; const TICK_COUNT = 5; -function formatTimestamp(ts: string, rangeMs: number): string { - const d = parseServerTime(ts); - // A live chart covers a minute or two, so minutes alone would repeat. - if (rangeMs <= 10 * 60 * 1000) { - return d.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - } - if (rangeMs <= 25 * 60 * 60 * 1000) { - return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - } - return ( - d.toLocaleDateString([], { month: "short", day: "numeric" }) + - " " + - d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) - ); -} - function valToY(v: number, drawH: number, domain: [number, number]): number { const [lo, hi] = domain; const range = hi === lo ? 1 : hi - lo; @@ -119,18 +100,7 @@ export function LineChart({ return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step); })(); - const xTickIndices: number[] = []; - if (timestamps.length > 1 && chartW > 0) { - const n = timestamps.length; - // Long labels ("Jun 28 14:30") are ~80px; short ("14:30") are ~40px. - // Use at most 3 ticks for long labels, 5 for short, so they never crowd. - const longLabel = rangeMs > 25 * 60 * 60 * 1000; - const maxTicks = longLabel ? 3 : 5; - const count = Math.min(maxTicks, n); - for (let k = 0; k < count; k++) { - xTickIndices.push(Math.round((k / (count - 1)) * (n - 1))); - } - } + const xTickIndices = pickXTicks(timestamps, rangeMs, chartW); const handleMouseMove = useCallback( (e: React.MouseEvent) => { diff --git a/src/ui/components/charts/x-ticks.ts b/src/ui/components/charts/x-ticks.ts new file mode 100644 index 000000000..a0b59b65d --- /dev/null +++ b/src/ui/components/charts/x-ticks.ts @@ -0,0 +1,74 @@ +import { parseServerTime } from "@/lib/server-time"; + +export function formatTimestamp(ts: string, rangeMs: number): string { + const d = parseServerTime(ts); + // A live chart covers a minute or two, so minutes alone would repeat. + if (rangeMs <= 10 * 60 * 1000) { + return d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } + if (rangeMs <= 25 * 60 * 60 * 1000) { + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + return ( + d.toLocaleDateString([], { month: "short", day: "numeric" }) + + " " + + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ); +} + +// Rough width of a 10px label, used to keep x labels apart. +const CHAR_W = 6.2; +const LABEL_GAP = 12; + +/** + * Picks which timestamps get an x label. The count follows the chart width and + * label length, and a label that would touch its neighbour is dropped. + */ +export function pickXTicks( + timestamps: string[], + rangeMs: number, + chartW: number, +): number[] { + const n = timestamps.length; + if (n < 2 || chartW <= 0) return []; + const labelW = (i: number) => + formatTimestamp(timestamps[i], rangeMs).length * CHAR_W; + const widest = Math.max(labelW(0), labelW(n - 1)); + const fit = Math.floor((chartW + LABEL_GAP) / (widest + LABEL_GAP)); + const count = Math.max(1, Math.min(5, n, fit)); + const candidates = + count === 1 + ? [n - 1] + : Array.from({ length: count }, (_, k) => + Math.round((k / (count - 1)) * (n - 1)), + ); + + // Box of a label as drawn: first is start anchored, last end, others middle. + const box = (i: number): [number, number] => { + const x = (i / (n - 1)) * chartW; + const w = labelW(i); + if (i === 0) return [x, x + w]; + if (i === n - 1) return [x - w, x]; + return [x - w / 2, x + w / 2]; + }; + + const clashes = (a: number | undefined, b: number) => + a !== undefined && box(a)[1] + LABEL_GAP > box(b)[0]; + const placed: number[] = []; + for (const i of [...new Set(candidates)]) { + // The newest time stays visible on a live chart, older labels make room. + if (i === n - 1) { + while (placed.length && clashes(placed[placed.length - 1], i)) { + placed.pop(); + } + } else if (clashes(placed[placed.length - 1], i)) { + continue; + } + placed.push(i); + } + return placed; +} diff --git a/src/ui/tests/components/charts/line-chart-ticks.test.ts b/src/ui/tests/components/charts/line-chart-ticks.test.ts new file mode 100644 index 000000000..8b2483d3c --- /dev/null +++ b/src/ui/tests/components/charts/line-chart-ticks.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { pickXTicks } from "../../../components/charts/x-ticks"; + +const live = (n: number) => + Array.from({ length: n }, (_, i) => + new Date(Date.UTC(2026, 10, 4, 15, 37, i * 2)).toISOString(), + ); + +describe("pickXTicks", () => { + it("returns nothing without two points or width", () => { + expect(pickXTicks(live(1), 0, 300)).toEqual([]); + expect(pickXTicks(live(5), 8000, 0)).toEqual([]); + }); + + it("uses up to five labels on a wide chart", () => { + const ts = live(60); + expect(pickXTicks(ts, 118_000, 900)).toHaveLength(5); + }); + + it("fits fewer labels on a narrow chart and keeps the newest", () => { + const ts = live(60); + const ticks = pickXTicks(ts, 118_000, 220); + expect(ticks.length).toBeLessThan(5); + expect(ticks[ticks.length - 1]).toBe(59); + }); + + it("never lets labels touch", () => { + for (const w of [120, 180, 220, 260, 300, 400, 600]) { + for (const n of [2, 3, 4, 5, 8, 60]) { + const ts = live(n); + const range = (n - 1) * 2000; + const ticks = pickXTicks(ts, range, w); + const labelW = 11 * 6.2; + const boxes = ticks.map((i) => { + const x = (i / (n - 1)) * w; + if (i === 0) return [x, x + labelW]; + if (i === n - 1) return [x - labelW, x]; + return [x - labelW / 2, x + labelW / 2]; + }); + for (let k = 1; k < boxes.length; k++) { + expect(boxes[k][0]).toBeGreaterThanOrEqual(boxes[k - 1][1]); + } + } + } + }); +});