Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .changeset/timeseries-tooltip-react.md
Original file line number Diff line number Diff line change
@@ -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
110 changes: 109 additions & 1 deletion packages/kumo-docs-astro/src/components/demos/Chart/ChartDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,6 +29,7 @@ echarts.use([
AxisPointerComponent,
BrushComponent,
GridComponent,
ToolboxComponent,
TooltipComponent,
AriaComponent,
LabelLayout,
Expand Down Expand Up @@ -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<FollowCursorOption>(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 (
<div className="flex w-full flex-col gap-4">
<Select
label="Tooltip follow cursor"
value={selected}
onValueChange={(v) => { if (v) setSelected(v); }}
renderValue={(v) => v.label}
>
{FOLLOW_CURSOR_OPTIONS.map((opt) => (
<Select.Option key={opt.value} value={opt}>
{opt.label}
</Select.Option>
))}
</Select>
<TimeseriesChart
echarts={echarts}
isDarkMode={isDarkMode}
data={data}
xAxisName="Time (UTC)"
yAxisName="Latency (ms)"
tooltipFollowCursor={selected.value}
/>
</div>
);
}

/**
* 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<HTMLDivElement | null>(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 (
<div
ref={boundaryRef}
className="w-full overflow-auto rounded-lg border border-kumo-line"
style={{ height: 300 }}
>
<TimeseriesChart
echarts={echarts}
isDarkMode={isDarkMode}
data={data}
xAxisName="Time (UTC)"
yAxisName="Count"
height={280}
tooltipBoundary={boundary ?? undefined}
/>
</div>
);
}

function buildSeriesData(
seed = 0,
points = 50,
Expand Down
35 changes: 35 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,8 @@ import {
IncompleteDataChartDemo,
LoadingChartDemo,
TimeRangeSelectionChartDemo,
TooltipFollowCursorDemo,
TooltipBoundaryDemo,
} from "~/components/demos/Chart/ChartDemo";

<p>
Expand Down Expand Up @@ -94,6 +96,39 @@ import {

<ComponentSection>

### Tooltip Cursor Tracking

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

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

<ComponentSection>

### Tooltip Boundary

<p>
Use the <code>tooltipBoundary</code> 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.
</p>

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

<ComponentSection>

### Bar Chart

<p>
Expand Down
Loading
Loading