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
This commit is contained in:
Levko Kravets
2019-09-12 10:23:43 +03:00
committed by Arik Fraimovich
parent 6458a1eb62
commit 54071e4b87
12 changed files with 186 additions and 158 deletions

View File

@@ -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';

View File

@@ -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;

View File

@@ -191,6 +191,7 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
options={options}
visualizationName={name}
onOptionsChange={onOptionsChanged}
context="query"
/>
</div>
</Grid.Col>

View File

@@ -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 <div className="chart-visualization-container" ref={setContainer} />;
}
CustomPlotlyChart.propTypes = RendererPropTypes;

View File

@@ -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 <div className="chart-visualization-container" ref={setContainer} />;
}
PlotlyChart.propTypes = RendererPropTypes;

View File

@@ -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 <CustomPlotlyChart options={options} {...props} />;
}
return <PlotlyChart options={options} {...props} />;
}
Renderer.propTypes = RendererPropTypes;

View File

@@ -1,4 +1,4 @@
.plotly-chart-container {
.chart-visualization-container {
height: 400px;
overflow: hidden;
}

View File

@@ -180,7 +180,7 @@
<div ng-if="$ctrl.options.globalSeriesType == 'custom'">
<div class="form-group">
<label class="control-label">Custom code</label>
<textarea ng-model="$ctrl.options.customCode" class="form-control v-resizable" rows="10">
<textarea ng-model="$ctrl.options.customCode" ng-model-options="{ debounce: 300 }" class="form-control v-resizable" rows="10">
</textarea>
</div>

View File

@@ -1,6 +0,0 @@
<div ng-if="$ctrl.options.globalSeriesType != 'custom'">
<plotly-chart options="$ctrl.options" series="$ctrl.chartSeries"></plotly-chart>
</div>
<div ng-if="plotlyOptions.globalSeriesType == 'custom'">
<custom-plotly-chart options="$ctrl.options" series="$ctrl.chartSeries"></custom-plotly-chart>
</div>

View File

@@ -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,

View File

@@ -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
}
}
};
}

View File

@@ -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: '<div class="plotly-chart-container" resize-event="handleResize()"></div>',
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: '<div class="plotly-chart-container" resize-event="handleResize()"></div>',
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,
};