Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Time scale improvements #3914

Merged
merged 6 commits into from
Apr 2, 2017
Merged

Conversation

tredston
Copy link
Contributor

@tredston tredston commented Feb 15, 2017

Complete work started in https://github.com/chartjs/Chart.js/tree/time-scale-improvements (#3673) to convert scale.time to internally use unix timestamps instead of moments. Improves performance and reduces complexity.

@tredston tredston force-pushed the time-scale-improvements branch from 0c82865 to ccbdaf7 Compare February 15, 2017 18:39
@tredston tredston changed the title Time scale improvements [WIP] Time scale improvements Feb 15, 2017
@tredston tredston force-pushed the time-scale-improvements branch from ccbdaf7 to dfefaa3 Compare February 15, 2017 18:50
@tredston tredston changed the title [WIP] Time scale improvements Time scale improvements Feb 15, 2017
@etimberg
Copy link
Member

Thanks @tredston I will look at reviewing this. In the meantime, could you rebase against master and squash into some smaller commits?

Copy link
Member

@etimberg etimberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this is a great start. Thank you for getting this to PR quality 😄

I had a few comments, questions, etc and some minor refactoring that I suggested in the comments. Please take a look and we can discuss if you have questions or concerns

var units = Object.keys(time);
var unit;

for (var i = units.indexOf(minUnit); i < units.length; i++) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would a while loop be slightly more clear here?

while (i < units.length && !fits)

or even maybe a do-while since we always want to loop to run at least once and we can make it clear.

do {

} while (i < units.length && !fits);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to use a for loop when doing simple array iteration, I think having the start index, end conditions and stride all together is clearer than spreading them out.

The fits variable would also need to be hoisted out of the loop, in which case we could write:

for (var i = units.indexOf(minUnit); i < units.length && !fits; i++)

instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we could leave this as is. I had just wondered if we could make it a bit simpler but it's already straightforward

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK 👍

name: 'month',
},
month: {
size: 2.628e9,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One question that came up from when I first worked on this ... what do we do for months where this is not true? ie, months like February that have less days.

Copy link
Contributor Author

@tredston tredston Feb 16, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These sizes are used to estimate the unit and step size to use, they aren't used for calculating the tick locations. The step size is a multiple of the unit, so a unit of month and stepSize of 3 give ticks which are 3 months apart. The labels are still rounded (by moment) to the start of the unit so they display correctly.

See e5cd916 for details.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok, great 👍

var stepSize = generationOptions.stepSize;
ticks.push(generationOptions.min !== undefined ? generationOptions.min : niceRange.min);
var cur = moment(niceRange.min);
while (cur.add(stepSize, generationOptions.unit).valueOf() < niceRange.max) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this still need to use moment? I thought once everything was timestamps this wasn't needed? Or does this make things a lot simpler overall?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This simplifies units which don't have a constant number of milliseconds in them eg months, years.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, makes sense 😄

var niceMin;
var niceMax;
var isoWeekday = generationOptions.isoWeekday;
if (isoWeekday !== false) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this option only applies if generationOptions.unit is 'week' otherwise it could be transforming data that is in seconds, or minutes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, will update to only apply when generationOptions.unit is week.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

getLabelCapacity: function(exampleTime) {
var me = this;

me.displayFormat = me.options.time.displayFormats.millisecond; // Pick the longest format for guestimation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be nice if we didn't have to set this on ourself .. could we refactor to pass this into tickFormatFunction instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that convertTicksToLabels uses tickFormatFunction as the callback for a map so we can't really change tickFormatFunction's message signature to include the display format.

// scale.buildTicks();
scale.update(400, 50);
var scale = createScale(mockData, config);
scale.update(5000, 200);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be this wide?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It just needed to be wide enough to not be in danger of having ticks dropped for overlapping. Looking again this can be revised down to about 2500 before the last two ticks might overlap.

Alternatively we can have a narrower graph and update the expect to remove the penultimate tick.

// scale.buildTicks();
scale.update(400, 50);
var scale = createScale(mockData, config);
scale.update(800, 200);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment here about the width

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above

});

it('Should use the isoWeekday option', function() {
var scaleID = 'myScale';
it('should not have overlapping ticks', function() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason there are two tests with the same name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One (in describe('when specifying limits'... suite) covers the case when specifying a time.max while the other covers the general case. Since they are in separate suites jasmine will print different messages if/when they break.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, just wanted to make sure this wasn't an error 😄

}
});
var scale = chart.scales.xScale0;
scale.update(24633, 160);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like a giant width :/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is essentially a regression test, the code was lifted from the example in #2249.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, ok. that makes sense

expect(xScale.getPixelForValue('', 0, 0)).toBeCloseToPixel(71);
expect(xScale.getPixelForValue('', 6, 0)).toBeCloseToPixel(452);
expect(xScale.getPixelForValue('2015-01-01T20:00:00')).toBeCloseToPixel(71);
it('should be bounded by midnights', function() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Is the goal of this test to ensure that that when days are displayed, the last tick is at the end of the last day and similarly for the first tick?

After reading the several years ones, I think a better title might be "should be bounded by the nearest day beginnings" since I was a little unclear on what this meant at first.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's the goal.

That title is much clearer, I will update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

@tredston tredston force-pushed the time-scale-improvements branch 2 times, most recently from bf77bb6 to 75feb75 Compare February 16, 2017 00:37
@etimberg
Copy link
Member

Tried these out and they work well. It seems really fast now 😄 I did notice that the add buttons on https://github.com/chartjs/Chart.js/blob/master/samples/scales/time/line-time-point-data.html don't work. This was likely broken when I started on this not by your changes afterwards. Do you mind fixing it while you're at it?

@tredston tredston force-pushed the time-scale-improvements branch 2 times, most recently from 7190f04 to 8a55cce Compare February 17, 2017 00:18
@tredston
Copy link
Contributor Author

Squashed to fewer commits and rebased onto master.

Updated the code following discussions thus far.

Updated the sample as requested.

Copy link
Member

@etimberg etimberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @tredston

@etimberg etimberg added this to the Version 2.6 milestone Feb 17, 2017
Copy link
Member

@simonbrunel simonbrunel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @tredston :)

I'm not familiar with the time scale, so can't really review the overall logic, so just reviewed the code style.

That's a big PR, I didn't go in details so to sum up:

  • gather local variables in function header (one per line, except for uninitialized variables)
  • cache object property and array value when used more than 2 or 3 times
  • cache for loop end condition when object property or array value
  • use shorter names when context is obvious

I didn't review unit test!

@@ -119,7 +119,7 @@

document.getElementById('addData').addEventListener('click', function() {
if (config.data.datasets.length > 0) {
var lastTime = myLine.scales['x-axis-0'].labelMoments[0].length ? myLine.scales['x-axis-0'].labelMoments[0][myLine.scales['x-axis-0'].labelMoments[0].length - 1] : moment();
var lastTime = myLine.scales['x-axis-0'].ticksAsTimestamps.length ? moment(myLine.scales['x-axis-0'].ticksAsTimestamps[myLine.scales['x-axis-0'].ticksAsTimestamps.length - 1]) : moment();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be more readable by caching myLine.scales['x-axis-0'].ticksAsTimestamps

var timestamps = myLine.scales['x-axis-0'].ticksAsTimestamps;
var lastTime = timestamps.length? moment(timestamps[timestamps.length  - 1]) : moment();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -61,253 +69,288 @@ module.exports = function(Chart) {
month: 'MMM YYYY', // Sept 2015
quarter: '[Q]Q - YYYY', // Q3
year: 'YYYY' // 2015
}
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why here and not at line 46?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because there were multiple authors and linting (comma-dangle) allows it :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course, I mean you modified both lines, but only added comma on the second one. Anyway, that's not important!

// Custom parsing (return an instance of moment)
console.warn('options.time.format is deprecated and replaced by options.time.parser. See http://nnnick.github.io/Chart.js/docs-v2/#scales-time-scale');
return timeOpts.format(label);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for all these else since you return for all if:

if (foo) {
    return 'foo';
}
if (bar) {
    return 'bar';
}
return false;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

} else if (label.isValid && label.isValid()) {
// Moment support
return label;
} else if (typeof timeOpts.format !== 'string' && timeOpts.format.call) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you cache timeOpts.format?

var format = timeOpts.format;
if (typeof format !== 'string' && format.call) {
    return format(label);
}
return moment(label, format);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return label;
} else if (typeof timeOpts.format !== 'string' && timeOpts.format.call) {
// Custom parsing (return an instance of moment)
console.warn('options.time.format is deprecated and replaced by options.time.parser. See http://nnnick.github.io/Chart.js/docs-v2/#scales-time-scale');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We try to reduce library size, so I would avoid to output the URL (which is quite outdated)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

* @return {Number[]} ticks
*/
Chart.Ticks.generators.time = function(generationOptions, dataRange) {
var niceMin;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

var isoWeekday = generationOptions.isoWeekday;
var niceMin, niceMax;  //< uninitialized variables on the same line

and generationOptions -> options

}
var timeGeneratorOptions = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for this temporary variable:

var ticks = me.ticks = Chart.Ticks.generators.time({
    maxTicks: maxTicks,
    min: minTimestamp,
    max: maxTimestamp,
    stepSize: stepSize,
    unit: unit,
    isoWeekday: timeOpts.isoWeekday
}, {
    min: dataMin,
    max: dataMax
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return moment(label, me.options.time.format);

var tickLabelWidth = me.ctx.measureText(label).width;
var cosRotation = Math.cos(helpers.toRadians(me.options.ticks.maxRotation));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you cache var ticksOptions = me.options.ticks?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -8,34 +8,42 @@ module.exports = function(Chart) {

var helpers = Chart.helpers;
var time = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This variable name could be a bit more explicit, time is quite a common term in this file :)

var me = this;
var epochWidth = me.max - me.min;
var decimal = 0;
if (epochWidth > 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

var decimal = epochWidth? (offset - me.min) / epochWidth : 0;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@tredston tredston force-pushed the time-scale-improvements branch 5 times, most recently from b10b545 to 2f0e1cb Compare February 27, 2017 13:33
@tredston
Copy link
Contributor Author

tredston commented Feb 27, 2017

Thanks for the review @simonbrunel.

I've implemented the requested changes around caching local variables and logic simplification.

However, I'm not keen to implement:

gather local variables in function header (one per line, except for uninitialized variables)

beacuse:

  1. there are linting rules for this (one-var and vars-on-top) which are explicitly disabled.
  2. splitting up declaration and assignment makes the code more difficult to follow, as well as more difficult to migrate to using const when/if ever you adopt ES6.
  3. it makes diffing harder, eg from
var i, j;

to

var i = 0;
var j;
  1. it only saves 63 bytes when minified, or 0.04% of Chart.min.js, which I don't think outweighs the above.

@simonbrunel
Copy link
Member

Thanks @tredston for the changes,

  1. one-var and vars-on-top are not enforced because we changed linting rules after years of development and it would have required to much change (that's why it's not applied everywhere).

  2. that's a point, we still use var not const and IMO it's safer to have var declared at the top of the scope it's introduced.

  3. not sure I follow that point.

  4. and from a global perspective? I agree that there is room for refactor all around the code, so it may sound useless but actually it makes a small appreciable difference when minified.

  5. kind related: IIRC in QML JavaScript engine, declaring a var inside a loop is (was?) a perf killer, so still better to have it declared outside.

Anyway, that's minor, we can still rework that later, too bad that you removed your last commit implementing these changes ;)

@hmcfletch
Copy link

I pulled this branch to use on my own site because I was having the overlapping tick issue with some of my time scales. It seems that instead of picking better start and end values to allow for evenly placed ticks, it simply drops what would have been the second to last tick. The makes for a much larger gap between the last two ticks on the axis when displayed.

It is definitely better than the current situation, but there is still an underlying issue that needs to be resolved.

@etimberg
Copy link
Member

@hmcfletch is it possible to drop a test case here that uses this branch?

@hmcfletch
Copy link

Working on a deploy today, but i'll see what i can do.

@etimberg
Copy link
Member

etimberg commented Mar 8, 2017

@hmcfletch were you able to make any progress on a test case for the issue you were seeing?

@hmcfletch
Copy link

Here is the data I am working with:

Screenshot of Chart
https://www.evernote.com/shard/s63/sh/f8cf9342-d934-43e8-820d-6bc4d49cf862/9edfdb6a5428fbcb

Chart Data

{
   "labels":[
      "2017-02-03T00:00:00.000Z",
      "2017-02-04T00:00:00.000Z",
      "2017-02-06T00:00:00.000Z",
      "2017-02-07T00:00:00.000Z",
      "2017-02-08T00:00:00.000Z",
      "2017-02-09T00:00:00.000Z",
      "2017-02-10T00:00:00.000Z",
      "2017-02-11T00:00:00.000Z",
      "2017-02-13T00:00:00.000Z",
      "2017-02-14T00:00:00.000Z",
      "2017-02-15T00:00:00.000Z",
      "2017-02-16T00:00:00.000Z",
      "2017-02-17T00:00:00.000Z",
      "2017-02-19T00:00:00.000Z",
      "2017-02-20T00:00:00.000Z",
      "2017-02-21T00:00:00.000Z",
      "2017-02-22T00:00:00.000Z",
      "2017-02-23T00:00:00.000Z",
      "2017-02-24T00:00:00.000Z",
      "2017-02-26T00:00:00.000Z",
      "2017-02-27T00:00:00.000Z",
      "2017-02-28T00:00:00.000Z",
      "2017-03-01T00:00:00.000Z"
   ],
   "datasets":[
      {
         "fill":false,
         "borderWidth":2,
         "borderColor":"rgba(145, 226, 254, 1)",
         "pointBackgroundColor":"rgba(37, 198, 254, 1)",
         "pointHoverBackgroundColor":"rgba(0, 237, 164, 1)",
         "data":[
            {
               "x":"2017-02-02",
               "y":168244
            },
            {
               "x":"2017-02-03",
               "y":168306
            },
            {
               "x":"2017-02-05",
               "y":168353
            },
            {
               "x":"2017-02-06",
               "y":168432
            },
            {
               "x":"2017-02-07",
               "y":168476
            },
            {
               "x":"2017-02-08",
               "y":168518
            },
            {
               "x":"2017-02-09",
               "y":168596
            },
            {
               "x":"2017-02-10",
               "y":168650
            },
            {
               "x":"2017-02-12",
               "y":168727
            },
            {
               "x":"2017-02-13",
               "y":168780
            },
            {
               "x":"2017-02-14",
               "y":168820
            },
            {
               "x":"2017-02-15",
               "y":168860
            },
            {
               "x":"2017-02-16",
               "y":168926
            },
            {
               "x":"2017-02-18",
               "y":168951
            },
            {
               "x":"2017-02-19",
               "y":169032
            },
            {
               "x":"2017-02-20",
               "y":169078
            },
            {
               "x":"2017-02-21",
               "y":169125
            },
            {
               "x":"2017-02-22",
               "y":169155
            },
            {
               "x":"2017-02-23",
               "y":169193
            },
            {
               "x":"2017-02-25",
               "y":169277
            },
            {
               "x":"2017-02-26",
               "y":169300
            },
            {
               "x":"2017-02-27",
               "y":169330
            },
            {
               "x":"2017-02-28",
               "y":169344
            }
         ]
      }
   ]
}

Chart Options

{
  "responsive": true,
  "legend": {
    "display": false
  },
  "tooltips": {
    "callbacks": {},
    "displayColors": false
  },
  "scales": {
    "yAxes": [
      {
        "type": "linear",
        "ticks": {
          "beginAtZero": false,
          "fontColor": "#7b8994"
        }
      }
    ],
    "xAxes": [
      {
        "ticks": {
          "minRotation": 45,
          "fontColor": "#7b8994"
        },
        "type": "time",
        "time": {
          "format": "YYYY-MM-DD",
          "tooltipFormat": "ll HH:mm"
        }
      }
    ]
  }
}

@etimberg
Copy link
Member

@tredston @hmcfletch the behaviour you're seeing is happening due to https://github.com/tredston/Chart.js/blob/time-scale-improvements/src/scales/scale.time.js#L185-L189

I can't recall what that was originally designed to achieve. I think it was something to always ensure the last tick is present if the size of the axis is not a multiple of the step size.

Options I see:

  1. leave it as is (regression from current behaviour)
  2. Always push realMax if it is not the last value in the array (don't want any duplicate tick marks).

I personally prefer option 2. @simonbrunel thoughts? @tredston could you make those changes?

@etimberg
Copy link
Member

etimberg commented Mar 14, 2017

I tried changing that if to:

if (ticks[ticks.length - 1] !== realMax) {
    ticks.push(realMax);
}

and it matched v2.5

@etimberg
Copy link
Member

@tredston one thing to ask as well when you're making that last change (the one in my previous comment). If you can rebase on master to fix the test file conflict this will be good to merge 😄

etimberg and others added 5 commits April 1, 2017 18:04
Months, quarters and years have non-constant numbers of seconds. A scale that's linear WRT milliseconds produces incorrect tick labels due to the label formatting losing precision (eg year labels lose month and day so a label of 2016-12-32 displays as 2016 instead of 2017).
@tredston tredston force-pushed the time-scale-improvements branch 2 times, most recently from 531ecf3 to 7271e16 Compare April 1, 2017 17:14
@tredston tredston force-pushed the time-scale-improvements branch from 7271e16 to 9e65c41 Compare April 1, 2017 17:25
@tredston
Copy link
Contributor Author

tredston commented Apr 1, 2017

That code was meant to avoid adding the penultimate tick if it was in danger of overlapping with the last tick. Seemingly label rotation was not accounted for.

Requested change applied and rebased onto master.

@etimberg
Copy link
Member

etimberg commented Apr 1, 2017

Thanks @tredston

@simonbrunel shall we merge this?

@simonbrunel
Copy link
Member

Sure!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants