rms-sparklines
Version:
A sparkline collection, written as web components
874 lines (768 loc) • 38.7 kB
text/typescript
// Copyright2018 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.
/**
* Introduction
* Bar chart sparlines are a collection of rectangles, a.k.a. bars, drawn
* alongside the canvas x-axis, with a small gap between them.
*
* Bar chart sparlines are charactized by their types: positive, negative,
* dual, and tri. Positive charts' bars with positve heights are drawn
* northward; bars with negative heights are cropped out. Negative charts'
* bars with negative heights are drawn southward; bars with positive heights
* are cropped out. Dual charts' bars heights are negative, zero, or positive,
* with a positive height bars drawn northward and negative ones southward.
* Tri charts have equal height bars, with positive drawn northward, zero
* straddling the x-axis, and negative southward.
* Bars are characterized by origin, height, width, gap, and fill color. The
* origin is always (0,0). The height is provided, and scaled to fit within the
* canvas. The width is calculated and its proportional number of bars and the
* the canvas width. The gap is provided. The fill color is provided and used to
* draw the bars' backgrounds.
*
* Calculation parameters
* The parameters required to draw a bar char sparkline fall within two
* categories: attributes, calculated. See below for their definitions and
* descriptions.
*
* Bar width calculation
* The bar width, barWidth, is the ratio between the canvas width and the
* number of bars to draw, rounded down. The barWidth calculation is an
* iteration seeking the number o bars to draw, barHeights.length, that yields
* a barWidth equal or higher than the minimum bar width, minimumBarWidth. If
* the initial barWidth, Math.floor(canvas.width / heights.length), yields a
* barWidth that is less than minimumBarWidth, we drop the leftmost barHeights
* element and recalculate the barWidth using the new heights.length; we
* iterate until the barWidth is greater or equal to minBarWidth.
*
* Bar gap insertion
* The bar gaps, barGap, inserted between the bars might cause rightmost bars to
* be cropped. The barGap insertion is an iteration seeking for a
* barHeights.length enabling barGaps to be inserted without cropping any
* rightmost bars. It has two phases: reducing the barWidth and pruning
* barHeights leftmost elements.
*
* If the initial width required, widthRequired, to insert the
* bars and their gaps,
* barWidth*barHeights.length + barGap*(barHeights.length - 1), is greater than
* the canvas.width and the barWidth is greater than the minimumBarWidth,
* decrement the barWidth by one pixel and recalculate
* the widthRequired using the new barWidth; we iterate until the
* widthRequired is less or equal to canvas.width.
*
* If the initial width required, widthRequired, to insert the
* bars and their gaps,
* barWidth*barHeights.length + barGap*(barHeights.length - 1), is greater than
* the canvas.width and the barWidth is not greater than the minimumBarWidth, we
* drop the leftmost barHeights element and recalculate the widthRequired using
* the new heights.length; we iterate until the widthRequired is less or equal to
* canvas.width.
*
0 1 2 3 4 5 6 7 8
12345678901234567890123456789012345678901234567890123456789012345678901234567890
* Bar chart type drawing
* The bar chart origin, direction, and scale calculations depend on the bar
* chart type. We use the canvas setTransform and translate functions to aid in
* setting them. We start by establishing the canvas default state and then
* describe the operations to transform it to suit the bar char we are drawing
*
* In the canvas original state, its origin is top-let, positive drawing
* southward, and negative northward. The canvas translate method is used to
* move the origin; the canvas scale method is used to flip the canvas drawing
* and to move the origin when inserting gaps.
*
* Wehn drawing a positive chart:
* - the canvas origin is translated to the bottom-left
* - the drawing is scaled to
* - flip the drawing direction
* - fit the barHeights into the canvas height.
*
* Wehn drawing a negative chart:
* - the canvas origin remains at top-right
* - the drawing is scaled to
* - flip the drawing direction
* - fit the barHeights into the canvas height.
*
* Wehn drawing a dual chart:
* - the canvas origin is translated to the middle-left
* - the drawing is scaled to
* - flip the drawing direction
* - fit the barHeights into half of the canvas height.
*
* Wehn drawing a tri chart:
* - the canvas origin is translated to the middle-left
* - the drawing is scaled to
* - flip the drawing direction
* - fit the barHeights into half of the canvas height.
* - All bars are drawn with height = canvas.height / 2
* - zero heights are drawn half northward, and hald southward
*
* TODO: Add validation for color names ... ValidColors.ts
*/
import { Bar } from './bar';
import { Bar3d} from './bar-3d';
import { CssColorString } from './valid-colors';
import * as mathjs from 'mathjs';
import { Matrix } from 'mathjs';
import { ChartTypeEnum } from './chart-type-enum';
import {Rectangle} from './rectangle';
export class BarChart {
// Attributes
canvasEl: HTMLCanvasElement; // the canvas element to draw the chart on
chartType: string; // Positive, negative, dual, tri
barHeights: number[]; // the bar heights world coordinates
minimumBarWidth: number; // the minimum width for a bar
barGap: number; // the width of the gap between two bars, likely to be always 1
fillColorMinus: string; // the color to fill up the negative bars
fillColorZero: string; // the color to fill up the zero bars
fillColorPlus: string; // the color to fill up the positive bars
// Calculated
ctx: CanvasRenderingContext2D; // canvas context
canvasWidth: number; // the canvas width
canvasHeight: number; // the canvas height
barWidth: number; // the bars' width
bars: Bar[]; // bar bars to be drawn
bars_3d: Bar3d[]; // bar bars to be drawn
coordinatesTips: any = [];
VALID_TYPES: string[] = ['positive', 'negative', 'dual', 'tri'];
POSITIVE = 0;
NEGATIVE = 1;
DUAL = 2;
TRI = 3;
tooltipId = 'rms-sparkline-barchart-tooltip';
private cssColorString: CssColorString = null;
// Attributes setters and Getters
setCanvasEl(value: HTMLCanvasElement) { this.canvasEl = value; }
getCanvasEl(): HTMLCanvasElement { return this.canvasEl; }
setChartType(value: string) { this.chartType = value; }
getChartType(): string { return this.chartType; }
setBarHeights(value: number[]) { this.barHeights = value.slice(0); }
getBarHeights(): number[] { return this.barHeights.slice(0); }
setMinimumBarWidth(value: number) { this.minimumBarWidth = value; }
getMinimumBarWidth(): number { return this.minimumBarWidth; }
setBarGap(value: number) { this.barGap = value; }
getBarGap(): number { return this.barGap; }
setFillColorMinus(value: string) { this.fillColorMinus = value; }
getFillColorMinus(): string { return this.fillColorMinus; }
setFillColorZero(value: string) { this.fillColorZero = value; }
getFillColorZero(): string { return this.fillColorZero; }
setFillColorPlus(value: string) { this.fillColorPlus = value; }
getFillColorPlus(): string { return this.fillColorPlus; }
// Parameters setters and getters
setCtx(value: CanvasRenderingContext2D) { this.ctx = value; }
getCtx(): CanvasRenderingContext2D { return this.ctx; }
setCanvasWidth(value: number) { this.canvasWidth = value; }
getCanvasWidth(): number { return this.canvasWidth; }
setCanvasHeight(value: number) { this.canvasHeight = value; }
getCanvasHeight(): number { return this.canvasHeight; }
setBarWidth(value: number) { this.barWidth = value; }
getBarWidth(): number { return this.barWidth; }
getBars(): Bar[] { return this.bars; }
setBars3D(value: Bar3d[]) { this.bars_3d = value.slice(0); }
getBars3D(): Bar3d[] { return this.bars_3d; }
setCoordinatesTips(value: Rectangle[]) { this.coordinatesTips = value.slice(0); }
getCoordinatesTips(): Rectangle[] { return this.coordinatesTips; }
constructor(canvasEl: HTMLCanvasElement,
chartType: string,
barHeights: number[],
minimumBarWidth: number,
barGap: number,
fillColorMinus: string,
fillColorZero: string,
fillColorPlus: string) {
this.cssColorString = new CssColorString();
// Must have 8 arguments
if (arguments.length !== 8) { throw new Error('barChart::constructor: invalid number of arguments: ' + arguments.length); }
// canvasEl must be provided
if (canvasEl === null) { throw new Error('barChar::constructor: canvasEl is null'); }
if (canvasEl.tagName !== `CANVAS`) { throw new Error('barChar::constructor: canvasEl is is not CANVAS: ' + canvasEl.tagName); }
this.setCanvasEl(canvasEl);
this.setCtx(this.getCanvasEl().getContext('2d'));
if (canvasEl.width === 0) { throw new Error('barChar::constructor: canvas width is zero'); }
this.setCanvasWidth(canvasEl.width);
if (canvasEl.height === 0) { throw new Error('barChar::constructor: canvas height is zero'); }
this.setCanvasHeight(canvasEl.height);
// chartType must be valid
if (this.VALID_TYPES.findIndex(checkCartType) === -1) { throw new Error('barChart::constructor: Invalid chart type: ' + chartType); }
function checkCartType(_chartType: string): boolean {
return _chartType === chartType;
}
this.setChartType(chartType);
if (!barHeights) { throw new Error('barChart::constructor: barHeights is null'); }
if (barHeights.length === 0) { throw new Error('barChart::constructor: barHeights is empty'); }
this.setBarHeights(barHeights.slice(0));
// Minimum barWidth must be equal or higher than 3
if (minimumBarWidth < 3) { throw new Error('barChart::constructor: minimumBarWidth less than 3: ' + minimumBarWidth); }
this.setMinimumBarWidth(minimumBarWidth);
// Bar gap must be equal or higher than 1
if (barGap < 1) { throw new Error('barChart::constructor: barGap less than 1: ' + barGap); }
this.setBarGap(barGap);
// fillColorPlus must be a valid CSS color
if (!this.cssColorString.isValid(fillColorMinus)) { throw new Error('barChart::constructor: Invalid fillColorMinus: ' + fillColorMinus); }
this.setFillColorMinus(fillColorMinus);
// fillColorZero must be a valid CSS color
if (!this.cssColorString.isValid(fillColorZero)) { throw new Error('barChart::constructor: Invalid fillColorZero: ' + fillColorZero); }
this.setFillColorZero(fillColorZero);
// fillColorPlus must be a valid CSS color
if (!this.cssColorString.isValid(fillColorPlus)) { throw new Error('barChart::constructor: Invalid fillColorPlus: ' + fillColorPlus); }
this.setFillColorPlus(fillColorPlus);
}
draw() {
// Calculate parameters
// this.setCtx(this.getCanvasEl().getContext('2d'));
// this.setCanvasWidth(this.getCanvasEl().width);
// this.setCanvasHeight(this.getCanvasEl().height);
// Insert the bars
let _barHeights: number[] =
this.calculateBarWidth(
this.getCanvasWidth(),
this.getBarHeights(),
this.getMinimumBarWidth()).slice(0);
// console.log('BarChart::draw - calculateBarWidth: ' + JSON.stringify(this.getBarHeights()));
// Save the bar width
this.setBarWidth(this.computeBarWidth(this.getCanvasWidth(), _barHeights));
if (_barHeights.length === 0) { throw new Error('barChart::calculateBarWidth: barHeights was trimmed to be empty'); }
// console.log('BarChart::draw - computeBarWidth: ' + this.getBarWidth());
// Insert the gaps by reducing barWidth to be no lower than minimumBarWidth, if necessary
this.setBarWidth(
this.insertGapsUsingBarWidth(
this.getCanvasWidth(),
_barHeights,
this.getBarGap(),
this.getMinimumBarWidth())
);
// console.log('BarChart::draw - insertGapsUsingBarWidth: ' + this.getBarWidth());
// Insert the gaps by trimming barHeights, if necessary
_barHeights = this.insertGapsUsingBarHeights(
this.getCanvasWidth(),
_barHeights,
this.getMinimumBarWidth(),
this.getBarGap());
if (_barHeights.length === 0) { throw new Error('barChart::insertGapsUsingBarHeights: barHeights was trimmed to be empty'); }
// console.log('BarChart::draw - insertGapsUsingBarHeights: ' + JSON.stringify(this.getBarHeights()));
// Set the bars to be drawn
// this.setBars(this.buildBars(
// this.getCanvasHeight(),
// _barHeights,
// this.getBarWidth(),
// this.getChartType(),
// this.getFillColorMinus(),
// this.getFillColorZero(),
// this.getFillColorPlus()));
// console.log('BarChart::draw - buildBars: ' + JSON.stringify(this.getBars()));
// Set the bars to be drawn
this.setBars3D(this.buildBars_1(
/* this.getCanvasHeight(), */
this.getBarGap(),
_barHeights,
this.getBarWidth(),
this.getChartType(),
this.getFillColorMinus(),
this.getFillColorZero(),
this.getFillColorPlus()));
// console.log('BarChart::draw - buildBars: ' + JSON.stringify(this.getBars()));
this.setCoordinatesTips(this.buildCoordinateTips(this.getBars3D()));
// Transform the canvas
// this.transformCanvas(
// this.getCtx(),
// this.getChartType(),
// this.getCanvasHeight(),
// this.getBarHeights());
// Draw the sparkline
this._draw_1(this.getCtx(), this.getBars3D());
}
/**
* The bar width, barWidth, is the ratio between the canvas width and the
* number of bars to draw, rounded down. The barWidth calculation is an
* iteration seeking the number o bars to draw, barHeights.length, that yields
* a barWidth equal or higher than the minimum bar width, minimumBarWidth. If
* the initial barWidth, Math.floor(canvas.width / heights.length), yields a
* barWidth that is less than minimumBarWidth, we drop the leftmost barHeights
* element and recalculate the barWidth using the new heights.length; we iterate
* until the barWidth is greater or equal to minBarWidth.
*
* Ideally, all bars will fit in the canvas.width, when drawn with the
* minBarWidth. Therefore, if maximum number of bars that can be drawn in the
* canvas is equal of larger than the number of bars to be drawn, we are done.
* Otherwise, if barHeights has more bars than can be draw and must be pruned
* to the maximum number of bars that can be drawn in the canvas.
*
* maxNumberOfBarsThatCanBeDrawnOnCanvas = Math.floor(canvasWidth / minBarWidth)
*
* @param {number} canvasWidth
* @param {number[]} barHeights
* @param {number} minBarWidth
* @returns {number[]}
*/
calculateBarWidth(canvasWidth: number, barHeights: number[], minBarWidth: number): number[] {
let _barHeights = barHeights.slice(0);
const maxBars = Math.floor(canvasWidth / minBarWidth);
if (maxBars < _barHeights.length) {
_barHeights = _barHeights.slice(-1 * maxBars);
}
return _barHeights;
}
computeBarWidth(canvasWidth: number, barHeights: number[]) {
return Math.floor(canvasWidth / barHeights.length);
}
/**
* If the initial width required, widthRequired, to insert the
* bars and their gaps,
* barWidth*barHeights.length + barGap*(barHeights.length - 1), is greater than
* the canvas.width and the barWidth is greater than the minimumBarWidth,
* decrement the barWidth by one pixel and recalculate
* the widthRequired using the new barWidth; we iterate until the
* widthRequired is less or equal to canvas.width.
*
*
* In essence, The iteration seeks to find the barWidth that solves the
* following equation:
* canvas.width <= barWidth * barHeights.length + barGap * (barHeights.length - 1)
*
* Resolving for barWidth.length, we get:
* barWidth = Math.max(minimumBarWidth, Math.floor(canvas.width + barGap - barGap * barHeights.length) /barHeights.length))
*
* barWidth = Math.max(minimumBarWidth, Math.floor( (canvasWidth - (barGap * (barHeights.length - 1))) / barHeights.length));
*
*
* @param {number} canvasWidth
* @param {number[]} barHeights
* @param {number} barGap
* @param {number} minimumBarWidth
* @returns {number}
*/
insertGapsUsingBarWidth(canvasWidth: number, barHeights: number[], barGap: number, minimumBarWidth: number): number {
// console.log(`bar-chart::insertGapsUsingBarWidth - canvasWidth: ` + canvasWidth);
// console.log(`bar-chart::insertGapsUsingBarWidth - barHeights.length: ` + barHeights.length);
// console.log(`bar-chart::insertGapsUsingBarWidth - barGap: ` + barGap);
// console.log(`bar-chart::insertGapsUsingBarWidth - minimumBarWidth: ` + minimumBarWidth);
// console.log(`bar-chart::insertGapsUsingBarWidth - requiredBarWidth: ` + Math.floor( (canvasWidth - (barGap * (barHeights.length - 1))) / barHeights.length));
return Math.max(minimumBarWidth, Math.floor( (canvasWidth - (barGap * (barHeights.length - 1))) / barHeights.length));
}
/**
* If the initial width required, widthRequired, to insert the
* bars and their gaps,
* barWidth*barHeights.length + barGap*(barHeights.length - 1), is greater than
* the canvas.width and the barWidth is not greater than the minimumBarWidth, we
* drop the leftmost barHeights element and recalculate the widthRequired using
* the new heights.length; we iterate until the widthRequired is less or equal to
* canvas.width.
*
* In essence, The iteration seeks to find the barHeights.length that solves the
* following equation:
* canvas.width <= barHeights.length * barWidth + (barHeights.length - 1) * barGap
*
* Resolving for barHeights.length, we get:
* barHeights.length = Math.floor((canvas.width + barGap) / (barWidth + barGap))
*
* Given the desired barHeights.length, we prune barHeights to have the desired
* length
*
* @param {number} canvasWidth
* @param {number[]} barHeights
* @param {number} barWidth
* @param {number} barGap
* @returns {number[]}
*/
insertGapsUsingBarHeights(canvasWidth: number, barHeights: number[], barWidth: number, barGap: number): number[] {
const desiredLength = Math.floor((canvasWidth + barGap) / (barWidth + barGap));
return barHeights.slice(-desiredLength);
}
buildBars(
canvasHeight: number,
barHeights: number[],
barWidth: number,
chartType: string,
fillColorMinus: string,
fillColorZero: string,
fillColorPlus: string): Bar[] {
const bars: Bar[] = [];
let fillColor: string;
let xOrigin: number;
let yOrigin: number;
let height: number;
for (const barHeight of barHeights) {
switch (chartType) {
case this.VALID_TYPES[this.POSITIVE]:
xOrigin = 0;
yOrigin = 0;
height = barHeight;
fillColor = fillColorPlus;
break;
case this.VALID_TYPES[this.NEGATIVE]:
xOrigin = 0;
yOrigin = 0;
height = barHeight;
fillColor = fillColorMinus;
break;
case this.VALID_TYPES[this.DUAL]:
xOrigin = 0;
yOrigin = 0;
height = barHeight;
fillColor = barHeight > 0 ? fillColorPlus : fillColorMinus;
break;
case this.VALID_TYPES[this.TRI]:
xOrigin = 0;
height = barHeight < 0 ? -canvasHeight / 2 : barHeight === 0 ? canvasHeight / 4 : canvasHeight / 2;
yOrigin = barHeight === 0 ? -height / 2 : 0;
fillColor = barHeight < 0 ? fillColorMinus : barHeight === 0 ? fillColorZero : fillColorPlus;
// console.log(`::buildBars - tri / barHeight: ` + barHeight + ` height: ` + height);
break;
default:
break;
}
const bar: Bar = new Bar(xOrigin, yOrigin, barWidth, height, fillColor);
bars.push(bar);
}
return bars;
}
buildBars_1(
/* canvasHeight: number, */
barGap: number,
barHeights: number[],
barWidth: number,
chartType: string,
fillColorMinus: string,
fillColorZero: string,
fillColorPlus: string): Bar3d[] {
let bar_3d: Bar3d[];
let sToCanvasHeightMatrix: Matrix;
let sFlipCanvasMatrix: Matrix;
let dMoveCanvasMatrix: Matrix;
let tMatrix: Matrix;
// create a collection of bars based on world (lower left) coordinates.
bar_3d = this.buildWorldCoordinateBars(barGap, barHeights.slice(0), barWidth, chartType, fillColorMinus, fillColorPlus, fillColorZero);
// Scale coordinates to fit the canvas height
sToCanvasHeightMatrix = this.scaleToCanvasHeight(barHeights, chartType, this.getCanvasHeight());
// Scale the coordinates to swtch from world to canvas coordinates
sFlipCanvasMatrix = this.scaleToCanvasCoordinates();
// Move the bars to fit the new coordinate system’s aesthetics
dMoveCanvasMatrix = this.moveWithinCanvas(this.getCanvasHeight(), chartType);
// compute the full transformation matrix
tMatrix = mathjs.matrix(mathjs.multiply(mathjs.multiply(sFlipCanvasMatrix, sToCanvasHeightMatrix), dMoveCanvasMatrix));
for (const bar of bar_3d) {
bar.lowerLeft = mathjs.matrix(mathjs.multiply( bar.lowerLeft, tMatrix));
bar.upperRight = mathjs.matrix(mathjs.multiply( bar.upperRight, tMatrix));
}
return bar_3d;
}
/**
* Builds a collection of Bar3D bars, one for each barHeight
* @param barGap
* @param barHeights
* @param barWidth
* @param chartType
* @param fillColorMinus
* @param fillColorPlus
* @param fillColorZero
*/
buildWorldCoordinateBars(
barGap: number,
barHeights: number[],
barWidth: number,
chartType: string,
fillColorMinus: string,
fillColorPlus: string,
fillColorZero: string): Bar3d[] {
const bar_3d: Bar3d[] = [];
let lowerLeft: Matrix;
let upperRight: Matrix;
let fillColor: string;
// create a collection of bars based on world (lower left) coordinates.
switch (chartType.toUpperCase()) {
case ChartTypeEnum.POSITIVE:
// https://palantir.github.io/tslint/rules/no-switch-case-fall-through/
/* falls through */
case ChartTypeEnum.NEGATIVE:
/* falls through */
case ChartTypeEnum.DUAL:
for (let i = 0; i < barHeights.length; i++) {
const barHeight = barHeights[i];
fillColor = barHeight < 0 ? fillColorMinus : barHeight === 0 ? fillColorZero : fillColorPlus;
lowerLeft = mathjs.matrix([i * (barWidth + barGap), 0, 1]);
upperRight = mathjs.matrix([i * (barWidth + barGap) + barWidth, barHeight, 1]);
bar_3d.push(new Bar3d(lowerLeft, upperRight, fillColor, barHeight));
}
break;
case ChartTypeEnum.TRI:
const negativeHeight = -8;
const zeroHeight = 4;
const positiveHeight = 8;
barHeights = barHeights.map(function(height: number) { return height < 0 ? negativeHeight : height === 0 ? zeroHeight : positiveHeight; });
// console.log(`barHeights TRI: ` + JSON.stringify(barHeights));
for (let i = 0; i < barHeights.length; i++) {
const barHeight = barHeights[i];
fillColor = barHeight === negativeHeight ? fillColorMinus : barHeight === zeroHeight ? fillColorZero : fillColorPlus;
if (barHeight === negativeHeight || barHeight === positiveHeight) {
lowerLeft = mathjs.matrix([i * (barWidth + barGap), 0, 1]);
upperRight = mathjs.matrix([i * (barWidth + barGap) + barWidth, barHeight, 1]);
// console.log(`barHeights TRI - lowerLeft: ` + JSON.stringify(lowerLeft) + `, upperRight: ` + JSON.stringify(upperRight.toArray()));
} else {
lowerLeft = mathjs.matrix([i * (barWidth + barGap), -barHeight / 2, 1]);
upperRight = mathjs.matrix([i * (barWidth + barGap) + barWidth, barHeight / 2, 1]);
// console.log(`barHeights TRI - lowerLeft: ` + JSON.stringify(lowerLeft) + `, upperRight: ` + JSON.stringify(upperRight.toArray()));
}
bar_3d.push(new Bar3d(lowerLeft, upperRight, fillColor, barHeight < 0 ? -1 : barHeight === 0 ? 0 : 1));
}
break;
default:
break;
}
return bar_3d;
}
/**
* Scale the Y coordinates; note that the X coordinates were scaled during the calculations to establish the width and gap.
* TODO: examine the possibility to switch all calculation to be done AFTER all transformations are done.
* @param barHeights
* @param chartType
* @param canvasHeight
*/
scaleToCanvasHeight(barHeights: number[], chartType: string, canvasHeight: number): Matrix {
let sToCanvasHeightMatrix: number[][];
const sX = 1;
let sY: number;
switch (chartType.toUpperCase()) {
case ChartTypeEnum.POSITIVE:
sY = canvasHeight / Math.abs(Math.max(...barHeights));
break;
case ChartTypeEnum.NEGATIVE:
sY = canvasHeight / Math.abs(Math.min(...barHeights));
break;
case ChartTypeEnum.DUAL:
sY = (canvasHeight / 2 ) / Math.max(Math.abs(Math.max(...barHeights)), Math.abs(Math.min(...barHeights))) ;
break;
case ChartTypeEnum.TRI:
sY = (canvasHeight / 2 ) / Math.max(Math.abs(Math.max(...barHeights)), Math.abs(Math.min(...barHeights))) ;
break;
default:
break;
}
sToCanvasHeightMatrix = [ [sX, 0, 0], [0, sY, 0], [0, 0, 1] ];
return mathjs.matrix(sToCanvasHeightMatrix);
}
/**
* Calculate sFlipCanvasMatrix, to switch from world coordinates to canvas coordinates
*/
scaleToCanvasCoordinates(): Matrix {
let sFlipCanvasMatrix: number[][];
sFlipCanvasMatrix = [ [1, 0, 0], [0, -1, 0], [0, 0, 1] ];
return mathjs.matrix(sFlipCanvasMatrix);
}
moveWithinCanvas(canvasHeight: number, chartType: string): Matrix {
let dMoveCanvasMatrix: number[][];
const dX = 0;
let dY: number;
switch (chartType.toUpperCase()) {
case ChartTypeEnum.POSITIVE:
dY = canvasHeight;
break;
case ChartTypeEnum.NEGATIVE:
dY = 0;
break;
case ChartTypeEnum.DUAL:
dY = canvasHeight / 2;
break;
case ChartTypeEnum.TRI:
dY = canvasHeight / 2;
break;
default:
break;
}
dMoveCanvasMatrix = [ [1, 0, 0], [0, 1, 0], [dX, dY, 1] ];
return mathjs.matrix(dMoveCanvasMatrix);
}
buildCoordinateTips(bard3D: Bar3d[]): Rectangle[] {
const coordinateTips: any = [];
for (const bar of bard3D) {
coordinateTips.push({
'rect': new Rectangle(bar.getLowerLeftX(), bar.getLowerLeftY(), bar.getWidth(), bar.getHeight()),
'color': 'red',
'tip': bar.originalHeight
});
}
return coordinateTips;
}
//
// transformCanvas(
// ctx: CanvasRenderingContext2D,
// chartType: string,
// canvasHeight: number,
// barHeights: number[]) {
//
// let hScaling = 1;
// const hSkewing = 0;
// const vSkewing = 0;
// let vScaling = 1;
// let hMoving = 0;
// let vMoving = 0;
// switch (chartType) {
// case this.VALID_TYPES[this.POSITIVE]:
// hScaling = 1;
// vScaling = -1 * canvasHeight / Math.max(...barHeights);
// hMoving = 0;
// vMoving = canvasHeight;
// break;
// case this.VALID_TYPES[this.NEGATIVE]:
// hScaling = 1;
// vScaling = -1 * canvasHeight / Math.abs(Math.min(...barHeights));
// hMoving = 0;
// vMoving = 0;
// break;
// case this.VALID_TYPES[this.DUAL]:
// hScaling = 1;
// // console.log(`::transformCanvas - DUAL / vScaling / Math.min(...barHeights: ` + Math.min(...barHeights));
// // console.log(`::transformCanvas - DUAL / vScaling / Math.max(...barHeights: ` + Math.max(...barHeights));
// vScaling = -1 * canvasHeight / (Math.max(Math.abs(Math.min(...barHeights)), Math.max(...barHeights)) * 2);
// hMoving = 0;
// vMoving = canvasHeight / 2;
// // console.log(`::transformCanvas - DUAL / vScaling / vMoving: ` + vMoving);
// break;
// case this.VALID_TYPES[this.TRI]:
// hScaling = 1;
// // console.log(`::transformCanvas - TRI / vScaling / Math.min(...barHeights: ` + Math.min(...barHeights));
// // console.log(`::transformCanvas - TRI / vScaling / Math.max(...barHeights: ` + Math.max(...barHeights));
// // vScaling = -1 * canvasHeight / (Math.max(Math.abs(Math.min(...barHeights)), Math.max(...barHeights)) * 2);
// vScaling = -1;
// hMoving = 0;
// vMoving = canvasHeight / 2;
// // console.log(`::transformCanvas - TRI / vScaling / vMoving: ` + vMoving);
// break;
// default:
// break;
// }
// ctx.setTransform(hScaling, hSkewing, vSkewing, vScaling, hMoving, vMoving);
// }
// Draw the bars!
// _draw(ctx: CanvasRenderingContext2D, bars: Bar[], barGap: number): void {
// for (const bar of bars) {
// // set the bar fill color
// ctx.fillStyle = bar.getFillColor();
//
// // draw the bar
// ctx.fillRect(bar.getX(), bar.getY(), bar.getWidth(), bar.getHeight());
//
// // Insert barGap by translating x-orign
// ctx.translate(barGap, 0);
// }
// }
// Draw the bars!
_draw_1(ctx: CanvasRenderingContext2D, bars: Bar3d[]): void {
console.log('drawing');
for (const bar of bars) {
bar.draw(ctx);
}
}
/**
* Sparkline Barchart 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(`BarChart::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();
let tipHeight = this.coordinatesTips[i].rect.getHeight();
let tipY = this.coordinatesTips[i].rect.getY();
if (tipHeight < 0) {
tipY += tipHeight;
tipHeight *= -1;
}
// 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 + 4;
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(`BarChart::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(`BarChart::handleMouseMove deleting tooltip`);
tooltip.parentElement.removeChild(tooltip);
}
}
}
}
handleMouseOut() {
// Remove the existing tooltip, if present
const tooltip = document.getElementById(this.tooltipId);
if (tooltip) {
// console.log(`BarChart::handleMouseOut deleting tooltip`);
tooltip.parentElement.removeChild(tooltip);
}
}
}