Skip to content

Chart

Chart component is based on ChartJs and named v-bsb-chart. Implementation differs with:

  • instead of <Bar ... use <v-bsb-chart bar ...>
  • clickable property that changes mouse cursor to pointer when moving over visible data areas
  • emit elementClick that returns dataset index, data index and data value on mouse click

Setup

  1. Install
ps
npm i vue-chartjs chart.js
npm i --save chartjs-plugin-autocolors
  1. Create component wrapper
@/components/VBsbChart.vue
vue
<template>
  <Bar
    ref="chartRef"
    v-if="bar"
    :data="chartDataBar"
    :options="chartOptions"
    :plugins="chartPlugins"
    :clickable
    @click="onChartClick"
  />
  <Line
    ref="chartRef"
    v-else-if="line"
    :data="chartDataLine"
    :options="chartOptions"
    :plugins="chartPlugins"
    @click="onChartClick"
  />
  <Pie
    ref="chartRef"
    v-else-if="pie"
    :data="chartDataPie"
    :options="chartOptions"
    :plugins="chartPlugins"
    @click="onChartClick"
  />
  <Doughnut
    ref="chartRef"
    v-else-if="doughnut"
    :data="chartDataDoughnut"
    :options="chartOptions"
    :plugins="chartPlugins"
    @click="onChartClick"
  />
  <Radar
    ref="chartRef"
    v-else-if="radar"
    :data="chartDataRadar"
    :options="chartOptions"
    :plugins="chartPlugins"
    @click="onChartClick"
  />
  <PolarArea
    ref="chartRef"
    v-else-if="polarArea"
    :data="chartDataPolarArea"
    :options="chartOptions"
    :plugins="chartPlugins"
    @click="onChartClick"
  />
  <Scatter
    ref="chartRef"
    v-else-if="scatter"
    :data="chartDataScatter"
    :options="chartOptions"
    :plugins="chartPlugins"
    @click="onChartClick"
  />
</template>

<script setup lang="ts">
import { Bar, Line, Pie, Doughnut, Radar, PolarArea, Scatter } from 'vue-chartjs'
import type { ChartData, ChartEvent } from 'chart.js'
import {
  Chart,
  Title,
  Tooltip,
  Legend,
  BarElement,
  CategoryScale,
  LinearScale,
  LineElement,
  PointElement,
  ArcElement,
  RadialLinearScale,
  Filler,
  ScatterController,
  type ActiveElement,
} from 'chart.js'
import autocolors from 'chartjs-plugin-autocolors'

Chart.register(
  Title,
  Tooltip,
  Legend,
  BarElement,
  CategoryScale,
  LinearScale,
  LineElement,
  PointElement,
  ArcElement,
  RadialLinearScale,
  Filler,
  ScatterController,
  autocolors,
)

const props = defineProps({
  bar: { type: Boolean, default: false },
  line: { type: Boolean, default: false },
  pie: { type: Boolean, default: false },
  doughnut: { type: Boolean, default: false },
  radar: { type: Boolean, default: false },
  polarArea: { type: Boolean, default: false },
  scatter: { type: Boolean, default: false },
  clickable: { type: Boolean, default: false },
  chartData: {
    type: Object as PropType<
      ChartData<'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polarArea' | 'scatter'>
    >,
    required: true,
    validator: (
      value: ChartData<'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polarArea' | 'scatter'>,
    ) => Array.isArray(value.datasets),
  },
  chartOptions: { type: Object, default: () => ({}) },
  chartPlugins: [],
})

const defaultChartOptions = {
  onHover: (event: ChartEvent, chartElement: ActiveElement[], chart: Chart) => {
    if (props.clickable) {
      chart.canvas.style.cursor = chartElement.length > 0 ? 'pointer' : 'default'
    }
    if (event.native) {
      event.native.preventDefault()
    }
  },
}

function mergeOptions(defaults: object, options: object): object {
  return { ...defaults, ...options }
}

const chartOptions = computed(() => {
  return mergeOptions(defaultChartOptions, props.chartOptions)
})

const emit = defineEmits(['elementClick'])

const chartRef = ref<InstanceType<
  | typeof Bar
  | typeof Line
  | typeof Pie
  | typeof Doughnut
  | typeof Radar
  | typeof PolarArea
  | typeof Scatter
> | null>(null)

const chartDataBar = computed(() =>
  props.bar ? (props.chartData as ChartData<'bar'>) : { datasets: [] },
)
const chartDataLine = computed(() =>
  props.line ? (props.chartData as ChartData<'line'>) : { datasets: [] },
)
const chartDataPie = computed(() =>
  props.pie ? (props.chartData as ChartData<'pie'>) : { datasets: [] },
)
const chartDataDoughnut = computed(() =>
  props.doughnut ? (props.chartData as ChartData<'doughnut'>) : { datasets: [] },
)
const chartDataRadar = computed(() =>
  props.radar ? (props.chartData as ChartData<'radar'>) : { datasets: [] },
)
const chartDataPolarArea = computed(() =>
  props.polarArea ? (props.chartData as ChartData<'polarArea'>) : { datasets: [] },
)
const chartDataScatter = computed(() =>
  props.scatter ? (props.chartData as ChartData<'scatter'>) : { datasets: [] },
)

function onChartClick(event: MouseEvent) {
  if (!props.clickable) return

  const chartInstance = chartRef.value?.chart as unknown as Chart
  if (!chartInstance) return

  const elements = chartInstance.getElementsAtEventForMode(
    event,
    'nearest',
    { intersect: true },
    false,
  )

  if (elements.length) {
    const firstElement: ActiveElement = elements[0]
    const datasetIndex = firstElement.datasetIndex
    const dataIndex = firstElement.index

    const dataset = chartInstance.data.datasets[datasetIndex]
    const clickedData = dataset.data[dataIndex]

    const order = datasetIndex
    const index = dataIndex
    const value = clickedData

    emit('elementClick', order, index, value)
  }
}
</script>
  1. Create component test
@/components/__tests__/VBsbChart.test.ts
ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import ChartComponent from '@/components/VBsbChart.vue'
import { Bar, Line, Pie, Doughnut, Radar, PolarArea, Scatter } from 'vue-chartjs'
import type { ChartData, Chart, ChartEvent } from 'chart.js'

describe('ChartComponent', () => {
  const chartData: ChartData<'bar'> = {
    labels: ['January', 'February', 'March'],
    datasets: [
      {
        label: 'Dataset 1',
        data: [10, 20, 30],
      },
    ],
  }

  const chartOptions = {
    responsive: true,
    plugins: {
      legend: {
        display: true,
      },
    },
  }

  it('renders Bar chart when bar prop is true', () => {
    const wrapper = mount(ChartComponent, {
      props: {
        bar: true,
        chartData,
        chartOptions,
      },
    })
    expect(wrapper.findComponent(Bar).exists()).toBe(true)
  })

  it('renders Line chart when line prop is true', () => {
    const wrapper = mount(ChartComponent, {
      props: {
        line: true,
        chartData,
        chartOptions,
      },
    })
    expect(wrapper.findComponent(Line).exists()).toBe(true)
  })

  it('renders Pie chart when pie prop is true', () => {
    const wrapper = mount(ChartComponent, {
      props: {
        pie: true,
        chartData,
        chartOptions,
      },
    })
    expect(wrapper.findComponent(Pie).exists()).toBe(true)
  })

  it('renders Doughnut chart when doughnut prop is true', () => {
    const wrapper = mount(ChartComponent, {
      props: {
        doughnut: true,
        chartData,
        chartOptions,
      },
    })
    expect(wrapper.findComponent(Doughnut).exists()).toBe(true)
  })

  it('renders Radar chart when radar prop is true', () => {
    const wrapper = mount(ChartComponent, {
      props: {
        radar: true,
        chartData,
        chartOptions,
      },
    })
    expect(wrapper.findComponent(Radar).exists()).toBe(true)
  })

  it('renders PolarArea chart when polarArea prop is true', () => {
    const wrapper = mount(ChartComponent, {
      props: {
        polarArea: true,
        chartData,
        chartOptions,
      },
    })
    expect(wrapper.findComponent(PolarArea).exists()).toBe(true)
  })

  it('renders Scatter chart when scatter prop is true', () => {
    const wrapper = mount(ChartComponent, {
      props: {
        scatter: true,
        chartData,
        chartOptions,
      },
    })
    expect(wrapper.findComponent(Scatter).exists()).toBe(true)
  })

  it('emits elementClick event when chart element is clicked', async () => {
    const wrapper = mount(ChartComponent, {
      props: {
        bar: true,
        clickable: true,
        chartData,
        chartOptions,
      },
    })

    const mockChart = vi.fn() as unknown as Chart
    mockChart.getElementsAtEventForMode = vi.fn().mockReturnValue([{ datasetIndex: 0, index: 1 }])
    mockChart.data = {
      datasets: [{ data: [10, 20, 30] }],
    } as ChartData<'bar'>

    // Access chart ref after component is mounted
    const chartRef = wrapper.vm.$refs.chartRef as unknown
    if (chartRef && typeof chartRef === 'object' && 'chart' in chartRef) {
      ;(chartRef as { chart: Chart }).chart = mockChart
    }

    const mockEvent = {
      native: new MouseEvent('click'),
      type: 'click',
      x: 100,
      y: 100,
    } as unknown as ChartEvent

    // Cast to unknown first then to the expected type
    const component = wrapper.vm as unknown as { onChartClick: (event: ChartEvent) => void }
    component.onChartClick(mockEvent as ChartEvent)

    expect(wrapper.emitted().elementClick).toBeTruthy()
    expect(wrapper.emitted().elementClick[0]).toEqual([0, 1, 20])
  })
})

Usage

Some examples:

vue
<template>
  <v-card>
    <v-card-title>Chart</v-card-title>
    <v-bsb-chart
      bar
      clickable
      :chart-data="chartData1"
      :chart-options="chartOptions1"
      @elementClick="chartClick"
    />
  </v-card>
</template>

<script setup lang="ts">
const chartData1 = ref({
  datasets: [
    {
      label: 'Bar Dataset -1',
      data: [10, 20, 30, 40],
    },
    {
      label: 'Bar Dataset -2',
      data: [10, 15, 30, 35],
    },
    {
      label: 'Line Dataset',
      data: [10, 30, 10, 20],
      type: 'line',
    },
  ],
  labels: ['January', 'February', 'March', 'April'],
})
const chartOptions1 = ref({
  scales: {
    x: {
      stacked: true,
    },
    y: {
      stacked: true,
    },
  },
})

function chartClick(order: number, index: number, value: number) {
  console.log('Clicked data: ', order, index, value)
  // Do something with the clicked data
}
</script>
vue
<template>
  <v-card>
    <v-card-title>Chart</v-card-title>
    <v-bsb-chart pie :chart-data="chartData2" :chart-options="chartOptions2" />
  </v-card>
</template>

<script setup lang="ts">
const chartData2 = ref({
  labels: ['A', 'B', 'C', 'D'],
  datasets: [
    {
      label: 'Pie data',
      type: 'doughnut',
      data: [10, 20, 30, 40],
      backgroundColor: ['#0123456', '#234567', '#345678', '#456789'],
    },
  ],
})

const chartOptions2 = ref({})
</script>

API

PropertyTypeDescriptionDefault
barBooleanChart type Barfalse
lineBooleanChart type Linefalse
pieBooleanChart type Piefalse
doughnutBooleanChart type Doughnutfalse
radarBooleanChart type Radarfalse
polarAreaBooleanChart type PolarAreafalse
bubbleBooleanChart type Bubblefalse
scatterBooleanChart type Scatterfalse
clickableBooleanChanges cursor to pointer and allows emit elementClickfalse
EmitsDescription
elementClickReturns dataset index, data index and data value on mouse click

Check all other configuration options of ChartJS