Time axis tick formatting with major and minor units (#4268)

Working towards creating the TimeSeries scale, this PR adds formatting for major and minor ticks on axes.
This commit is contained in:
Andrew
2017-06-15 14:20:16 +03:00
committed by Evert Timberg
parent 3a2884fef7
commit 2d7c1f0d2c
12 changed files with 161 additions and 43 deletions

View File

@@ -35,3 +35,27 @@ 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.
| `minor` | `object` | `{}` | Minor ticks configuration. Ommited options are inherited from options above.
| `major` | `object` | `{}` | Major ticks configuration. Ommited options are inherited from options above.
## Minor Tick Configuration
The minorTick configuration is nested under the ticks configuration in the `minor` key. It defines options for the minor tick marks that are generated by the axis. Omitted options are inherited 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).
## Major Tick Configuration
The majorTick configuration is nested under the ticks configuration in the `major` key. It defines options for the major tick marks that are generated by the axis. Omitted options are inherited 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).

View File

@@ -88,7 +88,13 @@
scaleLabel: {
display: true,
labelString: 'Date'
}
},
ticks: {
major: {
fontStyle: "bold",
fontColor: "#FF0000"
}
}
}],
yAxes: [{
display: true,
@@ -104,7 +110,6 @@
window.onload = function() {
var ctx = document.getElementById("canvas").getContext("2d");
window.myLine = new Chart(ctx, config);
};
document.getElementById('randomizeData').addEventListener('click', function() {

View File

@@ -265,6 +265,7 @@ module.exports = function(Chart) {
});
scales[scale.id] = scale;
scale.mergeTicksOptions();
// TODO(SB): I think we should be able to remove this custom case (options.scale)
// and consider it as a regular scale part of the "scales"" map only! This would

View File

@@ -48,7 +48,9 @@ module.exports = function(Chart) {
autoSkipPadding: 0,
labelOffset: 0,
// We pass through arrays to be rendered as multiline labels, we convert Others to strings here.
callback: Chart.Ticks.formatters.values
callback: Chart.Ticks.formatters.values,
minor: {},
major: {}
}
};
@@ -94,6 +96,29 @@ module.exports = function(Chart) {
// Any function defined here is inherited by all scale types.
// Any function can be extended by the scale type
mergeTicksOptions: function() {
var ticks = this.options.ticks;
if (ticks.minor === false) {
ticks.minor = {
display: false
};
}
if (ticks.major === false) {
ticks.major = {
display: false
};
}
for (var key in ticks) {
if (key !== 'major' && key !== 'minor') {
if (typeof ticks.minor[key] === 'undefined') {
ticks.minor[key] = ticks[key];
}
if (typeof ticks.major[key] === 'undefined') {
ticks.major[key] = ticks[key];
}
}
}
},
beforeUpdate: function() {
helpers.callback(this.options.beforeUpdate, [this]);
},
@@ -486,7 +511,8 @@ module.exports = function(Chart) {
var context = me.ctx;
var globalDefaults = Chart.defaults.global;
var optionTicks = options.ticks;
var optionTicks = options.ticks.minor;
var optionMajorTicks = options.ticks.major || optionTicks;
var gridLines = options.gridLines;
var scaleLabel = options.scaleLabel;
@@ -503,6 +529,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;
@@ -513,9 +541,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) {
@@ -547,7 +572,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 = (tick && tick.value) || tick;
// If the callback returned a null or undefined value, do not draw this line
if (label === undefined || label === null) {
return;
@@ -645,6 +671,7 @@ module.exports = function(Chart) {
glBorderDashOffset: borderDashOffset,
rotation: -1 * labelRotationRadians,
label: label,
major: tick.major,
textBaseline: textBaseline,
textAlign: textAlign
});
@@ -678,10 +705,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;

View File

@@ -145,13 +145,16 @@ module.exports = function(Chart) {
*/
determineMajorUnit: function(unit) {
var units = Object.keys(interval);
var majorUnit = null;
var unitIndex = units.indexOf(unit);
if (unitIndex < units.length - 1) {
majorUnit = units[unitIndex + 1];
while (unitIndex < units.length) {
var majorUnit = units[++unitIndex];
// exclude 'week' and 'quarter' units
if (majorUnit !== 'week' && majorUnit !== 'quarter') {
return majorUnit;
}
}
return majorUnit;
return null;
},
/**

View File

@@ -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" ?
month: 'MMM YYYY', // Sept 2015
quarter: '[Q]Q - YYYY', // Q3
@@ -45,6 +45,8 @@ 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);
},
determineDataLimits: function() {
@@ -182,14 +184,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 major unit
formattedTick = tick.format(this.majorDisplayFormat);
tickOpts = this.options.ticks.major;
major = true;
} else {
// format as minor (base) unit
formattedTick = tick.format(this.displayFormat);
tickOpts = this.options.ticks.minor;
}
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;
@@ -257,11 +279,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;
}
});

View File

@@ -215,6 +215,8 @@ describe('Core helper tests', function() {
autoSkip: true,
autoSkipPadding: 0,
labelOffset: 0,
minor: {},
major: {},
},
type: 'linear'
}, {
@@ -253,6 +255,8 @@ describe('Core helper tests', function() {
autoSkip: true,
autoSkipPadding: 0,
labelOffset: 0,
minor: {},
major: {},
},
type: 'linear'
}]

View File

@@ -44,7 +44,9 @@ describe('Category scale tests', function() {
callback: defaultConfig.ticks.callback, // make this nicer, then check explicitly below
autoSkip: true,
autoSkipPadding: 0,
labelOffset: 0
labelOffset: 0,
minor: {},
major: {},
}
});

View File

@@ -42,7 +42,9 @@ describe('Linear Scale', function() {
callback: defaultConfig.ticks.callback, // make this work nicer, then check below
autoSkip: true,
autoSkipPadding: 0,
labelOffset: 0
labelOffset: 0,
minor: {},
major: {},
}
});

View File

@@ -41,7 +41,9 @@ describe('Logarithmic Scale tests', function() {
callback: defaultConfig.ticks.callback, // make this nicer, then check explicitly below
autoSkip: true,
autoSkipPadding: 0,
labelOffset: 0
labelOffset: 0,
minor: {},
major: {},
},
});

View File

@@ -58,7 +58,9 @@ describe('Test the radial linear scale', function() {
callback: defaultConfig.ticks.callback, // make this nicer, then check explicitly below
autoSkip: true,
autoSkipPadding: 0,
labelOffset: 0
labelOffset: 0,
minor: {},
major: {},
},
});

View File

@@ -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({
@@ -83,7 +89,9 @@ describe('Time scale tests', function() {
callback: defaultConfig.ticks.callback, // make this nicer, then check explicitly below,
autoSkip: false,
autoSkipPadding: 0,
labelOffset: 0
labelOffset: 0,
minor: {},
major: {},
},
time: {
parser: false,
@@ -96,14 +104,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
}
},
}
});
@@ -125,7 +133,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 2015', 'Jan 2', 'Jan 3', 'Jan 4', 'Jan 5', 'Jan 6', 'Jan 7', 'Jan 8', 'Jan 9', 'Jan 10', 'Jan 11']);
});
it('should accept labels as date objects', function() {
@@ -134,7 +144,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 2015', 'Jan 2', 'Jan 3', 'Jan 4', 'Jan 5', 'Jan 6', 'Jan 7', 'Jan 8', 'Jan 9', 'Jan 10', 'Jan 11']);
});
it('should accept data as xy points', function() {
@@ -180,7 +192,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 2015', 'Jan 2', 'Jan 3', 'Jan 4', 'Jan 5', 'Jan 6', 'Jan 7', 'Jan 8', 'Jan 9', 'Jan 10', 'Jan 11']);
});
});
@@ -218,8 +232,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() {
@@ -232,8 +246,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() {
@@ -245,7 +260,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 2015', 'Jan 2', 'Jan 3']);
});
it('should build ticks using the config diff', function() {
@@ -259,9 +276,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() {
@@ -279,7 +297,7 @@ 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 28');
});
it('should use the max option', function() {
@@ -287,7 +305,8 @@ describe('Time scale tests', function() {
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');
});
});
@@ -305,7 +324,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() {
@@ -394,7 +415,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() {
@@ -404,7 +425,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,
});