From f3ecfb04927d39b54d26d2ac729147141702b4a9 Mon Sep 17 00:00:00 2001 From: Dan Onoshko Date: Sat, 3 Dec 2022 14:18:50 +0400 Subject: [PATCH] feat: base Chart component (#958) it can be used for multitype charts and as a base for custom charts BREAKING CHANGE: chart events were removed --- .size-limit.json | 8 +- rollup.config.mjs | 10 +- sandboxes/reactive/src/App.vue | 2 +- sandboxes/reactive/src/chartConfig.ts | 4 +- src/BaseCharts.ts | 285 -------------------------- src/chart.ts | 116 +++++++++++ src/index.ts | 38 +++- src/props.ts | 29 +++ src/typedCharts.ts | 75 +++++++ src/types.ts | 89 +++----- src/utils.ts | 157 ++++---------- stories/reactive.stories.ts | 2 +- 12 files changed, 340 insertions(+), 475 deletions(-) delete mode 100644 src/BaseCharts.ts create mode 100644 src/chart.ts create mode 100644 src/props.ts create mode 100644 src/typedCharts.ts diff --git a/.size-limit.json b/.size-limit.json index e8548450..dd0eb0b3 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1,24 +1,24 @@ [ { "path": "dist/index.cjs", - "limit": "2.3 KB", + "limit": "1.75 KB", "webpack": false, "running": false }, { "path": "dist/index.cjs", - "limit": "60 KB", + "limit": "1 KB", "import": "{ Bar }" }, { "path": "dist/index.js", - "limit": "2.25 KB", + "limit": "1.7 KB", "webpack": false, "running": false }, { "path": "dist/index.js", - "limit": "8.5 KB", + "limit": "1 KB", "import": "{ Bar }" } ] diff --git a/rollup.config.mjs b/rollup.config.mjs index 8e93049a..0b5b60ff 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,12 +1,10 @@ -import vue from '@vitejs/plugin-vue' import { swc } from 'rollup-plugin-swc3' import { nodeResolve } from '@rollup/plugin-node-resolve' import pkg from './package.json' assert { type: 'json' } const extensions = ['.js', '.ts'] const external = _ => /node_modules/.test(_) && !/@swc\/helpers/.test(_) -const plugins = (targets, vueOptions = {}) => [ - vue(vueOptions), +const plugins = (targets) => [ nodeResolve({ extensions }), @@ -25,11 +23,7 @@ const plugins = (targets, vueOptions = {}) => [ export default [ { input: pkg.main, - plugins: plugins('defaults, not ie 11, not ie_mob 11', { - template: { - optimizeSSR: true - } - }), + plugins: plugins('defaults, not ie 11, not ie_mob 11'), external, output: { format: 'cjs', diff --git a/sandboxes/reactive/src/App.vue b/sandboxes/reactive/src/App.vue index 4dd249e8..2120f8d1 100644 --- a/sandboxes/reactive/src/App.vue +++ b/sandboxes/reactive/src/App.vue @@ -26,7 +26,7 @@ const data = ref>({ onMounted(() => { setInterval(() => { - data.value = { ...chartConfig.data } + data.value = chartConfig.randomData() }, 3000) }) diff --git a/sandboxes/reactive/src/chartConfig.ts b/sandboxes/reactive/src/chartConfig.ts index 4ef68e71..1eb2f6c6 100644 --- a/sandboxes/reactive/src/chartConfig.ts +++ b/sandboxes/reactive/src/chartConfig.ts @@ -2,7 +2,7 @@ function getRandomInt() { return Math.floor(Math.random() * (50 - 5 + 1)) + 5 } -export const data = { +export const randomData = () => ({ labels: [ 'January' + getRandomInt(), 'February', @@ -37,7 +37,7 @@ export const data = { ] } ] -} +}) export const options = { responsive: true, diff --git a/src/BaseCharts.ts b/src/BaseCharts.ts deleted file mode 100644 index 42e82b9e..00000000 --- a/src/BaseCharts.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { - Chart as ChartJS, - BarController, - BubbleController, - DoughnutController, - LineController, - PieController, - PolarAreaController, - RadarController, - ScatterController -} from 'chart.js' - -import type { - ChartType, - ChartComponentLike, - DefaultDataPoint, - ChartOptions, - Plugin -} from 'chart.js' - -import { - defineComponent, - ref, - shallowRef, - h, - onMounted, - onBeforeUnmount, - watch, - isProxy, - toRaw, - PropType -} from 'vue' - -import { - chartCreate, - chartDestroy, - chartUpdate, - getChartData, - setChartLabels, - setChartDatasets, - compareData, - templateError, - chartUpdateError, - setChartOptions -} from './utils' - -import type { - TChartData, - TChartOptions, - TypedChartJS, - TypedChartComponent -} from './types' - -export function createTypedChart< - TType extends ChartType = ChartType, - TData = DefaultDataPoint, - TLabel = unknown ->( - type: TType, - registerables: ChartComponentLike -): TypedChartComponent { - ChartJS.register(registerables) - - return defineComponent({ - props: { - data: { - type: Object as PropType>, - required: true - }, - options: { - type: Object as PropType>, - default: () => {} - }, - datasetIdKey: { - type: String, - default: 'label' - }, - plugins: { - type: Array as PropType[]>, - default: () => [] - } - }, - setup(props, context) { - const _chart = shallowRef | null>(null) - const canvasEl = ref(null) - - function renderChart( - data: TChartData, - options: TChartOptions - ): void { - if (_chart.value !== null) { - chartDestroy(toRaw(_chart.value), context) - } - - if (canvasEl.value === null) { - throw new Error(templateError) - } else { - const chartData = getChartData( - data, - props.datasetIdKey - ) - const canvasEl2DContext = canvasEl.value.getContext('2d') - - if (canvasEl2DContext !== null) { - _chart.value = new ChartJS( - canvasEl2DContext, - { - type, - data: isProxy(data) ? new Proxy(chartData, {}) : chartData, - options, - plugins: props.plugins - } - ) - } - } - } - - function chartDataHandler( - newValue: TChartData, - oldValue: TChartData - ): void { - const newData = isProxy(newValue) ? toRaw(newValue) : { ...newValue } - const oldData = isProxy(oldValue) ? toRaw(oldValue) : { ...oldValue } - - if (Object.keys(oldData).length > 0) { - const chart = toRaw(_chart.value) - - const isEqualLabelsAndDatasetsLength = compareData< - TType, - TData, - TLabel - >(newData, oldData) - - if (isEqualLabelsAndDatasetsLength && chart !== null) { - setChartDatasets( - chart?.data, - newData, - props.datasetIdKey - ) - - if (newData.labels !== undefined) { - setChartLabels( - chart, - newData.labels, - context - ) - } - - updateChart() - } else { - if (chart !== null) { - chartDestroy(chart, context) - } - - chartCreate( - renderChart, - props.data, - props.options as ChartOptions, - context - ) - } - } else { - if (_chart.value !== null) { - chartDestroy(toRaw(_chart.value), context) - } - - chartCreate( - renderChart, - props.data, - props.options as ChartOptions, - context - ) - } - } - - function chartOptionsHandler(options: TChartOptions): void { - const chart = toRaw(_chart.value) - - if (chart !== null) { - setChartOptions(chart, options) - updateChart() - } else { - chartCreate( - renderChart, - props.data, - props.options as ChartOptions, - context - ) - } - } - - function updateChart(): void { - const chart = toRaw(_chart.value) - - if (chart !== null) { - chartUpdate(chart, context) - } else { - console.error(chartUpdateError) - } - } - - watch( - () => props.data, - ( - newValue: TChartData, - oldValue: TChartData - ) => chartDataHandler(newValue, oldValue), - { deep: true } - ) - - watch( - () => props.options, - newValue => chartOptionsHandler(newValue as ChartOptions), - { deep: true } - ) - - onMounted(() => { - if ('datasets' in props.data && props.data.datasets.length > 0) { - chartCreate( - renderChart, - props.data, - props.options as ChartOptions, - context - ) - } - }) - - onBeforeUnmount(() => { - if (_chart.value !== null) { - chartDestroy(toRaw(_chart.value), context) - } - }) - - context.expose({ - chart: _chart, - updateChart - }) - - return () => { - return h('canvas', { - ref: canvasEl - }) - } - } - }) as any -} - -export const Bar = /* #__PURE__ */ createTypedChart('bar', BarController) - -export const Doughnut = /* #__PURE__ */ createTypedChart( - 'doughnut', - DoughnutController -) - -export const Line = /* #__PURE__ */ createTypedChart('line', LineController) - -export const Pie = /* #__PURE__ */ createTypedChart('pie', PieController) - -export const PolarArea = /* #__PURE__ */ createTypedChart( - 'polarArea', - PolarAreaController -) - -export const Radar = /* #__PURE__ */ createTypedChart('radar', RadarController) - -export const Bubble = /* #__PURE__ */ createTypedChart( - 'bubble', - BubbleController -) - -export const Scatter = /* #__PURE__ */ createTypedChart( - 'scatter', - ScatterController -) - -export default { - Bar, - Doughnut, - Line, - Pie, - PolarArea, - Radar, - Bubble, - Scatter -} diff --git a/src/chart.ts b/src/chart.ts new file mode 100644 index 00000000..463ee905 --- /dev/null +++ b/src/chart.ts @@ -0,0 +1,116 @@ +import { + defineComponent, + ref, + shallowRef, + h, + onMounted, + onBeforeUnmount, + watch, + toRaw +} from 'vue' +import { Chart as ChartJS } from 'chart.js' +import { Props } from './props' +import { + cloneData, + setLabels, + setDatasets, + setOptions, + toRawIfProxy, + cloneProxy +} from './utils' + +export const Chart = defineComponent({ + props: Props, + setup(props, { expose }) { + const canvasRef = ref(null) + const chartRef = shallowRef(null) + + expose({ chart: chartRef }) + + const renderChart = () => { + if (!canvasRef.value) return + + const { type, data, options, plugins, datasetIdKey } = props + const clonedData = cloneData(data, datasetIdKey) + const proxiedData = cloneProxy(clonedData, data) + + chartRef.value = new ChartJS(canvasRef.value, { + type, + data: proxiedData, + options: { ...options }, + plugins + }) + } + + const destroyChart = () => { + const chart = toRaw(chartRef.value) + + if (chart) { + chart.destroy() + chartRef.value = null + } + } + + const update = (chart: ChartJS) => { + chart.update() + } + + onMounted(renderChart) + + onBeforeUnmount(destroyChart) + + watch( + [() => props.options, () => props.data], + ( + [nextOptionsProxy, nextDataProxy], + [prevOptionsProxy, prevDataProxy] + ) => { + const chart = toRaw(chartRef.value) + + if (!chart) { + return + } + + let shouldUpdate = false + + if (nextOptionsProxy) { + const nextOptions = toRawIfProxy(nextOptionsProxy) + const prevOptions = toRawIfProxy(prevOptionsProxy) + + if (nextOptions && nextOptions !== prevOptions) { + setOptions(chart, nextOptions) + shouldUpdate = true + } + } + + if (nextDataProxy) { + const nextLabels = toRawIfProxy(nextDataProxy.labels) + const prevLabels = toRawIfProxy(prevDataProxy.labels) + const nextDatasets = toRawIfProxy(nextDataProxy.datasets) + const prevDatasets = toRawIfProxy(prevDataProxy.datasets) + + if (nextLabels !== prevLabels) { + setLabels(chart.config.data, nextLabels) + shouldUpdate = true + } + + if (nextDatasets && nextDatasets !== prevDatasets) { + setDatasets(chart.config.data, nextDatasets, props.datasetIdKey) + shouldUpdate = true + } + } + + if (shouldUpdate) { + update(chart) + } + }, + { deep: true } + ) + + return () => { + return h('canvas', { + ref: canvasRef + }) + } + } +}) diff --git a/src/index.ts b/src/index.ts index 9a492f62..53ece2a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,37 @@ -export * from './BaseCharts' +import { Chart } from './chart' +import { + createTypedChart, + Bar, + Doughnut, + Line, + Pie, + PolarArea, + Radar, + Bubble, + Scatter +} from './typedCharts' + +export type { ChartProps, ChartComponentRef } from './types' +export { + Chart, + createTypedChart, + Bar, + Doughnut, + Line, + Pie, + PolarArea, + Radar, + Bubble, + Scatter +} + +export default { + Bar, + Doughnut, + Line, + Pie, + PolarArea, + Radar, + Bubble, + Scatter +} diff --git a/src/props.ts b/src/props.ts new file mode 100644 index 00000000..05dbd384 --- /dev/null +++ b/src/props.ts @@ -0,0 +1,29 @@ +import { PropType } from 'vue' +import type { ChartType, ChartData, ChartOptions, Plugin } from 'chart.js' + +export const CommonProps = { + data: { + type: Object as PropType, + required: true + }, + options: { + type: Object as PropType, + default: () => ({}) + }, + plugins: { + type: Array as PropType, + default: () => [] + }, + datasetIdKey: { + type: String, + default: 'label' + } +} as const + +export const Props = { + type: { + type: String as PropType, + required: true + }, + ...CommonProps +} as const diff --git a/src/typedCharts.ts b/src/typedCharts.ts new file mode 100644 index 00000000..1da7cfb1 --- /dev/null +++ b/src/typedCharts.ts @@ -0,0 +1,75 @@ +import { defineComponent, shallowRef, h } from 'vue' +import type { ChartType, ChartComponentLike, DefaultDataPoint } from 'chart.js' +import { + Chart as ChartJS, + BarController, + BubbleController, + DoughnutController, + LineController, + PieController, + PolarAreaController, + RadarController, + ScatterController +} from 'chart.js' +import type { TypedChartComponent, ChartComponentRef } from './types' +import { CommonProps } from './props' +import { Chart } from './chart' + +export function createTypedChart< + TType extends ChartType = ChartType, + TData = DefaultDataPoint, + TLabel = unknown +>( + type: TType, + registerables: ChartComponentLike +): TypedChartComponent { + ChartJS.register(registerables) + + return defineComponent({ + props: CommonProps, + setup(props, { expose }) { + const ref = shallowRef(null) + const reforwardRef = (chartRef: ChartComponentRef) => { + ref.value = chartRef?.chart + } + + expose({ chart: ref }) + + return () => { + return h(Chart, { + ref: reforwardRef as any, + type, + ...props + }) + } + } + }) as any +} + +export const Bar = /* #__PURE__ */ createTypedChart('bar', BarController) + +export const Doughnut = /* #__PURE__ */ createTypedChart( + 'doughnut', + DoughnutController +) + +export const Line = /* #__PURE__ */ createTypedChart('line', LineController) + +export const Pie = /* #__PURE__ */ createTypedChart('pie', PieController) + +export const PolarArea = /* #__PURE__ */ createTypedChart( + 'polarArea', + PolarAreaController +) + +export const Radar = /* #__PURE__ */ createTypedChart('radar', RadarController) + +export const Bubble = /* #__PURE__ */ createTypedChart( + 'bubble', + BubbleController +) + +export const Scatter = /* #__PURE__ */ createTypedChart( + 'scatter', + ScatterController +) diff --git a/src/types.ts b/src/types.ts index 452357d5..f9b5e0eb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ -import { Chart as ChartJS } from 'chart.js' +import type { DefineComponent } from 'vue' import type { + Chart as ChartJS, ChartType, ChartData, ChartOptions, @@ -7,77 +8,49 @@ import type { Plugin } from 'chart.js' -import { - ComponentOptionsMixin, - ComputedOptions, - DefineComponent, - MethodOptions, - Ref, - ShallowRef -} from 'vue' - -import { ChartEmits } from './utils' - -export type TChartData< - TType extends ChartType = ChartType, - TData = DefaultDataPoint, - TLabel = unknown -> = ChartData - -export type TChartOptions = ChartOptions - -export type TypedChartJS< +export interface ChartProps< TType extends ChartType = ChartType, TData = DefaultDataPoint, TLabel = unknown -> = ChartJS - -export interface IChartProps< - TType extends ChartType, - TData = DefaultDataPoint, - TLabel = unknown > { - data: TChartData - options?: TChartOptions + /** + * Chart.js chart type + */ + type: TType + /** + * The data object that is passed into the Chart.js chart + * @see https://www.chartjs.org/docs/latest/getting-started/ + */ + data: ChartData + /** + * The options object that is passed into the Chart.js chart + * @see https://www.chartjs.org/docs/latest/general/options.html + * @default {} + */ + options?: ChartOptions + /** + * The plugins array that is passed into the Chart.js chart + * @see https://www.chartjs.org/docs/latest/developers/plugins.html + * @default [] + */ plugins?: Plugin[] + /** + * Key name to identificate dataset + * @default 'label' + */ datasetIdKey?: string } -export interface IChartComponentData< - TType extends ChartType, +export interface ChartComponentRef< + TType extends ChartType = ChartType, TData = DefaultDataPoint, TLabel = unknown > { - _chart: ShallowRef | null> - canvasEl: Ref - renderChart: ( - data: TChartData, - options: TChartOptions - ) => void - chartDataHandler: ( - newValue: TChartData, - oldValue: TChartData - ) => void -} - -export type TypedChartEmits = { - [ChartEmits.ChartRendered]: () => true - [ChartEmits.ChartUpdated]: () => true - [ChartEmits.ChartDestroyed]: () => true - [ChartEmits.LabelsUpdated]: () => true + chart: ChartJS | null } export type TypedChartComponent< TType extends ChartType, TData = DefaultDataPoint, TLabel = unknown -> = DefineComponent< - IChartProps, - IChartComponentData, - unknown, - ComputedOptions, - MethodOptions, - ComponentOptionsMixin, - ComponentOptionsMixin, - TypedChartEmits -> +> = DefineComponent, 'type'>> diff --git a/src/utils.ts b/src/utils.ts index 432c7eff..49f1c7fb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,92 +1,59 @@ -import type { ChartType, ChartDataset, DefaultDataPoint } from 'chart.js' - -import type { TChartData, TChartOptions, TypedChartJS } from './types' - -import { SetupContext } from 'vue' - -export enum ChartEmits { - ChartRendered = 'chart:rendered', - ChartUpdated = 'chart:updated', - ChartDestroyed = 'chart:destroyed', - LabelsUpdated = 'labels:updated' +import { isProxy, toRaw } from 'vue' +import type { + Chart, + ChartType, + ChartData, + ChartDataset, + ChartOptions, + DefaultDataPoint +} from 'chart.js' + +export function toRawIfProxy(obj: T) { + return isProxy(obj) ? toRaw(obj) : obj } -export function chartCreate< - TType extends ChartType = ChartType, - TData = DefaultDataPoint, - TLabel = unknown ->( - createChartFunction: ( - data: TChartData, - options: TChartOptions - ) => void, - data: TChartData, - options: TChartOptions, - context?: SetupContext -): void { - createChartFunction(data, options) - - if (context !== undefined) { - context.emit(ChartEmits.ChartRendered) - } +export function cloneProxy(obj: T, src = obj) { + return isProxy(src) ? new Proxy(obj, {}) : obj } -export function chartUpdate< - TType extends ChartType, - TData = DefaultDataPoint, - TLabel = unknown ->(chart: TypedChartJS, context?: SetupContext): void { - chart.update() - - if (context !== undefined) { - context.emit(ChartEmits.ChartUpdated) - } -} - -export function chartDestroy< - TType extends ChartType, +export function setOptions< + TType extends ChartType = ChartType, TData = DefaultDataPoint, TLabel = unknown ->(chart: TypedChartJS, context?: SetupContext): void { - chart.destroy() +>(chart: Chart, nextOptions: ChartOptions) { + const options = chart.options - if (context !== undefined) { - context.emit(ChartEmits.ChartDestroyed) + if (options && nextOptions) { + Object.assign(options, nextOptions) } } -export function getChartData< +export function setLabels< TType extends ChartType = ChartType, TData = DefaultDataPoint, TLabel = unknown >( - data: TChartData, - datasetIdKey: string -): TChartData { - const nextData = { - labels: typeof data.labels === 'undefined' ? [] : [...data.labels], - datasets: [] - } - - setChartDatasets(nextData, { ...data }, datasetIdKey) - return nextData + currentData: ChartData, + nextLabels: TLabel[] | undefined +) { + currentData.labels = nextLabels } -export function setChartDatasets< +export function setDatasets< TType extends ChartType = ChartType, TData = DefaultDataPoint, TLabel = unknown >( - oldData: TChartData, - newData: TChartData, + currentData: ChartData, + nextDatasets: ChartDataset[], datasetIdKey: string -): void { +) { const addedDatasets: ChartDataset[] = [] - oldData.datasets = newData.datasets.map( + currentData.datasets = nextDatasets.map( (nextDataset: Record) => { // given the new set, find it's current match - const currentDataset = oldData.datasets.find( + const currentDataset = currentData.datasets.find( (dataset: Record) => dataset[datasetIdKey] === nextDataset[datasetIdKey] ) @@ -97,7 +64,7 @@ export function setChartDatasets< !nextDataset.data || addedDatasets.includes(currentDataset) ) { - return { ...nextDataset } + return { ...nextDataset } as ChartDataset } addedDatasets.push(currentDataset) @@ -106,61 +73,21 @@ export function setChartDatasets< return currentDataset } - ) as ChartDataset[] -} - -export function setChartLabels< - TType extends ChartType, - TData = DefaultDataPoint, - TLabel = unknown ->( - chart: TypedChartJS, - labels: TLabel[] | undefined, - context?: SetupContext -): void { - chart.data.labels = labels - - if (context !== undefined) { - context.emit(ChartEmits.LabelsUpdated) - } -} - -export function setChartOptions< - TType extends ChartType, - TData = DefaultDataPoint, - TLabel = unknown ->( - chart: TypedChartJS, - options: TChartOptions -): void { - chart.options = { ...options } + ) } -export function compareData< +export function cloneData< TType extends ChartType = ChartType, TData = DefaultDataPoint, TLabel = unknown ->( - newData: TChartData, - oldData: TChartData -): boolean { - // Get new and old DataSet Labels - const newDatasetLabels = newData.datasets.map(dataset => { - return dataset.label - }) +>(data: ChartData, datasetIdKey: string) { + const nextData: ChartData = { + labels: [], + datasets: [] + } - const oldDatasetLabels = oldData.datasets.map(dataset => { - return dataset.label - }) + setLabels(nextData, data.labels) + setDatasets(nextData, data.datasets, datasetIdKey) - // Check if Labels are equal and if dataset length is equal - return ( - oldData.datasets.length === newData.datasets.length && - newDatasetLabels.every((value, index) => value === oldDatasetLabels[index]) - ) + return nextData } - -export const templateError = - 'Please remove the tags from your chart component. See https://vue-chartjs.org/guide/#vue-single-file-components' - -export const chartUpdateError = 'Update ERROR: chart instance not found' diff --git a/stories/reactive.stories.ts b/stories/reactive.stories.ts index d48b9f4e..2626c9f6 100644 --- a/stories/reactive.stories.ts +++ b/stories/reactive.stories.ts @@ -23,7 +23,7 @@ export function Default(args) { onMounted(() => { setInterval(() => { - data.value = { ...reactiveChartConfig.data } + data.value = reactiveChartConfig.randomData() }, 3000) })