UNPKG

apexcharts

Version:

A JavaScript Chart Library

752 lines (691 loc) 25.4 kB
import CoreUtils from './CoreUtils' import Utils from '../utils/Utils' export default class Scales { constructor(ctx) { this.ctx = ctx this.w = ctx.w this.coreUtils = new CoreUtils(this.ctx) } // http://stackoverflow.com/questions/326679/choosing-an-attractive-linear-scale-for-a-graphs-y-axis // This routine creates the Y axis values for a graph. niceScale(yMin, yMax, index = 0) { // Calculate Min amd Max graphical labels and graph // increments. // // Output will be an array of the Y axis values that // encompass the Y values. const jsPrecision = 1e-11 // JS precision errors const w = this.w const gl = w.globals let axisCnf let maxTicks let gotMin let gotMax if (gl.isBarHorizontal) { axisCnf = w.config.xaxis // The most ticks we can fit into the svg chart dimensions maxTicks = Math.max((gl.svgWidth - 100) / 25, 2) // Guestimate } else { axisCnf = w.config.yaxis[index] maxTicks = Math.max((gl.svgHeight - 100) / 15, 2) } if (!Utils.isNumber(maxTicks)) { maxTicks = 10 } gotMin = axisCnf.min !== undefined && axisCnf.min !== null gotMax = axisCnf.max !== undefined && axisCnf.min !== null let gotStepSize = axisCnf.stepSize !== undefined && axisCnf.stepSize !== null let gotTickAmount = axisCnf.tickAmount !== undefined && axisCnf.tickAmount !== null let ticks = gotTickAmount ? axisCnf.tickAmount : gl.niceScaleDefaultTicks[ Math.min( Math.round(maxTicks / 2), gl.niceScaleDefaultTicks.length - 1 ) ] // In case we have a multi axis chart: // Ensure subsequent series start with the same tickAmount as series[0], // because the tick lines are drawn based on series[0]. This does not // override user defined options for any yaxis. if (gl.isMultipleYAxis && !gotTickAmount && gl.multiAxisTickAmount > 0) { ticks = gl.multiAxisTickAmount gotTickAmount = true } if (ticks === 'dataPoints') { ticks = gl.dataPoints - 1 } else { // Ensure ticks is an integer ticks = Math.abs(Math.round(ticks)) } if ( (yMin === Number.MIN_VALUE && yMax === 0) || (!Utils.isNumber(yMin) && !Utils.isNumber(yMax)) || (yMin === Number.MIN_VALUE && yMax === -Number.MAX_VALUE) ) { // when all values are 0 yMin = Utils.isNumber(axisCnf.min) ? axisCnf.min : 0 yMax = Utils.isNumber(axisCnf.max) ? axisCnf.max : yMin + ticks gl.allSeriesCollapsed = false } if (yMin > yMax) { // if somehow due to some wrong config, user sent max less than min, // adjust the min/max again console.warn( 'axis.min cannot be greater than axis.max: swapping min and max' ) let temp = yMax yMax = yMin yMin = temp } else if (yMin === yMax) { // If yMin and yMax are identical, then // adjust the yMin and yMax values to actually // make a graph. Also avoids division by zero errors. yMin = yMin === 0 ? 0 : yMin - 1 // choose an integer in case yValueDecimals=0 yMax = yMax === 0 ? 2 : yMax + 1 // choose an integer in case yValueDecimals=0 } let result = [] if (ticks < 1) { ticks = 1 } let tiks = ticks // Determine Range let range = Math.abs(yMax - yMin) // Snap min or max to zero if close let proximityRatio = 0.15 if (!gotMin && yMin > 0 && yMin / range < proximityRatio) { yMin = 0 gotMin = true } if (!gotMax && yMax < 0 && -yMax / range < proximityRatio) { yMax = 0 gotMax = true } range = Math.abs(yMax - yMin) // Calculate a pretty step value based on ticks // Initial stepSize let stepSize = range / tiks let niceStep = stepSize let mag = Math.floor(Math.log10(niceStep)) let magPow = Math.pow(10, mag) // ceil() is used below in conjunction with the values populating // niceScaleAllowedMagMsd[][] to ensure that (niceStep * tiks) // produces a range that doesn't clip data points after stretching // the raw range out a little to match the prospective new range. let magMsd = Math.ceil(niceStep / magPow) // See globals.js for info on what niceScaleAllowedMagMsd does magMsd = gl.niceScaleAllowedMagMsd[gl.yValueDecimal === 0 ? 0 : 1][magMsd] niceStep = magMsd * magPow // Initial stepSize stepSize = niceStep // Get step value if (gl.isBarHorizontal && axisCnf.stepSize && axisCnf.type !== 'datetime') { stepSize = axisCnf.stepSize gotStepSize = true } else if (gotStepSize) { stepSize = axisCnf.stepSize } if (gotStepSize) { if (axisCnf.forceNiceScale) { // Check that given stepSize is sane with respect to the range. // // The user can, by setting forceNiceScale = true, // define a stepSize that will be scaled to a useful value before // it's checked for consistency. // // If, for example, the range = 4 and the user defined stepSize = 8 // (or 8000 or 0.0008, etc), then stepSize is inapplicable as // it is. Reducing it to 0.8 will fit with 5 ticks. // let stepMag = Math.floor(Math.log10(stepSize)) stepSize *= Math.pow(10, mag - stepMag) } } // Start applying some rules if (gotMin && gotMax) { let crudeStep = range / tiks // min and max (range) cannot be changed if (gotTickAmount) { if (gotStepSize) { if (Utils.mod(range, stepSize) != 0) { // stepSize conflicts with range let gcdStep = Utils.getGCD(stepSize, crudeStep) // gcdStep is a multiple of range because crudeStep is a multiple. // gcdStep is also a multiple of stepSize, so it partially honoured // All three could be equal, which would be very nice // if the computed stepSize generates too many ticks they will be // reduced later, unless the number is prime, in which case, // the chart will display all of them or just one (plus the X axis) // depending on svg dimensions. Setting forceNiceScale: true will force // the display of at least the default number of ticks. if (crudeStep / gcdStep < 10) { stepSize = gcdStep } else { // stepSize conflicts and no reasonable adjustment, but must // honour tickAmount stepSize = crudeStep } } else { // stepSize fits if (Utils.mod(stepSize, crudeStep) == 0) { // crudeStep is a multiple of stepSize, or vice versa // but we know that crudeStep will generate tickAmount ticks stepSize = crudeStep } else { // stepSize conflicts with tickAmount // if the user is setting up a multi-axis chart and wants // synced axis ticks then they should not define stepSize // or ensure there is no conflict between any of their options // on any axis. crudeStep = stepSize // De-prioritizing ticks from now on gotTickAmount = false } } } else { // no user stepSize, honour tickAmount stepSize = crudeStep } } else { // default ticks in use, tiks can change if (gotStepSize) { if (Utils.mod(range, stepSize) == 0) { // user stepSize fits crudeStep = stepSize } else { stepSize = crudeStep } } else { // no user stepSize if (Utils.mod(range, stepSize) == 0) { // generated nice stepSize fits crudeStep = stepSize } else { tiks = Math.ceil(range / stepSize) crudeStep = range / tiks let gcdStep = Utils.getGCD(range, stepSize) if (range / gcdStep < maxTicks) { crudeStep = gcdStep } stepSize = crudeStep } } } tiks = Math.round(range / stepSize) } else { // Snap range to ticks if (!gotMin && !gotMax) { if (gl.isMultipleYAxis && gotTickAmount) { // Ensure graph doesn't clip. let tMin = stepSize * Math.floor(yMin / stepSize) let tMax = tMin + stepSize * tiks if (tMax < yMax) { stepSize *= 2 } yMin = tMin tMax = yMax yMax = yMin + stepSize * tiks // Snap min or max to zero if possible range = Math.abs(yMax - yMin) if (yMin > 0 && yMin < Math.abs(tMax - yMax)) { yMin = 0 yMax = stepSize * tiks } if (yMax < 0 && -yMax < Math.abs(tMin - yMin)) { yMax = 0 yMin = -stepSize * tiks } } else { yMin = stepSize * Math.floor(yMin / stepSize) yMax = stepSize * Math.ceil(yMax / stepSize) } } else if (gotMax) { if (gotTickAmount) { yMin = yMax - stepSize * tiks } else { let yMinPrev = yMin yMin = stepSize * Math.floor(yMin / stepSize) if ( Math.abs(yMax - yMin) / Utils.getGCD(range, stepSize) > maxTicks ) { // Use default ticks to compute yMin then shrinkwrap yMin = yMax - stepSize * ticks yMin += stepSize * Math.floor((yMinPrev - yMin) / stepSize) } } } else if (gotMin) { if (gotTickAmount) { yMax = yMin + stepSize * tiks } else { let yMaxPrev = yMax yMax = stepSize * Math.ceil(yMax / stepSize) if ( Math.abs(yMax - yMin) / Utils.getGCD(range, stepSize) > maxTicks ) { // Use default ticks to compute yMin then shrinkwrap yMax = yMin + stepSize * ticks yMax += stepSize * Math.ceil((yMaxPrev - yMax) / stepSize) } } } range = Math.abs(yMax - yMin) // Final check and possible adjustment of stepSize to prevent // overriding the user's min or max choice. stepSize = Utils.getGCD(range, stepSize) tiks = Math.round(range / stepSize) } // Shrinkwrap ticks to the range if (!gotTickAmount && !(gotMin || gotMax)) { tiks = Math.ceil((range - jsPrecision) / (stepSize + jsPrecision)) // No user tickAmount, or min or max, we are free to adjust to avoid a // prime number. This helps when reducing ticks for small svg dimensions. if (tiks > 16 && Utils.getPrimeFactors(tiks).length < 2) { tiks++ } } // Prune tiks down to range if series is all integers. Since tiks > range, // range is very low (< 10 or so). Skip this step if gotTickAmount is true // because either the user set tickAmount or the chart is multiscale and // this axis is not determining the number of grid lines. if ( !gotTickAmount && axisCnf.forceNiceScale && gl.yValueDecimal === 0 && tiks > range ) { tiks = range stepSize = Math.round(range / tiks) } if ( tiks > maxTicks && (!(gotTickAmount || gotStepSize) || axisCnf.forceNiceScale) ) { // Reduce the number of ticks nicely if chart svg dimensions shrink too far. // The reduced tick set should always be a subset of the full set. // // This following products of prime factors method works as follows: // We compute the prime factors of the full tick count (tiks), then all the // possible products of those factors in order from smallest to biggest, // until we find a product P such that: tiks/P < maxTicks. // // Example: // Computing products of the prime factors of 30. // // tiks | pf | 1 2 3 4 5 6 <-- compute order // -------------------------------------------------- // 30 | 5 | 5 5 5 <-- Multiply all // | 3 | 3 3 3 3 <-- primes in each // | 2 | 2 2 2 <-- column = P // -------------------------------------------------- // 15 10 6 5 2 1 <-- tiks/P // // tiks = 30 has prime factors [2, 3, 5] // The loop below computes the products [2,3,5,6,15,30]. // The last product of P = 2*3*5 is skipped since 30/P = 1. // This yields tiks/P = [15,10,6,5,2,1], checked in order until // tiks/P < maxTicks. // // Pros: // 1) The ticks in the reduced set are always members of the // full set of ticks. // Cons: // 1) None: if tiks is prime, we get all or one, nothing between, so // the worst case is to display all, which is the status quo. Really // only a problem visually for larger tick numbers, say, > 7. // let pf = Utils.getPrimeFactors(tiks) let last = pf.length - 1 let tt = tiks reduceLoop: for (var xFactors = 0; xFactors < last; xFactors++) { for (var lowest = 0; lowest <= last - xFactors; lowest++) { let stop = Math.min(lowest + xFactors, last) let t = tt let div = 1 for (var next = lowest; next <= stop; next++) { div *= pf[next] } t /= div if (t < maxTicks) { tt = t break reduceLoop } } } if (tt === tiks) { // Could not reduce ticks at all, go all in and display just the // X axis and one tick. stepSize = range } else { stepSize = range / tt } tiks = Math.round(range / stepSize) } // Record final tiks for use by other series that call niceScale(). // Note: some don't, like logarithmicScale(), etc. if ( gl.isMultipleYAxis && gl.multiAxisTickAmount == 0 && gl.ignoreYAxisIndexes.indexOf(index) < 0 ) { gl.multiAxisTickAmount = tiks } // build Y label array. let val = yMin - stepSize // Ensure we don't under/over shoot due to JS precision errors. // This also fixes (amongst others): // https://github.com/apexcharts/apexcharts.js/issues/430 let err = stepSize * jsPrecision do { val += stepSize result.push(Utils.stripNumber(val, 7)) } while (yMax - val > err) return { result, niceMin: result[0], niceMax: result[result.length - 1], } } linearScale(yMin, yMax, ticks = 10, index = 0, step = undefined) { let range = Math.abs(yMax - yMin) let result = [] if (yMin === yMax) { result = [yMin] return { result, niceMin: result[0], niceMax: result[result.length - 1], } } ticks = this._adjustTicksForSmallRange(ticks, index, range) if (ticks === 'dataPoints') { ticks = this.w.globals.dataPoints - 1 } if (!step) { step = range / ticks } step = Math.round((step + Number.EPSILON) * 100) / 100 if (ticks === Number.MAX_VALUE) { ticks = 5 step = 1 } let v = yMin while (ticks >= 0) { result.push(v) v = Utils.preciseAddition(v, step) ticks -= 1 } return { result, niceMin: result[0], niceMax: result[result.length - 1], } } logarithmicScaleNice(yMin, yMax, base) { // Basic validation to avoid for loop starting at -inf. if (yMax <= 0) yMax = Math.max(yMin, base) if (yMin <= 0) yMin = Math.min(yMax, base) const logs = [] // Get powers of base for our max and min const logMax = Math.ceil(Math.log(yMax) / Math.log(base) + 1) const logMin = Math.floor(Math.log(yMin) / Math.log(base)) for (let i = logMin; i < logMax; i++) { logs.push(Math.pow(base, i)) } return { result: logs, niceMin: logs[0], niceMax: logs[logs.length - 1], } } logarithmicScale(yMin, yMax, base) { // Basic validation to avoid for loop starting at -inf. if (yMax <= 0) yMax = Math.max(yMin, base) if (yMin <= 0) yMin = Math.min(yMax, base) const logs = [] // Get the logarithmic range. const logMax = Math.log(yMax) / Math.log(base) const logMin = Math.log(yMin) / Math.log(base) // Get the exact logarithmic range. // (This is the exact number of multiples of the base there are between yMin and yMax). const logRange = logMax - logMin // Round the logarithmic range to get the number of ticks we will create. // If the chosen min/max values are multiples of each other WRT the base, this will be neat. // If the chosen min/max aren't, we will at least still provide USEFUL ticks. const ticks = Math.round(logRange) // Get the logarithmic spacing between ticks. const logTickSpacing = logRange / ticks // Create as many ticks as there is range in the logs. for ( let i = 0, logTick = logMin; i < ticks; i++, logTick += logTickSpacing ) { logs.push(Math.pow(base, logTick)) } // Add a final tick at the yMax. logs.push(Math.pow(base, logMax)) return { result: logs, niceMin: yMin, niceMax: yMax, } } _adjustTicksForSmallRange(ticks, index, range) { let newTicks = ticks if ( typeof index !== 'undefined' && this.w.config.yaxis[index].labels.formatter && this.w.config.yaxis[index].tickAmount === undefined ) { const formattedVal = Number( this.w.config.yaxis[index].labels.formatter(1) ) if (Utils.isNumber(formattedVal) && this.w.globals.yValueDecimal === 0) { newTicks = Math.ceil(range) } } return newTicks < ticks ? newTicks : ticks } setYScaleForIndex(index, minY, maxY) { const gl = this.w.globals const cnf = this.w.config let y = gl.isBarHorizontal ? cnf.xaxis : cnf.yaxis[index] if (typeof gl.yAxisScale[index] === 'undefined') { gl.yAxisScale[index] = [] } let range = Math.abs(maxY - minY) if (y.logarithmic && range <= 5) { gl.invalidLogScale = true } if (y.logarithmic && range > 5) { gl.allSeriesCollapsed = false gl.yAxisScale[index] = y.forceNiceScale ? this.logarithmicScaleNice(minY, maxY, y.logBase) : this.logarithmicScale(minY, maxY, y.logBase) } else { if ( maxY === -Number.MAX_VALUE || !Utils.isNumber(maxY) || minY === Number.MAX_VALUE || !Utils.isNumber(minY) ) { // no data in the chart. // Either all series collapsed or user passed a blank array. // Show the user's yaxis with their scale options but with a range. gl.yAxisScale[index] = this.niceScale(Number.MIN_VALUE, 0, index) } else { // there is some data. Turn off the allSeriesCollapsed flag gl.allSeriesCollapsed = false gl.yAxisScale[index] = this.niceScale(minY, maxY, index) } } } setXScale(minX, maxX) { const w = this.w const gl = w.globals let diff = Math.round(Math.abs(maxX - minX)) if (maxX === -Number.MAX_VALUE || !Utils.isNumber(maxX)) { // no data in the chart. Either all series collapsed or user passed a blank array gl.xAxisScale = this.linearScale(0, 10, 10) } else { let ticks = gl.xTickAmount gl.xAxisScale = this.linearScale( minX, maxX, ticks, 0, w.config.xaxis.stepSize ) } return gl.xAxisScale } scaleMultipleYAxes() { const cnf = this.w.config const gl = this.w.globals this.coreUtils.setSeriesYAxisMappings() let axisSeriesMap = gl.seriesYAxisMap let minYArr = gl.minYArr let maxYArr = gl.maxYArr // Compute min..max for each yaxis gl.allSeriesCollapsed = true gl.barGroups = [] axisSeriesMap.forEach((axisSeries, ai) => { let groupNames = [] axisSeries.forEach((as) => { let group = cnf.series[as]?.group if (groupNames.indexOf(group) < 0) { groupNames.push(group) } }) if (axisSeries.length > 0) { let minY = Number.MAX_VALUE let maxY = -Number.MAX_VALUE let lowestY = minY let highestY = maxY let seriesType let seriesGroupName if (cnf.chart.stacked) { // Series' on this axis with the same group name will be stacked. // Sum series in each group separately let mapSeries = new Array(gl.dataPoints).fill(0) let sumSeries = [] let posSeries = [] let negSeries = [] groupNames.forEach(() => { sumSeries.push(mapSeries.map(() => Number.MIN_VALUE)) posSeries.push(mapSeries.map(() => Number.MIN_VALUE)) negSeries.push(mapSeries.map(() => Number.MIN_VALUE)) }) for (let i = 0; i < axisSeries.length; i++) { // Assume chart type but the first series that has a type overrides. if (!seriesType && cnf.series[axisSeries[i]].type) { seriesType = cnf.series[axisSeries[i]].type } // Sum all series for this yaxis at each corresponding datapoint // For bar and column charts we need to keep positive and negative // values separate, for each group separately. let si = axisSeries[i] if (cnf.series[si].group) { seriesGroupName = cnf.series[si].group } else { seriesGroupName = 'axis-'.concat(ai) } let collapsed = !( gl.collapsedSeriesIndices.indexOf(si) < 0 && gl.ancillaryCollapsedSeriesIndices.indexOf(si) < 0 ) if (!collapsed) { gl.allSeriesCollapsed = false groupNames.forEach((gn, gni) => { // Undefined group names will be grouped together as their own // group. if (cnf.series[si].group === gn) { for (let j = 0; j < gl.series[si].length; j++) { let val = gl.series[si][j] if (val >= 0) { posSeries[gni][j] += val } else { negSeries[gni][j] += val } sumSeries[gni][j] += val // For non bar-like series' we need these point max/min values. lowestY = Math.min(lowestY, val) highestY = Math.max(highestY, val) } } }) } if (seriesType === 'bar' || seriesType === 'column') { gl.barGroups.push(seriesGroupName) } } if (!seriesType) { seriesType = cnf.chart.type } if (seriesType === 'bar' || seriesType === 'column') { groupNames.forEach((gn, gni) => { minY = Math.min(minY, Math.min.apply(null, negSeries[gni])) maxY = Math.max(maxY, Math.max.apply(null, posSeries[gni])) }) } else { groupNames.forEach((gn, gni) => { lowestY = Math.min(lowestY, Math.min.apply(null, sumSeries[gni])) highestY = Math.max( highestY, Math.max.apply(null, sumSeries[gni]) ) }) minY = lowestY maxY = highestY } if (minY === Number.MIN_VALUE && maxY === Number.MIN_VALUE) { // No series data maxY = -Number.MAX_VALUE } } else { for (let i = 0; i < axisSeries.length; i++) { let si = axisSeries[i] minY = Math.min(minY, minYArr[si]) maxY = Math.max(maxY, maxYArr[si]) let collapsed = !( gl.collapsedSeriesIndices.indexOf(si) < 0 && gl.ancillaryCollapsedSeriesIndices.indexOf(si) < 0 ) if (!collapsed) { gl.allSeriesCollapsed = false } } } if (cnf.yaxis[ai].min !== undefined) { if (typeof cnf.yaxis[ai].min === 'function') { minY = cnf.yaxis[ai].min(minY) } else { minY = cnf.yaxis[ai].min } } if (cnf.yaxis[ai].max !== undefined) { if (typeof cnf.yaxis[ai].max === 'function') { maxY = cnf.yaxis[ai].max(maxY) } else { maxY = cnf.yaxis[ai].max } } gl.barGroups = gl.barGroups.filter((v, i, a) => a.indexOf(v) === i) // Set the scale for this yaxis this.setYScaleForIndex(ai, minY, maxY) // Set individual series min and max to nice values axisSeries.forEach((si) => { minYArr[si] = gl.yAxisScale[ai].niceMin maxYArr[si] = gl.yAxisScale[ai].niceMax }) } else { // No series referenced by this yaxis this.setYScaleForIndex(ai, 0, -Number.MAX_VALUE) } }) } }