diff --git a/src/core/series/plugin.ts b/src/core/series/plugin.ts index 0b77ba697..790d9485b 100644 --- a/src/core/series/plugin.ts +++ b/src/core/series/plugin.ts @@ -57,6 +57,15 @@ export interface RenderShapesArgs { dispatcher?: Dispatch; } +export interface ValidateSeriesArgs { + /** The series being validated. */ + series: T; + /** All series in the chart. Needed only by collection-level checks (e.g. treemap uniqueness); other types ignore it. */ + allSeries: ChartSeries[]; + xAxis?: ChartXAxis; + yAxis?: ChartYAxis[]; +} + export interface SeriesPlugin { /** Unique series type identifier (e.g. `'line'`, `'bar-x'`). Used for plugin lookup and CSS class generation. */ type: T['type']; @@ -67,6 +76,11 @@ export interface SeriesPlugin { useClipPath?: boolean; /** Transforms raw chart series config into prepared series objects used throughout the render pipeline. */ prepareSeries(args: PrepareSeriesArgs): PreparedSeries[] | Promise; + /** + * Validates type-specific series config. Called once per series by `validateData`. + * Should throw a `ChartError` on invalid input. Omit for types that need no validation. + */ + validate?(args: ValidateSeriesArgs): void; /** Computes shape data (geometry, labels, markers) from prepared series. Called once per render cycle. */ prepareShapeData( args: PrepareShapeDataArgs, diff --git a/src/core/series/seriesRegistry.ts b/src/core/series/seriesRegistry.ts index 3fed86c7d..280155310 100644 --- a/src/core/series/seriesRegistry.ts +++ b/src/core/series/seriesRegistry.ts @@ -15,3 +15,11 @@ export function getSeriesPlugin(type: string): SeriesPlugin { } return plugin; } + +export function hasSeriesPlugin(type: string): boolean { + return registry.has(type); +} + +export function getRegisteredSeriesTypes(): string[] { + return [...registry.keys()]; +} diff --git a/src/core/validation/__tests__/validation.test.ts b/src/core/validation/__tests__/validation.test.ts index 923933b81..7dfd92846 100644 --- a/src/core/validation/__tests__/validation.test.ts +++ b/src/core/validation/__tests__/validation.test.ts @@ -1,6 +1,7 @@ import {validateData} from '../'; import type {ChartError} from '../../../libs'; import {CHART_ERROR_CODE} from '../../../libs'; +import '../../../plugins'; import type {ChartData} from '../../types'; import {PIE_SERIES, XY_SERIES} from '../__mocks__'; diff --git a/src/core/validation/helpers.ts b/src/core/validation/helpers.ts new file mode 100644 index 000000000..b3103e45b --- /dev/null +++ b/src/core/validation/helpers.ts @@ -0,0 +1,296 @@ +import get from 'lodash/get'; + +import {CHART_ERROR_CODE, ChartError} from '../../libs'; +import {DEFAULT_AXIS_TYPE} from '../constants'; +import {i18n} from '../i18n'; +import type {ChartXAxis, ChartYAxis} from '../types'; + +type XYDataPoint = {x?: unknown; y?: unknown}; + +type XYValidationSeries = { + name: string; + yAxis?: number; + data: XYDataPoint[]; +}; + +export function validateXYSeries(args: { + series: XYValidationSeries; + xAxis?: ChartXAxis; + yAxis?: ChartYAxis[]; +}) { + const {series, xAxis, yAxis = []} = args; + + const yAxisIndex = get(series, 'yAxis', 0); + const seriesYAxis = yAxis[yAxisIndex]; + if (yAxisIndex !== 0 && typeof seriesYAxis === 'undefined') { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-y-axis-index', { + index: yAxisIndex, + }), + }); + } + + const xType = get(xAxis, 'type', DEFAULT_AXIS_TYPE); + const yType = get(seriesYAxis, 'type', DEFAULT_AXIS_TYPE); + + series.data.forEach(({x, y}) => { + switch (xType) { + case 'category': { + if (typeof x !== 'string' && typeof x !== 'number' && x !== null) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-axis-category-data-point', { + key: 'x', + seriesName: series.name, + }), + }); + } + + break; + } + case 'datetime': { + if (typeof x !== 'number') { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-axis-datetime-data-point', { + key: 'x', + seriesName: series.name, + }), + }); + } + + break; + } + case 'linear': { + if (typeof x !== 'number' && x !== null) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-axis-linear-data-point', { + key: 'x', + seriesName: series.name, + }), + }); + } + } + } + switch (yType) { + case 'category': { + if (typeof y !== 'string' && typeof y !== 'number' && y !== null) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-axis-category-data-point', { + key: 'y', + seriesName: series.name, + }), + }); + } + + break; + } + case 'datetime': { + if (typeof y !== 'number') { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-axis-datetime-data-point', { + key: 'y', + seriesName: series.name, + }), + }); + } + + break; + } + case 'linear': { + if (typeof y !== 'number' && y !== null) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-axis-linear-data-point', { + key: 'y', + seriesName: series.name, + }), + }); + } + } + } + }); +} + +export function validateAxisPlotValues(args: { + series: XYValidationSeries; + xAxis?: ChartXAxis; + yAxis?: ChartYAxis[]; +}) { + const {series, xAxis, yAxis = []} = args; + + const yAxisIndex = get(series, 'yAxis', 0); + const seriesYAxis = yAxis[yAxisIndex]; + if (yAxisIndex !== 0 && typeof seriesYAxis === 'undefined') { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-y-axis-index', { + index: yAxisIndex, + }), + }); + } + + const xPlotBands = get(xAxis, 'plotBands', []); + const yPlotBands = get(yAxis, 'plotBands', []); + + if (!xPlotBands.length && !yPlotBands.length) { + return; + } + + const xType = get(xAxis, 'type', DEFAULT_AXIS_TYPE); + const yType = get(seriesYAxis, 'type', DEFAULT_AXIS_TYPE); + + xPlotBands.forEach(({from = 0, to = 0}) => { + const fromNotEqualTo = typeof to !== typeof from; + + if (fromNotEqualTo) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_axis-plot-band-options-not-equal', { + axis: 'x', + option: 'from', + }), + }); + } + + switch (xType) { + case 'category': { + const invalidFrom = typeof from !== 'string' && typeof from !== 'number'; + const invalidTo = typeof to !== 'string' && typeof to !== 'number'; + if (invalidFrom) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_invalid-axis-plot-band-option', { + axis: 'x', + option: 'from', + }), + }); + } + + if (invalidTo) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_invalid-axis-plot-band-option', { + axis: 'x', + option: 'to', + }), + }); + } + + break; + } + case 'linear': + case 'datetime': { + const invalidFrom = typeof from !== 'number'; + const invalidTo = typeof to !== 'number'; + if (invalidFrom) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_invalid-axis-plot-band-option', { + axis: 'x', + option: 'from', + }), + }); + } + + if (invalidTo) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_invalid-axis-plot-band-option', { + axis: 'x', + option: 'to', + }), + }); + } + + break; + } + } + }); + + yPlotBands.forEach(({from = 0, to = 0}) => { + const fromNotEqualTo = typeof to !== typeof from; + + if (fromNotEqualTo) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_axis-plot-band-options-not-equal', { + axis: 'x', + option: 'from', + }), + }); + } + + switch (yType) { + case 'category': { + const invalidFrom = typeof from !== 'string' && typeof from !== 'number'; + const invalidTo = typeof to !== 'string' && typeof to !== 'number'; + if (invalidFrom) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_invalid-axis-plot-band-option', { + axis: 'y', + option: 'from', + }), + }); + } + + if (invalidTo) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_invalid-axis-plot-band-option', { + axis: 'y', + option: 'to', + }), + }); + } + + break; + } + case 'linear': + case 'datetime': { + const invalidFrom = typeof from !== 'number'; + const invalidTo = typeof to !== 'number'; + if (invalidFrom) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_invalid-axis-plot-band-option', { + axis: 'y', + option: 'from', + }), + }); + } + + if (invalidTo) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, + message: i18n('error', 'label_invalid-axis-plot-band-option', { + axis: 'y', + option: 'to', + }), + }); + } + + break; + } + } + }); +} + +export function validateStacking({series}: {series: {stacking?: string}}) { + const availableStackingValues = ['normal', 'percent']; + + if (series.stacking && !availableStackingValues.includes(series.stacking)) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-series-property', { + key: 'stacking', + values: availableStackingValues, + }), + }); + } +} diff --git a/src/core/validation/index.ts b/src/core/validation/index.ts index ea3c67f5f..641278d0e 100644 --- a/src/core/validation/index.ts +++ b/src/core/validation/index.ts @@ -1,430 +1,24 @@ -import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; import {CHART_ERROR_CODE, ChartError} from '../../libs'; -import {DEFAULT_AXIS_TYPE, SERIES_TYPE, TOOLTIP_TOTALS_BUILT_IN_AGGREGATION} from '../constants'; +import {TOOLTIP_TOTALS_BUILT_IN_AGGREGATION} from '../constants'; import {i18n} from '../i18n'; -import type { - AreaSeries, - BarXSeries, - BarYSeries, - ChartData, - ChartSeries, - ChartTooltip, - ChartXAxis, - ChartYAxis, - LineSeries, - PieSeries, - ScatterSeries, - TreemapSeries, -} from '../types'; +import {getRegisteredSeriesTypes, getSeriesPlugin, hasSeriesPlugin} from '../series/seriesRegistry'; +import type {ChartData, ChartTooltip} from '../types'; import {validateAxes} from './validate-axes'; -type XYSeries = ScatterSeries | BarXSeries | BarYSeries | LineSeries | AreaSeries; type GetTypeOfResult = ReturnType; function getTypeOf(value: unknown) { return typeof value; } -const AVAILABLE_SERIES_TYPES = Object.values(SERIES_TYPE); const AVAILABLE_TOOLTIP_TOTALS_BUILT_IN_AGGREGATIONS = Object.values( TOOLTIP_TOTALS_BUILT_IN_AGGREGATION, ); const AVAILABLE_TOOLTIP_TOTALS_AGGREGATION_TYPES: GetTypeOfResult[] = ['function', 'string']; -function validateXYSeries(args: {series: XYSeries; xAxis?: ChartXAxis; yAxis?: ChartYAxis[]}) { - const {series, xAxis, yAxis = []} = args; - - const yAxisIndex = get(series, 'yAxis', 0); - const seriesYAxis = yAxis[yAxisIndex]; - if (yAxisIndex !== 0 && typeof seriesYAxis === 'undefined') { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-y-axis-index', { - index: yAxisIndex, - }), - }); - } - - const xType = get(xAxis, 'type', DEFAULT_AXIS_TYPE); - const yType = get(seriesYAxis, 'type', DEFAULT_AXIS_TYPE); - - series.data.forEach(({x, y}) => { - switch (xType) { - case 'category': { - if (typeof x !== 'string' && typeof x !== 'number' && x !== null) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-axis-category-data-point', { - key: 'x', - seriesName: series.name, - }), - }); - } - - break; - } - case 'datetime': { - if (typeof x !== 'number') { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-axis-datetime-data-point', { - key: 'x', - seriesName: series.name, - }), - }); - } - - break; - } - case 'linear': { - if (typeof x !== 'number' && x !== null) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-axis-linear-data-point', { - key: 'x', - seriesName: series.name, - }), - }); - } - } - } - switch (yType) { - case 'category': { - if (typeof y !== 'string' && typeof y !== 'number' && y !== null) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-axis-category-data-point', { - key: 'y', - seriesName: series.name, - }), - }); - } - - break; - } - case 'datetime': { - if (typeof y !== 'number') { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-axis-datetime-data-point', { - key: 'y', - seriesName: series.name, - }), - }); - } - - break; - } - case 'linear': { - if (typeof y !== 'number' && y !== null) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-axis-linear-data-point', { - key: 'y', - seriesName: series.name, - }), - }); - } - } - } - }); -} - -function validateAxisPlotValues(args: { - series: XYSeries; - xAxis?: ChartXAxis; - yAxis?: ChartYAxis[]; -}) { - const {series, xAxis, yAxis = []} = args; - - const yAxisIndex = get(series, 'yAxis', 0); - const seriesYAxis = yAxis[yAxisIndex]; - if (yAxisIndex !== 0 && typeof seriesYAxis === 'undefined') { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-y-axis-index', { - index: yAxisIndex, - }), - }); - } - - const xPlotBands = get(xAxis, 'plotBands', []); - const yPlotBands = get(yAxis, 'plotBands', []); - - if (!xPlotBands.length && !yPlotBands.length) { - return; - } - - const xType = get(xAxis, 'type', DEFAULT_AXIS_TYPE); - const yType = get(seriesYAxis, 'type', DEFAULT_AXIS_TYPE); - - xPlotBands.forEach(({from = 0, to = 0}) => { - const fromNotEqualTo = typeof to !== typeof from; - - if (fromNotEqualTo) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_axis-plot-band-options-not-equal', { - axis: 'x', - option: 'from', - }), - }); - } - - switch (xType) { - case 'category': { - const invalidFrom = typeof from !== 'string' && typeof from !== 'number'; - const invalidTo = typeof to !== 'string' && typeof to !== 'number'; - if (invalidFrom) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_invalid-axis-plot-band-option', { - axis: 'x', - option: 'from', - }), - }); - } - - if (invalidTo) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_invalid-axis-plot-band-option', { - axis: 'x', - option: 'to', - }), - }); - } - - break; - } - case 'linear': - case 'datetime': { - const invalidFrom = typeof from !== 'number'; - const invalidTo = typeof to !== 'number'; - if (invalidFrom) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_invalid-axis-plot-band-option', { - axis: 'x', - option: 'from', - }), - }); - } - - if (invalidTo) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_invalid-axis-plot-band-option', { - axis: 'x', - option: 'to', - }), - }); - } - - break; - } - } - }); - - yPlotBands.forEach(({from = 0, to = 0}) => { - const fromNotEqualTo = typeof to !== typeof from; - - if (fromNotEqualTo) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_axis-plot-band-options-not-equal', { - axis: 'x', - option: 'from', - }), - }); - } - - switch (yType) { - case 'category': { - const invalidFrom = typeof from !== 'string' && typeof from !== 'number'; - const invalidTo = typeof to !== 'string' && typeof to !== 'number'; - if (invalidFrom) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_invalid-axis-plot-band-option', { - axis: 'y', - option: 'from', - }), - }); - } - - if (invalidTo) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_invalid-axis-plot-band-option', { - axis: 'y', - option: 'to', - }), - }); - } - - break; - } - case 'linear': - case 'datetime': { - const invalidFrom = typeof from !== 'number'; - const invalidTo = typeof to !== 'number'; - if (invalidFrom) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_invalid-axis-plot-band-option', { - axis: 'y', - option: 'from', - }), - }); - } - - if (invalidTo) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_OPTION_TYPE, - message: i18n('error', 'label_invalid-axis-plot-band-option', { - axis: 'y', - option: 'to', - }), - }); - } - - break; - } - } - }); -} - -function validatePieSeries({series}: {series: PieSeries}) { - series.data.forEach(({value}) => { - if (typeof value !== 'number' && value !== null) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-pie-data-value'), - }); - } - }); -} - -function validateStacking({series}: {series: AreaSeries | BarXSeries | BarYSeries}) { - const availableStackingValues = ['normal', 'percent']; - - if (series.stacking && !availableStackingValues.includes(series.stacking)) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-series-property', { - key: 'stacking', - values: availableStackingValues, - }), - }); - } -} - -function validateStackingAreaNullMode({series}: {series: ChartSeries[]}) { - const availableStackingValues = ['normal', 'percent']; - const invalid = series.find( - (s): s is AreaSeries => - s.type === 'area' && - availableStackingValues.includes((s as AreaSeries).stacking as string) && - (s as AreaSeries).nullMode === 'connect', - ); - - if (invalid) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_stacking-area-connect-null-mode'), - }); - } -} - -function validateTreemapSeries({series}: {series: TreemapSeries}) { - const parentIds: Record = {}; - series.data.forEach((d) => { - if (d.parentId && !parentIds[d.parentId]) { - parentIds[d.parentId] = true; - } - }); - series.data.forEach((d) => { - let idOrName = d.id; - if (!idOrName) { - idOrName = Array.isArray(d.name) ? d.name.join() : d.name; - } - - if (parentIds[idOrName] && typeof d.value === 'number') { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-treemap-redundant-value', { - id: d.id, - name: d.name, - }), - }); - } - - if (!parentIds[idOrName] && typeof d.value !== 'number') { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-treemap-missing-value', { - id: d.id, - name: d.name, - }), - }); - } - }); -} - -function validateSeries(args: {series: ChartSeries; xAxis?: ChartXAxis; yAxis?: ChartYAxis[]}) { - const {series, xAxis, yAxis} = args; - - if (!AVAILABLE_SERIES_TYPES.includes(series.type)) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: i18n('error', 'label_invalid-series-type', { - types: AVAILABLE_SERIES_TYPES.join(', '), - }), - }); - } - - switch (series.type) { - case 'area': - case 'bar-y': - case 'bar-x': { - validateAxisPlotValues({series, xAxis, yAxis}); - validateXYSeries({series, xAxis, yAxis}); - validateStacking({series}); - break; - } - case 'line': - case 'scatter': { - validateAxisPlotValues({series, xAxis, yAxis}); - validateXYSeries({series, xAxis, yAxis}); - break; - } - case 'pie': { - validatePieSeries({series}); - break; - } - case 'treemap': { - validateTreemapSeries({series}); - } - } -} - -function countSeriesByType(args: {series: ChartSeries[]; type: ChartSeries['type']}) { - const {series, type} = args; - let count = 0; - - series.forEach((s) => { - if (s.type === type) { - count += 1; - } - }); - - return count; -} - function validateTooltip({tooltip}: {tooltip?: ChartTooltip}) { if (!tooltip) { return; @@ -473,7 +67,6 @@ export function validateData(data?: ChartData) { validateAxes({xAxis: data.xAxis, yAxis: data.yAxis}); validateTooltip({tooltip: data.tooltip}); - validateStackingAreaNullMode({series: data.series.data}); if (data.series.data.some((s) => isEmpty(s.data))) { throw new ChartError({ @@ -482,19 +75,21 @@ export function validateData(data?: ChartData) { }); } - const treemapSeriesCount = countSeriesByType({ - series: data.series.data, - type: SERIES_TYPE.Treemap, - }); + data.series.data.forEach((series) => { + if (!hasSeriesPlugin(series.type)) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-series-type', { + types: getRegisteredSeriesTypes().join(', '), + }), + }); + } - if (treemapSeriesCount > 1) { - throw new ChartError({ - code: CHART_ERROR_CODE.INVALID_DATA, - message: 'It looks like you are trying to define more than one "treemap" series.', + getSeriesPlugin(series.type).validate?.({ + series, + allSeries: data.series.data, + xAxis: data.xAxis, + yAxis: data.yAxis, }); - } - - data.series.data.forEach((series) => { - validateSeries({series, yAxis: data.yAxis, xAxis: data.xAxis}); }); } diff --git a/src/plugins/area/index.ts b/src/plugins/area/index.ts index 57a1c419f..8af114de1 100644 --- a/src/plugins/area/index.ts +++ b/src/plugins/area/index.ts @@ -1,3 +1,4 @@ +import {i18n} from '~core/i18n'; import type { PrepareShapeDataArgs, PrepareShapeDataResult, @@ -11,7 +12,9 @@ import {renderArea} from '~core/shapes/area/renderer'; import type {PreparedAreaData} from '~core/shapes/area/types'; import {getTooltipColorSymbol} from '~core/tooltip/utils'; import {filterLayerLabels} from '~core/utils'; +import {validateAxisPlotValues, validateStacking, validateXYSeries} from '~core/validation/helpers'; +import {CHART_ERROR_CODE, ChartError} from '../../libs'; import type {AreaSeries} from '../../types'; import {prepareAreaSeries} from './prepare-area-series'; @@ -19,6 +22,19 @@ import {prepareAreaSeries} from './prepare-area-series'; export const areaPlugin: SeriesPlugin = { type: 'area', prepareSeries: prepareAreaSeries, + validate: ({series, xAxis, yAxis}) => { + validateAxisPlotValues({series, xAxis, yAxis}); + validateXYSeries({series, xAxis, yAxis}); + validateStacking({series}); + + const isStacking = ['normal', 'percent'].includes(series.stacking as string); + if (isStacking && series.nullMode === 'connect') { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_stacking-area-connect-null-mode'), + }); + } + }, prepareShapeData: async function (args: PrepareShapeDataArgs): Promise { const { series, diff --git a/src/plugins/bar-x/index.ts b/src/plugins/bar-x/index.ts index 05b4a80e0..8c9acf709 100644 --- a/src/plugins/bar-x/index.ts +++ b/src/plugins/bar-x/index.ts @@ -11,6 +11,7 @@ import {renderBarX} from '~core/shapes/bar-x/renderer'; import type {PreparedBarXData} from '~core/shapes/bar-x/types'; import {getTooltipColorSymbol} from '~core/tooltip/utils'; import {filterLayerLabels} from '~core/utils'; +import {validateAxisPlotValues, validateStacking, validateXYSeries} from '~core/validation/helpers'; import type {BarXSeries} from '../../types'; @@ -72,6 +73,11 @@ function renderShapes({ export const barXPlugin: SeriesPlugin = { type: 'bar-x', prepareSeries: prepareBarXSeries, + validate: ({series, xAxis, yAxis}) => { + validateAxisPlotValues({series, xAxis, yAxis}); + validateXYSeries({series, xAxis, yAxis}); + validateStacking({series}); + }, prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/bar-y/index.ts b/src/plugins/bar-y/index.ts index f4dbe0cf0..8cb2a794c 100644 --- a/src/plugins/bar-y/index.ts +++ b/src/plugins/bar-y/index.ts @@ -10,6 +10,7 @@ import {prepareBarYData} from '~core/shapes/bar-y/prepare-data'; import {renderBarY} from '~core/shapes/bar-y/renderer'; import type {BarYShapesArgs} from '~core/shapes/bar-y/types'; import {getTooltipColorSymbol} from '~core/tooltip/utils'; +import {validateAxisPlotValues, validateStacking, validateXYSeries} from '~core/validation/helpers'; import type {BarYSeries} from '../../types'; @@ -43,6 +44,11 @@ function renderShapes({plot, preparedData, seriesOptions, dispatcher}: RenderSha export const barYPlugin: SeriesPlugin = { type: 'bar-y', prepareSeries: prepareBarYSeries, + validate: ({series, xAxis, yAxis}) => { + validateAxisPlotValues({series, xAxis, yAxis}); + validateXYSeries({series, xAxis, yAxis}); + validateStacking({series}); + }, prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/line/index.ts b/src/plugins/line/index.ts index 6603c3523..f90ff6a4b 100644 --- a/src/plugins/line/index.ts +++ b/src/plugins/line/index.ts @@ -11,6 +11,7 @@ import {renderLine} from '~core/shapes/line/renderer'; import type {PreparedLineData} from '~core/shapes/line/types'; import {getTooltipLineSymbol} from '~core/tooltip/utils'; import {filterLayerLabels} from '~core/utils'; +import {validateAxisPlotValues, validateXYSeries} from '~core/validation/helpers'; import type {LineSeries} from '../../types'; @@ -57,6 +58,10 @@ function renderShapes({plot, preparedData, seriesOptions, dispatcher}: RenderSha export const linePlugin: SeriesPlugin = { type: 'line', prepareSeries: prepareLineSeries, + validate: ({series, xAxis, yAxis}) => { + validateAxisPlotValues({series, xAxis, yAxis}); + validateXYSeries({series, xAxis, yAxis}); + }, prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/pie/index.ts b/src/plugins/pie/index.ts index 14a54568d..907e67f7d 100644 --- a/src/plugins/pie/index.ts +++ b/src/plugins/pie/index.ts @@ -1,3 +1,4 @@ +import {i18n} from '~core/i18n'; import type { PrepareShapeDataArgs, PrepareShapeDataResult, @@ -11,6 +12,7 @@ import {renderPie} from '~core/shapes/pie/renderer'; import type {PreparedPieData} from '~core/shapes/pie/types'; import {getTooltipColorSymbol} from '~core/tooltip/utils'; +import {CHART_ERROR_CODE, ChartError} from '../../libs'; import type {PieSeries} from '../../types'; import {preparePieSeries} from './prepare-pie-series'; @@ -37,6 +39,16 @@ export const piePlugin: SeriesPlugin = { useClipPath: false, prepareSeries: ({series, seriesOptions, legend, colors}) => preparePieSeries({series: series as PieSeries[], seriesOptions, legend, colors}), + validate: ({series}) => { + series.data.forEach(({value}) => { + if (typeof value !== 'number' && value !== null) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-pie-data-value'), + }); + } + }); + }, prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/scatter/index.ts b/src/plugins/scatter/index.ts index e69659039..dd98a46ff 100644 --- a/src/plugins/scatter/index.ts +++ b/src/plugins/scatter/index.ts @@ -10,6 +10,7 @@ import {prepareScatterData} from '~core/shapes/scatter/prepare-data'; import {renderScatter} from '~core/shapes/scatter/renderer'; import type {PreparedScatterShapeData} from '~core/shapes/scatter/types'; import {getTooltipColorSymbol} from '~core/tooltip/utils'; +import {validateAxisPlotValues, validateXYSeries} from '~core/validation/helpers'; import type {ScatterSeries} from '../../types'; @@ -48,6 +49,10 @@ function renderShapes({plot, preparedData, seriesOptions, dispatcher}: RenderSha export const scatterPlugin: SeriesPlugin = { type: 'scatter', prepareSeries: prepareScatterSeries, + validate: ({series, xAxis, yAxis}) => { + validateAxisPlotValues({series, xAxis, yAxis}); + validateXYSeries({series, xAxis, yAxis}); + }, prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/treemap/index.ts b/src/plugins/treemap/index.ts index 8491eeb5a..dc6a33a88 100644 --- a/src/plugins/treemap/index.ts +++ b/src/plugins/treemap/index.ts @@ -1,3 +1,4 @@ +import {i18n} from '~core/i18n'; import type { PrepareShapeDataArgs, PrepareShapeDataResult, @@ -11,6 +12,7 @@ import {renderTreemap} from '~core/shapes/treemap/renderer'; import type {PreparedTreemapData} from '~core/shapes/treemap/types'; import {getTooltipColorSymbol} from '~core/tooltip/utils'; +import {CHART_ERROR_CODE, ChartError} from '../../libs'; import type {TreemapSeries} from '../../types'; import {prepareTreemap} from './prepare-treemap-series'; @@ -32,11 +34,57 @@ function renderShapes({plot, preparedData, seriesOptions, dispatcher}: RenderSha return renderTreemap({plot}, preparedData[0] as PreparedTreemapData, seriesOptions, dispatcher); } +function validateTreemapData(series: TreemapSeries) { + const parentIds: Record = {}; + series.data.forEach((d) => { + if (d.parentId && !parentIds[d.parentId]) { + parentIds[d.parentId] = true; + } + }); + series.data.forEach((d) => { + let idOrName = d.id; + if (!idOrName) { + idOrName = Array.isArray(d.name) ? d.name.join() : d.name; + } + + if (parentIds[idOrName] && typeof d.value === 'number') { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-treemap-redundant-value', { + id: d.id, + name: d.name, + }), + }); + } + + if (!parentIds[idOrName] && typeof d.value !== 'number') { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-treemap-missing-value', { + id: d.id, + name: d.name, + }), + }); + } + }); +} + export const treemapPlugin: SeriesPlugin = { type: 'treemap', useClipPath: false, prepareSeries: ({series, seriesOptions, legend, colorScale}) => prepareTreemap({series: series as TreemapSeries[], seriesOptions, legend, colorScale}), + validate: ({series, allSeries}) => { + const treemapCount = allSeries.filter((s) => s.type === 'treemap').length; + if (treemapCount > 1) { + throw new ChartError({ + code: CHART_ERROR_CODE.INVALID_DATA, + message: 'It looks like you are trying to define more than one "treemap" series.', + }); + } + + validateTreemapData(series); + }, prepareShapeData, renderShapes, tooltip: {