mirror of
https://github.com/valitydev/redash.git
synced 2024-11-07 09:28:51 +00:00
Add funnel visualization
This commit is contained in:
parent
a1aeb1d614
commit
d583f6f273
9
client/app/visualizations/funnel/funnel-editor.html
Normal file
9
client/app/visualizations/funnel/funnel-editor.html
Normal file
@ -0,0 +1,9 @@
|
||||
<div class="form-horizontal">
|
||||
<div>
|
||||
This visualization expects the query result to be in the following formats:
|
||||
<ul>
|
||||
<li><strong>steps</strong> - the name of the funnel steps</li>
|
||||
<li><strong>values</strong> - value of the funnel steps</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
31
client/app/visualizations/funnel/funnel.less
Normal file
31
client/app/visualizations/funnel/funnel.less
Normal file
@ -0,0 +1,31 @@
|
||||
@lightgrey: rgb(211, 211, 211);
|
||||
@lightblue: rgb(135, 206, 250);
|
||||
|
||||
.funnel-visualization-container {
|
||||
table {
|
||||
max-width: 800px;
|
||||
}
|
||||
.table-borderless td, .table-borderless th {
|
||||
border: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
div.bar {
|
||||
height: 30px;
|
||||
background-color: @lightgrey;
|
||||
}
|
||||
div.bar.centered {
|
||||
margin: auto;
|
||||
background-color: @lightblue;
|
||||
}
|
||||
.value {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.container {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
169
client/app/visualizations/funnel/index.js
Normal file
169
client/app/visualizations/funnel/index.js
Normal file
@ -0,0 +1,169 @@
|
||||
import { debounce, sortBy } from 'underscore';
|
||||
import d3 from 'd3';
|
||||
import angular from 'angular';
|
||||
|
||||
import editorTemplate from './funnel-editor.html';
|
||||
import './funnel.less';
|
||||
|
||||
function Funnel(scope, element) {
|
||||
this.element = element;
|
||||
this.watches = [];
|
||||
const vis = d3.select(element);
|
||||
|
||||
function normalizePercentage(num) {
|
||||
return num < 0.01 ? '<0.01%' : num.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function drawFunnel(data) {
|
||||
// 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('Step');
|
||||
header.append('th');
|
||||
header.append('th').attr('class', 'text-center').text('% Total');
|
||||
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-md-3')
|
||||
.text(d => d.step);
|
||||
|
||||
// Funnel bars
|
||||
const valContainers = trs.append('td')
|
||||
.attr('class', 'col-md-5')
|
||||
.append('div')
|
||||
.attr('class', 'container')
|
||||
.style('min-width', '200px');
|
||||
valContainers.append('div')
|
||||
.attr('class', 'bar centered')
|
||||
.style('width', d => d.pctTotal + '%');
|
||||
valContainers.append('div')
|
||||
.attr('class', 'value')
|
||||
.text(d => d.value.toLocaleString());
|
||||
|
||||
// pctTotal
|
||||
trs.append('td')
|
||||
.attr('class', 'col-md-2 text-center')
|
||||
.text(d => normalizePercentage(d.pctTotal));
|
||||
|
||||
// pctPrevious
|
||||
const pctContainers = trs.append('td')
|
||||
.attr('class', 'col-md-2')
|
||||
.append('div')
|
||||
.attr('class', 'container');
|
||||
pctContainers.append('div')
|
||||
.attr('class', 'bar')
|
||||
.style('width', d => d.pctPrevious.toFixed(2) + '%');
|
||||
pctContainers.append('div')
|
||||
.attr('class', 'value')
|
||||
.text(d => normalizePercentage(d.pctPrevious));
|
||||
}
|
||||
|
||||
// visualize funnel data
|
||||
function createVisualization(data) {
|
||||
drawFunnel(data); // draw funnel
|
||||
}
|
||||
|
||||
function removeVisualization() {
|
||||
vis.selectAll('table').remove();
|
||||
}
|
||||
|
||||
function prepareData(queryData, stepCol, valCol) {
|
||||
// Column validity
|
||||
const sortedData = sortBy(queryData, valCol).reverse();
|
||||
return sortedData.map((row, i) => ({
|
||||
step: row[stepCol],
|
||||
value: row[valCol],
|
||||
pctTotal: row[valCol] / sortedData[0][valCol] * 100.0,
|
||||
pctPrevious: i === 0 ? 100.0 : row[valCol] / sortedData[i - 1][valCol] * 100.0,
|
||||
}), []);
|
||||
}
|
||||
|
||||
function render(queryData) {
|
||||
const data = prepareData(queryData, 'steps', 'values'); // build funnel data
|
||||
removeVisualization(); // remove existing visualization if any
|
||||
createVisualization(data); // visualize funnel data
|
||||
}
|
||||
|
||||
function refreshData() {
|
||||
const queryData = scope.queryResult.getData();
|
||||
if (queryData) {
|
||||
render(queryData);
|
||||
}
|
||||
}
|
||||
|
||||
refreshData();
|
||||
this.watches.push(scope.$watch('visualization.options', refreshData, true));
|
||||
this.watches.push(scope.$watch('queryResult && queryResult.getData()', refreshData));
|
||||
}
|
||||
|
||||
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.height', (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 = {
|
||||
defaultrs: 7,
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'FUNNEL',
|
||||
name: 'Funnel',
|
||||
renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions,
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user