Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wet-dancers-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": minor
---

Extend LegendItems to take pointer events & timeseries merges forwardref to support those events
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import * as echarts from "echarts/core";
import type { EChartsOption } from "echarts";
import { BarChart, LineChart, PieChart } from "echarts/charts";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AriaComponent,
AxisPointerComponent,
Expand Down Expand Up @@ -540,6 +540,98 @@ export function ChartExampleDemo() {
);
}

/**
* Timeseries chart with legend items that highlight the corresponding series on hover.
* Hovering a legend item dispatches a highlight action to the chart and fades the other legend items.
*/
export function LegendHighlightDemo() {
const isDarkMode = useIsDarkMode();
const chartRef = useRef<echarts.ECharts>(null);
const [hoveredSeries, setHoveredSeries] = useState<string | null>(null);

const series = useMemo(
() => [
{
name: "P99",
color: ChartPalette.semantic("Attention", isDarkMode),
value: "124",
unit: "ms",
},
{
name: "P95",
color: ChartPalette.semantic("Warning", isDarkMode),
value: "76",
unit: "ms",
},
{
name: "P75",
color: ChartPalette.semantic("Neutral", isDarkMode),
value: "32",
unit: "ms",
},
{
name: "P50",
color: ChartPalette.semantic("NeutralLight", isDarkMode),
value: "10",
unit: "ms",
},
],
[isDarkMode],
);

const data = useMemo(
() =>
series.map((s, i) => ({
name: s.name,
data: buildSeriesData(3 - i, 30, 60_000, 1 - i * 0.2),
color: s.color,
})),
[series],
);

return (
<LayerCard>
<LayerCard.Secondary>Read latency</LayerCard.Secondary>
<LayerCard.Primary>
<div className="flex divide-x divide-kumo-line gap-4 px-2 mb-2">
{series.map((s) => (
<ChartLegend.LargeItem
key={s.name}
name={s.name}
color={s.color}
value={s.value}
unit={s.unit}
inactive={hoveredSeries !== null && hoveredSeries !== s.name}
onPointerEnter={() => {
setHoveredSeries(s.name);
chartRef.current?.dispatchAction({
type: "highlight",
seriesName: s.name,
});
}}
onPointerLeave={() => {
setHoveredSeries(null);
chartRef.current?.dispatchAction({
type: "downplay",
seriesName: s.name,
});
}}
/>
))}
</div>
<TimeseriesChart
ref={chartRef}
xAxisName="Time (UTC)"
echarts={echarts}
isDarkMode={isDarkMode}
data={data}
height={300}
/>
</LayerCard.Primary>
</LayerCard>
);
}

function buildSeriesData(
seed = 0,
points = 50,
Expand Down
16 changes: 16 additions & 0 deletions packages/kumo-docs-astro/src/pages/charts/timeseries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
CustomAxisLabelFormatDemo,
GradientLineChartDemo,
IncompleteDataChartDemo,
LegendHighlightDemo,
LoadingChartDemo,
TimeRangeSelectionChartDemo,
} from "~/components/demos/Chart/ChartDemo";
Expand Down Expand Up @@ -101,6 +102,21 @@ import Heading from "~/components/docs/Heading.astro";
</ComponentExample>
</ComponentSection>

<ComponentSection>
<Heading level={3}>Legend Highlight</Heading>

<p>
Hovering a legend item highlights the corresponding series on the chart and
fades the others. Use <code>onPointerEnter</code> and{" "}
<code>onPointerLeave</code> on <code>ChartLegend</code> items together with{" "}
<code>dispatchAction</code> on the chart ref.
</p>

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

<ComponentSection>
<Heading level={3}>Loading State</Heading>

Expand Down
72 changes: 68 additions & 4 deletions packages/kumo/src/components/chart/Legend.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import type {
KeyboardEventHandler,
MouseEventHandler,
PointerEventHandler,
} from "react";
import { cn } from "../../utils";

const onInteractiveKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.currentTarget.click();
};

/** Shared props for both legend item variants */
interface LegendItemProps {
/** Series name shown as a label */
Expand All @@ -12,16 +23,47 @@ interface LegendItemProps {
unit?: string;
/** When `true`, renders the item at 50% opacity to indicate a deselected state */
inactive?: boolean;
/** Fired when a pointer enters the legend item — useful for highlighting the corresponding chart series */
onPointerEnter?: PointerEventHandler<HTMLDivElement>;
/** Fired when a pointer leaves the legend item — useful for resetting chart series emphasis */
onPointerLeave?: PointerEventHandler<HTMLDivElement>;
Comment thread
jcworth marked this conversation as resolved.
/** Fired when the legend item is clicked — useful for toggling series visibility */
onClick?: MouseEventHandler<HTMLDivElement>;
/** Optional className to customize legend item presentation */
className?: string;
}

/**
* Large legend item — stacked layout with a colored dot + series name on top
* and a large value with an optional small unit below. Use for prominent
* single-metric displays such as dashboard cards.
*/
function LargeItem({ color, value, name, unit, inactive }: LegendItemProps) {
function LargeItem({
color,
value,
name,
unit,
inactive,
onPointerEnter,
onPointerLeave,
onClick,
className,
}: LegendItemProps) {
return (
<div className="inline-flex flex-col gap-2 min-w-42 py-2">
<div
// oxlint-disable-next-line prefer-tag-over-role
role="button"
tabIndex={onClick ? 0 : -1}
className={cn(
"inline-flex flex-col gap-2 min-w-42 py-2",
{ "cursor-pointer": !!onClick },
className,
)}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onClick={onClick}
onKeyDown={onClick ? onInteractiveKeyDown : undefined}
>
<div className="flex items-center gap-2">
<span
className={cn("size-2 rounded-full inline-block", {
Expand Down Expand Up @@ -59,9 +101,31 @@ function LargeItem({ color, value, name, unit, inactive }: LegendItemProps) {
* Small legend item — inline layout with a colored dot, series name, and value
* on a single row. Use for compact legends below or beside a chart.
*/
function SmallItem({ color, value, name, inactive }: LegendItemProps) {
function SmallItem({
color,
value,
name,
inactive,
onPointerEnter,
onPointerLeave,
onClick,
className,
}: LegendItemProps) {
return (
<div className="inline-flex items-center gap-2">
<div
// oxlint-disable-next-line prefer-tag-over-role
role="button"
tabIndex={onClick ? 0 : -1}
className={cn(
"inline-flex items-center gap-2",
{ "cursor-pointer": !!onClick },
className,
)}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onClick={onClick}
onKeyDown={onClick ? onInteractiveKeyDown : undefined}
>
<span
className={cn("size-2 rounded-full inline-block", {
"opacity-50": inactive,
Expand Down
67 changes: 44 additions & 23 deletions packages/kumo/src/components/chart/TimeseriesChart.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type * as echarts from "echarts/core";
import type { LineSeriesOption, BarSeriesOption } from "echarts/charts";
import type { EChartsOption } from "echarts";
import { useEffect, useMemo, useRef } from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import { Chart, ChartEvents } from "./EChart";

/** A single data series rendered on a `TimeseriesChart` */
Expand Down Expand Up @@ -119,27 +119,46 @@ export interface TimeseriesChartProps {
* />
* ```
*/
export function TimeseriesChart({
echarts,
type = "line",
data,
xAxisName,
xAxisTickCount,
xAxisTickFormat,
yAxisTickFormat,
yAxisTickLabelFormat,
yAxisName,
yAxisTickCount,
tooltipValueFormat,
onTimeRangeChange,
height = 350,
incomplete,
isDarkMode,
gradient,
loading,
ariaDescription,
}: TimeseriesChartProps) {
export const TimeseriesChart = forwardRef<
echarts.ECharts | null,
TimeseriesChartProps
>(function TimeseriesChart(
{
echarts,
type = "line",
data,
xAxisName,
xAxisTickCount,
xAxisTickFormat,
yAxisTickFormat,
yAxisTickLabelFormat,
yAxisName,
yAxisTickCount,
tooltipValueFormat,
onTimeRangeChange,
height = 350,
incomplete,
isDarkMode,
gradient,
loading,
ariaDescription,
},
ref,
) {
const chartRef = useRef<echarts.ECharts | null>(null);

const mergedRef = useCallback(
(instance: echarts.ECharts | null) => {
chartRef.current = instance;
if (typeof ref === "function") {
ref(instance);
} else if (ref) {
ref.current = instance;
}
},
[ref],
);

const incompleteBefore = incomplete?.before;
const incompleteAfter = incomplete?.after;

Expand Down Expand Up @@ -378,7 +397,7 @@ export function TimeseriesChart({
{!loading && (
<Chart
echarts={echarts}
ref={chartRef}
ref={mergedRef}
options={options as EChartsOption}
height={height}
isDarkMode={isDarkMode}
Expand All @@ -387,7 +406,9 @@ export function TimeseriesChart({
)}
</div>
);
}
});

TimeseriesChart.displayName = "TimeseriesChart";

/**
* Animated sine-wave skeleton shown while `TimeseriesChart` is in `loading` state.
Expand Down