Timeseries Chart
kumo-svelte

The timeseries chart is a specialized chart for displaying time-based data. Each data point is a tuple of [timestamp_in_ms, value].

Basic Line Chart

A simple line chart displaying multiple data series over time.

<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import { TimeseriesChart, ChartPalette } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);

  const data: { name: string; color: string; data: [number, number][] }[] = $derived([
    {
      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)
    }
  ]);

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

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

Custom X-Axis Label Format

Use the xAxisTickLabelFormat prop to control how x-axis tick labels are rendered. The formatter receives the raw timestamp in milliseconds and returns a display string, overriding ECharts' built-in time formatting.

<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import { ChartPalette, TimeseriesChart } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);

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

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

<TimeseriesChart
  {echarts}
  {isDarkMode}
  {data}
  xAxisName="Time (UTC)"
  yAxisName="Requests"
  xAxisTickFormat={(timestamp) => {
    const date = new Date(timestamp);
    return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
  }}
  yAxisTickFormat={(value) => {
    if (value >= 1000) return `${value / 1000}k`;
    return value.toString();
  }}
  tooltipValueFormat={(value) => `${(value / 1000).toFixed(1)}k requests`}
/>

Gradient Fill

Set gradient to true to render a vertical gradient fill beneath each line series. The fill fades from the series color at the top to transparent at the bottom, giving the chart a polished area-chart look without losing the clarity of individual lines.

<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import { TimeseriesChart, ChartPalette } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);

  const data: { name: string; color: string; data: [number, number][] }[] = $derived([
    {
      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)
    }
  ]);

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

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

Incomplete Data

Use the incomplete prop to indicate regions where data may be incomplete or still being collected.

<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import { TimeseriesChart, ChartPalette } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);

  const data: { name: string; color: string; data: [number, number][] }[] = $derived([
    {
      name: 'Bandwidth',
      data: buildSeriesData(0, 50, 60_000, 1),
      color: ChartPalette.categorical(0, isDarkMode)
    }
  ]);
  const incompleteTimestamp = $derived(data[0].data[data[0].data.length - 5][0]);

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

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

Time Range Selection

Enable time range selection by providing the onTimeRangeChange callback. Users can click and drag on the chart to select a time range.

<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import { TimeseriesChart, ChartPalette } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);

  const data: { name: string; color: string; data: [number, number][] }[] = $derived([
    {
      name: 'CPU Usage',
      data: buildSeriesData(0, 50, 60_000, 1),
      color: ChartPalette.categorical(0, isDarkMode)
    }
  ]);

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

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

Legend Highlight

Hovering a legend item highlights the corresponding series on the chart and fades the others. Use onpointerenter and onpointerleave on ChartLegend items together with dispatchAction on the chart ref.

Read latency
P99
124 ms
P95
76 ms
P75
32 ms
P50
10 ms
<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import type { EChartsType } from 'echarts/core';
  import { ChartLegend, ChartPalette, LayerCard, TimeseriesChart } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);
  let chartRef: EChartsType | null = $state(null);
  let hoveredSeries: string | null = $state(null);

  const series = $derived([
    {
      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'
    }
  ]);

  const data = $derived(
    series.map((item, index) => ({
      name: item.name,
      data: buildSeriesData(3 - index, 30, 60_000, 1 - index * 0.2),
      color: item.color
    }))
  );

  function handlePointerEnter(name: string) {
    hoveredSeries = name;
    chartRef?.dispatchAction({ type: 'highlight', seriesName: name });
  }

  function handlePointerLeave(name: string) {
    hoveredSeries = null;
    chartRef?.dispatchAction({ type: 'downplay', seriesName: name });
  }

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

<LayerCard>
  <LayerCard.Secondary>Read latency</LayerCard.Secondary>
  <LayerCard.Primary>
    <div class="mb-2 flex divide-x divide-kumo-line px-2">
      {#each series as item (item.name)}
        <ChartLegend
          variant="large"
          name={item.name}
          color={item.color}
          value={item.value}
          unit={item.unit}
          inactive={hoveredSeries !== null && hoveredSeries !== item.name}
          onpointerenter={() => handlePointerEnter(item.name)}
          onpointerleave={() => handlePointerLeave(item.name)}
          class="not-first:pl-4"
        />
      {/each}
    </div>
    <TimeseriesChart
      bind:chartRef
      {echarts}
      xAxisName="Time (UTC)"
      {isDarkMode}
      {data}
      height={300}
    />
  </LayerCard.Primary>
</LayerCard>

Legend Click

Clicking a ChartLegend item isolates that series, showing only it and hiding the rest. Clicking the already-isolated series restores them all.

Read latency
P99
124 ms
P95
76 ms
P75
32 ms
P50
10 ms
<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import type { EChartsType } from 'echarts/core';
  import { ChartLegend, ChartPalette, LayerCard, TimeseriesChart } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);
  let chartRef: EChartsType | null = $state(null);
  let hiddenSeries: Record<string, boolean> = $state({});

  const series = $derived([
    {
      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'
    }
  ]);

  const data = $derived(
    series.map((item, index) => ({
      name: item.name,
      data: buildSeriesData(3 - index, 30, 60_000, 1 - index * 0.2),
      color: item.color
    }))
  );

  $effect(() => {
    isDarkMode;
    hiddenSeries = {};
  });

  function handleClick(name: string) {
    if (!chartRef) return;

    const isIsolated = series.every((item) => (item.name === name ? !hiddenSeries[item.name] : hiddenSeries[item.name]));
    const nextHidden: Record<string, boolean> = {};

    for (const item of series) {
      const shouldHide = isIsolated ? false : item.name !== name;
      nextHidden[item.name] = shouldHide;
      chartRef.dispatchAction({
        type: shouldHide ? 'legendUnSelect' : 'legendSelect',
        name: item.name
      });
    }

    hiddenSeries = nextHidden;
  }

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

<LayerCard>
  <LayerCard.Secondary>Read latency</LayerCard.Secondary>
  <LayerCard.Primary>
    <div class="mb-2 flex divide-x divide-kumo-line px-2">
      {#each series as item (item.name)}
        <ChartLegend
          variant="large"
          name={item.name}
          color={item.color}
          value={item.value}
          unit={item.unit}
          inactive={hiddenSeries[item.name] ?? false}
          onclick={() => handleClick(item.name)}
          class="not-first:pl-4"
        />
      {/each}
    </div>
    <TimeseriesChart
      bind:chartRef
      {echarts}
      xAxisName="Time (UTC)"
      {isDarkMode}
      {data}
      height={300}
      enableLegendSelection
    />
  </LayerCard.Primary>
</LayerCard>

Tooltip Cursor Tracking

Use tooltipFollowCursor to choose whether the tooltip follows both cursor axes or only tracks the x position. Set tooltipMode="single" when dense charts should show only the series nearest to the pointer.

<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import { TimeseriesChart, ChartPalette } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);

  const data: { name: string; color: string; data: [number, number][] }[] = $derived([
    {
      name: 'Requests',
      data: buildSeriesData(0, 72, 60_000, 1),
      color: ChartPalette.semantic('Neutral', isDarkMode)
    },
    {
      name: 'Edge errors',
      data: buildSeriesData(1, 72, 60_000, 0.35),
      color: ChartPalette.semantic('Attention', isDarkMode)
    },
    {
      name: 'Cache hits',
      data: buildSeriesData(2, 72, 60_000, 0.7),
      color: ChartPalette.semantic('Success', isDarkMode)
    }
  ]);

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

<TimeseriesChart
  {echarts}
  {data}
  {isDarkMode}
  tooltipMode="single"
  tooltipFollowCursor="x"
  xAxisName="Time (UTC)"
  yAxisName="Events"
/>

Tooltip Boundary

Set tooltipBoundary to keep the native tooltip within the chart container instead of letting it overflow the visual frame.

<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import { TimeseriesChart, ChartPalette } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);
  let boundary: HTMLDivElement | undefined = $state();

  const data: { name: string; color: string; data: [number, number][] }[] = $derived([
    {
      name: 'Origin requests',
      data: buildSeriesData(0, 64, 60_000, 0.8),
      color: ChartPalette.semantic('Neutral', isDarkMode)
    },
    {
      name: 'Blocked requests',
      data: buildSeriesData(1, 64, 60_000, 0.25),
      color: ChartPalette.semantic('Attention', isDarkMode)
    }
  ]);

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

<div bind:this={boundary} class="w-full overflow-hidden rounded-md border border-kumo-line p-3">
  <TimeseriesChart
    {echarts}
    {data}
    {isDarkMode}
    tooltipBoundary={boundary}
    tooltipMaxItems={4}
    xAxisName="Time (UTC)"
    yAxisName="Requests"
  />
</div>

Bar Chart

Set type="bar" to render series as stacked bars instead of lines. All other props — axes, tooltips, colors — work identically.

<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import { TimeseriesChart, ChartPalette } from 'kumo-svelte';
  import { buildSeriesData, getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);

  const data: { name: string; color: string; data: [number, number][] }[] = $derived([
    {
      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)
    }
  ]);

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

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

Loading State

Set loading to true to display an animated sine-wave skeleton while data is being fetched. The chart canvas is hidden until loading completes; swap back to loading=false to reveal the chart.

<script lang="ts">
  import { onMount } from 'svelte';
  import * as echarts from 'echarts';
  import { TimeseriesChart } from 'kumo-svelte';
  import { getIsDarkMode } from './chart-color-demo-data';

  let isDarkMode = $state(false);

  onMount(() => {
    const update = () => {
      isDarkMode = getIsDarkMode();
    };
    const observer = new MutationObserver(update);

    update();
    [document.documentElement, document.body].forEach((node) => {
      observer.observe(node, { attributes: true, attributeFilter: ['data-mode', 'class'] });
    });

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
    mediaQuery?.addEventListener('change', update);

    return () => {
      observer.disconnect();
      mediaQuery?.removeEventListener('change', update);
    };
  });
</script>

<div class="flex w-full flex-1 flex-col">
  <TimeseriesChart {echarts} {isDarkMode} xAxisName="Time (UTC)" yAxisName="Count" data={[]} loading />
</div>