UNPKG

@gooddata/react-components

Version:

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

363 lines (309 loc) • 11.4 kB
// tslint:disable-line /** * Highcharts extension that overwrites 'axis.adjustTickAmount' of Highcharts * Original code snippet * https://github.com/highcharts/highcharts/blob/b54fe33d91c0d1fd7da009aaa84af694f15cffad/js/parts/Axis.js#L4214 * * Modified by binh.nguyen@gooddata.com to support zero alignment */ import isNil = require("lodash/isNil"); import get = require("lodash/get"); import Highcharts from "../highchartsEntryPoint"; import { IHighchartsAxisExtend } from "../../../../../interfaces/HighchartsExtend"; import { isLineChart } from "../../../utils/common"; interface IBaseAndAlignedAxes { baseYAxis: IHighchartsAxisExtend; alignedYAxis: IHighchartsAxisExtend; } export const ALIGNED = 0; export const MOVE_ZERO_LEFT = -1; export const MOVE_ZERO_RIGHT = 1; export const Y_AXIS_SCORE = { NO_DATA: 0, ONLY_NEGATIVE_OR_POSITIVE_DATA: 1, NEGATIVE_AND_POSITIVE_DATA: 2, }; function getYAxes(chart: Highcharts.Chart): IHighchartsAxisExtend[] { return chart.axes.filter(isYAxis); } function isYAxis(axis: IHighchartsAxisExtend): boolean { return axis.coll === "yAxis"; } /** * Check if user sets min/max on any axis * @param chart * @return true if any axis is set min/max to. Otherwise false */ function isUserSetExtremesOnAnyAxis(chart: Highcharts.Chart): boolean { const yAxes = chart.userOptions.yAxis; return yAxes[0].isUserMinMax || yAxes[1].isUserMinMax; } /** * Get direction to make secondary axis align to primary axis * @param primaryAxis * @param secondaryAxis * @return * -1: move zero index to left * 0: it aligns * 1: move zero index to right */ export function getDirection( primaryAxis: IHighchartsAxisExtend, secondaryAxis: IHighchartsAxisExtend, ): number { if (isNil(primaryAxis) || isNil(secondaryAxis)) { return ALIGNED; } const { tickPositions: primaryTickPositions = [] } = primaryAxis; const { tickPositions: secondaryTickPositions = [] } = secondaryAxis; const primaryZeroIndex = primaryTickPositions.indexOf(0); const secondaryZeroIndex = secondaryTickPositions.indexOf(0); // no need to align zero on axes without zero if (primaryZeroIndex < 0 || secondaryZeroIndex < 0) { return ALIGNED; } if (primaryZeroIndex > secondaryZeroIndex) { return MOVE_ZERO_RIGHT; } if (primaryZeroIndex < secondaryZeroIndex) { return MOVE_ZERO_LEFT; } return ALIGNED; } /** * Add new tick to first or last position * @param tickPositions * @param tickInterval * @param isAddFirst: if true, add to first. Otherwise, add to last */ function addTick(tickPositions: number[], tickInterval: number, isAddFirst: boolean): number[] { const tick: number = isAddFirst ? Highcharts.correctFloat(tickPositions[0] - tickInterval) : Highcharts.correctFloat(tickPositions[tickPositions.length - 1] + tickInterval); return isAddFirst ? [tick, ...tickPositions] : [...tickPositions, tick]; } /** * Add or reduce ticks * @param axis */ export function adjustTicks(axis: IHighchartsAxisExtend): void { let tickPositions: number[] = (axis.tickPositions || []).slice(); const tickAmount: number = axis.tickAmount; const currentTickAmount: number = tickPositions.length; if (currentTickAmount === tickAmount) { return; } // add ticks to either start or end if (currentTickAmount < tickAmount) { const min = axis.min; const tickInterval = axis.tickInterval; while (tickPositions.length < tickAmount) { const isAddFirst = axis.dataMax <= 0 || // negative dataSet axis.max <= 0 || !( axis.dataMin >= 0 || // positive dataSet axis.min >= 0 || min === 0 || // default HC behavior tickPositions.length % 2 !== 0 ); tickPositions = addTick(tickPositions, tickInterval, isAddFirst); } } else { // reduce ticks const [start, end] = getSelectionRange(axis); tickPositions = tickPositions.slice(start, end); } axis.tickPositions = tickPositions.slice(); } export function getSelectionRange(axis: IHighchartsAxisExtend): number[] { const { tickAmount, tickPositions, dataMin, dataMax } = axis; const currentTickAmount: number = tickPositions.length; if (dataMin >= 0) { return [currentTickAmount - tickAmount, currentTickAmount]; } if (dataMax <= 0) { return [0, tickAmount]; } const zeroIndex = tickPositions.indexOf(0); const firstTickToZero = Math.abs(0 - zeroIndex); const lastTickToZero = currentTickAmount - 1 - zeroIndex; // get range from furthest tick to zero if (firstTickToZero <= lastTickToZero) { return [0, tickAmount]; } return [currentTickAmount - tickAmount, currentTickAmount]; } /** * Get axis score that increase 1 for data having positive and negative values * @param Y axis * @return Y axis score */ export function getYAxisScore(axis: IHighchartsAxisExtend): number { const { dataMin, dataMax } = axis; const yAxisMin = Math.min(0, dataMin); const yAxisMax = Math.max(0, dataMax); if (yAxisMin < 0 && yAxisMax > 0) { return Y_AXIS_SCORE.NEGATIVE_AND_POSITIVE_DATA; } if (yAxisMin < 0 || yAxisMax > 0) { return Y_AXIS_SCORE.ONLY_NEGATIVE_OR_POSITIVE_DATA; } return Y_AXIS_SCORE.NO_DATA; } /** * Base on axis score which is bigger than another, will become base axis * The other axis will be aligned to base axis * @param yAxes * @return base Y axis and aligned Y axis */ function getBaseYAxis(yAxes: IHighchartsAxisExtend[]): IBaseAndAlignedAxes { const [firstAxisScore, secondAxisScore] = yAxes.map(getYAxisScore); if (firstAxisScore >= secondAxisScore) { return { baseYAxis: yAxes[0], alignedYAxis: yAxes[1], }; } return { baseYAxis: yAxes[1], alignedYAxis: yAxes[0], }; } export function alignToBaseAxis(yAxis: IHighchartsAxisExtend, baseYAxis: IHighchartsAxisExtend): void { const { tickInterval } = yAxis; for ( let direction: number = getDirection(baseYAxis, yAxis); direction !== ALIGNED; direction = getDirection(baseYAxis, yAxis) ) { let tickPositions: number[] = yAxis.tickPositions.slice(); if (direction === MOVE_ZERO_RIGHT) { // add new tick to the start tickPositions = addTick(tickPositions, tickInterval, true); // remove last tick tickPositions = tickPositions.slice(0, tickPositions.length - 1); } else if (direction === MOVE_ZERO_LEFT) { // add new tick to the end tickPositions = addTick(tickPositions, tickInterval, false); // remove first tick tickPositions = tickPositions.slice(1, tickPositions.length); } yAxis.tickPositions = tickPositions; } } function updateAxis(axis: IHighchartsAxisExtend, currentTickAmount: number): void { const { options, tickPositions } = axis; axis.transA *= (currentTickAmount - 1) / (Math.max(axis.tickAmount, 2) - 1); // avoid N/0 case axis.min = options.startOnTick ? tickPositions[0] : Math.min(axis.min, tickPositions[0]); axis.max = options.endOnTick ? tickPositions[tickPositions.length - 1] : Math.max(axis.max, tickPositions[tickPositions.length - 1]); } /** * Prevent data is cut off by increasing tick interval to zoom out axis * Only apply to chart without user-input min/max * @param axis */ export function preventDataCutOff(axis: IHighchartsAxisExtend): void { const { chart } = axis; const { min, max, dataMin, dataMax } = axis; const isCutOff = !isUserSetExtremesOnAnyAxis(chart) && (min > dataMin || max < dataMax); if (!isCutOff) { return; } axis.tickInterval *= 2; axis.tickPositions = axis.tickPositions.map((value: number): number => value * 2); updateAxis(axis, axis.tickAmount); } /** * Align axes once secondary axis is ready * Cause at the time HC finishes adjust primary axis, secondary axis has not been done yet * @param axis */ function alignYAxes(axis: IHighchartsAxisExtend): void { const chart: Highcharts.Chart = axis.chart; const yAxes = getYAxes(chart); const { baseYAxis, alignedYAxis } = getBaseYAxis(yAxes); const direction: number = getDirection(baseYAxis, alignedYAxis); const isReadyToAlign: boolean = axis.opposite && direction !== ALIGNED; const hasLineChart: boolean = isAxisWithLineChartType(baseYAxis) || isAxisWithLineChartType(alignedYAxis); if (baseYAxis && alignedYAxis && isReadyToAlign && !hasLineChart) { alignToBaseAxis(alignedYAxis, baseYAxis); updateAxis(alignedYAxis, alignedYAxis.tickAmount); preventDataCutOff(alignedYAxis); } } /** * Copy and modify Highcharts behavior */ export function customAdjustTickAmount(): void { const axis = this; if (!axis.hasData()) { return; } if (isYAxis(axis)) { // persist tick amount value to calculate transA in 'updateAxis' const currentTickAmount = (axis.tickPositions || []).length; adjustTicks(axis); updateAxis(axis, currentTickAmount); preventDataCutOff(axis); } // The finalTickAmt property is set in getTickAmount const { finalTickAmt } = axis; if (!isNil(finalTickAmt)) { const len = axis.tickPositions.length; let i = len; while (i--) { if ( // Remove every other tick (finalTickAmt === 3 && i % 2 === 1) || // Remove all but first and last (finalTickAmt <= 2 && i > 0 && i < len - 1) ) { axis.tickPositions.splice(i, 1); } } axis.finalTickAmt = undefined; } } function isAxisWithLineChartType(axis: IHighchartsAxisExtend): boolean { if (isLineChart(get(axis, "chart.userOptions.chart.type"))) { return true; } const { series } = axis; return series.reduce((result: boolean, item: Highcharts.Series) => { return isLineChart(item.type) ? true : result; }, false); } function isSingleAxisChart(axis: IHighchartsAxisExtend): boolean { const yAxes = getYAxes(axis.chart); return yAxes.length < 2; } /** * Decide whether run default or custom behavior * @param axis * @return true as leaving to HC, otherwise false as running custom behavior */ export function shouldBeHandledByHighcharts(axis: IHighchartsAxisExtend): boolean { if (!isYAxis(axis) || isSingleAxisChart(axis) || isAxisWithLineChartType(axis)) { return true; } const yAxes = getYAxes(axis.chart); return yAxes.some((axis: IHighchartsAxisExtend) => axis.visible === false); } export const adjustTickAmount = (HighchartsInstance: any) => { Highcharts.wrap(HighchartsInstance.Axis.prototype, "adjustTickAmount", function( proceed: Highcharts.WrapProceedFunction, ) { const axis = this; if (shouldBeHandledByHighcharts(axis)) { proceed.call(axis); } else { customAdjustTickAmount.call(axis); } if (!isSingleAxisChart(axis)) { alignYAxes(axis); } }); };