Skip to content
5 changes: 5 additions & 0 deletions .changeset/two-numbers-throw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": minor
---

feat(chart): dim non-hovered series rows in tooltip
40 changes: 40 additions & 0 deletions packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<TimeseriesChart
echarts={echarts}
isDarkMode={isDarkMode}
type="bar"
data={data}
xAxisName="Time (UTC)"
yAxisName="Count"
tooltipValueFormat={(r) => 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.
Expand Down
17 changes: 17 additions & 0 deletions packages/kumo-docs-astro/src/pages/charts/timeseries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
IncompleteDataChartDemo,
LoadingChartDemo,
TimeRangeSelectionChartDemo,
TooltipCssDemo,
} from "~/components/demos/Chart/ChartDemo";

<p>
Expand Down Expand Up @@ -108,6 +109,22 @@ import {

<ComponentSection>

### Tooltip CSS

<p>
Use the <code>tooltipCss</code> 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'
<code>extraCssText</code> option.
</p>

<ComponentExample demo="TooltipCssDemo">
<TooltipCssDemo client:visible />
</ComponentExample>
</ComponentSection>

<ComponentSection>

### Loading State

<p>
Expand Down
76 changes: 67 additions & 9 deletions packages/kumo/src/components/chart/TimeseriesChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -138,8 +158,19 @@ export function TimeseriesChart({
gradient,
loading,
ariaDescription,
tooltipCss,
tooltipEnterable,
}: TimeseriesChartProps) {
const chartRef = useRef<echarts.ECharts | null>(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<string | null>(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;

Expand Down Expand Up @@ -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];

Expand All @@ -262,17 +300,23 @@ export function TimeseriesChart({
? `<div style="font-weight:600;margin-bottom:4px;">${echarts.format.encodeHTML(formatTimestamp(ts))}</div>`
: "";

const hovered = hoveredSeriesRef.current;
Comment thread
lkipke marked this conversation as resolved.
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)}: <strong>${formattedValue}</strong>`;
const isDimmed =
hovered != null && param.seriesName !== hovered;
const style = isDimmed ? ' style="opacity:0.5"' : "";

return `<div${style}>${param.marker} ${echarts.format.encodeHTML(param.seriesName)}: <strong>${formattedValue}</strong></div>`;
Comment thread
lkipke marked this conversation as resolved.
})
.join("<br/>");
.join("");

return `${header}${rows}`;
},
Expand Down Expand Up @@ -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<Partial<ChartEvents>>(() => {
if (!onTimeRangeChange) return {};
const handlers: Partial<ChartEvents> = {};

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,
Expand Down