Timeseries Chart
@cloudflare/kumo

时间序列图表是用于展示基于时间的数据的专用图表。 每个数据点是一个 [timestamp_in_ms, value] 二元组。

基本折线图

展示随时间变化的多个数据系列的简单折线图。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * Basic line chart example showing simple time-based data visualization.
 */
export function BasicLineChartDemo() {
  const isDarkMode = useIsDarkMode();

  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 (
    <TimeseriesChart
      echarts={echarts}
      isDarkMode={isDarkMode}
      data={data}
      xAxisName="Time (UTC)"
      yAxisName="Count"
    />
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

离散计数数据

对于请求数等离散值,设置 yAxisMinInterval=1。 这可以避免 y 轴出现小数刻度,同时对速率和其他连续值保持 默认的坐标轴行为不变。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * Discrete request-count chart with whole-number y-axis ticks.
 */
export function IntegerYAxisChartDemo() {
  const isDarkMode = useIsDarkMode();

  const data = useMemo(() => {
    const end = Date.now();
    const values = [0, 1, 3, 2, 5, 4, 7, 3, 6, 2, 4, 1];

    return [
      {
        name: "Requests",
        data: values.map((value, index): [number, number] => [
          end - (values.length - index - 1) * 60_000,
          value,
        ]),
        color: ChartPalette.semantic("Neutral", isDarkMode),
      },
    ];
  }, [isDarkMode]);

  return (
    <TimeseriesChart
      echarts={echarts}
      isDarkMode={isDarkMode}
      data={data}
      xAxisName="Time (UTC)"
      yAxisName="Requests"
      yAxisMinInterval={1}
    />
  );
}

参考标记线

使用 markers 属性在特定时间戳处渲染垂直参考线, 例如部署、发布或配置变更。标记线可以包含简短标签、用于工具 提示的描述、颜色和线条样式。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

export function ReferenceMarkersChartDemo() {
  const isDarkMode = useIsDarkMode();

  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],
  );

  const markers = useMemo(
    () => [
      {
        timestamp: data[0].data[15][0],
        label: "change a1b2c3d4",
        description: "Configuration change applied",
      },
      {
        timestamp: data[0].data[16][0],
        label: "change b2c3d4e5",
        description: "Routing rule updated",
      },
      {
        timestamp: data[0].data[17][0],
        label: "change c3d4e5f6",
        description: "Limit adjusted",
      },
      {
        timestamp: data[0].data[34][0],
        label: "change e5f6g7h8",
        description: "New version released",
        lineStyle: "dotted" as const,
      },
    ],
    [data],
  );

  return (
    <TimeseriesChart
      echarts={echarts}
      isDarkMode={isDarkMode}
      data={data}
      markers={markers}
      xAxisName="Time (UTC)"
      yAxisName="Count"
    />
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

阈值

使用 thresholds 属性在数值轴上渲染水平参考线, 例如内存上限或配额边界。阈值包含一个数值和颜色,标签可选。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

export function ThresholdsChartDemo() {
  const isDarkMode = useIsDarkMode();

  const data = useMemo(
    () => [
      {
        name: "Memory used",
        data: buildSeriesData(0, 50, 60_000, 1),
        color: ChartPalette.semantic("Neutral", isDarkMode),
      },
    ],
    [isDarkMode],
  );

  return (
    <TimeseriesChart
      echarts={echarts}
      isDarkMode={isDarkMode}
      data={data}
      thresholds={[
        {
          value: 55,
          label: "Memory limit",
          color: ChartPalette.semantic("Attention", isDarkMode),
        },
      ]}
      xAxisName="Time (UTC)"
      yAxisName="Memory (MB)"
    />
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

自定义时间戳与数值格式

使用 xAxisTickFormat 自定义 x 轴标签,使用 tooltipTimestampFormat 自定义标准工具提示和标记 工具提示中的时间戳。两者接收的都是毫秒级的原始时间戳,因此 可以一致地应用用户选定的时区。未提供格式化函数时,工具提示 的时间戳使用浏览器的区域设置和时区。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/** Timeseries chart with custom UTC timestamps and compact value formats. */
export function CustomAxisLabelFormatDemo() {
  const isDarkMode = useIsDarkMode();

  const data = useMemo(
    () => [
      {
        name: "Requests",
        data: buildSeriesData(0, 50, 60_000, 1000),
        color: ChartPalette.semantic("Neutral", isDarkMode),
      },
    ],
    [isDarkMode],
  );

  return (
    <TimeseriesChart
      echarts={echarts}
      isDarkMode={isDarkMode}
      data={data}
      xAxisName="Time (UTC)"
      yAxisName="Requests"
      xAxisTickFormat={(timestamp) =>
        new Intl.DateTimeFormat(undefined, {
          timeZone: "UTC",
          hour: "2-digit",
          minute: "2-digit",
        }).format(timestamp)
      }
      yAxisTickFormat={(value) => {
        if (value >= 1000) return `${value / 1000}k`;
        return value.toString();
      }}
      tooltipValueFormat={(value) => `${(value / 1000).toFixed(1)}k requests`}
      tooltipTimestampFormat={(timestamp) =>
        new Intl.DateTimeFormat(undefined, {
          timeZone: "UTC",
          dateStyle: "medium",
          timeStyle: "medium",
        }).format(timestamp)
      }
    />
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

渐变填充

将 gradient 设为 true,可在每条折线 系列下方渲染垂直渐变填充。填充从顶部的系列颜色渐变到底部的 透明色,让图表呈现精致的面积图观感,同时不损失各条折线的 清晰度。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * Timeseries chart with gradient fill beneath each line series.
 */
export function GradientLineChartDemo() {
  const isDarkMode = useIsDarkMode();

  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 (
    <TimeseriesChart
      echarts={echarts}
      isDarkMode={isDarkMode}
      data={data}
      xAxisName="Time (UTC)"
      yAxisName="Count"
      gradient
    />
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

不完整数据

使用 incomplete 属性标记数据可能不完整或仍在 收集中的区域。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * Timeseries chart with incomplete data regions highlighted.
 */
export function IncompleteDataChartDemo() {
  const isDarkMode = useIsDarkMode();

  const data = useMemo(
    () => [
      {
        name: "Bandwidth",
        data: buildSeriesData(0, 50, 60_000, 1),
        color: ChartPalette.categorical(0, isDarkMode),
      },
    ],
    [isDarkMode],
  );

  const incompleteTimestamp = data[0].data[data[0].data.length - 5][0];

  return (
    <TimeseriesChart
      echarts={echarts}
      isDarkMode={isDarkMode}
      data={data}
      xAxisName="Time (UTC)"
      yAxisName="Mbps"
      incomplete={{ after: incompleteTimestamp }}
    />
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

时间范围选择

通过提供 onTimeRangeChange 回调来启用时间范围 选择。用户可以在图表上点击并拖动来选取时间范围。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * Timeseries chart with time range selection enabled.
 */
export function TimeRangeSelectionChartDemo() {
  const isDarkMode = useIsDarkMode();

  const data = useMemo(
    () => [
      {
        name: "CPU Usage",
        data: buildSeriesData(0, 50, 60_000, 1),
        color: ChartPalette.categorical(0, isDarkMode),
      },
    ],
    [isDarkMode],
  );

  return (
    <TimeseriesChart
      echarts={echarts}
      isDarkMode={isDarkMode}
      data={data}
      xAxisName="Time (UTC)"
      yAxisName="%"
      onTimeRangeChange={(from, to) => {
        alert(
          `Selected range:\nFrom: ${new Date(from).toLocaleString()}\nTo: ${new Date(to).toLocaleString()}`,
        );
      }}
    />
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

使用 tooltipFooter 属性,可在标准系列工具提示的 数值下方添加简短的辅助文字。它适用于所有时间序列图表配置, 包括折线、柱状、渐变、阈值和自定义坐标轴图表。

Read latency
P99
124ms
P95
76ms
P75
32ms
P50
10ms
import { ChartPalette, TimeseriesChart, ChartLegend, LayerCard } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

export function ChartExampleDemo() {
  const isDarkMode = useIsDarkMode();

  const data = useMemo(
    () => [
      {
        name: "P99",
        data: buildSeriesData(3, 30, 60_000, 1),
        color: ChartPalette.semantic("Attention", isDarkMode),
      },
      {
        name: "P95",
        data: buildSeriesData(2, 30, 60_000, 0.6),
        color: ChartPalette.semantic("Warning", isDarkMode),
      },
      {
        name: "P75",
        data: buildSeriesData(1, 30, 60_000, 0.4),
        color: ChartPalette.semantic("Neutral", isDarkMode),
      },
      {
        name: "P50",
        data: buildSeriesData(0, 30, 60_000, 0.2),
        color: ChartPalette.semantic("Neutral", isDarkMode),
      },
    ],
    [isDarkMode],
  );

  return (
    <LayerCard>
      <LayerCard.Secondary>Read latency</LayerCard.Secondary>
      <LayerCard.Primary>
        <div className="mb-2 flex gap-4 divide-x divide-kumo-hairline px-2">
          <ChartLegend.LargeItem
            name="P99"
            color={ChartPalette.semantic("Attention", isDarkMode)}
            value="124"
            unit="ms"
          />
          <ChartLegend.LargeItem
            name="P95"
            color={ChartPalette.semantic("Warning", isDarkMode)}
            value="76"
            unit="ms"
          />
          <ChartLegend.LargeItem
            name="P75"
            color={ChartPalette.semantic("Neutral", isDarkMode)}
            value="32"
            unit="ms"
          />
          <ChartLegend.LargeItem
            name="P50"
            color={ChartPalette.semantic("Neutral", isDarkMode)}
            value="10"
            unit="ms"
          />
        </div>
        <TimeseriesChart
          xAxisName="Time (UTC)"
          echarts={echarts}
          isDarkMode={isDarkMode}
          data={data}
          height={300}
          tooltipFooter="Percentiles use a five-minute rolling window."
        />
      </LayerCard.Primary>
    </LayerCard>
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

工具提示跟随光标

使用 tooltipFollowCursor 属性控制工具提示在哪个 轴上跟随光标。默认为 “both”,即自由跟随光标。 设为 “x” 可得到 Recharts 风格的轴锁定工具提示, 只在水平方向移动。

Tooltip follow cursor
import { ChartPalette, TimeseriesChart, Select } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo, useState } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * 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>
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

工具提示边界

使用 tooltipBoundary 属性把工具提示限制在特定的 容器元素内。默认情况下,工具提示会避免溢出任何会产生裁剪的 祖先元素 (滚动容器、视口)。传入一个 DOM 元素可进一步收紧 限制——当图表位于卡片或面板内、且工具提示不应溢出其外时 很有用。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useCallback, useMemo, useState } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * 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,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

柱状图

设置 type=“bar” 可把系列渲染为堆叠柱状图而非 折线。其他所有属性——坐标轴、工具提示、颜色——的行为完全 相同。

import { ChartPalette, TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * Timeseries chart rendered as a stacked bar chart.
 */
export function BarChartDemo() {
  const isDarkMode = useIsDarkMode();

  const data = useMemo(
    () => [
      {
        name: "Requests where age > 10",
        data: buildSeriesData(0, 20, 3_600_000, 1),
        color: ChartPalette.semantic("Neutral", isDarkMode),
      },
      {
        name: "Errors",
        data: buildSeriesData(1, 20, 3_600_000, 0.3),
        color: ChartPalette.semantic("Attention", isDarkMode),
      },
    ],
    [isDarkMode],
  );

  return (
    <TimeseriesChart
      echarts={echarts}
      isDarkMode={isDarkMode}
      type="bar"
      data={data}
      xAxisName="Time (UTC)"
      yAxisName="Count"
      tooltipValueFormat={(r) => r.toFixed(2)}
    />
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

图例高亮

悬停在图例项上会高亮图表中对应的系列,并淡化其他系列。 在 ChartLegend 项上配合使用 onPointerEnter 和 onPointerLeave,以及图表 ref 上的 dispatchAction。

Read latency
P99
124ms
P95
76ms
P75
32ms
P50
10ms
import { ChartPalette, TimeseriesChart, ChartLegend, LayerCard } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useMemo, useRef, useState } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * 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("Neutral", 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="mb-2 flex divide-x divide-kumo-line px-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,
                });
              }}
              className="not-first:pl-4"
            />
          ))}
        </div>
        <TimeseriesChart
          ref={chartRef}
          xAxisName="Time (UTC)"
          echarts={echarts}
          isDarkMode={isDarkMode}
          data={data}
          height={300}
        />
      </LayerCard.Primary>
    </LayerCard>
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

图例点击

点击 ChartLegend 项会单独显示该系列,只展示它 并隐藏其余系列。再次点击已单独显示的系列会恢复全部系列。

Read latency
P99
124ms
P95
76ms
P75
32ms
P50
10ms
import { ChartPalette, TimeseriesChart, ChartLegend, LayerCard } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useEffect, useMemo, useRef, useState } from "react";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * Timeseries chart where the legend isolates a series on click. Clicking a
 * `ChartLegend` item shows only that series and hides the rest; clicking the
 * already-isolated series restores all. Visibility is driven through a hidden
 * ECharts legend via the `legendSelect` / `legendUnSelect` actions.
 */
export function LegendOnClickDemo() {
  const isDarkMode = useIsDarkMode();
  const chartRef = useRef<echarts.ECharts>(null);
  const [hiddenSeries, setHiddenSeries] = useState<Record<string, boolean>>({});

  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("Neutral", 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],
  );

  // A theme switch re-inits the ECharts instance, resetting legend selection to
  // all-visible. Reset our state to match so the legend doesn't desync.
  useEffect(() => {
    setHiddenSeries({});
  }, [isDarkMode]);

  // Click isolates a series: show only the clicked one and hide the rest via the
  // (hidden) ECharts legend. Clicking the already-isolated series restores all.
  const handleClick = (name: string) => {
    const chart = chartRef.current;
    if (!chart) return;

    setHiddenSeries((prev) => {
      // Already isolated to this series? (only it visible, everything else hidden)
      const isIsolated = series.every((s) =>
        s.name === name ? !prev[s.name] : prev[s.name],
      );

      const nextHidden: Record<string, boolean> = {};
      for (const s of series) {
        const shouldHide = isIsolated ? false : s.name !== name;
        nextHidden[s.name] = shouldHide;
        chart.dispatchAction({
          type: shouldHide ? "legendUnSelect" : "legendSelect",
          name: s.name,
        });
      }
      return nextHidden;
    });
  };

  return (
    <LayerCard>
      <LayerCard.Secondary>Read latency</LayerCard.Secondary>
      <LayerCard.Primary>
        <div className="mb-2 flex divide-x divide-kumo-line px-2">
          {series.map((s) => (
            <ChartLegend.LargeItem
              key={s.name}
              name={s.name}
              color={s.color}
              value={s.value}
              unit={s.unit}
              inactive={hiddenSeries[s.name] ?? false}
              onClick={() => handleClick(s.name)}
              className="not-first:pl-4"
            />
          ))}
        </div>
        <TimeseriesChart
          ref={chartRef}
          xAxisName="Time (UTC)"
          echarts={echarts}
          isDarkMode={isDarkMode}
          data={data}
          height={300}
          enableLegendSelection
        />
      </LayerCard.Primary>
    </LayerCard>
  );
}

function buildSeriesData(
  seed = 0,
  points = 50,
  stepMs = 60_000,
  timeScale = 1,
): [number, number][] {
  const end = Date.now();
  const start = end - (points - 1) * stepMs;

  return Array.from({ length: points }, (_, i) => {
    const ts = start + i * stepMs;
    const trend = i * 0.15;
    const noise = (Math.random() - 0.5) * 8;
    const value = Math.round((30 + seed * 15 + trend + noise) * 100) / 100;
    return [ts, value * timeScale];
  });
}

加载状态

将 loading 设为 true,可在数据加载 时显示与图表 type 相匹配的骨架屏。

折线图

import { TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * Line timeseries chart in loading state, showing the calm area-shaped skeleton.
 */
export function LoadingChartDemo() {
  const isDarkMode = useIsDarkMode();
  return (
    <div className="flex w-full flex-1 flex-col">
      <TimeseriesChart
        echarts={echarts}
        isDarkMode={isDarkMode}
        xAxisName="Time (UTC)"
        yAxisName="Count"
        data={[]}
        loading
      />
    </div>
  );
}

柱状图

import { TimeseriesChart } from "@cloudflare/kumo";
import * as echarts from "echarts/core";
import { useIsDarkMode } from "~/lib/use-is-dark-mode";

/**
 * Bar timeseries chart in loading state, showing the bar-shaped skeleton that
 * matches the chart's `type="bar"` output.
 */
export function LoadingBarChartDemo() {
  const isDarkMode = useIsDarkMode();
  return (
    <div className="flex w-full flex-1 flex-col">
      <TimeseriesChart
        echarts={echarts}
        isDarkMode={isDarkMode}
        type="bar"
        xAxisName="Time (UTC)"
        yAxisName="Count"
        data={[]}
        loading
      />
    </div>
  );
}