canvas-calendar-chart
Version:
Calendar chart using an HTML canvas
377 lines (317 loc) • 14.5 kB
JavaScript
/**
* Created by Ivo Zeba on 5/6/17.
*
* Calendar component
*/
import { defaultConfig } from "./defaultConfigurations";
import { CanvasHelper } from "./CanvasHelper";
import { JsHelper } from "./JsHelper";
export class Calendar {
/**
* Default constructor
*
* @param config
*/
constructor(config) {
Calendar.validateConfig(config);
this.setConfig(config);
this.initializeCanvas();
this.prepareData();
this.renderChart();
};
/**
* Validates whether the minimum values have been provided to the configuration
*
* @param config
*/
static validateConfig(config) {
if(config.id === null || config.id === undefined || config.id === "")
throw new Error("canvas-calendar-chart: Please provide an id in the Calendar configuration object");
if(config.chart.startDate === null || config.chart.startDate === undefined || config.chart.startDate === "")
throw new Error("canvas-calendar-chart: Please provide a start date in the Calendar configuration object");
if(config.chart.endDate === null || config.chart.endDate === undefined || config.chart.endDate === "")
throw new Error("canvas-calendar-chart: Please provide an end date in the Calendar configuration object");
}
/**
* Merges the provided configuration with the default configuration set
*
* @param config
*/
setConfig(config) {
let configCopy = JSON.parse(JSON.stringify(config));
this.config = JSON.parse(JSON.stringify(defaultConfig));
this.config.id = configCopy.id;
for(let propertyName in configCopy) {
if (configCopy.hasOwnProperty(propertyName) && defaultConfig.hasOwnProperty(propertyName)) {
if (propertyName !== "events")
Object.assign(this.config[propertyName], configCopy[propertyName]);
else {
if (config["events"].hasOwnProperty("onClick"))
this.config["events"].onClick = config["events"].onClick;
if(config["events"].hasOwnProperty("onHover")) {
if(config["events"]["onHover"].hasOwnProperty("date"))
this.config["events"]["onHover"].date = config["events"]["onHover"].date;
if(config["events"]["onHover"].hasOwnProperty("value"))
this.config["events"]["onHover"].value = config["events"]["onHover"].value;
}
}
}
}
}
/**
* Initializes the canvases and sets member variables
*/
initializeCanvas() {
let element = document.getElementById(this.config.id);
this.canvas = document.createElement("canvas");
this.canvas.id = this.config.id + "Canvas";
this.context = this.canvas.getContext("2d");
this.canvas.width = this.config.chart.width;
this.canvas.height = this.config.chart.height;
element.appendChild(this.canvas);
this.toolTip = document.createElement("canvas");
this.toolTip.id = this.config.id + "ToolTip";
this.toolTip.width = 100;
this.toolTip.style = "background-color:white; border:1px solid blue; position:absolute; display: none;";
this.toolTipContext = this.toolTip.getContext("2d");
element.appendChild(this.toolTip);
this.canvas.addEventListener("mousemove", this.onHover.bind(this), false);
if(this.config.events.onClick !== null)
this.canvas.addEventListener("click", this.onClick.bind(this), false);
};
/**
* Destroys the canvas
*/
destroy() {
let element = document.getElementById(this.config.id);
element.removeChild(this.canvas);
this.canvas = null;
this.context = null;
element.removeChild(this.toolTip);
this.toolTip = null;
this.toolTipContext = null;
}
/**
* Callback which is called when a mouse move occurs on the canvas
*
* @param event
*/
onHover(event) {
let point = this.getPointFromCoordinates(event);
if(point !== null) {
this.toolTipContext.clearRect(0, 0, this.toolTip.width, this.toolTip.height);
this.toolTip.style = "position:absolute; display: inline; background-color: #6a6a6a; padding: 5px 0; border-radius: 6px;";
this.toolTip.style.left = event.clientX - this.canvas.getBoundingClientRect().left + "px";
if (point.value !== null && point.value !== undefined && point.value !== "N/A") {
this.toolTip.style.top = event.clientY - 20 - this.canvas.getBoundingClientRect().top + "px";
this.toolTip.height = 40;
this.toolTipContext.fillStyle = "#fff";
let tooltipValueText;
if(this.config.events.onHover.value === null)
tooltipValueText = "Value: " + point.value.toFixed(2);
else
tooltipValueText = this.config.events.onHover.value(point, this, event);
this.toolTipContext.fillText(tooltipValueText, 7, 30);
} else {
this.toolTip.style.top = event.clientY - 10 - this.canvas.getBoundingClientRect().top + "px";
this.toolTip.height = 25;
}
this.toolTipContext.fillStyle = "#fff";
let tooltipDateText;
if(this.config.events.onHover.date === null)
tooltipDateText = "Date: " + JsHelper.getDateString(point.date, this.config.text.months);
else
tooltipDateText = this.config.events.onHover.date(point, this, event);
this.toolTipContext.fillText(tooltipDateText, 7, 15);
} else {
this.toolTipContext.clearRect(0, 0, this.toolTip.width, this.toolTip.height);
this.toolTip.style = "background-color:white; position:absolute; display: none;";
}
}
/**
* Callback which is called when a mouse click occurs on the canvas
*
* @param event
*/
onClick(event) {
let point = this.getPointFromCoordinates(event);
if(point !== null)
this.config.events.onClick(point, this, event);
}
/**
* Returns the data point being displayed for the x,y coordinate. If no data point exists null is returned.
*
* @param event
* @return {*}
*/
getPointFromCoordinates(event) {
let x = event.clientX - this.canvas.getBoundingClientRect().left;
let y = event.clientY - this.canvas.getBoundingClientRect().top;
for(let i = 0; i < this.config.data.length; i++) {
let point = this.config.data[i];
let sideLength = this.config.style.squareSideLength;
if(point.x <= x && point.y <= y && point.x + sideLength > x && point.y + sideLength > y) {
return point;
}
}
return null;
}
/**
* Clears the canvas and renders the chart
*/
renderChart() {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.drawMonths();
this.drawWeekDays();
this.drawChart();
}
/**
* Helper function which returns a font string for the canvas
*
* @return {string}
*/
getFontString() {
return "" + this.config.text.fontSize + " " + this.config.text.font;
};
/**
* Returns the y-margin based on whether weekends are to be skipped. This makes sure that the months are not
* covered up by the Sunday boxes.
*
* @return {number}
*/
getYOffset() {
return this.config.chart.skipWeekend ? 0 : this.config.style.yMargin;
};
/**
* Renders the individual weekday names onto the canvas
*/
drawWeekDays() {
let xMargin = 5;
let yMargin = this.getYOffset() + 12;
for(let i = 0; i < this.config.text.weekDays.length; i++) {
if(this.config.chart.skipWeekend && i !== 0 && i !== 6) {
this.context.font = this.getFontString();
this.context.fillText(this.config.text.weekDays[i], xMargin, yMargin + this.config.style.squareSideLength * i);
} else if(!this.config.chart.skipWeekend) {
this.context.font = this.getFontString();
this.context.fillText(this.config.text.weekDays[i], xMargin, yMargin + this.config.style.squareSideLength * i);
}
}
};
/**
* Renders the individual month names onto the canvas
*/
drawMonths() {
let numberOfMonths = JsHelper.getNumberOfMonthsBetweenDates(this.config.chart.startDate, this.config.chart.endDate);
let xMargin = 60;
let yMargin = 12;
let monthCounter = this.config.chart.startDate.getMonth();
for(let i = 0; i <= numberOfMonths; i++) {
this.context.font = this.getFontString();
this.context.fillText(this.config.text.months[monthCounter], xMargin+75*i, yMargin);
monthCounter++;
if(monthCounter >= this.config.text.months.length)
monthCounter = 0;
}
};
/**
* Converts a value to a color. Special cases are taken into consideration if a value is "N/A" or null/undefined
*
* @param value between 0 and 1
* @return {*}
*/
valueToColor(value) {
if(value === null || value === undefined)
return this.config.color.missingValue;
else if(value === "N/A")
return this.config.color.missingDate;
for(let i = 0; i < this.config.color.range.length; i++) {
if(this.config.color.range.length-1 === i)
return this.config.color.range[i];
else if(value >= i*(1/this.config.color.range.length) && value <= (i+1)*(1/this.config.color.range.length))
return this.config.color.range[i];
}
};
/**
* Draws borders around the months
*
* @param date
* @param xStart X position of the top left corner of the square on the canvas
* @param yStart Y position of the top left corner of the square on the canvas
*/
drawMonthBorder(date, xStart, yStart) {
let color = this.config.color.monthOutline;
// Draw a border if the square is at the top or if it is the first day in the month
if((this.config.chart.skipWeekend && date.getDay() === 1) || date.getDay() === 0 || date.getDate() === 1)
CanvasHelper.drawLine(this.context, xStart, yStart, xStart+this.config.style.squareSideLength, yStart, color);
// Draw a border to the left if it is one of the first days of the month
if(date.getDate() <= 7)
CanvasHelper.drawLine(this.context, xStart, yStart, xStart, yStart+this.config.style.squareSideLength, color);
let lastDay = JsHelper.getDaysInMonth(date.getMonth(), date.getYear());
// Draw a border if the square is at the bottom
if((this.config.chart.skipWeekend && date.getDay() === 5) || date.getDay() === 6 || date.getDate() === lastDay)
CanvasHelper.drawLine(this.context, xStart, yStart+this.config.style.squareSideLength, xStart+this.config.style.squareSideLength, yStart+this.config.style.squareSideLength, color);
// Draw a border to the right if it is one of the last days of the month
if(date.getDate() > lastDay - 7)
CanvasHelper.drawLine(this.context, xStart+this.config.style.squareSideLength, yStart, xStart+this.config.style.squareSideLength, yStart+this.config.style.squareSideLength, color);
};
/**
* Prepares the data for rendering
*/
prepareData() {
this.config.chart.startDate = new Date(this.config.chart.startDate);
this.config.chart.endDate = new Date(this.config.chart.endDate);
let normalizer = 0;
let min;
if(this.config.data.length > 0) {
min = JsHelper.minObjectValue(this.config.data, "value");
let max = JsHelper.maxObjectValue(this.config.data, "value");
normalizer = max - min;
JsHelper.sortObjectArrayByDate(this.config.data);
}
let date = this.config.chart.startDate;
for(let i = 0; i <= JsHelper.getNumberOfDaysBetweenDates(this.config.chart.startDate, this.config.chart.endDate); i++) {
if (normalizer !== 0 && i < this.config.data.length && date.getTime() === (new Date(this.config.data[i].date)).getTime()) {
if(this.config.data[i].value !== null && this.config.data[i].value !== undefined)
this.config.data[i].color = this.valueToColor((this.config.data[i].value - min)/ normalizer);
else {
this.config.data[i].color = this.valueToColor(null);
}
} else {
this.config.data.splice(i, 0, { date: date, value: "N/A", color: this.valueToColor("N/A") });
}
date = new Date(date.getTime() + this.config.constants.dayInMs);
}
};
/**
* Renders the chart
*/
drawChart() {
let column = 0;
for (let i = 0; i < this.config.data.length; i++) {
let date = new Date(this.config.data[i].date);
if (this.config.chart.skipWeekend && date.getDay() === 6) {
if (i + 2 < this.config.data.length) {
i = i + 2;
date = new Date(this.config.data[i].date);
} else {
return;
}
}
this.config.data[i].x = this.config.style.xMargin + this.config.style.squareSideLength * column;
this.config.data[i].y = this.getYOffset() + this.config.style.squareSideLength * date.getDay();
CanvasHelper.drawSquare(this.context, this.config.data[i].x, this.config.data[i].y, this.config.style.squareSideLength, this.config.color.dateOutline, this.config.data[i].color);
this.drawMonthBorder(date, this.config.data[i].x, this.config.data[i].y);
if ((this.config.chart.skipWeekend && date.getDay() === 5) || date.getDay() === 6)
column++;
}
};
getCanvasHelper() {
return CanvasHelper;
}
updateChart(data) {
this.config.data = data;
this.prepareData();
this.drawChart();
}
}