UNPKG

@progress/kendo-charts

Version:

Kendo UI platform-independent Charts library

545 lines (437 loc) 18.4 kB
import ErrorRangeCalculator from './error-bars/error-range-calculator'; import CategoricalErrorBar from './error-bars/categorical-error-bar'; import { ERROR_LOW_FIELD, ERROR_HIGH_FIELD } from './constants'; import { evalOptions, categoriesCount } from './utils'; import { ChartElement, Box } from '../core'; import { VALUE, STRING, MIN_VALUE, MAX_VALUE, OBJECT } from '../common/constants'; import { convertableToNumber, deepExtend, isNumber, last, setDefaultOptions, sparseArrayLimits } from '../common'; class CategoricalChart extends ChartElement { constructor(plotArea, options) { super(options); this.plotArea = plotArea; this.chartService = plotArea.chartService; this.categoryAxis = plotArea.seriesCategoryAxis(options.series[0]); // Value axis ranges grouped by axis name, e.g.: // primary: { min: 0, max: 1 } this.valueAxisRanges = {}; this.points = []; this.categoryPoints = []; this.seriesPoints = []; this.seriesOptions = []; this._evalSeries = []; this.render(); } render() { this.traverseDataPoints(this.addValue.bind(this)); } pointOptions(series, seriesIx) { let options = this.seriesOptions[seriesIx]; if (!options) { const defaults = this.pointType().prototype.defaults; this.seriesOptions[seriesIx] = options = deepExtend({ }, defaults, { vertical: !this.options.invertAxes }, series); } return options; } plotValue(point) { if (!point) { return 0; } if (this.options.isStacked100 && isNumber(point.value)) { const categoryIx = point.categoryIx; const categoryPoints = this.categoryPoints[categoryIx]; const otherValues = []; let categorySum = 0; for (let i = 0; i < categoryPoints.length; i++) { const other = categoryPoints[i]; if (other) { const stack = point.series.stack; const otherStack = other.series.stack; if ((stack && otherStack) && stack.group !== otherStack.group) { continue; } if (isNumber(other.value)) { categorySum += Math.abs(other.value); otherValues.push(Math.abs(other.value)); } } } if (categorySum > 0) { return point.value / categorySum; } } return point.value; } plotRange(point, startValue = 0) { const categoryPoints = this.categoryPoints[point.categoryIx]; if (this.options.isStacked) { let plotValue = this.plotValue(point); const positive = plotValue >= 0; let prevValue = startValue; let isStackedBar = false; const stack = point.series.stack !== undefined ? point.series.stack : this.options.defaultStack; const isNonGroupStack = (stack) => stack === true || typeof stack === OBJECT && !stack.group; if (stack) { for (let i = 0; i < categoryPoints.length; i++) { const other = categoryPoints[i]; if (point === other) { break; } const otherStack = other.series.stack !== undefined ? other.series.stack : this.options.defaultStack; if (!otherStack) { continue; } if (typeof stack === STRING && stack !== otherStack) { continue; } if (isNonGroupStack(stack) && !isNonGroupStack(otherStack)) { continue; } if (stack.group && stack.group !== otherStack.group) { continue; } const otherValue = this.plotValue(other); if ((otherValue >= 0 && positive) || (otherValue < 0 && !positive)) { // zero values should be skipped for log axis (startValue !== 0) if (startValue === 0 || otherValue !== 0) { prevValue += otherValue; plotValue += otherValue; isStackedBar = true; if (this.options.isStacked100) { plotValue = Math.min(plotValue, 1); } } } } } if (isStackedBar) { prevValue -= startValue; } return [ prevValue, plotValue ]; } const series = point.series; const valueAxis = this.seriesValueAxis(series); const axisCrossingValue = this.categoryAxisCrossingValue(valueAxis); return [ axisCrossingValue, convertableToNumber(point.value) ? point.value : axisCrossingValue ]; } stackLimits(axisName, stackName) { let min = MAX_VALUE; let max = MIN_VALUE; for (let i = 0; i < this.categoryPoints.length; i++) { const categoryPoints = this.categoryPoints[i]; if (!categoryPoints) { continue; } for (let pIx = 0; pIx < categoryPoints.length; pIx++) { const point = categoryPoints[pIx]; if (point) { if (point.series.stack === stackName || point.series.axis === axisName) { const to = this.plotRange(point, 0)[1]; if (to !== undefined && isFinite(to)) { max = Math.max(max, to); min = Math.min(min, to); } } } } } return { min: min, max: max }; } updateStackRange() { const { isStacked, series: chartSeries } = this.options; const limitsCache = {}; if (isStacked) { for (let i = 0; i < chartSeries.length; i++) { const series = chartSeries[i]; const axisName = series.axis; const key = axisName + series.stack; let limits = limitsCache[key]; if (!limits) { limits = this.stackLimits(axisName, series.stack); const errorTotals = this.errorTotals; if (errorTotals) { if (errorTotals.negative.length) { limits.min = Math.min(limits.min, sparseArrayLimits(errorTotals.negative).min); } if (errorTotals.positive.length) { limits.max = Math.max(limits.max, sparseArrayLimits(errorTotals.positive).max); } } if (limits.min !== MAX_VALUE || limits.max !== MIN_VALUE) { limitsCache[key] = limits; } else { limits = null; } } if (limits) { this.valueAxisRanges[axisName] = limits; } } } } addErrorBar(point, data, categoryIx) { const { value, series, seriesIx } = point; const errorBars = point.options.errorBars; const lowValue = data.fields[ERROR_LOW_FIELD]; const highValue = data.fields[ERROR_HIGH_FIELD]; let errorRange; if (isNumber(lowValue) && isNumber(highValue)) { errorRange = { low: lowValue, high: highValue }; } else if (errorBars && errorBars.value !== undefined) { this.seriesErrorRanges = this.seriesErrorRanges || []; this.seriesErrorRanges[seriesIx] = this.seriesErrorRanges[seriesIx] || new ErrorRangeCalculator(errorBars.value, series, VALUE); errorRange = this.seriesErrorRanges[seriesIx].getErrorRange(value, errorBars.value); } if (errorRange) { point.low = errorRange.low; point.high = errorRange.high; this.addPointErrorBar(point, categoryIx); } } addPointErrorBar(point, categoryIx) { const isVertical = !this.options.invertAxes; const options = point.options.errorBars; let { series, low, high } = point; if (this.options.isStacked) { const stackedErrorRange = this.stackedErrorRange(point, categoryIx); low = stackedErrorRange.low; high = stackedErrorRange.high; } else { const fields = { categoryIx: categoryIx, series: series }; this.updateRange({ value: low }, fields); this.updateRange({ value: high }, fields); } const errorBar = new CategoricalErrorBar(low, high, isVertical, this, series, options); point.errorBars = [ errorBar ]; point.append(errorBar); } stackedErrorRange(point, categoryIx) { const plotValue = this.plotRange(point, 0)[1] - point.value; const low = point.low + plotValue; const high = point.high + plotValue; this.errorTotals = this.errorTotals || { positive: [], negative: [] }; if (low < 0) { this.errorTotals.negative[categoryIx] = Math.min(this.errorTotals.negative[categoryIx] || 0, low); } if (high > 0) { this.errorTotals.positive[categoryIx] = Math.max(this.errorTotals.positive[categoryIx] || 0, high); } return { low: low, high: high }; } addValue(data, fields) { const { categoryIx, series, seriesIx } = fields; let categoryPoints = this.categoryPoints[categoryIx]; if (!categoryPoints) { this.categoryPoints[categoryIx] = categoryPoints = []; } let seriesPoints = this.seriesPoints[seriesIx]; if (!seriesPoints) { this.seriesPoints[seriesIx] = seriesPoints = []; } const point = this.createPoint(data, fields); if (point) { Object.assign(point, fields); point.owner = this; point.noteText = data.fields.noteText; if (point.dataItem === undefined) { point.dataItem = series.data[categoryIx]; } this.addErrorBar(point, data, categoryIx); } this.points.push(point); seriesPoints.push(point); categoryPoints.push(point); this.updateRange(data.valueFields, fields); } evalPointOptions(options, value, fields) { const categoryIx = fields.categoryIx; const category = fields.category; const series = fields.series; const seriesIx = fields.seriesIx; const state = { defaults: series._defaults, excluded: [ "data", "aggregate", "_events", "tooltip", "content", "template", "visual", "toggle", "_outOfRangeMinPoint", "_outOfRangeMaxPoint", "drilldownSeriesFactory", "ariaTemplate", "ariaContent" ] }; let doEval = this._evalSeries[seriesIx]; if (doEval === undefined) { this._evalSeries[seriesIx] = doEval = evalOptions(options, {}, state, true); } let pointOptions = options; if (doEval) { pointOptions = deepExtend({}, pointOptions); evalOptions(pointOptions, { value: value, category: category, index: categoryIx, series: series, dataItem: series.data[categoryIx] }, state); } return pointOptions; } updateRange(data, fields) { const axisName = fields.series.axis; const value = data.value; let axisRange = this.valueAxisRanges[axisName]; if (isFinite(value) && value !== null) { axisRange = this.valueAxisRanges[axisName] = axisRange || { min: MAX_VALUE, max: MIN_VALUE }; axisRange.min = Math.min(axisRange.min, value); axisRange.max = Math.max(axisRange.max, value); } } seriesValueAxis(series) { const plotArea = this.plotArea; const axisName = series.axis; const axis = axisName ? plotArea.namedValueAxes[axisName] : plotArea.valueAxis; if (!axis) { throw new Error("Unable to locate value axis with name " + axisName); } return axis; } reflow(targetBox) { const categorySlots = this.categorySlots = []; const chartPoints = this.points; const categoryAxis = this.categoryAxis; let pointIx = 0; this.traverseDataPoints((data, fields) => { const { categoryIx, series: currentSeries } = fields; const valueAxis = this.seriesValueAxis(currentSeries); const point = chartPoints[pointIx++]; let categorySlot = categorySlots[categoryIx]; if (!categorySlot) { categorySlots[categoryIx] = categorySlot = this.categorySlot(categoryAxis, categoryIx, valueAxis); } if (point) { const plotRange = this.plotRange(point, valueAxis.startValue()); const valueSlot = this.valueSlot(valueAxis, plotRange); if (valueSlot) { const pointSlot = this.pointSlot(categorySlot, valueSlot); point.aboveAxis = this.aboveAxis(point, valueAxis); point.stackValue = plotRange[1]; if (this.options.isStacked100) { point.percentage = this.plotValue(point); } this.reflowPoint(point, pointSlot); } else { point.visible = false; } } }); this.reflowCategories(categorySlots); if (!this.options.clip && this.options.limitPoints && this.points.length) { this.limitPoints(); } this.box = targetBox; } valueSlot(valueAxis, plotRange) { return valueAxis.getSlot(plotRange[0], plotRange[1], !this.options.clip); } limitPoints() { const categoryPoints = this.categoryPoints; const points = categoryPoints[0].concat(last(categoryPoints)); for (let idx = 0; idx < points.length; idx++) { if (points[idx]) { this.limitPoint(points[idx]); } } } limitPoint(point) { const limitedSlot = this.categoryAxis.limitSlot(point.box); if (!limitedSlot.equals(point.box)) { point.reflow(limitedSlot); } } aboveAxis(point, valueAxis) { const axisCrossingValue = this.categoryAxisCrossingValue(valueAxis); const value = point.value; return valueAxis.options.reverse ? value < axisCrossingValue : value >= axisCrossingValue; } categoryAxisCrossingValue(valueAxis) { const categoryAxis = this.categoryAxis; const options = valueAxis.options; const crossingValues = [].concat( options.axisCrossingValues || options.axisCrossingValue ); return crossingValues[categoryAxis.axisIndex || 0] || 0; } reflowPoint(point, pointSlot) { point.reflow(pointSlot); } reflowCategories() { } pointSlot(categorySlot, valueSlot) { const options = this.options; const invertAxes = options.invertAxes; const slotX = invertAxes ? valueSlot : categorySlot; const slotY = invertAxes ? categorySlot : valueSlot; return new Box(slotX.x1, slotY.y1, slotX.x2, slotY.y2); } categorySlot(categoryAxis, categoryIx) { return categoryAxis.getSlot(categoryIx); } traverseDataPoints(callback) { const series = this.options.series; const count = categoriesCount(series); const seriesCount = series.length; for (let seriesIx = 0; seriesIx < seriesCount; seriesIx++) { this._outOfRangeCallback(series[seriesIx], "_outOfRangeMinPoint", seriesIx, callback); } for (let categoryIx = 0; categoryIx < count; categoryIx++) { const currentCategory = this.categoryAxis.categoryAt(categoryIx); for (let seriesIx = 0; seriesIx < seriesCount; seriesIx++) { const currentSeries = series[seriesIx]; const pointData = this.plotArea.bindPoint(currentSeries, categoryIx); callback(pointData, { category: currentCategory, categoryIx: categoryIx, categoriesCount: count, series: currentSeries, seriesIx: seriesIx }); } } for (let seriesIx = 0; seriesIx < seriesCount; seriesIx++) { this._outOfRangeCallback(series[seriesIx], "_outOfRangeMaxPoint", seriesIx, callback); } } _outOfRangeCallback(series, field, seriesIx, callback) { const outOfRangePoint = series[field]; if (outOfRangePoint) { const categoryIx = outOfRangePoint.categoryIx; const pointData = this.plotArea.bindPoint(series, categoryIx, outOfRangePoint.item); callback(pointData, { category: outOfRangePoint.category, categoryIx: categoryIx, series: series, seriesIx: seriesIx, dataItem: outOfRangePoint.item }); } } formatPointValue(point, format) { if (point.value === null) { return ""; } return this.chartService.format.auto(format, point.value); } pointValue(data) { return data.valueFields.value; } } setDefaultOptions(CategoricalChart, { series: [], invertAxes: false, isStacked: false, clip: true, limitPoints: true }); export default CategoricalChart;