/*****************************************************************************************
                                    Line Chart Script
Version 1.2.1
Written Mark "Tarquin" Wilton-Jones, 17-19/05/2013 as Progress Chart Script
Updated 20/7/2022-25/08/2022 to use a better input format, more options, allow numbers or
dates or date ranges for X axis, and allow negative numbers.
Updated 28/08/2022 to avoid a floating point error in grid labels.
Updated 29/08/2022 to allow 'auto0' options to work, and avoid clipping lines.
Updated 02/09/2022 to add ySecondConverter option.
Updated 12/09/2022 to add ySecondConverted and workingCanvas information.
Updated 03/04/2024 to correct handling of hour ranges when xType is datetime or datetimeutc.
******************************************************************************************

Please see http://www.howtocreate.co.uk/jslibs/script-linechart for details
Please see http://www.howtocreate.co.uk/tutorials/jsexamples/linechart.html for details and API documentation
Please see http://www.howtocreate.co.uk/jslibs/termsOfUse.html for terms and conditions of use

Uses HTML Canvas to draw numeric and date-based scatter graphs and line charts.
_______________________________________________________________________________________*/

function lineChart( parentElement, xTitle, yTitle, dataSets, options, xBands, yBands ) {

	function plotPoint( ctx, x, y, r, pointType ) {
		//various shapes around a central point
		var sin60 = 0.866, cos60 = 0.5, tan30 = 0.57735;
		pointType %= 6;
		switch( pointType ) {
			case 1:
				//square
				r *= 0.8; //it looks better this way
				ctx.fillRect( x - r, y - r, 2 * r, 2 * r );
				break;
			case 2:
				//point-up triangle
				ctx.beginPath();
				ctx.moveTo( x, y - r );
				ctx.lineTo( x + r * sin60, y + r * cos60 );
				ctx.lineTo( x - r * sin60, y + r * cos60 );
				ctx.closePath();
				ctx.fill();
				break;
			case 3:
				//point-down triangle
				ctx.beginPath();
				ctx.moveTo( x, y + r );
				ctx.lineTo( x + r * sin60, y - r * cos60 );
				ctx.lineTo( x - r * sin60, y - r * cos60 );
				ctx.closePath();
				ctx.fill();
				break;
			case 4:
				//6-pointed star, could overlay 2 and 3, but that takes much more processing time
				ctx.beginPath();
				ctx.moveTo( x, y - r );
				ctx.lineTo( x + r * cos60 * tan30, y - r * cos60 );
				ctx.lineTo( x + r * sin60, y - r * cos60 );
				ctx.lineTo( x + r * tan30, y );
				ctx.lineTo( x + r * sin60, y + r * cos60 );
				ctx.lineTo( x + r * cos60 * tan30, y + r * cos60 );
				ctx.lineTo( x, y + r );
				ctx.lineTo( x - r * cos60 * tan30, y + r * cos60 );
				ctx.lineTo( x - r * sin60, y + r * cos60 );
				ctx.lineTo( x - r * tan30, y );
				ctx.lineTo( x - r * sin60, y - r * cos60 );
				ctx.lineTo( x - r * cos60 * tan30, y - r * cos60 );
				ctx.closePath();
				ctx.fill();
				break;
			case 5:
				//diamond
				r *= 0.8 * Math.SQRT2; //it looks better this way, root 2 to scale it to the same size as a square
				ctx.beginPath();
				ctx.moveTo( x, y - r );
				ctx.lineTo( x + r, y );
				ctx.lineTo( x, y + r );
				ctx.lineTo( x - r, y );
				ctx.closePath();
				ctx.fill();
				break;
			default:
				//circle
				ctx.beginPath();
				ctx.arc( x, y, r, 0, Math.PI * 2, true );
				ctx.fill();
		}
	}
	function drawPoint( x, y, r, pointType, colour, totalPoints ) {
		//Chromium browsers perform much faster with direct rendering for lower number of points - twice as fast as copying a canvas.
		//This then swaps around after 37300 points, where Chromium suddenly gets much worse at direct rendering -
		//rendering some without the cache then some with the cache increases the time; weird optimisation.
		//Gecko is always more than twice as fast when copying a canvas, and always twice as fast as Chromium.
		var offset;
		if( ( window.chrome && totalPoints > 37300 ) || ( !window.chrome && totalPoints > 2 ) ) {
			offset = Math.ceil(r) + 1;
			if( lastPoint.r != r || lastPoint.type != pointType || lastPoint.colour != colour ) {
				lastPoint.r = r;
				lastPoint.type = pointType;
				canvasCache.height = canvasCache.width = r * 2 + 3;
				contextCache.clearRect(0, 0, r * 2 + 3, r * 2 + 3);
				contextCache.fillStyle = lastPoint.colour = colour;
				plotPoint( contextCache, offset, offset, r, pointType );
			}
			context.drawImage( canvasCache, x - offset, y - offset );
		} else {
			plotPoint( context, x, y, r, pointType );
		}
	}

	function padString( num, length, padChar, right ) {
		var negative = num < 0;
		num = Math.abs(num) + '';
		while( num.length <  length ) {
			num = ( right ? '' : padChar ) + num + ( right ? padChar : '' );
		}
		return ( negative ? '-' : '' ) + num;
	}

	function niceDate( date, utcOrNot, workingIn ) {
		var dateString = '', timezone, year, separatorS = false, separatorM = false, separatorMo = false, showAnyway;
		date = new Date(date);
		if( window.Intl && Intl.DateTimeFormat ) {
			if( !dateFormatter ) {
				//the same format is used every time
				//this is one of the most costly objects to instantiate, so create it once and store it, avoiding a massive performance hit
				utcOrNot = utcOrNot || undefined;
				if( workingIn == 'milliseconds' ) {
					dateFormatter = new Intl.DateTimeFormat( usedFormat.locales, { year: usedFormat.year, month: usedFormat.month, day: usedFormat.day, hour: usedFormat.hour, minute: usedFormat.minute, second: usedFormat.second, fractionalSecondDigits: usedFormat.fractionalSecondDigits, era: usedFormat.era, timeZoneName: usedFormat.timeZoneName, timeZone: utcOrNot } );
				} else if( workingIn == 'seconds' ) {
					dateFormatter = new Intl.DateTimeFormat( usedFormat.locales, { year: usedFormat.year, month: usedFormat.month, day: usedFormat.day, hour: usedFormat.hour, minute: usedFormat.minute, second: usedFormat.second, era: usedFormat.era, timeZoneName: usedFormat.timeZoneName, timeZone: utcOrNot } );
				} else if( workingIn == 'minutes' || workingIn == 'hours' ) {
					dateFormatter = new Intl.DateTimeFormat( usedFormat.locales, { year: usedFormat.year, month: usedFormat.month, day: usedFormat.day, hour: usedFormat.hour, minute: usedFormat.minute, era: usedFormat.era, timeZoneName: usedFormat.timeZoneName, timeZone: utcOrNot } );
				} else if( workingIn == 'days' || workingIn == 'weeks' ) {
					dateFormatter = new Intl.DateTimeFormat( usedFormat.locales, { year: usedFormat.year, month: usedFormat.month, day: usedFormat.day, era: usedFormat.era, timeZoneName: usedFormat.timeZoneName, timeZone: utcOrNot } );
				} else if( workingIn == 'months' ) {
					dateFormatter = new Intl.DateTimeFormat( usedFormat.locales, { year: usedFormat.year, month: usedFormat.month, era: usedFormat.era, timeZoneName: usedFormat.timeZoneName, timeZone: utcOrNot } );
				} else {
					dateFormatter = new Intl.DateTimeFormat( usedFormat.locales, { year: usedFormat.year, era: usedFormat.era, timeZoneName: usedFormat.timeZoneName, timeZone: utcOrNot } );
				}
			}
			return dateFormatter.format(date);
		}
		if( workingIn == 'milliseconds' && usedFormat.fractionalSecondDigits ) {
			dateString = '.' + padString( date[ 'get' + utcOrNot + 'Milliseconds' ](), 3, '0', true );
			separatorM = separatorS = true;
		}
		if( ( workingIn == 'milliseconds' || workingIn == 'seconds' ) && usedFormat.second ) {
			dateString = padString( date[ 'get' + utcOrNot + 'Seconds' ](), 2, '0' ) + dateString;
			separatorM = separatorS = true;
		}
		if( separatorS ) {
			dateString = ':' + dateString;
		}
		if( workingIn == 'milliseconds' || workingIn == 'seconds' || workingIn == 'minutes' || workingIn == 'hours' ) {
			if( usedFormat.minute ) {
				dateString = padString( date[ 'get' + utcOrNot + 'Minutes' ](), 2, '0' ) + dateString;
				separatorM = true;
			}
			if( separatorM ) {
				dateString = ':' + dateString;
			}
			if( usedFormat.hour ) {
				dateString = padString( date[ 'get' + utcOrNot + 'Hours' ](), 2, '0' ) + dateString
			}
			if( dateString && ( usedFormat.day && usedFormat.month && usedFormat.year ) ) {
				dateString = ' ' + dateString;
			}
		}
		showAnyway = !dateString && ( !usedFormat.day || workingIn == 'months' || workingIn == 'years' ) && ( !usedFormat.month || workingIn == 'years' ) && !usedFormat.year;
		if( ( workingIn != 'months' && workingIn != 'years' && usedFormat.day ) || showAnyway ) {
			dateString = '-' + padString( date[ 'get' + utcOrNot + 'Date' ](), 2, '0' ) + dateString;
			separatorMo = true;
		}
		if( ( workingIn != 'years' && usedFormat.month ) || showAnyway ) {
			dateString = padString( date[ 'get' + utcOrNot + 'Month' ]() + 1, 2, '0' ) + dateString;
			separatorMo = true;
		}
		if( separatorMo ) {
			dateString = '-' + dateString;
		}
		year = date[ 'get' + utcOrNot + 'FullYear' ]();
		if( year < 1 ) { year--; }
		if( usedFormat.year || showAnyway ) {
			dateString = padString( year, 4, '0' ) + dateString;
		}
		if( usedFormat.era && year < 1 ) {
			dateString += ( dateString ? ' ' : '' ) + 'BCE';
		}
		if( !usedFormat.timeZoneName ) {
			return dateString;
		}
		dateString += ( dateString ? ' ' : '' ) + 'UTC';
		if( utcOrNot ) {
			return dateString;
		}
		timezone = date.getTimezoneOffset();
		dateString += ( ( timezone > 0 ) ? '-' : '+' );
		timezone = Math.abs(timezone);
		return dateString + padString( Math.floor( timezone / 60 ), 2, '0' ) + padString( timezone % 60, 2, '0' );
	}

	function orderOfMagnitude(val) {
		//the formula in perfect maths is floor log base 10, however, floating point arithmetic means it often fails to get
		//the right answer, so use regex to count the digits instead, because this is always right
		val *= 1;
		if( val == Number.POSITIVE_INFINITY || val == Number.NEGATIVE_INFINITY ) {
			return Number.POSITIVE_INFINITY;
		} else if( isNaN( val ) || !val ) {
			//special case, 0 is not defined, but could be Number.NEGATIVE_INFINITY
			return Number.NEGATIVE_INFINITY;
		}
		var numString = val + '';
		var matches = numString.match(/^-?(\d*)(\.(0*)\d*)?(e((-|\+|)\d+))?$/i);
		if( !matches ) {
			//no idea what this could be
			return Number.NEGATIVE_INFINITY;
		}
		//if there is e+number or e-number, then that is the answer, else count digits before or zeros after the decimal point
		return matches[5] ? matches[5] * 1 : ( ( matches[1] != '0' ) ? matches[1].length : matches[3] ? -matches[3].length : 0 ) - 1;
	}

	function fixFloatingPoint(num) {
		//essentially, this is rounding to 14 decimal places precision
		//however, since all numeric ways to do this will introduce floating point errors of their own
		//it is easiest to do with string manipulation
		if( isNaN(num) || num == Number.POSITIVE_INFINITY || num == Number.NEGATIVE_INFINITY ) {
			return num;
		}
		var numStr = num + '';
		if( numStr.length < 14 || numStr.indexOf('.') == -1 ) {
			//quick test, rejects most simple non-floating point error numbers
			return num;
		}
		var exponent = '', exponentIndex = numStr.indexOf('e');
		if( exponentIndex != -1 ) {
			exponent = numStr.substring(exponentIndex);
			numStr = numStr.substring( 0, exponentIndex );
		}
		var sign = ( numStr.charAt(0) == '-' ) ? '-' : '';
		//ensure that there is always a leading 0, so that there is definitely a number to increment at the end
		if( sign ) {
			numStr = '0' + numStr.substring(1);
		} else {
			numStr = '0' + numStr;
		}
		var leadingZeros = numStr.match(/^[0\.]*/);
		var remainingLength = numStr.length - leadingZeros[0].length;
		var allowedFigures = 14;
		if( remainingLength <= allowedFigures ) {
			//already at 14 decimal places precision anyway
			return num;
		}
		if( numStr.substring( leadingZeros[0].length, leadingZeros[0].length + allowedFigures ).indexOf('.') != -1 ) {
			//decimal points do not count as a significant figure, ignore it
			allowedFigures++;
			if( remainingLength == allowedFigures ) { return num; }
		}
		var afterAllowed = numStr.substring( leadingZeros[0].length + allowedFigures );
		var rounded = Math.round( parseFloat( '.' + afterAllowed.replace( /\./, '' ) ) ) == 1;
		afterAllowed = afterAllowed.replace( /[1-9]/g, '0' );
		if( !rounded ) {
			return parseFloat( sign + numStr.substring( 0, leadingZeros[0].length + allowedFigures ) + afterAllowed + exponent );
		}
		//stepping from the end to find the first character that can be incremented
		var tailStr = '', oneChar, charIndex;
		for( charIndex = leadingZeros[0].length + allowedFigures - 1; charIndex >= 0; charIndex-- ) {
			oneChar = numStr.charAt(charIndex);
			if( oneChar == '9' ) {
				tailStr = '0' + tailStr;
			} else if( oneChar == '.' ) {
				tailStr = oneChar + tailStr;
			} else {
				tailStr = ( parseInt(oneChar) + 1 ) + tailStr;
				break;
			}
		}
		return parseFloat( sign + numStr.substring( 0, charIndex ) + tailStr + afterAllowed + exponent );
	}
	function getGridStep(range) {
		//calculate the useful Y axis grid stepping, and appropriate Y limit
		var magnitude = orderOfMagnitude(range), gridStep;
		var scaledLimit = fixFloatingPoint( range / Math.pow(10,magnitude) );
		if( scaledLimit == 1 ) {
			//50 < x <= 100, go up in 10s - this one handles the '= 100' part
			gridStep = 1;
		} else if( scaledLimit <= 2 ) {
			//10 < x <= 20, go up in 2s
			gridStep = 2;
		} else if( scaledLimit <= 5 ) {
			//20 < x <= 50, go up in 5s
			gridStep = 5;
		} else {
			//50 < x <= 100, go up in 10s - this one handles the '50 < x < 100' part
			gridStep = 10;
		}
		gridStep *= Math.pow( 10, magnitude - 1 );
		return fixFloatingPoint(gridStep);
	}

	function floorHour( date, utcOrNot ) {
		date[ 'set' + utcOrNot + 'Minutes' ]( 0, 0, 0 );
		if( date[ 'get' + utcOrNot + 'Minutes' ]() ) {
			//in Lord Howe Island timezone, DST shift is 30 minutes at 02:00, so shifting back to 2:00 will send the time forward to 2:30 again, because 02:00 does not exist
			//it is the only timezone to do so
			date[ 'set' + utcOrNot + 'Hours' ]( date[ 'get' + utcOrNot + 'Hours' ]() - 1, 0, 0, 0 );
		}
		return date;
	}
	function upToNextHour( date, utcOrNot ) {
		//like ceil, but it will always increment, even if the number was already where it needed to be
		//in timezones with DST, setting the local time to the "next" hour during a DST end change will cause it to skip over the time that gets repeated in the middle -
		//floor the time instead, then add on 1 hour of ms
		date[ 'set' + utcOrNot + 'Minutes' ]( 0, 0, 0 );
		if( date[ 'get' + utcOrNot + 'Minutes' ]() ) {
			//Lord Howe Island being different again
			date.setTime( date.getTime() + ( 60 - date[ 'get' + utcOrNot + 'Minutes' ]() ) * 60 * 1000 );
		} else {
			date.setTime( date.getTime() + 60 * 60 * 1000 );
		}
		return date;
	}
	function incrementHour( date, utcOrNot ) {
		date.setTime( date.getTime() + 60 * 60 * 1000 );
		if( date[ 'get' + utcOrNot + 'Minutes' ]() ) {
			//Lord Howe Island timezone DST shifts by 30 minutes at 02:00, creating a 1.5 hour gap - it is the only timezone to do so
			date.setTime( date.getTime() + ( 60 - date[ 'get' + utcOrNot + 'Minutes' ]() ) * 60 * 1000 );
		}
		return date;
	}

	function checkValidRange( num, negativeInfinite, infinite, name ) {
		if( typeof(num) != 'number' ) {
			return num;
		}
		if( isNaN(num) ) {
			console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', ' + name + ' is NaN, and has been ignored. Continuing anyway, but this is a faulty configuration.' );
			return undefined;
		}
		if( num > infinite || num < negativeInfinite ) {
			console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', ' + name + ' is ' + num + ', while the permitted range is ' + negativeInfinite + ' to ' + infinite + ', so the value has been forced within that range. Continuing anyway, but this is a faulty configuration.' );
			return Math.max( negativeInfinite, Math.min( num, infinite ) );
		}
		return num;
	}

	//beyond Number.MAX_SAFE_INTEGER, keep trying bigger and bigger numbers until the value changes
	function forcedIncrement( num, startIncrement ) {
		if( isNaN(num) || num == Number.POSITIVE_INFINITY || num == Number.NEGATIVE_INFINITY ) {
			return num;
		}
		var numIncremented = num;
		do {
			numIncremented += startIncrement;
			startIncrement *= 2;
		} while( numIncremented == num );
		return numIncremented;
	}

	function getDashPattern( option, i ) {
		if( !window.Array || !Array.isArray ) {
			//outdated browser, cannot run the script for other reasons, don't try
			return [];
		}
		if( typeof(option) == 'number' ) {
			return dashPatterns[ ( Math.abs(Math.round(option)) % dashPatterns.length ) || 0 ];
		} else if( option && Array.isArray(option) ) {
			return option;
		} else if( i == undefined ) {
			return [];
		} else if( Array.isArray(i) ) {
			return i;
		} else {
			return dashPatterns[ i % dashPatterns.length ];
		}
	}

	var startedAt = new Date();
	var canvas, context, measurer, computedStyle, dateFormatter, canvasCache, contextCache, lastPoint = {}, copied, copyContext; //elements and APIs
	var i, j; //generic counters
	//Chromium, IE, Safri and Firefox all have different canvas size limits (MDN gets this wrong; Firefox has a low limit), Safari is memory dependent
	var chromium = window.chrome, ie = document.documentMode, safari = window.safari || ( navigator.vendor && navigator.vendor.indexOf('Apple') != -1 );
	var maxCanvas = chromium ? 16384 : ie ? 8192 : safari ? 14000 : 11180;
	//Chromium and IE+Firefox have different canvas coordinate limits, Chromium's depends on where the line starts, so assume worst case
	var maxCanvasCoord = ( chromium || safari ) ? 3.402823466385288e+38 : 5.649272421481508e+35;
	//Chromium negative limit depends where the line starts from, assume worst case
	var minCanvasCoord = chromium ? -1e+9 : safari ? -5.764607351235542e+17 : -5.649272421481508e+35;
	var infinite = ( Number.MAX_VALUE / 2 ), negativeInfinite = -1 * ( Number.MAX_VALUE / 2 ), xInfinite, xNegativeInfinite;
	//measured data limits
	var hasData = false, hasFirstYAxisData = false, hasSecondYAxisData = false;
	var xMin = Number.POSITIVE_INFINITY, xMax = Number.NEGATIVE_INFINITY, yMin = Number.POSITIVE_INFINITY, yMax = Number.NEGATIVE_INFINITY;
	var yMin2 = Number.POSITIVE_INFINITY, yMax2 = Number.NEGATIVE_INFINITY, swapper;
	var addonText = '', divisor = 1, utcOrNot, workingIn, weekStartDay, tempDate, zeroDate; //calculating the grid spacing and units
	var gridStepX, gridStepY, gridStepY2, currentGridX, currentGridY, currentGridY2; //grid size
	var xStart, xEnd, xDiff, yStart, yEnd, yStart2, yEnd2, xStartText, xEndText, yStartText, yEndText, yStartText2, yEndText2; //used data limits
	var dataKeyCount = 0, xBandKeyCount = 0, yBandKeyCount = 0, keyCount, hasSecondYAxis = false; //data analysis
	var dataRelated, styles, strings, dateFormat, usedFormat, handlers, interaction, debug; //options
	//gaps and measurements
	var xLinePad, yLinePad, labelGap, titleGap, labelLineHeight, dataKeyLineHeight, bandKeyLineHeight, dataKeyLineMiddle, bandKeyLineMiddle;
	var keySizeHalf, keyGap, keyHeight, keyWidth, yPad, spaceForXAxisLabels, spaceForXAxisTitle, spaceForYAxis, spaceForYAxis2, spaceForXLabelsAndTitle;
	var chartWidth, chartHeight, mainHeight, plotWidth, plotHeight, maxPointSize, maxPointOrLine, maxLineWidth, xFlatLabels;
	//chart positions
	var xGridParts = [], yGridParts = [], yGridParts2 = [], scalingFactorX, scalingFactorY, scalingFactorY2, whichOffset, whichFactor;
	//canvas drawing
	var yTitle2, gradient, labelCountX = 0, labelCountY = 0, labelCountY2 = 0, sectionStart, sectionEnd, itemPos, lastItemPos;
	var lineWidth, showKey, pointSize, pointRenderer, tempText, xTextReplacer, yTextReplacer, yTextReplacer2, dataSetIndex, keyHidden;
	var storedData = [], toolTip, toolTipBody, toolTipHead, firstTipTitle, secondTipTitle, lastHits = [], toolTipShowing = false; //hover effect, uses a local copy of the rendered data so it cannot be destroyed/modified
	var dashPatterns = [
		[3,3],
		[10,3],
		[3,3,10,3],
		[3,10],
		[10,10],
		[3,10,3,3]
	];

	if( !window.console ) {
		window.console = { log: function () {}, warn: function () {}, error: function () {} };
	}
	if( typeof(yTitle) == 'object' ) {
		if( yTitle.second != undefined ) {
			hasSecondYAxis = true;
		}
		yTitle2 = hasSecondYAxis ? yTitle.second : yTitle.first;
		yTitle = yTitle.first;
	} else {
		yTitle2 = yTitle;
	}
	if( !options ) { options = {}; }
	if( options.debug && options.debug.time ) { console.info( 'LineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ' starting debugging rendering time. Finished declaring variables at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }
	//keep local copies of a few settings that need to persist into any later event handlers, without being modified by external scripts
	dataRelated = {};
	if( options.dataRelated ) {
		for( i in options.dataRelated ) {
			dataRelated[i] = options.dataRelated[i];
		}
		if( dataRelated.ySecondConverter ) {
			dataRelated.ySecondConverter = {
				offset: ( typeof(dataRelated.ySecondConverter.offset) == 'number' ) ? dataRelated.ySecondConverter.offset || 0 : 0,
				ratio: ( typeof(dataRelated.ySecondConverter.ratio) == 'number' ) ? dataRelated.ySecondConverter.ratio || 1 : 1
			};
		}
	}
	dataRelated.dateTime = ( dataRelated.xType == 'datetime' || dataRelated.xType == 'datetimeutc' );
	if( !dataRelated.dateTime && dataRelated.xType != 'duration' ) {
		dataRelated.xType = 'number';
	}
	styles = options.styles || {};
	styles = {
		chartWidth: styles.chartWidth,
		chartHeight: styles.chartHeight,
		plotWidth: styles.plotWidth,
		plotHeight: styles.plotHeight,
		titleFontFamily: styles.titleFontFamily || 'serif',
		titleFontSize: Math.round( Math.abs( styles.titleFontSize * 1 ) ) || 20,
		xTitleColour: styles.xTitleColour || styles.xTitleColor || 'black',
		yTitleColour: styles.yTitleColour || styles.yTitleColor || 'black',
		ySecondTitleColour: styles.ySecondTitleColour || styles.ySecondTitleColor || 'black',
		xLabels: styles.xLabels == undefined || ( ( styles.xLabels == 'off' ) ? false : styles.xLabels ),
		yLabels: styles.yLabels == undefined || ( styles.yLabels && styles.yLabels != 'off' ),
		ySecondLabels: styles.ySecondLabels == undefined || ( styles.ySecondLabels && styles.ySecondLabels != 'off' ),
		labelFontSize: Math.round( Math.abs( styles.labelFontSize * 1 ) ) || 16,
		labelFontFamily: styles.labelFontFamily || 'serif',
		xLabelColour: styles.xLabelColour || styles.xLabelColor || 'black',
		yLabelColour: styles.yLabelColour || styles.yLabelColor || 'black',
		ySecondLabelColour: styles.ySecondLabelColour || styles.ySecondLabelColor || 'black',
		keyFontSize: Math.round( Math.abs( styles.keyFontSize * 1 ) ) || 16,
		keyFontFamily: styles.keyFontFamily || 'serif',
		dataKeyHidden: styles.dataKeyHidden == 'no' ? false : styles.dataKeyHidden,
		bandKeyHidden: styles.bandKeyHidden == 'no' ? false : styles.bandKeyHidden,
		chartBackgroundColour: styles.chartBackgroundColour || styles.chartBackgroundColor || '',
		plotTopColour: styles.plotTopColour || styles.plotTopColor || 'white',
		plotBottomColour: styles.plotBottomColour || styles.plotBottomColor || '#eee',
		xGridColour: styles.xGridColour || styles.xGridColor || '#ccc',
		yGridColour: styles.yGridColour || styles.yGridColor || '#ccc',
		ySecondGridColour: styles.ySecondGridColour || styles.ySecondGridColor || '#ccc',
		xGridZeroColour: styles.xGridZeroColour || styles.xGridZeroColor || 'black',
		yGridZeroColour: styles.yGridZeroColour || styles.yGridZeroColor || 'black',
		ySecondGridZeroColour: styles.ySecondGridZeroColour || styles.ySecondGridZeroColor || 'black',
		xGridLength: styles.xGridLength,
		yGridLength: styles.yGridLength,
		ySecondGridLength: styles.ySecondGridLength,
		xGridTicks: styles.xGridTicks,
		yGridTicks: styles.yGridTicks,
		ySecondGridTicks: styles.ySecondGridTicks,
		xGridDashPattern: getDashPattern(styles.xGridDashPattern),
		yGridDashPattern: getDashPattern(styles.yGridDashPattern),
		ySecondGridDashPattern: getDashPattern(styles.ySecondGridDashPattern),
		xGridZeroDashPattern: getDashPattern(styles.xGridZeroDashPattern),
		yGridZeroDashPattern: getDashPattern(styles.yGridZeroDashPattern),
		ySecondGridZeroDashPattern: getDashPattern(styles.ySecondGridZeroDashPattern),
		pointSize: ( typeof(styles.pointSize) == 'number' ) ? Math.abs( styles.pointSize ) : 5,
		dataLineWidth: ( typeof(styles.dataLineWidth) == 'number' ) ? Math.abs(styles.dataLineWidth) || 0 : 1,
		bandLineWidth: ( typeof(styles.bandLineWidth) == 'number' ) ? Math.abs(styles.bandLineWidth) || 0 : 1,
		gridLineWidth: ( typeof(styles.gridLineWidth) == 'number' ) ? Math.abs(styles.gridLineWidth) || 0 : 1,
		crosshairLineWidth: ( typeof(styles.crosshairLineWidth) == 'number' ) ? Math.abs(styles.crosshairLineWidth) || 0 : 1,
		crosshairDashPattern: getDashPattern( styles.crosshairDashPattern, 4 )
	};
	strings = options.strings || {};
	dateFormat = options.dateFormat || {};
	handlers = options.handlers || {};
	xTextReplacer = ( typeof(handlers.xTextReplacer) == 'function' ) ? handlers.xTextReplacer : null;
	yTextReplacer = ( typeof(handlers.yTextReplacer) == 'function' ) ? handlers.yTextReplacer : null;
	yTextReplacer2 = ( typeof(handlers.ySecondTextReplacer) == 'function' ) ? handlers.ySecondTextReplacer : null;
	interaction = options.interaction || {};
	debug = options.debug || {};
	if( debug.time ) { console.info( 'Finished reading options at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//set up the elements
	if( !parentElement || !parentElement.appendChild ) {
		console.error( 'LineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', needed a place to insert the chart, but was not provided with a parent element.', parentElement );
		return null;
	}
	canvas = document.createElement('canvas');
	if( !canvas.getContext || !canvas.getContext('2d') || !canvas.getContext('2d').measureText || !canvas.getContext('2d').drawImage || !canvas.getContext('2d').save || !window.Array || !Array.isArray ) {
		parentElement.appendChild(document.createElement('p'));
		parentElement.lastChild.className = 'linecharterror';
		parentElement.lastChild.appendChild( document.createTextNode( strings.oldBrowser || 'Modern browsers can display a chart here. Your browser is too old. You should consider upgrading to the latest version of your browser, or using an alternative browser that has better standards support.' ) );
		console.error( 'LineChart; chart of ' + yTitle + ' vs ' + xTitle + ' could not be drawn because the browser does not support basic canvas functions.' );
		return null;
	}
	canvas.className = 'linechartcanvas';
	if( debug.time ) { console.info( 'Finished checking canvas at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//work out the data ranges - do this even if the options have hardcoded it, as some of these values may get used later
	xInfinite = dataRelated.dateTime ? 864e+13 : infinite;
	xNegativeInfinite = dataRelated.dateTime ? -864e+13 : negativeInfinite;
	if( !dataSets || !dataSets.length ) {
		console.info( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', no dataSets found. This is unusual, but continuing anyway.' );
		dataSets = [];
	}
	maxPointSize = styles.pointSize;
	maxLineWidth = styles.dataLineWidth;
	for( i = 0; i < dataSets.length; i++ ) {
		if( dataSets[i] ) {
			keyHidden = dataSets[i].keyHidden == undefined ? styles.dataKeyHidden : dataSets[i].keyHidden == 'no' ? false : dataSets[i].keyHidden;
			if( !keyHidden || ( keyHidden == 'empty' && dataSets[i].data && dataSets[i].data.length ) ) {
				dataKeyCount++;
			}
		}
		if( dataSets[i] && dataSets[i].data && dataSets[i].data.length ) {
			if( typeof(dataSets[i].pointSize) == 'number' ) {
				maxPointSize = Math.max( Math.abs(dataSets[i].pointSize) || 0, maxPointSize );
			}
			if( typeof(dataSets[i].lineWidth) == 'number' ) {
				maxPointSize = Math.max( Math.abs(dataSets[i].lineWidth) || 0, maxLineWidth );
			}
			for( j = 0; j < dataSets[i].data.length; j++ ) {
				if( dataSets[i].data[j] && typeof(dataSets[i].data[j].x) == 'number' && typeof(dataSets[i].data[j].y) == 'number' && !isNaN(dataSets[i].data[j].x) && !isNaN(dataSets[i].data[j].y) ) {
					if( dataSets[i].data[j].x < xNegativeInfinite || dataSets[i].data[j].x > xInfinite || dataSets[i].data[j].y < negativeInfinite || dataSets[i].data[j].y > infinite ) {
						console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', dataSet ' + i + ' data point ' + j + ' has ' + dataSets[i].data[j].x + ' for x and ' + dataSets[i].data[j].y + ' for y, while the permitted range is ' + xNegativeInfinite + ' to ' + xInfinite + ' for x and ' + negativeInfinite + ' to ' + infinite + ' for y. The values have been forced within that range. Continuing anyway, but this data is beyond the abilities of JavaScript, so the chart may look wrong.' );
					}
					if( dataSets[i].data[j].x < xMin ) { xMin = dataSets[i].data[j].x; }
					if( dataSets[i].data[j].x > xMax ) { xMax = dataSets[i].data[j].x; }
					if( dataSets[i].yAxis == 'second' ) {
						if( dataSets[i].data[j].y < yMin2 ) { yMin2 = dataSets[i].data[j].y; }
						if( dataSets[i].data[j].y > yMax2 ) { yMax2 = dataSets[i].data[j].y; }
						hasSecondYAxis = hasSecondYAxisData = true;
					} else {
						if( dataSets[i].data[j].y < yMin ) { yMin = dataSets[i].data[j].y; }
						if( dataSets[i].data[j].y > yMax ) { yMax = dataSets[i].data[j].y; }
						hasFirstYAxisData = true;
					}
					hasData = true;
				} else {
					console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', dataSet ' + i + ' data point ' + j + ' does not have valid x and y properties. Continuing without this data point, but this is faulty data.' );
				}
			}
		} else {
			console.info( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', dataSet ' + i + ' has no data, continuing anyway.' );
		}
	}
	if( dataSets.length && !hasData ) {
		console.info( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ' has no data in any data sets, continuing anyway.' );
	}
	if( hasData && !dataRelated.ySecondConverter ) {
		if( ( hasSecondYAxis || typeof(dataRelated.ySecondStart) == 'number' || typeof(dataRelated.ySecondEnd) == 'number' || dataRelated.ySecondGrid != undefined ) && !hasSecondYAxisData ) {
			console.info( 'lineChart; chart of ' + yTitle + ' and ' + yTitle2 + '  vs ' + xTitle + ' has a second Y axis, but no data associated with it. This is unusual, but continuing anyway.' );
		}
		if( hasSecondYAxisData && !hasFirstYAxisData ) {
			console.info( 'lineChart; chart of ' + yTitle + ' and ' + yTitle2 + '  vs ' + xTitle + ' has data for the second Y axis, but no data for the first Y axis. This is unusual, but continuing anyway.' );
		}
	}

	if( debug.time ) { console.info( 'Finished calculating data range at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }
	if( !xBands || !xBands.length ) { xBands = []; }
	for( i = 0; i < xBands.length; i++ ) {
		if( xBands[i] && typeof(xBands[i].start) == 'number' && typeof(xBands[i].end) == 'number' && !isNaN(xBands[i].start) && !isNaN(xBands[i].end) ) {
			if( xBands[i].start < xNegativeInfinite || xBands[i].start > xInfinite || xBands[i].end < xNegativeInfinite || xBands[i].end > xInfinite ) {
				console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', xBand ' + i + ' has start ' + xBands[i].start + ' and end ' + xBands[i].end + ', while the permitted range is ' + xNegativeInfinite + ' to ' + xInfinite + '. The values have been forced within that range. Continuing anyway, but this data is beyond the abilities of JavaScript, so the chart may look wrong.' );
			}
			if( ( xBands[i].keyHidden == undefined && !styles.bandKeyHidden ) || xBands[i].keyHidden == 'no' || ( xBands[i].keyHidden != undefined && !xBands[i].keyHidden ) ) {
				xBandKeyCount++;
			}
			if( xBands[i].end < xBands[i].start ) {
				console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', xBand ' + i + ' has start and end properties reversed. Continuing anyway, but this is a faulty configuration.' );
			}
			//test all values to allow them to be backwards, without altering the original object
			if( xBands[i].start < xMin ) { xMin = xBands[i].start; }
			if( xBands[i].end < xMin ) { xMin = xBands[i].end; }
			if( xBands[i].end > xMax ) { xMax = xBands[i].end; }
			if( xBands[i].start > xMax ) { xMax = xBands[i].start; }
			hasData = true;
		} else {
			console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', xBand ' + i + ' does not have valid start and end properties. Continuing anyway, but this looks like faulty data.' );
		}
	}
	if( !yBands || !yBands.length ) { yBands = []; }
	for( i = 0; i < yBands.length; i++ ) {
		if( yBands[i] && typeof(yBands[i].start) == 'number' && typeof(yBands[i].end) == 'number' && !isNaN(yBands[i].start) && !isNaN(yBands[i].end) ) {
			if( yBands[i].start < negativeInfinite || yBands[i].start > infinite || yBands[i].end < negativeInfinite || yBands[i].end > infinite ) {
				console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', yBand ' + i + ' has start ' + yBands[i].start + ' and end ' + yBands[i].end + ', while the permitted range is ' + negativeInfinite + ' to ' + infinite + '. The values have been forced within that range. Continuing anyway, but this data is beyond the abilities of JavaScript, so the chart may look wrong.' );
			}
			if( ( yBands[i].keyHidden == undefined && !styles.bandKeyHidden ) || yBands[i].keyHidden == 'no' || ( yBands[i].keyHidden != undefined && !yBands[i].keyHidden ) ) {
				yBandKeyCount++;
			}
			if( yBands[i].end < yBands[i].start ) {
				console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', yBand ' + i + ' has start and end properties reversed. Continuing anyway, but this is a faulty configuration.' );
			}
			if( yBands[i].yAxis == 'second' ) {
				if( yBands[i].start < yMin2 ) { yMin2 = yBands[i].start; }
				if( yBands[i].end < yMin2 ) { yMin2 = yBands[i].end; }
				if( yBands[i].end > yMax2 ) { yMax2 = yBands[i].end; }
				if( yBands[i].start > yMax2 ) { yMax2 = yBands[i].start; }
				hasSecondYAxis = hasSecondYAxisData = true;
			} else {
				if( yBands[i].start < yMin ) { yMin = yBands[i].start; }
				if( yBands[i].end < yMin ) { yMin = yBands[i].end; }
				if( yBands[i].end > yMax ) { yMax = yBands[i].end; }
				if( yBands[i].start > yMax ) { yMax = yBands[i].start; }
				hasFirstYAxisData = true;
			}
		} else {
			console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', yBand ' + i + ' does not have valid start and end properties. Continuing anyway, but this looks like faulty data.' );
		}
	}
	if( dataRelated.ySecondConverter && hasSecondYAxisData ) {
		yMin2 = yMin2 / dataRelated.ySecondConverter.ratio + dataRelated.ySecondConverter.offset;
		yMax2 = yMax2 / dataRelated.ySecondConverter.ratio + dataRelated.ySecondConverter.offset;
		if( yMin2 < negativeInfinite || yMin2 > infinite || yMax2 < negativeInfinite || yMax2 > infinite ) {
			console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', dataSet ' + i + ' needs the first Y axis to range from ' + yMin2 + ' to ' + yMax2 + ' after applying the ySecondConverter, while the permitted range is ' + negativeInfinite + ' to ' + infinite + ' for y. The values have been forced within that range. Continuing anyway, but this data is beyond the abilities of JavaScript, so the chart may look wrong.' );
		}
		if( yMin2 < yMin ) { yMin = yMin2; }
		if( yMax2 > yMax ) { yMax = yMax2; }
	}
	if( hasData ) {
		xMin = Math.max( xNegativeInfinite, Math.min( xMin, xInfinite ) );
		xMax = Math.max( xNegativeInfinite, Math.min( xMax, xInfinite ) );
	} else {
		xMin = xMax = 0;
	}
	if( hasFirstYAxisData ) {
		yMin = Math.max( negativeInfinite, Math.min( yMin, infinite ) );
		yMax = Math.max( negativeInfinite, Math.min( yMax, infinite ) );
	} else {
		yMin = yMax = 0;
	}
	if( hasSecondYAxisData ) {
		yMin2 = Math.max( negativeInfinite, Math.min( yMin2, infinite ) );
		yMax2 = Math.max( negativeInfinite, Math.min( yMax2, infinite ) );
	} else {
		yMin2 = yMax2 = 0;
	}
	if( debug.time ) { console.info( 'Finished calculating bands at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//catch numbers not in range
	dataRelated.xStart = checkValidRange( dataRelated.xStart, xNegativeInfinite, xInfinite, 'xStart' );
	dataRelated.xEnd = checkValidRange( dataRelated.xEnd, xNegativeInfinite, xInfinite, 'xEnd' );
	dataRelated.yStart = checkValidRange( dataRelated.yStart, negativeInfinite, infinite, 'yStart' );
	dataRelated.yEnd = checkValidRange( dataRelated.yEnd, negativeInfinite, infinite, 'yEnd' );
	if( !dataRelated.ySecondConverter ) {
		dataRelated.yStart2 = checkValidRange( dataRelated.yStart2, negativeInfinite, infinite, 'ySecondStart' );
		dataRelated.yEnd2 = checkValidRange( dataRelated.yEnd2, negativeInfinite, infinite, 'ySecondEnd' );
	}
	//they have been specified the wrong way around
	if( typeof(dataRelated.xStart) == 'number' && typeof(dataRelated.xEnd) == 'number' && dataRelated.xEnd < dataRelated.xStart ) {
		console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', xStart and xEnd options reversed. Continuing anyway, but this is a faulty configuration.' );
		swapper = dataRelated.xEnd;
		dataRelated.xEnd = dataRelated.xStart;
		dataRelated.xStart = swapper;
	}
	if( typeof(dataRelated.yStart) == 'number' && typeof(dataRelated.yEnd) == 'number' && dataRelated.yEnd < dataRelated.yStart ) {
		console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', yStart and yEnd options reversed. Continuing anyway, but this is a faulty configuration.' );
		swapper = dataRelated.yEnd;
		dataRelated.yEnd = dataRelated.yStart;
		dataRelated.yStart = swapper;
	}
	if( !dataRelated.ySecondConverter && typeof(dataRelated.ySecondStart) == 'number' && typeof(dataRelated.ySecondEnd) == 'number' && dataRelated.ySecondEnd < dataRelated.ySecondStart ) {
		console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', ySecondStart and ySecondEnd options reversed. Continuing anyway, but this is a faulty configuration.' );
		swapper = dataRelated.ySecondEnd;
		dataRelated.ySecondEnd = dataRelated.ySecondStart;
		dataRelated.ySecondStart = swapper;
	}

	//automatic start and specific end must not end up the wrong way around either (but will end up as a range of 0 since no data can be rendered)
	if( typeof(dataRelated.xEnd) == 'number' && dataRelated.xEnd < xMin ) { xMin = dataRelated.xEnd; }
	if( typeof(dataRelated.xStart) == 'number' && dataRelated.xStart > xMax ) { xMax = dataRelated.xStart; }
	if( typeof(dataRelated.yEnd) == 'number' && dataRelated.yEnd < yMin ) { yMin = dataRelated.yEnd; }
	if( typeof(dataRelated.yStart) == 'number' && dataRelated.yStart > yMax ) { yMax = dataRelated.yStart; }
	if( typeof(dataRelated.ySecondEnd) == 'number' && dataRelated.ySecondEnd < yMin2 ) { yMin2 = dataRelated.ySecondEnd; }
	if( typeof(dataRelated.ySecondStart) == 'number' && dataRelated.ySecondStart > yMax2 ) { yMax2 = dataRelated.ySecondStart; }
	//the start and end values that will actually be used
	xStart = fixFloatingPoint( ( typeof(dataRelated.xStart) == 'number' ) ? dataRelated.xStart : ( dataRelated.xStart == 'auto0' ) ? Math.min( xMin, 0 ) : xMin );
	xEnd = fixFloatingPoint( ( typeof(dataRelated.xEnd) == 'number' ) ? dataRelated.xEnd : ( dataRelated.xEnd == 'auto0' ) ? Math.max( xMax, 0 ) : xMax );
	yStart = fixFloatingPoint( ( typeof(dataRelated.yStart) == 'number' ) ? dataRelated.yStart : ( dataRelated.yStart == 'auto0' ) ? Math.min( yMin, 0 ) : yMin );
	yEnd = fixFloatingPoint( ( typeof(dataRelated.yEnd) == 'number' ) ? dataRelated.yEnd : ( dataRelated.yEnd == 'auto0' ) ? Math.max( yMax, 0 ) : yMax );
	yStart2 = fixFloatingPoint( ( typeof(dataRelated.ySecondStart) == 'number' ) ? dataRelated.ySecondStart : ( dataRelated.ySecondStart == 'auto0' ) ? Math.min( yMin2, 0 ) : yMin2 );
	yEnd2 = fixFloatingPoint( ( typeof(dataRelated.ySecondEnd) == 'number' ) ? dataRelated.ySecondEnd : ( dataRelated.ySecondEnd == 'auto0' ) ? Math.max( yMax2, 0 ) : yMax2 );
	if( typeof(dataRelated.ySecondStart) == 'number' || typeof(dataRelated.ySecondEnd) == 'number' ) {
		hasSecondYAxis = true;
	}

	if( debug.time ) { console.info( 'Finished calculating data and band ranges at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//work out x grid
	xDiff = xEnd - xStart;
	if( dataRelated.xType == 'duration' ) {
		//xType == 'duration'
		//xGrid: non-0-number||'none'||null|'auto'|0|
		//workingIn is used exclusively for replacer functions
		if( xDiff <= 2 * 1000 ) {
			//under 1 second, work in ms
			workingIn = 'milliseconds';
			addonText = ( 'milliseconds' in strings ) ? strings.milliseconds : ' (ms)';
		} else if( xDiff <= 2 * 60 * 1000 ) {
			//under 2 minutes, work in seconds
			workingIn = 'seconds';
			addonText = ( 'seconds' in strings ) ? strings.seconds : ' (seconds)';
			divisor = 1000;
		} else if( xDiff <= 2 * 60 * 60 * 1000 ) {
			//under 2 hours, work in minutes
			workingIn = 'minutes';
			addonText = ( 'minutes' in strings ) ? strings.minutes : ' (minutes)';
			divisor = 60 * 1000;
		} else if( xDiff <= 2 * 24 * 60 * 60 * 1000 ) {
			//under 2 days, work in hours
			workingIn = 'hours';
			addonText = ( 'hours' in strings ) ? strings.hours : ' (hours)';
			divisor = 60 * 60 * 1000;
		} else if( xDiff <= 14 * 24 * 60 * 60 * 1000 ) {
			//under 14 days, work in days
			workingIn = 'days';
			addonText = ( 'days' in strings ) ? strings.days : ' (days)';
			divisor = 24 * 60 * 60 * 1000;
		} else if( xDiff <= 2 * 30.436875 * 24 * 60 * 60 * 1000 ) {
			//under 2 months, work in weeks
			workingIn = 'weeks';
			addonText = ( 'weeks' in strings ) ? strings.weeks : ' (weeks)';
			divisor = 7 * 24 * 60 * 60 * 1000;
			//numbers must be specified in this order to avoid floating point errors (!)
		} else if( xDiff <= 2 * 365.4525 * 1000 * 24 * 60 * 60 ) {
			//under 2 years, work in months
			workingIn = 'months';
			addonText = ( 'months' in strings ) ? strings.months : ' (months)';
			divisor = 30.436875 * 24 * 60 * 60 * 1000;
		} else {
			//over 2 years, work in years
			workingIn = 'years';
			addonText = ( 'years' in strings ) ? strings.years : ' (years)';
			divisor = 365.4525 * 1000 * 24 * 60 * 60;
		}
		xStart = fixFloatingPoint( xStart / divisor );
		xEnd = fixFloatingPoint(  xEnd / divisor );
		if( typeof(dataRelated.xGrid) == 'number' && dataRelated.xGrid && dataRelated.xGrid != Number.POSITIVE_INFINITY && dataRelated.xGrid != Number.NEGATIVE_INFINITY ) {
			//xGrid: non-0-number
			dataRelated.xGrid = Math.max( xNegativeInfinite, Math.min( dataRelated.xGrid, xInfinite ) );
			gridStepX = fixFloatingPoint( Math.abs( dataRelated.xGrid / divisor ) );
		} else if( dataRelated.xGrid != 'none' ) {
			//xGrid: null|'auto'|0|(default)
			//xDiff is no longer correct
			gridStepX = getGridStep( xEnd - xStart || 1000 );
		}
	} else if( dataRelated.dateTime ) {
		//xType: 'datetime'||'datetimeutc'
		//xGrid: 'none'||null|'auto'|0|(default)
		//number makes no sense when using exact dates, since there is no 0 reference point to work from (is it the start of this year, this month, this day, this hour, year 1, the epoch?)
		utcOrNot = ( dataRelated.xType == 'datetime' ) ? '' : 'UTC';
		if( dataRelated.xGrid == 'none' ) {
			workingIn = 'seconds';
		} else {
			if( typeof(dataRelated.xGrid) == 'number' ) {
				console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ' tried to use numeric xGrid while xType was set to ' + dataRelated.xType + ', which is not possible. Continuing anyway, but this is a faulty configuration.' );
			}
			//cannot do fractions of a step, since there are many cases where that makes no sense:
			//what date is 0.2 or 0.5 of a month or year?
			//what time is half way through the day on a day where the clocks change?
			//as a result, max(n,1) is used in cases where the stepping is calculated
			//JavaScript cannot cope with time changes in local time, at the end of DST - the hour or 30 minutes in between simply goes missing, so work in UTC where possible
			xStart = new Date(xStart);
			tempDate = new Date(xStart);
			xEnd = new Date(xEnd);
			if( xDiff <= 2 * 60 * 1000 ) {
				//under 2 minutes, work in seconds
				workingIn = 'seconds';
				gridStepX = Math.max( getGridStep( xDiff / 1000 || 1000 ), 1 );
				//enlarge the start and end to line up with the grid size, if allowed
				//work in UTC, since seconds in UTC are the same as seconds in non-UTC, and this avoids the missing hour problem
				if( typeof(dataRelated.xStart) == 'number' ) {
					//no matter whether xStart is on a gridStepX number of seconds, this will put it up to the next gridStepX seconds
					currentGridX = tempDate.setUTCSeconds( Math.floor( tempDate.getUTCSeconds() / gridStepX ) * gridStepX, 0 );
					if( xEnd.getTime() > tempDate.getTime() ) {
						currentGridX = tempDate.setUTCSeconds( tempDate.getUTCSeconds() + gridStepX, 0 );
					}
				} else {
					currentGridX = xStart.setUTCSeconds( Math.floor( xStart.getUTCSeconds() / gridStepX ) * gridStepX, 0 );
				}
				if( typeof(dataRelated.xEnd) != 'number' ) {
					tempDate.setTime(xEnd.getTime());
					tempDate.setUTCSeconds( Math.floor( tempDate.getUTCSeconds() / gridStepX ) * gridStepX, 0 );
					if( xEnd.getTime() > tempDate.getTime() ) {
						xEnd.setUTCSeconds( tempDate.getUTCSeconds() + gridStepX, 0 );
					}
				}
			} else if( xDiff <= 2 * 60 * 60 * 1000 ) {
				//under 2 hours, work in minutes
				//cannot work in UTC, because some timezones are offset by 45 minutes, which is not divisible by 2, so 2 minute gridStepX will land on odd minutes
				workingIn = 'minutes';
				gridStepX = Math.max( getGridStep( xDiff / ( 60 * 1000 ) ), 1 );
				//enlarge the start and end to line up with the grid size, if allowed
				if( typeof(dataRelated.xStart) == 'number' ) {
					tempDate[ 'set' + utcOrNot + 'Minutes' ]( Math.floor( tempDate[ 'get' + utcOrNot + 'Minutes' ]() / gridStepX ) * gridStepX, 0, 0 );
					if( xStart.getTime() > tempDate.getTime() ) {
						currentGridX = tempDate.setUTCMinutes( tempDate.getUTCMinutes() + gridStepX, 0, 0 );
					}
				} else {
					//when flooring, it will never cross a DST change, because 0 and 30 are divisible by all possible gridStepX values
					currentGridX = xStart[ 'set' + utcOrNot + 'Minutes' ]( Math.floor( xStart[ 'get' + utcOrNot + 'Minutes' ]() / gridStepX ) * gridStepX, 0, 0 );
				}
				if( typeof(dataRelated.xEnd) != 'number' ) {
					tempDate.setTime(xEnd.getTime());
					tempDate[ 'set' + utcOrNot + 'Minutes' ]( Math.floor( tempDate[ 'get' + utcOrNot + 'Minutes' ]() / gridStepX ) * gridStepX, 0, 0 );
					if( xEnd.getTime() > tempDate.getTime() ) {
						xEnd.setTime( tempDate.setUTCMinutes( tempDate.getUTCMinutes() + gridStepX, 0, 0 ) );
					}
				}
			} else if( xDiff <= 2 * 24 * 60 * 60 * 1000 ) {
				//under 2 days, work in hours
				workingIn = 'hours';
				gridStepX = 1;
				//enlarge the start and end to line up with the grid size, if allowed
				if( typeof(dataRelated.xStart) == 'number' ) {
					currentGridX = floorHour( tempDate, utcOrNot ).getTime();
					if( xStart.getTime() > tempDate.getTime() ) {
						currentGridX = incrementHour( tempDate, utcOrNot ).getTime();
					}
				} else {
					currentGridX = floorHour( xStart, utcOrNot ).getTime();
				}
				if( typeof(dataRelated.xEnd) != 'number' && ( xEnd[ 'get' + utcOrNot + 'Minutes' ]() || xEnd[ 'get' + utcOrNot + 'Seconds' ]() || xEnd[ 'get' + utcOrNot + 'Milliseconds' ]() ) ) {
					upToNextHour( xEnd, utcOrNot );
				}
			} else if( xDiff <= 14 * 24 * 60 * 60 * 1000 ) {
				//under 14 days, work in days
				workingIn = 'days';
				gridStepX = 1;
				if( typeof(dataRelated.xStart) == 'number' ) {
					currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					if( xStart.getTime() > tempDate.getTime() ) {
						tempDate[ 'set' + utcOrNot + 'Date' ]( tempDate[ 'get' + utcOrNot + 'Date' ]() + 1 );
						//set the time again - there is a small chance that a DST change could have shifted the time to something else
						currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					}
				} else {
					currentGridX = xStart[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				}
				if( typeof(dataRelated.xEnd) != 'number' && ( xEnd[ 'get' + utcOrNot + 'Hours' ]() || xEnd[ 'get' + utcOrNot + 'Minutes' ]() || xEnd[ 'get' + utcOrNot + 'Seconds' ]() || xEnd[ 'get' + utcOrNot + 'Milliseconds' ]() ) ) {
					xEnd[ 'set' + utcOrNot + 'Date' ]( xEnd[ 'get' + utcOrNot + 'Date' ]() + 1 );
					xEnd[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				}
			} else if( xDiff <= 2 * 30.436875 * 24 * 60 * 60 * 1000 ) {
				//under 2 months, work in weeks
				workingIn = 'weeks';
				gridStepX = 1;
				//force it to a positive integer, default to 0, wrap 0-6, cope with infinity
				weekStartDay = ( ( Math.abs(Math.round(dataRelated.weekStartDay)) || 0 ) % 7 ) || 0;
				if( typeof(dataRelated.xStart) == 'number' ) {
					tempDate[ 'set' + utcOrNot + 'Date' ]( tempDate[ 'get' + utcOrNot + 'Date' ]() - ( 7 + tempDate[ 'get' + utcOrNot + 'Day' ]() - weekStartDay ) % 7 );
					currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					if( xStart.getTime() > tempDate.getTime() ) {
						tempDate[ 'set' + utcOrNot + 'Date' ]( tempDate[ 'get' + utcOrNot + 'Date' ]() + 7 );
						currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					}
				} else {
					xStart[ 'set' + utcOrNot + 'Date' ]( xStart[ 'get' + utcOrNot + 'Date' ]() - ( 7 + xStart[ 'get' + utcOrNot + 'Day' ]() - weekStartDay ) % 7 );
					currentGridX = xStart[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				}
				if( typeof(dataRelated.xEnd) != 'number' && ( xEnd[ 'get' + utcOrNot + 'Day' ]() != weekStartDay || xEnd[ 'get' + utcOrNot + 'Hours' ]() || xEnd[ 'get' + utcOrNot + 'Minutes' ]() || xEnd[ 'get' + utcOrNot + 'Seconds' ]() || xEnd[ 'get' + utcOrNot + 'Milliseconds' ]() ) ) {
					xEnd[ 'set' + utcOrNot + 'Date' ]( xEnd[ 'get' + utcOrNot + 'Date' ]() + ( 7 + weekStartDay - xEnd[ 'get' + utcOrNot + 'Day' ]() ) % 7 || 7 );
					xEnd[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				}
				//numbers must be specified in this order to avoid floating point errors (!)
			} else if( xDiff <= 2 * 365.4525 * 1000 * 24 * 60 * 60 ) {
				workingIn = 'months';
				gridStepX = 1;
				if( typeof(dataRelated.xStart) == 'number' ) {
					tempDate[ 'set' + utcOrNot + 'Month' ]( tempDate[ 'get' + utcOrNot + 'Month' ](), 1 );
					currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					if( xStart.getTime() > tempDate.getTime() ) {
						tempDate[ 'set' + utcOrNot + 'Month' ]( tempDate[ 'get' + utcOrNot + 'Month' ]() + 1, 1 );
						//set the time again - there is a small chance that a DST change could have shifted the time to something else
						currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					}
				} else {
					xStart[ 'set' + utcOrNot + 'Month' ]( xStart[ 'get' + utcOrNot + 'Month' ](), 1 );
					currentGridX = xStart[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				}
				if( typeof(dataRelated.xEnd) != 'number' && ( xEnd[ 'get' + utcOrNot + 'Date' ]() != 1 || xEnd[ 'get' + utcOrNot + 'Hours' ]() || xEnd[ 'get' + utcOrNot + 'Minutes' ]() || xEnd[ 'get' + utcOrNot + 'Seconds' ]() || xEnd[ 'get' + utcOrNot + 'Milliseconds' ]() ) ) {
					xEnd[ 'set' + utcOrNot + 'Month' ]( xEnd[ 'get' + utcOrNot + 'Month' ]() + 1, 1 );
					xEnd[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				}
			} else {
				//over 2 years, work in years
				workingIn = 'years';
				gridStepX = Math.max( getGridStep( xDiff / ( 365.4525 * 1000 * 24 * 60 * 60 ) ), 1 );
				if( typeof(dataRelated.xStart) == 'number' ) {
					tempDate[ 'set' + utcOrNot + 'FullYear' ]( Math.floor( tempDate[ 'get' + utcOrNot + 'FullYear' ]() / gridStepX ) * gridStepX, 0, 1 );
					currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					if( xStart.getTime() > tempDate.getTime() ) {
						tempDate[ 'set' + utcOrNot + 'FullYear' ]( tempDate[ 'get' + utcOrNot + 'FullYear' ]() + gridStepX, 0, 1 );
						currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					}
				} else {
					xStart[ 'set' + utcOrNot + 'FullYear' ]( Math.floor( xStart[ 'get' + utcOrNot + 'FullYear' ]() / gridStepX ) * gridStepX, 0, 1 );
					currentGridX = xStart[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				}
				if( typeof(dataRelated.xEnd) != 'number' ) {
					tempDate.setTime(xEnd.getTime());
					tempDate[ 'set' + utcOrNot + 'FullYear' ]( Math.floor( tempDate[ 'get' + utcOrNot + 'FullYear' ]() / gridStepX ) * gridStepX, 0, 1 );
					tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					if( xEnd.getTime() > tempDate.getTime() ) {
						xEnd[ 'set' + utcOrNot + 'FullYear' ]( tempDate[ 'get' + utcOrNot + 'FullYear' ]() + gridStepX, 0, 1 );
						xEnd[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
					}
				}
			}
			xStart = xStart.getTime();
			xEnd = xEnd.getTime();
			if( isNaN(currentGridX) ) {
				currentGridX = xNegativeInfinite;
			}
			if( isNaN(xStart) ) {
				xStart = xNegativeInfinite;
			}
			if( isNaN(xEnd) ) {
				xEnd = xInfinite;
			}
		}
	} else if( typeof(dataRelated.xGrid) == 'number' && dataRelated.xGrid && dataRelated.xGrid != Number.POSITIVE_INFINITY && dataRelated.xGrid != Number.NEGATIVE_INFINITY ) {
		//xType: null|'number'|(default)
		//xGrid: non-0-number
		dataRelated.xGrid = Math.max( xNegativeInfinite, Math.min( dataRelated.xGrid, xInfinite ) );
		gridStepX = fixFloatingPoint( Math.abs(dataRelated.xGrid) );
	} else if( dataRelated.xGrid != 'none' ) {
		//xType: null|'number'|(default)
		//xGrid: null|'auto'|0|(default)
		//basic numbers with automatic xGrid
		gridStepX = getGridStep( xDiff || 1 );
	}
	if( debug.time ) { console.info( 'Finished calculating X grid stepping at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//enlarge the start and end to line up with the grid size, if allowed
	//for datetime, this will already have been done
	if( !dataRelated.dateTime && dataRelated.xGrid != 'none' ) {
		if( typeof(dataRelated.xStart) != 'number' ) {
			xStart = fixFloatingPoint( Math.floor( fixFloatingPoint( xStart / gridStepX ) ) * gridStepX );
		}
		if( typeof(dataRelated.xEnd) != 'number' ) {
			xEnd = fixFloatingPoint( Math.ceil( fixFloatingPoint( xEnd / gridStepX ) ) * gridStepX + 0 );
		}
		currentGridX = fixFloatingPoint( Math.ceil( fixFloatingPoint( xStart / gridStepX ) ) * gridStepX + 0 );
	}
	xStart = Math.max( xNegativeInfinite, Math.min( xStart, xInfinite ) );
	xEnd = Math.max( xNegativeInfinite, Math.min( xEnd, xInfinite ) );
	currentGridX = Math.max( xNegativeInfinite, Math.min( currentGridX, xInfinite ) );
	//if start and end are still the same, it ends up dividing by 0, so this is not allowed, even with an explicit request to set the size
	if( xStart == xEnd ) {
		if( ( typeof(dataRelated.xEnd) != 'number' || typeof(dataRelated.xStart) == 'number' ) && xEnd != xInfinite ) {
			//even if the end has been specified, it is not acceptable
			xEnd = ( dataRelated.xType == 'duration' || dataRelated.dateTime ) ? xEnd + 1000 : forcedIncrement( xEnd, 1 );
		} else {
			xStart = ( dataRelated.xType == 'duration' || dataRelated.dateTime ) ? xStart - 1000 : forcedIncrement( xStart, -1 );
			if( dataRelated.xGrid != 'none' ) {
				currentGridX = ( dataRelated.xType == 'duration' || dataRelated.dateTime ) ? currentGridX - 1000 : forcedIncrement( currentGridX, -1 );
			}
		}
	}
	if( debug.time ) { console.info( 'Finished expanding data range to match grid at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	if( typeof(dataRelated.yGrid) == 'number' && dataRelated.yGrid && dataRelated.yGrid != Number.POSITIVE_INFINITY && dataRelated.yGrid != Number.NEGATIVE_INFINITY ) {
		//yGrid: non-0-number
		dataRelated.yGrid = Math.max( negativeInfinite, Math.min( dataRelated.yGrid, infinite ) );
		gridStepY = fixFloatingPoint( Math.abs(dataRelated.yGrid) );
	} else if( dataRelated.yGrid != 'none' ) {
		//yGrid: null|'auto'|0|(default)
		//basic numbers with automatic yGrid
		gridStepY = getGridStep( ( yEnd - yStart ) || 1 );
	}
	//enlarge the start and end to line up with the grid size, if allowed
	if( dataRelated.yGrid != 'none' ) {
		if( typeof(dataRelated.yStart) != 'number' ) {
			yStart = fixFloatingPoint( Math.floor( fixFloatingPoint( yStart / gridStepY ) ) * gridStepY );
		}
		if( typeof(dataRelated.yEnd) != 'number' ) {
			yEnd = fixFloatingPoint( Math.ceil( fixFloatingPoint( yEnd / gridStepY ) ) * gridStepY + 0 );
		}
		currentGridY = fixFloatingPoint( Math.ceil( fixFloatingPoint( yStart / gridStepY ) ) * gridStepY + 0 );
	}
	yStart = Math.max( negativeInfinite, Math.min( yStart, infinite ) );
	yEnd = Math.max( negativeInfinite, Math.min( yEnd, infinite ) );
	currentGridX = Math.max( xNegativeInfinite, Math.min( currentGridX, xInfinite ) );
	if( yStart == yEnd ) {
		if( ( typeof(dataRelated.yEnd) != 'number' || typeof(dataRelated.yStart) == 'number' ) && yEnd != infinite ) {
			//even if the end has been specified, it is not acceptable
			yEnd = forcedIncrement( yEnd, 1 );
		} else {
			yStart = forcedIncrement( yStart, -1 );
			if( dataRelated.yGrid != 'none' ) {
				currentGridY = forcedIncrement( currentGridY, -1 );
			}
		}
	}
	if( debug.time ) { console.info( 'Finished calculating Y grid stepping at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	if( dataRelated.ySecondConverter ) {
		hasSecondYAxis = true;
		if( typeof(dataRelated.ySecondStart) == 'number' || typeof(dataRelated.ySecondEnd) == 'number' ) {
			console.warn( 'lineChart; chart of ' + yTitle + ' and ' + yTitle2 + ' vs ' + xTitle + ', ySecondStart and ySecondEnd options cannot be used in combination with the ySecondConverter option, and have been ignored. Continuing anyway, but this is a faulty configuration.' );
			dataRelated.ySecondStart = dataRelated.ySecondEnd = undefined;
		}
		yStart2 = ( yStart - dataRelated.ySecondConverter.offset ) * dataRelated.ySecondConverter.ratio;
		yEnd2 = ( yEnd - dataRelated.ySecondConverter.offset ) * dataRelated.ySecondConverter.ratio;
		yStart2 = checkValidRange( yStart2, negativeInfinite, infinite, 'calculated second Y axis start after applying converter' ) || 0;
		yEnd2 = checkValidRange( yEnd2, negativeInfinite, infinite, 'calculated second Y axis end after applying converter' ) || 1;
	}
	if( typeof(dataRelated.ySecondGrid) == 'number' && dataRelated.ySecondGrid && dataRelated.ySecondGrid != Number.POSITIVE_INFINITY && dataRelated.ySecondGrid != Number.NEGATIVE_INFINITY ) {
		//yGrid: non-0-number
		dataRelated.ySecondGrid = Math.max( negativeInfinite, Math.min( dataRelated.ySecondGrid, infinite ) );
		gridStepY2 = fixFloatingPoint( Math.abs(dataRelated.ySecondGrid) );
		hasSecondYAxis = true;
	} else if( dataRelated.ySecondGrid != 'none' ) {
		//yGrid: null|'auto'|0|(default)
		//basic numbers with automatic yGrid
		gridStepY2 = getGridStep( ( yEnd2 > yStart2 ? yEnd2 - yStart2 : yStart2 - yEnd2 ) || 1 );
		if( dataRelated.ySecondGrid != undefined ) {
			hasSecondYAxis = true;
		}
	}
	//enlarge the start and end to line up with the grid size, if allowed
	if( dataRelated.ySecondGrid != 'none' ) {
		if( typeof(dataRelated.ySecondStart) != 'number' && !dataRelated.ySecondConverter ) {
			yStart2 = fixFloatingPoint( Math.floor( fixFloatingPoint( yStart2 / gridStepY2 ) ) * gridStepY2 );
		}
		if( typeof(dataRelated.ySecondEnd) != 'number' && !dataRelated.ySecondConverter ) {
			yEnd2 = fixFloatingPoint( Math.ceil( fixFloatingPoint( yEnd2 / gridStepY2 ) ) * gridStepY2 + 0 );
		}
		if( yStart2 > yEnd2 ) {
			//can only happen with a multiplier
			gridStepY2 *= -1;
			currentGridY2 = fixFloatingPoint( Math.floor( fixFloatingPoint( yStart2 / gridStepY2 ) ) * gridStepY2 );
		} else {
			currentGridY2 = fixFloatingPoint( Math.ceil( fixFloatingPoint( yStart2 / gridStepY2 ) ) * gridStepY2 + 0 );
		}
	}
	yStart2 = Math.max( negativeInfinite, Math.min( yStart2, infinite ) );
	yEnd2 = Math.max( negativeInfinite, Math.min( yEnd2, infinite ) );
	currentGridY2 = Math.max( negativeInfinite, Math.min( currentGridY2, infinite ) );
	if( yStart2 == yEnd2 ) {
		if( ( typeof(dataRelated.ySecondEnd) != 'number' || typeof(dataRelated.ySecondStart) == 'number' ) && yEnd2 != infinite ) {
			//even if the end has been specified, it is not acceptable
			yEnd2 = forcedIncrement( yEnd2, 1 );
		} else {
			yStart2 = forcedIncrement( yStart2, -1 );
			if( dataRelated.ySecondGrid != 'none' ) {
				currentGridY2 = forcedIncrement( currentGridY2, -1 );
			}
		}
	}
	if( debug.time ) { console.info( 'Finished calculating second Y grid stepping at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	if( xStart == xEnd || isNaN( xEnd - xStart ) || xEnd - xStart == Number.POSITIVE_INFINITY || yStart == yEnd || isNaN( yEnd - yStart ) || yEnd - yStart == Number.POSITIVE_INFINITY || yStart2 == yEnd2 || isNaN( yEnd2 - yStart2 ) || yEnd2 - yStart2 == Number.POSITIVE_INFINITY ) {
		//it has run out of number precision, so it is not possible to draw a chart
		console.error( 'LineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', ended up working in a number range beyond the ability of JavaScript, usually because one of the following numbers has reached Infinity or -Infinity, or NaN. The script tries to prevent this, but may have made a mistake. Please report this problem.', xStart, xEnd, yStart, yEnd, yStart2, yEnd2 );
		parentElement.appendChild(document.createElement('p'));
		parentElement.lastChild.className = 'linecharterror';
		parentElement.lastChild.textContent = strings.numberLimit || 'It was not possible to draw the chart. JavaScript ran out of number precision, so the chart appears to start and end at the same number.';
		return null;
	}

	//prepare to measure labels
	context = canvas.getContext('2d');
	context.save();
	context.font = styles.labelFontSize + 'px ' + styles.labelFontFamily;

	if( dataRelated.dateTime ) {
		//working out the date range has to happen first, to know whether BCE will be needed, since all labels use the same format
		usedFormat = {
			locales: dateFormat.locales || undefined,
			year: dateFormat.year || 'numeric',
			month: dateFormat.month || ( ( workingIn == 'months' ) ? 'short' : '2-digit' ),
			day: dateFormat.day || '2-digit',
			hour: dateFormat.hour || '2-digit',
			minute: dateFormat.minute || '2-digit',
			second: dateFormat.second || '2-digit',
			//Chromium does not support 0
			fractionalSecondDigits: ( typeof(dateFormat.fractionalSecondDigits) == 'number' ) ? ( dateFormat.fractionalSecondDigits || undefined ) : 3,
			timeZoneName: dateFormat.timeZoneName || 'short',
			era: dateFormat.era || ( ( ( new Date(xStart) )['get' + utcOrNot + 'FullYear']() < 1 ) ? 'short' : undefined )
		};
		for( i in usedFormat ) {
			if( i != 'locales' && dateFormat[i] == 'none' ) {
				usedFormat[i] = undefined;
			}
		}
	}

	//prepare X axis labels and grid positions
	if( dataRelated.xType == 'datetime' ) {
		zeroDate = ( new Date( 1970, 0, 1, 0, 0, 0, 0 ) ).setFullYear(0); //cannot set year to 0 in the constructor - treated as 1900
	} else if( dataRelated.xType == 'datetimeutc' ) {
		zeroDate = ( new Date('0000-01-01T00:00:00.000Z') ).getTime();
	}
	//start and end labels might be needed due to lack of grid or lack of space for grid labels, so measure them even if they end up being inconvenient numbers
	if( styles.xLabels ) {
		if( dataRelated.dateTime ) {
			xStartText = xTextReplacer ? xTextReplacer( xStart, { xType: dataRelated.xType, divisor: divisor, datePrecision: workingIn, dateFormat: dateFormat }, null, null ) : niceDate( xStart, utcOrNot, workingIn );
			xEndText = xTextReplacer ? xTextReplacer( xEnd, { xType: dataRelated.xType, divisor: divisor, datePrecision: workingIn, dateFormat: dateFormat }, null, null ) : niceDate( xEnd, utcOrNot, workingIn );
		} else {
			xStartText = xTextReplacer ? xTextReplacer( xStart, { xType: dataRelated.xType, divisor: divisor, datePrecision: workingIn }, null, null ) : xStart;
			xEndText = xTextReplacer ? xTextReplacer( xEndText, { xType: dataRelated.xType, divisor: divisor, datePrecision: workingIn }, null, null ) : xEnd;
		}
		sectionStart = context.measureText(xStartText).width;
		sectionEnd = context.measureText(xEndText).width;
		spaceForXAxisLabels = Math.max( sectionStart, sectionEnd );
		xFlatLabels = sectionStart + sectionEnd;
	} else {
		xFlatLabels = spaceForXAxisLabels = 0;
	}
	if( dataRelated.xGrid != 'none' ) {
		while( currentGridX <= xEnd ) {
			if( dataRelated.dateTime ) {
				tempText = xTextReplacer ? xTextReplacer( currentGridX, { xType: dataRelated.xType, divisor: divisor, datePrecision: workingIn, dateFormat: dateFormat }, null, null ) : niceDate( currentGridX, utcOrNot, workingIn );
			} else {
				tempText = xTextReplacer ? xTextReplacer( currentGridX, { xType: dataRelated.xType, divisor: divisor, datePrecision: workingIn }, null, null ) : currentGridX;
			}
			xGridParts.push({
				itemPos: currentGridX - xStart,
				grid0: dataRelated.dateTime ? ( currentGridX == zeroDate ) : !currentGridX,
				text: tempText
			});
			if( styles.xLabels ) {
				spaceForXAxisLabels = Math.max( spaceForXAxisLabels, context.measureText(tempText).width );
			}
			if( currentGridX + gridStepX == currentGridX ) {
				//tiny grid at really high numbers causes infinite loop because of the lack of number precision
				console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', JavaScript ran out of number precision when drawing the X axis grid lines, so that the chart lines appear to be at the same number as each other. Abandoning the attempt to draw grid lines.' );
				break;
			}

			if( dataRelated.dateTime ) {
				tempDate = new Date(currentGridX);
				//set the time again after a date change - there is a small chance that a DST change could have shifted the previous time to something else
				if( workingIn == 'seconds' ) {
					currentGridX = tempDate.setUTCSeconds( tempDate.getUTCSeconds() + gridStepX, 0 );
				} else if( workingIn == 'minutes' ) {
					currentGridX = tempDate.setUTCMinutes( tempDate.getUTCMinutes() + gridStepX, 0, 0 );
				} else if( workingIn == 'hours' ) {
					currentGridX = incrementHour( tempDate, utcOrNot ).getTime();
				} else if( workingIn == 'days' ) {
					tempDate[ 'set' + utcOrNot + 'Date' ]( tempDate[ 'get' + utcOrNot + 'Date' ]() + 1 );
					currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				} else if( workingIn == 'weeks' ) {
					tempDate[ 'set' + utcOrNot + 'Date' ]( tempDate[ 'get' + utcOrNot + 'Date' ]() + 7 );
					currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				} else if( workingIn == 'months' ) {
					tempDate[ 'set' + utcOrNot + 'Month' ]( tempDate[ 'get' + utcOrNot + 'Month' ]() + 1, 1 );
					currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				} else {
					tempDate[ 'set' + utcOrNot + 'FullYear' ]( tempDate[ 'get' + utcOrNot + 'FullYear' ]() + gridStepX, 0, 1 );
					currentGridX = tempDate[ 'set' + utcOrNot + 'Hours' ]( 0, 0, 0, 0 );
				}
				if( isNaN(tempDate) ) {
					break;
				}
			} else {
				currentGridX = fixFloatingPoint( currentGridX + gridStepX );
			}
		}
	}
	if( debug.time ) { console.info( 'Finished calculating X grid labels and positions at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//prepare Y axis labels and grid positions
	if( styles.yLabels ) {
		yStartText = yTextReplacer ? yTextReplacer( yStart, null, { yAxis: 'first' }, null ) : yStart;
		yEndText = yTextReplacer ? yTextReplacer( yEnd, null, { yAxis: 'first' }, null ) : yEnd;
		spaceForYAxis = Math.max( context.measureText(yStartText).width, context.measureText(yEndText).width );
	} else {
		spaceForYAxis = 0;
	}
	for( ; dataRelated.yGrid != 'none' && currentGridY <= yEnd; currentGridY = fixFloatingPoint( gridStepY + currentGridY ) ) {
		tempText = yTextReplacer ? yTextReplacer( currentGridY, null, { yAxis: 'first' }, null ) : currentGridY;
		yGridParts.push({
			itemPos: yEnd - currentGridY,
			grid0: !currentGridY,
			text: tempText
		});
		if( styles.yLabels ) {
			spaceForYAxis = Math.max( spaceForYAxis, context.measureText(tempText).width );
		}
		if( currentGridY + gridStepY == currentGridY ) {
			//tiny grid at really high numbers causes infinite loop because of the lack of number precision
			console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', JavaScript ran out of number precision when drawing the Y axis grid lines, so that the chart lines appear to be at the same number as each other. Abandoning the attempt to draw grid lines.' );
			break;
		}
	}
	if( debug.time ) { console.info( 'Finished calculating Y grid labels and positions at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	if( styles.ySecondLabels && ( !dataRelated.ySecondConverter || dataRelated.ySecondGrid == 'none' ) ) {
		//with multipliers, the gaps are horrible, so do not add end labels when drawing a grid, even if they are needed
		yStartText2 = yTextReplacer2 ? yTextReplacer2( yStart, null, { yAxis: 'second' }, null ) : yStart2;
		yEndText2 = yTextReplacer2 ? yTextReplacer2( yEnd, null, { yAxis: 'second' }, null ) : yEnd2;
		spaceForYAxis2 = Math.max( context.measureText(yStartText2).width, context.measureText(yEndText2).width );
	} else {
		spaceForYAxis2 = 0;
	}
	for( ; dataRelated.ySecondGrid != 'none' && ( gridStepY2 < 0 ? yEnd2 : currentGridY2 ) <= ( gridStepY2 < 0 ? currentGridY2 : yEnd2 ); currentGridY2 = fixFloatingPoint( gridStepY2 + currentGridY2 ) ) {
		tempText = yTextReplacer2 ? yTextReplacer2( currentGridY2, null, { yAxis: 'second' }, null ) : currentGridY2;
		yGridParts2.push({
			itemPos: yEnd2 - currentGridY2,
			grid0: !currentGridY2,
			text: tempText
		});
		if( styles.ySecondLabels ) {
			spaceForYAxis2 = Math.max( spaceForYAxis2, context.measureText(tempText).width );
		}
		if( currentGridY2 + gridStepY2 == currentGridY2 ) {
			//tiny grid at really high numbers causes infinite loop because of the lack of number precision
			console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ', JavaScript ran out of number precision when drawing the second Y axis grid lines, so that the chart lines appear to be at the same number as each other. Abandoning the attempt to draw grid lines.' );
			break;
		}
	}
	if( debug.time ) { console.info( 'Finished calculating second Y grid labels and positions at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//work out the chart dimensions and location
	if( hasSecondYAxis ) {
		if( styles.yGridTicks == undefined ) {
			styles.yGridTicks = true;
		}
		if( styles.ySecondGridTicks == undefined ) {
			styles.ySecondGridTicks = true;
		}
	}
	labelGap = Math.round( 0.5 * styles.labelFontSize ); //allows two labels at the corner without them bumping into each other
	titleGap = Math.round( 0.2 * styles.titleFontSize + 0.2 * styles.labelFontSize ); //line-height gap between label and title
	labelLineHeight = Math.round( 1.2 * styles.labelFontSize );
	bandKeyLineHeight = Math.round( 1.3 * styles.keyFontSize );
	bandKeyLineMiddle = Math.round( bandKeyLineHeight / 2 );
	//big enough to fit all the points beside them without overlaps
	dataKeyLineHeight = Math.max( bandKeyLineHeight, Math.round( 2 * maxPointSize + 2 ), maxLineWidth + 2 );
	dataKeyLineMiddle = Math.round( dataKeyLineHeight / 2 );
	keySizeHalf = Math.round( styles.keyFontSize / 2 );
	keyGap = Math.round( 0.5 * styles.labelFontSize );
	keyWidth = Math.max( 2 * Math.ceil(maxPointSize) + 10, 40 ); //even numbers only
	keyHeight = dataKeyCount * dataKeyLineHeight + ( xBandKeyCount + yBandKeyCount ) * bandKeyLineHeight;
	//allow for line thickness at the ends, which might not be needed if no grid lines or rounded ends are nearby, but safest to assume it will
	xLinePad = ( dataRelated.xGrid != 'none' ) ? styles.gridLineWidth / 2 : 0;
	yLinePad = ( dataRelated.yGrid != 'none' || ( hasSecondYAxis && dataRelated.ySecondGrid != 'none' ) ) ? styles.gridLineWidth / 2 : 0;
	spaceForYAxis += styles.titleFontSize + titleGap + ( styles.yLabels ? labelGap : 0 ) + xLinePad + ( ( styles.yGridTicks && dataRelated.yGrid != 'none' ) ? styles.labelFontSize : 0 ); //space for the Y axis title and gaps
	spaceForYAxis = Math.max( spaceForYAxis, maxPointSize, maxLineWidth / 2, Math.ceil( styles.labelFontSize / 2 ) ); //space for the Y axis title and gaps
	spaceForYAxis2 += ( ( styles.ySecondGridTicks && dataRelated.ySecondGrid != 'none' ) ? styles.titleFontSize : 0 ) + xLinePad + ( styles.ySecondLabels ? labelGap : 0 ) + titleGap + styles.titleFontSize; //space for the second Y axis title and gaps
	if( hasSecondYAxis ) {
		spaceForYAxis2 = Math.max( spaceForYAxis2, maxPointSize, maxLineWidth / 2, Math.ceil( styles.labelFontSize / 2 ) ); //space for the second Y axis title and gaps
	} else {
		//space on the right
		spaceForYAxis2 = Math.max( maxPointSize, maxLineWidth / 2, Math.ceil( styles.labelFontSize / 2 ), xLinePad );
	}
	spaceForXAxisTitle = ( ( styles.xGridTicks && dataRelated.xGrid != 'none' ) ? styles.labelFontSize : 0 ) + yLinePad + 2 * titleGap + (  styles.xLabels ? labelGap : 0 ) + styles.titleFontSize; //space for the X axis title and gaps
	//spaces above and to the side are over half the font size to allow axis labels, and enough for a data point circle
	yPad = Math.max( maxPointSize, maxLineWidth / 2, Math.round( styles.labelFontSize / 2 ), yLinePad ); //space above
	if( debug.time ) { console.info( 'Finished preparing gaps at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	if( typeof(styles.chartWidth) == 'number' ) {
		chartWidth = Math.round(styles.chartWidth);
		if( styles.plotWidth != undefined ) {
			console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ' has both chartWidth and plotWidth options set. Only one may be used at a time.' );
		}
	} else if( typeof(styles.plotWidth) == 'number' ) {
		chartWidth = spaceForYAxis + Math.round(styles.plotWidth) + spaceForYAxis2;
	} else {
		//the most reliable way to measure the space is to append a dummy element then measure it
		measurer = document.createElement('div');
		measurer.className = 'linechartcanvas';
		measurer.innerHTML = '&nbsp;';
		parentElement.appendChild(measurer);
		computedStyle = getComputedStyle( measurer, null );
		chartWidth = Math.floor( measurer.clientWidth - parseFloat(computedStyle.paddingLeft) - parseFloat(computedStyle.paddingRight) );
		parentElement.removeChild(measurer);
		computedStyle = null;
	}
	//minimum width is enough so that no matter where a single grid line ends up, there will always be at least one label width on one side to have a label at the end if needed
	canvas.width = chartWidth = Math.max( spaceForYAxis + 2 * labelLineHeight + spaceForYAxis2, Math.min( chartWidth || 0, maxCanvas ) );
	//this will always be slow, since it was checking the offsetWidth of an element, which requires a reflow
	if( debug.time ) { console.info( 'Finished measuring chart width at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	plotWidth = chartWidth - ( spaceForYAxis + spaceForYAxis2 );
	if( ( ( styles.xLabels && dataRelated.xGrid == 'none' ) || styles.xLabels == 'ends' ) && xFlatLabels + styles.labelFontSize < plotWidth ) {
		spaceForXAxisLabels = styles.labelFontSize;
	}

	spaceForXLabelsAndTitle = spaceForXAxisTitle + spaceForXAxisLabels;
	//the key might not be enough space, since parts of the key can be selectively hidden, and points can overspill
	keyHeight = Math.max( maxPointSize - spaceForXLabelsAndTitle, maxLineWidth / 2 - spaceForXLabelsAndTitle, keyHeight );
	if( typeof(styles.chartHeight) == 'number' ) {
		plotHeight = styles.chartHeight - ( yPad + spaceForXLabelsAndTitle + keyHeight )
		if( styles.plotHeight != undefined ) {
			console.warn( 'lineChart; chart of ' + yTitle + ( hasSecondYAxis ? ' and ' + yTitle2 : '' ) + ' vs ' + xTitle + ' has both chartHeight and plotHeight options set. Only one may be used at a time.' );
		}
	} else if( typeof(styles.plotHeight) == 'number' ) {
		plotHeight = styles.plotHeight;
	} else {
		//make the chart itself be the ratio given
		plotHeight = Math.ceil( plotWidth * ( 5 / 8 ) );
	}
	//minimum height is enough so that no matter where a single grid line ends up, there will always be at least one label width on one side to have a label at the end if needed
	plotHeight = Math.max( 2 * labelLineHeight, Math.min( plotHeight || 0, maxCanvas - ( yPad + spaceForXLabelsAndTitle + keyHeight ) ) );
	mainHeight = yPad + plotHeight + spaceForXLabelsAndTitle;

	scalingFactorX = plotWidth / ( xEnd - xStart );
	scalingFactorY = plotHeight / ( yEnd - yStart );
	scalingFactorY2 = plotHeight / ( yEnd2 - yStart2 );

	if( debug.time ) { console.info( 'Finished calculating chart height at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	canvas.height = chartHeight = Math.ceil( mainHeight + keyHeight );
	parentElement.appendChild(canvas);

	if( debug.time ) { console.info( 'Finished appending the canvas at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//draw the chart space
	if( styles.chartBackgroundColour && styles.chartBackgroundColour != 'transparent' ) {
		context.fillStyle = styles.chartBackgroundColour;
		context.fillRect( 0, 0, chartWidth, chartHeight );
	}
	gradient = context.createLinearGradient( 0, yPad, 0, yPad + plotHeight );
	gradient.addColorStop( 0, styles.plotTopColour );
	gradient.addColorStop( 1, styles.plotBottomColour );
	context.fillStyle = gradient;
	context.fillRect( spaceForYAxis, yPad, plotWidth, plotHeight );
	if( debug.time ) { console.info( 'Finished drawing chart background at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//shade the chart wherever there is a band to be drawn
	for( i = 0, keyCount = 0; i < xBands.length; i++ ) {
		if( xBands[i] && typeof(xBands[i].start) == 'number' && typeof(xBands[i].end) == 'number' ) {
			//test both values to allow them to be backwards, without altering the original object
			sectionStart = ( Math.min( xBands[i].start, xBands[i].end ) / divisor - xStart ) * scalingFactorX;
			sectionEnd = ( Math.max( xBands[i].end, xBands[i].start ) / divisor - xStart ) * scalingFactorX;

			context.fillStyle = xBands[i].backgroundColour || xBands[i].backgroundColor || 'hsla(' + ( 360 * i / xBands.length ) + ',100%,60%,0.2)';
			context.fillRect( spaceForYAxis + sectionStart, yPad, sectionEnd - sectionStart, plotHeight );

			//key
			if( ( xBands[i].keyHidden == undefined && !styles.bandKeyHidden ) || ( xBands[i].keyHidden != undefined && !xBands[i].keyHidden ) ) {
				itemPos = mainHeight + dataKeyCount * dataKeyLineHeight + keyCount * bandKeyLineHeight + bandKeyLineMiddle; //space for each key line below the chart
				context.fillRect( spaceForYAxis, itemPos - keySizeHalf, keyWidth, styles.keyFontSize );
				keyCount++;
			}
		}
	}
	for( i = 0, keyCount = 0; i < yBands.length; i++ ) {
		if( yBands[i] && typeof(yBands[i].start) == 'number' && typeof(yBands[i].end) == 'number' ) {
			//test both values to allow them to be backwards, without altering the original object
			if( yBands[i].yAxis == 'second' ) {
				sectionStart = ( yEnd2 - Math.min( yBands[i].start, yBands[i].end ) ) * scalingFactorY2;
				sectionEnd = ( yEnd2 - Math.max( yBands[i].end, yBands[i].start ) ) * scalingFactorY2;
			} else {
				sectionStart = ( yEnd - Math.min( yBands[i].start, yBands[i].end ) ) * scalingFactorY;
				sectionEnd = ( yEnd - Math.max( yBands[i].end, yBands[i].start ) ) * scalingFactorY;
			}

			context.fillStyle = yBands[i].backgroundColour || yBands[i].backgroundColor || 'hsla(' + ( 360 * i / yBands.length ) + ',100%,60%,0.2)';
			context.fillRect( spaceForYAxis, yPad + sectionEnd, plotWidth, sectionStart - sectionEnd );

			//key
			if( ( yBands[i].keyHidden == undefined && !styles.bandKeyHidden ) || ( yBands[i].keyHidden != undefined && !yBands[i].keyHidden ) ) {
				itemPos = mainHeight + dataKeyCount * dataKeyLineHeight + xBandKeyCount * bandKeyLineHeight + keyCount * bandKeyLineHeight + bandKeyLineMiddle; //space for each key line below the chart
				context.fillRect( spaceForYAxis, itemPos - keySizeHalf, keyWidth, styles.keyFontSize );
				keyCount++;
			}
		}
	}
	if( debug.time ) { console.info( 'Finished shading bands and band key at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//draw Y axis grid and labels - font is already set, but gets reset when the canvas dimensions are changed, so set it again
	context.font = styles.labelFontSize + 'px ' + styles.labelFontFamily;
	context.fillStyle = styles.yLabelColour;
	context.textAlign = 'right';
	context.textBaseline = 'middle';
	context.lineWidth = styles.gridLineWidth;
	//when both lines are used, curve the edges so the corners join nicely - might not be needed if no grid lines end up at the edges, but safest to assume they will
	context.lineCap = ( ( yGridParts.length || yGridParts2.length ) && xGridParts.length ) ? 'round' : 'butt';
	sectionStart = ( styles.yGridTicks && dataRelated.yGrid != 'none' ) ? styles.labelFontSize : 0;
	if( styles.yGridLength == 'short' ) {
		sectionEnd = spaceForYAxis + Math.min( styles.labelFontSize, plotWidth );
	} else if( typeof(styles.yGridLength) == 'number' ) {
		sectionEnd = spaceForYAxis + plotWidth * Math.max( 0, Math.min( styles.yGridLength || 0, 100 ) ) / 100;
	} else {
		sectionEnd = spaceForYAxis + plotWidth;
	}
	for( i = 0; i < yGridParts.length; i++ ) {
		itemPos = yPad + yGridParts[i].itemPos * scalingFactorY;

		if( context.setLineDash ) {
			context.setLineDash( yGridParts[i].grid0 ? styles.yGridZeroDashPattern : styles.yGridDashPattern );
		}

		context.beginPath();
		context.strokeStyle = yGridParts[i].grid0 ? styles.yGridZeroColour : styles.yGridColour;
		context.moveTo( spaceForYAxis - sectionStart, itemPos );
		context.lineTo( sectionEnd, itemPos );
		context.stroke();

		if( styles.yLabels && ( !i || lastItemPos - itemPos >= labelLineHeight ) ) {
			lastItemPos = itemPos;
			labelCountY++;
			context.fillText( yGridParts[i].text, spaceForYAxis - xLinePad - sectionStart - labelGap, itemPos );
		}
	}
	if( styles.yLabels && ( dataRelated.yGrid == 'none' || labelCountY == 0 || ( labelCountY == 1 && yPad + plotHeight - lastItemPos >= lastItemPos - yPad ) ) ) {
		context.fillText( yStartText, spaceForYAxis - xLinePad - sectionStart - labelGap, yPad + plotHeight );
	}
	if( styles.yLabels && ( dataRelated.yGrid == 'none' || labelCountY == 0 || ( labelCountY == 1 && yPad + plotHeight - lastItemPos < lastItemPos - yPad ) ) ) {
		context.fillText( yEndText, spaceForYAxis - xLinePad - sectionStart - labelGap, yPad );
	}
	if( debug.time ) { console.info( 'Finished rendering Y grid labels and positions at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//draw second Y axis grid and labels
	if( hasSecondYAxis ) {
		context.fillStyle = styles.ySecondLabelColour;
		context.textAlign = 'left';

		sectionStart = spaceForYAxis + plotWidth + ( ( styles.ySecondGridTicks && dataRelated.ySecondGrid != 'none' ) ? styles.labelFontSize : 0 );
		if( typeof(styles.ySecondGridLength) == 'number' ) {
			sectionEnd = spaceForYAxis + ( 100 - Math.max( 0, Math.min( styles.ySecondGridLength || 0, 100 ) ) ) / 100 * plotWidth;
		} else {
			sectionEnd = spaceForYAxis + plotWidth - Math.min( styles.labelFontSize, plotWidth );
		}
		for( i = 0; i < yGridParts2.length; i++ ) {
			itemPos = yPad + yGridParts2[i].itemPos * scalingFactorY2;
	
			if( context.setLineDash ) {
				context.setLineDash( yGridParts2[i].grid0 ? styles.ySecondGridZeroDashPattern : styles.ySecondGridDashPattern );
			}

			context.beginPath();
			context.strokeStyle = yGridParts2[i].grid0 ? styles.ySecondGridZeroColour : styles.ySecondGridColour;
			context.moveTo( sectionStart, itemPos );
			context.lineTo( sectionEnd, itemPos );
			context.stroke();
	
			if( styles.ySecondLabels && ( !i || lastItemPos - itemPos >= labelLineHeight ) ) {
				lastItemPos = itemPos;
				labelCountY2++;
				context.fillText( yGridParts2[i].text, sectionStart + xLinePad + labelGap, itemPos );
			}
		}
		//with multipliers, the gaps are horrible, so do not add end labels when drawing a grid, even if they are needed
		if( styles.ySecondLabels && ( !dataRelated.ySecondConverter || dataRelated.ySecondGrid == 'none' ) && ( dataRelated.ySecondGrid == 'none' || labelCountY2 == 0 || ( labelCountY2 == 1 && yPad + plotHeight - lastItemPos >= lastItemPos - yPad ) ) ) {
			context.fillText( yStartText2, sectionStart + xLinePad + labelGap, yPad + plotHeight );
		}
		if( styles.ySecondLabels && ( !dataRelated.ySecondConverter || dataRelated.ySecondGrid == 'none' ) && ( dataRelated.ySecondGrid == 'none' || labelCountY2 == 0 || ( labelCountY2 == 1 && yPad + plotHeight - lastItemPos < lastItemPos - yPad ) ) ) {
			context.fillText( yEndText2, sectionStart + xLinePad + labelGap, yPad );
		}
		if( debug.time ) { console.info( 'Finished rendering second Y grid labels and positions at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }
	}

	//draw X axis grid and labels
	context.fillStyle = styles.xLabelColour;
	context.textAlign = 'right';
	sectionStart = yPad + plotHeight + ( ( styles.xGridTicks && dataRelated.xGrid != 'none' ) ? styles.labelFontSize : 0 );
	if( styles.xGridLength == 'short' ) {
		sectionEnd = yPad + plotHeight - Math.min( styles.labelFontSize, plotHeight );
	} else if( typeof(styles.xGridLength) == 'number' ) {
		sectionEnd = yPad + ( 100 - Math.max( 0, Math.min( styles.xGridLength || 0, 100 ) ) ) / 100 * plotHeight;
	} else {
		sectionEnd = yPad;
	}
	for( i = 0; i < xGridParts.length; i++ ) {
		itemPos = spaceForYAxis + xGridParts[i].itemPos * scalingFactorX;

		if( context.setLineDash ) {
			context.setLineDash( xGridParts[i].grid0 ? styles.xGridZeroDashPattern : styles.xGridDashPattern );
		}

		context.beginPath();
		context.strokeStyle = xGridParts[i].grid0 ? styles.xGridZeroColour : styles.xGridColour;
		context.moveTo( itemPos, sectionStart );
		context.lineTo( itemPos, sectionEnd );
		context.stroke();

		if( styles.xLabels && styles.xLabels != 'ends' && ( !i || itemPos - lastItemPos >= labelLineHeight ) ) {
			lastItemPos = itemPos;
			labelCountX++;
			context.save();
			context.rotate( Math.PI * -1 / 2 );
			context.fillText( xGridParts[i].text, -1 * ( sectionStart + yLinePad + labelGap ), itemPos );
			context.restore();
		}
	}
	//additional X axis labels might be needed at the edges of the chart
	if( ( ( styles.xLabels && dataRelated.xGrid == 'none' ) || styles.xLabels == 'ends' ) && xFlatLabels + styles.labelFontSize < plotWidth ) {
		context.textAlign = 'left';
		context.textBaseline = 'top';
		context.fillText( xStartText, spaceForYAxis, sectionStart + yLinePad + labelGap );
		context.textAlign = 'right';
		context.fillText( xEndText, spaceForYAxis + plotWidth, sectionStart + yLinePad + labelGap );
	} else {
		context.save();
		context.rotate( Math.PI * -1 / 2 );
		if( styles.xLabels && ( dataRelated.xGrid == 'none' || labelCountX == 0 || ( labelCountX == 1 && lastItemPos - spaceForYAxis >= spaceForYAxis + plotWidth - lastItemPos ) ) ) {
			context.fillText( xStartText, -1 * ( sectionStart + yLinePad + labelGap ), spaceForYAxis );
		}
		if( styles.xLabels && ( dataRelated.xGrid == 'none' || labelCountX == 0 || ( labelCountX == 1 && lastItemPos - spaceForYAxis < spaceForYAxis + plotWidth - lastItemPos ) ) ) {
			context.fillText( xEndText, -1 * ( sectionStart + yLinePad + labelGap ), spaceForYAxis + plotWidth );
		}
		context.restore();
	}
	context.lineCap = 'butt';
	if( debug.time ) { console.info( 'Finished rendering X grid labels and positions at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//write axis titles
	context.font = styles.titleFontSize + 'px ' + styles.titleFontFamily;
	context.textAlign = 'center';
	context.textBaseline = 'top';
	context.fillStyle = styles.xTitleColour;
	context.fillText( xTitle + addonText, spaceForYAxis + ( plotWidth / 2 ), yPad + plotHeight + ( ( styles.xGridTicks && dataRelated.xGrid != 'none' ) ? styles.labelFontSize : 0 ) + ( styles.xLabels ? labelGap : 0 ) + spaceForXAxisLabels + titleGap + yLinePad );

	context.save();
	context.rotate( Math.PI * -1 / 2 );
	context.fillStyle = styles.yTitleColour;
	context.fillText( yTitle, -1 * ( yPad + ( plotHeight / 2 ) ), 0 );
	if( hasSecondYAxis ) {
		context.fillStyle = styles.ySecondTitleColour;
		context.textBaseline = 'bottom';
		context.fillText( yTitle2, -1 * ( yPad + ( plotHeight / 2 ) ), chartWidth );
	}
	context.restore();

	if( debug.time ) { console.info( 'Finished rendering axis titles at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	context.font = styles.keyFontSize + 'px ' + styles.keyFontFamily;
	context.textAlign = 'left';
	context.textBaseline = 'middle';
	//draw band boundary lines
	for( i = 0, keyCount = 0; i < xBands.length; i++ ) {
		if( xBands[i] && typeof(xBands[i].start) == 'number' && typeof(xBands[i].end) == 'number' ) {
			lineWidth = ( typeof(xBands[i].lineWidth) == 'number' ) ? Math.abs(xBands[i].lineWidth) || 0 : styles.bandLineWidth;
			context.fillStyle = context.strokeStyle = xBands[i].colour || xBands[i].color || 'hsla(' + ( 360 * i / xBands.length ) + ',100%,30%,0.9)';
			showKey = ( xBands[i].keyHidden == undefined && !styles.bandKeyHidden ) || ( xBands[i].keyHidden != undefined && !xBands[i].keyHidden );
			if( showKey ) {
				itemPos = mainHeight + dataKeyCount * dataKeyLineHeight + keyCount * bandKeyLineHeight + bandKeyLineMiddle; //space for each key line below the chart
				keyCount++;
			}

			if( lineWidth ) {
				context.lineWidth = lineWidth;
				sectionStart = ( xBands[i].start / divisor - xStart ) * scalingFactorX;
				sectionEnd = ( xBands[i].end / divisor - xStart ) * scalingFactorX;

				if( context.setLineDash ) {
					context.setLineDash( getDashPattern( xBands[i].dashPattern, i ) );
				}

				context.beginPath();
				context.moveTo( spaceForYAxis + sectionStart, yPad );
				context.lineTo( spaceForYAxis + sectionStart, yPad + plotHeight );
				context.stroke();

				context.beginPath();
				context.moveTo( spaceForYAxis + sectionEnd, yPad );
				context.lineTo( spaceForYAxis + sectionEnd, yPad + plotHeight );
				context.stroke();

				//key
				if( showKey ) {
					context.beginPath();
					context.moveTo( spaceForYAxis, itemPos - keySizeHalf );
					context.lineTo( spaceForYAxis, itemPos + keySizeHalf );
					context.stroke();

					context.beginPath();
					context.moveTo( spaceForYAxis, itemPos - keySizeHalf );
					context.lineTo( spaceForYAxis + keyWidth, itemPos + keySizeHalf );
					context.stroke();

					context.beginPath();
					context.moveTo( spaceForYAxis + keyWidth, itemPos - keySizeHalf );
					context.lineTo( spaceForYAxis + keyWidth, itemPos + keySizeHalf );
					context.stroke();
				}
			}

			if( showKey ) {
				//key text
				context.fillText( xBands[i].name, spaceForYAxis + keyWidth + keyGap, itemPos ); //half the font size
			}
		}
	}
	for( i = 0, keyCount = 0; i < yBands.length; i++ ) {
		if( yBands[i] && typeof(yBands[i].start) == 'number' && typeof(yBands[i].end) == 'number' ) {
			lineWidth = ( typeof(yBands[i].lineWidth) == 'number' ) ? Math.abs(yBands[i].lineWidth) || 0 : styles.bandLineWidth;
			context.fillStyle = context.strokeStyle = yBands[i].colour || yBands[i].color || 'hsla(' + ( 360 * i / yBands.length ) + ',100%,30%,0.9)';
			showKey = ( yBands[i].keyHidden == undefined && !styles.bandKeyHidden ) || ( yBands[i].keyHidden != undefined && !yBands[i].keyHidden );
			if( showKey ) {
				itemPos = mainHeight + dataKeyCount * dataKeyLineHeight + xBandKeyCount * bandKeyLineHeight + keyCount * bandKeyLineHeight + bandKeyLineMiddle; //space for each key line below the chart
				keyCount++;
			}

			if( lineWidth ) {
				context.lineWidth = lineWidth;
				sectionStart = ( yEnd - yBands[i].start ) * scalingFactorY;
				sectionEnd = ( yEnd - yBands[i].end ) * scalingFactorY;

				if( context.setLineDash ) {
					context.setLineDash( getDashPattern( yBands[i].dashPattern, i ) );
				}

				context.beginPath();
				context.moveTo( spaceForYAxis, yPad + sectionStart );
				context.lineTo( spaceForYAxis + plotWidth, yPad + sectionStart );
				context.stroke();

				context.beginPath();
				context.moveTo( spaceForYAxis, yPad + sectionEnd );
				context.lineTo( spaceForYAxis + plotWidth, yPad + sectionEnd );
				context.stroke();

				//key
				if( showKey ) {
					context.beginPath();
					context.moveTo( spaceForYAxis, itemPos - keySizeHalf );
					context.lineTo( spaceForYAxis + keyWidth, itemPos - keySizeHalf );
					context.stroke();

					context.beginPath();
					context.moveTo( spaceForYAxis, itemPos + keySizeHalf );
					context.lineTo( spaceForYAxis + keyWidth, itemPos + keySizeHalf );
					context.stroke();
				}
			}

			if( showKey ) {
				//key text
				context.fillText( yBands[i].name, spaceForYAxis + keyWidth + keyGap, itemPos ); //half the font size
			}
		}
	}
	if( debug.time ) { console.info( 'Finished rendering band boundaries and key at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	//draw the data and key
	if( typeof(handlers.pointRenderer) != 'function' ) {
		canvasCache = document.createElement('canvas');
		contextCache = canvasCache.getContext('2d')
	}
	maxPointOrLine = Math.max( maxPointSize, maxLineWidth / 2 );
	for( i = 0, keyCount = 0; i < dataSets.length; i++ ) {
		if( dataSets[i] ) {
			pointSize = ( typeof(dataSets[i].pointSize) == 'number' ) ? Math.abs(dataSets[i].pointSize) || 0 : styles.pointSize;
			pointRenderer = ( typeof(dataSets[i].pointRenderer) == 'function' ) ? dataSets[i].pointRenderer : ( ( typeof(handlers.pointRenderer) == 'function' ) ? handlers.pointRenderer : null );
			dataSetIndex = ( typeof(dataSets[i].pointSymbol) == 'number' ) ? Math.abs(Math.round(dataSets[i].pointSymbol)) || 0 : i;
			storedData[i] = { name: dataSets[i].name, yAxis: ( dataSets[i].yAxis == 'second' ) ? 'second' : 'first', colour: dataSets[i].colour || dataSets[i].color || 'hsl(' + ( 360 * i / dataSets.length ) + ',70%,45%)', pointSize: pointSize, data: [] };
			context.fillStyle = context.strokeStyle = storedData[i].colour;
			context.lineWidth = lineWidth = ( typeof(dataSets[i].lineWidth) == 'number' ) ? Math.abs(dataSets[i].lineWidth) || 0 : styles.dataLineWidth;
			if( lineWidth && context.setLineDash ) {
				context.setLineDash(getDashPattern(dataSets[i].dashPattern));
			}

			//key
			if( dataSets[i] ) {
				keyHidden = dataSets[i].keyHidden == undefined ? styles.dataKeyHidden : dataSets[i].keyHidden == 'no' ? false : dataSets[i].keyHidden;
				if( !keyHidden || ( keyHidden == 'empty' && dataSets[i].data && dataSets[i].data.length ) ) {
					itemPos = mainHeight + keyCount * dataKeyLineHeight + dataKeyLineMiddle; //space for each key line below the chart
					context.fillText( dataSets[i].name, spaceForYAxis + keyWidth + keyGap, itemPos );
					if( pointSize ) {
						if( pointRenderer ) {
							context.save();
							context.beginPath();
							context.textAlign = 'start';
							context.textBaseline = 'alphabetic';
							context.font = '10px sans-serif';
							pointRenderer( context, spaceForYAxis + keyWidth / 2, itemPos, pointSize, dataSetIndex, i, -1 );
							//just in case their code changes the settings
							context.restore();
						} else {
							drawPoint( spaceForYAxis + keyWidth / 2, itemPos, pointSize, dataSetIndex, storedData[i].colour, ( dataSets[i].data && dataSets[i].data.length ) || 0 );
						}
					}
					if( lineWidth ) {
						context.beginPath();
						context.moveTo( spaceForYAxis, itemPos );
						context.lineTo( spaceForYAxis + keyWidth, itemPos );
						context.stroke();
					}
					keyCount++;
					if( debug.time ) { console.info( 'Finished data set ' + i + ' key at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }
				}

				//clip to the chart area, with enough pixels on all sides to allow points to be drawn, to hide lines that extend out of the chart area
				context.save();
				context.beginPath();
				context.rect( spaceForYAxis - ( maxPointOrLine + 1 ), yPad - ( maxPointOrLine + 1 ), plotWidth + 2 * maxPointOrLine + 2, plotHeight + 2 * maxPointOrLine + 2 );
				context.clip();
				if( debug.time ) { console.info( 'Finished clipping at ' + i + ' key at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

				//point
				if( dataSets[i].yAxis == 'second' ) {
					whichOffset = yEnd2;
					whichFactor = scalingFactorY2;
				} else {
					whichOffset = yEnd;
					whichFactor = scalingFactorY;
				}
				for( j = 0; j < dataSets[i].data.length; j++ ) {
					if( dataSets[i].data[j] && typeof(dataSets[i].data[j].x) == 'number' && typeof(dataSets[i].data[j].y) == 'number' && !isNaN(dataSets[i].data[j].x) && !isNaN(dataSets[i].data[j].y) ) {
						storedData[i].data[j] = {
							posX: Math.max( xNegativeInfinite, Math.min( dataSets[i].data[j].x, xInfinite ) ),
							posY: Math.max( negativeInfinite, Math.min( dataSets[i].data[j].y, infinite ) )
						};
						storedData[i].data[j].posX = spaceForYAxis + ( storedData[i].data[j].posX / divisor - xStart ) * scalingFactorX;
						storedData[i].data[j].posY = yPad + ( whichOffset - storedData[i].data[j].posY ) * whichFactor;
						storedData[i].data[j].posX = Math.max( minCanvasCoord, Math.min( storedData[i].data[j].posX, maxCanvasCoord ) );
						storedData[i].data[j].posY = Math.max( minCanvasCoord, Math.min( storedData[i].data[j].posY, maxCanvasCoord ) );
						if( interaction.hoverTooltip ) {
							storedData[i].data[j].realX = dataSets[i].data[j].x;
							storedData[i].data[j].x = dataSets[i].data[j].x / divisor;
							storedData[i].data[j].y = dataSets[i].data[j].y;
						}
						if( pointSize ) {
							if( pointRenderer ) {
								context.save();
								context.beginPath();
								context.textAlign = 'start';
								context.textBaseline = 'alphabetic';
								context.font = '10px sans-serif';
								pointRenderer( context, storedData[i].data[j].posX, storedData[i].data[j].posY, pointSize, dataSetIndex, i, j );
								//just in case their code changes the settings
								context.restore();
							} else {
								drawPoint( storedData[i].data[j].posX, storedData[i].data[j].posY, pointSize, dataSetIndex, storedData[i].colour, dataSets[i].data.length );
							}
						}
					}
				}
				if( debug.time ) { console.info( 'Finished data set ' + i + ' points at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

				//line
				if( lineWidth && storedData[i].data.length ) {
					context.beginPath();
					hasData = false;
					for( j = 0; j < storedData[i].data.length; j++ ) {
						if( storedData[i].data[j] ) {
							if( hasData ) {
								context.lineTo( storedData[i].data[j].posX, storedData[i].data[j].posY );
							} else {
								context.moveTo( storedData[i].data[j].posX, storedData[i].data[j].posY );
								hasData = true;
							}
						}
					}
					context.stroke();
				}
				if( debug.time ) { console.info( 'Finished data set ' + i + ' line at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

				if( !interaction.hoverTooltip ) {
					//save memory
					storedData[i] = null;
				}

				//restore the saved context - disables clipping
				context.restore();
			}
		}
	}
	context.restore();
	context.beginPath();

	dataSets = xBands = yBands = null; //save memory
	if( debug.time ) { console.info( 'Finished rendering data and data key at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms.' ); }

	if( interaction.hoverTooltip ) {
		dateFormatter = undefined; //tooltips use a different format
		if( usedFormat ) {
			usedFormat.month = dateFormat.month == 'none' ? undefined : ( dateFormat.month || '2-digit' );
		}
		canvas.style.cursor = 'crosshair';
		if( styles.crosshairLineWidth ) {
			copied = document.createElement('canvas');
			copied.width = chartWidth;
			copied.height = chartHeight;
			copyContext = copied.getContext('2d');
			copyContext.drawImage( canvas, 0, 0 );
		}
		toolTip = document.createElement('div');
		toolTip.className = 'linecharttooltip' + ( parentElement.id ? ' linecharttooltip' + parentElement.id : '' );
		toolTip.style.position = 'absolute';
		toolTip.style.left = toolTip.style.top = toolTip.style.margin = '0';
		toolTip.style.display = 'none';
		toolTipBody = toolTip.appendChild(document.createElement('table'));
		toolTipBody.style.whiteSpace = toolTip.style.whiteSpace = 'nowrap';
		toolTipBody.appendChild(document.createElement('thead'));
		toolTipHead = toolTipBody.firstChild.appendChild(document.createElement('tr'));
		toolTipHead.appendChild(document.createElement('th'));
		toolTipHead.cells[0].textContent = strings.dataSet || 'Data set';
		toolTipHead.appendChild(document.createElement('th'));
		toolTipHead.cells[1].textContent = xTitle + addonText;
		firstTipTitle = document.createElement('th');
		firstTipTitle.textContent = yTitle;
		if( hasSecondYAxis ) {
			secondTipTitle = document.createElement('th');
			secondTipTitle.textContent = yTitle2;
		}
		toolTipBody = toolTipBody.appendChild(document.createElement('tbody'));
		document.documentElement.appendChild(toolTip);
		canvas.ontouchmove = function (e) {
			if( e.touches.length == 1 ) {
				this.onmousemove( e.touches[0], e );
			} else {
				this.onmouseout();
			}
		};
		canvas.onmousemove = function ( e, wasTouch ) {
			var allHits = [], subHits, element = canvas, canvasX = 0, canvasY = 0, diff, foundOne = false, i, j, hasNew = false, hasFirst = false, hasSecond = false, tempEl, tempRow, tempCell, xAxisInfo, yAxisInfo, pointInfo;
			var extraSize = ( typeof(interaction.hoverTooltip) == 'number' ) ? interaction.hoverTooltip : 0;
			do {
	      canvasX += element.offsetLeft;
	      canvasY += element.offsetTop;
	      element = element.offsetParent;
			} while(element);
			for( i = 0; i < storedData.length; i++ ) {
				if( storedData[i] ) {
					subHits = [];
					for( j = 0; j < storedData[i].data.length; j++ ) {
						if( storedData[i].data[j] ) {
							//cannot use e.offsetX, as that changes when passing over a highlight div
							diff = Math.sqrt( ( e.pageX - canvasX - storedData[i].data[j].posX ) * ( e.pageX - canvasX - storedData[i].data[j].posX ) + ( e.pageY - canvasY - storedData[i].data[j].posY ) * ( e.pageY - canvasY - storedData[i].data[j].posY ) );
							if( diff < storedData[i].pointSize + extraSize ) {
								subHits.push( { diff: diff, point: storedData[i].data[j], pointIndex: j } );
							}
						}
					}
					if( subHits.length ) {
						//get the nearest data point in this data set
						subHits.sort( function ( a, b ) { return a.diff - b.diff; } );
						allHits.push( { name: storedData[i].name, yAxis: storedData[i].yAxis, point: subHits[0].point, colour: storedData[i].colour, dataSetIndex: i, pointIndex: subHits[0].pointIndex, pointSize: storedData[i].pointSize, extraSize: extraSize } );
						if( storedData[i].yAxis != 'second' || dataRelated.ySecondConverter ) {
							hasFirst = true;
						}
						if( storedData[i].yAxis == 'second' || dataRelated.ySecondConverter ) {
							hasSecond = true;
						}
					}
				}
			}
			subHits = null; //save memory
			if( allHits.length ) {
				//work out if the tooltip needs to be recreated
				if( allHits.length != lastHits.length ) {
					hasNew = true;
				} else {
					for( i = 0; i < allHits.length; i++ ) {
						if( allHits[i].point != lastHits[i].point ) {
							hasNew = true;
							break;
						}
					}
				}
				if( hasNew ) {
					lastHits = allHits;
					if( hasFirst && !firstTipTitle.parentNode ) {
						if( hasSecondYAxis && secondTipTitle.parentNode ) {
							toolTipHead.insertBefore( firstTipTitle, secondTipTitle );
						} else {
							toolTipHead.appendChild(firstTipTitle);
						}
					} else if( !hasFirst && firstTipTitle.parentNode ) {
						toolTipHead.removeChild(firstTipTitle);
					}
					if( hasSecondYAxis && hasSecond && !secondTipTitle.parentNode ) {
						toolTipHead.appendChild(secondTipTitle);
					} else if( hasSecondYAxis && !hasSecond && secondTipTitle.parentNode ) {
						toolTipHead.removeChild(secondTipTitle);
					}
					toolTipBody.textContent = '';
					for( i = 0; i < allHits.length; i++ ) {
						//cache text; uses memory but means that it does not have to call replacer or date processing functions again, improving performance
						if( !( 'xText' in allHits[i].point ) || !( 'yText' in allHits[i].point ) ) {
							pointInfo = { x: allHits[i].point.realX, y: allHits[i].point.y, ySecondConverted: null, dataSetIndex: allHits[i].dataSetIndex, dataSetName: allHits[i].name, pointIndex: allHits[i].pointIndex };
						}
						if( !( 'xText' in allHits[i].point ) ) {
							xAxisInfo = { xType: dataRelated.xType, divisor: divisor, datePrecision: workingIn, dateFormat: dataRelated.dateTime ? dateFormat : undefined }
							if( xTextReplacer ) {
								allHits[i].point.xText = xTextReplacer( allHits[i].point.x, xAxisInfo, null, pointInfo );
							} else {
								allHits[i].point.xText = dataRelated.dateTime ? niceDate( allHits[i].point.x, utcOrNot, 'milliseconds', dateFormat ) : allHits[i].point.x;
							}
						}
						if( !( 'yText' in allHits[i].point ) ) {
							yAxisInfo = { yAxis: allHits[i].yAxis };
							if( allHits[i].yAxis == 'second' && yTextReplacer2 ) {
								allHits[i].point.yText = yTextReplacer2( allHits[i].point.y, null, yAxisInfo, pointInfo );
							} else if( allHits[i].yAxis == 'first' && yTextReplacer ) {
								allHits[i].point.yText = yTextReplacer( allHits[i].point.y, null, yAxisInfo, pointInfo );
							} else {
								allHits[i].point.yText = allHits[i].point.y;
							}
							if( dataRelated.ySecondConverter ) {
								if( allHits[i].yAxis == 'second' ) {
									allHits[i].point.yMultipliedText = allHits[i].point.y / dataRelated.ySecondConverter.ratio + dataRelated.ySecondConverter.offset;
								} else {
									allHits[i].point.yMultipliedText = ( allHits[i].point.y - dataRelated.ySecondConverter.offset ) * dataRelated.ySecondConverter.ratio;
								}
								allHits[i].point.yMultipliedText = Math.max( negativeInfinite, Math.min( allHits[i].point.yMultipliedText, infinite ) );
								pointInfo = { x: allHits[i].point.realX, y: allHits[i].point.y, ySecondConverted: { dataSetYAxis: ( allHits[i].yAxis == 'second' ) ? 'second' : 'first', raw: allHits[i].point.yMultipliedText }, dataSetIndex: allHits[i].dataSetIndex, dataSetName: allHits[i].name, pointIndex: allHits[i].pointIndex };
								allHits[i].point.yMultipliedText = fixFloatingPoint(allHits[i].point.yMultipliedText);
								if( allHits[i].yAxis == 'second' && yTextReplacer ) {
									allHits[i].point.yMultipliedText = yTextReplacer( allHits[i].point.yMultipliedText, null, { yAxis: 'first' }, pointInfo );
								} else if( allHits[i].yAxis != 'second' && yTextReplacer2 ) {
									allHits[i].point.yMultipliedText = yTextReplacer2( allHits[i].point.yMultipliedText, null, { yAxis: 'second' }, pointInfo );
								}
							}
						}
						toolTipBody.appendChild(document.createElement('tr'));
						toolTipBody.lastChild.appendChild(document.createElement('th'));
						toolTipBody.lastChild.lastChild.style.color = allHits[i].colour;
						toolTipBody.lastChild.lastChild.textContent = allHits[i].name;
						toolTipBody.lastChild.appendChild(document.createElement('td'));
						toolTipBody.lastChild.lastChild.textContent = allHits[i].point.xText;
						toolTipBody.lastChild.appendChild(document.createElement('td'));
						if( !hasFirst || allHits[i].yAxis != 'second' ) {
							toolTipBody.lastChild.lastChild.textContent = allHits[i].point.yText;
						} else if( dataRelated.ySecondConverter ) {
							toolTipBody.lastChild.lastChild.textContent = allHits[i].point.yMultipliedText;
						}
						if( hasFirst && hasSecond ) {
							toolTipBody.lastChild.appendChild(document.createElement('td'));
							if( allHits[i].yAxis == 'second' ) {
								toolTipBody.lastChild.lastChild.textContent = allHits[i].point.yText;
							} else if( dataRelated.ySecondConverter ) {
								toolTipBody.lastChild.lastChild.textContent = allHits[i].point.yMultipliedText;
							}
						}
					}
				}
				if( ( hasNew || !toolTipShowing ) && styles.crosshairLineWidth ) {
					//use the main canvas, not an overlay, so right click - copy works
					context.save();
					context.clearRect( 0, 0, chartWidth, chartHeight );
					context.drawImage( copied, 0, 0 );
					for( i = 0; i < allHits.length; i++ ) {
						if( context.setLineDash ) {
							context.setLineDash( styles.crosshairDashPattern );
						}
						context.lineWidth = styles.crosshairLineWidth;
						context.strokeStyle = allHits[i].colour;
						context.beginPath();
						context.moveTo( allHits[i].point.posX, yPad + plotHeight );
						context.lineTo( allHits[i].point.posX, yPad );
						context.moveTo( spaceForYAxis + ( ( allHits[i].yAxis == 'second' ) ? plotWidth : 0 ), allHits[i].point.posY );
						context.lineTo( spaceForYAxis + ( ( allHits[i].yAxis == 'second' ) ? 0 : plotWidth ), allHits[i].point.posY );
						context.stroke();
					}
					context.restore();
				}
				//have to show it before it can be measured
				//there is a chance that this can cause a scrollbar flicker, but the only other option is to move it out of sight, which could cause the tooltip to flicker instead
				if( !toolTipShowing ) {
					toolTipShowing = true;
					toolTip.style.display = '';
				}
				toolTip.style.left = ( ( e.pageX + 10 + toolTip.offsetWidth > window.innerWidth + window.pageXOffset - 20 ) ? Math.max( 0, e.pageX - 10 - toolTip.offsetWidth ) : e.pageX + 10 ) + 'px';
				toolTip.style.top = ( ( e.pageY + 10 + toolTip.offsetHeight > window.innerHeight + window.pageYOffset - 20 ) ? Math.max( 0, e.pageY - 10 - toolTip.offsetHeight ) : e.pageY + 10 ) + 'px';
			} else {
				if( toolTipShowing ) {
					toolTipShowing = false;
					toolTip.style.display = 'none';
					if( styles.crosshairLineWidth ) {
						context.clearRect( 0, 0, chartWidth, chartHeight );
						context.drawImage( copied, 0, 0 );
					}
				}
			}
			if( wasTouch && e.pageX - canvasX >= spaceForYAxis && e.pageX - canvasX <= spaceForYAxis + plotWidth && e.pageY - canvasY >= yPad && e.pageY - canvasY <= yPad + plotHeight ) {
				wasTouch.preventDefault();
			}
		};
		canvas.onmouseout = function (e) {
			if( toolTipShowing ) {
				toolTipShowing = false;
				toolTip.style.display = 'none';
				if( styles.crosshairLineWidth ) {
					context.clearRect( 0, 0, chartWidth, chartHeight );
					context.drawImage( copied, 0, 0 );
				}
			}
		};
	}
	if( debug.time ) { console.info( 'Finished setting up tooltip and mouse handling at ' + ( ( new Date() ).getTime() - startedAt.getTime() ) + 'ms. Rendering complete.' ); }

	return { canvas: canvas, workingCanvas: copied, tooltip: toolTip };

}