rms-sparklines
Version:
A sparkline collection, written as web components
593 lines (528 loc) • 25.6 kB
text/typescript
// Copyright 2018 Rodrigo Silveira
//
// 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.
import { Rectangle } from './rectangle';
export class BoxPlot {
// Attributes
canvasEl: HTMLCanvasElement;
axisColor: string;
axisLineWidth: number;
chartType: string;
className: string;
drawingMethod: string;
lowWhiskerColor: string;
lowWhiskerLineWidth: number;
height: number;
interQuartileRangeLineColor: string;
interQuartileRangeFillColor: string;
interQuartileRangeLineWidth: number;
highWhiskerColor: string;
highWhiskerLineWidth: number;
medianColor: string;
medianLineWidth: number;
population: number[];
width: number;
// Calculated Attributes
maximum: number;
quartile_3: number;
median: number;
quartile_1: number;
minimum: number;
x_axis_canvas_gap: number; // canvas.width / 16
x_axis_translation: number; // canvas.width / 16
whiskerHeight: number; // (canvas.height / 5)
medianHeight: number; // = 2 * (canvas.height / 3)
coordinatesTips: any = [];
tootipBoxMargin = 2;
tooltipId = 'rms-sparkline-boxplot-tooltip';
constructor(
axisColor: string,
axisLineWidth: number,
chartType: string,
className: string,
drawingMethod: string,
height: number,
highWhiskerColor: string,
highWhiskerLineWidth: number,
interQuartileRangeLineColor: string,
interQuartileRangeFillColor: string,
interQuartileRangeLineWidth: number,
lowWhiskerColor: string,
lowWhiskerLineWidth: number,
medianColor: string,
medianLineWidth: number,
population: number[],
width: number) {
// Save the attributes
this.axisColor = axisColor;
this.axisLineWidth = axisLineWidth;
this.chartType = chartType;
this.className = className;
this.drawingMethod = drawingMethod;
this.height = height;
this.highWhiskerLineWidth = highWhiskerLineWidth;
this.highWhiskerColor = highWhiskerColor;
this.interQuartileRangeLineColor = interQuartileRangeLineColor;
this.interQuartileRangeFillColor = interQuartileRangeFillColor;
this.interQuartileRangeLineWidth = interQuartileRangeLineWidth;
this.lowWhiskerColor = lowWhiskerColor;
this.lowWhiskerLineWidth = lowWhiskerLineWidth;
this.medianColor = medianColor;
this.medianLineWidth = medianLineWidth;
this.population = population;
this.width = width;
}
/**
* Compute the amount of white space between the canvas vertical boundaries
*
* @param {number} width
* @returns {number}
*/
static calculateX_axis_canvas_gap(width: number): number {
return width / 16;
}
/**
* Computer the whisker height
*
* @param {number} height
* @returns {number}
*/
static calculateWhiskerHeight(height: number): number {
return height / 5;
}
/**
* Computer the median height
*
* @param {number} height
* @returns {number}
*/
static calculateMedianHeight(height: number): number {
return (height / 3) * 2;
}
/**
* Compute the distance to move the x-axis
*
* @param {number} height
* @returns {number}
*/
static calculateX_axis_translation(height: number): number {
return (height / 2);
}
static helloWorld(): string { return `hello world`; }
/**
* Sort an array
*
* @param {number[]} array
* @returns {number[]}
*/
static sortArray (array: number[]): number[] {
const sortedArray: number[] = array.slice(0);
sortedArray.sort(function(a, b) { return a - b; });
return sortedArray;
}
/**
* Calculate the median of a sorted array
* @param {number[]} population
* @returns {number}
*/
static calculateMedian(population: number[]): number {
const sortedPopulation = BoxPlot.sortArray(population);
let median = 0;
const numsLen: number = sortedPopulation.length;
// console.log(`::calculateMedian - population: ` + JSON.stringify(sortedPopulation));
if (numsLen % 2 === 0 /* is even */) {
// average of two middle numbers
median = (sortedPopulation[numsLen / 2 - 1] + sortedPopulation[numsLen / 2]) / 2;
} else { /* is odd */
// middle number only
median = sortedPopulation[Math.floor(numsLen / 2)];
}
return median;
}
/**
* Calculate the first quartile of a sorted array
* @param {number[]} population
* @returns {number}
*/
static calculateQuartile_1 (population: number[]): number {
const sortedPopulation = BoxPlot.sortArray(population);
const length = sortedPopulation.length;
let quartile: number[] = null;
switch (length % 2) {
case 0:
quartile = sortedPopulation.slice(0, (length / 2) + 1);
// console.log(`::calculateQuartile_1 - quartile - even: ` + JSON.stringify(quartile));
break;
case 1:
quartile = sortedPopulation.slice(0, Math.floor(length / 2));
// console.log(`::calculateQuartile_1 - quartile - odd: ` + JSON.stringify(quartile));
break;
}
return BoxPlot.calculateMedian(quartile);
}
/**
* Calculate the third quartile of a sorted array
* @param {number[]} population
* @returns {number}
*/
static calculateQuartile_3 (population: number[]): number {
const sortedPopulation = BoxPlot.sortArray(population);
// console.log(`::calculateQuartile_3 - quartile - sortedPopulation: ` + JSON.stringify(sortedPopulation));
const length = sortedPopulation.length;
let quartile: number[] = null;
switch (length % 2) {
case 0:
quartile = sortedPopulation.slice((length / 2));
// console.log(`::calculateQuartile_3 - quartile - even: ` + JSON.stringify(quartile));
break;
case 1:
quartile = sortedPopulation.slice(Math.ceil(length / 2));
// console.log(`::calculateQuartile_3 - quartile - odd: ` + JSON.stringify(quartile));
break;
}
return BoxPlot.calculateMedian(quartile);
}
draw() {
let drawingHtml: HTMLElement;
this.maximum = Math.floor(Math.max(...this.population));
this.minimum = Math.floor(Math.min(...this.population));
this.median = Math.floor(BoxPlot.calculateMedian(this.population));
this.quartile_1 = Math.floor(BoxPlot.calculateQuartile_1(this.population));
this.quartile_3 = Math.floor(BoxPlot.calculateQuartile_3(this.population));
this.x_axis_canvas_gap = Math.floor(BoxPlot.calculateX_axis_canvas_gap(this.width));
this.whiskerHeight = Math.floor(BoxPlot.calculateWhiskerHeight(this.height));
this.medianHeight = Math.floor(BoxPlot.calculateMedianHeight(this.height));
this.x_axis_translation = Math.floor(BoxPlot.calculateX_axis_translation(this.height));
/**
* Build tooltips
* There are four tooltips: minimum, q1 - q3 quartile, medium, maximum
* - Imagine a rectangle around the minimum whisker; if the mouse moves
* within it, its tooltip is shown
* - Imagine a rectangle around the IQR rectangle whisker; if the mouse
* moves within it, its tooltip is shown
* - Imagine a rectangle around the medium line; if the mouse moves
* within it, its tooltip is shown; it has priority over the IQR
* rectangle
* - Imagine a rectangle around the maxmum whisker; if the mouse moves
* within it, its tooltip is shown
*/
this.coordinatesTips = [];
const canvasPixels = this.width - 2 * this.x_axis_canvas_gap;
const canvasX = this.x_axis_canvas_gap + this.quartile_1 * canvasPixels / this.maximum - this.tootipBoxMargin;
const canvasY = this.height / 2 - this.medianHeight / 2 - this.tootipBoxMargin;
const canvasW = ((this.quartile_3 - this.quartile_1) * canvasPixels) / this.maximum + 2 * this.tootipBoxMargin;
const canvasH = this.medianHeight + 2 * this.tootipBoxMargin;
this.coordinatesTips.push({
rect: new Rectangle(
this.x_axis_canvas_gap,
this.height / 2 - this.whiskerHeight / 2 - this.tootipBoxMargin,
2 * this.tootipBoxMargin,
this.whiskerHeight + 2 * this.tootipBoxMargin
),
color: 'red',
tip: 'Min: ' + this.minimum
});
this.coordinatesTips.push({
rect: new Rectangle(
Math.floor(this.x_axis_canvas_gap + this.median * canvasPixels / this.maximum - this.tootipBoxMargin),
this.height / 2 - this.medianHeight / 2 - this.tootipBoxMargin,
2 * this.tootipBoxMargin,
this.medianHeight + 2 * this.tootipBoxMargin
),
color: 'red',
tip: 'Median: ' + this.median
});
this.coordinatesTips.push({
rect: new Rectangle(
canvasX,
canvasY,
canvasW,
canvasH
),
color: 'red',
tip: 'Q1 : Q3: ' + this.quartile_1 + ` : ` + this.quartile_3
});
this.coordinatesTips.push({
rect: new Rectangle(
Math.floor(this.width - this.x_axis_canvas_gap) - this.tootipBoxMargin,
this.height / 2 - this.whiskerHeight / 2 - this.tootipBoxMargin,
2 * this.tootipBoxMargin,
this.whiskerHeight + 2 * this.tootipBoxMargin
),
color: 'red',
tip: 'Max: ' + this.maximum
});
// Dispatch the drawing
switch (this.drawingMethod) {
case 'canvas':
drawingHtml = this._draw_canvas();
break;
case 'svg':
drawingHtml = this._draw_svg();
break;
default:
drawingHtml = this._draw_invalid();
break;
}
return drawingHtml;
}
_draw_canvas(): HTMLElement {
// console.log(`Boxchart::_draw_canvas`)
const canvasEl: HTMLCanvasElement = document.createElement('canvas') as HTMLCanvasElement;
const ctx = canvasEl.getContext('2d');
// See https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
// for rationale
let canvasAdjustment;
canvasEl.width = this.width;
canvasEl.height = this.height;
canvasEl.style.display = 'inline-block';
canvasEl.style.verticalAlign = 'top';
if (this.className && this.className !== ``) { canvasEl.classList.add(this.className); }
// the canonical drawing to demonstrate the pipeline is working
// ctx.beginPath();
// ctx.moveTo(0,0);
// ctx.lineTo(canvasEl.width, canvasEl.height);
// ctx.stroke();
// Draw x-axis
canvasAdjustment = this.axisLineWidth % 2 === 0 ? 0 : 0.5;
ctx.beginPath();
ctx.strokeStyle = this.axisColor;
ctx.lineWidth = this.axisLineWidth;
ctx.moveTo(this.x_axis_canvas_gap, Math.floor(this.height / 2) + canvasAdjustment);
ctx.lineTo(this.width - this.x_axis_canvas_gap, Math.floor(this.height / 2) + canvasAdjustment);
ctx.stroke();
// Draw western (low) whisker
canvasAdjustment = this.lowWhiskerLineWidth % 2 === 0 ? 0 : 0.5;
ctx.beginPath();
ctx.strokeStyle = this.lowWhiskerColor;
ctx.lineWidth = this.lowWhiskerLineWidth;
ctx.moveTo(this.x_axis_canvas_gap + canvasAdjustment, this.height / 2 - this.whiskerHeight / 2);
ctx.lineTo(this.x_axis_canvas_gap + canvasAdjustment, this.height / 2 + this.whiskerHeight / 2);
ctx.stroke();
// Draw IQR range
const canvasPixels = this.width - 2 * this.x_axis_canvas_gap;
let canvasX = this.x_axis_canvas_gap + this.quartile_1 * canvasPixels / this.maximum;
const canvasY = this.height / 2 - this.medianHeight / 2;
const canvasW = ((this.quartile_3 - this.quartile_1) * canvasPixels) / this.maximum;
const canvasH = this.medianHeight;
// Draw filled rectangle
ctx.fillStyle = this.interQuartileRangeFillColor;
ctx.fillRect(canvasX, canvasY, canvasW, canvasH);
// Draw rectangle's border
ctx.strokeStyle = this.interQuartileRangeLineColor;
ctx.lineWidth = this.interQuartileRangeLineWidth;
ctx.strokeRect(canvasX, canvasY, canvasW, canvasH);
// Draw Median
canvasAdjustment = this.medianLineWidth % 2 === 0 ? 0 : 0.5;
canvasX = Math.floor(this.x_axis_canvas_gap + this.median * canvasPixels / this.maximum);
ctx.beginPath();
ctx.strokeStyle = this.medianColor;
ctx.lineWidth = this.medianLineWidth;
ctx.moveTo( canvasX + canvasAdjustment, this.height / 2 - this.medianHeight / 2);
ctx.lineTo( canvasX + canvasAdjustment, this.height / 2 + this.medianHeight / 2);
ctx.stroke();
// Draw eastern (high) whisker
canvasAdjustment = this.highWhiskerLineWidth % 2 === 0 ? 0 : 0.5;
ctx.beginPath();
ctx.strokeStyle = this.highWhiskerColor;
ctx.lineWidth = this.highWhiskerLineWidth;
ctx.moveTo(Math.floor(this.width - this.x_axis_canvas_gap) + canvasAdjustment, this.height / 2 - this.whiskerHeight / 2);
ctx.lineTo(Math.floor(this.width - this.x_axis_canvas_gap) + canvasAdjustment, this.height / 2 + this.whiskerHeight / 2);
ctx.stroke();
return this.getDivContainer(canvasEl);
}
_draw_svg(): HTMLElement {
// console.log(`Boxchart::_draw_svg`)
let canvasPixels;
let canvasX;
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgEl.setAttributeNS(null, 'width', String(this.width));
svgEl.setAttributeNS(null, 'height', String(this.height));
if (this.className && this.className !== ``) { svgEl.classList.add(this.className); }
// the canonical drawing to demonstrate the pipeline is working
// const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
// line.setAttributeNS(null, 'x1', String(0));
// line.setAttributeNS(null, 'y1', String(0));
// line.setAttributeNS(null, 'x2', String(this.width - 1));
// line.setAttributeNS(null, 'y2', String(this.height - 1));
// line.style.stroke = 'rgb(255,0,0)';
// line.style.strokeWidth = '1px';
// svgEl.appendChild(line);
// Draw x-axis
const xAxisLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
xAxisLine.setAttributeNS(null, 'x1', String(this.x_axis_canvas_gap));
xAxisLine.setAttributeNS(null, 'y1', String(this.height / 2));
xAxisLine.setAttributeNS(null, 'x2', String(this.width - this.x_axis_canvas_gap));
xAxisLine.setAttributeNS(null, 'y2', String(this.height / 2));
xAxisLine.style.stroke = this.axisColor;
xAxisLine.style.strokeWidth = this.axisLineWidth + 'px';
svgEl.appendChild(xAxisLine);
// Draw western (low) whisker
const lowWhisker = document.createElementNS('http://www.w3.org/2000/svg', 'line');
lowWhisker.setAttributeNS(null, 'x1', String(this.x_axis_canvas_gap));
lowWhisker.setAttributeNS(null, 'y1', String(this.height / 2 - this.whiskerHeight / 2));
lowWhisker.setAttributeNS(null, 'x2', String(this.x_axis_canvas_gap));
lowWhisker.setAttributeNS(null, 'y2', String(this.height / 2 + this.whiskerHeight / 2));
lowWhisker.style.stroke = this.lowWhiskerColor;
lowWhisker.style.strokeWidth = this.lowWhiskerLineWidth + 'px';
svgEl.appendChild(lowWhisker);
// Draw IQR range
canvasPixels = this.width - 2 * this.x_axis_canvas_gap;
canvasX = this.x_axis_canvas_gap + this.quartile_1 * canvasPixels / this.maximum;
const canvasY = this.height / 2 - this.medianHeight / 2;
const canvasW = ((this.quartile_3 - this.quartile_1) * canvasPixels) / this.maximum;
const canvasH = this.medianHeight;
const iqrRange = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
iqrRange.setAttributeNS(null, 'x', String(canvasX));
iqrRange.setAttributeNS(null, 'y', String(canvasY));
iqrRange.setAttributeNS(null, 'width', String(canvasW));
iqrRange.setAttributeNS(null, 'height', String(canvasH));
iqrRange.style.fill = this.interQuartileRangeFillColor;
iqrRange.style.strokeWidth = this.interQuartileRangeLineWidth + 'px';
iqrRange.style.stroke = this.interQuartileRangeLineColor;
svgEl.appendChild(iqrRange);
// Draw Median
canvasX = this.x_axis_canvas_gap + this.median * canvasPixels / this.maximum;
const median = document.createElementNS('http://www.w3.org/2000/svg', 'line');
median.setAttributeNS(null, 'x1', String(canvasX));
median.setAttributeNS(null, 'y1', String(this.height / 2 - this.medianHeight / 2));
median.setAttributeNS(null, 'x2', String(canvasX));
median.setAttributeNS(null, 'y2', String(this.height / 2 + this.medianHeight / 2));
median.style.stroke = this.medianColor;
median.style.strokeWidth = this.medianLineWidth + 'px';
svgEl.appendChild(median);
// Draw eastern (high) whisker
const highWhisker = document.createElementNS('http://www.w3.org/2000/svg', 'line');
highWhisker.setAttributeNS(null, 'x1', String(this.width - this.x_axis_canvas_gap));
highWhisker.setAttributeNS(null, 'y1', String(this.height / 2 - this.whiskerHeight / 2));
highWhisker.setAttributeNS(null, 'x2', String(this.width - this.x_axis_canvas_gap));
highWhisker.setAttributeNS(null, 'y2', String(this.height / 2 + this.whiskerHeight / 2));
highWhisker.style.stroke = this.highWhiskerColor;
highWhisker.style.strokeWidth = this.highWhiskerColor + 'px';
svgEl.appendChild(highWhisker);
return this.getDivContainer(svgEl);
}
_draw_invalid(): HTMLElement {
const invalidHtml = document.createElement('p');
invalidHtml.innerText = `Invalid drawing method`;
return this.getDivContainer(invalidHtml);
}
getDivContainer(insertMe: any): HTMLElement {
const divContainer = document.createElement('div');
divContainer.classList.add('content');
if (this.className && this.className !== ``) { divContainer.classList.add(this.className); }
divContainer.appendChild(insertMe);
const slotEl = document.createElement('slot');
divContainer.appendChild(slotEl);
return divContainer;
}
/**
* Sparkline Boxplot Mouse Move Handler
* @param $event
* @param canvasEl
*/
handleMouseMove($event: MouseEvent, canvasEl: HTMLElement) {
let tooltip;
let mySpan: HTMLSpanElement;
const fontDefinition = '12px FUTURA';
let body: any;
let width: number;
let height: number;
let rect: any;
let scrollLeft: number;
let scrollTop: number;
// Get the position of the canvas element relative to the document
// https://plainjs.com/javascript/styles/get-the-position-of-an-element-relative-to-the-document-24/
rect = canvasEl.getBoundingClientRect();
scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
scrollTop = window.pageYOffset || document.documentElement.scrollTop;
rect = { left: rect.left + scrollLeft, top: rect.top + scrollTop };
const mouseX = $event.clientX - rect.left + window.pageXOffset || document.documentElement.scrollLeft;
const mouseY = $event.clientY - rect.top + window.pageYOffset || document.documentElement.scrollTop;
// console.log(`SparklineLine::handleMouseMove mouseX: ` + mouseX + `, mouseY: ` + mouseY);
for (let i = 0; i < this.coordinatesTips.length; i++) {
const tipX = this.coordinatesTips[i].rect.getX();
const tipWidth = this.coordinatesTips[i].rect.getWidth();
const tipHeight = this.coordinatesTips[i].rect.getHeight();
const tipY = this.coordinatesTips[i].rect.getY();
// console.log(`mouseX: ` + mouseX + `, mouseY: ` + mouseY);
// console.log(`tipX: ` + tipX + `, tipY: ` + tipY + `, tipWidth: ` + tipWidth + `, tipHeight: ` + tipHeight);
if (tipX <= mouseX && mouseX <= tipX + tipWidth &&
tipY <= mouseY && mouseY <= tipY + tipHeight) {
// console.log(`We have a match`);
// A trick to find the width / height of the canvas to host the tooltip
mySpan = document.createElement('span') as HTMLSpanElement;
mySpan.id = 'mySpanId';
mySpan.style.font = fontDefinition;
mySpan.style.textAlign = 'center';
mySpan.innerHTML = '' + this.coordinatesTips[i].tip;
body = document.getElementsByTagName('body')[0];
body.appendChild(mySpan);
mySpan = document.getElementById('mySpanId');
width = mySpan.getBoundingClientRect().width + 2;
height = mySpan.getBoundingClientRect().height + 2;
mySpan.parentElement.removeChild(mySpan);
// console.log(`mySpanWidth: ` + width + `, mySpanHeight: ` + height);
// Remove the existing tooltip, if present
tooltip = document.getElementById(this.tooltipId);
if (tooltip) {
// console.log(`SparklineLine::handleMouseMove deleting tooltip`);
tooltip.parentElement.removeChild(tooltip);
}
tooltip = document.createElement('CANVAS') as HTMLCanvasElement;
tooltip.id = this.tooltipId;
tooltip.width = width;
tooltip.height = height;
tooltip.style.position = 'absolute';
tooltip.style.left = ($event.clientX + 5) + 'px';
tooltip.style.top = ($event.clientY + 7) + 'px';
// tooltip.style.left = '' + 100 + 'px';
// tooltip.style.top = '' + 100 + 'px';
tooltip.style.border = '1px solid';
tooltip.style.zIndex = '20';
tooltip.style.textAlign = 'center';
const ctx = (tooltip as HTMLCanvasElement).getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, width, height);
ctx.fill();
ctx.fillStyle = 'red';
ctx.font = fontDefinition;
ctx.fillText('' + this.coordinatesTips[i].tip, 1, height - 2);
body = document.getElementsByTagName('body')[0];
body.appendChild(tooltip);
break; /// required to prevent Q1 : Q3 tooltip from showing
} else {
// console.log(`this is not a match`);
// Remove the existing tooltip, if present
tooltip = document.getElementById(this.tooltipId);
if (tooltip) {
// console.log(`SparklineLine::handleMouseMove deleting tooltip`);
tooltip.parentElement.removeChild(tooltip);
}
}
}
}
handleMouseOut() {
// Remove the existing tooltip, if present
const tooltip = document.getElementById(this.tooltipId);
if (tooltip) {
// console.log(`SparklineLine::handleMouseOut deleting tooltip`);
tooltip.parentElement.removeChild(tooltip);
}
}
}