UNPKG

@gooddata/react-components

Version:

GoodData.UI - A powerful JavaScript library for building analytical applications

477 lines (426 loc) • 14.3 kB
// tslint:disable-line /** * Calculate new min/max to make Y axes aligned, and insert them to Highcharts config * * Inspired by * Author: Christos Koumenides * Page: https://www.highcharts.com/products/plugin-registry/single/42/Zero-align%20y-axes * Github: https://github.com/chriskmnds/highcharts-zero-align-y-axes * * Modified by binh.nguyen@gooddata.com to support min/max configuration */ import partial = require("lodash/partial"); import isNil = require("lodash/isNil"); import zip = require("lodash/zip"); import sum = require("lodash/sum"); import compact = require("lodash/compact"); import { PERCENT_STACK } from "./getOptionalStackingConfiguration"; import { IChartOptions, IHighChartAxis, ISeriesDataItem, ISeriesItem } from "../../../../interfaces/Config"; import { isComboChart, isLineChart } from "../../utils/common"; export interface ICanon { min?: number; max?: number; } export type IMinMax = ICanon; export interface IMinMaxInfo extends ICanon { id: number; isSetMin: boolean; isSetMax: boolean; } export interface IMinMaxLookup { 0?: IMinMaxInfo; 1?: IMinMaxInfo; } /** * Check if user sets min or max * @param minmax * @param index */ function isMinMaxConfig(minmax: IMinMaxInfo[], index: number): boolean { return minmax[index].isSetMin || minmax[index].isSetMax; } function isMinMaxConfigOnAnyAxis(minmax: IMinMaxInfo[]): boolean { return isMinMaxConfig(minmax, 0) || isMinMaxConfig(minmax, 1); } function minmaxCanon(minmax: IMinMaxInfo[]): ICanon[] { const canon: ICanon[] = []; const extremes = ["min", "max"]; minmax.forEach((item: IMinMaxInfo, i: number) => { canon[i] = {}; extremes.forEach((extreme: string) => { if (item[extreme] === 0) { canon[i][extreme] = 0; } else if (item[extreme] > 0) { canon[i][extreme] = 1; } else { canon[i][extreme] = -1; } }); }); return canon; } function getMinMaxLookup(minmax: IMinMaxInfo[]): IMinMaxLookup { return minmax.reduce((result: IMinMaxLookup, item: IMinMaxInfo) => { result[item.id] = item; return result; }, {}); } function calculateMin( idx: number, minmax: IMinMaxInfo[], minmaxLookup: IMinMaxLookup, axisIndex: number, ): number { const fraction = !minmax[idx].max ? minmax[idx].min // handle divide zero case : minmax[idx].min / minmax[idx].max; return fraction * minmaxLookup[axisIndex].max; } function calculateMax( idx: number, minmax: IMinMaxInfo[], minmaxLookup: IMinMaxLookup, axisIndex: number, ): number { const fraction = !minmax[idx].min ? minmax[idx].max // handle divide zero case : minmax[idx].max / minmax[idx].min; return fraction * minmaxLookup[axisIndex].min; } /** * Calculate min or max and return it * * For min, the calculation is based on axis having smallest min in case having min/max setting * Otherwise, it is calculated base on axis having smaller min * * For max, the calculation is base on axis having largest max in case having min/max setting * Otherwise, it is calculated base on axis having larger max * * @param minmax * @param minmaxLookup * @param axisIndex * @param getIndex * @param calculateLimit * @param findExtreme */ function getLimit( minmax: IMinMaxInfo[], minmaxLookup: IMinMaxLookup, axisIndex: number, getIndex: (...params: any[]) => any, // TODO: make the types more specific (FET-282) calculateLimit: (...params: any[]) => any, // TODO: make the types more specific (FET-282) findExtreme: (...params: any[]) => any, // TODO: make the types more specific (FET-282) ): number { const isMinMaxConfig = isMinMaxConfigOnAnyAxis(minmax); if (isMinMaxConfig) { const idx = getIndex(minmax); return calculateLimit(idx, minmax, minmaxLookup, axisIndex); } return findExtreme([0, 1].map((index: number) => calculateLimit(index, minmax, minmaxLookup, axisIndex))); } export function getMinMax(axisIndex: number, min: number, max: number, minmax: IMinMaxInfo[]): IMinMax { const minmaxLookup: IMinMaxLookup = getMinMaxLookup(minmax); const axesCanon: ICanon[] = minmaxCanon(minmax); const getLimitPartial = partial(getLimit, minmax, minmaxLookup, axisIndex); let { min: newMin, max: newMax } = minmaxLookup[axisIndex]; const { isSetMin, isSetMax } = minmaxLookup[axisIndex]; if (axesCanon[0].min <= 0 && axesCanon[0].max <= 0 && axesCanon[1].min <= 0 && axesCanon[1].max <= 0) { // set 0 at top of chart // ['----', '-0--', '---0'] newMax = Math.min(0, max); } else if ( axesCanon[0].min >= 0 && axesCanon[0].max >= 0 && axesCanon[1].min >= 0 && axesCanon[1].max >= 0 ) { // set 0 at bottom of chart // ['++++', '0+++', '++0+'] newMin = Math.max(0, min); } else if (axesCanon[0].max === axesCanon[1].max) { newMin = getLimitPartial( (minmax: IMinMaxInfo[]) => (minmax[0].min <= minmax[1].min ? 0 : 1), calculateMin, (minOnAxes: number[]) => Math.min(minOnAxes[0], minOnAxes[1]), ); } else if (axesCanon[0].min === axesCanon[1].min) { newMax = getLimitPartial( (minmax: IMinMaxInfo[]) => (minmax[0].max > minmax[1].max ? 0 : 1), calculateMax, (maxOnAxes: number[]) => Math.max(maxOnAxes[0], maxOnAxes[1]), ); } else { // set 0 at center of chart // ['--++', '-0++', '--0+', '-00+', '++--', '++-0', '0+--', '0+-0'] if (minmaxLookup[axisIndex].min < 0) { newMax = Math.abs(newMin); } else { newMin = 0 - newMax; } } return { min: isSetMin ? minmaxLookup[axisIndex].min : newMin, max: isSetMax ? minmaxLookup[axisIndex].max : newMax, }; } export function getMinMaxInfo(config: any, stacking: string, type: string): IMinMaxInfo[] { const { series, yAxis } = config; const isStackedChart = !isNil(stacking); return yAxis.map( (axis: IHighChartAxis, axisIndex: number): IMinMaxInfo => { const isLineChartOnAxis = isLineChartType(series, axisIndex, type); const seriesOnAxis = getSeriesOnAxis(series, axisIndex); const { min, max, opposite } = axis; const { min: dataMin, max: dataMax } = isStackedChart && !isLineChartOnAxis ? getDataMinMaxOnStackedChart(seriesOnAxis, stacking, opposite) : getDataMinMax(seriesOnAxis, isLineChartOnAxis); return { id: axisIndex, min: isNil(min) ? dataMin : min, max: isNil(max) ? dataMax : max, isSetMin: min !== undefined, isSetMax: max !== undefined, }; }, ); } /** * Get series on related axis * @param axisIndex * @param series */ function getSeriesOnAxis(series: ISeriesItem[], axisIndex: number): ISeriesItem[] { return series.filter((item: ISeriesItem): boolean => item.yAxis === axisIndex); } /** * Get y value in series * @param series */ function getYDataInSeries(series: ISeriesItem): number[] { return series.data.map((item: ISeriesDataItem): number => item.y); } /** * Convert table of y value from row-view * [ * [1, 2, 3], * [4, 5, 6] * ] * to column-view * [ * [1, [2, [3, * 4] 5] 6] * ] * @param yData */ function getStackedYData(yData: number[][]): number[][] { return zip(...yData); } /** * Get extreme on columns * [ * [1, [2, [3, * 4] 5] 6] * ] * @param columns * @return [min, max] */ function getColumnExtremes(columns: number[]): IMinMax { return columns.reduce( (result: IMinMax, item: number): IMinMax => { const extreme = item < 0 ? "min" : "max"; result[extreme] += item; return result; }, { min: 0, max: 0 }, ); } function getStackedDataMinMax(yData: number[][]): IMinMax { const isEmpty = yData.length === 0; let min = isEmpty ? 0 : Number.POSITIVE_INFINITY; let max = isEmpty ? 0 : Number.NEGATIVE_INFINITY; yData.forEach((column: number[]) => { const { min: columnDataMin, max: columnDataMax } = getColumnExtremes(column); min = Math.min(min, columnDataMin); max = Math.max(max, columnDataMax); }); return { min, max }; } /** * Convert number to percent base on total of column * From * [ * [1, [3, [4, [null, [20, * 4] 7] -6] null], null] * ] * to * [ * [20, [30, [40, [ , [100 * 80] 70] -60] ] ] * ] * @param yData */ export function convertNumberToPercent(yData: number[][]): number[][] { return yData.map((columns: number[]) => { const columnsWithoutNull = compact(columns); // remove null values const total = sum(columnsWithoutNull.map((num: number) => Math.abs(num))); return columnsWithoutNull.map((num: number) => (num / total) * 100); }); } /** * Get data min/max in stacked chart * By comparing total of positive value to get max and total of negative value to get min * @param series * @param stacking * @param opposite */ function getDataMinMaxOnStackedChart(series: ISeriesItem[], stacking: string, opposite: boolean): IMinMax { const yData = series.map(getYDataInSeries); const stackedYData = getStackedYData(yData); if (stacking === PERCENT_STACK && !opposite) { const percentData = convertNumberToPercent(stackedYData); return getStackedDataMinMax(percentData); } return getStackedDataMinMax(stackedYData); } /** * Get data min/max in normal chart * By comparing min max value in all series in axis * @param series */ function getDataMinMax(series: ISeriesItem[], isLineChart: boolean): IMinMax { const { min, max } = series.reduce( (result: IMinMax, item: ISeriesItem): IMinMax => { const yData = getYDataInSeries(item); return { min: Math.min(result.min, ...yData), max: Math.max(result.max, ...yData), }; }, { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY, }, ); return { min: isLineChart ? min : Math.min(0, min), max: isLineChart ? max : Math.max(0, max), }; } function isLineChartType(series: ISeriesItem[], axisIndex: number, type: string): boolean { if (isLineChart(type)) { return true; } if (isComboChart(type)) { return getSeriesOnAxis(series, axisIndex).every((item: ISeriesItem) => isLineChart(item.type)); } return false; } function getExtremeByChartTypeOnAxis( extreme: number, series: ISeriesItem[], axisIndex: number, type: string, ): number { const isLineChartOnAxis = isLineChartType(series, axisIndex, type); if (isLineChartOnAxis) { return extreme; } return Math.min(0, extreme); } /** * Check whether axis has invalid min/max * @param minmax */ function hasInvalidAxis(minmax: IMinMaxInfo[]): boolean { return minmax.reduce((result: boolean, item: IMinMaxInfo) => { const { min, max } = item; if (min >= max) { return true; } return result; }, false); } /** * Hide invalid axis by setting 'visible' to false * @param config * @param minmax * @param type */ function hideInvalidAxis(config: any, minmax: IMinMaxInfo[], type: string) { const series: ISeriesItem[] = config.series.map((item: ISeriesItem) => { const { yAxis, type } = item; return type ? { yAxis, type } : { yAxis }; }); const yAxis: Array<Partial<IHighChartAxis>> = minmax.map((item: IMinMaxInfo, index: number) => { const isLineChartOnAxis = isLineChartType(series, index, type); const { min, max } = item; const shouldInvisible = isLineChartOnAxis ? min > max : min >= max; if (shouldInvisible) { return { visible: false, }; } return {}; }); yAxis.forEach((axis: Partial<IHighChartAxis>, index: number) => { const { visible } = axis; if (visible === false) { series.forEach((item: ISeriesItem) => { if (item.yAxis === index) { item.visible = false; } }); } }); return { yAxis, series }; } /** * Calculate new min/max to make Y axes aligned * @param chartOptions * @param config */ export function getZeroAlignConfiguration(chartOptions: IChartOptions, config: any) { const { stacking, type } = chartOptions; const { yAxis } = config; const isDualAxis = (yAxis || []).length === 2; if (!isDualAxis) { return {}; } const minmax: IMinMaxInfo[] = getMinMaxInfo(config, stacking, type); if (hasInvalidAxis(minmax)) { return hideInvalidAxis(config, minmax, type); } if (minmax[0].isSetMin && minmax[0].isSetMax && minmax[1].isSetMin && minmax[1].isSetMax) { // take user-input min/max, no need to calculate // this 'isUserMinMax' acts as a flag, // so that 'adjustTickAmount' plugin knows this min/max is either user input or calculated return { yAxis: [ { isUserMinMax: true, }, { isUserMinMax: true, }, ], }; } // calculate min/max on both Y axes and set it to HighCharts yAxis config const yAxisWithMinMax = [0, 1].map((axisIndex: number) => { const { min, max } = minmax[axisIndex]; const newMinMax = getMinMax( axisIndex, getExtremeByChartTypeOnAxis(min, config.series, axisIndex, type), getExtremeByChartTypeOnAxis(max, config.series, axisIndex, type), minmax, ); return { isUserMinMax: minmax[axisIndex].isSetMin || minmax[axisIndex].isSetMax, ...newMinMax, }; }); return { yAxis: yAxisWithMinMax, }; }