From 4ee7caefc4d136608d09275b80bc4647d43d971b Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 12 Sep 2019 10:23:43 +0300 Subject: [PATCH] Migrate Chart visualization to React Part 1: Renderer (#4130) * Migrate Chart visualization: Renderer * Refine PlotlyChart component; move stylesheets to visualization's folder * Migrate Custom JS Chart to React * Cleanup --- client/app/assets/less/main.less | 1 - client/app/pages/dashboards/dashboard.less | 2 +- .../EditVisualizationDialog.jsx | 1 + .../chart/Renderer/CustomPlotlyChart.jsx | 48 +++++++ .../chart/Renderer/PlotlyChart.jsx | 51 +++++++ .../visualizations/chart/Renderer/index.jsx | 16 +++ .../chart/Renderer/renderer.less} | 2 +- .../visualizations/chart/chart-editor.html | 2 +- client/app/visualizations/chart/chart.html | 6 - client/app/visualizations/chart/index.js | 44 +++--- .../chart/plotly/customChartUtils.js | 40 ++++++ .../app/visualizations/chart/plotly/index.js | 131 ++---------------- 12 files changed, 186 insertions(+), 158 deletions(-) create mode 100644 client/app/visualizations/chart/Renderer/CustomPlotlyChart.jsx create mode 100644 client/app/visualizations/chart/Renderer/PlotlyChart.jsx create mode 100644 client/app/visualizations/chart/Renderer/index.jsx rename client/app/{assets/less/inc/visualizations/chart.less => visualizations/chart/Renderer/renderer.less} (54%) delete mode 100644 client/app/visualizations/chart/chart.html create mode 100644 client/app/visualizations/chart/plotly/customChartUtils.js diff --git a/client/app/assets/less/main.less b/client/app/assets/less/main.less index 8b0b26f3e6..0f764f8144 100644 --- a/client/app/assets/less/main.less +++ b/client/app/assets/less/main.less @@ -56,7 +56,6 @@ @import 'inc/visualizations/sankey'; @import 'inc/visualizations/pivot-table'; @import 'inc/visualizations/map'; -@import 'inc/visualizations/chart'; @import 'inc/visualizations/sunburst'; @import 'inc/visualizations/cohort'; @import 'inc/visualizations/misc'; diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index e672fada81..97e039c625 100644 --- a/client/app/pages/dashboards/dashboard.less +++ b/client/app/pages/dashboards/dashboard.less @@ -80,7 +80,7 @@ .map-visualization-container, .word-cloud-visualization-container, .box-plot-deprecated-visualization-container, - .plotly-chart-container { + .chart-visualization-container { position: absolute; left: 0; top: 0; diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx index db2a94384c..4316e82757 100644 --- a/client/app/visualizations/EditVisualizationDialog.jsx +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -191,6 +191,7 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult }) options={options} visualizationName={name} onOptionsChange={onOptionsChanged} + context="query" /> diff --git a/client/app/visualizations/chart/Renderer/CustomPlotlyChart.jsx b/client/app/visualizations/chart/Renderer/CustomPlotlyChart.jsx new file mode 100644 index 0000000000..d60a43c213 --- /dev/null +++ b/client/app/visualizations/chart/Renderer/CustomPlotlyChart.jsx @@ -0,0 +1,48 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { RendererPropTypes } from '@/visualizations'; + +import { clientConfig } from '@/services/auth'; +import resizeObserver from '@/services/resizeObserver'; + +import getChartData from '../getChartData'; +import { Plotly, prepareCustomChartData, createCustomChartRenderer } from '../plotly'; + +export default function CustomPlotlyChart({ options, data }) { + if (!clientConfig.allowCustomJSVisualizations) { + return null; + } + + const [container, setContainer] = useState(null); + + const renderCustomChart = useMemo( + () => createCustomChartRenderer(options.customCode, options.enableConsoleLogs), + [options.customCode, options.enableConsoleLogs], + ); + + const plotlyData = useMemo( + () => prepareCustomChartData(getChartData(data.rows, options)), + [options, data], + ); + + useEffect(() => { + if (container) { + const unwatch = resizeObserver(container, () => { + // Clear existing data with blank data for succeeding codeCall adds data to existing plot. + Plotly.purge(container); + renderCustomChart(plotlyData.x, plotlyData.ys, container, Plotly); + }); + return unwatch; + } + }, [container, plotlyData]); + + // Cleanup when component destroyed + useEffect(() => { + if (container) { + return () => Plotly.purge(container); + } + }, [container]); + + return
; +} + +CustomPlotlyChart.propTypes = RendererPropTypes; diff --git a/client/app/visualizations/chart/Renderer/PlotlyChart.jsx b/client/app/visualizations/chart/Renderer/PlotlyChart.jsx new file mode 100644 index 0000000000..df96f730a6 --- /dev/null +++ b/client/app/visualizations/chart/Renderer/PlotlyChart.jsx @@ -0,0 +1,51 @@ +import { isArray, isObject } from 'lodash'; +import React, { useState, useEffect } from 'react'; +import { RendererPropTypes } from '@/visualizations'; +import resizeObserver from '@/services/resizeObserver'; + +import getChartData from '../getChartData'; +import { Plotly, prepareData, prepareLayout, updateData, applyLayoutFixes } from '../plotly'; + +export default function PlotlyChart({ options, data }) { + const [container, setContainer] = useState(null); + + useEffect(() => { + if (container) { + const plotlyOptions = { showLink: false, displaylogo: false }; + + const chartData = getChartData(data.rows, options); + const plotlyData = prepareData(chartData, options); + const plotlyLayout = prepareLayout(container, options, plotlyData); + + // It will auto-purge previous graph + Plotly.newPlot(container, plotlyData, plotlyLayout, plotlyOptions).then(() => { + applyLayoutFixes(container, plotlyLayout, (e, u) => Plotly.relayout(e, u)); + }); + + container.on('plotly_restyle', (updates) => { + // This event is triggered if some plotly data/layout has changed. + // We need to catch only changes of traces visibility to update stacking + if (isArray(updates) && isObject(updates[0]) && updates[0].visible) { + updateData(plotlyData, options); + Plotly.relayout(container, plotlyLayout); + } + }); + + const unwatch = resizeObserver(container, () => { + applyLayoutFixes(container, plotlyLayout, (e, u) => Plotly.relayout(e, u)); + }); + return unwatch; + } + }, [options, data, container]); + + // Cleanup when component destroyed + useEffect(() => { + if (container) { + return () => Plotly.purge(container); + } + }, [container]); + + return
; +} + +PlotlyChart.propTypes = RendererPropTypes; diff --git a/client/app/visualizations/chart/Renderer/index.jsx b/client/app/visualizations/chart/Renderer/index.jsx new file mode 100644 index 0000000000..f44f3065af --- /dev/null +++ b/client/app/visualizations/chart/Renderer/index.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { RendererPropTypes } from '@/visualizations'; + +import PlotlyChart from './PlotlyChart'; +import CustomPlotlyChart from './CustomPlotlyChart'; + +import './renderer.less'; + +export default function Renderer({ options, ...props }) { + if (options.globalSeriesType === 'custom') { + return ; + } + return ; +} + +Renderer.propTypes = RendererPropTypes; diff --git a/client/app/assets/less/inc/visualizations/chart.less b/client/app/visualizations/chart/Renderer/renderer.less similarity index 54% rename from client/app/assets/less/inc/visualizations/chart.less rename to client/app/visualizations/chart/Renderer/renderer.less index a9b3616285..524cec77b9 100644 --- a/client/app/assets/less/inc/visualizations/chart.less +++ b/client/app/visualizations/chart/Renderer/renderer.less @@ -1,4 +1,4 @@ -.plotly-chart-container { +.chart-visualization-container { height: 400px; overflow: hidden; } diff --git a/client/app/visualizations/chart/chart-editor.html b/client/app/visualizations/chart/chart-editor.html index f782912051..ebe23910fa 100644 --- a/client/app/visualizations/chart/chart-editor.html +++ b/client/app/visualizations/chart/chart-editor.html @@ -180,7 +180,7 @@
-
diff --git a/client/app/visualizations/chart/chart.html b/client/app/visualizations/chart/chart.html deleted file mode 100644 index 8c2cbedc34..0000000000 --- a/client/app/visualizations/chart/chart.html +++ /dev/null @@ -1,6 +0,0 @@ -
- -
-
- -
diff --git a/client/app/visualizations/chart/index.js b/client/app/visualizations/chart/index.js index f4da59f3c9..d8a3e2fed5 100644 --- a/client/app/visualizations/chart/index.js +++ b/client/app/visualizations/chart/index.js @@ -6,9 +6,10 @@ import { registerVisualization } from '@/visualizations'; import { clientConfig } from '@/services/auth'; import ColorPalette from '@/visualizations/ColorPalette'; import getChartData from './getChartData'; -import template from './chart.html'; import editorTemplate from './chart-editor.html'; +import Renderer from './Renderer'; + const DEFAULT_OPTIONS = { globalSeriesType: 'column', sortX: true, @@ -71,26 +72,6 @@ function initEditorForm(options, columns) { return result; } -const ChartRenderer = { - template, - bindings: { - data: '<', - options: '<', - }, - controller($scope) { - this.chartSeries = []; - - const update = () => { - if (this.data) { - this.chartSeries = getChartData(this.data.rows, this.options); - } - }; - - $scope.$watch('$ctrl.data', update); - $scope.$watch('$ctrl.options', update, true); - }, -}; - const ChartEditor = { template: editorTemplate, bindings: { @@ -306,7 +287,6 @@ const ChartEditor = { }; export default function init(ngModule) { - ngModule.component('chartRenderer', ChartRenderer); ngModule.component('chartEditor', ChartEditor); ngModule.run(($injector) => { @@ -314,11 +294,21 @@ export default function init(ngModule) { type: 'CHART', name: 'Chart', isDefault: true, - getOptions: options => merge({}, DEFAULT_OPTIONS, { - showDataLabels: options.globalSeriesType === 'pie', - dateTimeFormat: clientConfig.dateTimeFormat, - }, options), - Renderer: angular2react('chartRenderer', ChartRenderer, $injector), + getOptions: (options) => { + const result = merge({}, DEFAULT_OPTIONS, { + showDataLabels: options.globalSeriesType === 'pie', + dateTimeFormat: clientConfig.dateTimeFormat, + }, options); + + // Backward compatibility + if (['normal', 'percent'].indexOf(result.series.stacking) >= 0) { + result.series.percentValues = result.series.stacking === 'percent'; + result.series.stacking = 'stack'; + } + + return result; + }, + Renderer, Editor: angular2react('chartEditor', ChartEditor, $injector), defaultColumns: 3, diff --git a/client/app/visualizations/chart/plotly/customChartUtils.js b/client/app/visualizations/chart/plotly/customChartUtils.js new file mode 100644 index 0000000000..a6970100e0 --- /dev/null +++ b/client/app/visualizations/chart/plotly/customChartUtils.js @@ -0,0 +1,40 @@ +import { each } from 'lodash'; +import { normalizeValue } from './utils'; + +export function prepareCustomChartData(series) { + const x = []; + const ys = {}; + + each(series, ({ name, data }) => { + ys[name] = []; + each(data, (point) => { + x.push(normalizeValue(point.x)); + ys[name].push(normalizeValue(point.y)); + }); + }); + + return { x, ys }; +} + +export function createCustomChartRenderer(code, logErrorsToConsole = false) { + // Create a function from custom code; catch syntax errors + let render = () => {}; + try { + render = new Function('x, ys, element, Plotly', code); // eslint-disable-line no-new-func + } catch (err) { + if (logErrorsToConsole) { + console.log(`Error while executing custom graph: ${err}`); // eslint-disable-line no-console + } + } + + // Return function that will invoke custom code; catch runtime errors + return (x, ys, element, Plotly) => { + try { + render(x, ys, element, Plotly); + } catch (err) { + if (logErrorsToConsole) { + console.log(`Error while executing custom graph: ${err}`); // eslint-disable-line no-console + } + } + }; +} diff --git a/client/app/visualizations/chart/plotly/index.js b/client/app/visualizations/chart/plotly/index.js index 19d6f87e13..3aa3ad382c 100644 --- a/client/app/visualizations/chart/plotly/index.js +++ b/client/app/visualizations/chart/plotly/index.js @@ -1,5 +1,3 @@ -import { each, debounce, isArray, isObject } from 'lodash'; - import Plotly from 'plotly.js/lib/core'; import bar from 'plotly.js/lib/bar'; import pie from 'plotly.js/lib/pie'; @@ -7,132 +5,23 @@ import histogram from 'plotly.js/lib/histogram'; import box from 'plotly.js/lib/box'; import heatmap from 'plotly.js/lib/heatmap'; -import { normalizeValue } from './utils'; - import prepareData from './prepareData'; import prepareLayout from './prepareLayout'; import updateData from './updateData'; import applyLayoutFixes from './applyLayoutFixes'; +import { prepareCustomChartData, createCustomChartRenderer } from './customChartUtils'; Plotly.register([bar, pie, histogram, box, heatmap]); Plotly.setPlotConfig({ modeBarButtonsToRemove: ['sendDataToCloud'], }); -const PlotlyChart = () => ({ - restrict: 'E', - template: '
', - scope: { - options: '=', - series: '=', - }, - link(scope, element) { - const plotlyElement = element[0].querySelector('.plotly-chart-container'); - const plotlyOptions = { showLink: false, displaylogo: false }; - let layout = {}; - let data = []; - - function update() { - if (['normal', 'percent'].indexOf(scope.options.series.stacking) >= 0) { - // Backward compatibility - scope.options.series.percentValues = scope.options.series.stacking === 'percent'; - scope.options.series.stacking = 'stack'; - } - - data = prepareData(scope.series, scope.options); - layout = prepareLayout(plotlyElement, scope.options, data); - - // It will auto-purge previous graph - Plotly.newPlot(plotlyElement, data, layout, plotlyOptions).then(() => { - applyLayoutFixes(plotlyElement, layout, (e, u) => Plotly.relayout(e, u)); - }); - - plotlyElement.on('plotly_restyle', (updates) => { - // This event is triggered if some plotly data/layout has changed. - // We need to catch only changes of traces visibility to update stacking - if (isArray(updates) && isObject(updates[0]) && updates[0].visible) { - updateData(data, scope.options); - Plotly.relayout(plotlyElement, layout); - } - }); - } - update(); - - scope.$watch('series', (oldValue, newValue) => { - if (oldValue !== newValue) { - update(); - } - }); - scope.$watch('options', (oldValue, newValue) => { - if (oldValue !== newValue) { - update(); - } - }, true); - - scope.handleResize = debounce(() => { - applyLayoutFixes(plotlyElement, layout, (e, u) => Plotly.relayout(e, u)); - }, 50); - }, -}); - -const CustomPlotlyChart = clientConfig => ({ - restrict: 'E', - template: '
', - scope: { - series: '=', - options: '=', - }, - link(scope, element) { - if (!clientConfig.allowCustomJSVisualizations) { - return; - } - - const refresh = () => { - // Clear existing data with blank data for succeeding codeCall adds data to existing plot. - Plotly.newPlot(element[0].firstChild); - - try { - // eslint-disable-next-line no-new-func - const codeCall = new Function('x, ys, element, Plotly', scope.options.customCode); - codeCall(scope.x, scope.ys, element[0].children[0], Plotly); - } catch (err) { - if (scope.options.enableConsoleLogs) { - // eslint-disable-next-line no-console - console.log(`Error while executing custom graph: ${err}`); - } - } - }; - - const timeSeriesToPlotlySeries = () => { - scope.x = []; - scope.ys = {}; - each(scope.series, (series) => { - scope.ys[series.name] = []; - each(series.data, (point) => { - scope.x.push(normalizeValue(point.x)); - scope.ys[series.name].push(normalizeValue(point.y)); - }); - }); - }; - - scope.handleResize = () => { - refresh(); - }; - - scope.$watch('[options.customCode, options.autoRedraw]', () => { - refresh(); - }, true); - - scope.$watch('series', () => { - timeSeriesToPlotlySeries(); - refresh(); - }, true); - }, -}); - -export default function init(ngModule) { - ngModule.directive('plotlyChart', PlotlyChart); - ngModule.directive('customPlotlyChart', CustomPlotlyChart); -} - -init.init = true; +export { + Plotly, + prepareData, + prepareLayout, + updateData, + applyLayoutFixes, + prepareCustomChartData, + createCustomChartRenderer, +};