mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Merge pull request #2334 from tonyjiangh/feat/funnel_visualization
Add funnel visualization
This commit is contained in:
43
client/app/visualizations/funnel/funnel-editor.html
Normal file
43
client/app/visualizations/funnel/funnel-editor.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<div class="form-horizontal">
|
||||
<div style="margin-bottom: 20px;">
|
||||
This visualization constructs funnel chart. Please notice that value column only accept number for values.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Step Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.stepCol.colName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Step Column Dispaly Name</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="text" ng-model="visualization.options.stepCol.displayAs" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Funnel Value Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.valueCol.colName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Funnel Value Column Dispaly Name</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="text" ng-model="visualization.options.valueCol.displayAs" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Auto Sort Record By Value</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="checkbox" ng-model="visualization.options.autoSort">
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="!visualization.options.autoSort">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Funnel Value Columns Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.sortKeyCol.colName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
49
client/app/visualizations/funnel/funnel.less
Normal file
49
client/app/visualizations/funnel/funnel.less
Normal file
@@ -0,0 +1,49 @@
|
||||
.funnel-visualization-container {
|
||||
table {
|
||||
min-width: 450px;
|
||||
}
|
||||
.table-borderless td, .table-borderless th {
|
||||
border: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.step {
|
||||
max-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.step .step-name {
|
||||
visibility: hidden;
|
||||
width: inherit;
|
||||
padding: 3px 5px;
|
||||
background-color: white;
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
white-space: initial;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.step:hover .step-name {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
div.bar {
|
||||
height: 30px;
|
||||
}
|
||||
div.bar.centered {
|
||||
margin: auto;
|
||||
}
|
||||
.value {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.container {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
211
client/app/visualizations/funnel/index.js
Normal file
211
client/app/visualizations/funnel/index.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { debounce, sortBy, isNumber, every, difference } from 'underscore';
|
||||
import d3 from 'd3';
|
||||
import angular from 'angular';
|
||||
|
||||
import { ColorPalette, normalizeValue } from '@/visualizations/chart/plotly/utils';
|
||||
import editorTemplate from './funnel-editor.html';
|
||||
import './funnel.less';
|
||||
|
||||
function isNoneNaNNum(val) {
|
||||
if (!isNumber(val) || isNaN(val)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizePercentage(num) {
|
||||
if (num < 0.01) { return '<0.01%'; }
|
||||
if (num > 1000) { return '>1000%'; }
|
||||
return num.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function Funnel(scope, element) {
|
||||
this.element = element;
|
||||
this.watches = [];
|
||||
const vis = d3.select(element);
|
||||
const options = scope.visualization.options;
|
||||
|
||||
function drawFunnel(data) {
|
||||
const maxToPrevious = d3.max(data, d => d.pctPrevious);
|
||||
// Table
|
||||
const table = vis.append('table')
|
||||
.attr('class', 'table table-condensed table-hover table-borderless');
|
||||
|
||||
// Header
|
||||
const header = table.append('thead').append('tr');
|
||||
header.append('th').text(options.stepCol.displayAs);
|
||||
header.append('th').attr('class', 'text-center').text(options.valueCol.displayAs);
|
||||
header.append('th').attr('class', 'text-center').text('% Max');
|
||||
header.append('th').attr('class', 'text-center').text('% Previous');
|
||||
|
||||
// Body
|
||||
const trs = table.append('tbody')
|
||||
.selectAll('tr')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('tr');
|
||||
|
||||
// Steps row
|
||||
trs.append('td')
|
||||
.attr('class', 'col-xs-3 step')
|
||||
.text(d => d.step)
|
||||
.append('div')
|
||||
.attr('class', 'step-name')
|
||||
.text(d => d.step);
|
||||
|
||||
// Funnel bars
|
||||
const valContainers = trs.append('td')
|
||||
.attr('class', 'col-xs-5')
|
||||
.append('div')
|
||||
.attr('class', 'container');
|
||||
valContainers.append('div')
|
||||
.attr('class', 'bar centered')
|
||||
.style('background-color', ColorPalette.Cyan)
|
||||
.style('width', d => d.pctMax + '%');
|
||||
valContainers.append('div')
|
||||
.attr('class', 'value')
|
||||
.text(d => d.value.toLocaleString());
|
||||
|
||||
// pctMax
|
||||
trs.append('td')
|
||||
.attr('class', 'col-xs-2 text-center')
|
||||
.text(d => normalizePercentage(d.pctMax));
|
||||
|
||||
// pctPrevious
|
||||
const pctContainers = trs.append('td')
|
||||
.attr('class', 'col-xs-2')
|
||||
.append('div')
|
||||
.attr('class', 'container');
|
||||
pctContainers.append('div')
|
||||
.attr('class', 'bar')
|
||||
.style('background-color', ColorPalette.Gray)
|
||||
.style('opacity', '0.2')
|
||||
.style('width', d => (d.pctPrevious / maxToPrevious * 100.0) + '%');
|
||||
pctContainers.append('div')
|
||||
.attr('class', 'value')
|
||||
.text(d => normalizePercentage(d.pctPrevious));
|
||||
}
|
||||
|
||||
function createVisualization(data) {
|
||||
drawFunnel(data); // draw funnel
|
||||
}
|
||||
|
||||
function removeVisualization() {
|
||||
vis.selectAll('table').remove();
|
||||
}
|
||||
|
||||
function prepareData(queryData) {
|
||||
const data = queryData.map(row => ({
|
||||
step: normalizeValue(row[options.stepCol.colName]),
|
||||
value: Number(row[options.valueCol.colName]),
|
||||
sortVal: options.autoSort ? '' : row[options.sortKeyCol.colName],
|
||||
}), []);
|
||||
let sortedData;
|
||||
if (options.autoSort) {
|
||||
sortedData = sortBy(data, 'value').reverse();
|
||||
} else {
|
||||
sortedData = sortBy(data, 'sortVal');
|
||||
}
|
||||
|
||||
// Column validity
|
||||
if (sortedData[0].value === 0 || !every(sortedData, d => isNoneNaNNum(d.value))) {
|
||||
return;
|
||||
}
|
||||
const maxVal = d3.max(data, d => d.value);
|
||||
sortedData.forEach((d, i) => {
|
||||
d.pctMax = d.value / maxVal * 100.0;
|
||||
d.pctPrevious = i === 0 ? 100.0 : d.value / sortedData[i - 1].value * 100.0;
|
||||
});
|
||||
return sortedData.slice(0, 100);
|
||||
}
|
||||
|
||||
function invalidColNames() {
|
||||
const colNames = scope.queryResult.getColumnNames();
|
||||
const colToCheck = [options.stepCol.colName, options.valueCol.colName];
|
||||
if (!options.autoSort) { colToCheck.push(options.sortKeyCol.colName); }
|
||||
if (difference(colToCheck, colNames).length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
removeVisualization();
|
||||
if (invalidColNames()) { return; }
|
||||
|
||||
const queryData = scope.queryResult.getData();
|
||||
const data = prepareData(queryData, options);
|
||||
if (data) {
|
||||
createVisualization(data); // draw funnel
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
this.watches.push(scope.$watch('visualization.options', refresh, true));
|
||||
this.watches.push(scope.$watch('queryResult && queryResult.getData()', refresh));
|
||||
}
|
||||
|
||||
Funnel.prototype.remove = function remove() {
|
||||
this.watches.forEach((unregister) => {
|
||||
unregister();
|
||||
});
|
||||
angular.element(this.element).empty('.vis-container');
|
||||
};
|
||||
|
||||
function funnelRenderer() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<div class="funnel-visualization-container resize-event="handleResize()"></div>',
|
||||
link(scope, element) {
|
||||
const container = element[0].querySelector('.funnel-visualization-container');
|
||||
let funnel = new Funnel(scope, container);
|
||||
|
||||
function resize() {
|
||||
funnel.remove();
|
||||
funnel = new Funnel(scope, container);
|
||||
}
|
||||
|
||||
scope.handleResize = debounce(resize, 50);
|
||||
|
||||
scope.$watch('visualization.options', (oldValue, newValue) => {
|
||||
if (oldValue !== newValue) {
|
||||
resize();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function funnelEditor() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: editorTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('funnelRenderer', funnelRenderer);
|
||||
ngModule.directive('funnelEditor', funnelEditor);
|
||||
|
||||
ngModule.config((VisualizationProvider) => {
|
||||
const renderTemplate =
|
||||
'<funnel-renderer options="visualization.options" query-result="queryResult"></funnel-renderer>';
|
||||
|
||||
const editTemplate = '<funnel-editor></funnel-editor>';
|
||||
const defaultOptions = {
|
||||
stepCol: { colName: '', displayAs: 'Steps' },
|
||||
valueCol: { colName: '', displayAs: 'Value' },
|
||||
sortKeyCol: { colName: '' },
|
||||
autoSort: true,
|
||||
defaultRows: 10,
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'FUNNEL',
|
||||
name: 'Funnel',
|
||||
renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user