From a8151b5137e983d388a4fc760131abb622825a9b Mon Sep 17 00:00:00 2001 From: Simon Brunel Date: Sat, 15 Jul 2017 11:43:06 +0200 Subject: [PATCH] New time scale `ticks.mode/.source` options `ticks.source` (`'auto'`(default)|`'labels'`): `auto` generates "optimal" ticks based on min, max and a few more options (current `time` implementation`). `labels` generates ticks from the user given `data.labels` values (two additional trailing and leading ticks can be added if min and max are provided). `ticks.mode` (`'series'`(default)|`'linear'`): `series` displays ticks at the same distance from each other, whatever the time value they represent, while `linear` displays them linearly in time: the distance between each tick represent the amount of time between their time values. --- src/helpers/helpers.time.js | 4 +- src/scales/scale.time.js | 453 ++++++++++++++++++++------------- test/specs/scale.time.tests.js | 5 +- 3 files changed, 290 insertions(+), 172 deletions(-) diff --git a/src/helpers/helpers.time.js b/src/helpers/helpers.time.js index f01dfaecd40..b667df54c8a 100644 --- a/src/helpers/helpers.time.js +++ b/src/helpers/helpers.time.js @@ -3,6 +3,8 @@ var moment = require('moment'); moment = typeof(moment) === 'function' ? moment : window.moment; +var helpers = require('./helpers.core'); + var interval = { millisecond: { size: 1, @@ -53,7 +55,7 @@ function generateTicksNiceRange(options, dataRange, niceRange) { var ticks = []; if (options.maxTicks) { var stepSize = options.stepSize; - var startTick = options.min !== undefined ? options.min : niceRange.min; + var startTick = helpers.isNullOrUndef(options.min)? niceRange.min : options.min; var majorUnit = options.majorUnit; var majorUnitStart = majorUnit ? moment(startTick).add(1, majorUnit).startOf(majorUnit) : startTick; var startRange = majorUnitStart.valueOf() - startTick; diff --git a/src/scales/scale.time.js b/src/scales/scale.time.js index fab5ccd6416..34ec6ef2d4a 100644 --- a/src/scales/scale.time.js +++ b/src/scales/scale.time.js @@ -10,6 +10,10 @@ module.exports = function(Chart) { var timeHelpers = helpers.time; + // Integer constants are from the ES6 spec. + var MIN_INTEGER = Number.MIN_SAFE_INTEGER || -9007199254740991; + var MAX_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991; + var defaultConfig = { position: 'bottom', @@ -36,10 +40,94 @@ module.exports = function(Chart) { }, }, ticks: { - autoSkip: false + autoSkip: false, + mode: 'series', // 'linear|series' + source: 'auto' // 'auto|labels' } }; + function buildLookupTable(ticks, min, max, linear) { + var ilen = ticks.length; + var table = []; + var i, prev, curr, next, decimal; + + if (ilen === 0) { + return table; + } + + if (ticks[0] !== min) { + table.push({time: min, decimal: 0}); + } + + for (i=0, ilen=ticks.length; i 1? i/(ilen-1) : 0; + table.push({time: curr, decimal: decimal}); + } + } + + if (ticks[ilen-1] !== max) { + table.push({time: max, decimal: 1}); + } + + return table; + } + + // @see adapted from http://www.anujgakhar.com/2014/03/01/binary-search-in-javascript/ + function lookup(table, key, value) { + var lo = 0; + var hi = table.length -1; + var mid, i0, i1; + + while (lo >= 0 && lo <= hi) { + mid = ((lo + hi) >> 1); + i0 = table[mid-1] || null; + i1 = table[mid]; + + if (!i0) { + // given value is outside table (before first item) + return {lo: null, hi: i1}; + } else if (i1[key] < value) { + lo = mid + 1; + } else if (i0[key] > value) { + hi = mid -1; + } else { + return {lo: i0, hi: i1}; + } + } + + // given value is outside table (after last item) + return {lo: i1, hi: null}; + } + + function parse(input, scale) { + if (helpers.isNullOrUndef(input)) { + return null; + } + + var round = scale.options.time.round; + var value = scale.getRightValue(input); + var time = value.isValid? value : timeHelpers.parseTime(scale, value); + if (!time || !time.isValid()) { + return null; + } + + if (round) { + time.startOf(round); + } + + return time.valueOf(); + } + + function sorter(a, b) { + return a - b; + } + var TimeScale = Chart.Scale.extend({ initialize: function() { if (!moment) { @@ -50,246 +138,271 @@ module.exports = function(Chart) { Chart.Scale.prototype.initialize.call(this); }, + determineDataLimits: function() { var me = this; - var timeOpts = me.options.time; - - // We store the data range as unix millisecond timestamps so dataMin and dataMax will always be integers. - // Integer constants are from the ES6 spec. - var dataMin = Number.MAX_SAFE_INTEGER || 9007199254740991; - var dataMax = Number.MIN_SAFE_INTEGER || -9007199254740991; - - var chartData = me.chart.data; - var parsedData = { - labels: [], - datasets: [] - }; - - var timestamp; - - helpers.each(chartData.labels, function(label, labelIndex) { - var labelMoment = timeHelpers.parseTime(me, label); - - if (labelMoment.isValid()) { - // We need to round the time - if (timeOpts.round) { - labelMoment.startOf(timeOpts.round); - } - - timestamp = labelMoment.valueOf(); - dataMin = Math.min(timestamp, dataMin); - dataMax = Math.max(timestamp, dataMax); - - // Store this value for later - parsedData.labels[labelIndex] = timestamp; - } - }); - - helpers.each(chartData.datasets, function(dataset, datasetIndex) { - var timestamps = []; + var chart = me.chart; + var options = me.options; + var datasets = chart.data.datasets || []; + var min = MAX_INTEGER; + var max = MIN_INTEGER; + var timestamps = []; + var labels = []; + var i, j, ilen, jlen, data, timestamp; + + // Convert labels to timestamps + for (i=0, ilen=chart.data.labels.length; i= 0 && index < this.ticks.length? + this.getPixelForOffset(this.ticks[index].time) : + null; }, + getValueForPixel: function(pixel) { var me = this; - var innerDimension = me.isHorizontal() ? me.width : me.height; - var offset = (pixel - (me.isHorizontal() ? me.left : me.top)) / innerDimension; - return moment(me.min + (offset * (me.max - me.min))); + var model = me._model; + var table = model.table; + var size = model.horizontal? me.width : me.height; + var start = model.horizontal? me.left : me.top; + var decimal = size? (pixel - start) / size : 0; + var range = lookup(table, 'decimal', decimal); + + // if pixel is out of bounds, use ticks [0, 1] or [n-1, n] for interpolation, + // note that the lookup table always contains at least 2 items (min and max) + var prev = !range.lo? table[0] : !range.hi? table[table.length-2] : range.lo; + var next = !range.lo? table[1] : !range.hi? table[table.length-1] : range.hi; + + var span = next.decimal - prev.decimal; + var ratio = span? (decimal - prev.decimal) / span : 0; + var offset = (next.time - prev.time) * ratio; + + return moment(prev.time + offset); }, - // Crude approximation of what the label width might be + + /** + * Crude approximation of what the label width might be + * @private + */ getLabelWidth: function(label) { var me = this; - var ticks = me.options.ticks; - + var ticksOpts = me.options.ticks; var tickLabelWidth = me.ctx.measureText(label).width; - var cosRotation = Math.cos(helpers.toRadians(ticks.maxRotation)); - var sinRotation = Math.sin(helpers.toRadians(ticks.maxRotation)); - var tickFontSize = helpers.valueOrDefault(ticks.fontSize, Chart.defaults.global.defaultFontSize); + var angle = helpers.toRadians(ticksOpts.maxRotation); + var cosRotation = Math.cos(angle); + var sinRotation = Math.sin(angle); + var tickFontSize = helpers.valueOrDefault(ticksOpts.fontSize, Chart.defaults.global.defaultFontSize); + return (tickLabelWidth * cosRotation) + (tickFontSize * sinRotation); }, + + /** + * @private + */ getLabelCapacity: function(exampleTime) { var me = this; me.displayFormat = me.options.time.displayFormats.millisecond; // Pick the longest format for guestimation + var exampleLabel = me.tickFormatFunction(moment(exampleTime), 0, []).value; var tickLabelWidth = me.getLabelWidth(exampleLabel); - var innerWidth = me.isHorizontal() ? me.width : me.height; - var labelCapacity = innerWidth / tickLabelWidth; - return labelCapacity; + return innerWidth / tickLabelWidth; } }); - Chart.scaleService.registerScaleType('time', TimeScale, defaultConfig); + Chart.scaleService.registerScaleType('time', TimeScale, defaultConfig); }; diff --git a/test/specs/scale.time.tests.js b/test/specs/scale.time.tests.js index 2b8c50ed969..fe2b3201133 100755 --- a/test/specs/scale.time.tests.js +++ b/test/specs/scale.time.tests.js @@ -83,6 +83,8 @@ describe('Time scale tests', function() { minRotation: 0, maxRotation: 50, mirror: false, + mode: 'series', + source: 'auto', padding: 0, reverse: false, display: true, @@ -417,7 +419,8 @@ describe('Time scale tests', function() { var xScale = chart.scales.xScale0; xScale.update(800, 200); - var step = xScale.ticksAsTimestamps[1] - xScale.ticksAsTimestamps[0]; + + var step = xScale.ticks[1].time - xScale.ticks[0].time; var stepsAmount = Math.floor((xScale.max - xScale.min) / step); it('should be bounded by nearest step year starts', function() {