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
- Install
ps
npm i vue-chartjs chart.js
npm i --save chartjs-plugin-autocolors
- 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>
- 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
Property | Type | Description | Default |
---|---|---|---|
bar | Boolean | Chart type Bar | false |
line | Boolean | Chart type Line | false |
pie | Boolean | Chart type Pie | false |
doughnut | Boolean | Chart type Doughnut | false |
radar | Boolean | Chart type Radar | false |
polarArea | Boolean | Chart type PolarArea | false |
bubble | Boolean | Chart type Bubble | false |
scatter | Boolean | Chart type Scatter | false |
clickable | Boolean | Changes cursor to pointer and allows emit elementClick | false |
Emits | Description |
---|---|
elementClick | Returns dataset index, data index and data value on mouse click |
Check all other configuration options of ChartJS