trading-charts
Version:
A lightweight trading chart library specially designed for small screens like mobile
1,187 lines (1,000 loc) • 65.8 kB
JavaScript
/*
An open-source single-file JavaScript class to draw candlestick charts.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
function Candlestick( timestamp , open , high , low , close )
{
this.timestamp = parseInt(timestamp);
this.open = parseFloat(open);
this.high = parseFloat(high);
this.low = parseFloat(low);
this.close = parseFloat(close);
}
Candlestick.prototype.update = function( open , high , low , close )
{
this.open = parseFloat(open);
this.high = parseFloat(high);
this.low = parseFloat(low);
this.close = parseFloat(close);
}
function CandlestickChart( parentElementID , options )
{
if ( options !== undefined ) this.options = options;
else this.options = {};
if ( !this.options.hasOwnProperty( "overlayMode" ) ) this.options.overlayMode = "hover";
if ( !this.options.hasOwnProperty( "disableGrid" ) ) this.options.disableGrid = false;
if ( !this.options.hasOwnProperty( "disableLegend" ) ) this.options.disableLegend = false;
if ( !this.options.hasOwnProperty( "disableLowHighPriceDisplay" ) ) this.options.disableLowHighPriceDisplay = false;
if ( !this.options.hasOwnProperty( "disableCrosshair" ) ) this.options.disableCrosshair = false;
if ( !this.options.hasOwnProperty( "disableCurrentMarketPrice" ) ) this.options.disableCurrentMarketPrice = false;
if ( !this.options.hasOwnProperty( "disablePanningAcceleration" ) ) this.options.disablePanningAcceleration = false;
if ( !this.options.hasOwnProperty( "allowOverPanning" ) ) this.options.allowOverPanning = false;
if ( !this.options.hasOwnProperty( "overlayShowTime" ) ) this.options.overlayShowTime = true;
if ( !this.options.hasOwnProperty( "overlayShowData" ) ) this.options.overlayShowData = true;
if ( !this.options.hasOwnProperty( "overlayShowChange" ) ) this.options.overlayShowChange = true;
var parent = document.getElementById( parentElementID );
parent.style.height = "100%";
parent.style.width = "100%";
parent.style.position = "relative";
var canvasParent = parent;
var el = document.createElement('canvas');
this.canvas = parent.appendChild(el);
this.canvas.style.position = "absolute";
this.canvas.style.top = 0;
this.canvas.style.left = 0;
this.canvas.style.touchAction = "none";
this.canvas.style.width = "100%";
this.canvas.style.height = "100%";
this.canvas.style.maxWidth = "100%";
this.canvas.style.maxHeight = "100%";
// don't let the canvas size get to 0
this.canvas.style.minWidth = "20px";
this.canvas.style.minHeight = "20px";
// to make sure the canvas is sharp on devices with a high resolution screen, we have to make the canvas bigger and scale all draw operations by the same factor, while downscaling the canvas back to its original size via its css width and height properties
this.devicePixelRatio = window.devicePixelRatio || 1;
this.canvas.width = parent.clientWidth * this.devicePixelRatio;
this.width = parseInt( canvasParent.getBoundingClientRect().width );
this.canvas.height = parent.clientHeight * this.devicePixelRatio;
this.height = parseInt( canvasParent.getBoundingClientRect().height );
// create the drawing context
this.context = this.canvas.getContext( "2d" );
// scale everything by the devices pixel ratio, this is 1 for most normal screens, but can be higher than 1 for high resolutions screens
this.context.scale( this.devicePixelRatio , this.devicePixelRatio );
this.context.lineWidth = 1;
// https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
const resizeObserver = new ResizeObserver( entries =>
{
for ( let entry of entries )
{
if ( entry.contentBoxSize )
{
var canvasParent = this.canvas.parentElement;
this.canvas.width = parent.clientWidth * this.devicePixelRatio;
this.width = parseInt( canvasParent.getBoundingClientRect().width );
this.xPixelRange = this.width-this.marginLeft-this.marginRight;
this.canvas.height = parent.clientHeight * this.devicePixelRatio;
this.height = parseInt( canvasParent.getBoundingClientRect().height );
this.yPixelRange = this.height-this.marginTop-this.marginBottom;
this.context = this.canvas.getContext( "2d" );
this.context.scale( this.devicePixelRatio , this.devicePixelRatio );
this.draw();
}
}
} );
// observe the body
resizeObserver.observe( document.body );
// mouse events
this.canvas.addEventListener( "mousemove" , ( e ) => {
this.mouseMove( e );
} );
this.canvas.addEventListener( "mouseout" , ( e ) => {
this.mouseOut( e );
} );
this.canvas.addEventListener( "wheel" , ( e ) => {
this.scroll( e );
e.preventDefault();
} );
// touch events - https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
this.canvas.addEventListener( "pointerdown" , ( e ) => {
this.b_dragging = true;
this.mouseDragStartX = e.offsetX;
this.mouseDragStartY = e.offsetY;
e.b_hasBeenMoved = false;
e.totalMovement = 0.0;
this.evCache.push( e );
} );
this.canvas.addEventListener( "pointermove" , ( e ) => {
this.pointerMoveEvent( e );
} );
this.canvas.addEventListener( "pointerup" , ( e ) => {
this.pointerUpEvent( e , true );
} );
this.canvas.addEventListener( "pointercancel" , ( e ) => {
this.pointerUpEvent( e , false );
} );
this.canvas.addEventListener( "pointerout" , ( e ) => {
this.pointerUpEvent( e , false );
} );
this.canvas.addEventListener( "pointerleave" , ( e ) => {
this.pointerUpEvent( e , false );
} );
// global vars to cache event state for pinch to zoom
this.evCache = new Array();
this.prevDiff = -1;
this.b_dragging = false;
this.mouseDragStartX = 0;
this.mouseDragStartY = 0;
this.scrollOffsetX = 0;
this.scrollOffsetXCounter = 0;
this.defaultDecimalPlaces = 1;
// background color
if ( this.options && this.options.hasOwnProperty( "backgroundColor" ) ) this.canvas.style.backgroundColor = this.options.backgroundColor;
else this.canvas.style.backgroundColor = "#252525";
// font
if ( !this.options.hasOwnProperty( "fontSize" ) ) this.options.fontSize = 12;
if ( !this.options.hasOwnProperty( "font" ) ) this.options.font = "sans-serif";
// mouse hover background color
if ( this.options && this.options.hasOwnProperty( "overlayBackgroundColor" ) ) this.overlayBackgroundColor = this.options.overlayBackgroundColor;
else this.overlayBackgroundColor = "#eeeeee";
// mouse hover text color
if ( this.options && this.options.hasOwnProperty( "overlayTextColor" ) ) this.overlayTextColor = this.options.overlayTextColor;
else this.overlayTextColor = "#000000";
// green color
if ( this.options && this.options.hasOwnProperty( "greenColor" ) ) this.greenColor = this.options.greenColor;
else this.greenColor = "#00cc00";
// red color
if ( this.options && this.options.hasOwnProperty( "redColor" ) ) this.redColor = this.options.redColor;
else this.redColor = "#cc0000";
// green hover color
if ( this.options && this.options.hasOwnProperty( "greenHoverColor" ) ) this.greenHoverColor = this.options.greenHoverColor;
else this.greenHoverColor = "#00ff00";
// red hover color
if ( this.options && this.options.hasOwnProperty( "redHoverColor" ) ) this.redHoverColor = this.options.redHoverColor;
else this.redHoverColor = "#ff0000";
// wickWidth
if ( !this.options.hasOwnProperty( "wickWidth" ) ) this.options.wickWidth = 1;
if ( !this.options.hasOwnProperty( "wickGreenColor" ) ) this.options.wickGreenColor = this.greenColor;
if ( !this.options.hasOwnProperty( "wickRedColor" ) ) this.options.wickRedColor = this.redColor;
// crosshair options
if ( !this.options.hasOwnProperty( "crosshairColor" ) ) this.options.crosshairColor = "#eeeeee";
if ( !this.options.hasOwnProperty( "crosshairTextColor" ) ) this.options.crosshairTextColor = "#000000";
if ( !this.options.hasOwnProperty( "crosshairLineStyle" ) ) this.options.crosshairLineStyle = "dashed";
if ( !this.options.hasOwnProperty( "crosshairLabelBackgroundColor" ) ) this.options.crosshairLabelBackgroundColor = this.options.crosshairColor;
if ( !this.options.hasOwnProperty( "candleBorderWidth" ) ) this.options.candleBorderWidth = 0;
// watermark image
if ( !this.options.hasOwnProperty( "watermarkImage" ) ) this.options.watermarkImage = "";
this.watermarkImage = null;
this.b_watermarkImageLoaded = false;
if ( this.options.watermarkImage !== "" )
{
this.watermarkImage = new Image();
this.watermarkImage.src = this.options.watermarkImage;
this.watermarkImage.onload = () => {
this.b_watermarkImageLoaded = true;
}
}
// margins
if ( this.options && this.options.hasOwnProperty( "marginLeft" ) ) this.marginLeft = parseInt( this.options.marginLeft );
else this.marginLeft = 10;
if ( this.options && this.options.hasOwnProperty( "marginRight" ) ) this.marginRight = parseInt( this.options.marginRight );
else this.marginRight = 100;
if ( this.options && this.options.hasOwnProperty( "marginTop" ) ) this.marginTop = parseInt( this.options.marginTop );
else this.marginTop = 20;
if ( this.options && this.options.hasOwnProperty( "marginBottom" ) ) this.marginBottom = parseInt( this.options.marginBottom );
else this.marginBottom = 50;
if ( !this.options.hasOwnProperty( "marketPriceLineColor" ) ) this.options.marketPriceLineColor = "#777777";
if ( !this.options.hasOwnProperty( "marketPriceTextColor" ) ) this.options.marketPriceTextColor = "#ffffff";
// zoom sensitivity
// scrolling with wheel
if ( this.options.hasOwnProperty( "scrollZoomSensitivity" ) ) this.scrollZoomSensitivity = this.options.scrollZoomSensitivity;
else this.scrollZoomSensitivity = 20;
// pinch to zoom
if ( this.options.hasOwnProperty( "touchZoomSensitivity" ) ) this.touchZoomSensitivity = this.options.touchZoomSensitivity;
else this.touchZoomSensitivity = 50;
// not really used anymore because of the 1:1 panning controls, but it's still used in the panning acceleration
this.panningSensitivity = 500;
// these are only approximations, the grid will be divided in a way so the numbers are nice
if ( this.options.hasOwnProperty( "xGridCells" ) ) this.xGridCells = this.options.xGridCells;
else this.xGridCells = 16;
if ( this.options.hasOwnProperty( "yGridCells" ) ) this.yGridCells = this.options.yGridCells;
else this.yGridCells = 16;
if ( !this.options.hasOwnProperty( "gridLineStyle" ) ) this.options.gridLineStyle = "dotted";
// grid color
if ( this.options && this.options.hasOwnProperty( "gridColor" ) ) this.gridColor = this.options.gridColor;
else this.gridColor = "#444444";
// grid text color
if ( this.options && this.options.hasOwnProperty( "gridTextColor" ) ) this.gridTextColor = this.options.gridTextColor;
else this.gridTextColor = "#aaaaaa";
this.candleWidth = 5;
this.yStart = 0;
this.yEnd = 0;
this.yRange = 0;
this.yPixelRange = this.height-this.marginTop-this.marginBottom;
this.yStartIndex = 0;
this.yEndIndex = 0;
this.xStart = 0;
this.xEnd = 0;
this.xRange = 0;
this.xPixelRange = this.width-this.marginLeft-this.marginRight;
this.b_drawMouseOverlay = false;
this.mousePosition = { x: 0 , y: 0 };
this.xMouseHover = 0;
this.yMouseHover = 0;
this.hoveredCandlestickID = 0;
this.b_moveOverlay = false;
this.b_pinchZooming = false;
this.panningDeltaX = 0;
if ( this.options.hasOwnProperty( "panningAccelerationDamping" ) ) this.panningAccelerationDamping = this.options.panningAccelerationDamping;
else this.panningAccelerationDamping = 0.975;
this.panningAtStartCallback = () => {};
this.panningAtStartCooldown = 0;
this.panningAtEndCallback = () => {};
this.panningAtEndCooldown = 0;
this.marketPriceRect = { x: 0, y: 0, width: 0, height: 0, b_clickable: false };
this.b_firstDraw = true;
// when zooming, just start at a later candlestick in the array
this.zoomStartID = 0;
this.candlesticks = [];
}
CandlestickChart.prototype.clear = function()
{
this.candlesticks = [];
this.draw();
}
CandlestickChart.prototype.addCandlestick = function( timestamp , open , high , low , close )
{
if ( !this.checkValidValues( timestamp , open , high , low , close , "addCandlestick()" ) )
{
return;
}
var candlestick = new Candlestick( timestamp , open , high , low , close );
// don't add candlesticks if the low is bigger than the high
if ( candlestick.low > candlestick.high )
{
console.log( "ERROR > addCandlestick() > the low value of this candle is higher than its high value, it won't be added" );
return;
}
// don' t add candlesticks with the same timestamp
for ( let i = 0 ; i < this.candlesticks.length ; ++i )
{
if ( this.candlesticks[i].timestamp == candlestick.timestamp )
{
console.log( "ERROR > addCandlestick() > a candlestick with the same timestamp already exists, it won't be added twice" );
return;
}
}
if ( this.candlesticks.length > 0 )
{
if ( candlestick.timestamp < this.candlesticks[0].timestamp )
{
this.zoomStartID++;
}
if ( candlestick.timestamp > this.candlesticks[this.candlesticks.length-1].timestamp )
{
this.zoomStartID++;
this.scrollOffsetX--;
this.scrollOffsetXCounter--;
if ( this.zoomStartID > this.candlesticks.length-50 )
{
this.zoomStartID = this.candlesticks.length-50;
}
if ( this.zoomStartID+this.scrollOffsetX < 0 )
{
this.scrollOffsetX = -this.zoomStartID;
this.scrollOffsetXCounter = -this.zoomStartID;
}
}
}
// add the candlestick
this.candlesticks.push( candlestick );
// pick a sensible default number of decimal places according to the first candlesticks value
if ( this.candlesticks.length == 1 )
{
var value = this.candlesticks[0].close;
if ( value > 1.0 ) this.defaultDecimalPlaces = 2;
else if ( value > 0.001 ) this.defaultDecimalPlaces = 4;
else if ( value > 0.00001 ) this.defaultDecimalPlaces = 6;
else if ( value > 0.0000001 ) this.defaultDecimalPlaces = 8;
else this.defaultDecimalPlaces = 10;
}
// sort the candlesticks according to their timestamps
this.candlesticks.sort( ( a , b ) => {
if ( a.timestamp < b.timestamp ) return -1;
if ( a.timestamp > b.timestamp ) return 1;
return 0;
} );
}
CandlestickChart.prototype.updateCandlestick = function( timestamp , open , high , low , close )
{
if ( this.candlesticks.length == 0 ) return;
if ( !this.checkValidValues( timestamp , open , high , low , close , "updateCandlestick()" ) )
{
return;
}
var lastCandlestick = this.candlesticks[this.candlesticks.length-1];
// check if the candlestick already exists in the chart
if ( lastCandlestick.timestamp == timestamp )
{
lastCandlestick.update( open , high , low , close );
}
else
{
// if the candlestick does not exist in the chart, add a new one
this.addCandlestick( timestamp , open , high , low , close );
}
// update the chart
this.draw();
}
CandlestickChart.prototype.checkValidValues = function( timestamp , open , high , low , close , functionName )
{
// check if all values are numbers (or can be converted to one)
if ( isNaN( timestamp ) )
{
console.log( "ERROR > "+functionName+" > timestamp is not a number: "+timestamp );
return false;
}
if ( isNaN( open ) )
{
console.log( "ERROR > "+functionName+" > open is not a number: "+open );
return false;
}
if ( isNaN( high ) )
{
console.log( "ERROR > "+functionName+" > high is not a number: "+high );
return false;
}
if ( isNaN( low ) )
{
console.log( "ERROR > "+functionName+" > low is not a number: "+low );
return false;
}
if ( isNaN( close ) )
{
console.log( "ERROR > "+functionName+" > close is not a number: "+close );
return false;
}
// check if the timestamp is a valid unix timestamp
if ( !( typeof timestamp == 'number' && timestamp >= -8.64e12 && timestamp <= +8.64e12 ) )
{
console.log( "ERROR > "+functionName+" > timestamp is not a valid unix timestamp: "+timestamp );
return false;
}
return true;
}
CandlestickChart.prototype.mouseMove = function( e )
{
if ( this.candlesticks.length == 0 ) return;
if ( this.options.overlayMode === "hover" )
{
this.updateOverlay( e );
}
}
CandlestickChart.prototype.mouseOut = function( e )
{
if ( this.options.overlayMode === "hover" )
{
this.b_drawMouseOverlay = false;
this.draw();
}
}
CandlestickChart.prototype.scroll = function( e )
{
// disable zooming while the overlay is active?
//if ( this.options.overlayMode === "click" && this.b_drawMouseOverlay ) return;
var currentZoomRange = this.candlesticks.length-this.zoomStartID;
if ( e.deltaY < 0 )
{
if ( this.candlesticks.length > 50 )
{
this.zoomStartID += currentZoomRange/this.scrollZoomSensitivity;
this.zoomStartID = Math.floor( this.zoomStartID );
if ( this.zoomStartID > this.candlesticks.length-50 ) this.zoomStartID = this.candlesticks.length-50;
}
}
else
{
this.zoomStartID -= currentZoomRange/this.scrollZoomSensitivity;
this.zoomStartID = Math.floor( this.zoomStartID );
if ( this.zoomStartID < 0 ) this.zoomStartID = 0;
}
this.limitScrollOffset();
if ( this.options.overlayMode === "hover" )
{
this.updateOverlay( e );
}
else if ( this.options.overlayMode === "click" && this.b_drawMouseOverlay )
{
this.moveOverlay( e );
}
else this.draw();
}
CandlestickChart.prototype.pointerMoveEvent = function( e )
{
// find this event in the cache and update its record with this event
for ( var i = 0 ; i < this.evCache.length ; i++ )
{
if ( e.pointerId == this.evCache[i].pointerId )
{
var totalMovement = this.evCache[i].totalMovement;
var dx = Math.abs( this.evCache[i].clientX - e.clientX );
var dy = Math.abs( this.evCache[i].clientY - e.clientY );
this.evCache[i] = e;
this.evCache[i].b_hasBeenMoved = true;
// count the total movement of the event (0 movement for detecting a click on a touch device is a bit tricky, so we should count very small movements as clicks as well)
var distance = Math.sqrt( dx*dx + dy*dy );
this.evCache[i].totalMovement = totalMovement + distance;
break;
}
}
// if two pointers are down, check for pinch gestures
if ( this.evCache.length == 2 )
{
// disable pinch to zoom while the overlay is active?
// if ( this.options.overlayMode === "click" && this.b_drawMouseOverlay )
// {
// e.preventDefault();
// e.stopPropagation();
// return;
// }
// calculate the distance between the two pointers
var curDiff = Math.abs( this.evCache[0].clientX - this.evCache[1].clientX );
if ( this.prevDiff > 0 )
{
var currentZoomRange = this.candlesticks.length-this.zoomStartID;
if ( curDiff > this.prevDiff )
{
if ( this.candlesticks.length > 50 )
{
this.zoomStartID += currentZoomRange/this.touchZoomSensitivity;
this.zoomStartID = Math.floor( this.zoomStartID );
if ( this.zoomStartID > this.candlesticks.length-50 ) this.zoomStartID = this.candlesticks.length-50;
}
this.limitScrollOffset();
if ( this.options.overlayMode === "click" && this.b_drawMouseOverlay )
{
this.moveOverlay( e );
}
this.draw();
}
if ( curDiff < this.prevDiff )
{
this.zoomStartID -= currentZoomRange/this.touchZoomSensitivity;
this.zoomStartID = Math.floor( this.zoomStartID );
if ( this.zoomStartID < 0 ) this.zoomStartID = 0;
this.limitScrollOffset();
if ( this.options.overlayMode === "click" && this.b_drawMouseOverlay )
{
this.moveOverlay( e );
}
this.draw();
}
}
this.prevDiff = curDiff;
this.b_pinchZooming = true;
e.preventDefault();
e.stopPropagation();
return;
}
if ( this.evCache.length == 1 )
{
// if this a leftover from pinch zooming, e.g. one finger is released slower then the other one, just do nothing until both fingers have left the surface
if ( this.b_pinchZooming )
{
e.preventDefault();
e.stopPropagation();
return;
}
// update the overlay via dragging when the overlay is active
if ( this.options.overlayMode === "click" && this.b_drawMouseOverlay )
{
// only trigger this after a somewhat significant movement (otherwise messy touch events would trigger this as well)
if ( this.evCache[i].totalMovement > 10 )
{
this.updateOverlay( e );
}
return;
}
if ( this.candlesticks.length > 1 )
{
// calculate how many pixels are between two candles
var pixelDifference = this.xToPixelCoords(this.candlesticks[1].timestamp)-this.xToPixelCoords(this.candlesticks[0].timestamp);
this.panningDeltaX = Math.floor( e.offsetX-this.mouseDragStartX );
this.scrollOffsetXCounter -= this.panningDeltaX/pixelDifference;
this.scrollOffsetX = Math.floor( this.scrollOffsetXCounter );
this.mouseDragStartX = e.offsetX;
this.limitScrollOffset();
this.draw();
}
e.preventDefault();
e.stopPropagation();
return;
}
}
CandlestickChart.prototype.pointerUpEvent = function( e , b_pointerUpEvent )
{
for ( var i = 0 ; i < this.evCache.length ; i++ )
{
if ( this.evCache[i].pointerId == e.pointerId )
{
// it's only a click if the pointer has not been moved (or moved very little) since its down event
if ( !this.evCache[i].b_hasBeenMoved || this.evCache[i].totalMovement < 20 )
{
this.click( e );
}
this.evCache.splice( i , 1 );
break;
}
}
if ( this.evCache.length < 2 )
{
this.prevDiff = -1;
}
if ( this.evCache.length == 0 )
{
if ( b_pointerUpEvent )
{
if ( this.b_pinchZooming === false )
{
if ( this.options.disablePanningAcceleration === false )
{
if ( Math.abs(this.panningDeltaX) > 1 )
{
var currentZoomRange = this.candlesticks.length-this.zoomStartID;
this.scrollOffsetXCounter -= this.panningDeltaX*(currentZoomRange/this.panningSensitivity);
this.scrollOffsetX = Math.floor( this.scrollOffsetXCounter );
this.limitScrollOffset();
this.draw();
setTimeout( () => { this.panningAccelerationLoop() } , 20 );
}
}
}
}
this.b_dragging = false;
this.b_pinchZooming = false;
}
e.preventDefault();
e.stopPropagation();
}
CandlestickChart.prototype.panningAccelerationLoop = function()
{
this.panningDeltaX *= this.panningAccelerationDamping;
var currentZoomRange = this.candlesticks.length-this.zoomStartID;
this.scrollOffsetXCounter -= this.panningDeltaX*(currentZoomRange/this.panningSensitivity);
this.scrollOffsetX = Math.floor( this.scrollOffsetXCounter );
this.limitScrollOffset();
this.draw();
if ( Math.abs( this.panningDeltaX ) > 0.5 )
{
setTimeout( () => { this.panningAccelerationLoop() } , 20 );
}
}
CandlestickChart.prototype.click = function( e )
{
// check if the market price marker is clicked
var getMousePos = ( e ) =>
{
var rect = this.canvas.getBoundingClientRect();
return { x: e.clientX-rect.left , y: e.clientY-rect.top };
}
var mousePosition = getMousePos( e );
if ( this.marketPriceRect.b_clickable )
{
if ( mousePosition.x > this.marketPriceRect.x && mousePosition.x < this.marketPriceRect.x+this.marketPriceRect.width )
{
if ( mousePosition.y > this.marketPriceRect.y && mousePosition.y < this.marketPriceRect.y+this.marketPriceRect.height )
{
this.scrollOffsetX = 0;
this.scrollOffsetXCounter = 0;
// when over panning is enabled, leave a bit of empty space at the end
if ( this.options.allowOverPanning )
{
var currentZoomRange = this.candlesticks.length-this.zoomStartID;
this.scrollOffsetXCounter = Math.floor( currentZoomRange/4 );
this.scrollOffsetX = Math.floor( this.scrollOffsetXCounter );
}
this.draw();
return;
}
}
}
if ( this.options.overlayMode === "click" )
{
if ( !this.b_drawMouseOverlay )
{
this.updateOverlay( e );
}
else
{
this.b_drawMouseOverlay = false;
this.draw();
}
}
}
// this function updates the overlay and its position, i.e. a new candle is focussed
CandlestickChart.prototype.updateOverlay = function( e )
{
var getMousePos = ( e ) =>
{
var rect = this.canvas.getBoundingClientRect();
return { x: e.clientX-rect.left , y: e.clientY-rect.top };
}
this.mousePosition = getMousePos( e );
this.mousePosition.x += this.candleWidth/2;
this.b_drawMouseOverlay = true;
if ( this.mousePosition.x < this.marginLeft ) this.b_drawMouseOverlay = false;
if ( this.mousePosition.x > this.width-this.marginRight+this.candleWidth ) this.b_drawMouseOverlay = false;
if ( this.mousePosition.y > this.height-this.marginBottom ) this.b_drawMouseOverlay = false;
if ( this.b_drawMouseOverlay )
{
this.yMouseHover = this.yToValueCoords( this.mousePosition.y );
this.overlayPriceValue = this.roundPriceValue( this.yMouseHover );
this.xMouseHover = this.xToValueCoords( this.mousePosition.x );
// snap to candlesticks
var candlestickDelta = this.candlesticks[1].timestamp-this.candlesticks[0].timestamp;
this.hoveredCandlestickID = Math.floor((this.xMouseHover-this.candlesticks[0].timestamp)/candlestickDelta);
this.xMouseHover = Math.floor(this.xMouseHover/candlestickDelta)*candlestickDelta;
this.mousePosition.x = this.xToPixelCoords( this.xMouseHover );
this.draw();
}
else this.draw();
}
// this function only moves the overlay on the chart and the same candle remains focussed
CandlestickChart.prototype.moveOverlay = function( e )
{
if ( this.b_drawMouseOverlay )
{
this.b_moveOverlay = true;
// the actual moving is done in the draw function, because it needs to happen after rescaling
this.draw();
}
}
CandlestickChart.prototype.limitScrollOffset = function()
{
if ( this.zoomStartID+this.scrollOffsetX < 0 )
{
this.scrollOffsetX = -this.zoomStartID;
this.scrollOffsetXCounter = -this.zoomStartID;
// don't call the callback more than once per second
if ( Date.now() > this.panningAtStartCooldown )
{
this.panningAtStartCallback();
this.panningAtStartCooldown = Date.now()+1000;
console.log( "panning at start" );
}
}
if ( this.options.allowOverPanning )
{
var currentZoomRange = this.candlesticks.length-this.zoomStartID;
if ( this.scrollOffsetX > Math.floor(currentZoomRange/2) )
{
this.scrollOffsetX = Math.floor(currentZoomRange/2);
this.scrollOffsetXCounter = Math.floor(currentZoomRange/2);
// don't call the callback more than once per second
if ( Date.now() > this.panningAtEndCooldown )
{
this.panningAtEndCallback();
this.panningAtEndCooldown = Date.now()+1000;
console.log( "panning at end" );
}
}
}
else
{
if ( this.scrollOffsetX > 0 )
{
this.scrollOffsetX = 0;
this.scrollOffsetXCounter = 0;
// don't call the callback more than once per second
if ( Date.now() > this.panningAtEndCooldown )
{
this.panningAtEndCallback();
this.panningAtEndCooldown = Date.now()+1000;
console.log( "panning at end" );
}
}
}
}
CandlestickChart.prototype.draw = function()
{
// clear background
this.context.clearRect( 0 , 0 , this.width , this.height );
// set the font
this.context.font = this.options.fontSize+"px "+this.options.font;
// watermark image
if ( this.watermarkImage !== null && this.b_watermarkImageLoaded )
{
var x = (this.width - this.watermarkImage.width ) * 0.5;
var y = (this.height - this.watermarkImage.height) * 0.5;
this.context.drawImage( this.watermarkImage , x , y );
}
if ( this.candlesticks.length == 0 ) return;
// on the first draw, set the zoom and pan/scroll to the end
if ( this.b_firstDraw )
{
this.zoomStartID = this.candlesticks.length-200;
if ( this.zoomStartID < 0 ) this.zoomStartID = 0;
this.scrollOffsetX = 0;
this.scrollOffsetXCounter = 0;
// when over panning is enabled, leave a bit of empty space at the end
if ( this.options.allowOverPanning )
{
this.scrollOffsetXCounter = 50;
this.scrollOffsetX = Math.floor( this.scrollOffsetXCounter );
}
this.b_firstDraw = false;
}
this.calculateYRange();
this.calculateXRange();
if ( this.options.disableGrid === false )
{
this.drawGrid();
}
this.candleWidth = Math.floor( this.xPixelRange/(this.candlesticks.length-this.zoomStartID) );
this.candleWidth--;
if ( this.candleWidth%2 == 0 ) this.candleWidth--;
var b_lastCandleIsVisible = false;
for ( var i = this.zoomStartID+this.scrollOffsetX ; i < this.candlesticks.length+this.scrollOffsetX ; ++i )
{
if ( i >= this.candlesticks.length ) continue;
if ( i == this.candlesticks.length-1 ) b_lastCandleIsVisible = true;
var color = ( this.candlesticks[i].close > this.candlesticks[i].open ) ? this.options.wickGreenColor : this.options.wickRedColor;
// draw the wick
this.context.lineWidth = this.options.wickWidth;
this.drawLine( Math.floor(this.xToPixelCoords( this.candlesticks[i].timestamp )) , Math.floor(this.yToPixelCoords( this.candlesticks[i].low )) , Math.floor(this.xToPixelCoords( this.candlesticks[i].timestamp )) , Math.floor(this.yToPixelCoords( this.candlesticks[i].high )) , color );
// candle color
color = ( this.candlesticks[i].close > this.candlesticks[i].open ) ? this.greenColor : this.redColor;
if ( i == this.hoveredCandlestickID )
{
if ( color == this.greenColor ) color = this.greenHoverColor;
else if ( color == this.redColor ) color = this.redHoverColor;
}
// draw the candle
this.fillRect( Math.floor(this.xToPixelCoords( this.candlesticks[i].timestamp ))-Math.floor( this.candleWidth/2 ) , Math.floor(this.yToPixelCoords( this.candlesticks[i].open )) , this.candleWidth , Math.floor(this.yToPixelCoords( this.candlesticks[i].close ) - this.yToPixelCoords( this.candlesticks[i].open )) , color );
// candle border
if ( this.options.candleBorderWidth > 0 )
{
color = ( this.candlesticks[i].close > this.candlesticks[i].open ) ? this.options.wickGreenColor : this.options.wickRedColor;
this.context.lineWidth = this.options.candleBorderWidth;
this.drawRect( Math.floor(this.xToPixelCoords( this.candlesticks[i].timestamp ))-Math.floor( this.candleWidth/2 ) , Math.floor(this.yToPixelCoords( this.candlesticks[i].open )) , this.candleWidth , Math.floor(this.yToPixelCoords( this.candlesticks[i].close ) - this.yToPixelCoords( this.candlesticks[i].open )) , color );
this.context.lineWidth = 1;
}
}
this.context.lineWidth = 1;
if ( this.options.disableLegend === false )
{
this.drawLegend();
}
// draw price high
if ( this.options.disableLowHighPriceDisplay === false )
{
this.context.fillStyle = this.gridTextColor;
var highPriceStr = this.roundPriceValue( this.yEnd );
var highPriceStrWidth = this.context.measureText( highPriceStr ).width;
var highPriceXPos = Math.floor( this.xToPixelCoords( this.candlesticks[this.yEndIndex].timestamp ) );
if ( highPriceXPos+highPriceStrWidth+20 > this.width )
{
highPriceXPos -= (highPriceStrWidth+20);
this.drawLine( highPriceXPos+highPriceStrWidth+5 , Math.floor(this.yToPixelCoords(this.yEnd)) , highPriceXPos+highPriceStrWidth+20-1 , Math.floor(this.yToPixelCoords(this.yEnd)) , this.gridTextColor );
this.context.fillText( highPriceStr , highPriceXPos , Math.floor(this.yToPixelCoords(this.yEnd))+this.options.fontSize/2 );
}
else
{
this.drawLine( highPriceXPos+1 , Math.floor(this.yToPixelCoords(this.yEnd)) , highPriceXPos+15 , Math.floor(this.yToPixelCoords(this.yEnd)) , this.gridTextColor );
this.context.fillText( highPriceStr , highPriceXPos+20 , Math.floor(this.yToPixelCoords(this.yEnd))+this.options.fontSize/2 );
}
// and price low
var lowPriceStr = this.roundPriceValue( this.yStart );
var lowPriceStrWidth = this.context.measureText( lowPriceStr ).width;
var lowPriceXPos = Math.floor( this.xToPixelCoords( this.candlesticks[this.yStartIndex].timestamp ) );
if ( lowPriceXPos+lowPriceStrWidth+20 > this.width )
{
lowPriceXPos -= (lowPriceStrWidth+20);
this.drawLine( lowPriceXPos+lowPriceStrWidth+5 , Math.floor(this.yToPixelCoords(this.yStart)) , lowPriceXPos+lowPriceStrWidth+20-1 , Math.floor(this.yToPixelCoords(this.yStart)) , this.gridTextColor );
this.context.fillText( lowPriceStr , lowPriceXPos , Math.floor(this.yToPixelCoords(this.yStart))+this.options.fontSize/2 );
}
else
{
this.drawLine( lowPriceXPos+1 , Math.floor(this.yToPixelCoords(this.yStart)) , lowPriceXPos+15 , Math.floor(this.yToPixelCoords(this.yStart)) , this.gridTextColor );
this.context.fillText( lowPriceStr , lowPriceXPos+20 , Math.floor(this.yToPixelCoords(this.yStart))+this.options.fontSize/2 );
}
}
// current market price
if ( this.options.disableCurrentMarketPrice === false )
{
var currentMarketPrice = this.candlesticks[this.candlesticks.length-1].close;
var currentMarketPriceStr = this.roundPriceValue( currentMarketPrice );
this.context.setLineDash( [2,2] );
if ( b_lastCandleIsVisible )
{
// line from the candle to the marker at the y-axis
this.drawLine( this.xToPixelCoords(this.candlesticks[this.candlesticks.length-1].timestamp)+this.candleWidth , this.yToPixelCoords(currentMarketPrice) , this.width , this.yToPixelCoords(currentMarketPrice) , this.options.marketPriceLineColor );
var color = ( this.candlesticks[this.candlesticks.length-1].close > this.candlesticks[this.candlesticks.length-1].open ) ? this.greenColor : this.redColor;
if ( this.options.hasOwnProperty( "marketPriceOpacity" ) )
{
var marketPriceOpacity = this.clamp( this.options.marketPriceOpacity , 0 , 1 );
marketPriceOpacity = Math.floor( marketPriceOpacity*255 );
var marketPriceOpacityStr = (marketPriceOpacity).toString(16).padStart( 2 , '0' );
color += marketPriceOpacityStr;
}
// marker at the y-axis
var textWidth = this.context.measureText( currentMarketPriceStr ).width;
this.fillRect( this.width-textWidth-10 , this.yToPixelCoords(currentMarketPrice)-this.options.fontSize/2-2 , textWidth+10 , this.options.fontSize+4 , color );
this.context.fillStyle = this.options.marketPriceTextColor;
this.context.fillText( currentMarketPriceStr , this.width-textWidth-5 , this.yToPixelCoords(currentMarketPrice)+this.options.fontSize/2-2 );
this.marketPriceRect.b_clickable = false;
}
else
{
var str = currentMarketPriceStr+" >";
var textWidth = this.context.measureText( str ).width;
var color = this.options.marketPriceLineColor;
if ( this.options.hasOwnProperty( "marketPriceOpacity" ) )
{
var marketPriceOpacity = this.clamp( this.options.marketPriceOpacity , 0 , 1 );
marketPriceOpacity = Math.floor( marketPriceOpacity*255 );
var marketPriceOpacityStr = (marketPriceOpacity).toString(16).padStart( 2 , '0' );
color += marketPriceOpacityStr;
}
// if the current market price is bigger than the biggest y value
if ( currentMarketPrice > this.yEnd )
{
var xPos = this.width-this.width/2-textWidth;
var yPos = 20;
// line forward a bit
this.drawLine( xPos , yPos , xPos+100 , yPos , this.options.marketPriceLineColor );
// and then up
this.drawLine( xPos+100 , yPos , xPos+100 , 0 , this.options.marketPriceLineColor );
// marker in the middle
this.fillRect( xPos-10 , yPos-this.options.fontSize/2-2 , textWidth+10 , this.options.fontSize+4 , color );
this.context.fillStyle = this.options.marketPriceTextColor;
this.context.fillText( str , xPos-5 , yPos+this.options.fontSize/2-2 );
this.marketPriceRect.x = xPos-10;
this.marketPriceRect.y = yPos-this.options.fontSize/2-2;
this.marketPriceRect.width = textWidth+10;
this.marketPriceRect.height = this.options.fontSize+4;
this.marketPriceRect.b_clickable = true;
}
// if the current market price is smaller than the lowest y value
else if ( currentMarketPrice < this.yStart )
{
var xPos = this.width-this.width/2-textWidth;
var yPos = this.height-this.marginBottom;
// line forward a bit
this.drawLine( xPos , yPos , xPos+100 , yPos , this.options.marketPriceLineColor );
// and then down
this.drawLine( xPos+100 , yPos , xPos+100 , this.height , this.options.marketPriceLineColor );
// marker in the middle
this.fillRect( xPos-10 , yPos-this.options.fontSize/2-2 , textWidth+10 , this.options.fontSize+4 , color );
this.context.fillStyle = this.options.marketPriceTextColor;
this.context.fillText( str , xPos-5 , yPos+this.options.fontSize/2-2 );
this.marketPriceRect.x = xPos-10;
this.marketPriceRect.y = yPos-this.options.fontSize/2-2;
this.marketPriceRect.width = textWidth+10;
this.marketPriceRect.height = this.options.fontSize+4;
this.marketPriceRect.b_clickable = true;
}
else
{
// line across the screen
this.drawLine( 0 , this.yToPixelCoords(currentMarketPrice) , this.width , this.yToPixelCoords(currentMarketPrice) , this.options.marketPriceLineColor );
// marker in the middle
this.fillRect( this.width-this.width/2-textWidth-10 , this.yToPixelCoords(currentMarketPrice)-this.options.fontSize/2-2 , textWidth+10 , this.options.fontSize+4 , color );
this.context.fillStyle = this.options.marketPriceTextColor;
this.context.fillText( str , this.width-this.width/2-textWidth-5 , this.yToPixelCoords(currentMarketPrice)+this.options.fontSize/2-2 );
this.marketPriceRect.x = this.width-this.width/2-textWidth-10;
this.marketPriceRect.y = this.yToPixelCoords(currentMarketPrice)-this.options.fontSize/2-2;
this.marketPriceRect.width = textWidth+10;
this.marketPriceRect.height = this.options.fontSize+4;
this.marketPriceRect.b_clickable = true;
}
}
this.context.setLineDash( [] );
}
// draw overlay
if ( this.b_drawMouseOverlay )
{
if ( this.b_moveOverlay )
{
this.mousePosition.x = this.xToPixelCoords( this.xMouseHover );
this.mousePosition.y = this.yToPixelCoords( this.overlayPriceValue );
this.b_moveOverlay = false;
}
// crosshair
if ( this.options.disableCrosshair === false )
{
if ( this.options.crosshairLineStyle == "dashed" )
{
this.context.setLineDash( [5,5] );
}
else if ( this.options.crosshairLineStyle == "dotted" )
{
this.context.setLineDash( [2,2] );
}
else
{
this.context.setLineDash( [] );
}
// price line
this.drawLine( 0 , this.mousePosition.y , this.width , this.mousePosition.y , this.options.crosshairColor );
var str = this.roundPriceValue( this.overlayPriceValue );
var textWidth = this.context.measureText( str ).width;
this.fillRect( this.width-textWidth-10 , this.mousePosition.y-this.options.fontSize/2-2 , textWidth+10 , this.options.fontSize+4 , this.options.crosshairLabelBackgroundColor );
this.context.fillStyle = this.options.crosshairTextColor;
this.context.fillText( str , this.width-textWidth-5 , this.mousePosition.y+this.options.fontSize/2-2 );
// time line
this.drawLine( this.mousePosition.x , 0 , this.mousePosition.x , this.height , this.options.crosshairColor );
str = this.formatDate( new Date( this.xMouseHover ) );
textWidth = this.context.measureText( str ).width;
this.fillRect( this.mousePosition.x-textWidth/2-5 , this.height-this.options.fontSize-5 , textWidth+10 , this.options.fontSize+5 , this.options.crosshairLabelBackgroundColor );
this.context.fillStyle = this.options.crosshairTextColor;
this.context.fillText( str , this.mousePosition.x-textWidth/2 , this.height-5 );
this.context.setLineDash( [] );
}
// because of the overscrolling at the end, it's possible to place the marker at a position without any candles, so only draw the databox if there is actually a candle
if ( this.hoveredCandlestickID >= 0 && this.hoveredCandlestickID < this.candlesticks.length )
{
// find the widest data text
var widestText = 0;
var dataBoxHeight = 10;
if ( this.options.overlayShowTime )
{
var timeStr = this.formatDate( new Date( this.xMouseHover ) );
if ( this.context.measureText( timeStr ).width > widestText ) widestText = this.context.measureText( timeStr ).width;
dataBoxHeight += this.options.fontSize;
}
if ( this.options.overlayShowData )
{
var openStr = "Open: "+this.roundPriceValue( this.candlesticks[this.hoveredCandlestickID].open );
if ( this.context.measureText( openStr ).width > widestText ) widestText = this.context.measureText( openStr ).width;
var highStr = "High: "+this.roundPriceValue( this.candlesticks[this.hoveredCandlestickID].high );
if ( this.context.measureText( highStr ).width > widestText ) widestText = this.context.measureText( highStr ).width;
var lowStr = "Low: "+this.roundPriceValue( this.candlesticks[this.hoveredCandlestickID].low );
if ( this.context.measureText( lowStr ).width > widestText ) widestText = this.context.measureText( lowStr ).width;
var closeStr = "Close: "+this.roundPriceValue( this.candlesticks[this.hoveredCandlestickID].close );
if ( this.context.measureText( closeStr ).width > widestText ) widestText = this.context.measureText( closeStr ).width;
dataBoxHeight += 4*this.options.fontSize;
}
if ( this.options.overlayShowChange )
{
var change = 0;
var changePercent = 0;
if ( this.hoveredCandlestickID > 0 )
{
change = this.roundPriceValue( this.candlesticks[this.hoveredCandlestickID].close - this.candlesticks[this.hoveredCandlestickID-1].close );
changePercent = (change/this.candlesticks[this.hoveredCandlestickID-1].close)*100;
changePercent = Math.round( changePercent*100 )/100;
}
var changeStr = "Change: "+change;
if ( this.context.measureText( changeStr ).width > widestText ) widestText = this.context.measureText( changeStr ).width;
var changePercentStr = "Change %: "+changePercent+"%";
if ( this.context.measureText( changePercentStr ).width > widestText ) widestText = this.context.measureText( changePercentStr ).width;
dataBoxHeight += 2*this.options.fontSize;
}
widestText += 20;
// total width = widest text width + 10 pixels for the red or green indicator and 5 pixels of padding on each side
var dataBoxWidth = widestText+10+10;
// data
var yPos = this.mousePosition.y-dataBoxHeight-15;
if ( yPos < 0 ) yPos = this.mousePosition.y+15;
var xPos = this.mousePosition.x+15;
if ( xPos+dataBoxWidth > this.width ) xPos = this.mousePosition.x-dataBoxWidth-15;
this.fillRect( xPos , yPos , dataBoxWidth , dataBoxHeight , this.overlayBackgroundColor );
var color = ( this.candlesticks[this.hoveredCandlestickID].close > this.candlesticks[this.hoveredCandlestickID].open ) ? this.greenColor : this.redColor;
this.fillRect( xPos , yPos , 10 , dataBoxHeight , color );
this.context.lineWidth = 2;
this.drawRect( xPos , yPos , dataBoxWidth , dataBoxHeight , color );
this.context.lineWidth = 1;
this.context.fillStyle = this.overlayTextColor;
var textYPosCounter = yPos+this.options.fontSize;
if ( this.options.overlayShowTime )
{
this.context.fillText( timeStr , xPos+15 , textYPosCounter );
textYPosCounter += this.options.fontSize;
}
if ( this.options.overlayShowData )
{
this.context.fillText( "Open:" , xPos+15 , textYPosCounter );
this.context.fillText( this.roundPriceValue( this.candlesticks[this.hoveredCandlestickID].open ) , xPos+dataBoxWidth-this.context.measureText( this.roundPriceValue( this.candlesticks[this.hoveredCandlestickID].open ) ).width-5 , textYPosCount