diff --git a/.changeset/timeseries-tooltip-react.md b/.changeset/timeseries-tooltip-react.md new file mode 100644 index 000000000..2637ada3f --- /dev/null +++ b/.changeset/timeseries-tooltip-react.md @@ -0,0 +1,15 @@ +--- +"@cloudflare/kumo": minor +--- + +**TimeseriesChart: React tooltip with Base UI positioning** + +Replaces ECharts' HTML-string tooltip with a React component positioned by Base UI's Tooltip primitive: + +- Tooltip rendered as a React component with correct theme tokens — no more inline styles or `getComputedStyle` hacks +- Positioning handled by Base UI Tooltip (Floating UI), with automatic collision avoidance and viewport flipping +- New `tooltipFollowCursor` prop: `"both"` (default, free-following) or `"x"` (axis-locked, Recharts-style) +- New `tooltipMode` prop: `"all"` (default) or `"single"` (nearest series to cursor) +- New `tooltipMaxItems` prop: caps rows in `"all"` mode with `+N more` footer (default `10`) +- Date formatted with `Intl.DateTimeFormat` (locale-aware) instead of ISO string +- Values sorted descending; fallback formatter avoids scientific notation diff --git a/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx b/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx index c7ee23cb9..5870b856c 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx @@ -4,17 +4,19 @@ import { Chart, ChartLegend, LayerCard, + Select, } from "@cloudflare/kumo"; import * as echarts from "echarts/core"; import type { EChartsOption } from "echarts"; import { BarChart, LineChart, PieChart } from "echarts/charts"; -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useIsDarkMode } from "~/lib/use-is-dark-mode"; import { AriaComponent, AxisPointerComponent, BrushComponent, GridComponent, + ToolboxComponent, TooltipComponent, } from "echarts/components"; import { CanvasRenderer } from "echarts/renderers"; @@ -27,6 +29,7 @@ echarts.use([ AxisPointerComponent, BrushComponent, GridComponent, + ToolboxComponent, TooltipComponent, AriaComponent, LabelLayout, @@ -604,6 +607,111 @@ export function CustomTooltipChartDemo() { ); } +interface FollowCursorOption { + label: string; + value: "both" | "x"; +} + +const FOLLOW_CURSOR_OPTIONS: FollowCursorOption[] = [ + { label: "Both axes", value: "both" }, + { label: "X-axis only", value: "x" }, +]; + +/** + * Interactive demo showing the `tooltipFollowCursor` prop. Use the dropdown to + * switch between cursor-tracking modes and see how the tooltip behaves. + */ +export function TooltipFollowCursorDemo() { + const isDarkMode = useIsDarkMode(); + const [selected, setSelected] = useState(FOLLOW_CURSOR_OPTIONS[0]); + + const data = useMemo( + () => [ + { + name: "P99", + data: buildSeriesData(0, 50, 60_000, 1), + color: ChartPalette.semantic("Attention", isDarkMode), + }, + { + name: "P50", + data: buildSeriesData(1, 50, 60_000, 0.4), + color: ChartPalette.semantic("Neutral", isDarkMode), + }, + ], + [isDarkMode], + ); + + return ( +
+ + +
+ ); +} + +/** + * Demo showing the `tooltipBoundary` prop. The chart is inside a small + * scrollable container — the tooltip is constrained to stay within it + * instead of overflowing into the surrounding page. + */ +export function TooltipBoundaryDemo() { + const isDarkMode = useIsDarkMode(); + const [boundary, setBoundary] = useState(null); + const boundaryRef = useCallback((el: HTMLDivElement | null) => setBoundary(el), []); + + const data = useMemo( + () => [ + { + name: "Requests", + data: buildSeriesData(0, 50, 60_000, 1), + color: ChartPalette.semantic("Neutral", isDarkMode), + }, + { + name: "Errors", + data: buildSeriesData(1, 50, 60_000, 0.3), + color: ChartPalette.semantic("Attention", isDarkMode), + }, + ], + [isDarkMode], + ); + + return ( +
+ +
+ ); +} + function buildSeriesData( seed = 0, points = 50, diff --git a/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx b/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx index 515538ab0..2a9684dcf 100644 --- a/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx +++ b/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx @@ -15,6 +15,8 @@ import { IncompleteDataChartDemo, LoadingChartDemo, TimeRangeSelectionChartDemo, + TooltipFollowCursorDemo, + TooltipBoundaryDemo, } from "~/components/demos/Chart/ChartDemo";

@@ -94,6 +96,39 @@ import { +### Tooltip Cursor Tracking + +

+ Use the tooltipFollowCursor prop to control which axis the tooltip + tracks the cursor on. The default is "both", which follows the + cursor freely. Set it to "x" for a Recharts-style axis-locked + tooltip that only moves horizontally. +

+ + + + + + + + +### Tooltip Boundary + +

+ Use the tooltipBoundary prop to constrain the tooltip to a + specific container element. By default the tooltip avoids overflowing any + clipping ancestor (scroll containers, viewports). Pass a DOM element to + restrict it further — useful when the chart lives inside a card or panel + and the tooltip shouldn't escape it. +

+ + + + +
+ + + ### Bar Chart

diff --git a/packages/kumo/src/components/chart/TimeseriesChart.tsx b/packages/kumo/src/components/chart/TimeseriesChart.tsx index 77790f3f9..e2c58c74a 100644 --- a/packages/kumo/src/components/chart/TimeseriesChart.tsx +++ b/packages/kumo/src/components/chart/TimeseriesChart.tsx @@ -1,7 +1,8 @@ import type * as echarts from "echarts/core"; import type { LineSeriesOption, BarSeriesOption } from "echarts/charts"; import type { EChartsOption, SeriesOption, SetOptionOpts } from "echarts"; -import { useEffect, useMemo, useRef } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; import { Chart, ChartEvents, KumoChartOption } from "./EChart"; /** A single data series rendered on a `TimeseriesChart` */ @@ -58,6 +59,48 @@ export interface TimeseriesChartProps { * deprecated `yAxisTickLabelFormat` prop. */ tooltipValueFormat?: (value: number) => string; + /** + * Controls which series are shown in the tooltip. + * - `"all"` — show all series at the hovered timestamp (default) + * - `"single"` — show only the series whose value is closest to the cursor + */ + tooltipMode?: "all" | "single"; + /** + * Maximum number of series rows shown in the tooltip when `tooltipMode` is `"all"`. + * Additional series are hidden with a `+N more` footer. Defaults to `10`. + */ + tooltipMaxItems?: number; + /** + * Constrains the tooltip to stay within a specific element or region. + * By default the tooltip avoids overflowing any clipping ancestor + * (scroll containers, viewports, etc.). + * + * Pass an `Element` or array of elements to restrict the tooltip to a + * specific container. + * + * @default "clipping-ancestors" + */ + tooltipBoundary?: "clipping-ancestors" | Element | Element[]; + /** + * Which axis the tooltip follows the cursor on. + * + * - `"both"` — tooltip tracks the cursor on both axes, staying near the + * pointer at all times. This is the default and matches the behaviour of + * ECharts' built-in tooltip. + * - `"x"` — tooltip follows the cursor horizontally but is locked to a + * fixed vertical position relative to the chart. This keeps the tooltip + * out of the way of the data and avoids vertical jitter as series values + * change — the same approach used by Recharts and many dashboard UIs. + * + * Only these two modes are offered because the x-axis is always time in a + * `TimeseriesChart`: y-only tracking and fully-fixed positioning don't + * produce useful tooltip behaviour for time-series data. + * + * Powered by Base UI Tooltip's `trackCursorAxis` under the hood. + * + * @default "both" + */ + tooltipFollowCursor?: "both" | "x"; /** Indicates incomplete data periods with optional before/after timestamps in ms */ incomplete?: { before?: number; after?: number }; /** Height of the chart in pixels. Defaults to `350`. */ @@ -94,6 +137,18 @@ export interface TimeseriesChartProps { optionUpdateBehavior?: SetOptionOpts; } +interface TooltipRow { + name: string; + value: number; + color: string; +} + +interface TooltipState { + ts: number; + rows: TooltipRow[]; + hiddenCount: number; +} + /** * TimeseriesChart — a time-series line or bar chart. * @@ -144,8 +199,37 @@ export function TimeseriesChart({ loading, ariaDescription, optionUpdateBehavior, + tooltipMode = "all", + tooltipMaxItems = 10, + tooltipFollowCursor = "both", + tooltipBoundary, }: TimeseriesChartProps) { const chartRef = useRef(null); + const containerRef = useRef(null); + + // Keep latest props accessible inside event handlers without stale closures + const dataRef = useRef(data); + dataRef.current = data; + const tooltipModeRef = useRef(tooltipMode); + tooltipModeRef.current = tooltipMode; + const tooltipMaxItemsRef = useRef(tooltipMaxItems); + tooltipMaxItemsRef.current = tooltipMaxItems; + + const [tooltipState, setTooltipState] = useState(null); + + // Track cursor position for single-mode y lookup (convertFromPixel needs relative coords) + const mousePosRef = useRef({ x: 0, y: 0 }); + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const onMove = (e: MouseEvent) => { + const rect = container.getBoundingClientRect(); + mousePosRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }; + container.addEventListener("mousemove", onMove); + return () => container.removeEventListener("mousemove", onMove); + }, []); + const incompleteBefore = incomplete?.before; const incompleteAfter = incomplete?.after; @@ -241,47 +325,8 @@ export function TimeseriesChart({ }, tooltip: { trigger: "axis" as const, - appendTo: "body", + showContent: false, axisPointer: { type: "shadow" as const }, - dangerousHtmlFormatter: (params) => { - const items = Array.isArray(params) ? params : [params]; - - // Track seen series names to avoid duplicates in tooltip - // This is needed because incomplete data series (dashed lines) and complete data series - // can overlap at the same timestamp, causing duplicate entries in the tooltip - const seenNames = new Set(); - const filteredParams = items.filter((param: any) => { - if (seenNames.has(param.seriesName)) return false; - seenNames.add(param.seriesName); - return true; - }); - - const first = filteredParams[0] as { - value?: [number, number]; - axisValue?: number; - }; - - const ts = first?.value?.[0] ?? first?.axisValue; - - const header = - ts != null - ? `

${echarts.format.encodeHTML(formatTimestamp(ts))}
` - : ""; - - const rows = filteredParams - .map((param: any) => { - const value = param?.value?.[1]; - const formatFn = tooltipValueFormat ?? yAxisTickLabelFormat; - const formattedValue = formatFn - ? echarts.format.encodeHTML(String(formatFn(value))) - : echarts.format.encodeHTML(String(value)); - - return `${param.marker} ${echarts.format.encodeHTML(param.seriesName)}: ${formattedValue}`; - }) - .join("
"); - - return `${header}${rows}`; - }, }, backgroundColor: "transparent", toolbox: { show: false }, @@ -333,10 +378,8 @@ export function TimeseriesChart({ xAxisTickCount, xAxisTickFormat, yAxisTickFormat, - yAxisTickLabelFormat, yAxisName, yAxisTickCount, - tooltipValueFormat, incompleteBefore, incompleteAfter, type, @@ -346,14 +389,63 @@ export function TimeseriesChart({ ]); const events = useMemo>(() => { - if (!onTimeRangeChange) return {}; - return { - brushend: (params) => { - const range = params.areas[0].coordRange; - onTimeRangeChange(range[0], range[1]); - chartRef.current?.dispatchAction({ type: "brush", areas: [] }); + updateaxispointer: (params: any) => { + const ts: number | undefined = params?.axesInfo?.[0]?.value; + if (ts == null) return; + + const seenNames = new Set(); + const allRows: TooltipRow[] = []; + + for (const s of dataRef.current) { + if (seenNames.has(s.name)) continue; + seenNames.add(s.name); + const value = findNearest(s.data, ts); + if (value != null) allRows.push({ name: s.name, value, color: s.color }); + } + + // Sort by value descending so highest series appears first + allRows.sort((a, b) => b.value - a.value); + + let rows: TooltipRow[]; + let hiddenCount = 0; + + if (tooltipModeRef.current === "single") { + // Find the series whose value is closest to the cursor's y position + const chart = chartRef.current; + const cursorValue = chart + ? (chart.convertFromPixel("grid", [0, mousePosRef.current.y]) as [number, number])?.[1] + : null; + if (cursorValue != null && allRows.length > 0) { + const nearest = allRows.reduce((best, row) => + Math.abs(row.value - cursorValue) < Math.abs(best.value - cursorValue) ? row : best, + ); + rows = [nearest]; + } else { + rows = allRows.slice(0, 1); + } + } else { + const max = tooltipMaxItemsRef.current; + rows = allRows.slice(0, max); + hiddenCount = Math.max(0, allRows.length - max); + } + + const nextState: TooltipState = { ts, rows, hiddenCount }; + setTooltipState((prev) => { + if (isSameTooltipState(prev, nextState)) return prev; + return nextState; + }); }, + globalout: () => { + setTooltipState(null); + }, + ...(onTimeRangeChange && { + brushend: (params: any) => { + const range = params.areas[0].coordRange; + onTimeRangeChange(range[0], range[1]); + chartRef.current?.dispatchAction({ type: "brush", areas: [] }); + }, + }), }; }, [onTimeRangeChange]); @@ -387,22 +479,131 @@ export function TimeseriesChart({ // Without this dep, the effect won't re-run after Chart mounts. }, [chartRef, hasTimeRangeCallback, loading]); + const formatFn = tooltipValueFormat ?? yAxisTickLabelFormat; + const tooltipOpen = tooltipState !== null; + + return ( -
- {loading && } - {!loading && ( - + + } + > + {loading && } + {!loading && ( + + )} + + {tooltipOpen && ( + + + + + + + )} -
+ + ); +} + +// ─── Tooltip content ────────────────────────────────────────────────────────── +// +// Memoized so React skips reconciliation when the cursor moves within the same +// data point. The timestamp dedup in updateAxisPointer already prevents most +// unnecessary state updates; this is a safety net for when the parent re-renders +// for unrelated reasons (e.g. a prop change on TimeseriesChart). + +interface TooltipContentProps { + state: TooltipState; + formatValue?: (v: number) => string; +} + +const TooltipContent = memo(function TooltipContent({ state, formatValue }: TooltipContentProps) { + const { ts, rows, hiddenCount } = state; + + return ( + <> +
+ {formatTimestamp(ts)} +
+ {rows.map((row) => ( +
+
+ + + {row.name} + +
+ + {formatValue ? formatValue(row.value) : formatDefaultValue(row.value)} + +
+ ))} + {hiddenCount > 0 && ( +
+ +{hiddenCount} more +
+ )} + ); +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Binary search for the value in `data` whose timestamp is closest to `ts`. */ +function findNearest(data: [number, number][], ts: number): number | null { + if (data.length === 0) return null; + let lo = 0, hi = data.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (data[mid][0] < ts) lo = mid + 1; + else hi = mid; + } + // Check both neighbours and return the closer one + if (lo > 0 && Math.abs(data[lo - 1][0] - ts) < Math.abs(data[lo][0] - ts)) lo--; + return data[lo][1]; +} + +/** Shallow-compare two tooltip states so React can skip renders when nothing changed. */ +function isSameTooltipState(a: TooltipState | null, b: TooltipState): boolean { + if (!a || a.ts !== b.ts || a.hiddenCount !== b.hiddenCount || a.rows.length !== b.rows.length) { + return false; + } + return a.rows.every((row, i) => { + const next = b.rows[i]; + return row.name === next.name && row.value === next.value && row.color === next.color; + }); +} + +const defaultNumberFormat = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 3, +}); + +/** Fallback value formatter — avoids floating point noise without scientific notation. */ +function formatDefaultValue(value: number): string { + if (Number.isInteger(value)) return String(value); + return defaultNumberFormat.format(value); } /** @@ -502,16 +703,19 @@ function colorWithOpacity(color: string, alpha: number): string { return `rgba(${r}, ${g}, ${b}, ${a})`; } -/** Zero-pads a number to two digits (e.g. `5` → `"05"`) */ -function pad(n: number) { - return n.toString().padStart(2, "0"); -} +const tooltipDateFormat = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, +}); /** - * Formats a timestamp as `"YYYY-MM-DD HH:mm:ss"` for use in chart tooltips. + * Formats a timestamp for use in chart tooltips using the browser's locale. * Accepts a Unix timestamp in milliseconds, an ISO date string, or a `Date` object. */ function formatTimestamp(ts: number | string | Date): string { - const d = new Date(ts); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + return tooltipDateFormat.format(new Date(ts)); }