Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Made export-data output accessible tables #7526

Merged
merged 3 commits into from
Dec 14, 2017
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
286 changes: 245 additions & 41 deletions js/modules/export-data.src.js
Expand Up @@ -24,6 +24,22 @@ var defined = Highcharts.defined,
seriesTypes = Highcharts.seriesTypes,
downloadAttrSupported = doc.createElement('a').download !== undefined;

// Can we add this to utils? Also used in screen-reader.js
/**
* HTML encode some characters vulnerable for XSS.
* @param {string} html The input string
* @return {string} The excaped string
*/
function htmlencode(html) {
return html
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}

Highcharts.setOptions({
/**
* @optionparent exporting
Expand All @@ -50,9 +66,18 @@ Highcharts.setOptions({
* example a range series has the keys `low` and `high` so the
* key length is 2.
*
* By default it returns the series name, followed by the key if
* there is more than one key. For the axis it returns the axis
* title or "Category" or "DateTime" by default.
* If [useMultiLevelHeaders](#exporting.useMultiLevelHeaders) is
* true, columnHeaderFormatter by default returns an object with
* columnTitle and topLevelColumnTitle for each key. Columns with
* the same topLevelColumnTitle have their titles merged into a
* single cell with colspan for table/Excel export.
*
* If `useMultiLevelHeaders` is false, or for CSV export, it returns
* the series name, followed by the key if there is more than one
* key.
*
* For the axis it returns the axis title or "Category" or
* "DateTime" by default.
*
* Return `false` to use Highcharts' proposed header.
*
Expand Down Expand Up @@ -94,7 +119,25 @@ Highcharts.setOptions({
* @sample highcharts/export-data/showtable/ Show the table
* @since 6.0.0
*/
showTable: false
showTable: false,

/**
* Export-data module required. Use multi level headers in data table.
* If [csv.columnHeaderFormatter](#exporting.csv.columnHeaderFormatter)
* is defined, it has to return objects in order for multi level headers
* to work.
*
* @since 6.0.4
*/
useMultiLevelHeaders: true,

/**
* Export-data module required. If using multi level table headers, use
* rowspans for headers that have only one level.
*
* @since 6.0.4
*/
useRowspanHeaders: true
},
/**
* @optionparent lang
Expand Down Expand Up @@ -146,39 +189,54 @@ Highcharts.Chart.prototype.setUpKeyToAxis = function () {
* Export-data module required. Returns a two-dimensional array containing the
* current chart data.
*
* @param {Boolean} multiLevelHeaders
* Use multilevel headers for the rows by default. Adds an extra row
* with top level headers. If a custom columnHeaderFormatter is
* defined, this can override the behavior.
*
* @returns {Array.<Array>}
* The current chart data
*/
Highcharts.Chart.prototype.getDataRows = function () {
Highcharts.Chart.prototype.getDataRows = function (multiLevelHeaders) {
var csvOptions = (this.options.exporting && this.options.exporting.csv) ||
{},
xAxis,
xAxes = this.xAxis,
rows = {},
rowArr = [],
dataRows,
names = [],
topLevelColumnTitles = [],
columnTitles = [],
columnTitleObj,
i,
x,
xTitle,
// Options
columnHeaderFormatter = function (item, key, keyLength) {

if (csvOptions.columnHeaderFormatter) {
var s = csvOptions.columnHeaderFormatter(item, key, keyLength);
if (s !== false) {
return s;
}
}


if (!item) {
return 'Category';
}

if (item instanceof Highcharts.Axis) {
return (item.options.title && item.options.title.text) ||
(item.isDatetimeAxis ? 'DateTime' : 'Category');
}
return item ?
item.name + (keyLength > 1 ? ' (' + key + ')' : '') :
'Category';

if (multiLevelHeaders) {
return {
columnTitle: keyLength > 1 ? key : item.name,
topLevelColumnTitle: item.name
};
}

return item.name + (keyLength > 1 ? ' (' + key + ')' : '');
},
xAxisIndices = [];

Expand All @@ -199,7 +257,6 @@ Highcharts.Chart.prototype.getDataRows = function () {

// Map the categories for value axes
each(pointArrayMap, function (prop) {

var axisName = (
(series.keyToAxis && series.keyToAxis[prop]) ||
prop
Expand Down Expand Up @@ -229,14 +286,23 @@ Highcharts.Chart.prototype.getDataRows = function () {
xAxisIndices.push([xAxisIndex, i]);
}

// Add the column headers, usually the same as series names
// Compute the column headers and top level headers, usually the
// same as series names
j = 0;
while (j < valueCount) {
names.push(columnHeaderFormatter(
columnTitleObj = columnHeaderFormatter(
series,
pointArrayMap[j],
pointArrayMap.length
));
);
columnTitles.push(
columnTitleObj.columnTitle || columnTitleObj
);
if (multiLevelHeaders) {
topLevelColumnTitles.push(
columnTitleObj.topLevelColumnTitle || columnTitleObj
);
}
j++;
}

Expand Down Expand Up @@ -294,7 +360,10 @@ Highcharts.Chart.prototype.getDataRows = function () {
}

var xAxisIndex, column;
dataRows = [names];

// Add computed column headers and top level headers to final row set
dataRows = multiLevelHeaders ? [topLevelColumnTitles, columnTitles] :
[columnTitles];

i = xAxisIndices.length;
while (i--) { // Start from end to splice in
Expand All @@ -310,6 +379,11 @@ Highcharts.Chart.prototype.getDataRows = function () {
// Add header row
xTitle = columnHeaderFormatter(xAxis);
dataRows[0].splice(column, 0, xTitle);
if (multiLevelHeaders && dataRows[1]) {
// If using multi level headers, we just added top level header.
// Also add for sub level
dataRows[1].splice(column, 0, xTitle);
}

// Add the category column
each(rowArr, function (row) { // eslint-disable-line no-loop-func
Expand Down Expand Up @@ -407,40 +481,170 @@ Highcharts.Chart.prototype.getCSV = function (useLocalDecimalPoint) {
* HTML representation of the data.
*/
Highcharts.Chart.prototype.getTable = function (useLocalDecimalPoint) {
var html = '<table><thead>',
rows = this.getDataRows();

// Transform the rows to HTML
each(rows, function (row, i) {
var tag = i ? 'td' : 'th',
val,
j,
n = useLocalDecimalPoint ? (1.1).toLocaleString()[1] : '.';

html += '<tr>';
for (j = 0; j < row.length; j = j + 1) {
val = row[j];
// Add the cell
var html = '<table>',
options = this.options,
decimalPoint = useLocalDecimalPoint ? (1.1).toLocaleString()[1] : '.',
useMultiLevelHeaders = pick(
options.exporting.useMultiLevelHeaders, true
),
rows = this.getDataRows(useMultiLevelHeaders),
rowLength = 0,
topHeaders = useMultiLevelHeaders ? rows.shift() : null,
subHeaders = rows.shift(),
// Compare two rows for equality
isRowEqual = function (row1, row2) {
var i = row1.length;
if (row2.length === i) {
while (i--) {
if (row1[i] !== row2[i]) {
return false;
}
}
} else {
return false;
}
return true;
},
// Get table cell HTML from value
getCellHTMLFromValue = function (tag, classes, attrs, value) {
var val = value || '',
className = 'text' + (classes ? ' ' + classes : '');
// Convert to string if number
if (typeof val === 'number') {
val = val.toString();
if (n === ',') {
val = val.replace('.', n);
if (decimalPoint === ',') {
val = val.replace('.', decimalPoint);
}
html += '<' + tag + ' class="number">' + val + '</' + tag + '>';
className = 'number';
} else if (!value) {
className = 'empty';
}
return '<' + tag + (attrs ? ' ' + attrs : '') +
' class="' + className + '">' +
val + '</' + tag + '>';
},
// Get table header markup from row data
getTableHeaderHTML = function (topheaders, subheaders, rowLength) {
var html = '<thead>',
i = 0,
len = rowLength || subheaders && subheaders.length,
next,
cur,
curColspan = 0,
rowspan;
// Clean up multiple table headers. Chart.getDataRows() returns two
// levels of headers when using multilevel, not merged. We need to
// merge identical headers, remove redundant headers, and keep it
// all marked up nicely.
if (
useMultiLevelHeaders &&
topheaders &&
subheaders &&
!isRowEqual(topheaders, subheaders)
) {
html += '<tr>';
for (; i < len; ++i) {
cur = topheaders[i];
next = topheaders[i + 1];
if (cur === next) {
++curColspan;
} else if (curColspan) {
// Ended colspan
// Add cur to HTML with colspan.
html += getCellHTMLFromValue(
'th',
'highcharts-table-topheading',
'scope="col" ' +
'colspan="' + (curColspan + 1) + '"',
cur
);
curColspan = 0;
} else {
// Cur is standalone. If it is same as sublevel,
// remove sublevel and add just toplevel.
if (cur === subheaders[i]) {
if (options.exporting.useRowspanHeaders) {
rowspan = 2;
delete subheaders[i];
} else {
rowspan = 1;
subheaders[i] = '';
}
} else {
rowspan = 1;
}
html += getCellHTMLFromValue(
'th',
'highcharts-table-topheading',
'scope="col"' +
(rowspan > 1 ?
' valign="top" rowspan="' + rowspan + '"' :
''),
cur
);
}
}
html += '</tr>';
}

} else {
html += '<' + tag + ' class="text">' +
(val === undefined ? '' : val) + '</' + tag + '>';
// Add the subheaders (the only headers if not using multilevels)
if (subheaders) {
html += '<tr>';
for (i = 0, len = subheaders.length; i < len; ++i) {
if (subheaders[i] !== undefined) {
html += getCellHTMLFromValue(
'th', null, 'scope="col"', subheaders[i]
);
}
}
html += '</tr>';
}
}
html += '</thead>';
return html;
};

html += '</tr>';
// Add table caption
if (options.exporting.tableCaption !== false) {
html += '<caption class="highcharts-table-caption">' + pick(
options.exporting.tableCaption,
(
options.title.text ?
htmlencode(options.title.text) :
'Chart'
)) +
'</caption>';
}

// After the first row, end head and start body
if (!i) {
html += '</thead><tbody>';
// Find longest row
for (var i = 0, len = rows.length; i < len; ++i) {
if (rows[i].length > rowLength) {
rowLength = rows[i].length;
}
}

// Add header
html += getTableHeaderHTML(
topHeaders,
subHeaders,
Math.max(rowLength, subHeaders.length)
);

// Transform the rows to HTML
html += '<tbody>';
each(rows, function (row) {
html += '<tr>';
for (var j = 0; j < rowLength; j++) {
// Make first column a header too. Especially important for
// category axes, but also might make sense for datetime? Should
// await user feedback on this.
html += getCellHTMLFromValue(
j ? 'td' : 'th',
null,
j ? '' : 'scope="row"',
row[j]
);
}
html += '</tr>';
});
html += '</tbody></table>';

Expand Down