Forecast: smooth + confidence band

smooth: true bends the polyline into a monotone cubic curve that still passes through every point but flows between them. upperValues + lowerValues shade a translucent envelope around the forecast for at-a-glance uncertainty. 12 weeks actuals + 8 weeks forecast.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.

About this example

A forecast chart in Svelte 5 with a smooth curve and a confidence band. smooth true swaps the polyline for a monotone cubic curve that still passes through every point without overshoot, and upperValues plus lowerValues give the series a translucent envelope that curves with it, over twelve weeks of actuals and eight of forecast. Flip actuals only to clip the chart to the historical part of the same series, with zoom available.

Two chart options that turn a raw forecast into something readable:

  • smooth: true on a series swaps the polyline for a monotone cubic curve. The curve still passes through every data point but flows smoothly between them - no overshoots.
  • upperValues[] + lowerValues[] give the chart a translucent band that shades the forecast envelope. Pair with smooth and the band itself curves to match the trend line.

The grid feeds the forecast; flip "actuals only" to clip the chart to the historical part of the same series.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: week (Week), actual (Actual), forecast (Forecast), lower (Lower), upper (Upper)

SvGridApi methods called: api.getDisplayedRows()

Frequently asked questions

How is the band defined?

Give the series upperValues and lowerValues arrays parallel to values; the chart fills between them with a translucent version of the series colour.

Why monotone cubic smoothing?

It keeps the curve through every point and never overshoots between them, so a smoothed forecast never shows a peak the data does not contain.

Can the band apply only to the forecast part?

Yes. Leave the upper and lower entries empty for historical points and the band starts where the forecast begins.

Related documentation

Related articles

  • Sparkline Cells in a Svelte Data Grid - Show inline trend sparklines inside grid cells using SvGrid's built-in sparkline column property - no charting library needed, just a field that holds a number array.
  • Render 100,000 Rows Smoothly with Grid Virtualization - A practical look at how SvGrid keeps a Svelte data grid at 60fps with 100k rows - what virtualization actually does, the one layout requirement that breaks it silently, and why $state.raw matters at scale.

Source code (157-chart-forecast-band.svelte)

<!-- Documented in: docs/help/charts/start.md -->
<script lang="ts">
  /**
   * 157. Forecast with smooth lines + confidence band
   * --------------------------------------------------
   * Two chart options that turn a raw forecast into something readable:
   *
   *  - `smooth: true` on a series swaps the polyline for a monotone cubic
   *    curve. The curve still passes through every data point but flows
   *    smoothly between them - no overshoots.
   *
   *  - `upperValues[]` + `lowerValues[]` give the chart a translucent
   *    band that shades the forecast envelope. Pair with `smooth` and the
   *    band itself curves to match the trend line.
   *
   * The grid feeds the forecast; flip "actuals only" to clip the chart
   * to the historical part of the same series.
   */
  import {
    SvGrid,
    SvGridChart,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
    type SvGridApi,
    type ChartSpec,
  } from '@svgrid/grid'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  type Row = {
    id: number; week: string;
    actual: number | null; forecast: number | null;
    upper: number | null; lower: number | null;
  }
  // 12 weeks of actuals + 8 weeks of forecast with widening uncertainty.
  let seed = 0xfeedface
  const rnd = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff)
  const HIST = 12
  const FCST = 8
  const rows: Row[] = Array.from({ length: HIST + FCST }, (_, i) => {
    const d = new Date(2026, 2, 1 + i * 7)
    const week = d.toISOString().slice(0, 10)
    const base = 4200 + i * 95
    const seasonal = Math.sin((i / 4) * 2 * Math.PI) * 220
    const value = Math.round(base + seasonal + (rnd() - 0.5) * 180)
    const forecast = i >= HIST ? value : null
    // Spread widens after the actuals run out.
    const k = i >= HIST ? (i - HIST + 1) : 0
    const spread = 220 + k * 95
    return {
      id: i,
      week,
      actual:   i < HIST ? value : null,
      forecast,
      upper:    forecast != null ? forecast + spread : null,
      lower:    forecast != null ? Math.max(0, forecast - spread) : null,
    }
  })

  const fmtCurrency = (v: number | null) => v == null ? '' : v.toLocaleString('en-US', { maximumFractionDigits: 0 })

  const columns: GridColumns<Row> = [
    { field: 'week', header: 'Week', width: 130 },
    { field: 'actual',   header: 'Actual',   width: 110, align: 'right' },
    { field: 'forecast', header: 'Forecast', width: 110, align: 'right' },
    { field: 'lower',    header: 'Lower',    width: 100, align: 'right' },
    { field: 'upper',    header: 'Upper',    width: 100, align: 'right' },
  ]

  let api = $state<SvGridApi<typeof features, Row> | null>(null)
  let displayed = $state<Row[]>(rows)
  let smooth = $state(true)
  let actualsOnly = $state(false)
  function sync() { displayed = (api?.getDisplayedRows() as Row[]) ?? rows }

  const spec = $derived.by<ChartSpec>(() => {
    const cats = displayed.map((r) => r.week)
    const actuals = displayed.map((r) => (r.actual ?? NaN) as number)
    const forecasts = actualsOnly ? [] : [{
      label: 'Forecast',
      values:      displayed.map((r) => (r.forecast ?? NaN) as number),
      upperValues: displayed.map((r) => (r.upper    ?? NaN) as number),
      lowerValues: displayed.map((r) => (r.lower    ?? NaN) as number),
      color: '#94a3b8',
      smooth,
    }]
    return {
      type: 'line',
      categories: cats,
      series: [
        { label: 'Actual', values: actuals, color: '#2563eb', smooth },
        ...forecasts,
      ],
      width: 720,
      height: 320,
      xType: 'time',
      xAxisTitle: 'Week',
      yAxisTitle: 'Weekly bookings',
    }
  })

  const compact = (v: number) => (Math.abs(v) >= 1e3 ? (v / 1e3).toFixed(v % 1e3 ? 1 : 0) + 'k' : String(Math.round(v)))

  /** Pane size, so the chart fills its card rather than a fixed viewBox. */
  let paneW = $state(0)
  let paneH = $state(0)
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="shrink-0 rounded-lg border px-4 py-3" style="border-color: var(--sg-border); background: var(--sg-header-bg);">
    <p class="text-sm font-semibold" style="color: var(--sg-fg);">
      Forecast with smooth curves + a shaded confidence band
    </p>
    <p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
      The forecast series has `upperValues` + `lowerValues` set, which shade the envelope between
      them. Toggle smoothing to compare monotone cubic curves vs. straight polylines.
    </p>
    <div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
      <label class="ic-chk"><input type="checkbox" bind:checked={smooth} /> Smooth curves</label>
      <label class="ic-chk"><input type="checkbox" bind:checked={actualsOnly} /> Actuals only</label>
    </div>
  </div>

  <div class="flex flex-1 min-h-0 gap-3">
    <div class="flex-1 min-w-0 min-h-0">
      <SvGrid responsive={true}
      columnResize
        data={rows}
        columns={columns}
        features={features}
        sortable
        filterable
        selectionMode="none"
        rowHeight={28}
        containerHeight="100%"
        fitColumns={true}
        onApiReady={(a) => { api = a; sync() }}
        onFiltersChange={sync}
        onSortingChange={sync}
      />
    </div>
    <div class="rounded-lg border p-3" style="flex: 0 1 760px; min-width: 0; min-height: 0; border-color: var(--sg-border); background: var(--sg-bg);">
      <!-- Measured box, not the card: its height comes from the parent, so the
           chart cannot push the thing it is sized against. -->
      <div style="width: 100%; height: 100%; min-height: 0;" bind:clientWidth={paneW} bind:clientHeight={paneH}>
        {#if paneW > 40 && paneH > 40}
          <!-- zoomable because a forecast is the chart people most want to zoom
               into, and because the band has to survive the zoom window: the
               envelopes are category-parallel arrays, so a window that sliced the
               values but not them used to drop the shading without a word. -->
          <SvGridChart {spec} formatValue={compact} zoomable width={paneW} height={paneH} />
        {/if}
      </div>
    </div>
  </div>
</section>

<style>
  .ic-chk { display: inline-flex; align-items: center; gap: 4px; color: var(--sg-fg); font-size: 12px; }
  .ic-chk input[type='checkbox'] { accent-color: var(--sg-accent); }
</style>

View this example on GitHub

More Charts examples

  • Chart a selection (context menu) - With integrated charting enabled, the right-click menu gains a Chart selected range item: select a block of cells, right-click, and the chart panel opens scoped to that range - the Excel chart-this gesture, built in. The item appears only when charting is on and is appended to the default context menu automatically.
  • Chart view of the grid - The `chart` prop turns the same <SvGrid> into a chart, driven by the grid’s filtered + sorted rows (search + sort flow through). A view of the grid like board and scheduler, but the renderer is free: the grid lazy-loads a built-in view wrapping the standalone SvChart via rowsToChartSpec. Flip Table <-> Chart (bar / line / area) over one source of truth.
  • Candlestick / OHLC - Candlestick and OHLC price marks with an ordinal date axis. ChartSeries.ohlc carries the four prices while values keeps the closes, so the CSV export, the screen-reader table and a 10-day moving average all work with no candle-specific code. Toggle the axis: a real time axis opens a gap over every weekend, an ordinal one spaces sessions evenly and still labels them by date. The strip under the plot is the chart brush: drag its window to pan, drag an edge to resize.
  • Box plot + error bars - Distribution rather than average: box plots with the 1.5 IQR whisker rule and individual outliers, next to the same data as a bar chart of the means with error bars. two regions with nearly identical means sit side by side in the bars and look nothing alike in the boxes. boxStats() summarises a raw sample, rowsToBoxSpec() does it per group, and ChartSeries.errors annotates any existing mark without changing its type.
  • Axes, titles and styling - The chart hands the decisions back: a numeric x axis that spreads payload sizes the way the numbers do, pinned domains with a fixed tick interval and a formatter per axis, grid lines on or off, a shaded budget band, a vertical reference line, per-point markers and colours, a stepped dashed series on the right axis, a title / subtitle / caption, a legend on any side, and a tooltip snippet in single-series mode. Below it, 50,000 monitoring readings decimated to one point per pixel with LTTB or min / max - toggle it off to see the cost.