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 axis ticks formatting for base and senior unit #4268

Merged
merged 16 commits into from
Jun 15, 2017
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/axes/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,14 @@ The tick configuration is nested under the scale configuration in the `ticks` ke
| `fontSize` | `Number` | `12` | Font size for the tick labels.
| `fontStyle` | `String` | `'normal'` | Font style for the tick labels, follows CSS font-style options (i.e. normal, italic, oblique, initial, inherit).
| `reverse` | `Boolean` | `false` | Reverses order of tick labels.

## Major Tick Configuration
The majorTick configuration is nested under the scale configuration in the `majorTicks` key. It defines options for the major tick marks that are generated by the axis. Omitted options are onherited from `ticks` configuration.

| Name | Type | Default | Description
| -----| ---- | --------| -----------
| `callback` | `Function` | | Returns the string representation of the tick value as it should be displayed on the chart. See [callback](../axes/labelling.md#creating-custom-tick-formats).
| `fontColor` | Color | `'#666'` | Font color for tick labels.
| `fontFamily` | `String` | `"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"` | Font family for the tick labels, follows CSS font-family options.
| `fontSize` | `Number` | `12` | Font size for the tick labels.
| `fontStyle` | `String` | `'normal'` | Font style for the tick labels, follows CSS font-style options (i.e. normal, italic, oblique, initial, inherit).
1 change: 0 additions & 1 deletion samples/scales/time/line-point-data.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@
window.onload = function() {
var ctx = document.getElementById("canvas").getContext("2d");
window.myLine = new Chart(ctx, config);

};

document.getElementById('randomizeData').addEventListener('click', function() {
Expand Down
14 changes: 9 additions & 5 deletions src/core/core.scale.js
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ module.exports = function(Chart) {
var context = me.ctx;
var globalDefaults = Chart.defaults.global;
var optionTicks = options.ticks;
var optionMajorTicks = options.majorTicks ? options.majorTicks : optionTicks;
var gridLines = options.gridLines;
var scaleLabel = options.scaleLabel;

Expand All @@ -503,6 +504,8 @@ module.exports = function(Chart) {

var tickFontColor = helpers.getValueOrDefault(optionTicks.fontColor, globalDefaults.defaultFontColor);
var tickFont = parseFontOptions(optionTicks);
var majorTickFontColor = helpers.getValueOrDefault(optionMajorTicks.fontColor, globalDefaults.defaultFontColor);
var majorTickFont = parseFontOptions(optionMajorTicks);

var tl = gridLines.drawTicks ? gridLines.tickMarkLength : 0;

Expand All @@ -513,9 +516,6 @@ module.exports = function(Chart) {
var cosRotation = Math.cos(labelRotationRadians);
var longestRotatedLabel = me.longestLabelWidth * cosRotation;

// Make sure we draw text in the correct color and font
context.fillStyle = tickFontColor;

var itemsToDraw = [];

if (isHorizontal) {
Expand Down Expand Up @@ -547,7 +547,8 @@ module.exports = function(Chart) {
var yTickStart = options.position === 'bottom' ? me.top : me.bottom - tl;
var yTickEnd = options.position === 'bottom' ? me.top + tl : me.bottom;

helpers.each(me.ticks, function(label, index) {
helpers.each(me.ticks, function(tick, index) {
var label = typeof tick === 'object' && typeof tick.value !== 'undefined' ? tick.value : tick;
Copy link
Member

Choose a reason for hiding this comment

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

var label = (tick && tick.value) || tick;

// If the callback returned a null or undefined value, do not draw this line
if (label === undefined || label === null) {
return;
Expand Down Expand Up @@ -645,6 +646,7 @@ module.exports = function(Chart) {
glBorderDashOffset: borderDashOffset,
rotation: -1 * labelRotationRadians,
label: label,
major: tick.major === true,
Copy link
Member

Choose a reason for hiding this comment

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

Why not simply: major: tick.major?

textBaseline: textBaseline,
textAlign: textAlign
});
Expand Down Expand Up @@ -678,10 +680,12 @@ module.exports = function(Chart) {
}

if (optionTicks.display) {
// Make sure we draw text in the correct color and font
context.save();
context.translate(itemToDraw.labelX, itemToDraw.labelY);
context.rotate(itemToDraw.rotation);
context.font = tickFont.font;
context.font = itemToDraw.major ? majorTickFont.font : tickFont.font;
context.fillStyle = itemToDraw.major ? majorTickFontColor : tickFontColor;
context.textBaseline = itemToDraw.textBaseline;
context.textAlign = itemToDraw.textAlign;

Expand Down
59 changes: 51 additions & 8 deletions src/scales/scale.time.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ module.exports = function(Chart) {
displayFormats: {
millisecond: 'h:mm:ss.SSS a', // 11:20:01.123 AM,
second: 'h:mm:ss a', // 11:20:01 AM
minute: 'h:mm:ss a', // 11:20:01 AM
hour: 'MMM D, hA', // Sept 4, 5PM
day: 'll', // Sep 4 2015
minute: 'h:mm a', // 11:20 AM
hour: 'hA', // 5PM
day: 'MMM D', // Sep 4
week: 'll', // Week 46, or maybe "[W]WW - YYYY" ?
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should change this to MMM D as well

month: 'MMM YYYY', // Sept 2015
quarter: '[Q]Q - YYYY', // Q3
Expand All @@ -36,6 +36,19 @@ module.exports = function(Chart) {
},
ticks: {
autoSkip: false
},
majorTicks: {
Copy link
Member

Choose a reason for hiding this comment

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

I liked to have all tick options scoped under the ticks key, but having it duplicated under majorTicks doesn't look ideal but also not obvious that it overrides the ticks options. Not sure either about this alternative, but what do you think about:

ticks: {
    // common tick options
    // ...
    minor: {
        // overrides for minor ticks only
    },
    major: {
        // overrides for major ticks only
    }
}

ticks.minor: false: don't show minor ticks
ticks.major: false: don't show major ticks

Copy link
Member

Choose a reason for hiding this comment

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

Also, defaults major tick options should be major: {}, no need to duplicate values if exactly the same, the merge process will take care of that (same for minor).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If minor ticks will fetch their options from options.ticks.minor I think it will be breaking change or at least we'll must to refactor all other scales to use options.ticks.minor options.
I suggest to use your variant but without minor key. In that case minor ticks will use common ticks options and major ticks will use major options. So ticks options will looks like:
ticks: { // common tick options // ... major: { // overrides for major ticks only } }

Copy link
Member

Choose a reason for hiding this comment

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

I mean, global options for ticks.minor and ticks.major would be empty objects:

ticks: {
  minor: {}, //< use common tick options
  major: {}  //< use common tick options
}

and we keep current global ticks options so when computing effective minor and major options, it would fallback to ticks global options:

  • (effective) minor options = deep merge(ticks, tick.minor)
  • (effective) major options = deep merge(ticks, tick.major)

It's not a breaking change because minor and major ticks would use the ticks values by default, same as what we have in 2.6. Also having the shorthand ticks.minor: false (same for major) to hide minor or major ticks would be great.

Copy link
Member

Choose a reason for hiding this comment

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

@etimberg @benmccann thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, @simonbrunel, I like your suggestion

Copy link
Member

Choose a reason for hiding this comment

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

@simonbrunel I like that suggestion as well 😄

beginAtZero: false,
minRotation: 0,
maxRotation: 50,
mirror: false,
padding: 0,
reverse: false,
display: true,
autoSkip: true,
autoSkipPadding: 0,
labelOffset: 0,
callback: Chart.Ticks.formatters.values
}
};

Expand All @@ -45,8 +58,17 @@ module.exports = function(Chart) {
throw new Error('Chart.js - Moment.js could not be found! You must include it before Chart.js to use the time scale. Download at https://momentjs.com');
}

this.mergeTicksOptions();

Chart.Scale.prototype.initialize.call(this);
},
mergeTicksOptions: function() {
Copy link
Member

Choose a reason for hiding this comment

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

The docs for this function imply that it should be applicable to all scales. Should mergeTicksOptions be moved to the base scale class?

Copy link
Member

Choose a reason for hiding this comment

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

Also, we should really mark all internal members private and make them public only if requested.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry I can't find docs for mergeTicksOptions. Goal of this function is to fill majorTicks options with default options. Should I rename this function to fillTimeTicksOptions?

Copy link
Member

Choose a reason for hiding this comment

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

This method should apply to all scale, so the new major options can be used by other scale implementations. That's why this merging logic should be in the base core.scale.js.

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 understood. Thanks!

for (var key in this.options.ticks) {
if (typeof this.options.majorTicks[key] === 'undefined') {
this.options.majorTicks[key] = this.options.ticks[key];
}
}
},
determineDataLimits: function() {
var me = this;
var timeOpts = me.options.time;
Expand Down Expand Up @@ -182,14 +204,34 @@ module.exports = function(Chart) {
},
// Function to format an individual tick mark
tickFormatFunction: function(tick, index, ticks) {
var formattedTick = tick.format(this.displayFormat);
var tickOpts = this.options.ticks;
var formattedTick;
var tickClone = tick.clone();
var tickTimestamp = tick.valueOf();
var major = false;
var tickOpts;
if (this.majorUnit && this.majorDisplayFormat && tickTimestamp === tickClone.startOf(this.majorUnit).valueOf()) {
// format as senior unit
formattedTick = tick.format(this.majorDisplayFormat);
tickOpts = this.options.majorTicks;
major = true;
} else {
// format as base unit
formattedTick = tick.format(this.displayFormat);
tickOpts = this.options.ticks;
}

var callback = helpers.getValueOrDefault(tickOpts.callback, tickOpts.userCallback);

if (callback) {
return callback(formattedTick, index, ticks);
return {
value: callback(formattedTick, index, ticks),
major: major
};
}
return formattedTick;
return {
value: formattedTick,
major: major
};
},
convertTicksToLabels: function() {
var me = this;
Expand Down Expand Up @@ -257,11 +299,12 @@ module.exports = function(Chart) {
var me = this;

me.displayFormat = me.options.time.displayFormats.millisecond; // Pick the longest format for guestimation
var exampleLabel = me.tickFormatFunction(moment(exampleTime), 0, []);
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;
}
});
Expand Down
66 changes: 49 additions & 17 deletions test/specs/scale.time.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ describe('Time scale tests', function() {
return scale;
}

function getTicksValues(ticks) {
return ticks.map(function(tick) {
return tick.value;
});
}

beforeEach(function() {
// Need a time matcher for getValueFromPixel
jasmine.addMatchers({
Expand Down Expand Up @@ -85,6 +91,19 @@ describe('Time scale tests', function() {
autoSkipPadding: 0,
labelOffset: 0
},
majorTicks: {
beginAtZero: false,
minRotation: 0,
maxRotation: 50,
mirror: false,
padding: 0,
reverse: false,
display: true,
callback: defaultConfig.majorTicks.callback, // make this nicer, then check explicitly below,
autoSkip: true,
autoSkipPadding: 0,
labelOffset: 0
},
time: {
parser: false,
format: false,
Expand All @@ -96,14 +115,14 @@ describe('Time scale tests', function() {
displayFormats: {
millisecond: 'h:mm:ss.SSS a', // 11:20:01.123 AM
second: 'h:mm:ss a', // 11:20:01 AM
minute: 'h:mm:ss a', // 11:20:01 AM
hour: 'MMM D, hA', // Sept 4, 5PM
day: 'll', // Sep 4 2015
minute: 'h:mm a', // 11:20 AM
hour: 'hA', // 5PM
day: 'MMM D', // Sep 4
week: 'll', // Week 46, or maybe "[W]WW - YYYY" ?
month: 'MMM YYYY', // Sept 2015
quarter: '[Q]Q - YYYY', // Q3
year: 'YYYY' // 2015
}
},
}
});

Expand All @@ -125,7 +144,9 @@ describe('Time scale tests', function() {
var scaleOptions = Chart.scaleService.getScaleDefaults('time');
var scale = createScale(mockData, scaleOptions);
scale.update(1000, 200);
expect(scale.ticks).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015', 'Jan 4, 2015', 'Jan 5, 2015', 'Jan 6, 2015', 'Jan 7, 2015', 'Jan 8, 2015', 'Jan 9, 2015', 'Jan 10, 2015', 'Jan 11, 2015']);
var ticks = getTicksValues(scale.ticks);

expect(ticks).toEqual(['Jan 1', 'Jan 2', 'Jan 3', 'Jan 4, 2015', 'Jan 5', 'Jan 6', 'Jan 7', 'Jan 8', 'Jan 9', 'Jan 10', 'Jan 11, 2015']);
});

it('should accept labels as date objects', function() {
Expand All @@ -134,7 +155,9 @@ describe('Time scale tests', function() {
};
var scale = createScale(mockData, Chart.scaleService.getScaleDefaults('time'));
scale.update(1000, 200);
expect(scale.ticks).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015', 'Jan 4, 2015', 'Jan 5, 2015', 'Jan 6, 2015', 'Jan 7, 2015', 'Jan 8, 2015', 'Jan 9, 2015', 'Jan 10, 2015', 'Jan 11, 2015']);
var ticks = getTicksValues(scale.ticks);

expect(ticks).toEqual(['Jan 1', 'Jan 2', 'Jan 3', 'Jan 4, 2015', 'Jan 5', 'Jan 6', 'Jan 7', 'Jan 8', 'Jan 9', 'Jan 10', 'Jan 11, 2015']);
});

it('should accept data as xy points', function() {
Expand Down Expand Up @@ -180,7 +203,9 @@ describe('Time scale tests', function() {

var xScale = chart.scales.xScale0;
xScale.update(800, 200);
expect(xScale.ticks).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015', 'Jan 4, 2015', 'Jan 5, 2015', 'Jan 6, 2015', 'Jan 7, 2015', 'Jan 8, 2015', 'Jan 9, 2015', 'Jan 10, 2015', 'Jan 11, 2015']);
var ticks = getTicksValues(xScale.ticks);

expect(ticks).toEqual(['Jan 1', 'Jan 2', 'Jan 3', 'Jan 4, 2015', 'Jan 5', 'Jan 6', 'Jan 7', 'Jan 8', 'Jan 9', 'Jan 10', 'Jan 11, 2015']);
});
});

Expand Down Expand Up @@ -218,8 +243,8 @@ describe('Time scale tests', function() {
var xScale = chart.scales.xScale0;

// Counts down because the lines are drawn top to bottom
expect(xScale.ticks[0]).toEqualOneOf(['Nov 19, 1981', 'Nov 20, 1981', 'Nov 21, 1981']); // handle time zone changes
expect(xScale.ticks[1]).toEqualOneOf(['Nov 19, 1981', 'Nov 20, 1981', 'Nov 21, 1981']); // handle time zone changes
expect(xScale.ticks[0].value).toEqualOneOf(['Nov 19', 'Nov 20', 'Nov 21']); // handle time zone changes
expect(xScale.ticks[1].value).toEqualOneOf(['Nov 19', 'Nov 20', 'Nov 21']); // handle time zone changes
});

it('should build ticks using the config unit', function() {
Expand All @@ -232,8 +257,9 @@ describe('Time scale tests', function() {

var scale = createScale(mockData, config);
scale.update(2500, 200);
var ticks = getTicksValues(scale.ticks);

expect(scale.ticks).toEqual(['Jan 1, 8PM', 'Jan 1, 9PM', 'Jan 1, 10PM', 'Jan 1, 11PM', 'Jan 2, 12AM', 'Jan 2, 1AM', 'Jan 2, 2AM', 'Jan 2, 3AM', 'Jan 2, 4AM', 'Jan 2, 5AM', 'Jan 2, 6AM', 'Jan 2, 7AM', 'Jan 2, 8AM', 'Jan 2, 9AM', 'Jan 2, 10AM', 'Jan 2, 11AM', 'Jan 2, 12PM', 'Jan 2, 1PM', 'Jan 2, 2PM', 'Jan 2, 3PM', 'Jan 2, 4PM', 'Jan 2, 5PM', 'Jan 2, 6PM', 'Jan 2, 7PM', 'Jan 2, 8PM', 'Jan 2, 9PM']);
expect(ticks).toEqual(['8PM', '9PM', '10PM', '11PM', 'Jan 2', '1AM', '2AM', '3AM', '4AM', '5AM', '6AM', '7AM', '8AM', '9AM', '10AM', '11AM', '12PM', '1PM', '2PM', '3PM', '4PM', '5PM', '6PM', '7PM', '8PM', '9PM']);
});

it('build ticks honoring the minUnit', function() {
Expand All @@ -245,7 +271,9 @@ describe('Time scale tests', function() {
config.time.minUnit = 'day';

var scale = createScale(mockData, config);
expect(scale.ticks).toEqual(['Jan 1, 2015', 'Jan 2, 2015', 'Jan 3, 2015']);
var ticks = getTicksValues(scale.ticks);

expect(ticks).toEqual(['Jan 1', 'Jan 2', 'Jan 3']);
});

it('should build ticks using the config diff', function() {
Expand All @@ -259,9 +287,10 @@ describe('Time scale tests', function() {

var scale = createScale(mockData, config);
scale.update(800, 200);
var ticks = getTicksValues(scale.ticks);

// last date is feb 15 because we round to start of week
expect(scale.ticks).toEqual(['Dec 28, 2014', 'Jan 4, 2015', 'Jan 11, 2015', 'Jan 18, 2015', 'Jan 25, 2015', 'Feb 1, 2015', 'Feb 8, 2015', 'Feb 15, 2015']);
expect(ticks).toEqual(['Dec 28, 2014', 'Jan 4, 2015', 'Jan 11, 2015', 'Jan 18, 2015', 'Jan 25, 2015', 'Feb 2015', 'Feb 8, 2015', 'Feb 15, 2015']);
});

describe('when specifying limits', function() {
Expand All @@ -279,15 +308,16 @@ describe('Time scale tests', function() {
config.time.min = '2014-12-29T04:00:00';

var scale = createScale(mockData, config);
expect(scale.ticks[0]).toEqual('Dec 29, 2014');
expect(scale.ticks[0].value).toEqual('Dec 29');
});

it('should use the max option', function() {
config.time.unit = 'day';
config.time.max = '2015-01-05T06:00:00';

var scale = createScale(mockData, config);
expect(scale.ticks[scale.ticks.length - 1]).toEqual('Jan 6, 2015');

expect(scale.ticks[scale.ticks.length - 1].value).toEqual('Jan 6');
});
});

Expand All @@ -305,7 +335,9 @@ describe('Time scale tests', function() {
// Wednesday
config.time.isoWeekday = 3;
var scale = createScale(mockData, config);
expect(scale.ticks).toEqual(['Dec 31, 2014', 'Jan 7, 2015']);
var ticks = getTicksValues(scale.ticks);

expect(ticks).toEqual(['Dec 31, 2014', 'Jan 7, 2015']);
});

describe('when rendering several days', function() {
Expand Down Expand Up @@ -394,7 +426,7 @@ describe('Time scale tests', function() {

it('should build the correct ticks', function() {
// Where 'correct' is a two year spacing.
expect(xScale.ticks).toEqual(['2005', '2007', '2009', '2011', '2013', '2015', '2017', '2019']);
expect(getTicksValues(xScale.ticks)).toEqual(['2005', '2007', '2009', '2011', '2013', '2015', '2017', '2019']);
});

it('should have ticks with accurate labels', function() {
Expand All @@ -404,7 +436,7 @@ describe('Time scale tests', function() {
for (var i = 0; i < ticks.length - 1; i++) {
var offset = 2 * pixelsPerYear * i;
expect(xScale.getValueForPixel(xScale.left + offset)).toBeCloseToTime({
value: moment(ticks[i] + '-01-01'),
value: moment(ticks[i].value + '-01-01'),
unit: 'day',
threshold: 0.5,
});
Expand Down