/* * * * (c) 2010-2020 Torstein Honsi * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import Axis from './Axis.js'; import H from './Globals.js'; import U from './Utilities.js'; var addEvent = U.addEvent, css = U.css, defined = U.defined, pick = U.pick, timeUnits = U.timeUnits; import './Chart.js'; // Has a dependency on Navigator due to the use of Axis.toFixedRange import './Navigator.js'; import './Series.js'; var Chart = H.Chart, Series = H.Series; /* eslint-disable valid-jsdoc */ var OrdinalAxisAdditions = /** @class */ (function () { /* * * * Constructors * * */ /** * @private */ function OrdinalAxisAdditions(axis) { this.index = {}; this.axis = axis; } /* * * * Functions * * */ /** * Get the ordinal positions for the entire data set. This is necessary * in chart panning because we need to find out what points or data * groups are available outside the visible range. When a panning * operation starts, if an index for the given grouping does not exists, * it is created and cached. This index is deleted on updated data, so * it will be regenerated the next time a panning operation starts. * * @private */ OrdinalAxisAdditions.prototype.getExtendedPositions = function () { var ordinal = this, axis = ordinal.axis, axisProto = axis.constructor.prototype, chart = axis.chart, grouping = axis.series[0].currentDataGrouping, ordinalIndex = ordinal.index, key = grouping ? grouping.count + grouping.unitName : 'raw', overscroll = axis.options.overscroll, extremes = axis.getExtremes(), fakeAxis, fakeSeries; // If this is the first time, or the ordinal index is deleted by // updatedData, // create it. if (!ordinalIndex) { ordinalIndex = ordinal.index = {}; } if (!ordinalIndex[key]) { // Create a fake axis object where the extended ordinal // positions are emulated fakeAxis = { series: [], chart: chart, getExtremes: function () { return { min: extremes.dataMin, max: extremes.dataMax + overscroll }; }, options: { ordinal: true }, ordinal: {}, ordinal2lin: axisProto.ordinal2lin, val2lin: axisProto.val2lin // #2590 }; fakeAxis.ordinal.axis = fakeAxis; // Add the fake series to hold the full data, then apply // processData to it axis.series.forEach(function (series) { fakeSeries = { xAxis: fakeAxis, xData: series.xData.slice(), chart: chart, destroyGroupedData: H.noop, getProcessedData: H.Series.prototype.getProcessedData }; fakeSeries.xData = fakeSeries.xData.concat(ordinal.getOverscrollPositions()); fakeSeries.options = { dataGrouping: grouping ? { enabled: true, forced: true, // doesn't matter which, use the fastest approximation: 'open', units: [[ grouping.unitName, [grouping.count] ]] } : { enabled: false } }; series.processData.apply(fakeSeries); fakeAxis.series.push(fakeSeries); }); // Run beforeSetTickPositions to compute the ordinalPositions axis.beforeSetTickPositions.apply(fakeAxis); // Cache it ordinalIndex[key] = fakeAxis.ordinal.positions; } return ordinalIndex[key]; }; /** * Find the factor to estimate how wide the plot area would have been if * ordinal gaps were included. This value is used to compute an imagined * plot width in order to establish the data grouping interval. * * A real world case is the intraday-candlestick example. Without this * logic, it would show the correct data grouping when viewing a range * within each day, but once moving the range to include the gap between * two days, the interval would include the cut-away night hours and the * data grouping would be wrong. So the below method tries to compensate * by identifying the most common point interval, in this case days. * * An opposite case is presented in issue #718. We have a long array of * daily data, then one point is appended one hour after the last point. * We expect the data grouping not to change. * * In the future, if we find cases where this estimation doesn't work * optimally, we might need to add a second pass to the data grouping * logic, where we do another run with a greater interval if the number * of data groups is more than a certain fraction of the desired group * count. * * @private */ OrdinalAxisAdditions.prototype.getGroupIntervalFactor = function (xMin, xMax, series) { var ordinal = this, axis = ordinal.axis, i, processedXData = series.processedXData, len = processedXData.length, distances = [], median, groupIntervalFactor = ordinal.groupIntervalFactor; // Only do this computation for the first series, let the other // inherit it (#2416) if (!groupIntervalFactor) { // Register all the distances in an array for (i = 0; i < len - 1; i++) { distances[i] = processedXData[i + 1] - processedXData[i]; } // Sort them and find the median distances.sort(function (a, b) { return a - b; }); median = distances[Math.floor(len / 2)]; // Compensate for series that don't extend through the entire // axis extent. #1675. xMin = Math.max(xMin, processedXData[0]); xMax = Math.min(xMax, processedXData[len - 1]); ordinal.groupIntervalFactor = groupIntervalFactor = (len * median) / (xMax - xMin); } // Return the factor needed for data grouping return groupIntervalFactor; }; /** * Get ticks for an ordinal axis within a range where points don't * exist. It is required when overscroll is enabled. We can't base on * points, because we may not have any, so we use approximated * pointRange and generate these ticks between Axis.dataMax, * Axis.dataMax + Axis.overscroll evenly spaced. Used in panning and * navigator scrolling. * * @private */ OrdinalAxisAdditions.prototype.getOverscrollPositions = function () { var ordinal = this, axis = ordinal.axis, extraRange = axis.options.overscroll, distance = ordinal.overscrollPointsRange, positions = [], max = axis.dataMax; if (defined(distance)) { // Max + pointRange because we need to scroll to the last positions.push(max); while (max <= axis.dataMax + extraRange) { max += distance; positions.push(max); } } return positions; }; /** * Make the tick intervals closer because the ordinal gaps make the * ticks spread out or cluster. * * @private */ OrdinalAxisAdditions.prototype.postProcessTickInterval = function (tickInterval) { // Problem: https://jsfiddle.net/highcharts/FQm4E/1/ // This is a case where this algorithm doesn't work optimally. In // this case, the tick labels are spread out per week, but all the // gaps reside within weeks. So we have a situation where the labels // are courser than the ordinal gaps, and thus the tick interval // should not be altered. var ordinal = this, axis = ordinal.axis, ordinalSlope = ordinal.slope, ret; if (ordinalSlope) { if (!axis.options.breaks) { ret = tickInterval / (ordinalSlope / axis.closestPointRange); } else { ret = axis.closestPointRange || tickInterval; // #7275 } } else { ret = tickInterval; } return ret; }; return OrdinalAxisAdditions; }()); /** * Extends the axis with ordinal support. * * @private */ var OrdinalAxis = /** @class */ (function () { function OrdinalAxis() { } /** * Extends the axis with ordinal support. * * @private * * @param AxisClass * Axis class to extend. * * @param ChartClass * Chart class to use. * * @param SeriesClass * Series class to use. */ OrdinalAxis.compose = function (AxisClass, ChartClass, SeriesClass) { AxisClass.keepProps.push('ordinal'); var axisProto = AxisClass.prototype; /** * Calculate the ordinal positions before tick positions are calculated. * * @private */ axisProto.beforeSetTickPositions = function () { var axis = this, ordinal = axis.ordinal, len, ordinalPositions = [], uniqueOrdinalPositions, useOrdinal = false, dist, extremes = axis.getExtremes(), min = extremes.min, max = extremes.max, minIndex, maxIndex, slope, hasBreaks = axis.isXAxis && !!axis.options.breaks, isOrdinal = axis.options.ordinal, overscrollPointsRange = Number.MAX_VALUE, ignoreHiddenSeries = axis.chart.options.chart.ignoreHiddenSeries, i, hasBoostedSeries; // Apply the ordinal logic if (isOrdinal || hasBreaks) { // #4167 YAxis is never ordinal ? axis.series.forEach(function (series, i) { uniqueOrdinalPositions = []; if ((!ignoreHiddenSeries || series.visible !== false) && (series.takeOrdinalPosition !== false || hasBreaks)) { // concatenate the processed X data into the existing // positions, or the empty array ordinalPositions = ordinalPositions.concat(series.processedXData); len = ordinalPositions.length; // remove duplicates (#1588) ordinalPositions.sort(function (a, b) { // without a custom function it is sorted as strings return a - b; }); overscrollPointsRange = Math.min(overscrollPointsRange, pick( // Check for a single-point series: series.closestPointRange, overscrollPointsRange)); if (len) { i = 0; while (i < len - 1) { if (ordinalPositions[i] !== ordinalPositions[i + 1]) { uniqueOrdinalPositions.push(ordinalPositions[i + 1]); } i++; } // Check first item: if (uniqueOrdinalPositions[0] !== ordinalPositions[0]) { uniqueOrdinalPositions.unshift(ordinalPositions[0]); } ordinalPositions = uniqueOrdinalPositions; } } if (series.isSeriesBoosting) { hasBoostedSeries = true; } }); if (hasBoostedSeries) { ordinalPositions.length = 0; } // cache the length len = ordinalPositions.length; // Check if we really need the overhead of mapping axis data // against the ordinal positions. If the series consist of // evenly spaced data any way, we don't need any ordinal logic. if (len > 2) { // two points have equal distance by default dist = ordinalPositions[1] - ordinalPositions[0]; i = len - 1; while (i-- && !useOrdinal) { if (ordinalPositions[i + 1] - ordinalPositions[i] !== dist) { useOrdinal = true; } } // When zooming in on a week, prevent axis padding for // weekends even though the data within the week is evenly // spaced. if (!axis.options.keepOrdinalPadding && (ordinalPositions[0] - min > dist || max - ordinalPositions[ordinalPositions.length - 1] > dist)) { useOrdinal = true; } } else if (axis.options.overscroll) { if (len === 2) { // Exactly two points, distance for overscroll is fixed: overscrollPointsRange = ordinalPositions[1] - ordinalPositions[0]; } else if (len === 1) { // We have just one point, closest distance is unknown. // Assume then it is last point and overscrolled range: overscrollPointsRange = axis.options.overscroll; ordinalPositions = [ ordinalPositions[0], ordinalPositions[0] + overscrollPointsRange ]; } else { // In case of zooming in on overscrolled range, stick to // the old range: overscrollPointsRange = ordinal.overscrollPointsRange; } } // Record the slope and offset to compute the linear values from // the array index. Since the ordinal positions may exceed the // current range, get the start and end positions within it // (#719, #665b) if (useOrdinal) { if (axis.options.overscroll) { ordinal.overscrollPointsRange = overscrollPointsRange; ordinalPositions = ordinalPositions.concat(ordinal.getOverscrollPositions()); } // Register ordinal.positions = ordinalPositions; // This relies on the ordinalPositions being set. Use // Math.max and Math.min to prevent padding on either sides // of the data. minIndex = axis.ordinal2lin(// #5979 Math.max(min, ordinalPositions[0]), true); maxIndex = Math.max(axis.ordinal2lin(Math.min(max, ordinalPositions[ordinalPositions.length - 1]), true), 1); // #3339 // Set the slope and offset of the values compared to the // indices in the ordinal positions ordinal.slope = slope = (max - min) / (maxIndex - minIndex); ordinal.offset = min - (minIndex * slope); } else { ordinal.overscrollPointsRange = pick(axis.closestPointRange, ordinal.overscrollPointsRange); ordinal.positions = axis.ordinal.slope = ordinal.offset = void 0; } } axis.isOrdinal = isOrdinal && useOrdinal; // #3818, #4196, #4926 ordinal.groupIntervalFactor = null; // reset for next run }; /** * In an ordinal axis, there might be areas with dense consentrations of * points, then large gaps between some. Creating equally distributed * ticks over this entire range may lead to a huge number of ticks that * will later be removed. So instead, break the positions up in * segments, find the tick positions for each segment then concatenize * them. This method is used from both data grouping logic and X axis * tick position logic. * * @private */ AxisClass.prototype.getTimeTicks = function (normalizedInterval, min, max, startOfWeek, positions, closestDistance, findHigherRanks) { if (positions === void 0) { positions = []; } if (closestDistance === void 0) { closestDistance = 0; } var start = 0, end, segmentPositions, higherRanks = {}, hasCrossedHigherRank, info, posLength, outsideMax, groupPositions = [], lastGroupPosition = -Number.MAX_VALUE, tickPixelIntervalOption = this.options.tickPixelInterval, time = this.chart.time, // Record all the start positions of a segment, to use when // deciding what's a gap in the data. segmentStarts = []; // The positions are not always defined, for example for ordinal // positions when data has regular interval (#1557, #2090) if ((!this.options.ordinal && !this.options.breaks) || !positions || positions.length < 3 || typeof min === 'undefined') { return time.getTimeTicks.apply(time, arguments); } // Analyze the positions array to split it into segments on gaps // larger than 5 times the closest distance. The closest distance is // already found at this point, so we reuse that instead of // computing it again. posLength = positions.length; for (end = 0; end < posLength; end++) { outsideMax = end && positions[end - 1] > max; if (positions[end] < min) { // Set the last position before min start = end; } if (end === posLength - 1 || positions[end + 1] - positions[end] > closestDistance * 5 || outsideMax) { // For each segment, calculate the tick positions from the // getTimeTicks utility function. The interval will be the // same regardless of how long the segment is. if (positions[end] > lastGroupPosition) { // #1475 segmentPositions = time.getTimeTicks(normalizedInterval, positions[start], positions[end], startOfWeek); // Prevent duplicate groups, for example for multiple // segments within one larger time frame (#1475) while (segmentPositions.length && segmentPositions[0] <= lastGroupPosition) { segmentPositions.shift(); } if (segmentPositions.length) { lastGroupPosition = segmentPositions[segmentPositions.length - 1]; } segmentStarts.push(groupPositions.length); groupPositions = groupPositions.concat(segmentPositions); } // Set start of next segment start = end + 1; } if (outsideMax) { break; } } // Get the grouping info from the last of the segments. The info is // the same for all segments. info = segmentPositions.info; // Optionally identify ticks with higher rank, for example when the // ticks have crossed midnight. if (findHigherRanks && info.unitRange <= timeUnits.hour) { end = groupPositions.length - 1; // Compare points two by two for (start = 1; start < end; start++) { if (time.dateFormat('%d', groupPositions[start]) !== time.dateFormat('%d', groupPositions[start - 1])) { higherRanks[groupPositions[start]] = 'day'; hasCrossedHigherRank = true; } } // If the complete array has crossed midnight, we want to mark // the first positions also as higher rank if (hasCrossedHigherRank) { higherRanks[groupPositions[0]] = 'day'; } info.higherRanks = higherRanks; } // Save the info info.segmentStarts = segmentStarts; groupPositions.info = info; // Don't show ticks within a gap in the ordinal axis, where the // space between two points is greater than a portion of the tick // pixel interval if (findHigherRanks && defined(tickPixelIntervalOption)) { var length = groupPositions.length, i = length, itemToRemove, translated, translatedArr = [], lastTranslated, medianDistance, distance, distances = []; // Find median pixel distance in order to keep a reasonably even // distance between ticks (#748) while (i--) { translated = this.translate(groupPositions[i]); if (lastTranslated) { distances[i] = lastTranslated - translated; } translatedArr[i] = lastTranslated = translated; } distances.sort(); medianDistance = distances[Math.floor(distances.length / 2)]; if (medianDistance < tickPixelIntervalOption * 0.6) { medianDistance = null; } // Now loop over again and remove ticks where needed i = groupPositions[length - 1] > max ? length - 1 : length; // #817 lastTranslated = void 0; while (i--) { translated = translatedArr[i]; distance = Math.abs(lastTranslated - translated); // #4175 - when axis is reversed, the distance, is negative // but tickPixelIntervalOption positive, so we need to // compare the same values // Remove ticks that are closer than 0.6 times the pixel // interval from the one to the right, but not if it is // close to the median distance (#748). if (lastTranslated && distance < tickPixelIntervalOption * 0.8 && (medianDistance === null || distance < medianDistance * 0.8)) { // Is this a higher ranked position with a normal // position to the right? if (higherRanks[groupPositions[i]] && !higherRanks[groupPositions[i + 1]]) { // Yes: remove the lower ranked neighbour to the // right itemToRemove = i + 1; lastTranslated = translated; // #709 } else { // No: remove this one itemToRemove = i; } groupPositions.splice(itemToRemove, 1); } else { lastTranslated = translated; } } } return groupPositions; }; /** * Translate from linear (internal) to axis value. * * @private * @function Highcharts.Axis#lin2val * * @param {number} val * The linear abstracted value. * * @param {boolean} [fromIndex] * Translate from an index in the ordinal positions rather than a * value. * * @return {number} */ axisProto.lin2val = function (val, fromIndex) { var axis = this, ordinal = axis.ordinal, ordinalPositions = ordinal.positions, ret; // the visible range contains only equally spaced values if (!ordinalPositions) { ret = val; } else { var ordinalSlope = ordinal.slope, ordinalOffset = ordinal.offset, i = ordinalPositions.length - 1, linearEquivalentLeft, linearEquivalentRight, distance; // Handle the case where we translate from the index directly, // used only when panning an ordinal axis if (fromIndex) { if (val < 0) { // out of range, in effect panning to the left val = ordinalPositions[0]; } else if (val > i) { // out of range, panning to the right val = ordinalPositions[i]; } else { // split it up i = Math.floor(val); distance = val - i; // the decimal } // Loop down along the ordinal positions. When the linear // equivalent of i matches an ordinal position, interpolate // between the left and right values. } else { while (i--) { linearEquivalentLeft = (ordinalSlope * i) + ordinalOffset; if (val >= linearEquivalentLeft) { linearEquivalentRight = (ordinalSlope * (i + 1)) + ordinalOffset; // something between 0 and 1 distance = (val - linearEquivalentLeft) / (linearEquivalentRight - linearEquivalentLeft); break; } } } // If the index is within the range of the ordinal positions, // return the associated or interpolated value. If not, just // return the value. return (typeof distance !== 'undefined' && typeof ordinalPositions[i] !== 'undefined' ? ordinalPositions[i] + (distance ? distance * (ordinalPositions[i + 1] - ordinalPositions[i]) : 0) : val); } return ret; }; /** * Translate from a linear axis value to the corresponding ordinal axis * position. If there are no gaps in the ordinal axis this will be the * same. The translated value is the value that the point would have if * the axis were linear, using the same min and max. * * @private * @function Highcharts.Axis#val2lin * * @param {number} val * The axis value. * * @param {boolean} [toIndex] * Whether to return the index in the ordinalPositions or the new value. * * @return {number} */ axisProto.val2lin = function (val, toIndex) { var axis = this, ordinal = axis.ordinal, ordinalPositions = ordinal.positions, ret; if (!ordinalPositions) { ret = val; } else { var ordinalLength = ordinalPositions.length, i, distance, ordinalIndex; // first look for an exact match in the ordinalpositions array i = ordinalLength; while (i--) { if (ordinalPositions[i] === val) { ordinalIndex = i; break; } } // if that failed, find the intermediate position between the // two nearest values i = ordinalLength - 1; while (i--) { if (val > ordinalPositions[i] || i === 0) { // interpolate // something between 0 and 1 distance = (val - ordinalPositions[i]) / (ordinalPositions[i + 1] - ordinalPositions[i]); ordinalIndex = i + distance; break; } } ret = toIndex ? ordinalIndex : ordinal.slope * (ordinalIndex || 0) + ordinal.offset; } return ret; }; // Record this to prevent overwriting by broken-axis module (#5979) axisProto.ordinal2lin = axisProto.val2lin; /* eslint-disable no-invalid-this */ addEvent(AxisClass, 'afterInit', function () { var axis = this; if (!axis.ordinal) { axis.ordinal = new OrdinalAxisAdditions(axis); } }); addEvent(AxisClass, 'foundExtremes', function () { var axis = this; if (axis.isXAxis && defined(axis.options.overscroll) && axis.max === axis.dataMax && ( // Panning is an execption. We don't want to apply // overscroll when panning over the dataMax !axis.chart.mouseIsDown || axis.isInternal) && ( // Scrollbar buttons are the other execption: !axis.eventArgs || axis.eventArgs && axis.eventArgs.trigger !== 'navigator')) { axis.max += axis.options.overscroll; // Live data and buttons require translation for the min: if (!axis.isInternal && defined(axis.userMin)) { axis.min += axis.options.overscroll; } } }); // For ordinal axis, that loads data async, redraw axis after data is // loaded. If we don't do that, axis will have the same extremes as // previously, but ordinal positions won't be calculated. See #10290 addEvent(AxisClass, 'afterSetScale', function () { var axis = this; if (axis.horiz && !axis.isDirty) { axis.isDirty = axis.isOrdinal && axis.chart.navigator && !axis.chart.navigator.adaptToUpdatedData; } }); // Extending the Chart.pan method for ordinal axes addEvent(ChartClass, 'pan', function (e) { var chart = this, xAxis = chart.xAxis[0], overscroll = xAxis.options.overscroll, chartX = e.originalEvent.chartX, panning = chart.options.chart && chart.options.chart.panning, runBase = false; if (panning && panning.type !== 'y' && xAxis.options.ordinal && xAxis.series.length) { var mouseDownX = chart.mouseDownX, extremes = xAxis.getExtremes(), dataMax = extremes.dataMax, min = extremes.min, max = extremes.max, trimmedRange, hoverPoints = chart.hoverPoints, closestPointRange = (xAxis.closestPointRange || (xAxis.ordinal && xAxis.ordinal.overscrollPointsRange)), pointPixelWidth = (xAxis.translationSlope * (xAxis.ordinal.slope || closestPointRange)), // how many ordinal units did we move? movedUnits = (mouseDownX - chartX) / pointPixelWidth, // get index of all the chart's points extendedAxis = { ordinal: { positions: xAxis.ordinal.getExtendedPositions() } }, ordinalPositions, searchAxisLeft, lin2val = xAxis.lin2val, val2lin = xAxis.val2lin, searchAxisRight; // we have an ordinal axis, but the data is equally spaced if (!extendedAxis.ordinal.positions) { runBase = true; } else if (Math.abs(movedUnits) > 1) { // Remove active points for shared tooltip if (hoverPoints) { hoverPoints.forEach(function (point) { point.setState(); }); } if (movedUnits < 0) { searchAxisLeft = extendedAxis; searchAxisRight = xAxis.ordinal.positions ? xAxis : extendedAxis; } else { searchAxisLeft = xAxis.ordinal.positions ? xAxis : extendedAxis; searchAxisRight = extendedAxis; } // In grouped data series, the last ordinal position // represents the grouped data, which is to the left of the // real data max. If we don't compensate for this, we will // be allowed to pan grouped data series passed the right of // the plot area. ordinalPositions = searchAxisRight.ordinal.positions; if (dataMax > ordinalPositions[ordinalPositions.length - 1]) { ordinalPositions.push(dataMax); } // Get the new min and max values by getting the ordinal // index for the current extreme, then add the moved units // and translate back to values. This happens on the // extended ordinal positions if the new position is out of // range, else it happens on the current x axis which is // smaller and faster. chart.fixedRange = max - min; trimmedRange = xAxis.navigatorAxis.toFixedRange(null, null, lin2val.apply(searchAxisLeft, [ val2lin.apply(searchAxisLeft, [min, true]) + movedUnits, true // translate from index ]), lin2val.apply(searchAxisRight, [ val2lin.apply(searchAxisRight, [max, true]) + movedUnits, true // translate from index ])); // Apply it if it is within the available data range if (trimmedRange.min >= Math.min(extremes.dataMin, min) && trimmedRange.max <= Math.max(dataMax, max) + overscroll) { xAxis.setExtremes(trimmedRange.min, trimmedRange.max, true, false, { trigger: 'pan' }); } chart.mouseDownX = chartX; // set new reference for next run css(chart.container, { cursor: 'move' }); } } else { runBase = true; } // revert to the linear chart.pan version if (runBase || (panning && /y/.test(panning.type))) { if (overscroll) { xAxis.max = xAxis.dataMax + overscroll; } } else { e.preventDefault(); } }); addEvent(SeriesClass, 'updatedData', function () { var xAxis = this.xAxis; // Destroy the extended ordinal index on updated data if (xAxis && xAxis.options.ordinal) { delete xAxis.ordinal.index; } }); /* eslint-enable no-invalid-this */ }; return OrdinalAxis; }()); OrdinalAxis.compose(Axis, Chart, Series); // @todo move to StockChart, remove from master export default OrdinalAxis;