-
Notifications
You must be signed in to change notification settings - Fork 90
Plugins (advanced)
The library allows a user to write their own code and plug it in via the register call. This lets the user's code be accessible like any other library call and have access to the internals of the library. Without the register mechanism, one would have to pass the 'recipe' handle as one of the parameters to the user's function just so that it could make a call to any of the library's interfaces.
The call looks like the following:
recipe.register('name', callback);
If an actual function is being registered as opposed to an anonymous callback code, the 'name' parameter is superfluous (unless you want to register the function under a different name). So as an example, if the function 'fooBar' was going to be registered with the library, the following could be done.
recipe.register(fooBar);
To be able to chain calls together, the plugin has to return the value of this.
After introducing the arc and register interfaces, I wanted to see if I could write a pie chart generator based on those constructs. One of the inputd to the pie call would be a chart object that would hold data such as title, general sector properties, general label properties, and an array of data objects. Each data object has an associated label (for display), a data value representing the count of the specific element, and an optional color (fill) value. Following is an examples of data values, one for movies watched according to genre.
const movies = [
{ label: "Comedy", value: 8, fill: 'red' },
{ label: "Action", value: 5, fill: '#d62e00' },
{ label: "Romance", value: 6, fill: '347d24' },
{ label: "Drama", value: 1, fill: 'blue'},
{ label: "SciFi", value: 4, fill: '#a425ff' }
];
const chart = {
title: {
text: "Favorite Type of Movie",
size: 16,
color: '#ff621f',
italic: true,
bold: true,
underline: {color: "#ff621f"}
},
data: movies,
sector: {
stroke: "#ffffff", // sector line color
percentLimit: 6, // angle at which percent cannot be written in sector
percentSize: 8 // font size of percent
},
label: {
size: 10, // label font size
bold: true,
italic: true
}
};
The pie interface itself would take an (x,y) position parameter, a radius parameter, and the chart object. The resulting calls to make a pie-chart would look like the following:
const HummusRecipe = require('hummus-recipe');
const recipe = new HummusRecipe('new', 'pie.pdf');
const movies ...
const chart ...
recipe.register(pie);
recipe
.createPage('letter')
.pie( 200, 400, 80, chart )
.endPage()
.endPDF();
As I went through the process of building pie, I found that I would need access to some further internals of the library. In this case it was the text measurement code supplied by the 'hummus' library so that I could correctly position the external labels around the pie circle. So 2 more interfaces had to be registered with the library, textDimension and _getFontFile. Now we are talking about getting into the guts of the library to understand how to access some its internal mechanisms. (Eventually, I would like to include textDimensions as an actual interface to make it easier for everyone else).
function _getFontFile(options = {}) {
let fontFile;
if (options.font) {
fontFile = this.fonts[options.font.toLowerCase()];
} else {
// default font used, now have to make final decision based on bold/italic considerations.
// Note, if this is not done explicitly, the font dimensions will be incorrect.
const font =
(options.bold && options.italic) ? 'helvetica-bold-italic' :
(options.italic) ? 'helvetica-italic' :
(options.bold) ? 'helvetica-bold' : 'helvetica';
fontFile = this.fonts[font];
}
return fontFile;
}
function textDimensions(text, options = {}) {
this.current = this.current || {};
const fontFile = this._getFontFile(options);
let width = 0, height = 0;
const defaultFontSize = 14;
if (fontFile) {
if (!this.current.font || this.current.fontFile !== fontFile) {
this.current.font = this.writer.getFontForFile(fontFile);
this.current.fontFile = fontFile;
}
const fontSize = options.size || defaultFontSize;
const dimensions = this.current.font.calculateTextDimensions(text, fontSize);
width = dimensions.xMax;
height = dimensions.height;
}
return {width:width, height: height};
}
So here is the actual code for creating the pie chart. It includes a random color selector for sectors which are not assigned a color value and an endPoint function to determine where to place text and ray pointers to that text.
function getRandomColor() {
var letters = '0123456789ABCDEF';
var color = '#';
for (var i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
function toRadians(angle) {return angle * (Math.PI / 180)}
function endPoint(x, y, l, angle) {
const radians = toRadians(angle)
return [(x + l * Math.cos(radians)), (y + l * Math.sin(radians))]
}
function pie(x, y, radius, chart) {
let sectorColors = [];
let startAt = -90;
let sectorLineColor = "#00";
let sectorOpacity = 1;
let sectorRay = false;
let sectorOffset = 0;
let sectorLimit = 10;
let sectorPcntColor = "#ffffff";
let sectorPcntSize = 8;
let total = 0;
let defaultFontSize = 14;
let labelOpts = {size: defaultFontSize};
const pie = chart.data;
for (let index = 0; index < pie.length; index++) {
const slice = pie[index];
total += slice.value;
sectorColors.push((slice.fill) ? slice.fill : getRandomColor());
}
if (chart.sector) {
if ( chart.sector.stroke ) sectorLineColor = chart.sector.stroke;
if ( chart.sector.opacity ) sectorOpacity = chart.sector.opacity;
if ( chart.sector.ray ) sectorRay = chart.sector.ray;
if ( chart.sector.offset ) sectorOffset = chart.sector.offset;
if ( chart.sector.percentColor ) sectorPcntColor = chart.sector.percentColor;
if ( chart.sector.percentSize ) sectorPcntSize = chart.sector.percentSize;
if ( chart.sector.percentLimit ) sectorLimit = chart.sector.percentLimit;
}
if (chart.label) {
Object.assign(labelOpts, chart.label);
}
let options = {stroke: sectorLineColor, width: 1, sector:true, opacity: sectorOpacity}
let smallestY = y - radius -20;
for (let i=0; i < pie.length; i++) {
const sectorSize = Math.round(pie[i].value / total * 100);
const halfWay = sectorSize / 2;
const endAt = startAt + sectorSize * 3.6;
const midArcAng = startAt + halfWay * 3.6;
const raySize = (sectorRay) ? 15 : 5;
const offdist = sectorOffset || 5;
const rayStart = (pie[i].offset) ? offdist : 0;
const rayPt1 = endPoint(x, y, radius+rayStart, midArcAng);
const rayPt2 = endPoint(x, y, radius+rayStart+raySize, midArcAng);
let [ox, oy] = [x, y];
let needPercent = false;
const sectorOpts = {fill: sectorColors[i]};
if ( pie[i].stroke ) sectorOpts.stroke = pie[i].stroke;
if ( pie[i].width ) sectorOpts.width = pie[i].width;
if ( pie[i].opacity) sectorOpts.opacity = pie[i].opacity;
if (pie[i].offset) {
[ox, oy] = endPoint(x, y, offdist, midArcAng);
}
this.arc(ox, oy, radius, startAt, endAt, Object.assign({}, options, sectorOpts));
// Implant percentages inside sectors where possible
if (sectorSize < sectorLimit) {
needPercent = true;
} else {
let percent = `${sectorSize}%`;
let pcntSize = sectorPcntSize;
let psz = this.textDimensions(percent, {size:pcntSize});
let prcntDist = radius / 2;
let tx=0, ty=0;
if (midArcAng > 180) {
tx = - psz.width / 2;
ty = - psz.height;
} else if (midArcAng > 90) {
tx = - psz.width / 2;
} else if (midArcAng < 0) {
ty = - psz.height;
}
if (pie[i].offset) {
prcntDist += offdist;
}
let [px, py] = endPoint(x, y, prcntDist, midArcAng);
this.text(percent, px+tx, py+ty, {color:sectorPcntColor, size: pcntSize});
}
// Add labels when present
if (pie[i].label) {
const opts = Object.assign({}, labelOpts, pie[i], {opacity:1});
if (!opts.color) opts.color = sectorColors[i];
let xOffset = 5;
let yOffset = opts.size / 2;
const label = (needPercent) ?`${pie[i].label} (${sectorSize}%)` : pie[i].label;
if (sectorRay) {
this.line([rayPt1, rayPt2], {stroke: sectorColors[i], width: .5});
}
if ( midArcAng > -90 && midArcAng < -45) {
let td = this.textDimensions(label, opts);
yOffset = td.height * 1.5;
xOffset = - td.width/2;
}
else if (midArcAng > 45 && midArcAng <= 90) {
let td = this.textDimensions(label, opts);
yOffset = -5;
xOffset = - td.width/2;
}
else if (midArcAng > 90 && midArcAng <= 220) {
let td = this.textDimensions(label, opts);
xOffset = -td.width-5;
if (midArcAng < 180) {
yOffset = 0;
}
}
else if (midArcAng > 220) {
let td = this.textDimensions(label, opts);
xOffset = -td.width / 2;
yOffset = opts.size;
}
let ry = rayPt2[1]-yOffset;
this.text(label, rayPt2[0]+xOffset, ry, opts);
if (ry < smallestY) {
smallestY = ry;
}
}
startAt = endAt;
}
if (chart.title && chart.title.text) {
let td = this.textDimensions(chart.title.text, chart.title);
let tx = x - (td.width / 2);
let ty = smallestY -labelOpts.size - td.height*1.5;
this.text(chart.title.text, tx, ty, chart.title);
}
return this;
}
Putting it altogether resulted in the following pie chart.
Then trying another set of data for student grades, I wanted to get a pie slice/sector to pop out away from the pie chart circle, so I added the concept of offset to the data entry object. Now with the new data and modifications to the existing chart data I was able to make another pie chart.
const grades = [
{label: 'A', value: 4, fill: '#c4861b' },
{label: 'B', value: 14, fill: '#1f750e' },
{label: 'C', value: 9, fill: '#125878' },
{label: 'D', value: 2, fill: 'red' , offset:true},
];
chart.data = grades;
chart.sector.ray = false;
chart.title.text = 'Student Grades';
chart.title.color = "#000000";
delete chart.title['underline'];
chart.title.underline = {color: chart.title.color};
recipe.pie(420, 400, 80, chart)