Skip to content

RevoGrid Charts JavaScript · TypeScript

JavaScript charts.
Bring your data to life.

Build interactive dashboards with a JavaScript and TypeScript chart library for React, Vue, Angular, and vanilla applications. Compare performance, spot trends, and explore your data.

Vanilla JavaScript React Vue Angular

Actual and budget revenue stacks with margin and target lines across twelve months
A year in perspectiveFY2025 · USD · Sample data

THE BIG PICTURE

Growth with a healthy margin.

Loading live chart…

Operating profit$3.79M
Operating margin26.6%
Exit margin · Dec31.0%

THE CONTRIBUTION

Where revenue comes from.

Loading live chart…

Outer · actualInner · plan

Enterprise mix is expanding.52% of revenue, up 6 pp from the 46% plan.

THE COMPARISON

Every region. Against plan.

Loading live chart…

Revenue variance
N. America+6.3%Europe+4.4%Asia Pacific+4.7%Other+10.8%

One chart. Or a whole story.

Start with a standalone chart. Compose a dashboard as your questions grow.

Your app. Your visual language.

Bring your colors, labels, and theme. Keep every visualization feeling consistent.

From seeing to exploring.

Take the next step with Pivot: connect charts to reports, filters, and aggregated data.

See the whole story

From one chart to a decision.

Explore three runnable dashboards. Each uses one fictional source dataset, with explicit filters that update complementary views. Choose a story, change a filter and inspect the data.

Did a deployment worsen latency everywhere, or only for one service and region?

272 requests · 10 errors / 272 requests · 3.7% errors · raw p95 972 ms. Full 30-minute period.

Fictional requests, 18 October 2026, 12:00–12:30 UTC. Release B starts at 12:15. Filter by service and region to isolate the change. Trend and request charts share a time window.

When did errors increase?

Count columns and error-percent line use separate named value axes.

Loading live chart…

What does the latency tail hide?

Fixed 0, 100, 200, 400, 800, 1600, 3200 ms edges. Every selected request belongs to exactly one bin.

Loading live chart…

Which service and bucket?

Pick a populated cell to focus its service and five-minute window. Empty buckets have no requests.

Loading live chart…

Inspect a real request

Each point retains its request ID. Click, tap, or focus a point and activate it.

Loading live chart…

Activate a scatter request for raw details, or a heatmap cell for an explicit aggregate filter.

Runnable source and aggregation policy

All observations are loaded locally. Error percentage uses selected 5xx requests / selected requests; p95 uses nearest rank over raw durations. The host owns shared filters, and only the compatible elapsed-time axes are linked.

Reliability: mount a chart ​

Copy the source module below and give the host an explicit height. Call this after the host mounts:

ts
import { createChart } from '@revolist/charts'
import { reliabilityExamples, selectRequests } from './reliability-data'

const examples = reliabilityExamples(selectRequests())
const chart = await createChart({
  container: document.getElementById('chart')!,
  chartType: 'columnLine',
  ...examples.trend,
})

// When the host unmounts:
// chart.destroy()

Reliability: data and chart configurations ​

ts
import { createChartDataset } from '@revolist/charts'
import type { StoryChartOptions } from './types'

export const services = ['Checkout', 'Search', 'Catalog'] as const
export const regions = ['Europe', 'Americas'] as const
export const reliabilityStart = 1792324800000 // 2026-10-18 12:00 UTC
export const bucketMs = 300_000
export const latencyBoundaries = [0, 100, 200, 400, 800, 1600, 3200]
export type RequestFact = {
  requestId: string
  startedAt: number
  elapsedMs: number
  service: string
  region: string
  durationMs: number
  statusCode: number
  deploymentId: string
}
/** Fictional deterministic requests; Catalog has no observations in minute 10–15. */
export const requests: RequestFact[] = services.flatMap((service, si) =>
  regions.flatMap((region, ri) =>
    Array.from({ length: 6 }, (_, bucket) =>
      service === 'Catalog' && bucket === 2
        ? []
        : Array.from({ length: 8 }, (_, sample) => {
            const elapsedMs = bucket * bucketMs + sample * 37_000
            const impacted = service === 'Checkout' && region === 'Europe' && bucket >= 3
            const error = impacted ? sample % 3 === 0 : (sample + bucket + si + ri) % 19 === 0
            return {
              requestId: `req-${si}-${ri}-${bucket}-${sample}`,
              startedAt: reliabilityStart + elapsedMs,
              elapsedMs,
              service,
              region,
              durationMs:
                55 + si * 25 + ri * 15 + sample * 19 + (impacted ? 500 + sample * 120 : 0),
              statusCode: error ? 503 : 200,
              deploymentId: bucket >= 3 ? 'release-b' : 'release-a',
            }
          }),
    ).flat(),
  ),
)
export const isError = (request: RequestFact) => request.statusCode >= 500
export function selectRequests(service = 'all', region = 'all', bucket: number | null = null) {
  return requests.filter(
    request =>
      (service === 'all' || request.service === service) &&
      (region === 'all' || request.region === region) &&
      (bucket === null || Math.floor(request.elapsedMs / bucketMs) === bucket),
  )
}
export function reliabilitySummary(rows: RequestFact[]) {
  const errors = rows.filter(isError).length
  const sorted = rows.map(row => row.durationMs).sort((a, b) => a - b)
  return {
    count: rows.length,
    errors,
    errorPercent: rows.length ? (errors / rows.length) * 100 : null,
    // Nearest-rank p95 over raw selected requests, never an average of percentiles.
    p95: sorted.length ? sorted[Math.ceil(sorted.length * 0.95) - 1] : null,
  }
}
export function reliabilityExamples(rows: RequestFact[]) {
  const buckets = Array.from({ length: 6 }, (_, index) => {
    const summary = reliabilitySummary(
      rows.filter(row => Math.floor(row.elapsedMs / bucketMs) === index),
    )
    return {
      elapsed: index * bucketMs,
      label: `${index * 5} min`,
      count: summary.count,
      errorPercent: summary.errorPercent,
    }
  })
  const trend: StoryChartOptions = {
    dataset: createChartDataset(buckets, {
      category: { field: 'elapsed', label: 'label' },
      series: [
        { field: 'count', label: 'Requests' },
        { field: 'errorPercent', label: 'Errors · %' },
      ],
    }),
    bindings: {},
    axes: {
      x: { scale: 'linear', min: 0, max: 1_800_000, title: 'Elapsed · ms', unit: 'elapsed-ms' },
      y: { min: 0, title: 'Request count' },
      yAxes: { errors: { position: 'right', min: 0, max: 100, title: 'Errors · %' } },
    },
    seriesOverrides: {
      count: { type: 'column', color: '#6647f0' },
      errorPercent: { type: 'line', yAxis: 'errors', color: '#ff6b26', markers: true },
    },
    navigation: { enabled: true },
    presentation: {
      legendPosition: 'top',
      description:
        'Five-minute request counts and error percentage, with independent count and percentage axes.',
    },
  }
  const histogram: StoryChartOptions = {
    dataset: createChartDataset(rows, {
      category: { field: 'requestId' },
      series: [{ field: 'durationMs', label: 'Request latency' }],
    }),
    bindings: { value: ['durationMs'] },
    chartOptions: { histogramBinBoundaries: latencyBoundaries, histogramAggregation: 'count' },
    axes: { x: { scale: 'linear', title: 'Latency · ms' }, y: { title: 'Requests' } },
    presentation: {
      legendPosition: 'none',
      description:
        'Raw request latency distribution with fixed millisecond boundaries; bins are aggregates.',
    },
  }
  const visibleServices = services.filter(service => rows.some(row => row.service === service))
  const heatRows = visibleServices.map(service => ({
    service,
    ...Object.fromEntries(
      buckets.map((_, index) => {
        const summary = reliabilitySummary(
          rows.filter(
            row => row.service === service && Math.floor(row.elapsedMs / bucketMs) === index,
          ),
        )
        return [`bucket-${index}`, summary.errorPercent]
      }),
    ),
  }))
  const heatmap: StoryChartOptions = {
    dataset: createChartDataset(heatRows, {
      category: { field: 'service' },
      series: buckets.map((_, index) => ({
        field: `bucket-${index}`,
        label: `${index * 5}–${index * 5 + 5} min`,
      })),
    }),
    bindings: {},
    axes: { matrix: { rows: { title: 'Service' }, columns: { title: 'Five-minute bucket' } } },
    presentation: {
      legendPosition: 'none',
      description:
        'Error percentage by service and five-minute bucket. Missing requests produce null cells, not zero-percent errors.',
    },
  }
  const scatter: StoryChartOptions = {
    dataset: createChartDataset(rows, {
      category: { field: 'requestId' },
      series: [
        { field: 'elapsedMs', label: 'Elapsed · ms' },
        { field: 'durationMs', label: 'Latency · ms' },
      ],
    }),
    bindings: { x: ['elapsedMs'], y: ['durationMs'] },
    axes: {
      x: { scale: 'linear', min: 0, max: 1_800_000, title: 'Elapsed · ms', unit: 'elapsed-ms' },
      y: { title: 'Latency · ms', min: 0 },
    },
    navigation: { enabled: true, axes: 'x' },
    presentation: {
      legendPosition: 'none',
      description:
        'One genuine request per point: elapsed milliseconds versus request latency. Activate a point to inspect the original request.',
    },
  }
  return { trend, histogram, heatmap, scatter }
}

Reliability: shared option type ​

ts
import type { ChartOptions } from '@revolist/charts'

/** Declarative example input; LiveChart owns the mounted chart and cleanup. */
export type StoryChartOptions = Pick<
  ChartOptions,
  | 'dataset'
  | 'bindings'
  | 'presentation'
  | 'axes'
  | 'seriesOverrides'
  | 'chartOptions'
  | 'rendererOptions'
  | 'annotations'
  | 'navigation'
  | 'viewport'
  | 'interaction'
  | 'rangeSelection'
>

Reliability: host filters and lifecycle ​

The running example's Vue component owns filters, record inspection and cleanup:

vue
<template>
  <div class="service-reliability" data-story="reliability">
    <p>Did a deployment worsen latency everywhere, or only for one service and region?</p>
    <div class="story-controls">
      <label
        >Service
        <select v-model="service" aria-label="Service" @change="clearInspection">
          <option value="all">All services</option>
          <option v-for="item in services" :key="item">{{ item }}</option>
        </select></label
      >
      <label
        >Region
        <select v-model="region" aria-label="Region" @change="clearInspection">
          <option value="all">All regions</option>
          <option v-for="item in regions" :key="item">{{ item }}</option>
        </select></label
      >
      <button type="button" @click="reset">Reset filters</button>
    </div>
    <p class="story-summary" aria-live="polite">
      {{ summary.count }} requests · {{ summary.errors }} errors / {{ summary.count }} requests ·
      {{ percent(summary.errorPercent) }} errors · raw p95 {{ summary.p95 ?? '—' }} ms.
      {{
        bucket === null
          ? 'Full 30-minute period.'
          : `Bucket ${bucket * 5}–${bucket * 5 + 5} minutes.`
      }}
    </p>
    <p class="story-note">
      Fictional requests, 18 October 2026, 12:00–12:30 UTC. Release B starts at 12:15. Filter by
      service and region to isolate the change. Trend and request charts share a time window.
    </p>
    <div class="story-grid">
      <section class="story-card">
        <h4>When did errors increase?</h4>
        <p>Count columns and error-percent line use separate named value axes.</p>
        <div class="story-chart">
          <LiveChart
            :key="`trend-${revision}`"
            type="columnLine"
            :example="examples.trend"
            eager
            @ready="connect('trend', $event)"
            @activate="aggregateTrend"
          />
        </div>
      </section>
      <section class="story-card">
        <h4>What does the latency tail hide?</h4>
        <p>
          Fixed 0, 100, 200, 400, 800, 1600, 3200 ms edges. Every selected request belongs to
          exactly one bin.
        </p>
        <div class="story-chart">
          <LiveChart
            type="histogram"
            :example="examples.histogram"
            eager
            @activate="histogramInspection"
          />
        </div>
      </section>
      <section class="story-card">
        <h4>Which service and bucket?</h4>
        <p>
          Pick a populated cell to focus its service and five-minute window. Empty buckets have no
          requests.
        </p>
        <div class="story-chart">
          <LiveChart type="heatmap" :example="examples.heatmap" eager @activate="inspectCell" />
        </div>
        <button v-if="cell" class="story-action" type="button" @click="applyCell">
          Use {{ cell.service }} and {{ cell.bucket * 5 }}–{{ cell.bucket * 5 + 5 }} min
        </button>
      </section>
      <section class="story-card">
        <h4>Inspect a real request</h4>
        <p>Each point retains its request ID. Click, tap, or focus a point and activate it.</p>
        <div class="story-chart">
          <LiveChart
            :key="`scatter-${revision}`"
            type="scatter"
            :example="examples.scatter"
            eager
            @ready="connect('scatter', $event)"
            @activate="inspectRequest"
          />
        </div>
      </section>
    </div>
    <p class="story-summary" aria-live="polite">{{ inspection }}</p>
    <details v-if="selectedRequest" open class="story-source">
      <summary>Request {{ selectedRequest.requestId }}</summary>
      <dl>
        <dt>Service / region</dt>
        <dd>{{ selectedRequest.service }} / {{ selectedRequest.region }}</dd>
        <dt>UTC instant</dt>
        <dd>{{ new Date(selectedRequest.startedAt).toISOString() }}</dd>
        <dt>Latency / response</dt>
        <dd>{{ selectedRequest.durationMs }} ms / HTTP {{ selectedRequest.statusCode }}</dd>
        <dt>Deployment</dt>
        <dd>{{ selectedRequest.deploymentId }}</dd>
      </dl>
    </details>
  </div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { createChartSyncGroup, type ChartRef, type ChartNodeClick } from '@revolist/charts'
import LiveChart from '../LiveChart.vue'
import {
  services,
  regions,
  requests,
  selectRequests,
  reliabilitySummary,
  reliabilityExamples,
  type RequestFact,
} from './reliability-data'
const service = ref('all'),
  region = ref('all'),
  bucket = ref<number | null>(null),
  revision = ref(0)
const selected = computed(() => selectRequests(service.value, region.value, bucket.value))
const summary = computed(() => reliabilitySummary(selected.value))
const examples = computed(() => reliabilityExamples(selected.value))
const selectedRequest = ref<RequestFact>()
const cell = ref<{ service: string; bucket: number }>()
const inspection = ref(
  'Activate a scatter request for raw details, or a heatmap cell for an explicit aggregate filter.',
)
const percent = (value: number | null) => (value === null ? '—' : `${value.toFixed(1)}%`)
let sync = createChartSyncGroup({ viewport: 'x', domain: false, hover: false })
const disconnect = new Map<string, () => void>()
function connect(key: string, chart: ChartRef) {
  disconnect.get(key)?.()
  disconnect.set(key, sync.connect(chart))
}
function clearInspection() {
  selectedRequest.value = undefined
  cell.value = undefined
  inspection.value = 'Host filters updated; inspect a request or an aggregate.'
}
function reset() {
  service.value = 'all'
  region.value = 'all'
  bucket.value = null
  revision.value++
  clearInspection()
}
function inspectRequest(detail: ChartNodeClick) {
  const request = requests.find(row => row.requestId === detail.category.path[0])
  if (!request || !selected.value.includes(request)) return
  selectedRequest.value = request
  inspection.value = `Raw request ${request.requestId}: ${request.durationMs} ms, HTTP ${request.statusCode}, ${request.service}, ${request.region}.`
}
function inspectCell(detail: ChartNodeClick) {
  const index = Number(detail.series.id.replace('bucket-', ''))
  const selectedService = String(detail.category.path[0])
  if (
    !services.includes(selectedService as (typeof services)[number]) ||
    !Number.isInteger(index) ||
    index < 0 ||
    index > 5
  )
    return
  selectedRequest.value = undefined
  cell.value = { service: selectedService, bucket: index }
  inspection.value = `Aggregate: ${selectedService}, ${index * 5}–${index * 5 + 5} min, ${percent(detail.value)} errors. Apply the explicit filter below the heatmap.`
}
function applyCell() {
  if (!cell.value) return
  service.value = cell.value.service
  bucket.value = cell.value.bucket
  clearInspection()
}
function aggregateTrend() {
  selectedRequest.value = undefined
  inspection.value =
    'Five-minute aggregate: count and error percentage use the selected requests. This mark is not an individual request.'
}
function histogramInspection() {
  selectedRequest.value = undefined
  inspection.value =
    'Latency bin: an aggregate over the selected raw requests. Use the scatter to inspect individual requests.'
}
watch(
  selected,
  () => {
    disconnect.forEach(stop => stop())
    disconnect.clear()
    sync.destroy()
    sync = createChartSyncGroup({ viewport: 'x', domain: false, hover: false })
  },
  { flush: 'sync' },
)
onBeforeUnmount(() => {
  disconnect.forEach(stop => stop())
  sync.destroy()
})
</script>
View runnable data and chart configurations

Fictional complete source: 72 shipments, with 7 cancellations excluded everywhere. Each shipment contributes once per route stage. Late means delivery after the promised instant; duration uses elapsed hours. Boxes and flows are aggregates.

Fulfilment: mount a chart ​

Copy the source module below and give the host an explicit height. Call this after the host mounts:

ts
import { createChart } from '@revolist/charts'
import { buildFulfilmentStory } from './fulfilment-data'

const examples = buildFulfilmentStory({ warehouse: '', carrier: '', region: '', period: 'all' })
const chart = await createChart({
  container: document.getElementById('chart')!,
  chartType: 'sankey',
  ...examples.flow,
})

// When the host unmounts:
// chart.destroy()

Fulfilment: data and chart configurations ​

ts
import { createChartDataset, createChartFlowData } from '@revolist/charts'
import type { StoryChartOptions } from './types'

// Fictional, deterministic UTC shipment facts. One shipment per order; no split shipments.
const HOUR = 3_600_000
const DAY = 24 * HOUR
export const fulfilmentEpoch = 1_792_022_400_000 // 2026-10-15T00:00:00Z
export const warehouses = ['North', 'Central', 'South'] as const
export const carriers = ['ParcelOne', 'SwiftPost'] as const
export const destinationRegions = ['North', 'Coast', 'Inland'] as const
export interface Shipment {
  shipmentId: string
  orderId: string
  customerId: string
  warehouse: string
  carrier: string
  region: string
  dispatchDay: number
  dispatchedAt: number
  deliveredAt: number
  promisedAt: number
  value: number
  cancelled: boolean
}
export const shipments: Shipment[] = warehouses.flatMap((warehouse, w) =>
  carriers.flatMap((carrier, c) =>
    destinationRegions.flatMap((region, r) =>
      Array.from({ length: 4 }, (_, dispatchDay) => {
        const index = ((w * 2 + c) * 3 + r) * 4 + dispatchDay
        const dispatchedAt = fulfilmentEpoch + dispatchDay * DAY + (8 + r) * HOUR
        const elapsedHours = 22 + w * 9 + c * 6 + r * 4 + ((index * 7) % 17)
        return {
          shipmentId: `SHP-${String(index + 1).padStart(3, '0')}`,
          orderId: `ORD-${String(index + 1).padStart(3, '0')}`,
          customerId: `CUS-${(index % 13) + 1}`,
          warehouse,
          carrier,
          region,
          dispatchDay,
          dispatchedAt,
          deliveredAt: dispatchedAt + elapsedHours * HOUR,
          promisedAt: dispatchedAt + 48 * HOUR,
          value: 85 + (index % 9) * 25,
          cancelled: index % 11 === 0,
        }
      }),
    ),
  ),
)
// Leg IDs describe genuine route stages of each shipment, independently of displayed aggregates.
export const shipmentLegs = shipments.flatMap(shipment => [
  {
    legId: `${shipment.shipmentId}:dispatch`,
    shipmentId: shipment.shipmentId,
    source: `warehouse:${shipment.warehouse}`,
    target: `carrier:${shipment.carrier}`,
  },
  {
    legId: `${shipment.shipmentId}:destination`,
    shipmentId: shipment.shipmentId,
    source: `carrier:${shipment.carrier}`,
    target: `region:${shipment.region}`,
  },
])
export interface FulfilmentFilters {
  warehouse: string
  carrier: string
  region: string
  period: 'all' | 'recent'
}
export function eligibleShipments(filters: FulfilmentFilters): Shipment[] {
  return shipments.filter(
    shipment =>
      !shipment.cancelled &&
      (!filters.warehouse || shipment.warehouse === filters.warehouse) &&
      (!filters.carrier || shipment.carrier === filters.carrier) &&
      (!filters.region || shipment.region === filters.region) &&
      (filters.period !== 'recent' || shipment.dispatchDay >= 2),
  )
}
export const elapsedDeliveryHours = (shipment: Shipment) =>
  (shipment.deliveredAt - shipment.dispatchedAt) / HOUR
export const shipmentIsOnTime = (shipment: Shipment) => shipment.deliveredAt <= shipment.promisedAt

export function buildFulfilmentStory(filters: FulfilmentFilters) {
  const selected = eligibleShipments(filters)
  const selectedIds = new Set(selected.map(shipment => shipment.shipmentId))
  const linkCounts = new Map<
    string,
    { id: string; source: string; target: string; value: number }
  >()
  for (const leg of shipmentLegs) {
    if (!selectedIds.has(leg.shipmentId)) continue
    const id = `aggregate:${leg.source}->${leg.target}`
    const entry = linkCounts.get(id) ?? { id, source: leg.source, target: leg.target, value: 0 }
    entry.value++
    linkCounts.set(id, entry)
  }
  const flowLinks = [...linkCounts.values()]
  const activeWarehouses = warehouses.filter(
    warehouse => !filters.warehouse || warehouse === filters.warehouse,
  )
  const warehouseRows = activeWarehouses.map(warehouse => {
    const rows = selected.filter(shipment => shipment.warehouse === warehouse)
    const onTime = rows.filter(shipmentIsOnTime).length
    return {
      warehouse,
      onTime,
      late: rows.length - onTime,
      // Matrix value is a labelled mean; box geometry exclusively uses the source samples below.
      hours: rows.length
        ? rows.reduce((sum, row) => sum + elapsedDeliveryHours(row), 0) / rows.length
        : null,
      samples: rows.map(elapsedDeliveryHours),
    }
  })
  const ranking: StoryChartOptions = {
    dataset: createChartDataset(warehouseRows, {
      category: { field: 'warehouse' },
      series: [
        { field: 'onTime', label: 'On time' },
        { field: 'late', label: 'Late' },
      ],
    }),
    presentation: {
      legendPosition: 'top',
      description: 'On-time and late shipment counts by warehouse',
    },
    axes: { x: { title: 'Shipments' } },
  }
  const durationDataset = createChartDataset(warehouseRows, {
    category: { field: 'warehouse' },
    series: [{ field: 'hours', label: 'Delivery hours' }],
  })
  durationDataset.analyticalFrame!.derived = {
    distributions: warehouseRows.map((row, index) => ({
      kind: 'samples' as const,
      categoryId: durationDataset.categories[index].id,
      seriesId: 'hours',
      samples: row.samples,
    })),
  }
  const duration: StoryChartOptions = {
    dataset: durationDataset,
    presentation: {
      legendPosition: 'none',
      description: 'Actual elapsed delivery hours by warehouse, source samples',
    },
    axes: { y: { min: 0, title: 'Elapsed hours' } },
  }
  const dayRows = [0, 1, 2, 3]
    .filter(day => filters.period !== 'recent' || day >= 2)
    .map(day => {
      const rows = selected.filter(shipment => shipment.dispatchDay === day)
      return {
        day: `Oct ${15 + day}`,
        count: rows.length,
        onTimePercent: rows.length
          ? (rows.filter(shipmentIsOnTime).length / rows.length) * 100
          : null,
      }
    })
  const daily: StoryChartOptions = {
    dataset: createChartDataset(dayRows, {
      category: { field: 'day' },
      series: [
        { field: 'count', label: 'Dispatched' },
        { field: 'onTimePercent', label: 'On time %' },
      ],
    }),
    presentation: {
      legendPosition: 'top',
      description: 'Daily dispatch count and on-time percent on separate value axes',
    },
    axes: {
      y: { min: 0, title: 'Shipments' },
      yAxes: { punctuality: { min: 0, max: 100, position: 'right', title: 'On time · %' } },
    },
    seriesOverrides: {
      count: { type: 'column', color: '#6647f0' },
      onTimePercent: { type: 'line', yAxis: 'punctuality', color: '#0098fa', markers: true },
    },
  }
  const flow: StoryChartOptions = {
    ...createChartFlowData({
      nodes: [
        ...warehouses.map(label => ({ id: `warehouse:${label}`, label: `${label} warehouse` })),
        ...carriers.map(label => ({ id: `carrier:${label}`, label })),
        ...destinationRegions.map(label => ({ id: `region:${label}`, label: `${label} region` })),
      ],
      links: flowLinks,
    }),
    presentation: {
      legendPosition: 'none',
      description:
        'Shipment counts from warehouse through carrier to destination region; aggregate flows',
    },
  }
  return { selected, flowLinks, warehouseRows, dayRows, flow, ranking, duration, daily }
}

Fulfilment: shared option type ​

ts
import type { ChartOptions } from '@revolist/charts'

/** Declarative example input; LiveChart owns the mounted chart and cleanup. */
export type StoryChartOptions = Pick<
  ChartOptions,
  | 'dataset'
  | 'bindings'
  | 'presentation'
  | 'axes'
  | 'seriesOverrides'
  | 'chartOptions'
  | 'rendererOptions'
  | 'annotations'
  | 'navigation'
  | 'viewport'
  | 'interaction'
  | 'rangeSelection'
>

Fulfilment: host filters and lifecycle ​

The running example's Vue component owns filters and cleanup:

vue
<template>
  <div data-story="fulfilment">
    <p class="story-note">
      Where are shipments delayed: a busy route, or a warehouse with consistently slow deliveries?
    </p>
    <div class="story-controls" aria-label="Fulfilment filters">
      <label
        >Warehouse<select v-model="filters.warehouse" aria-label="Warehouse">
          <option value="">All warehouses</option>
          <option v-for="warehouse in warehouses" :key="warehouse">{{ warehouse }}</option>
        </select></label
      >
      <label
        >Carrier<select v-model="filters.carrier" aria-label="Carrier">
          <option value="">All carriers</option>
          <option v-for="carrier in carriers" :key="carrier">{{ carrier }}</option>
        </select></label
      >
      <label
        >Destination<select v-model="filters.region" aria-label="Destination">
          <option value="">All regions</option>
          <option v-for="region in destinationRegions" :key="region">{{ region }}</option>
        </select></label
      >
      <label
        >Dispatch period<select v-model="filters.period" aria-label="Dispatch period">
          <option value="all">Oct 15–18 · UTC</option>
          <option value="recent">Oct 17–18 · UTC</option>
        </select></label
      >
      <button type="button" @click="reset">Reset filters</button>
    </div>
    <p class="story-summary" role="status">
      {{ story.selected.length }} eligible shipments · {{ onTimeCount }} on time ·
      {{ story.selected.length - onTimeCount }} late.
    </p>
    <div class="story-grid">
      <article class="story-card">
        <h4>1. Follow the route volume</h4>
        <p>See how shipment volume moves from warehouses through carriers to destinations.</p>
        <LiveChart class="story-chart" type="sankey" eager :example="story.flow" />
      </article>
      <article class="story-card">
        <h4>2. Compare warehouse outcomes</h4>
        <p>Counts make the biggest late-shipment workload visible.</p>
        <LiveChart class="story-chart" type="groupedBar" eager :example="story.ranking" />
      </article>
      <article class="story-card">
        <h4>3. Separate the tail from a general slowdown</h4>
        <p>Boxes use actual elapsed-hour samples, not averages of warehouse totals.</p>
        <LiveChart class="story-chart" type="boxPlot" eager :example="story.duration" />
      </article>
      <article class="story-card">
        <h4>4. Check the daily direction</h4>
        <p>Columns show dispatch counts; the line uses its own 0–100% value axis.</p>
        <LiveChart class="story-chart" type="columnLine" eager :example="story.daily" />
      </article>
    </div>
    <p class="story-note">
      Compare late-shipment counts with delivery spread to find which warehouses need attention. For
      record-level investigation, try the <a href="/demo/pivot-charts">Pivot Charts example</a>.
    </p>
  </div>
</template>

<script setup lang="ts">
import { computed, reactive } from 'vue'
import LiveChart from '../LiveChart.vue'
import {
  buildFulfilmentStory,
  carriers,
  destinationRegions,
  shipmentIsOnTime,
  warehouses,
  type FulfilmentFilters,
} from './fulfilment-data'

const filters = reactive<FulfilmentFilters>({
  warehouse: '',
  carrier: '',
  region: '',
  period: 'all',
})
const story = computed(() => buildFulfilmentStory(filters))
const onTimeCount = computed(() => story.value.selected.filter(shipmentIsOnTime).length)
function reset() {
  Object.assign(filters, { warehouse: '', carrier: '', region: '', period: 'all' })
}
</script>
View runnable manufacturing data and chart configuration

Histogram totals and box samples use all selected measured parts. Fixed 0.05 mm bins retain their edges. The separate reference is N × bin width × an authored normal density (mean 20 mm, standard deviation 0.04 mm), not fitted measurements. Only scatter points open part records.

Manufacturing: mount a chart ​

Copy the source module below and give the host an explicit height. Call this after the host mounts:

ts
import { createChart } from '@revolist/charts'
import { buildManufacturingStory, selectParts } from './manufacturing-data'

const examples = buildManufacturingStory(selectParts())
const chart = await createChart({
  container: document.getElementById('chart')!,
  chartType: 'columnLine',
  ...examples.histogram,
})

// When the host unmounts:
// chart.destroy()

Manufacturing: data and chart configurations ​

ts
import { createChartDataset, type ChartNodeClick } from '@revolist/charts'
import type { StoryChartOptions } from './types'

export const MACHINES = ['Lathe A', 'Lathe B', 'Lathe C'] as const
export const BATCHES = ['Batch 1', 'Batch 2', 'Batch 3', 'Batch 4'] as const
export const SPECIFICATION = { minimum: 19.94, maximum: 20.06, target: 20 }
export const BIN_EDGES = [19.8, 19.85, 19.9, 19.95, 20, 20.05, 20.1, 20.15, 20.2]
export interface Part {
  partId: string
  measuredAt: number
  machineId: string
  batchId: string
  diameterMm: number
  temperatureC: number
  scrapCost: number
}
/** Fictional complete local fact table: 12 measured parts per machine and batch. */
export const PARTS: Part[] = MACHINES.flatMap((machineId, machine) =>
  BATCHES.flatMap((batchId, batch) =>
    Array.from({ length: 12 }, (_, sample) => {
      const drift = machine === 2 ? batch * 0.023 : batch * 0.002
      const deviation = Math.sin((sample + 1) * 1.7 + machine) * 0.034
      const outlier = machine === 2 && sample === 10 ? 0.065 : 0
      const diameterMm = Number((20 + drift + deviation + outlier).toFixed(4))
      return {
        partId: `PART-${machine + 1}-${batch + 1}-${String(sample + 1).padStart(2, '0')}`,
        measuredAt: Date.UTC(2026, 0, 12 + batch, 8 + machine, sample * 4),
        machineId,
        batchId,
        diameterMm,
        temperatureC: Number((21 + machine * 3 + batch * 0.8 + Math.sin(sample) * 1.4).toFixed(2)),
        scrapCost:
          diameterMm < SPECIFICATION.minimum || diameterMm > SPECIFICATION.maximum ? 7.5 : 0,
      }
    }),
  ),
)
export function selectParts(machine = 'all', batch = 'all') {
  return PARTS.filter(
    part =>
      (machine === 'all' || part.machineId === machine) &&
      (batch === 'all' || part.batchId === batch),
  )
}
const mean = (values: number[]) => values.reduce((sum, value) => sum + value, 0) / values.length
export function partFromActivation(detail: ChartNodeClick, parts: readonly Part[]) {
  return parts.find(part => detail.category.path[0] === part.partId)
}

/** Host filters produce new canonical datasets; Charts does not cross-filter these panels. */
export function buildManufacturingStory(parts: readonly Part[]) {
  const rows = parts.map(part => ({ ...part }))
  const scatterDataset = createChartDataset(rows, {
    category: { field: 'partId' },
    series: [
      { field: 'temperatureC', label: 'Temperature °C' },
      { field: 'diameterMm', label: 'Diameter mm' },
    ],
  })
  const scatter: StoryChartOptions = {
    dataset: scatterDataset,
    bindings: { x: ['temperatureC'], y: ['diameterMm'] },
    presentation: {
      description: 'Each dot is a measured part. Activate a dot to inspect its original part ID.',
    },
    axes: {
      x: { scale: 'linear', title: 'Temperature', unit: '°C' },
      y: { min: 19.8, max: 20.2, title: 'Diameter', unit: 'mm' },
    },
  }

  // Reference count = N × 0.05 mm bin width × authored normal density (20 mm, σ 0.04 mm).
  // These reference coordinates are deliberately separate from genuine part records.
  const referenceRows = Array.from({ length: 33 }, (_, index) => {
    const x = Number((19.8 + index * 0.0125).toFixed(4))
    return {
      partId: `REFERENCE-${index}`,
      diameterMm: null,
      referenceCount:
        ((parts.length * 0.05) / (0.04 * Math.sqrt(2 * Math.PI))) *
        Math.exp(-0.5 * ((x - 20) / 0.04) ** 2),
      coordinate: x,
    }
  })
  const compositionRows = [
    ...parts.map(part => ({
      partId: part.partId,
      diameterMm: part.diameterMm,
      referenceCount: null,
      coordinate: part.diameterMm,
    })),
    ...referenceRows,
  ]
  const histogramDataset = createChartDataset(compositionRows, {
    category: { field: 'partId' },
    series: [
      { field: 'diameterMm', label: 'Measured part count' },
      { field: 'referenceCount', label: 'Authored reference count / 0.05 mm' },
    ],
  })
  const bins = BIN_EDGES.slice(0, -1).map((lower, index) => {
    const indexes = parts.flatMap((part, partIndex) =>
      part.diameterMm >= lower &&
      (part.diameterMm < BIN_EDGES[index + 1] ||
        (index === BIN_EDGES.length - 2 && part.diameterMm === BIN_EDGES[index + 1]))
        ? [partIndex]
        : [],
    )
    const values = indexes.map(partIndex => parts[partIndex].diameterMm)
    const sum = values.reduce((total, value) => total + value, 0)
    return {
      count: indexes.length,
      valueCount: indexes.length,
      sum,
      mean: values.length ? sum / values.length : null,
      sampleMean: values.length ? sum / values.length : null,
      ...(indexes.length ? { representative: histogramDataset.categories[indexes[0]] } : {}),
    }
  })
  histogramDataset.analyticalFrame!.derived = {
    histograms: [
      {
        kind: 'histogram',
        scope: 'all',
        sampleSeriesId: 'diameterMm',
        valueSeriesId: 'diameterMm',
        requestedBinCount: 8,
        aggregation: 'count',
        boundaries: [...BIN_EDGES],
        bins,
      },
    ],
  }
  const histogram: StoryChartOptions = {
    dataset: histogramDataset,
    bindings: { value: ['diameterMm', 'referenceCount'] },
    presentation: {
      description:
        'Complete fixed diameter bins with a separate authored reference count curve; reference points are not parts.',
    },
    axes: {
      x: {
        scale: 'linear',
        min: 19.8,
        max: 20.2,
        title: 'Diameter',
        unit: 'mm',
        data: Object.fromEntries(
          histogramDataset.categories.map((category, index) => [
            category.id,
            compositionRows[index].coordinate,
          ]),
        ),
      },
      y: { min: 0, title: 'Parts / 0.05 mm interval' },
    },
    seriesOverrides: {
      diameterMm: {
        type: 'histogram',
        histogram: { binCount: 8, boundaries: [...BIN_EDGES], aggregation: 'count' },
      },
      referenceCount: { type: 'line', color: '#7c3aed' },
    },
    navigation: { tooltipMode: 'shared' },
  }

  const machines = MACHINES.filter(machine => parts.some(part => part.machineId === machine))
  const boxDataset = createChartDataset(
    machines.map(machineId => ({
      machineId,
      diameterMm: mean(
        parts.filter(part => part.machineId === machineId).map(part => part.diameterMm),
      ),
    })),
    {
      category: { field: 'machineId' },
      series: [{ field: 'diameterMm', label: 'Diameter samples' }],
    },
  )
  boxDataset.analyticalFrame!.derived = {
    distributions: boxDataset.categories.map((category, index) => ({
      kind: 'samples',
      categoryId: category.id,
      seriesId: 'diameterMm',
      samples: parts
        .filter(part => part.machineId === machines[index])
        .map(part => part.diameterMm),
    })),
  }
  const box: StoryChartOptions = {
    dataset: boxDataset,
    bindings: { value: ['diameterMm'] },
    presentation: {
      description:
        'Machine diameter distributions from every eligible measured part; these boxes are aggregates.',
    },
    axes: { y: { min: 19.8, max: 20.2, title: 'Diameter', unit: 'mm' } },
  }
  const batchSummaries = BATCHES.flatMap((batchId, index) => {
    const samples = parts.filter(part => part.batchId === batchId)
    return samples.length
      ? [
          {
            batchId,
            index: index + 1,
            count: samples.length,
            meanDiameter: mean(samples.map(part => part.diameterMm)),
          },
        ]
      : []
  })
  const batchDataset = createChartDataset(batchSummaries, {
    category: { field: 'batchId' },
    series: [{ field: 'meanDiameter', label: 'Batch mean diameter' }],
  })
  const trend: StoryChartOptions = {
    dataset: batchDataset,
    bindings: { value: ['meanDiameter'] },
    presentation: {
      description:
        'Batch means derived from eligible parts, compared with authored specification limits.',
    },
    axes: { y: { min: 19.8, max: 20.2, title: 'Mean diameter', unit: 'mm' } },
    annotations: {
      references: [
        {
          id: 'lower-spec',
          kind: 'line',
          axis: 'primary',
          value: SPECIFICATION.minimum,
          label: 'Lower specification 19.94 mm',
          color: '#c2410c',
          lineDash: 'dash',
        },
        {
          id: 'upper-spec',
          kind: 'line',
          axis: 'primary',
          value: SPECIFICATION.maximum,
          label: 'Upper specification 20.06 mm',
          color: '#c2410c',
          lineDash: 'dash',
        },
      ],
    },
  }
  return {
    scatter,
    histogram,
    box,
    trend,
    bins,
    batchSummaries,
    count: parts.length,
    defective: parts.filter(part => part.scrapCost > 0).length,
  }
}

Manufacturing: shared option type ​

ts
import type { ChartOptions } from '@revolist/charts'

/** Declarative example input; LiveChart owns the mounted chart and cleanup. */
export type StoryChartOptions = Pick<
  ChartOptions,
  | 'dataset'
  | 'bindings'
  | 'presentation'
  | 'axes'
  | 'seriesOverrides'
  | 'chartOptions'
  | 'rendererOptions'
  | 'annotations'
  | 'navigation'
  | 'viewport'
  | 'interaction'
  | 'rangeSelection'
>

Manufacturing: host filters and lifecycle ​

The running example's Vue component owns filters, record inspection and cleanup:

vue
<template>
  <section class="manufacturing-story" data-story="manufacturing">
    <h3>Is a machine creating the defective tail?</h3>
    <p>
      Follow the same measured parts from a diameter distribution to temperature, machine spread and
      batch drift.
    </p>
    <div class="story-controls" aria-label="Manufacturing filters">
      <label
        >Machine
        <select v-model="machine" aria-label="Machine">
          <option value="all">All machines</option>
          <option v-for="item in MACHINES" :key="item">{{ item }}</option>
        </select></label
      >
      <label
        >Batch
        <select v-model="batch" aria-label="Batch">
          <option value="all">All batches</option>
          <option v-for="item in BATCHES" :key="item">{{ item }}</option>
        </select></label
      >
      <button type="button" @click="reset">Reset filters</button>
      <button type="button" @click="inspectTail">Inspect highest-diameter part</button>
    </div>
    <p class="story-summary" role="status">
      {{ story.count }} measured parts · {{ story.defective }} outside 19.94–20.06 mm ·
      {{ story.bins.reduce((sum, bin) => sum + bin.count, 0) }} parts in complete fixed bins.
    </p>
    <div class="story-grid">
      <article class="story-card">
        <h4>Measured distribution + reference</h4>
        <p>
          Eight complete bins, 0.05 mm wide. The violet curve is an authored count reference, scaled
          to the selected part count.
        </p>
        <div class="story-chart">
          <LiveChart
            :key="`histogram-${generation}`"
            type="columnLine"
            eager
            :example="story.histogram"
          />
        </div>
      </article>
      <article class="story-card">
        <h4>Temperature versus diameter</h4>
        <p>
          Each dot retains its genuine part ID. Click or use keyboard inspection to open a part
          below.
        </p>
        <div class="story-chart">
          <LiveChart
            :key="`scatter-${generation}`"
            type="scatter"
            eager
            :example="story.scatter"
            @activate="inspect"
          />
        </div>
      </article>
      <article class="story-card">
        <h4>Spread by machine</h4>
        <p>
          Boxes use every eligible part’s actual diameter sample. A box is a machine aggregate, not
          one part.
        </p>
        <div class="story-chart">
          <LiveChart :key="`box-${generation}`" type="boxPlot" eager :example="story.box" />
        </div>
      </article>
      <article class="story-card">
        <h4>Batch mean versus specification</h4>
        <p>
          Means are calculated from the selected parts in each batch. Dashed limits are authored
          specifications.
        </p>
        <div class="story-chart">
          <LiveChart :key="`trend-${generation}`" type="line" eager :example="story.trend" />
        </div>
      </article>
    </div>
    <p class="story-summary" role="status" aria-live="polite" data-manufacturing-inspection>
      {{
        inspected
          ? `${inspected.partId} · ${inspected.machineId} · ${inspected.batchId} · ${inspected.diameterMm} mm · ${inspected.temperatureC} °C · measured ${new Date(inspected.measuredAt).toISOString()} · ${inspected.scrapCost > 0 ? 'outside specification' : 'within specification'}`
          : 'Select a temperature/diameter dot or inspect the highest-diameter part to see its original record.'
      }}
    </p>
    <p class="story-note">
      Try Lathe C and Batch 4 to see how drift appears across all four views.
    </p>
  </section>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ChartNodeClick } from '@revolist/charts'
import LiveChart from '../LiveChart.vue'
import {
  BATCHES,
  MACHINES,
  buildManufacturingStory,
  partFromActivation,
  selectParts,
  type Part,
} from './manufacturing-data'
const machine = ref('all')
const batch = ref('all')
const generation = ref(0)
const inspected = ref<Part>()
const parts = computed(() => selectParts(machine.value, batch.value))
const story = computed(() => buildManufacturingStory(parts.value))
watch([machine, batch], () => {
  inspected.value = undefined
  generation.value++
})
function reset() {
  machine.value = 'all'
  batch.value = 'all'
  inspected.value = undefined
  generation.value++
}
function inspect(detail: ChartNodeClick) {
  inspected.value = partFromActivation(detail, parts.value)
}
function inspectTail() {
  inspected.value = [...parts.value].sort((a, b) => b.diameterMm - a.diameterMm)[0]
}
</script>

Give a moment in your data its own style.

Highlight a decline, a recovery or an unusual observation with authored segment and datum styles. The values and source identities stay the same. Toggle the styles to compare.

A dashed decline. A gradient recovery.

A diamond marks March; directed segment rules style February → March and March → April.

Loading live chart…

View line styling code
ts
encoding: {
  datums: [{ categoryId: marchId,
    style: { marker: 'diamond', markerSize: 9 } }],
  segments: [{ from: februaryId, to: marchId,
    style: { stroke: '#c2410c', strokeDasharray: '6 4' } }],
}

One observation, a different finish.

March uses a diagonal pattern; June uses a gradient. Every bar retains its raw value.

Loading live chart…

View column styling code
ts
encoding: {
  datums: [{ categoryId: marchId, style: {
    fill: { type: 'pattern', pattern: 'diagonal',
      color: '#7c3aed', size: 8 },
  } }],
}

Rules use actual dataset IDs and are saved with the chart. Segment rules apply to line and area outlines; marker policies apply to supported line and scatter glyphs. Custom datum encoding uses individual SVG marks, with an explicit fallback from batched SVG or WebGL. Explore styling options.

See your data change.

Line, grouped column and grouped bar charts animate data updates for 180 ms with ease-out by default. Compare the same update with motion switched off. Initial rendering is immediate; your device’s reduced-motion preference is respected.

Dataset 1

Default animation

View animated chart code
ts
// No transition options needed
const chart = await createChart(options)
await chart.update({ data: nextRows })

Explicit opt-out

View immediate chart code
ts
const chart = await createChart({
  ...options,
  rendererOptions: { transitions: false },
})
await chart.update({ data: nextRows })

Override timing with transitions: { duration: 300, easing: 'linear' } in rendererOptions. Other chart families, batched SVG and WebGL remain immediate. Resize cancels motion; image and vector exports settle to committed data.

Build and customize.

Explore the standalone Charts guides for configuration, supported interactions and renderer limitations.

Better together

The chart starts the conversation.
Pivot takes it further.

Group and aggregate records in a Pivot table, then explore linked charts that follow the report's filters. See the dashboard that brings it all together.

Try the live dashboard

Loading live chart…

FAQ

JavaScript charts — common questions

RevoGrid Charts is a standalone JavaScript and TypeScript chart library for interactive dashboards and data visualization. Create charts without a data grid, or connect them to RevoGrid Pivot for report-driven analysis.

Make your next dashboard a little more insightful.

Explore the chart dashboard, or discover how Charts and Pivot fit into your application.