-
Notifications
You must be signed in to change notification settings - Fork 4.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2334 from tonyjiangh/feat/funnel_visualization
Add funnel visualization
- Loading branch information
Showing
3 changed files
with
303 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}); | ||
}); | ||
} |