diff --git a/.gitignore b/.gitignore index b7e028c..7d9283f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ +bower-* bower_components* node_modules -bower-* diff --git a/bower.json b/bower.json index 85c6135..a8509b8 100644 --- a/bower.json +++ b/bower.json @@ -1,12 +1,11 @@ { "name": "google-chart", - "version": "2.0.0", + "version": "2.1.0", "description": "Encapsulates Google Charts into a web component", "homepage": "https://googlewebcomponents.github.io/google-chart", "main": "google-chart.html", "authors": [ - "Wes Alvaro", - "Sérgio Gomes" + "Wes Alvaro" ], "license": "Apache-2.0", "ignore": [ @@ -14,29 +13,30 @@ "/test/" ], "keywords": [ - "web-component", - "web-components", - "polymer", "chart", "charts", + "google", "google-visualization", - "google" + "polymer", + "visualization", + "web-component", + "web-components" ], "dependencies": { - "google-apis": "GoogleWebComponents/google-apis#1 - 2", - "iron-ajax": "PolymerElements/iron-ajax#1 - 2", "polymer": "Polymer/polymer#1.9 - 2" }, "devDependencies": { + "iron-ajax": "PolymerElements/iron-ajax#1 - 2", "iron-component-page": "PolymerElements/iron-component-page#1 - 2", - "promise-polyfill": "PolymerLabs/promise-polyfill#1 - 2", - "web-component-tester": "^6.0.0" + "iron-media-query": "PolymerElements/iron-media-query#1 - 2", + "iron-resizable-behavior": "PolymerElements/iron-resizable-behavior#1 - 2", + "paper-button": "PolymerElements/paper-button#1 - 2", + "promise-polyfill": "polymerlabs/promise-polyfill#1 - 2", + "web-component-tester": "Polymer/web-component-tester#^6.4.0" }, "variants": { "1.x": { "dependencies": { - "google-apis": "GoogleWebComponents/google-apis#^1.0.0", - "iron-ajax": "PolymerElements/iron-ajax#^1.0.0", "polymer": "Polymer/polymer#^1.0.0", "promise-polyfill": "PolymerLabs/promise-polyfill#^1.0.0" }, diff --git a/charts-loader.html b/charts-loader.html deleted file mode 100644 index bf38e0b..0000000 --- a/charts-loader.html +++ /dev/null @@ -1,4 +0,0 @@ - - diff --git a/demo/advanced-demo.html b/demo/advanced-demo.html new file mode 100644 index 0000000..6eccd8f --- /dev/null +++ b/demo/advanced-demo.html @@ -0,0 +1,397 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/gallery-demo.html b/demo/gallery-demo.html new file mode 100644 index 0000000..21213c5 --- /dev/null +++ b/demo/gallery-demo.html @@ -0,0 +1,260 @@ + + + + + + + + + \ No newline at end of file diff --git a/demo/google-chart-demo.html b/demo/google-chart-demo.html new file mode 100644 index 0000000..fec24b3 --- /dev/null +++ b/demo/google-chart-demo.html @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/demo/index.html b/demo/index.html index a276fc7..a7e51ea 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1,627 +1,18 @@ - - + - - google-chart Demo - - - - - - - - - -

A simple google-chart looks like this:

- - - - -

Charts can be resized with CSS, but you'll need to call the redraw method when the size changes.

-

Here's a basic responsive example using only CSS and JS (You could also use <iron-media-query>):

- - - - - - - - -

Here's a chart that changes data every 3 seconds:

- - - - - - -

Here's a pie chart with an area selection:

- - -
- - -
- Selected row: None. -
-
- - - -

Here's a pie chart listening for `onmouseover`:

- - -
- - -
- Moused over row: None. -
-
- - - -

Here's a chart defined using data, rather than rows and cols:

- - - - -

And one with some pretty complicated styling, where the data is loaded from an external JSON resource using the data attribute:

- - - - -

Website traffic data by country from an external JSON resource where the data is in raw DataTable format.

- - - - -

Chart gallery

- -

Here's an area chart:

- - - - -

Here's a bar chart:

- - - - -

And here is the material bar chart:

- - - -

Here's a bubble chart:

- - - - -

Here's a candlestick chart:

- - - - -

Here's a column chart:

- - - - -

Here's a combo chart:

- - - - -

Here's a geo chart:

- - - - -

Here's a histogram:

- - - - -

Here's a line chart:

- - - - -

Here's a material line chart:

- - - - -

Here's a organization chart:

- - - - -

Here's a pie chart:

- - - - -

Here's a sankey diagram:

- - - - -

Here's a scatter chart:

- - - - -

Here's a material scatter chart:

- - - - -

Here's a stepped area chart:

- - - - -

Here's a table chart:

- - - -

Here's a timeline chart:

- - - + - -

Here's a wordtree:

- - - -

Here are three gauges:

- - - - -

Here are three gauges with random data that change every three seconds:

- - - - - - -

Here's a treemap:

- - - - -

Here is a chart using a DataTable as its source:

- - - - - - -

Here is a chart using a filtered DataView as its source:

- - - - -

DataViews can be altered, but you'll need to call the redraw method afterward.

- - - -

Here's an image of the line chart:

- - - - + + + Google Chart Polymer Elements Demo + + + diff --git a/demo/simple-demo.html b/demo/simple-demo.html new file mode 100644 index 0000000..abc0ff1 --- /dev/null +++ b/demo/simple-demo.html @@ -0,0 +1,125 @@ + + + + + + + + + + \ No newline at end of file diff --git a/google-chart-api.html b/google-chart-api.html new file mode 100644 index 0000000..9408160 --- /dev/null +++ b/google-chart-api.html @@ -0,0 +1 @@ + diff --git a/google-chart-control.html b/google-chart-control.html new file mode 100644 index 0000000..9f85467 --- /dev/null +++ b/google-chart-control.html @@ -0,0 +1,17 @@ + + + + + + + diff --git a/google-chart-control.js b/google-chart-control.js new file mode 100644 index 0000000..5fe1f2b --- /dev/null +++ b/google-chart-control.js @@ -0,0 +1,173 @@ +(() => { + +/** + * Supported control type short hand values. + * @enum {string} + */ +const ControlTypes = { + 'category': 'CategoryFilter', + 'filter': 'StringFilter', + 'range': 'NumberRangeFilter', + 'range-chart': 'ChartRangeFilter', + 'range-date': 'DateRangeFilter', +}; + +const loader = new GoogleChartLoader(['controls']); + +Polymer({ + is: 'google-chart-control', + properties: { + /** + * The type of control we should draw. + * This can be a string in the `ControlTypes` object or any string corresponding to + * a valid control name. + * @type {string} + * @attribute type + */ + type: { + type: String, + value: 'range', + }, + /** + * The options of the specific control. + * @type {!Object} + * @attribute options + */ + options: { + type: Object, + value: () => ({}), + }, + /** + * The state of the specific control. + * @type {Object|undefined} + * @attribute state + */ + state: { + type: Object, + notify: true, + }, + /** + * True when the control has been drawn and is ready for interaction. + * @type {boolean} + * @attribute drawn + */ + drawn: { + type: Boolean, + notify: true, + readOnly: true, + value: false, + }, + /** + * The label of the column in the data to control. + * Either `label` or `index` should be set, not both. + * @type {string} + * @attribute label + */ + label: { + type: String, + value: null, + observer: '_labelChanged', + }, + /** + * The index of the column in the data to control. + * Either `label` or `index` should be set, not both. + * @type {number} + * @attribute index + */ + index: { + type: Number, + value: -1, + observer: '_indexChanged', + }, + /** + * Specifies the group for the chart in a Dashboard. + * @type {string} + * @attribute group + */ + group: { + type: String, + }, + /** + * Internal promise for creating a `ChartWrapper`. + * Should not be used externally. + * @type {!Promise} + * @attribute wrapper + */ + wrapper: { + type: String, + readOnly: true, + notify: true, + computed: '_computeWrapper(type)', + }, + }, + observers: [ + '_draw(options.*)', + '_draw(state.*)', + ], + + /** + * Update the options with the index properties. + * Only one of index or label should be set. + * @param {number} index the column index to control + */ + _indexChanged(index) { + this.set('options.filterColumnIndex', index >= 0 ? index : undefined); + }, + + /** + * Update the options with the label properties. + * Only one of index or label should be set. + * @param {?string} label the column label to control + */ + _labelChanged(label) { + this.set('options.filterColumnLabel', label || undefined); + }, + + _draw() { + if (!this.drawn || !this.wrapper || this._dontReact) { + this._dontReact = false; + return; + } + this.wrapper.then(w => { + requestAnimationFrame(() => { + w.setState(this.state); + w.setOptions(this.options); + w.draw(); + }); + }); + }, + + /** + * Creates a `ControlWrapper` for the specified `type`. + * @param {string} type the type of the `Control` + * @return {!Promise} + */ + _computeWrapper(type) { + this._setDrawn(false); + return loader.visualization.then(v => { + const w = new v.ControlWrapper({ + 'controlType': ControlTypes[this.type] || this.type, + 'container': this.$.control, + 'options': this.options, + 'state': this.state , + }); + v.events.addOneTimeListener(w, 'ready', () => { + this._dontReact = true; + loader.moveStyles(this); + this._setDrawn(true); + this.state = w.getState(); + this.fire('google-chart-ready', w.getControl()); + // We draw it a second time so that the ranges render correctly... + this._draw(); + }); + v.events.addListener(w, 'statechange', () => { + this._dontReact = true; + this.state = w.getState(); + this.fire('google-chart-statechange', this.state); + }); + return w; + }); + }, +}); + +})(); diff --git a/google-chart-dashboard.html b/google-chart-dashboard.html new file mode 100644 index 0000000..2310b3e --- /dev/null +++ b/google-chart-dashboard.html @@ -0,0 +1,19 @@ + + + + + + + diff --git a/google-chart-dashboard.js b/google-chart-dashboard.js new file mode 100644 index 0000000..73d916c --- /dev/null +++ b/google-chart-dashboard.js @@ -0,0 +1,223 @@ +(() => { + +const loader = new GoogleChartLoader(['controls']); + +Polymer({ + is: 'google-chart-dashboard', + properties: { + /** + * The data we should draw. + * This can be a `DataTable`, `DataView`, or a 2D Array. + * @type {!DataTable|!Array|undefined} + * @attribute data + */ + data: { + type: Object, + notify: true, + }, + + /** + * The current selection in the dashboard. + * @type {!Array<{col:number,row:number}>} + * @attribute selection + */ + selection: { + type: Array, + notify: true, + readOnly: true, + value: () => [], + }, + + /** + * Indicates if the dashboard has finished drawing. + * @type {boolean} + * @attribute drawn + */ + drawn: { + type: Boolean, + notify: true, + readOnly: true, + value: false, + }, + + /** + * Whether the dashboard has been bound. + * @type {!Promise} + * @attribute wrappers + */ + bound: { + type: Boolean, + readOnly: true, + value: false, + }, + + /** + * Internal promise for the `Dashboard` creation. + * @type {!Promise} + * @attribute dashboard + */ + dashboard: { + type: Object, + readOnly: true, + }, + + /** + * Internal object tracking the chart and control groups. + * @type {!Object, + * charts: !Array}>} + * @attribute groups + */ + groups: { + type: Object, + readOnly: true, + }, + }, + + listeners: { + 'google-chart-data-change': '_onDataChanged', + 'google-chart-select': '_onSelectChanged', + }, + observers: [ + '_bindDashboard(dashboard, groups)', + '_drawInitialDashboard(bound, data)', + ], + _uncontrolledCharts: [], + + /** + * Let's iterate through all the charts and controls, building their bind groups. + * Those without a specified group are put into a default group. + * @return {!Object, + * charts: !Array}>} + */ + _createGroups() { + const $ = q => Polymer.dom(this).querySelectorAll(q); + const groups = {}; + const getGroup = id => { + id = id || '__DEFAULT'; + if (!groups[id]) { + groups[id] = {controls:[], charts:[]}; + } + return groups[id]; + }; + const wrapper = el => new Promise(resolve => { + const wrapperChanged = () => { + resolve(el); + el.removeEventListener('wrapper-changed', wrapperChanged); + }; + el.addEventListener('wrapper-changed', wrapperChanged); + }); + $('google-chart-control').forEach( + control => getGroup(control.group).controls.push(wrapper(control))); + $('google-chart').forEach( + chart => getGroup(chart.group).charts.push(wrapper(chart))); + return groups; + }, + + /** + * After the dashboard is attached, we can start looking for charts and controls. + * We'll go ahead and create the Dashboard, too. + */ + attached() { + this._setGroups(this._createGroups()); + this._setDashboard(loader.visualization.then(v => new v.Dashboard(this.$.dashboard))); + }, + + /** + * Once we have the groups of charts and controls, we need to bind them wrappers. + * For each group, if we have at least one chart and control, we're good. + * If there are no controls in a group, add the chart to the uncontrolled list. + * (These charts will get the full dataset to be drawn with, later.) + * If there are no charts in a group, set the `unconnected` class on the control. + * (The styling for unconnected controls will hide them.) + * @param {!google.visualization.Dashboard} dashboard + * @param {!Object>, + * charts: !Array}>>} groups the chart and control binding groups + * @return {!Array>} a promise for the completed binding phase + */ + _bindDashboard(dashboard, groups) { + if (!dashboard || !groups) { + return; + } + const wrappers = []; + for (const id in groups) { + const group = groups[id]; + Promise.all([Promise.all(group.charts), Promise.all(group.controls)]).then(cc => { + const [charts, controls] = cc; + // Bind controls charts if the are specified + if (charts.length && controls.length) { + // We need to resolve the dashboard and all the wrappers before binding. + wrappers.push(Promise.all([ + Promise.all(controls.map(c => c.wrapper)), + Promise.all(charts.map(c => c.wrapper)), + ])); + } else if (charts.length) { + this._uncontrolledCharts.push(...charts); + } else if (controls.length) { + // Add the class `unconnected` to unconnected controls. + // `google-chart-control` should hide itself when this class is added. + controls.forEach(c => { + Polymer.dom(c).classList.add('unconnected'); + }); + } + }); + } + Promise.all([dashboard, wrappers]).then(dww => { + const [d, ww] = dww; + return Promise.all(ww.map(w => w.then(cc => d.bind(cc[0], cc[1])))); + }).then(() => this._setBound(true)); + }, + + /** + * Bindings are configured, now we need to draw data changes. + * For all the uncontrolled charts, just set the data on them. + * @param {!Promise} dashboard + * @param {!Promise} a promise for the completed binding phase + * @param {!google.visualization.DataTable} + */ + _drawInitialDashboard(bound, data) { + if (!bound || !data) { + return; + } + this._setDrawn(false); + this._uncontrolledCharts.forEach(c => { + c.data = data; + }); + this.dashboard.then(d => { + d.draw(data); + this._setDrawn(true); + }); + }, + + /** + * Handle data updates fired from within the dashboard. + * We'll stop it because no other element should be interested. + * @param {!Event} evt the `google-chart-data-change` event + */ + _onDataChanged(evt) { + evt.stopPropagation(); + this.data = evt.detail; + }, + + /** + * Stop and re-fire the select event in the context of the dashboard. + * Chart select events are different from a dashboard. + * (they are based on the chart's data slice, not the full dataset) + * If someone is listening for a select on the Dashboard, they should get + * a reference to the Dashboard's dataset. + * If the select event comes from an uncontrolled chart, + * the selection value will remain unchanged. + * @param {!Event} evt the `google-chart-select` event + */ + _onSelectChanged(evt) { + evt.stopPropagation(); + this.dashboard.then(d => { + this._setSelection(d.getSelection()); + this.fire('google-chart-select', this.selection, {node: this.parentNode}); + }); + }, +}); + +})(); diff --git a/google-chart-data.html b/google-chart-data.html new file mode 100644 index 0000000..7a98dac --- /dev/null +++ b/google-chart-data.html @@ -0,0 +1,4 @@ + + + + diff --git a/google-chart-data.js b/google-chart-data.js new file mode 100644 index 0000000..58af23b --- /dev/null +++ b/google-chart-data.js @@ -0,0 +1,129 @@ +Polymer({ + is: 'google-chart-data', + properties: { + /** + * The main data property of the element. + * Can be either a `DataTable` or a `DataView` if `view` is set. + * @type {?google.visualization.IDataTable} + * @attribute data + */ + data: { + type: Object, + notify: true, + readOnly: true, + observer: '_onDataChanged' + }, + /** + * Can be either a 2D-`Array` or `Object` `DataTable` format. + * @type {(Array|Object)} + * @attribute value + */ + value: { + type: Array, + }, + /** + * An array specifying the column definitions. + * This should only be used with `rows`, not `value`. + * @type {Array} + * @attribute cols + */ + cols: { + type: Array, + }, + /** + * A 2D-array specifying the row data. + * This should only be used with `cols`, not `value`. + * @type {Array>} + * @attribute rows + */ + rows: { + type: Array, + }, + /** + * This specifies that a `DataView` should be created. + * This value should be the result of the `DataView.toJSON` method. + * To simply have `data` be a `DataView` instead of a `DataTable`, + * set this to '{}'. + * This propertiy is observed for splice changes. + * @type {?Object} + * @attribute view + */ + view: { + type: Object, + value: null, + }, + /** + * This value is always a `DataTable`. + * This value is computed from either the: + * `value` or `rows` and `columns` properties. + * @type {?google.visualization.DataTable} + * @attribute table + */ + table: { + type: Object, + notify: true, + readOnly: true, + }, + }, + observers: [ + '_computeData(table, view.*)', + '_computeTableFromValue(value.*)', + '_computeTableFromRowsAndColumns(rows.*, cols.*)', + ], + hostAttributes: { + hidden: true, + }, + factoryImpl: function(opt_value_or_cols, opt_rows) { + if (opt_rows) { + this.cols = opt_value_or_cols; + this.rows = opt_rows; + } else { + this.value = opt_value_or_cols; + } + }, + _v: new GoogleChartLoader().visualization, + _onDataChanged(data) { + this.fire('google-chart-data-change', data); + }, + _computeData(table, viewSplice) { + if (!table) { + return; + } + const view = viewSplice.base; + if (view) { + this._v.then(v => new v.DataView.fromJSON(table, view)).then(view => this._setData(view)); + } else { + this._setData(table); + } + }, + _computeTableFromRowsAndColumns(rowsSplice, colsSplice) { + const rows = rowsSplice.base || []; + const cols = colsSplice.base; + this.debounce('updateDataTable', () => { + this._v.then(v => { + const table = new v.DataTable(); + cols.forEach(function(col) { + table.addColumn(col); + }); + table.addRows(rows); + this._setTable(table); + }); + }); + }, + _computeTableFromValue(valueSplice) { + const value = valueSplice.base; + this.debounce('updateDataTable', () => { + this._v.then(v => { + let table; + if (!value) { + // pass + } else if (value.cols) { + table = new v.DataTable(value); + } else { + table = v.arrayToDataTable(value); + } + this._setTable(table); + }); + }, 0); + } +}); diff --git a/google-chart-editor.html b/google-chart-editor.html new file mode 100644 index 0000000..97c9042 --- /dev/null +++ b/google-chart-editor.html @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/google-chart-editor.js b/google-chart-editor.js new file mode 100644 index 0000000..843afb0 --- /dev/null +++ b/google-chart-editor.js @@ -0,0 +1,92 @@ +(() => { + +const loader = new GoogleChartLoader(['charteditor']); + +Polymer({ + is: 'google-chart-editor', + properties: { + editor: { + type: Object, + value: () => loader.visualization.then(v => new v.ChartEditor()), + observer: '_editorChanged', + }, + opened: { + type: Boolean, + notify: true, + observer: '_openedChanged', + value: false, + }, + type: { + type: String, + notify: true, + }, + options: { + type: Object, + notify: true, + }, + src: { + type: String, + notify: true, + }, + }, + listeners: { + 'google-chart-ready': '_onChartReady', + }, + attached() { + const $ = q => Polymer.dom(this).querySelector(q); + this.chart = $('google-chart'); + const controlSlot = this.shadowRoot.querySelector('slot[name=control]'); + this.dataSourceInput = controlSlot.assignedNodes()[0] || 'urlbox'; + }, + _editorChanged(editor) { + Promise.all([loader.visualization, editor]).then(ve => { + const [v, e] = ve; + v.events.addListener(e, 'ok', () => { + const wrapper = e.getChartWrapper(); + this.chart.type = this.type = wrapper.getChartType(); + this.chart.options = this.options = wrapper.getOptions(); + this.chart.src = this.src = wrapper.getDataSourceUrl(); + this._dontReact = true; + this.opened = false; + this.fire('google-chart-ok'); + }); + v.events.addListener(e, 'cancel', () => { + this._dontReact = true; + this.opened = false; + this.fire('google-chart-cancel'); + }); + }); + }, + _openedChanged(opened) { + if (this._dontReact) { + this._dontReact = false; + return; + } + if (opened) { + Promise.all([this.editor, this.chart.wrapper]).then(ew => { + const [editor, wrapper] = ew; + editor.openDialog(wrapper, {'dataSourceInput': this.dataSourceInput}); + }); + } else { + this.editor.then(e => e.closeDialog()); + } + }, + /** + * Updates the chart in the open dialog. + * If we make changes to the chart outside of the editor, we should update + * the chart inside the editor, as well. + * We only update the chart if it's the one used for the editor. + * @param {!Event} evt + */ + _onChartReady(evt) { + if (!this.opened || this.chart != evt.target) { + return; + } + Promise.all([this.editor, this.chart.wrapper]).then(ew => { + const [editor, wrapper] = ew; + editor.setChartWrapper(wrapper); + }); + }, +}); + +})(); diff --git a/google-chart-loader.html b/google-chart-loader.html index 4e87d31..9954a1c 100644 --- a/google-chart-loader.html +++ b/google-chart-loader.html @@ -1,361 +1,4 @@ - + - + diff --git a/google-chart-loader.js b/google-chart-loader.js new file mode 100644 index 0000000..cbd3521 --- /dev/null +++ b/google-chart-loader.js @@ -0,0 +1,108 @@ +(() => { + +window.google = window.google || {}; +window.google.visualization = window.google.visualization || {}; + +/* + * Allows configuring the defaults. + * We check for: + * - `language` in `google.visualization` then the document element (default: 'en'). + * - `packages` in `google.visualization` (default: ['corechart']). + * - `Version` in `google.visualization` (default: 'current'). This is overridden after load. + */ +const language = window.google.visualization.language || document.documentElement.lang || 'en'; +const packages = window.google.visualization.packages || ['corechart']; +const version = window.google.visualization.Version || 'current'; + +const packagesLoaded = {}; + +let mostRecentLoad = null; + +/** + * Wraps the `google.charts.load` function to return a promise. + * This promise is resolved when the requested packages are loaded. + * @param {!Array} packages the packages to load + * @return {!Promise} resolves with the `google` object + */ +const load = packages => { + const packagesToLoad = packages.reduce((packagesToLoad, pkg) => { + if (!packagesLoaded[pkg]) { + packagesLoaded[pkg] = true + packagesToLoad.push(pkg); + } + return packagesToLoad; + }, []); + if (0 == packagesToLoad.length) { + // No new packages to load, just return the newest result. + return mostRecentLoad; + } + return mostRecentLoad = new Promise(resolve => { + google.charts.load(version, {'packages': packagesToLoad, 'language': language, 'callback': function() { + console.log(`Google Charts v${google.visualization.Version} loaded with packages: ${packagesToLoad}`); + resolve(google); + }}); + }); +}; + +/** @const {string} the selector for Google Visualization stylesheets. */ +const styleSheetSelector = 'head > link[id^="load-css-"][href^="https://www.gstatic.com/charts/"]'; +/** @const {string} the ID prefix to use when import stylesheets into the shadow DOM. */ +const styleElementId = 'shadow-css'; + +/** + * Allows creation of a loader when extra packages are required. + * e.g. new GoogleChartLoader(['table']).visualization + * This new loader's `visualization` property will not be resolved until both + * packages (`corechart` and `table`) have been loaded. + * @param {!Array=} opt_packages the extra packages to load + */ +const GoogleChartLoader = function(opt_packages) { + const goog = load(packages.concat(opt_packages || [])); + /** + * Promise for the `google.visualization` object. + * @type {!Promise} + */ + this.visualization = goog.then(g => g.visualization); + /** + * Promise for the `google.charts` object. + * @type {!Promise} + */ + this.charts = goog.then(g => g.charts); + Object.freeze(this); +}; + +/** + * Move styles from the `Document`'s head to the shadow DOM root. + * If the target's root node is the document, this is a no-op. + * @param {!Polymer.Element} target the location to which we'll import the style sheets + * @return {!Promise} resolves when the style sheets have been moved + */ +GoogleChartLoader.prototype.moveStyles = function(target) { + target = target.root.getRootNode(); + if (target == document) { + return Promise.resolve(); + } + if (target.sheetCount > 0) { + const styleElement = target.querySelector(`#${styleElementId}-${target.sheetCount}`); + styleElement.parentNode.removeChild(styleElement); + } else { + target.sheetCount = 0; + } + styleElement = document.createElement('style'); + styleElement.setAttribute('id', `${styleElementId}-${++target.sheetCount}`); + // We wait for this loader's visualization to be loaded + // because it is resolved after the CSS is available. + return this.visualization.then(() => { + const styleSheets = Polymer.dom(document).querySelectorAll(styleSheetSelector); + const imports = Array.prototype.map.call(styleSheets, css => { + return css.getAttribute('href'); + }).join("';\n @import '"); + + styleElement.innerHTML = `@import '${imports}';`; + target.appendChild(styleElement); + }); +}; + +window.GoogleChartLoader = GoogleChartLoader; + +})(); diff --git a/google-chart-query.html b/google-chart-query.html new file mode 100644 index 0000000..8d075cb --- /dev/null +++ b/google-chart-query.html @@ -0,0 +1,4 @@ + + + + diff --git a/google-chart-query.js b/google-chart-query.js new file mode 100644 index 0000000..6440dab --- /dev/null +++ b/google-chart-query.js @@ -0,0 +1,90 @@ +Polymer({ + is: 'google-chart-query', + properties: { + /** + * URL specifying the source of the data. + * The response of this URL must conform to the + * Chart Tools Datasource Protocol. + * https://developers.google.com/chart/interactive/docs/dev/implementing_data_source + * @type {string|undefined} + * @attribute src + */ + src: { + type: String, + }, + /** + * Query for the datasource. + * The format should match the Google Visualization Query Language. + * https://developers.google.com/chart/interactive/docs/querylanguage + * @type {string} + * @attribute query + */ + query: { + type: String, + value: '', + }, + /** + * This specifies that a `DataView` should be created. + * This value should be the result of the `DataView.toJSON` method. + * To simply have `data` be a `DataView` instead of a `DataTable`, + * set this to '{}'. + * This propertiy is observed for splice changes. + * @type {?Object} + * @attribute view + */ + view: { + type: Object, + value: null, + }, + /** + * The main data property of the element. + * Can be either a `DataTable` or a `DataView` if `view` is set. + * @type {?google.visualization.IDataTable} + * @attribute data + */ + data: { + type: Object, + readOnly: true, + notify: true, + }, + /** + * This value is always a `DataTable`. + * This value is computed from either the: + * `value` or `rows` and `columns` properties. + * @type {?google.visualization.DataTable} + * @attribute table + */ + table: { + type: Object, + notify: true, + readOnly: true, + }, + }, + observers: [ + '_computeData(src,query,view)', + ], + hostAttributes: { + hidden: true, + }, + _v: new GoogleChartLoader().visualization, + /** + * Compute the data and the table. + * @param {string} src the datasource + * @param {string} query the query for the datasource + * @param {?Object} view the `DataView` configuration + */ + _computeData(src, query, view) { + this._v.then(v => { + const request = new v.Query(src); + if (query) { + request.setQuery(query); + } + request.send(response => { + this._setTable(response.getDataTable()); + this._setData( + view ? v.DataView.fromJSON(this.table, view) : this.table); + this.fire('google-chart-data-change', this.data); + }); + }); + } +}); diff --git a/google-chart-styles.html b/google-chart-styles.html deleted file mode 100644 index 10e189a..0000000 --- a/google-chart-styles.html +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/google-chart.html b/google-chart.html index b913050..a7a613d 100644 --- a/google-chart.html +++ b/google-chart.html @@ -1,475 +1,17 @@ - - - - - - + +
Loading chart...
+ + + diff --git a/google-chart.js b/google-chart.js new file mode 100644 index 0000000..e8db4ad --- /dev/null +++ b/google-chart.js @@ -0,0 +1,482 @@ +(() => { + +/** + * Supported chart type short hand values. + * This value corresponds to what the `ChartWrapper` wants. + * (The default namespace is `google.visualization`) + * @enum {string} + */ +const ChartTypes = { + 'area': 'AreaChart', + 'area-stepped': 'SteppedAreaChart', + 'bar': 'BarChart', + 'bar-md': 'google.charts.Bar', + 'bubble': 'BubbleChart', + 'candlestick': 'CandlestickChart', + 'column': 'ColumnChart', + 'combo': 'ComboChart', + 'gantt': 'Gantt', + 'gauge': 'Gauge', + 'geo': 'GeoChart', + 'histogram': 'Histogram', + 'line': 'LineChart', + 'line-md': 'google.charts.Line', + 'org': 'OrgChart', + 'pie': 'PieChart', + 'sankey': 'Sankey', + 'scatter': 'ScatterChart', + 'scatter-md': 'google.charts.Scatter', + 'table': 'Table', + 'timeline': 'Timeline', + 'treemap': 'TreeMap', + 'wordtree': 'WordTree', +}; + +const loader = new GoogleChartLoader(); + +Polymer({ + is: 'google-chart', + properties: { + /** + * The type of chart we should draw. + * This can be a string in the `ChartTypes` object or any string corresponding to + * a valid visualization name. + * @type {string} + * @attribute type + */ + type: { + type: String, + value: 'column', + }, + + /** + * The data we should draw. + * This can be a `DataTable`, `DataView`, or a 2D Array. + * @type {!DataTable|!Array|undefined} + * @attribute data + */ + data: { + type: Object, + notify: true, + }, + + /** + * The options to use when drawing the chart. + * @type {!Object} + * @attribute options + */ + options: { + type: Object, + value: () => ({}), + }, + + /** + * The current selection in the chart. + * Not supported by all chart types (e.g. Gauge). + * @type {!Array<{col:number,row:number}>} + * @attribute selection + */ + selection: { + type: Array, + notify: true, + value: () => [], + }, + + /** + * URL specifying the source of the data. + * The response of this URL must conform to the + * Chart Tools Datasource Protocol. + * https://developers.google.com/chart/interactive/docs/dev/implementing_data_source + * @type {string|undefined} + * @attribute src + */ + src: { + type: String, + }, + + /** + * Query for the datasource. + * The format should match the Google Visualization Query Language. + * https://developers.google.com/chart/interactive/docs/querylanguage + * @type {string} + * @attribute query + */ + query: { + type: String, + }, + + /** + * List of actions to show in chart tooltips. + * When an action is clicked, a `google-chart-action` event is fired. + * The details of the event are simply the ID of the action. + * Actions can be specified as either a object containing an `id` and `text` + * or just as a string that will serve as both values. + * + * This property is observed for splice changes. + * + * Examples: + * actions='["Action Foo", {"id": "actionIdBar", "text": "Action Bar"}]' + * @type {!Array|undefined} + * @attribute actions + */ + actions: { + type: Array, + value: () => [], + }, + + /** + * List of events to relay from the chart. + * Different charts have different event types available. + * Events `ready`, `select` and `error` are always relayed. + * This property is not observed. Events are attached when the chart is created. + * + * Examples: + * events='["rollup", "mouseover"]' + * @type {!Array} + * @attribute events + */ + events: { + type: Array, + value: () => [], + }, + + /** + * Indicates if the chart has finished drawing. + * @type {boolean} + * @attribute drawn + */ + drawn: { + type: Boolean, + value: false, + notify: true, + readOnly: true, + }, + + /** + * The error (if applicable) that occurred when drawing. + * @type {!Object|undefined} + * @attribute errors + */ + error: { + type: Object, + notify: true, + readOnly: true, + }, + + /** + * View specification for the `ChartWrapper`. + * @type {{columns:Array,rows:Array}} + * @attribute view + */ + view: { + type: Object, + }, + + /** + * Specifies the group for the chart in a Dashboard. + * @type {string} + * @attribute group + */ + group: { + type: String, + }, + + /** + * Internal promise for creating a `ChartWrapper`. + * Should not be used externally. + * @type {!Promise} + * @attribute wrapper + */ + wrapper: { + type: Object, + readOnly: true, + notify: true, + computed: '_computeWrapper(type, options)', + }, + }, + observers: [ + '_changeData(data)', + '_changeOptions(options.*)', + '_changeSrc(src, query)', + '_changeView(view.*)', + '_changeSelection(chart, selection.*)', + '_changeActions(chart, actions.*)', + ], + listeners: { + 'google-chart-data-change': '_onDataChanged', + }, + + /** @type {!Array { + this.wrapper.then(w => { + if (w.getDataTable() || w.getDataSourceUrl()) { + requestAnimationFrame(() => w.draw()); + } + }); + }); + }, + + /** + * Execute a method on the chart object. + * If the chart is not created, this is a no-op. + * Otherwise, returns the value from the command. + * + * Example: + * this.chart.execute('goUpAndDraw'); // For the TreeMap + * @param {string} command the method to execute + * @param {*} args variable arguments to pass to the method + * @return {*} the result of executing the chart method + */ + execute(command, ...args) { + // We might want to rely on a promise for this. + return this.drawn ? this.chart[command].apply(this.chart, args) : null; + }, + + /** + * Get an image URI for the graph. + * If the chart is not created, this is a no-op. + * Otherwise, returns a string suitable for an img[src]. + * @return {string} the chart's image URI + */ + get imageURI() { + return this.drawn ? this.chart.getImageURI() : null; + }, + + /** + * Adds an error to the chart. + * @param {string} message the message to show + * @param {string=} opt_detailedMessage the text to show in a tooltip + * @param {!Object=} opt_options options for the error + * @return {!Promise} promise resolving to the string ID of the error + */ + addError(message, opt_detailedMessage, opt_options) { + return loader.visualization.then(v => v.errors.addError( + this.$.chart, message, opt_detailedMessage, opt_options)); + }, + + /** + * Remove an error from the chart. + * @param {string} id the ID of the error to remove + * @returns {boolean} true if the error was removed + */ + removeError(id) { + // gviz removeError does not work in the shadow DOM. + const error = this.root.getElementById(id); + if (!error) { + return false; + } + Polymer.dom(error.parentNode).removeChild(error); + if (id == this.error.id) { + delete this.error; + } + return true; + }, + + /** + * Remove all errors from the chart. + * @return {!Promise} resolves when the errors have been removed + */ + removeAllErrors() { + delete this.error; + return loader.visualization.then(v => v.errors.removeAll(this.$.chart)); + }, + + /** + * Event listener for the `google-chart-data-changed` event. + * This event is fired from nested `google-chart-data` and `google-chart-query` elements. + * We stop propagation and use the data for display. + * @param {!Event} evt the `google-chart-data-changed` event + */ + _onDataChanged(evt) { + // Nothing above us should interpret this event. + evt.stopPropagation(); + this.data = evt.detail; + }, + + /** + * Computes the `ChartWrapper` by the chart type. + * @param {string} type the type of chart to draw, one of `ChartTypes` or freeform + * @return {!Promise} the created `ChartWrapper` + */ + _computeWrapper(type) { + this._setDrawn(false); + return loader.visualization.then(v => { + const w = new v.ChartWrapper({ + chartType: ChartTypes[type] || type, + container: this.$.chart, + dataTable: this.data, + dataSourceUrl: this.src, + options: this.options, + query: this.query, + view: this.view, + }); + v.events.addListener(w, 'ready', () => { + this.chart = w.getChart(); + this._setDrawn(true); + this.fire('google-chart-ready', this.chart); + }); + v.events.addOneTimeListener(w, 'ready', () => { + const c = w.getChart(); + this.events.forEach(evtType => { + v.events.addListener(c, evtType, evt => { + this.fire(`google-chart-${evtType}`, evt); + }); + }); + // Don't move stylesheets if we're using the default (column) chart. + // We probably don't need to move styles for a lot of charts... + // TODO(wesalvaro): Maybe we can make this smarter. + if (this.type != 'column') { + loader.moveStyles(this); + } + }); + v.events.addListener(w, 'error', err => { + this._setError(err); + this.fire('google-chart-error', err); + }); + v.events.addListener(w, 'select', () => { + this._noReact = true; // Don't echo selection + this.selection = w.getChart().getSelection(); + this.fire('google-chart-select', this.selection); + }); + return w; + }); + }, + + /** + * Listens for changes in the selection to reflect them in the chart. + * If we set the selection via an event from the chart, this should be a no-op. + * If the chart does not support selection (e.g. Gauge), this is a no-op. + * @param {!Chart} chart the chart object + * @param {{base:!Array}} selectionSplice the selection change information + */ + _changeSelection(chart, selectionSplice) { + if (!chart) { + return; + } + if (this._noReact) { + this._noReact = false; + return; + } + // Some charts do not support selection. + if (chart.setSelection) { + chart.setSelection(selectionSplice.base); + } + }, + + /** + * Listens for changes in the action list to reflect them in the chart. + * If the chart does not support actions (e.g. Gauge), this is a no-op. + * Adds/removes actions to reflect the array after splice changes. + * @param {!Chart} chart the chart object + * @param {!Object} actionSplice the selection change information + */ + _changeActions(chart, actionsSplice) { + if (!chart) { + return; + } + if (!chart.setAction || !chart.removeAction) { + return; + } + const actionId = a => String(a.id || a); + const addAction = a => { + const id = actionId(a); + chart.setAction({ + id, + text: String(a.text || a), + action: () => this.fire('google-chart-action', id), + }); + }; + const removeAction = a => chart.removeAction(actionId(a)); + switch(actionsSplice.path) { + case 'actions': + // Remove all current actions then add the new ones. + this._attachedActions.forEach(removeAction); + actionsSplice.base.forEach(addAction); + break; + case 'actions.splices': + // We need to check the splices to only remove/add those changed. + actionsSplice.value.indexSplices.forEach(s => { + s.removed.forEach(removeAction); + s.object.slice(s.index, s.index + s.addedCount).forEach(addAction); + }); + break; + } + // Store current actions so we can remove them when the array is replaced + this._attachedActions = actionsSplice.base; + }, + + /** + * @param {!Options} options the chart options + */ + _changeOptions(optionsSlice) { + if (!this.wrapper) { + return; + } + this.wrapper.then(w => { + w.setOptions(optionsSlice.base); + this.draw(); + }); + }, + + /** + * Listens for changes in the view to reflect them in the chart. + * @param {{base:!Array}} viewSplice the view change information + */ + _changeView(viewSplice) { + if (!this.wrapper) { + return; + } + this.wrapper.then(w => { + w.setView(viewSplice.base); + this.draw(); + }); + }, + + /** + * Listens for changes to the data then reflects it in the chart. + * This should override the Data Source specification. + * @param {!DataTable|!Array} data the new data to draw + */ + _changeData(data) { + if (!this.wrapper) { + return; + } + this.wrapper.then(w => { + w.setDataTable(data); + this.draw(); + }); + }, + + /** + * Listens for changes to the query or data source URL then updates the chart. + * Clears the `DataTable` first. + * @param {string} src the data source URL + * @param {string} query the data source query + */ + _changeSrc(src, query) { + if (!this.wrapper) { + return; + } + this.wrapper.then(w => { + w.setDataTable(); + w.setDataSourceUrl(src); + if (query) { + w.setQuery(query); + } + this.draw(); + }); + }, +}); + +})(); diff --git a/test/basic-tests.html b/test/basic-tests.html index 6299199..baa1f79 100644 --- a/test/basic-tests.html +++ b/test/basic-tests.html @@ -19,234 +19,154 @@ +