quickchart / lib /google_image_charts.js
glutamatt's picture
glutamatt HF Staff
Initial deployment of QuickChart service
ecdd7f6 verified
// Google Image Charts compatibility
const DEFAULT_COLOR_WHEEL = ['#4D89F9', '#00B88A', 'red', 'purple', 'yellow', 'brown'];
const AXIS_FORMAT_REGEX_CHXS = /^\d(N([^\*]+)?(\*([fpe]+)?(c[A-Z]{3})?(\d)?([zsxy]+)?\*)?(.*))?$/;
function parseSize(chs) {
if (!chs) {
return {
width: 500,
height: 300,
};
}
const size = chs.split('x');
return {
width: Math.min(2048, parseInt(size[0], 10)),
height: Math.min(2048, parseInt(size[1], 10)),
};
}
function parseBackgroundColor(chf) {
if (!chf) {
return 'white';
}
const series = chf.split('|');
// For now we don't support any of the series coloring features - just look
// at the first part.
const parts = series[0].split(',');
if (parts[0] === 'a') {
// Transparency
backgroundColor = '#000000' + parts[2].slice(-2);
} else {
// Fill
backgroundColor = '#' + parts[2];
}
return backgroundColor;
}
/**
* Returns a list of series objects. Each series object is a list of values.
*/
function parseSeriesData(chd, chds) {
let seriesData;
const [encodingType, seriesStr] = chd.split(':');
switch (encodingType) {
case 't':
if (chds === 'a') {
// Basic text format with auto scaling
seriesData = seriesStr.split('|').map(valuesStr => {
return valuesStr.split(',').map(val => {
if (val === '_') {
return null;
}
return parseFloat(val);
});
});
} else {
// Basic text format with set range
const seriesValues = seriesStr.split('|');
const seriesRanges = [];
if (chds) {
if (Array.isArray(chds)) {
// We don't want to support Google weird scaling here per series...
chds = chds[0];
}
const ranges = chds.split(',');
for (let i = 0; i < ranges.length; i += 2) {
const min = parseFloat(ranges[i]);
const max = parseFloat(ranges[i + 1]);
seriesRanges.push({ min, max });
}
if (seriesRanges.length < seriesValues.length) {
// Fill out the remainder of ranges for all series, using the last
// value.
for (let i = 0; i <= seriesValues.length - seriesRanges.length; i++) {
seriesRanges.push(seriesRanges[seriesRanges.length - 1]);
}
}
} else {
// Apply default minimums of 0 and maximums of 100.
seriesValues.forEach(() => {
seriesRanges.push({ min: 0, max: 100 });
});
}
seriesData = seriesValues.map((valuesStr, idx) => {
return valuesStr.split(',').map(val => {
if (val === '_') {
return null;
}
const floatVal = parseFloat(val);
if (floatVal < seriesRanges[idx].min) {
return null;
}
if (floatVal > seriesRanges[idx].max) {
return seriesRanges[idx].max;
}
return floatVal;
});
});
}
break;
case 's':
// Simple encoding format
const SIMPLE_LOOKUP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
seriesData = seriesStr.split(',').map(encoded => {
const vals = [];
for (let i = 0; i < encoded.length; i++) {
const char = encoded.charAt(i);
if (char === '_') {
vals.push(null);
} else {
vals.push(SIMPLE_LOOKUP.indexOf(char));
}
}
return vals;
});
break;
case 'e':
const EXTENDED_LOOKUP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.';
seriesData = seriesStr.split(',').map(encoded => {
const vals = [];
for (let i = 0; i < encoded.length; i += 2) {
const word = encoded.slice(i, i + 2);
if (word === '__') {
vals.push(null);
} else {
const idx1 = EXTENDED_LOOKUP.indexOf(word[0]);
const idx2 = EXTENDED_LOOKUP.indexOf(word[1]);
const val = idx1 * EXTENDED_LOOKUP.length + idx2;
vals.push(val);
}
}
return vals;
});
break;
case 'a':
// Image Chart "awesome" format
seriesData = seriesStr.split('|').map(valuesStr => {
return valuesStr.split(',').map(parseFloat);
});
break;
}
return seriesData;
}
function setChartType(cht, chartObj) {
let chartType;
switch (cht) {
case 'bhs':
// Horizontal with stacked bars
chartObj.type = 'horizontalBar';
chartObj.options.scales = {
xAxes: [
{
display: false,
stacked: true,
gridLines: { display: false },
},
],
yAxes: [
{
display: false,
stacked: true,
gridLines: { display: false },
ticks: {
beginAtZero: true,
},
},
],
};
break;
case 'bvs':
// Vertical with stacked bars
chartObj.type = 'bar';
chartObj.options.scales = {
xAxes: [
{
display: false,
stacked: true,
gridLines: { display: false },
},
],
yAxes: [
{
display: false,
stacked: true,
gridLines: { display: false },
ticks: {
beginAtZero: true,
},
},
],
};
break;
case 'bvo':
// Vertical stacked in front of each other
chartObj.type = 'bar';
chartObj.options.scales = {
xAxes: [
{
stacked: true,
gridLines: { display: false },
},
],
yAxes: [
{
stacked: false,
gridLines: { display: false },
ticks: {
beginAtZero: true,
},
},
],
};
break;
case 'bhg':
// Horizontal with grouped bars
chartObj.type = 'horizontalBar';
chartObj.options.scales = {
xAxes: [
{
display: false,
gridLines: { display: false },
},
],
yAxes: [
{
display: false,
gridLines: { display: false },
ticks: {
beginAtZero: true,
},
},
],
};
break;
case 'bvg':
// Vertical with grouped bars
chartObj.type = 'bar';
chartObj.options.scales = {
xAxes: [
{
display: false,
gridLines: { display: false },
},
],
yAxes: [
{
display: false,
gridLines: { display: false },
ticks: {
beginAtZero: true,
},
},
],
};
break;
case 'lc':
// TODO(ian): Support 'nda': no default axes to suppress axes
// https://chart.googleapis.com/chart?cht=lc:nda&chs=200x125&chd=t:40,60,60,45,47,75,70,72
chartObj.type = 'line';
chartObj.options.scales = {
xAxes: [
{
display: false,
gridLines: {
drawOnChartArea: false,
drawTicks: false,
},
},
],
yAxes: [
{
display: false,
gridLines: {
drawOnChartArea: false,
drawTicks: false,
},
ticks: {
beginAtZero: true,
},
},
],
};
break;
case 'ls':
// Sparkline
chartObj.type = 'line';
chartObj.options.scales = {
xAxes: [
{
display: false,
gridLines: { display: false },
},
],
yAxes: [
{
display: false,
gridLines: { display: false },
ticks: {
beginAtZero: true,
},
},
],
};
break;
case 'p':
case 'p3':
case 'pc':
chartObj.type = 'pie';
chartObj.options.plugins = {
datalabels: {
display: false,
},
};
break;
case 'lxy':
// TODO(ian): x-y coordinates/scatter chart
// https://developers.google.com/chart/image/docs/gallery/scatter_charts
break;
}
}
function setData(seriesData, chartObj) {
const lengths = seriesData.map(series => series.length);
const longestSeriesLength = Math.max(...lengths);
// TODO(ian): For horizontal stacked bar charts, indexes are shown top down
// instead of bottom up.
chartObj.data.labels = Array(longestSeriesLength)
.fill(0)
.map((_, idx) => idx);
// Round chart types (e.g. pie) have different color handling.
const isRound = chartObj.type === 'pie';
chartObj.data.datasets = seriesData.map((series, idx) => {
return {
data: series,
fill: false,
backgroundColor: isRound ? undefined : DEFAULT_COLOR_WHEEL[idx % DEFAULT_COLOR_WHEEL.length],
borderColor: isRound ? undefined : DEFAULT_COLOR_WHEEL[idx % DEFAULT_COLOR_WHEEL.length],
borderWidth: 2,
pointRadius: 0,
};
});
if (chartObj.type === 'pie') {
chartObj.data.datasets = chartObj.data.datasets.reverse();
}
}
function setTitle(chtt, chts, chartObj) {
if (!chtt) {
return;
}
let fontColor, fontSize;
if (chts) {
const splits = chts.split(',');
fontColor = `#${splits[0]}`;
fontSize = parseInt(splits[1], 10);
}
chartObj.options.title = {
display: true,
text: chtt.replace('|', '\n', 'g'),
fontSize,
fontColor,
};
}
function setDataLabels(chl, chartObj) {
if (!chl) {
return;
}
const labels = chl.split('|');
// TODO(ian): line charts are supposed to have an effect similar to axis
// value formatters, rather than data labels.
chartObj.options.plugins = chartObj.options.plugins || {};
chartObj.options.plugins.datalabels = {
display: true,
color: '#000',
font: {
size: 14,
},
formatter: (val, ctx) => {
let labelIdx = 0;
for (let datasetIndex = 0; datasetIndex < ctx.datasetIndex; datasetIndex++) {
// Skip over the labels for all the previous datasets.
labelIdx += chartObj.data.datasets[datasetIndex].data.length;
}
// Skip over the labels for data in the current dataset.
labelIdx += ctx.dataIndex;
if (!labels[labelIdx]) {
return '';
}
return labels[labelIdx].replace('\\n', '\n');
},
};
}
function setLegend(chdl, chdlp, chdls, chartObj) {
if (!chdl) {
chartObj.options.legend = {
display: false,
};
return;
}
chartObj.options.legend = {
display: true,
};
const labels = chdl.split('|');
labels.forEach((label, idx) => {
// Note that this overrides 'chl' labels right now
chartObj.data.datasets[idx].label = label;
});
switch (chdlp || 'r') {
case 'b':
chartObj.options.legend.position = 'bottom';
break;
case 't':
chartObj.options.legend.position = 'top';
break;
case 'r':
chartObj.options.legend.position = 'right';
chartObj.options.legend.align = 'start';
break;
case 'l':
chartObj.options.legend.position = 'left';
chartObj.options.legend.align = 'start';
break;
default:
// chdlp is not fully supported
}
// Make legend labels smaller.
chartObj.options.legend.labels = {
boxWidth: 10,
};
if (chdls) {
const [fontColor, fontSize] = chdls.split(',');
chartObj.options.legend.fontSize = parseInt(fontSize, 10);
chartObj.options.legend.fontColor = `#${fontColor}`;
}
}
function setMargins(chma, chartObj) {
const margins = {
left: 0,
right: 0,
top: 10,
bottom: 0,
};
if (chma) {
const inputs = chma.split(',').map(x => parseInt(x, 10));
margins.left = inputs[0];
margins.right = inputs[1];
margins.top = inputs[2];
margins.bottom = inputs[3];
}
chartObj.options.layout = chartObj.options.layout || {};
chartObj.options.layout.padding = margins;
}
function setColors(chco, chartObj) {
if (!chco) {
return null;
}
let seriesColors = chco.split(',').map(colors => {
if (colors.indexOf('|') > -1) {
return colors.split('|').map(color => `#${color}`);
}
return colors;
});
chartObj.data.datasets.forEach((dataset, idx) => {
if (Array.isArray(seriesColors[idx])) {
// Colors behave differently for Chart.js pie chart and bar charts.
dataset.backgroundColor = dataset.borderColor = seriesColors[idx];
} else {
dataset.backgroundColor = dataset.borderColor = '#' + seriesColors[idx];
}
});
}
function setAxes(chxt, chxr, chartObj) {
if (!chxt) {
return;
}
const enabledAxes = chxt.split(',');
if (chxr) {
// Custom axes range
const axesSettings = chxr.split('|');
axesSettings.forEach(axisSetting => {
const opts = axisSetting.split(',').map(parseFloat);
const axisName = enabledAxes[opts[0] /* axisIndex */];
const minVal = opts[1];
const maxVal = opts[2];
const stepVal = opts.length > 3 ? opts[3] : undefined;
let axis;
if (axisName === 'x') {
// Manually scale X-axis so data values extend the full range:
// chart.js doesn't respect min/max in categorical axes.
axis = chartObj.options.scales.xAxes[0];
axis.type = 'linear';
chartObj.data.datasets.forEach(dataset => {
const step = (maxVal - minVal) / dataset.data.length;
let currentStep = minVal;
dataset.data = dataset.data.map(dp => {
const ret = {
x: currentStep,
y: dp,
};
currentStep += step;
return ret;
});
});
} else if (axisName === 'y') {
axis = chartObj.options.scales.yAxes[0];
}
axis.ticks = axis.ticks || {};
axis.ticks.min = minVal;
axis.ticks.max = maxVal;
axis.ticks.stepSize = stepVal;
axis.ticks.maxTicksLimit = Number.MAX_VALUE;
});
}
}
function setAxesLabels(chxt, chxl, chxs, chartObj) {
if (!chxt) {
return;
}
// e.g. {
// 0: 'x',
// 1: 'y',
// }
const axisByIndex = {};
// e.g. {
// 'x': ['Jan', 'Feb', 'March'],
// 'y': ['0', '1', '2'],
// }
const axisLabelsLookup = {
x: [],
y: [],
};
// e.g. {
// 'x': Array<Function>,
// 'y': Array<Function>,
// }
const axisValFormatters = {
x: [],
y: [],
};
// Parse chxt
const validAxesLabels = new Set();
const axes = chxt.split(',');
axes.forEach((axis, idx) => {
axisByIndex[idx] = axis;
validAxesLabels.add(`${idx}:`);
});
if (axes.indexOf('x') > -1) {
chartObj.options.scales.xAxes[0].display = true;
if (chartObj.type === 'horizontalBar') {
// Horizontal bar charts have x axis ticks.
chartObj.options.scales.xAxes[0].gridLines = chartObj.options.scales.xAxes[0].gridLines || {};
chartObj.options.scales.xAxes[0].gridLines.display =
chartObj.options.scales.xAxes[0].gridLines.display ?? true;
chartObj.options.scales.xAxes[0].gridLines.drawOnChartArea =
chartObj.options.scales.xAxes[0].gridLines.drawOnChartArea ?? false;
chartObj.options.scales.xAxes[0].gridLines.drawTicks =
chartObj.options.scales.xAxes[0].gridLines.drawTicks ?? true;
}
chartObj.options.scales.xAxes[0].ticks = chartObj.options.scales.xAxes[0].ticks || {};
chartObj.options.scales.xAxes[0].ticks.autoSkip =
chartObj.options.scales.xAxes[0].ticks.autoSkip ?? false;
}
if (axes.indexOf('y') > -1) {
chartObj.options.scales.yAxes[0].display = true;
// Google Image Charts show yAxes ticks.
chartObj.options.scales.yAxes[0].gridLines = chartObj.options.scales.yAxes[0].gridLines || {};
chartObj.options.scales.yAxes[0].gridLines.display =
chartObj.options.scales.yAxes[0].gridLines.display ?? true;
chartObj.options.scales.yAxes[0].gridLines.drawOnChartArea =
chartObj.options.scales.yAxes[0].gridLines.drawOnChartArea ?? false;
chartObj.options.scales.yAxes[0].gridLines.offsetGridLines =
chartObj.options.scales.yAxes[0].gridLines.offsetGridLines ?? false;
chartObj.options.scales.yAxes[0].gridLines.drawTicks =
chartObj.options.scales.yAxes[0].gridLines.drawTicks ?? true;
}
if (chxs) {
// TODO(ian): If chxs doesn't have N in front of it, then skip forward to
// color and labels.
// https://developers.google.com/chart/image/docs/gallery/bar_charts#axis-label-styles-chxs
// chxs=0,000000,0,0,_
const axisRules = chxs.split('|');
axisRules.forEach(rule => {
const parts = rule.split(',');
// Parse the first character of the first part as axis index.
const axisIdx = parseInt(parts[0][0], 10);
const axisName = axisByIndex[axisIdx];
const axis =
axisName === 'x' ? chartObj.options.scales.xAxes[0] : chartObj.options.scales.yAxes[0];
const hexColor = parts[1];
const fontSize = parts[2];
const alignment = parts[3];
const axisTickVisibility = parts[4];
const tickColor = parts[5];
const axisColor = parts[6];
const skipLabels = parts[7];
axis.gridLines = axis.gridLines || {};
switch (axisTickVisibility) {
case 'l':
// Axis line only
axis.display = true;
axis.gridLines.drawTicks = false;
break;
case 't':
// Tick marks only
axis.display = true;
axis.gridLines.drawTicks = true;
break;
case '_':
// Neither axis nor tick marks
axis.display = false;
break;
case 'lt':
default:
// Ticks and axis line
axis.display = true;
axis.gridLines.drawTicks = true;
break;
}
const matchResults = AXIS_FORMAT_REGEX_CHXS.exec(parts[0]);
if (matchResults && matchResults[1]) {
// Apply prefix and suffix.
let tickPrefix = matchResults[2] || '';
let tickSuffix = matchResults[8] || '';
// Apply cryptic formatting rules.
const valueType = matchResults[4];
const currency = matchResults[5];
const numDecimalPlaces = matchResults[6] ? parseInt(matchResults[6], 10) : 2;
const otherOptions = matchResults[7];
if (valueType && valueType.indexOf('p') > -1) {
// Display a percentage: add % sign and multiply by 100.
tickSuffix += '%';
axisValFormatters[axisName].push(val => {
return val * 100.0;
});
} else if (valueType && valueType.indexOf('e') > -1) {
// Exponential: scientific notation
axisValFormatters[axisName].push(val => {
return val.toExponential();
});
} else if (currency) {
const CURRENCY_SYMBOLS = {
AUD: '$',
CAD: '$',
CHF: 'CHF',
CNY: '元',
EUR: '€',
GBP: '£',
HKD: '$',
INR: '₹',
JPY: '¥',
KRW: '₩',
MXN: '$',
NOK: 'kr',
NZD: '$',
RUB: '₽',
SEK: 'kr',
TRY: '₺',
USD: '$',
ZAR: 'R',
};
const symbol = CURRENCY_SYMBOLS[currency.slice(1)] || '$';
tickPrefix += symbol;
}
if (otherOptions && otherOptions.indexOf('s')) {
// Add thousands separator and apply number of decimal places
axisValFormatters[axisName].push(val => {
return val.toLocaleString('en', {
minimumFractionDigits: numDecimalPlaces,
});
});
} else {
// Apply number of decimal places
axisValFormatters[axisName].push(val => {
return val.toFixed(numDecimalPlaces);
});
}
axisValFormatters[axisName].push(val => {
return tickPrefix + val + tickSuffix;
});
// TODO(ian): Support trailing zeroes option 'z'
// Apply formatters!
axis.ticks = axis.ticks || {};
let nextLabelIdx = 0;
axis.ticks.callback = (val, tickIdx, vals) => {
let retVal = val;
axisValFormatters[axisName].forEach(formatFn => {
retVal = formatFn(retVal);
});
return retVal;
};
}
});
}
if (chxl) {
const splits = chxl.split('|');
let currentAxisIdx, currentAxisName;
splits.forEach(label => {
if (validAxesLabels.has(label)) {
currentAxisIdx = parseInt(label.replace(':', ''), 10);
currentAxisName = axisByIndex[currentAxisIdx];
// Placeholder lists already created, below line unnecessary.
// axisLabelsLookup[currentAxisName] = axisLabelsLookup[currentAxisName] || [];
} else {
axisLabelsLookup[currentAxisName].push(label);
}
});
// These axis ticks override the above automatic formatting axis ticks.
setAxisTicks('x', axisLabelsLookup, chartObj);
setAxisTicks('y', axisLabelsLookup, chartObj);
}
}
function setAxisTicks(axisName, axisLabelsLookup, chartObj) {
let axisLabels = axisLabelsLookup[axisName];
if (axisLabels && axisLabels.length > 0) {
if (axisName === 'y') {
axisLabels = axisLabels.reverse();
}
const axis =
axisName === 'x' ? chartObj.options.scales.xAxes[0] : chartObj.options.scales.yAxes[0];
axis.ticks = axis.ticks || {};
let nextLabelIdx = 0;
axis.ticks.callback = (val, tickIdx, vals) => {
// This needs to be rebuilt every time in callback, because this is the
// only place we have access to accurate 'vals' (which varies based on
// axis scale etc).
// TODO(ian): Need an odd number of ticks for an odd number of axis
// labels. Otherwise they won't quite be spaced evenly.
const numTicks = vals.length;
const numLabels = axisLabels.length;
const idxToLabel = {};
const stepSize = numTicks / (numLabels - 1);
for (let i = 0; i < numLabels - 1; i++) {
const label = axisLabels[i];
idxToLabel[Math.floor(stepSize * i)] = label;
}
idxToLabel[numTicks - 1] = axisLabels[numLabels - 1];
return idxToLabel[tickIdx] || '';
};
axis.ticks.minRotation = 0;
axis.ticks.maxRotation = 0;
axis.ticks.padding = 2;
}
}
function setMarkers(chm, chartObj) {
if (!chm) {
return;
}
const enabledSeriesIndexes = new Set();
let hideMarkers = false;
chm.split('|').forEach((markerRule, idx) => {
const parts = markerRule.split(',');
const markerType = parts[0];
const markerColor = parts[1];
if (markerType === 'B' || markerType === 'b') {
chartObj.data.datasets[idx].fill = true;
chartObj.data.datasets[idx].backgroundColor = '#' + markerColor;
}
// TODO(ian): All of the marker options. See
// https://developers.google.com/chart/image/docs/chart_params#gcharts_data_point_labels
const seriesIndex = parts[2];
const size = parts[4];
if (parseInt(size, 10) === 0) {
hideMarkers = true;
}
enabledSeriesIndexes.add(parseInt(seriesIndex, 10));
// chm=N,000000,0,,10|N,000000,1,,10|N,000000,2,,10
});
chartObj.options.plugins = {
datalabels: {
display: !hideMarkers,
anchor: 'end',
align: 'end',
offset: 0,
font: {
size: 10,
weight: 'bold',
},
formatter: (value, context) => {
if (enabledSeriesIndexes.has(context.datasetIndex)) {
return value;
}
return null;
},
},
};
}
function setGridLines(chg, chartObj) {
if (!chg) {
return;
}
const parts = chg.split(',');
if (Number(parts[0]) > 0) {
chartObj.options.scales.xAxes[0].gridLines.display = true;
chartObj.options.scales.xAxes[0].gridLines.drawOnChartArea = true;
}
if (Number(parts[1]) > 0) {
chartObj.options.scales.yAxes[0].gridLines.display = true;
chartObj.options.scales.yAxes[0].gridLines.drawOnChartArea = true;
}
if (parts.length >= 2) {
const numGridLinesX = 100 / parseInt(parts[0], 10);
const numGridLinesY = 100 / parseInt(parts[1], 10);
chartObj.options.scales.xAxes[0].ticks = {
maxTicksLimit: numGridLinesX,
};
chartObj.options.scales.yAxes[0].ticks = {
maxTicksLimit: numGridLinesY,
};
}
// TODO(ian): dash sizes etc
// https://developers.google.com/chart/image/docs/gallery/line_charts
// TODO(ian): Full implementation https://developers.google.com/chart/image/docs/chart_params#gcharts_grid_lines
}
function setLineChartOptions(chl, chartObj) {
// Set options specific to line chart.
if (!chl) {
return;
}
const series = chl.split('|');
series.forEach((serie, idx) => {
const parts = serie.split(',');
const thickness = parseInt(parts[0], 10);
// TODO(ian): Support for dashed line
// dashLength = parts[1]
// spaceLength = parts[2]
if (!isNaN(thickness)) {
chartObj.data.datasets[idx].borderWidth = thickness;
}
});
}
function toChartJs(query) {
//renderChart(width, height, backgroundColor, devicePixelRatio, untrustedChart) {
const { width, height } = parseSize(query.chs);
const backgroundColor = parseBackgroundColor(query.chf);
// Parse data
const seriesData = parseSeriesData(query.chd, query.chds);
// Start building the chart
const chartObj = {
data: {},
options: {},
};
setChartType(query.cht, chartObj);
setData(seriesData, chartObj);
setTitle(query.chtt, query.chts, chartObj);
setGridLines(query.chg, chartObj);
setLegend(query.chdl, query.chdlp, query.chdls, chartObj);
setMargins(query.chma, chartObj);
setDataLabels(query.chl, chartObj);
setColors(query.chco, chartObj);
setAxes(query.chxt, query.chxr, chartObj);
setAxesLabels(query.chxt, query.chxl, query.chxs, chartObj);
setMarkers(query.chm, chartObj);
setGridLines(query.chg, chartObj);
setLineChartOptions(query.chls, chartObj);
// TODO(ian): Bar Width and Spacing chbh
// Zero Line chp
// console.log(JSON.stringify(chartObj, null, 2));
return {
width,
height,
backgroundColor,
chart: chartObj,
};
}
module.exports = {
toChartJs,
parseSeriesData,
parseSize,
};