mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 10:00:45 -05:00
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:
committed by
Arik Fraimovich
parent
6458a1eb62
commit
54071e4b87
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -191,6 +191,7 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
|
||||
options={options}
|
||||
visualizationName={name}
|
||||
onOptionsChange={onOptionsChanged}
|
||||
context="query"
|
||||
/>
|
||||
</div>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -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;
|
||||
51
client/app/visualizations/chart/Renderer/PlotlyChart.jsx
Normal file
51
client/app/visualizations/chart/Renderer/PlotlyChart.jsx
Normal 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;
|
||||
16
client/app/visualizations/chart/Renderer/index.jsx
Normal file
16
client/app/visualizations/chart/Renderer/index.jsx
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
.plotly-chart-container {
|
||||
.chart-visualization-container {
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
40
client/app/visualizations/chart/plotly/customChartUtils.js
Normal file
40
client/app/visualizations/chart/plotly/customChartUtils.js
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user