diff --git a/.changeset/two-numbers-throw.md b/.changeset/two-numbers-throw.md new file mode 100644 index 0000000000..ee1112ea6b --- /dev/null +++ b/.changeset/two-numbers-throw.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +feat(chart): dim non-hovered series rows in tooltip 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 156ba3336f..bbe1b79edf 100644 --- a/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx @@ -450,6 +450,46 @@ export function BarChartDemo() { ); } +/** Bar chart with many series and a scrollable tooltip via `tooltipCss`. */ +export function TooltipCssDemo() { + const isDarkMode = useIsDarkMode(); + + const colors = [ + ChartPalette.categorical(0, isDarkMode), + ChartPalette.categorical(1, isDarkMode), + ChartPalette.categorical(2, isDarkMode), + ChartPalette.categorical(3, isDarkMode), + ChartPalette.categorical(4, isDarkMode), + ChartPalette.categorical(5, isDarkMode), + ChartPalette.categorical(6, isDarkMode), + ChartPalette.categorical(7, isDarkMode), + ]; + + const data = useMemo( + () => + Array.from({ length: 8 }, (_, i) => ({ + name: `Series ${String.fromCharCode(65 + i)}`, + data: buildSeriesData(i, 20, 3_600_000, 0.3 + i * 0.1), + color: colors[i], + })), + [isDarkMode], + ); + + return ( + r.toFixed(2)} + tooltipCss="max-height:180px;overflow-y:auto;" + tooltipEnterable + /> + ); +} + /** * Timeseries chart in loading state, showing the animated sine-wave skeleton. * Loads for 5 seconds then reveals the real chart. A button restarts the cycle. diff --git a/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx b/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx index 515538ab0d..25f5245010 100644 --- a/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx +++ b/packages/kumo-docs-astro/src/pages/charts/timeseries.mdx @@ -15,6 +15,7 @@ import { IncompleteDataChartDemo, LoadingChartDemo, TimeRangeSelectionChartDemo, + TooltipCssDemo, } from "~/components/demos/Chart/ChartDemo";

@@ -108,6 +109,22 @@ import { +### Tooltip CSS + +

+ Use the tooltipCss prop to append custom CSS to the tooltip + container. This is useful for constraining tooltip height when there are many + series, or adding scroll behavior. The string is passed directly to ECharts' + extraCssText option. +

+ + + + + + + + ### Loading State

diff --git a/packages/kumo/src/components/chart/TimeseriesChart.tsx b/packages/kumo/src/components/chart/TimeseriesChart.tsx index edf31be631..54f4b70474 100644 --- a/packages/kumo/src/components/chart/TimeseriesChart.tsx +++ b/packages/kumo/src/components/chart/TimeseriesChart.tsx @@ -87,6 +87,26 @@ export interface TimeseriesChartProps { * @see https://echarts.apache.org/handbook/en/best-practices/aria/ */ ariaDescription?: string; + /** + * Extra CSS text appended to the tooltip's inline `style` attribute. + * Useful for constraining tooltip size or adding scroll behavior when + * there are many series. + * + * @example + * ```tsx + * tooltipCss="max-height:200px;overflow-y:auto;" + * ``` + */ + tooltipCss?: string; + /** + * When `true`, the tooltip stays visible when the user moves their cursor + * into it, allowing interaction with scrollable or selectable content. + * Pair with `tooltipCss` to create scrollable tooltips for charts with + * many series. + * + * @default false + */ + tooltipEnterable?: boolean; } /** @@ -138,8 +158,19 @@ export function TimeseriesChart({ gradient, loading, ariaDescription, + tooltipCss, + tooltipEnterable, }: TimeseriesChartProps) { const chartRef = useRef(null); + // Tracks which series the user is hovering over so the tooltip + // formatter can dim the rows of non-hovered series, matching the + // emphasis dimming on the chart. + const hoveredSeriesRef = useRef(null); + // Stable ref to the tooltip value formatter so the dangerousHtmlFormatter + // closure (captured by useMemo) always calls the latest function without + // needing to be a useMemo dependency. + const tooltipValueFormatRef = useRef(tooltipValueFormat ?? yAxisTickLabelFormat); + tooltipValueFormatRef.current = tooltipValueFormat ?? yAxisTickLabelFormat; const incompleteBefore = incomplete?.before; const incompleteAfter = incomplete?.after; @@ -237,6 +268,13 @@ export function TimeseriesChart({ trigger: "axis" as const, appendTo: "body", axisPointer: { type: "shadow" as const }, + ...(tooltipCss && { extraCssText: tooltipCss }), + ...(tooltipEnterable && { + enterable: true, + // Position the tooltip close to the cursor so the user can + // easily move into it (e.g. to scroll overflow content). + position: (point: number[]) => [point[0] + 10, point[1] - 10], + }), dangerousHtmlFormatter: (params) => { const items = Array.isArray(params) ? params : [params]; @@ -262,17 +300,23 @@ export function TimeseriesChart({ ? `

${echarts.format.encodeHTML(formatTimestamp(ts))}
` : ""; + const hovered = hoveredSeriesRef.current; + const formatFn = tooltipValueFormatRef.current; + 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}`; + const isDimmed = + hovered != null && param.seriesName !== hovered; + const style = isDimmed ? ' style="opacity:0.5"' : ""; + + return `${param.marker} ${echarts.format.encodeHTML(param.seriesName)}: ${formattedValue}`; }) - .join("
"); + .join(""); return `${header}${rows}`; }, @@ -327,28 +371,42 @@ export function TimeseriesChart({ xAxisTickCount, xAxisTickFormat, yAxisTickFormat, - yAxisTickLabelFormat, yAxisName, yAxisTickCount, - tooltipValueFormat, incompleteBefore, incompleteAfter, type, gradient, echarts, ariaDescription, + tooltipCss, + tooltipEnterable, ]); const events = useMemo>(() => { - if (!onTimeRangeChange) return {}; + const handlers: Partial = {}; - return { - brushend: (params) => { + if (onTimeRangeChange) { + handlers.brushend = (params) => { const range = params.areas[0].coordRange; onTimeRangeChange(range[0], range[1]); chartRef.current?.dispatchAction({ type: "brush", areas: [] }); - }, + }; + } + + // Track which series is hovered so the tooltip formatter can dim + // non-hovered rows, matching the emphasis dimming on the chart. + handlers.mouseover = (params) => { + hoveredSeriesRef.current = params.seriesName ?? null; + }; + handlers.mouseout = () => { + hoveredSeriesRef.current = null; }; + handlers.globalout = () => { + hoveredSeriesRef.current = null; + }; + + return handlers; }, [onTimeRangeChange]); // Activate the lineX brush cursor when a time-range callback is provided,