583 lines
15 KiB
JavaScript
583 lines
15 KiB
JavaScript
|
/**
|
||
|
* @license Data plugin for Highcharts
|
||
|
*
|
||
|
* (c) 2012-2013 Torstein Hønsi
|
||
|
* Last revision 2013-06-07
|
||
|
*
|
||
|
* License: www.highcharts.com/license
|
||
|
*/
|
||
|
|
||
|
/*
|
||
|
* The Highcharts Data plugin is a utility to ease parsing of input sources like
|
||
|
* CSV, HTML tables or grid views into basic configuration options for use
|
||
|
* directly in the Highcharts constructor.
|
||
|
*
|
||
|
* Demo: http://jsfiddle.net/highcharts/SnLFj/
|
||
|
*
|
||
|
* --- OPTIONS ---
|
||
|
*
|
||
|
* - columns : Array<Array<Mixed>>
|
||
|
* A two-dimensional array representing the input data on tabular form. This input can
|
||
|
* be used when the data is already parsed, for example from a grid view component.
|
||
|
* Each cell can be a string or number. If not switchRowsAndColumns is set, the columns
|
||
|
* are interpreted as series. See also the rows option.
|
||
|
*
|
||
|
* - complete : Function(chartOptions)
|
||
|
* The callback that is evaluated when the data is finished loading, optionally from an
|
||
|
* external source, and parsed. The first argument passed is a finished chart options
|
||
|
* object, containing series and an xAxis with categories if applicable. Thise options
|
||
|
* can be extended with additional options and passed directly to the chart constructor.
|
||
|
*
|
||
|
* - csv : String
|
||
|
* A comma delimited string to be parsed. Related options are startRow, endRow, startColumn
|
||
|
* and endColumn to delimit what part of the table is used. The lineDelimiter and
|
||
|
* itemDelimiter options define the CSV delimiter formats.
|
||
|
*
|
||
|
* - endColumn : Integer
|
||
|
* In tabular input data, the first row (indexed by 0) to use. Defaults to the last
|
||
|
* column containing data.
|
||
|
*
|
||
|
* - endRow : Integer
|
||
|
* In tabular input data, the last row (indexed by 0) to use. Defaults to the last row
|
||
|
* containing data.
|
||
|
*
|
||
|
* - googleSpreadsheetKey : String
|
||
|
* A Google Spreadsheet key. See https://developers.google.com/gdata/samples/spreadsheet_sample
|
||
|
* for general information on GS.
|
||
|
*
|
||
|
* - googleSpreadsheetWorksheet : String
|
||
|
* The Google Spreadsheet worksheet. The available id's can be read from
|
||
|
* https://spreadsheets.google.com/feeds/worksheets/{key}/public/basic
|
||
|
*
|
||
|
* - itemDelimiter : String
|
||
|
* Item or cell delimiter for parsing CSV. Defaults to ",".
|
||
|
*
|
||
|
* - lineDelimiter : String
|
||
|
* Line delimiter for parsing CSV. Defaults to "\n".
|
||
|
*
|
||
|
* - parsed : Function
|
||
|
* A callback function to access the parsed columns, the two-dimentional input data
|
||
|
* array directly, before they are interpreted into series data and categories.
|
||
|
*
|
||
|
* - parseDate : Function
|
||
|
* A callback function to parse string representations of dates into JavaScript timestamps.
|
||
|
* Return an integer on success.
|
||
|
*
|
||
|
* - rows : Array<Array<Mixed>>
|
||
|
* The same as the columns input option, but defining rows intead of columns.
|
||
|
*
|
||
|
* - startColumn : Integer
|
||
|
* In tabular input data, the first column (indexed by 0) to use.
|
||
|
*
|
||
|
* - startRow : Integer
|
||
|
* In tabular input data, the first row (indexed by 0) to use.
|
||
|
*
|
||
|
* - table : String|HTMLElement
|
||
|
* A HTML table or the id of such to be parsed as input data. Related options ara startRow,
|
||
|
* endRow, startColumn and endColumn to delimit what part of the table is used.
|
||
|
*/
|
||
|
|
||
|
// JSLint options:
|
||
|
/*global jQuery */
|
||
|
|
||
|
(function (Highcharts) {
|
||
|
|
||
|
// Utilities
|
||
|
var each = Highcharts.each;
|
||
|
|
||
|
|
||
|
// The Data constructor
|
||
|
var Data = function (dataOptions, chartOptions) {
|
||
|
this.init(dataOptions, chartOptions);
|
||
|
};
|
||
|
|
||
|
// Set the prototype properties
|
||
|
Highcharts.extend(Data.prototype, {
|
||
|
|
||
|
/**
|
||
|
* Initialize the Data object with the given options
|
||
|
*/
|
||
|
init: function (options, chartOptions) {
|
||
|
this.options = options;
|
||
|
this.chartOptions = chartOptions;
|
||
|
this.columns = options.columns || this.rowsToColumns(options.rows) || [];
|
||
|
|
||
|
// No need to parse or interpret anything
|
||
|
if (this.columns.length) {
|
||
|
this.dataFound();
|
||
|
|
||
|
// Parse and interpret
|
||
|
} else {
|
||
|
|
||
|
// Parse a CSV string if options.csv is given
|
||
|
this.parseCSV();
|
||
|
|
||
|
// Parse a HTML table if options.table is given
|
||
|
this.parseTable();
|
||
|
|
||
|
// Parse a Google Spreadsheet
|
||
|
this.parseGoogleSpreadsheet();
|
||
|
}
|
||
|
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Get the column distribution. For example, a line series takes a single column for
|
||
|
* Y values. A range series takes two columns for low and high values respectively,
|
||
|
* and an OHLC series takes four columns.
|
||
|
*/
|
||
|
getColumnDistribution: function () {
|
||
|
var chartOptions = this.chartOptions,
|
||
|
getValueCount = function (type) {
|
||
|
return (Highcharts.seriesTypes[type || 'line'].prototype.pointArrayMap || [0]).length;
|
||
|
},
|
||
|
globalType = chartOptions && chartOptions.chart && chartOptions.chart.type,
|
||
|
individualCounts = [];
|
||
|
|
||
|
each((chartOptions && chartOptions.series) || [], function (series) {
|
||
|
individualCounts.push(getValueCount(series.type || globalType));
|
||
|
});
|
||
|
|
||
|
this.valueCount = {
|
||
|
global: getValueCount(globalType),
|
||
|
individual: individualCounts
|
||
|
};
|
||
|
},
|
||
|
|
||
|
|
||
|
dataFound: function () {
|
||
|
// Interpret the values into right types
|
||
|
this.parseTypes();
|
||
|
|
||
|
// Use first row for series names?
|
||
|
this.findHeaderRow();
|
||
|
|
||
|
// Handle columns if a handleColumns callback is given
|
||
|
this.parsed();
|
||
|
|
||
|
// Complete if a complete callback is given
|
||
|
this.complete();
|
||
|
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Parse a CSV input string
|
||
|
*/
|
||
|
parseCSV: function () {
|
||
|
var self = this,
|
||
|
options = this.options,
|
||
|
csv = options.csv,
|
||
|
columns = this.columns,
|
||
|
startRow = options.startRow || 0,
|
||
|
endRow = options.endRow || Number.MAX_VALUE,
|
||
|
startColumn = options.startColumn || 0,
|
||
|
endColumn = options.endColumn || Number.MAX_VALUE,
|
||
|
lines,
|
||
|
activeRowNo = 0;
|
||
|
|
||
|
if (csv) {
|
||
|
|
||
|
lines = csv
|
||
|
.replace(/\r\n/g, "\n") // Unix
|
||
|
.replace(/\r/g, "\n") // Mac
|
||
|
.split(options.lineDelimiter || "\n");
|
||
|
|
||
|
each(lines, function (line, rowNo) {
|
||
|
var trimmed = self.trim(line),
|
||
|
isComment = trimmed.indexOf('#') === 0,
|
||
|
isBlank = trimmed === '',
|
||
|
items;
|
||
|
|
||
|
if (rowNo >= startRow && rowNo <= endRow && !isComment && !isBlank) {
|
||
|
items = line.split(options.itemDelimiter || ',');
|
||
|
each(items, function (item, colNo) {
|
||
|
if (colNo >= startColumn && colNo <= endColumn) {
|
||
|
if (!columns[colNo - startColumn]) {
|
||
|
columns[colNo - startColumn] = [];
|
||
|
}
|
||
|
|
||
|
columns[colNo - startColumn][activeRowNo] = item;
|
||
|
}
|
||
|
});
|
||
|
activeRowNo += 1;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
this.dataFound();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Parse a HTML table
|
||
|
*/
|
||
|
parseTable: function () {
|
||
|
var options = this.options,
|
||
|
table = options.table,
|
||
|
columns = this.columns,
|
||
|
startRow = options.startRow || 0,
|
||
|
endRow = options.endRow || Number.MAX_VALUE,
|
||
|
startColumn = options.startColumn || 0,
|
||
|
endColumn = options.endColumn || Number.MAX_VALUE,
|
||
|
colNo;
|
||
|
|
||
|
if (table) {
|
||
|
|
||
|
if (typeof table === 'string') {
|
||
|
table = document.getElementById(table);
|
||
|
}
|
||
|
|
||
|
each(table.getElementsByTagName('tr'), function (tr, rowNo) {
|
||
|
colNo = 0;
|
||
|
if (rowNo >= startRow && rowNo <= endRow) {
|
||
|
each(tr.childNodes, function (item) {
|
||
|
if ((item.tagName === 'TD' || item.tagName === 'TH') && colNo >= startColumn && colNo <= endColumn) {
|
||
|
if (!columns[colNo]) {
|
||
|
columns[colNo] = [];
|
||
|
}
|
||
|
columns[colNo][rowNo - startRow] = item.innerHTML;
|
||
|
|
||
|
colNo += 1;
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
this.dataFound(); // continue
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* TODO:
|
||
|
* - switchRowsAndColumns
|
||
|
*/
|
||
|
parseGoogleSpreadsheet: function () {
|
||
|
var self = this,
|
||
|
options = this.options,
|
||
|
googleSpreadsheetKey = options.googleSpreadsheetKey,
|
||
|
columns = this.columns,
|
||
|
startRow = options.startRow || 0,
|
||
|
endRow = options.endRow || Number.MAX_VALUE,
|
||
|
startColumn = options.startColumn || 0,
|
||
|
endColumn = options.endColumn || Number.MAX_VALUE,
|
||
|
gr, // google row
|
||
|
gc; // google column
|
||
|
|
||
|
if (googleSpreadsheetKey) {
|
||
|
jQuery.getJSON('https://spreadsheets.google.com/feeds/cells/' +
|
||
|
googleSpreadsheetKey + '/' + (options.googleSpreadsheetWorksheet || 'od6') +
|
||
|
'/public/values?alt=json-in-script&callback=?',
|
||
|
function (json) {
|
||
|
|
||
|
// Prepare the data from the spreadsheat
|
||
|
var cells = json.feed.entry,
|
||
|
cell,
|
||
|
cellCount = cells.length,
|
||
|
colCount = 0,
|
||
|
rowCount = 0,
|
||
|
i;
|
||
|
|
||
|
// First, find the total number of columns and rows that
|
||
|
// are actually filled with data
|
||
|
for (i = 0; i < cellCount; i++) {
|
||
|
cell = cells[i];
|
||
|
colCount = Math.max(colCount, cell.gs$cell.col);
|
||
|
rowCount = Math.max(rowCount, cell.gs$cell.row);
|
||
|
}
|
||
|
|
||
|
// Set up arrays containing the column data
|
||
|
for (i = 0; i < colCount; i++) {
|
||
|
if (i >= startColumn && i <= endColumn) {
|
||
|
// Create new columns with the length of either end-start or rowCount
|
||
|
columns[i - startColumn] = [];
|
||
|
|
||
|
// SettingMer the length to avoid jslint warning
|
||
|
columns[i - startColumn].length = Math.min(rowCount, endRow - startRow);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Loop over the cells and assign the value to the right
|
||
|
// place in the column arrays
|
||
|
for (i = 0; i < cellCount; i++) {
|
||
|
cell = cells[i];
|
||
|
gr = cell.gs$cell.row - 1; // rows start at 1
|
||
|
gc = cell.gs$cell.col - 1; // columns start at 1
|
||
|
|
||
|
// If both row and col falls inside start and end
|
||
|
// set the transposed cell value in the newly created columns
|
||
|
if (gc >= startColumn && gc <= endColumn &&
|
||
|
gr >= startRow && gr <= endRow) {
|
||
|
columns[gc - startColumn][gr - startRow] = cell.content.$t;
|
||
|
}
|
||
|
}
|
||
|
self.dataFound();
|
||
|
});
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Find the header row. For now, we just check whether the first row contains
|
||
|
* numbers or strings. Later we could loop down and find the first row with
|
||
|
* numbers.
|
||
|
*/
|
||
|
findHeaderRow: function () {
|
||
|
var headerRow = 0;
|
||
|
each(this.columns, function (column) {
|
||
|
if (typeof column[0] !== 'string') {
|
||
|
headerRow = null;
|
||
|
}
|
||
|
});
|
||
|
this.headerRow = 0;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Trim a string from whitespace
|
||
|
*/
|
||
|
trim: function (str) {
|
||
|
return typeof str === 'string' ? str.replace(/^\s+|\s+$/g, '') : str;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Parse numeric cells in to number types and date types in to true dates.
|
||
|
* @param {Object} columns
|
||
|
*/
|
||
|
parseTypes: function () {
|
||
|
var columns = this.columns,
|
||
|
col = columns.length,
|
||
|
row,
|
||
|
val,
|
||
|
floatVal,
|
||
|
trimVal,
|
||
|
dateVal;
|
||
|
|
||
|
while (col--) {
|
||
|
row = columns[col].length;
|
||
|
while (row--) {
|
||
|
val = columns[col][row];
|
||
|
floatVal = parseFloat(val);
|
||
|
trimVal = this.trim(val);
|
||
|
|
||
|
/*jslint eqeq: true*/
|
||
|
if (trimVal == floatVal) { // is numeric
|
||
|
/*jslint eqeq: false*/
|
||
|
columns[col][row] = floatVal;
|
||
|
|
||
|
// If the number is greater than milliseconds in a year, assume datetime
|
||
|
if (floatVal > 365 * 24 * 3600 * 1000) {
|
||
|
columns[col].isDatetime = true;
|
||
|
} else {
|
||
|
columns[col].isNumeric = true;
|
||
|
}
|
||
|
|
||
|
} else { // string, continue to determine if it is a date string or really a string
|
||
|
dateVal = this.parseDate(val);
|
||
|
|
||
|
if (col === 0 && typeof dateVal === 'number' && !isNaN(dateVal)) { // is date
|
||
|
columns[col][row] = dateVal;
|
||
|
columns[col].isDatetime = true;
|
||
|
|
||
|
} else { // string
|
||
|
columns[col][row] = trimVal === '' ? null : trimVal;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
//*
|
||
|
dateFormats: {
|
||
|
'YYYY-mm-dd': {
|
||
|
regex: '^([0-9]{4})-([0-9]{2})-([0-9]{2})$',
|
||
|
parser: function (match) {
|
||
|
return Date.UTC(+match[1], match[2] - 1, +match[3]);
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
// */
|
||
|
/**
|
||
|
* Parse a date and return it as a number. Overridable through options.parseDate.
|
||
|
*/
|
||
|
parseDate: function (val) {
|
||
|
var parseDate = this.options.parseDate,
|
||
|
ret,
|
||
|
key,
|
||
|
format,
|
||
|
match;
|
||
|
|
||
|
if (parseDate) {
|
||
|
ret = parseDate(val);
|
||
|
}
|
||
|
|
||
|
if (typeof val === 'string') {
|
||
|
for (key in this.dateFormats) {
|
||
|
format = this.dateFormats[key];
|
||
|
match = val.match(format.regex);
|
||
|
if (match) {
|
||
|
ret = format.parser(match);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return ret;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Reorganize rows into columns
|
||
|
*/
|
||
|
rowsToColumns: function (rows) {
|
||
|
var row,
|
||
|
rowsLength,
|
||
|
col,
|
||
|
colsLength,
|
||
|
columns;
|
||
|
|
||
|
if (rows) {
|
||
|
columns = [];
|
||
|
rowsLength = rows.length;
|
||
|
for (row = 0; row < rowsLength; row++) {
|
||
|
colsLength = rows[row].length;
|
||
|
for (col = 0; col < colsLength; col++) {
|
||
|
if (!columns[col]) {
|
||
|
columns[col] = [];
|
||
|
}
|
||
|
columns[col][row] = rows[row][col];
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return columns;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* A hook for working directly on the parsed columns
|
||
|
*/
|
||
|
parsed: function () {
|
||
|
if (this.options.parsed) {
|
||
|
this.options.parsed.call(this, this.columns);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* If a complete callback function is provided in the options, interpret the
|
||
|
* columns into a Highcharts options object.
|
||
|
*/
|
||
|
complete: function () {
|
||
|
|
||
|
var columns = this.columns,
|
||
|
firstCol,
|
||
|
type,
|
||
|
options = this.options,
|
||
|
valueCount,
|
||
|
series,
|
||
|
data,
|
||
|
i,
|
||
|
j,
|
||
|
seriesIndex;
|
||
|
|
||
|
|
||
|
if (options.complete) {
|
||
|
|
||
|
this.getColumnDistribution();
|
||
|
|
||
|
// Use first column for X data or categories?
|
||
|
if (columns.length > 1) {
|
||
|
firstCol = columns.shift();
|
||
|
if (this.headerRow === 0) {
|
||
|
firstCol.shift(); // remove the first cell
|
||
|
}
|
||
|
|
||
|
|
||
|
if (firstCol.isDatetime) {
|
||
|
type = 'datetime';
|
||
|
} else if (!firstCol.isNumeric) {
|
||
|
type = 'category';
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Get the names and shift the top row
|
||
|
for (i = 0; i < columns.length; i++) {
|
||
|
if (this.headerRow === 0) {
|
||
|
columns[i].name = columns[i].shift();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Use the next columns for series
|
||
|
series = [];
|
||
|
for (i = 0, seriesIndex = 0; i < columns.length; seriesIndex++) {
|
||
|
|
||
|
// This series' value count
|
||
|
valueCount = Highcharts.pick(this.valueCount.individual[seriesIndex], this.valueCount.global);
|
||
|
|
||
|
// Iterate down the cells of each column and add data to the series
|
||
|
data = [];
|
||
|
for (j = 0; j < columns[i].length; j++) {
|
||
|
data[j] = [
|
||
|
firstCol[j],
|
||
|
columns[i][j] !== undefined ? columns[i][j] : null
|
||
|
];
|
||
|
if (valueCount > 1) {
|
||
|
data[j].push(columns[i + 1][j] !== undefined ? columns[i + 1][j] : null);
|
||
|
}
|
||
|
if (valueCount > 2) {
|
||
|
data[j].push(columns[i + 2][j] !== undefined ? columns[i + 2][j] : null);
|
||
|
}
|
||
|
if (valueCount > 3) {
|
||
|
data[j].push(columns[i + 3][j] !== undefined ? columns[i + 3][j] : null);
|
||
|
}
|
||
|
if (valueCount > 4) {
|
||
|
data[j].push(columns[i + 4][j] !== undefined ? columns[i + 4][j] : null);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Add the series
|
||
|
series[seriesIndex] = {
|
||
|
name: columns[i].name,
|
||
|
data: data
|
||
|
};
|
||
|
|
||
|
i += valueCount;
|
||
|
}
|
||
|
|
||
|
// Do the callback
|
||
|
options.complete({
|
||
|
xAxis: {
|
||
|
type: type
|
||
|
},
|
||
|
series: series
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Register the Data prototype and data function on Highcharts
|
||
|
Highcharts.Data = Data;
|
||
|
Highcharts.data = function (options, chartOptions) {
|
||
|
return new Data(options, chartOptions);
|
||
|
};
|
||
|
|
||
|
// Extend Chart.init so that the Chart constructor accepts a new configuration
|
||
|
// option group, data.
|
||
|
Highcharts.wrap(Highcharts.Chart.prototype, 'init', function (proceed, userOptions, callback) {
|
||
|
var chart = this;
|
||
|
|
||
|
if (userOptions && userOptions.data) {
|
||
|
Highcharts.data(Highcharts.extend(userOptions.data, {
|
||
|
complete: function (dataOptions) {
|
||
|
|
||
|
// Merge series configs
|
||
|
if (userOptions.series) {
|
||
|
each(userOptions.series, function (series, i) {
|
||
|
userOptions.series[i] = Highcharts.merge(series, dataOptions.series[i]);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// Do the merge
|
||
|
userOptions = Highcharts.merge(dataOptions, userOptions);
|
||
|
|
||
|
proceed.call(chart, userOptions, callback);
|
||
|
}
|
||
|
}), userOptions);
|
||
|
} else {
|
||
|
proceed.call(chart, userOptions, callback);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
}(Highcharts));
|