Time Scale Rewrite

This commit is contained in:
Tanner Linsley 2015-09-15 11:40:01 -06:00
parent 88d30d8c93
commit 2598446d54
2 changed files with 164 additions and 123 deletions

View File

@ -38,12 +38,21 @@
var randomColor = function(opacity) {
return 'rgba(' + randomColorFactor() + ',' + randomColorFactor() + ',' + randomColorFactor() + ',' + (opacity || '.3') + ')';
};
var newDate = function(days) {
var date = new Date();
return date.setDate(date.getDate() + days);
};
var newTimestamp = function(days) {
return Date.now() - days * 100000;
};
var config = {
type: 'line',
data: {
// labels: ["01/01/2015 20:00", "01/02/2015 21:00", "01/03/2015 22:00", "01/06/2015 23:00", "01/15/2015 03:00", "01/17/2015 10:00", "01/30/2015 11:00"], // Hours
labels: ["01/01/2015", "01/02/2015", "01/03/2015", "01/06/2015", "01/15/2015", "01/17/2015", "01/30/2015"], // Days
//labels: [newTimestamp(0), newTimestamp(1), newTimestamp(2), newTimestamp(3), newTimestamp(4), newTimestamp(5), newTimestamp(6)], // unix timestamps
// labels: [newDate(0), newDate(1), newDate(2), newDate(3), newDate(4), newDate(5), newDate(6)], // Date Objects
labels: ["01/01/2015 20:00", "01/02/2015 21:00", "01/03/2015 22:00", "01/05/2015 23:00", "01/07/2015 03:00", "01/08/2015 10:00", "02/1/2015"], // Hours
// labels: ["01/01/2015", "01/02/2015", "01/03/2015", "01/06/2015", "01/15/2015", "01/17/2015", "01/30/2015"], // Days
// labels: ["12/25/2014", "01/08/2015", "01/15/2015", "01/22/2015", "01/29/2015", "02/05/2015", "02/12/2015"], // Weeks
datasets: [{
label: "My First dataset",
@ -61,14 +70,20 @@
xAxes: [{
type: "time",
display: true,
time: {
tick: {
format: 'MM/DD/YYYY HH:mm',
// round: 'day'
}
}, ],
yAxes: [{
display: true
}]
}
},
elements: {
line: {
tension: 0
}
},
}
};

View File

@ -5,6 +5,58 @@
Chart = root.Chart,
helpers = Chart.helpers;
var time = {
units: [
'millisecond',
'second',
'minute',
'hour',
'day',
'week',
'month',
'quarter',
'year',
],
unit: {
'millisecond': {
display: 'SSS [ms]', // 002 ms
maxStep: 1000,
},
'second': {
display: 'h:mm:ss a', // 11:20:01 AM
maxStep: 60,
},
'minute': {
display: 'h:mm:ss a', // 11:20:01 AM
maxStep: 60,
},
'hour': {
display: 'MMM D, hA', // Sept 4, 5PM
maxStep: 24,
},
'day': {
display: 'll', // Sep 4 2015
maxStep: 7,
},
'week': {
display: 'll', // Week 46, or maybe "[W]WW - YYYY" ?
maxStep: 4.3333,
},
'month': {
display: 'MMM YYYY', // Sept 2015
maxStep: 12,
},
'quarter': {
display: '[Q]Q - YYYY', // Q3
maxStep: 4,
},
'year': {
display: 'YYYY', // 2015
maxStep: false,
},
}
};
var defaultConfig = {
display: true,
position: "bottom",
@ -18,22 +70,11 @@
drawTicks: true, // draw ticks extending towards the label
},
time: {
format: false, // http://momentjs.com/docs/#/parsing/string-format/
unit: false, // week, month, year, etc.
aggregation: 'average',
display: false, //http://momentjs.com/docs/#/parsing/string-format/
unitFormats: {
'millisecond': 'h:mm:ss SSS', // 11:20:01 002
'second': 'h:mm:ss', // 11:20:01
'minute': 'h:mm:ss a', // 11:20:01 AM
'hour': 'MMM D, hA', // Sept 4, 5PM
'day': 'll', // Sep 4 2015 8:30 PM
'week': '[W]WW - YYYY', // Week 46
'month': 'MMM YYYY', // Sept 2015
'quarter': '[Q]Q - YYYY', // Q3
'year': 'YYYY' // 2015
}
tick: {
format: false, // false == date objects or use pattern string from http://momentjs.com/docs/#/parsing/string-format/
unit: false, // false == automatic or override with week, month, year, etc.
round: false, // none, or override with week, month, year, etc.
displayFormat: false, // defaults to unit's corresponding unitFormat below or override using pattern string from http://momentjs.com/docs/#/displaying/format/
},
// scale numbers
@ -49,7 +90,8 @@
fontSize: 12,
fontStyle: "normal",
fontColor: "#666",
fontFamily: "Helvetica Neue"
fontFamily: "Helvetica Neue",
maxRotation: 45,
}
};
@ -58,119 +100,100 @@
return this.options.position == "top" || this.options.position == "bottom";
},
parseTime: function(label) {
if (typeof this.options.time.format !== 'string' && this.options.time.format.call) {
return this.options.time.format(label);
} else {
return moment(label, this.options.time.format);
// Date objects
if (typeof label.getMonth === 'function' || typeof label == 'number') {
return moment(label);
}
// Moment support
if (label.isValid && label.isValid()) {
return label;
}
// Custom parsing (return an instance of moment)
if (typeof this.options.tick.format !== 'string' && this.options.tick.format.call) {
return this.options.tick.format(label);
}
// Moment format parsing
return moment(label, this.options.tick.format);
},
buildLabels: function(index) {
// Actual labels on the grid
this.labels = [];
// A map of original labelIndex to time labelIndex
this.timeLabelIndexMap = {};
// The time formatted versions of the labels for use by tooltips
this.data.timeLabels = [];
generateTicks: function(index) {
var definedMoments = [];
this.ticks = [];
this.labelMoments = [];
// Format each point into a moment
// Parse each label into a moment
this.data.labels.forEach(function(label, index) {
definedMoments.push(this.parseTime(label));
var labelMoment = this.parseTime(label);
if (this.options.tick.round) {
labelMoment.startOf(this.options.tick.round);
}
this.labelMoments.push(labelMoment);
}, this);
// Find the first and last moments, and range
this.firstTick = moment.min.call(this, this.labelMoments).clone();
this.lastTick = moment.max.call(this, this.labelMoments).clone();
// Find or set the unit of time
if (!this.options.time.unit) {
// Set unit override if applicable
if (this.options.tick.unit) {
this.tickUnit = this.options.tick.unit || 'day';
this.displayFormat = time.unit.day.display;
this.tickRange = Math.ceil(this.lastTick.diff(this.firstTick, this.tickUnit, true));
} else {
// Determine the smallest needed unit of the time
helpers.each([
'millisecond',
'second',
'minute',
'hour',
'day',
'week',
'month',
'quarter',
'year',
], function(format) {
if (this.timeUnit) {
var innerWidth = this.width - (this.paddingLeft + this.paddingRight);
var labelCapacity = innerWidth / this.options.labels.fontSize + 4;
var buffer = this.options.tick.round ? 0 : 2;
this.tickRange = Math.ceil(this.lastTick.diff(this.firstTick, true) + buffer);
var done;
helpers.each(time.units, function(format) {
if (this.tickRange <= labelCapacity) {
return;
}
var start;
helpers.each(definedMoments, function(mom) {
if (!start) {
start = mom[format]();
}
this.tickUnit = format;
this.tickRange = Math.ceil(this.lastTick.diff(this.firstTick, this.tickUnit) + buffer);
this.displayFormat = time.unit[format].display;
if (mom[format]() !== start) {
this.timeUnit = format;
if (!this.displayFormat) {
this.displayFormat = this.options.time.unitFormats[format];
}
}
}, this);
}, this);
} else {
this.timeUnit = this.options.time.unit;
}
if (!this.timeUnit) {
this.timeUnit = 'day';
this.displayFormat = this.options.time.unitFormats.day;
this.firstTick.startOf(this.tickUnit);
this.lastTick.endOf(this.tickUnit);
// Tick displayFormat override
if (this.options.tick.displayFormat) {
this.displayFormat = this.options.tick.displayFormat;
}
if (this.options.time.display) {
this.displayFormat = this.options.time.display;
}
// Find the first and last moments
this.firstMoment = moment.min.call(this, definedMoments);
this.lastMoment = moment.max.call(this, definedMoments);
// Find the length of the timeframe in the desired unit
var momentRangeLength = this.lastMoment.diff(this.firstMoment, this.timeUnit);
helpers.each(definedMoments, function(definedMoment, index) {
this.timeLabelIndexMap[index] = momentRangeLength - this.lastMoment.diff(definedMoment, this.timeUnit);
this.data.timeLabels.push(
definedMoment
.format(this.options.time.display ? this.options.time.display : this.displayFormat)
);
}, this);
// For every unit in between the first and last moment, create a moment and add it to the labels tick
var i = 0;
if (this.options.labels.userCallback) {
for (; i <= momentRangeLength; i++) {
this.labels.push(
this.options.labels.userCallback(this.firstMoment
.add((!i ? 0 : 1), this.timeUnit)
.format(this.options.time.display ? this.options.time.display : this.displayFormat)
for (; i <= this.tickRange; i++) {
this.ticks.push(
this.options.labels.userCallback(this.firstTick.clone()
.add(i, this.tickUnit)
.format(this.options.tick.displayFormat ? this.options.tick.displayFormat : time.unit[this.tickUnit].display)
)
);
}
} else {
for (; i <= momentRangeLength; i++) {
this.labels.push(this.firstMoment
.add((!i ? 0 : 1), this.timeUnit)
.format(this.options.time.display ? this.options.time.display : this.displayFormat)
for (; i <= this.tickRange; i++) {
this.ticks.push(this.firstTick.clone()
.add(i, this.tickUnit)
.format(this.options.tick.displayFormat ? this.options.tick.displayFormat : time.unit[this.tickUnit].display)
);
}
}
},
getPixelForValue: function(value, index, datasetIndex, includeOffset) {
getPixelForValue: function(value, decimal, datasetIndex, includeOffset) {
// This must be called after fit has been run so that
// this.left, this.top, this.right, and this.bottom have been defined
if (this.isHorizontal()) {
var innerWidth = this.width - (this.paddingLeft + this.paddingRight);
var valueWidth = innerWidth / Math.max((this.labels.length - ((this.options.gridLines.offsetGridLines) ? 0 : 1)), 1);
var valueOffset = (valueWidth * index) + this.paddingLeft;
var valueWidth = innerWidth / Math.max((this.ticks.length - ((this.options.gridLines.offsetGridLines) ? 0 : 1)), 1);
var valueOffset = (innerWidth * decimal) + this.paddingLeft;
if (this.options.gridLines.offsetGridLines && includeOffset) {
valueOffset += (valueWidth / 2);
@ -178,17 +201,18 @@
return this.left + Math.round(valueOffset);
} else {
return this.top + (index * (this.height / this.labels.length));
return this.top + (decimal * (this.height / this.ticks.length));
}
},
getPointPixelForValue: function(value, index, datasetIndex) {
// This function references the timeLaabelIndexMap to know which index in the timeLabels corresponds to the index of original labels
return this.getPixelForValue(value, this.timeLabelIndexMap[index], datasetIndex, true);
var offset = this.labelMoments[index].diff(this.firstTick, this.tickUnit, true);
return this.getPixelForValue(value, offset / this.tickRange, datasetIndex);
},
// Functions needed for bar charts
calculateBaseWidth: function() {
return (this.getPixelForValue(null, 1, 0, true) - this.getPixelForValue(null, 0, 0, true)) - (2 * this.options.categorySpacing);
return (this.getPixelForValue(null, this.ticks.length / 100, 0, true) - this.getPixelForValue(null, 0, 0, true)) - (2 * this.options.categorySpacing);
},
calculateBarWidth: function(barDatasetCount) {
//The padding between datasets is to the right of each bar, providing that there are more than 1 dataset
@ -200,6 +224,7 @@
return (baseWidth / barDatasetCount);
},
calculateBarX: function(barDatasetCount, datasetIndex, elementIndex) {
var xWidth = this.calculateBaseWidth(),
xAbsolute = this.getPixelForValue(null, elementIndex, datasetIndex, true) - (xWidth / 2),
barWidth = this.calculateBarWidth(barDatasetCount);
@ -211,14 +236,14 @@
return xAbsolute + (barWidth * datasetIndex) + (datasetIndex * this.options.spacing) + barWidth / 2;
},
calculateLabelRotation: function(maxHeight, margins) {
calculateTickRotation: function(maxHeight, margins) {
//Get the width of each grid by calculating the difference
//between x offsets between 0 and 1.
var labelFont = helpers.fontString(this.options.labels.fontSize, this.options.labels.fontStyle, this.options.labels.fontFamily);
this.ctx.font = labelFont;
var firstWidth = this.ctx.measureText(this.labels[0]).width;
var lastWidth = this.ctx.measureText(this.labels[this.labels.length - 1]).width;
var firstWidth = this.ctx.measureText(this.ticks[0]).width;
var lastWidth = this.ctx.measureText(this.ticks[this.ticks.length - 1]).width;
var firstRotated;
var lastRotated;
@ -228,7 +253,7 @@
this.labelRotation = 0;
if (this.options.display) {
var originalLabelWidth = helpers.longestText(this.ctx, labelFont, this.labels);
var originalLabelWidth = helpers.longestText(this.ctx, labelFont, this.ticks);
var cosRotation;
var sinRotation;
var firstRotatedWidth;
@ -238,7 +263,7 @@
//Allow 3 pixels x2 padding either side for label readability
// only the index matters for a dataset scale, but we want a consistent interface between scales
var datasetWidth = Math.floor(this.getPixelForValue(0, 1) - this.getPixelForValue(0, 0)) - 6;
var datasetWidth = Math.floor(this.getPixelForValue(null, 1 / this.ticks.length) - this.getPixelForValue(null, 0)) - 6;
//Max label rotation can be set or default to 90 - also act as a loop counter
while (this.labelWidth > datasetWidth && this.labelRotation <= this.options.labels.maxRotation) {
@ -264,6 +289,7 @@
this.labelRotation++;
this.labelWidth = cosRotation * originalLabelWidth;
}
} else {
this.labelWidth = 0;
@ -292,8 +318,8 @@
this.height = maxHeight;
}
this.buildLabels();
this.calculateLabelRotation(maxHeight, margins);
this.generateTicks();
this.calculateTickRotation(maxHeight, margins);
var minSize = {
width: 0,
@ -301,7 +327,7 @@
};
var labelFont = helpers.fontString(this.options.labels.fontSize, this.options.labels.fontStyle, this.options.labels.fontFamily);
var longestLabelWidth = helpers.longestText(this.ctx, labelFont, this.labels);
var longestLabelWidth = helpers.longestText(this.ctx, labelFont, this.ticks);
// Width
if (this.isHorizontal()) {
@ -340,17 +366,17 @@
var isRotated = this.labelRotation !== 0;
var skipRatio = false;
if ((this.options.labels.fontSize + 4) * this.labels.length > (this.width - (this.paddingLeft + this.paddingRight))) {
skipRatio = 1 + Math.floor(((this.options.labels.fontSize + 4) * this.labels.length) / (this.width - (this.paddingLeft + this.paddingRight)));
if ((this.options.labels.fontSize + 4) * this.ticks.length > (this.width - (this.paddingLeft + this.paddingRight))) {
skipRatio = 1 + Math.floor(((this.options.labels.fontSize + 4) * this.ticks.length) / (this.width - (this.paddingLeft + this.paddingRight)));
}
helpers.each(this.labels, function(label, index) {
// Blank labels
if ((skipRatio > 1 && index % skipRatio > 0) || (label === undefined || label === null)) {
helpers.each(this.ticks, function(tick, index) {
// Blank ticks
if ((skipRatio > 1 && index % skipRatio > 0) || (tick === undefined || tick === null)) {
return;
}
var xLineValue = this.getPixelForValue(label, index, null, false); // xvalues for grid lines
var xLabelValue = this.getPixelForValue(label, index, null, true); // x values for labels (need to consider offsetLabel option)
var xLineValue = this.getPixelForValue(null, (1 / (this.ticks.length - 1)) * index, null, false); // xvalues for grid lines
var xLabelValue = this.getPixelForValue(null, (1 / (this.ticks.length - 1)) * index, null, true); // x values for ticks (need to consider offsetLabel option)
if (this.options.gridLines.show) {
if (index === 0) {
@ -366,7 +392,7 @@
xLineValue += helpers.aliasPixel(this.ctx.lineWidth);
// Draw the label area
// Draw the tick area
this.ctx.beginPath();
if (this.options.gridLines.drawTicks) {
@ -391,7 +417,7 @@
this.ctx.font = this.font;
this.ctx.textAlign = (isRotated) ? "right" : "center";
this.ctx.textBaseline = (isRotated) ? "middle" : "top";
this.ctx.fillText(label, 0, 0);
this.ctx.fillText(tick, 0, 0);
this.ctx.restore();
}
}, this);